티스토리 뷰

개요

  • 곰곰히 생각해보면 백엔드 개발에 쏟는 시간의 대부분은 원격지 데이터베이스에 대한 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.5.0")
        classpath("org.jetbrains.kotlin:kotlin-allopen:1.5.0")
        classpath("org.jetbrains.kotlin:kotlin-noarg:1.5.0")
    }
}

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

repositories {
    // 한글 형태소 분석기 KOMORAN을 사용하기 위해 리파지터리 추가
    maven("https://jitpack.io")
}

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")
    // 한글 형태소 분석기 KOMORAN 의존성 추가
    implementation("com.github.shin285:KOMORAN:3.3.4")
    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 작성 부분인데 스뎅 님의 블로그 글에서 힌트를 얻어 성공적으로 셋업할 수 있었다.
  • 원래 Spring Data MongoDBSpring Data JPA와 유사한 부분이 있어도, 서로 독립적인 패키지로 JPA를 사용하지 않는다. 하지만, Querydsl을 연동하여 도큐먼트 조건 조회시 Type-Safe가 보장되는 Q 클래스를 자동 생성하기 위해 spring-boot-starter-data-jpa 패키지를 의존성에 추가했다. (아래 설명할 도큐먼트 클래스 설계시에도 @Entity가 명시되어야 빌드 시점에 Q 클래스가 자동 생성된다.)
  • MongoDB는 현재 한글을 포함하는 CJK 계열의 풀텍스트 검색(FTS)를 지원하지 않고 있다. 따라서 그에 대한 대안으로 한글 형태소 분석기 KOMORAN을 의존성에 추가했다. (한글 문자열의 인덱스 설계 전략에 대한 자세한 내용은 본 블로그의 이 글을 참고한다.)

application.yml 작성

  • /src/main/resources/application.yml 파일에 아래 내용을 추가한다. Spring Data MongoDBQuerydsl에 의해 해석되어 실제 실행되는 쿼리를 로깅하기 위한 설정이다. (개발 환경에서 개발작 의도한대로 쿼리가 수행되는지 확인하는 것은 매우 중요하다.) [관련 링크]
logging:
  level:
    org:
      springframework:
        data:
          mongodb:
            core:
              MongoTemplate: DEBUG

MongoConfig 작성

  • MongoDB 인스턴스에 대한 연결 설정은 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 com.querydsl.core.types.ExpressionUtils
import com.querydsl.core.types.Ops
import com.querydsl.core.types.Path
import com.querydsl.core.types.Predicate
import com.querydsl.core.types.dsl.Expressions
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.core.query.TextCriteria
import org.springframework.data.mongodb.core.query.Update
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)
    }
}

// Querydsl 에서의 text 인덱스에 대한 FTS 를 위한 TextCriteria.toPredicate() 확장 함수 작성
fun TextCriteria.toPredicate(documentType: Any): Predicate {

    val path: Path<Any>? = ExpressionUtils.path(documentType::class.java, "\$text")
    val value = Expressions.constant(this.criteriaObject["\$text"])

    return ExpressionUtils.predicate(Ops.EQ, path, value)
}

// 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)
}
  • ConnectionString()의 아큐먼트에는 실제 연결할 MongoDB 인스턴스의 연결 주소를 입력한다 mongodb://, mongodb+srv// 문자열 모두 입력이 가능하다. username, pasword가 포함된 문자열도 가능하다.
  • getDatabaseName()의 리턴 값에는 연결할 데이터베이스의 이름을 입력한다.
  • 예제에서는 편의를 위해 위 2개 값을 코드에 명시했지만, 실제 개발은 물론이고 운영 레벨에서는 철저히 환경 변수 또는 암호화된 격리 공간에 저장해야 한다. (운영체제에 정의된 환경 변수를 참조하는 방법은 본 블로그의 이 글을 참고한다.)
  • text 인덱스가 사용된 필드는 $text 를 사용한 풀텍스트 검색이 가능하다. 하지만 현재 Querydsl는 풀텍스트 검색을 지원하지 않고 있어, TextCriteria.toPredicate() 확장 함수를 별도로 작성하였다. [관련 링크]

Spring Data MongoDB 활성화

  • 애플리케이션 클래스에 아래 내용을 추가한다.
@SpringBootApplication(exclude = [DataSourceAutoConfiguration::class])
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")
    var createdAt: LocalDateTime = LocalDateTime.now(),

    @Field("updatedAt")
    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("꼬북이"))
)

// Users 도큐먼트 중 name에 대해 "멍멍이" 키워드와 매칭되는 풀텍스트 검색 결과 목록을 반환
// 앞서 작성한 TextCriteria.toPredicate() 확장 함수를 이용
val users = userRepository.findAll(
    TextCriteria.forLanguage("en").matching("멍멍이").toPredicate(User::class.java)
)

