티스토리 뷰

개요

  • 곰곰히 생각해보면 백엔드 개발에 쏟는 시간의 대부분은 원격지 데이터베이스에 대한 CRUD라고 할 수 있다. RDBMS의 경우 Spring Boot, Spring Data JPA, Querydsl 조합을 이용하면 직관적이고 높은 생산성으로 개발에 집중할 수 있다. (MySQL/MariaDB 연동 방법은 본 블로그의 이 글에 소개한 적이 있다.) 이번 글에서는 MongoDB에도 동일한 기술 스택을 적용하는 방법을 소개하고자 한다.

MongoDB 로컬 인스턴스 실행

  • 아래는 예제를 실행하기 위한 목적의 MongoDB 로컬 인스턴스를 실행하는 예이다. (자신이 선호하는 다른 방법으로 실행해도 무방하다.)
# MongoDB 도커 컨테이너를 실행
$ docker run -d --name mongodb -p 27017:27017 mongo
  • 만약, Spring Data MongoDB가 제공하는 @Transactional을 사용하려면 인스턴스가 레플리카 셋으로 구성되어야 한다. Docker Compose를 이용하여 레플리카 셋을 구성하는 방법은 본 블로그의 이 글을 참고한다.

build.gradle.kts

  • 프로젝트 루트의 build.gradle.kts 파일에 아래 내용을 추가한다.
buildscript {
    dependencies {
        classpath("gradle.plugin.com.ewerk.gradle.plugins:querydsl-plugin:1.0.10")
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.31")
        classpath("org.jetbrains.kotlin:kotlin-allopen:1.4.31")
        classpath("org.jetbrains.kotlin:kotlin-noarg:1.4.31")
    }
}

plugins {
    kotlin("jvm") version "1.4.31"
    kotlin("kapt") version "1.4.31"
    kotlin("plugin.spring") version "1.4.31"
    idea
}

allOpen {
    annotation("org.springframework.data.mongodb.core.mapping.Document")
    annotation("javax.persistence.Entity")
    annotation("javax.persistence.MappedSuperclass")
    annotation("javax.persistence.Embeddable")
}

dependencies {
    api("com.querydsl:querydsl-jpa:4.4.0")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
    implementation("com.querydsl:querydsl-mongodb:4.4.0")
    kapt("com.querydsl:querydsl-apt:4.4.0:jpa")
    kapt("org.hibernate.javax.persistence:hibernate-jpa-2.1-api:1.0.2.Final")
}

idea {
    module {
        val kaptMain = file("build/generated/source/kapt/main")
        sourceDirs.add(kaptMain)
        generatedSourceDirs.add(kaptMain)
    }
}
  • Querydsl 연동시 제일 어려운 작업이 위 Gradle 작성 부분인데 스뎅 님의 블로그 글에서 힌트를 얻어 성공적으로 셋업할 수 있었다.

application.yml

  • /src/main/resources/application.yml 파일에 아래 내용을 추가한다. 로컬 테스트 목적의 간단한 구성으로 각자의 환경에 맞게 설정해야 한다.
spring:
  data:
    mongodb:
      host: localhost
      port: 27017
      database: test
      # authentication-database: *****
      # username: *****
      # password: *****

MongoConfig 작성

  • 아래 내용은 앞서 작성한 application.yml 부분을 대체할 수 있다. 코드 레벨로 상세하게 MongoDB 인스턴스 설정을 원할 경우 추천한다. 또한, @Transactional 사용에 앞서 요구되는 MongoTransactionManager 빈을 생성할 수 있다.
package com.jsonobject.example

import com.mongodb.ConnectionString
import com.mongodb.MongoClientSettings
import com.mongodb.client.MongoClient
import com.mongodb.client.MongoClients
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.mongodb.MongoDatabaseFactory
import org.springframework.data.mongodb.MongoTransactionManager
import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories

@Configuration
@EnableMongoRepositories(basePackages = ["com.example.demo"])
class MongoConfig : AbstractMongoClientConfiguration() {

