Backend/Spring Framework

[Spring Boot] MySQL, MongoDB 연동 및 CRUD 예제 (Kotlin)

mopil 2023. 7. 1. 13:39
반응형

코프링 환경에서 MySQL과 MongoDB 두 개를 연동하고 간단한 CRUD 예제를 만들어본다.

 

MongoDB만 연동해보려 했으나, 보통 NoSQL 하나만 사용하는 경우는 드물기 때문에 RDBMS도 같이 연동해보기로 했다. 

 

https://github.com/mopil/spring-boot-mongo-mysql-sample

 

GitHub - mopil/spring-boot-mongo-mysql-sample: mysql, mongodb 연동 예제

mysql, mongodb 연동 예제. Contribute to mopil/spring-boot-mongo-mysql-sample development by creating an account on GitHub.

github.com

 

# 기본 설정

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-data-jpa")
	implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
	runtimeOnly("com.mysql:mysql-connector-j")
}

 

# MySQL
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul
spring.datasource.username=root
spring.datasource.password=1102
spring.jpa.database=mysql
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create
spring.jpa.properties.hibernate.format_sql=true

# MongoDB
spring.data.mongodb.database=local
spring.data.mongodb.port=27017
spring.data.mongodb.host=localhost
spring.data.mongodb.username=mopil
spring.data.mongodb.password=1102
spring.data.mongodb.authentication-database=admin

mysql은 datasourece로 mongodb는 data로 설정한다.

 

# 구체적인 설정

mysql(jpa)와 같이 사용할 예정이므로, 컴포넌트 스캔을 디렉토리별로 나눠서 하도록 설정해야 한다.

 

디렉토리 구조는 다음과 같이 가져갔다.

 

jpa 디렉토리 하위에는 MySQL용 엔티티와 리포지토리를, mongo 하위에는 MongoDB용 도큐먼트 엔티티와 리포지토리를 선언한다.

@Document(collection = "chat")
class Chat(
    val senderId: Long,
    val receiverId: Long,
    val contents: String
) {
    /*
    id에 auto increment를 하기 위해서는 초기값을 String, nullable하게 설정해야하고
    val 대신 var로 선언해야 정상 작동한다. 혹시나 모를 id 변경지점을 없애기 위해 private로 선언한다.
     */
    @Id
    var id: String? = null
        private set
}

코틀린에서 도큐먼트 엔티티를 선언할 때 id를 val로 선언하면 오류가 발생하기 때문에 var로 하고 private set으로 안전하게 id 변경 가능 지점을 없애주자

@Id를 붙여주면 auto increment 기능이 자동으로 된다. 그리고 꼭 Id는 String으로 명시하자.

 

추가로 MongoDB 도큐먼트에는 JPA가 관리하지 않으므로, @Entity를 붙히지 말자 (만약 붙히면 MySQL에 똑같은 테이블이 생긴다.)

 

이제 스프링어플리케이션이 컴포넌트스캔을 적절하게 할 수 있도록 스캔 리포지토리를 명시해 준다.

@SpringBootApplication
@EnableJpaRepositories(basePackages = ["com.example.mysqlmongo.model.jpa"])
@EnableMongoRepositories(basePackages = ["com.example.mysqlmongo.model.mongo"])
class MysqlMongoApplication

fun main(args: Array<String>) {
	runApplication<MysqlMongoApplication>(*args)
}

 

 

# 테스트

아주 간단한 서비스와 컨트롤러를 만들어서 테스트해 보자

@RestController
class ChatController(
    private val chatService: ChatService
) {
    @PostMapping("/chat")
    fun saveChat(@RequestBody request: CreateChatRequest): ApiResponse<String> {
        return ApiResponse(chatService.saveChat(request))
    }

    @GetMapping("/chat/{id}")
    fun getChat(@PathVariable id: String): Chat {
        return chatService.getChat(id)
    }

    @DeleteMapping("/chat/{id}")
    fun deleteChat(@PathVariable id: String) {
        chatService.deleteChat(id)
    }
}

 