// 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))
        )
        
        // users 컬렉션의 name 필드에 text 인덱스 생성
        mongoTemplate
            .indexOps("users")
            .ensureIndex(
                TextIndexDefinition.TextIndexDefinitionBuilder()
                    .onField("name", 1F)
                    // 기본 언어는 english로 설정 (한글은 현재 미지원)
                    .withDefaultLanguage("english")
                    .build()
            )  

        // user_access_tokens 컬렉션의 created_at 필드에 TTL 인덱스 생성
        mongoTemplate
            .indexOps("user_access_tokens")
            .ensureIndex(
                // 1시간 후 도큐먼트 삭제
                Index().on("created_at", Sort.Direction.ASC).expire(Duration.ofHours(1))
            )
    }
}
  • 문자열 타입의 필드에 text 인덱스를 설정하면 풀텍스트 검색(FTS)이 가능해진다. [관련 링크] 현재 공식적으로 CJK 계열인 한글은 지원하지 않고 있다.
  • 특정 필드 값에 대해 특정 시간 경과 후 해당 도큐먼트를 자동으로 삭제하는 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

부록: KOMORAN 한글 형태소 분석기로 문자열 필드에서 명사 추출

  • 앞서 언급한 KOMORAN 한글 형태소 분석기를 사용하면 문자열 필드에서 명사 목록만 추출할 수 있다. 이렇게 추출된 목록을 문자열 배열 필드에 별도로 저장하고, 인덱스를 생성하면 현재 공식적으로 지원하지 않는 CJK 계열의 한글 문자열에 대한 풀텍스트 검색을 효율적이지는 않지만 제한적으로나마 해소할 수 있다.
  • 먼저, 프로젝트 리소스에 위치한 커스텀 사전을 로컬에 복사하기 위한 목적의 유틸리티 클래스를 아래와 같이 작성한다.
import java.io.InputStream

object CommonUtil {

    // 리소스 경로로부터 InputStream을 생성한다.
    fun convertResourcePathToInputStream(source: String): InputStream? {

        return this.javaClass.getResourceAsStream(source)
    }
}
  • 이제 원본 문자열(실제 필드 값)로부터 형태소 분석을 통해 명사 목록을 추출할 차례이다.
// 원본 문자열 지정
val source = "Amazon Web Services는 안정성이고 확장 가능하며 저렴한 클라우드 컴퓨팅 서비스를 제공합니다. 무료로 가입할 수 있으며 요금은 사용한 만큼 지불하면 됩니다."

// Komoran 오브젝트는 재사용을 위해 싱글턴으로 생성 필요
val komoran = Komoran(DEFAULT_MODEL.FULL)

// 커스텀 사전을 로컬 디렉토리에 생성
val customDicSourceResourcePath = "/custom-dic.txt"
val customDicTargetFilePath = "${System.getProperty("java.io.tmpdir")}/$customDicSourceResourcePath"
val customDicTargetFileInputStream = CommonUtil.convertResourcePathToInputStream(customDicSourceResourcePath)
customDicTargetFileInputStream.use {
    FileUtils.copyInputStreamToFile(it, File(customDicTargetFilePath))
}

// 앞서 생성한 커스텀 사전을 적용
komoran.setUserDic(customDicTargetFilePath)

// 문자열 형태소 분석 실행
val analyzeResultList: KomoranResult = komoran.analyze(source)

// 영문 명사 목록 추출
val englishNouns = analyzeResultList.tokenList.filter { it.pos == "SL" }.map { it.morph }.toSet()

// 한글 명사 목록 추출
val koreanNouns = analyzeResultList.nouns.toSet()

// 추출된 명사 목록을 키워드의 배열로 저장
// [Amazon, Web, Services, 안정, 확장, 클라우드 컴퓨팅, 서비스, 제공, 무료, 가입, 요금, 사용, 지불]
val fieldKeywords = englishNouns.plus(koreanNouns).toList()
  • 커스텀 사전을 별도로 설정하면 서비스가 속한 도메인에 속한 고유 명사를 추가하여 형태소 분석의 완성도를 높일 수 있다. 프로젝트에 /src/main/resources/custom-dic.txt 파일을 생성하고 아래와 같이 명사를 추가할 수 있다. (단어와 타입 품사 사이는 Tab 문자로 구분한다.) [관련 링크]
AWS    NNP
Amazon Web Services    NNP
클라우드    NNP
온프레미스    NNP

사용 소감

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

참고 글

댓글
댓글쓰기 폼