    @Bean
    fun transactionManager(dbFactory: MongoDatabaseFactory): MongoTransactionManager {

        return MongoTransactionManager(dbFactory)
    }

    override fun getDatabaseName(): String {

        return "test"
    }

    override fun mongoClient(): MongoClient {

        val connectionString = ConnectionString("mongodb://localhost:27017/test")
        val mongoClientSettings = MongoClientSettings
            .builder()
            .applyConnectionString(connectionString)
            .build()

        return MongoClients.create(mongoClientSettings)
    }
}

// Type-Safe로 작동하는 Update.set() 확장 함수 작성
fun Update.set(property: KProperty<*>, value: Any?): Update {

    return set(property.name, value)
}

// Type-Safe로 작동하는 Update.inc() 확장 함수 작성
fun Update.inc(property: KProperty<*>, value: Number): Update {

    return inc(property.name, value)
}

Spring Data MongoDB 활성화

  • 애플리케이션 클래스에 아래 내용을 추가한다.
@SpringBootApplication(exclude = [DataSourceAutoConfiguration::class])
@EnableMongoRepositories
@EnableMongoAuditing
class ExampleApplication

User 엔티티 작성

  • MongoDB가 가진, RDBMS에 없는 장점 중 하나는 스키마가 자유롭다는 것이다. 전체 컬렉션에 걸쳐 일관된 스키마가 요구되지 않기 때문에 자유롭게 도큐먼트 단위로 스키마를 정의할 수 있다. 하지만 이러한 특징이 거꾸로 독이 되어 극악의 유지보수성과 장애를 유발할 수 있다. Kotlin 언어가 제공하는 Nullable을 포함하는 강력한 정적 타입 통제와 data 클래스를 활용하면 일관된 스키마 구조를 정의하고 유지하면서 필요할 때마다 필드 정의를 추가하거나 제거할 수 있다. 도큐먼트를 대표할 엔티티를 아래와 같이 작성한다.
package com.jsonobject.example

import com.querydsl.core.annotations.QueryEntity
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.mapping.Document
import java.time.LocalDateTime
import javax.persistence.Entity
import javax.persistence.GeneratedValue
import javax.persistence.GenerationType
import javax.persistence.Id

// @Entity를 명시해야 Querydsl의 Q 엔티티 클래스가 생성됨
@Entity
@QueryEntity
@Document(collection = "users")
data class User(

    // _id 필드는 Nullable 타입으로 선언 및 초기값 null 부여
    @Id
    var id: String? = null,

    @Field("name")
    var name: String = "",

    // Nested Object 선언
    @Field("pets")
    var pets: List<Pet> = mutableListOf(),

    // Type이 일정하지 않은 가변 Key-Value 오브젝트는 Map으로 선언
    @Field("dynamicObject")
    var dynamicObject: Map<String, Any> = mutableMapOf(),

    @Field("createdAt")
    @CreatedDate
    var createdAt: LocalDateTime = LocalDateTime.now(),

    @Field("updatedAt")
    @LastModifiedDate
    var updatedAt: LocalDateTime? = null
)

// Nested Object에는 @Document가 생략되어야 함
@Entity
@QueryEntity
data class Pet(

    var name: String = "",

    var createdAt: LocalDateTime = LocalDateTime.now(),

    var updatedAt: LocalDateTime? = null
)
  • 클래스 레벨에 @Entity를 명시하지 않으면 Q 클래스가 생성되지 않음에 유의한다.
  • Nested Array를 다뤄보기 위해 User 도큐먼트의 필드로 List을 추가했다.

UserRepository 리파지터리 작성

  • User 엔티티에 대한 CRUD를 수행할 리파지터리를 아래와 같이 작성한다.
package com.jsonobject.example

import org.springframework.data.mongodb.repository.MongoRepository
import org.springframework.data.querydsl.QuerydslPredicateExecutor