@Service
class ChatService(
    private val chatRepository: ChatRepository
) {

    @Transactional
    fun saveChat(request: CreateChatRequest): String {
        val chat = chatRepository.save(request.toEntity())
        return chat.id!!
    }

    fun getChat(id: String): Chat {
        return chatRepository.findById(id).orElseThrow()
    }

    fun deleteChat(id: String) {
        chatRepository.deleteById(id)
    }

}

 

 

ID가 UUID로 자동 채번된다.

정상적으로 적재됨을 확인했다.

 

# @Query()로 조회하기

import org.springframework.data.mongodb.repository.Query

를 통해서 JPQL처럼 조회 쿼리를 작성할 수도 있다. (일반 JPA JPQL 임포트와 다르니 주의)

 

interface ChatRepository : MongoRepository<Chat, String> {
    fun findAllBySenderId(senderId: Long): List<Chat>

    @Query("{'senderId': { \$gte: ?0 }, 'receiverId': ?1, 'contents': { \$ne: '' }}")
    fun findAllByCondition(senderId: Long, receiverId: Long): List<Chat>
}

?0, ?1이 자리에는 변수가 들어가고 $gte는 >=, $ne는 !=이다. MongoDB에서 사용하는 쿼리를 그대로 사용할 수 있다.

 

 

# _class 없애기

Spring Data MongoDB를 사용해서 도큐먼트를 저장하면,

이렇게 직렬화한 클래스 정보가 같이 _class 키값으로 저장되는걸 볼 수 있다.

 

이를 없애고 싶다면 아래와 같이 설정 빈을 만들어주면 된다.

@Configuration
class MongoConfiguration(
    private val mongoMappingContext: MongoMappingContext
) {
    @Bean
    fun mappingMongoConverter(
        mongoDatabaseFactory: MongoDatabaseFactory,
        mongoMappingContext: MongoMappingContext,
    ): MappingMongoConverter {
        val dbRefResolver: DbRefResolver = DefaultDbRefResolver(mongoDatabaseFactory)
        val converter = MappingMongoConverter(dbRefResolver, mongoMappingContext)
        // 이 설정을 해줘야 _class 타입이 저장안 됨
        converter.setTypeMapper(DefaultMongoTypeMapper(null))
        return converter
    }
}

 

# BaseTimeEntity 만들기

MongoDB에도 레코드를 저장할 때 createdAt, updatedAt을 공통 필드로 저장하고 싶을 때는 별도의 BaseTimeEntity를 제작해야한다.

@MappedSuperclass
abstract class MongoBaseTimeEntity : Serializable {

    @field:Field("createdAt")
    @CreatedDate
    private var _createdAt: LocalDateTime = LocalDateTime.now()

    /*
    Spring Data MongoDB에는 JPA처럼 더티체킹 방식을 지원하지 않아서 @PreUpdate 어노테이션을 이용 불가능
    그래서 @LastModifiedDate를 이용해서 MongoRepository에서 save()를 호출할 때마다 갱신되도록 함
     */
    @field:Field("updatedAt")
    @LastModifiedDate
    private var _updatedAt: LocalDateTime = LocalDateTime.now()
    val createdAt: LocalDateTime
        get() = _createdAt

    val updatedAt: LocalDateTime
        get() = _updatedAt
}

 

Spring Data MongoDB는 JPA의 더티체킹을 지원하지 않기 때문에 @PreUpdate 어노테이션은 사용못하고 이런식으로 구현하면 된다.

비슷하게 더티체킹을 지원 안 하므로 업데이트 로직은 다음과 같이 작성하면 된다.

 

@Transactional
fun updateChat(id: String, request: UpdateChatRequest): String {
    val chat = chatRepository.findById(id).orElseThrow()
    chat.updateContents(request.contents)
    chatRepository.save(chat) // save() 명시적 호출
    return chat.id!!
}

 

 

 

본문에서는 채팅 로그를 저장하는 예시를 들었는데, 보통 대용량과 비정형(혹은 조인을 많이 안 하고 schemaless 한 데이터, 또는 자주 schema가 바뀔 가능성이 있는) 이 두 가지 요건이 맞춰지면  RDBMS보다 NoSQL디비를 사용하는 것도 좋은 선택지인 것 같다.

반응형