@Repository
interface UserRepository : MongoRepository<User, String>, QuerydslPredicateExecutor<User>
  • Spring Data MongoDB가 제공하는 MongoRepository<T, ID> 인터페이스를 구현하는 순간 기본적인 CRUD 메써드가 제공되는 마법이 벌어지는데, 여기에 더해 QuerydslPredicateExecutor<T> 인터페이스를 구현하면 Querydsl의 자랑이라고 할 수 있는 Predicate, OrderSpecifier를 사용할 수 있게 된다. 제공되는 메써드는 아래와 같다.
// Spring Data MongoDB 제공
<S extends T> S insert(S entity);
<S extends T> List<S> insert(Iterable<S> entities);
<S extends T> S save(S entity);
<S extends T> Iterable<S> saveAll(Iterable<S> entities);
<S extends T> List<S> saveAll(Iterable<S> entities);
boolean existsById(ID id);
<S extends T> boolean exists(Example<S> example);
long count();
<S extends T> long count(Example<S> example);
Optional<T> findById(ID id);
<S extends T> Optional<S> findOne(Example<S> example);
Iterable<T> findAllById(Iterable<ID> ids);
Iterable<T> findAll();
Iterable<T> findAll(Sort sort);
List<T> findAll();
List<T> findAll(Sort sort);
Page<T> findAll(Pageable pageable);
<S extends T> Iterable<S> findAll(Example<S> example);
<S extends T> Iterable<S> findAll(Example<S> example, Sort sort);
<S extends T> List<S> findAll(Example<S> example);
<S extends T> List<S> findAll(Example<S> example, Sort sort);
<S extends T> Page<S> findAll(Example<S> example, Pageable pageable);
void deleteById(ID id);
void delete(T entity);
void deleteAll(Iterable<? extends T> entities);
void deleteAll();

// Querydsl 제공
Optional<T> findOne(Predicate predicate);
Iterable<T> findAll(Predicate predicate);
Iterable<T> findAll(Predicate predicate, Sort sort);
Iterable<T> findAll(Predicate predicate, OrderSpecifier<?>... orders);
Iterable<T> findAll(OrderSpecifier<?>... orders);
Page<T> findAll(Predicate predicate, Pageable pageable);
long count(Predicate predicate);
boolean exists(Predicate predicate);

UserRepositorySupport 리파지터리 도우미 작성

  • 앞서 작성된 UserRepository 인터페이스만으로도 정형화된 패턴의 다양한 CRUD를 수행할 수 있다. 나는 여기에 더해 기본 리파지터리에서 처리하기가 까다롭거나 불가능한 쿼리는 XXXRepositorySupport 클래스를 만들어 작성한다. 이러한 설계 패턴을 통해 쿼리는 XXXRepository, XXXRepositorySupport에만 작성하고, 서비스 레이어에서는 순수한 비지니스 로직에 집중할 수 있어 코드 가독성과 유지보수성을 높일 수 있다.
import com.jsonobejct.example.QUser.user
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Repository

@Repository
class UserRepositorySupport(
    private val userRepository: UserRepository,
    private val mongoTemplate: MongoTemplate
) {
    fun findAllActiveUsers(): List<User> {

        return userRepository
            .findAll(
                user.status.eq(UserStatus.ACTIVE),
                Sort.by("email").ascending()
            )
    }
}

SELECT 단건 조회

val user: User? = userRepository.findByIdOrNull(userId)

SELECT 목록 조회

  • 아래는 앞서 작성한 리파지터리를 이용한 도큐먼트 조회 예이다.
import com.example.demo.QUser.user

...
// User 도큐먼트 중 name = "꼬북"으로 시작하거나 "꼬북이"와 일치하는 도큐먼트 목록을 반환
val users = userRepository.findAll(
    user.name.startsWith("꼬북")
    .or(user.name.eq("꼬북이"))
)

// User 도큐먼트 중 name = "꼬북"으로 시작하는 도큐먼트 목록의
// 1페이지 5개 도큐먼트 목록 반환
val pageOfUsers = userRepository.findAll(
    user.name.startsWith("꼬북"),
    PageRequest.of(0, 5)
)

// User 도큐먼트의 Pet 목록 중 name = "멍멍이"와 일치하는 도큐먼트 목록을
// id 내림차순으로 반환
val users = userRepository.findAll(
   user.pets.any().name.eq("멍멍이"),
   user.id.desc()
)

// 만약 필드가 2차 Depth를 가진다해도 조회가 가능
val users = userRepository.findAll(
   user.pets.any().petToys.any().name.eq("뼈다귀")
   user.id.desc()
)

SELECT 목록 조회: 조회 조건, 정렬 옵션 설계

  • QuerydslPredicateExecutor<T> 인터페이스의 findAll() 메써드에는 아래와 같이 조건 옵션과 함께 정렬 옵션을 전달할 수 있다. 이를 기반으로 유연하고 Type-Safe한 방식의 조건 조회 및 정렬 로직을 설계할 수 있다.
Iterable<T> findAll(Predicate predicate, Sort sort);
Page<T> findAll(Predicate predicate, Pageable pageable);
  • 가장 먼저 앞서 작성한 User 엔티티의 정렬 옵션을 정의한 UserSortBy 이넘 클래스를 아래와 같이 작성한다. (Sort 클래스의 메써드 체이닝 기능을 이용하여 n개의 필드에 대한 복합 정렬 또한 하나의 이넘 코드로 정의할 수 있다.)
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonFormat
import org.springframework.data.domain.Sort

@JsonFormat(shape = JsonFormat.Shape.STRING)
enum class UserSortBy(val sort: Sort) {

    CREATED_AT_ASC(Sort.by("createdAt").ascending()),
    CREATED_AT_DESC(Sort.by("createdAt").descending()),
    UPDATED_AT_ASC(Sort.by("updatedAt").ascending()),
    UPDATED_AT_DESC(Sort.by("updatedAt").descending());

    companion object {
        @JsonCreator
        @JvmStatic
        fun forValue(code: String): ImageSortBy {

            return values().firstOrNull { it.name.contentEquals(code) }
                ?: throw FooApiException(FooApiErrorCode.INVALID_ENUM_TYPE)
        }
    }
}
  • 컨트롤러에서 사용할 요청 DTO를 아래와 같이 작성한다. (대개 @RequestParam을 이용하여 컨트롤러의 메써드 레벨에서 직접 작성하는데 GET 요청에 대해서도 얼마든지 DTO를 작성할 수 있다. 이 방법이 훨씬 코드 가독성이 높다.)
import com.jsonobject.example.QUser.user

data class GetUsersRequestDTO(

    @JsonProperty
    var email: String? = null,

    @JsonProperty
    var sortBy: UserSortBy = UserSortBy.UPDATED_AT_DESC,

    @JsonProperty
    var page: Int = 0,

    @JsonProperty
    var pageSize: Int = 10
) {
    fun toPredicate(): Predicate {

        val where = BooleanBuilder()
        if (!email.isNullOrBlank()) {
            where.and(user.email.eq(this.email))
        }

        return where
    }
}
  • 최종적으로 컨트롤러에서는 아래와 같이 요청 DTO의 조회 조건과 정렬 옵션을 전달하면 된다.
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest

@RestController
@Validated
class UserController(
    private val userRepository: UserRepository
) {
    @GetMapping("/v1/users")
    fun getUsers(
        request: GetUsersRequestDTO
    ): ResponseEntity<Page<User>> {

        return ResponseEntity.ok(
            userRepository.findAll(
                request.toPredicate(),
                PageRequest.of(request.page, request.pageSize, request.sortBy.sort)
            )
        )
    }
}

UPDATE 단건 수정

  • 동일한 컬렉션 내의 1개 이상의 도큐먼트에 대해, 원하는 필드 목록만 수정하려면 기본 리파지터리의 메써드로는 불가능하다. 이 경우, 아래와 같이 XXXRepositorySupport 내에 MongoTemplateupdateFirst<T>() 또는 updateMulti<T>()를 이용하여 작성하면 된다.
import org.springframework.data.mongodb.core.MongoTemplate
import org.springframework.data.mongodb.core.query.Query.query
import org.springframework.data.mongodb.core.query.Update
import org.springframework.data.mongodb.core.query.isEqualTo
import org.springframework.data.mongodb.core.query.where
import org.springframework.data.mongodb.core.updateFirst
import org.springframework.stereotype.Repository

@Repository
class UserRepositorySupport(
    val mongoTemplate: MongoTemplate
) {
...
val updatedCount =
    mongoTemplate
        .updateFirst<User>(
            query(
                where(User::lastLoggedAt).lt(LocalDateTime.now().minusDays(100)))
            ),
            Update()
               .inc(User::point, 10000)
               .set(User::updatedAt, LocalDateTime.now())
        ).modifiedCount

UPDATE 일괄 수정

  • 아래는 MongoTemplate을 이용한 n개 도큐먼트에 대한 일괄 수정의 예이다.
import org.springframework.data.mongodb.core.MongoTemplate
import org.springframework.data.mongodb.core.query.Query.query
import org.springframework.data.mongodb.core.query.Update
import org.springframework.data.mongodb.core.query.isEqualTo
import org.springframework.data.mongodb.core.query.where
import org.springframework.data.mongodb.core.updateMulti
import org.springframework.stereotype.Repository

@Repository
class UserRepositorySupport(
    val mongoTemplate: MongoTemplate
) {
...
val updatedCount =
    mongoTemplate
        .updateMulti<User>(
            query(
                where(User::lastLoggedAt).lt(LocalDateTime.now().minusDays(100)))
            ),
            Update()
               .inc(User::lifetimeHour, 10000)
               .set(User::updatedAt, LocalDateTime.now())
        ).modifiedCount

인덱스 생성: 코드 레벨

  • RDBMS와 마찬가지로 MongoDB 또한 도큐먼트의 조회 성능을 높이려면 인덱스 생성이 필수이다. 인덱스는 애플리케이션에서 실행되는 모든 쿼리의 필드 조건에 대해 순서를 지켜 생성되어야 한다. (반대로 애플리케이션에서 사용하지 않는 인덱스는 제거해야 한다. 인덱스를 유지하는 만큼 비용이 따르기 때문이다.) 아래와 같이 엔티티 레벨이 아닌 코드 레벨에서 인덱스를 생성할 수 있다. [관련 링크]
import org.bson.Document
import org.springframework.context.annotation.Configuration
import org.springframework.data.domain.Sort
import org.springframework.data.mongodb.core.MongoTemplate
import org.springframework.data.mongodb.core.index.CompoundIndexDefinition
import org.springframework.data.mongodb.core.index.Index
import java.time.Duration
import javax.annotation.PostConstruct

@Configuration
class MongoIndexConfig(
    val mongoTemplate: MongoTemplate
) {
    @PostConstruct
    fun ensureIndexes() {

        // users 컬렉션의 email 필드에 인덱스 생성
        mongoTemplate
            .indexOps("users")
            .ensureIndex(
                Index().on("email", Sort.Direction.ASC)
        )

        // users 컬렉션의 user_id, email 필드에 복합 인덱스 생성
        mongoTemplate
            .indexOps("users")
            .ensureIndex(
                CompoundIndexDefinition(Document().append("user_id", 1).append("email", 1))
        )

        // user_access_tokens 컬렉션의 created_at 필드에 TTL 인덱스 생성
        mongoTemplate
            .indexOps("user_access_tokens")
            .ensureIndex(
                // 1시간 후 도큐먼트 삭제
                Index().on("created_at", Sort.Direction.ASC).expire(Duration.ofHours(1))
            )
    }
}
  • 특정 필드 값에 대해 특정 시간 경과 후 해당 도큐먼트를 자동으로 삭제하는 TTL 인덱스를 생성할 수 있다. 이 경우, 인덱스의 생성은 Date 타입 필드에만 허용된다. [관련 링크]

트러블슈팅: IllegalOperation: Transaction numbers are only allowed on a replica set member or mongos

  • MongoDB의 트랜잭션을 이용하고자 @Transactional을 명시할 경우 아래 오류가 발생할 수 있다. 원인은 MongoDB 인스턴스가 레플리카 셋이 아니기 때문이다. 본 기능을 이용하려면 레플리카 셋이 요구된다.
com.mongodb.MongoCommandException: Command failed with error 20 (IllegalOperation): 'Transaction numbers are only allowed on a replica set member or mongos' on server localhost:27017. The full response is {"ok": 0.0, "errmsg": "Transaction numbers are only allowed on a replica set member or mongos", "code": 20, "codeName": "IllegalOperation"}

부록: LocalDateTime 타입의 JSON 문자열 변환

  • MongoDB는 날짜/시간 데이터를 UTC 타임존으로 저장한다. 애플리케이션에서 날짜/시간 데이터를 조회할 때 Spring Data MongoDB를 통해 자동으로 시스템의 타임존을 적용한 LocalDateTime으로 변환해준다.
  • 문제는 이렇게 변환된 날짜/시간 데이터를 JSON 문자열로 변환시 그대로 타임존 정보 없이 변환되는데 있다. 클라이언트에서는 어떤 타임존에 해당하는 데이터인지 알 방법이 없어 추가적인 안내가 필요하다.
  • 해결책은 커스텀 JsonSerializer를 제작하면 된다. 작성 예는 아래와 같다.
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter

class LocalDateTimeWithTimeZoneSerializer : JsonSerializer<LocalDateTime>() {

    companion object {
        private val TIME_ZONE_OFFSET = try {
            ZonedDateTime.now(ZoneId.systemDefault()).offset.toString()
        } catch (ex: Exception) {
            System.getenv("TIME_ZONE_OFFSET") ?: "Z"
        }
    }

    override fun serialize(value: LocalDateTime?, gen: JsonGenerator?, serializers: SerializerProvider?) {

        val localDateTimeString = value?.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"))
        gen?.writeString(
            when (localDateTimeString) {
                null -> null
                else -> "$localDateTimeString$TIME_ZONE_OFFSET"
            }
        )
    }
}
  • 이제 JSON 문자열 변환 대상이 되는 엔티티 또는 DTO 클래스의 필드 레벨에는 아래와 같이 @JsonSerialize 어노테이션을 추가하면 타임존이 추가된 ISO 8601 문자열을 응답한다.
// 2021-03-31T14:22:35.652+09:00
@JsonSerialize(using = LocalDateTimeWithTimeZoneSerializer::class)
var createdAt: LocalDateTime = LocalDateTime.now()
  • 애플리케이션 내에서는 아래와 같이 시스템 환경 변수의 타임존 오프셋 값을 적용할 수 있다.
val curruntDateTime = LocalDateTime.now(ZoneOffset.of(LocalDateTimeWithTimeZoneSerializer.TIME_ZONE_OFFSET))
  • 마지막으로 애플리케이션을 실행하는 인스턴스의 시스템 환경 변수에는 아래와 같이 각 환경에 적합한 타임존 오프셋을 추가하면 된다.
// UTC+0, 모든 Amazon EC2, Fargate 인스턴스에 기본 타임존 오프셋에 해당
TIME_ZONE_OFFSET=Z

// Asia/Seoul
TIME_ZONE_OFFSET=+09:00

사용 소감

  • Spring Data MongoDB + QueryDsl 조합은 Spring Data JPA + QueryDsl 조합보다 제공하는 기능이 제한적인 편이다. 컨트롤러의 요청 DTO 레벨에서 부터 PredicateOrderSpecifier를 Type-Safe하게 다룰 수 있다는 것은 여전히 강력한 장점이지만, 프로젝션 조회나, 집합 기능은 제공하지 않아 MongoTemplate과 병행해서 사용해야 한다.

참고 글

댓글
댓글쓰기 폼