Skip to content

MongoDB, lazy loading과 data class에게 당하지 않는 법

이 글은 Local Up에서 MongoDB를 도입하며 흔들리던 설계 기준을 바로잡고 팀의 공통 원칙을 세우기 위해 작성했다. MongoDB 개요, 연관 모델 실험과 실패 원인, Kotlin data class와 지연 로딩 프록시 충돌을 다룬다. 이를 통해 재논의를 줄이고 장애 가능성을 낮추며, 성능과 일관성을 예측 가능하게 하고, 코드 품질과 리뷰 효율을 높이고자 한다.

MongoDB

MongoDB는 필드-값 쌍으로 구성된 문서를 기본 단위로 하며, 필드 값으로 다른 문서, 배열 그리고 문서 배열을 포함할 수 있는 문서 지향 데이터베이스이다.

MongoDB 공식 문서에 따르면 하나의 컬렉션 내 문서들은 동일한 필드 집합을 가질 필요가 없으며, 동일한 필드라도 문서마다 데이터 유형이 다를 수 있다고 언급한다.

Data in MongoDB has a flexible schema model, which means:

  • Documents within a single collection are not required to have the same set of fields.
  • A field's data type can differ between documents within a collection.

MongoDB 공식 문서는 문서 지향 데이터베이스의 장점을 다음과 같이 제시한다.

The advantages of using documents are:

  • Documents correspond to native data types in many programming languages.
  • Embedded documents and arrays reduce need for expensive joins.
  • Dynamic schema supports fluent polymorphism.

앞서 살펴본 것처럼 MongoDB는 컬렉션 내 문서의 필드 구성과 데이터 타입이 달라도 저장할 수 있다. 간단한 예제를 통해 하나의 컬렉션에 서로 다른 필드 구성을 가진 문서들이 함께 저장되고 조회되는 모습을 확인해보자.

  1. 저장

    > db.user.insertMany([
    {
        name: "Alice",
    },
    {
        nickname: "Bob",
    }
    ])
  2. 저장 결과

    {
    acknowledged: true,
    insertedIds: {
        '0': ObjectId('0'),
        '1': ObjectId('1')
    }
    }
  3. 조회

    > db.user.find().pretty()
  4. 조회 결과

    [
        { _id: ObjectId('0'), name: 'Alice' },
        { _id: ObjectId('1'), nickname: 'Bob' }
    ]

MongoDB, Data Modeling

MongoDB는 데이터 모델링 방식으로 내장된 데이터(Embedded Data)와 참조(Reference) 두 가지를 제시한다.

내장된 데이터는 하나의 문서에 관련 데이터를 내장하는 방식으로, 읽기 속도가 빠르고 단일 데이터베이스 작업으로 관련 데이터를 조회할 수 있으며, 하나의 원자적 쓰기 작업으로 여러 관련 데이터를 동시에 갱신할 수 있다는 장점이 있다. 그러나 데이터가 중복될 수 있고, 문서 크기 제한(16MB)이 존재하며, 특히 중복 데이터가 자주 변경되는 경우 일관성을 유지하는 데 부담이 커지는 단점이 있다.

참조는 한 문서에서 다른 문서로 연결되는 링크를 포함해 데이터 간의 관계를 저장하며, 이로 인해 데이터가 여러 컬렉션으로 분리되고 중복되지 않아 정규화된 데이터 모델을 구성할 수 있다. 이 방식은 대규모 다대다 관계나 깊은 계층 구조, 그리고 독립적으로 자주 조회되는 엔터티를 다루는 데 유리하다. 다만 여러 문서를 조회해야 하므로 추가 쿼리나 $lookup이 필요하고, 쓰기 시 다중 문서에 걸친 원자성이 보장되지 않는다는 한계가 있다.

데이터 모델링 방식을 선택할 때는 데이터 중복과 일관성, 인덱스 설계, 하드웨어 제약, 그리고 원자성 보장 범위와 같은 요소를 함께 고려해야 한다. 예를 들어, 내장 데이터는 단일 문서 단위의 원자성을 보장하므로 관련 데이터의 일관성을 쉽게 유지할 수 있지만, 데이터가 자주 변경되면 중복 관리 비용이 커진다. 반면 참조 방식은 데이터 중복을 피할 수 있으나, 다중 문서에 걸친 쓰기 작업에서 원자성을 확보하기 어렵다. 이러한 특성을 이해한 뒤, 데이터 변경 주기, 읽기·쓰기 성능 요구사항, 하드웨어 리소스, 쿼리 패턴 등을 종합적으로 판단해 모델을 선택하는 것이 바람직하다. 보다 구체적인 가이드와 예시는 MongoDB 공식 문서를 참고할 수 있다.

문서 관계 예시는 링크에서 더 자세히 확인할 수 있다.

Local Up은 다양한 공공 데이터와 파일을 결합해 제공하는 특성상, 데이터 출처와 갱신 주기가 제각각이다. 이에 따라 변경 가능성이 크고 재활용이 많은 원천 데이터는 참조 기반으로 정규화해 일관성을 보장하고, 조회 빈도가 높고 즉시 응답이 필요한 파생 데이터는 내장해 단일 쿼리로 조회하는 방식이 적합할 것으로 예상된다.

MongoDB와 영속성 컨텍스트

Kotlin 기반 Spring에서 MongoDB는 Spring Data MongoDB를 통해 사용되며, Repository는 내부적으로 MongoTemplate를 통해 동작한다. 이 경로에는 JPA의 영속성 컨텍스트가 없으므로, 동일 트랜잭션에서 동일 ID를 재조회하면 JPA는 캐시된 동일 객체를, MongoDB는 매번 DB에서 읽은 새 인스턴스를 반환한다. 아래 테스트로 이를 확인할 수 있다.

kt
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
class JpaTest {
    @PersistenceContext
    lateinit var entityManager: EntityManager

    @Autowired
    lateinit var userRepository: UserJpaRepository

    @Test
    fun test() {
        // given
        val userName = "Alice"

        val savedUser = userRepository.save(
            UserJpaEntity(
                name = userName
            )
        )

        entityManager.flush()
        entityManager.clear()

        // when
        val foundUser1 = entityManager.find(UserJpaEntity::class.java, savedUser.id)
        val foundUser2 = entityManager.find(UserJpaEntity::class.java, savedUser.id)

        // then
        assertThat(foundUser1 === foundUser2).isTrue()
    }
}
kt
@DataMongoTest
@Testcontainers
class MongoTest {
    @Autowired
    lateinit var userMongoRepository: UserMongoRepository

    companion object {
        @Container
        @JvmStatic
        val mongo = MongoDBContainer("mongo:8.0")

        @JvmStatic
        @DynamicPropertySource
        fun mongoProps(registry: DynamicPropertyRegistry) {
            registry.add("spring.data.mongodb.uri") { mongo.replicaSetUrl }
        }
    }

    @BeforeEach
    fun beforeEach() {
        userMongoRepository.deleteAll()
    }

    @Test
    fun test() {
        // given
        val userName = "Alice"

        val savedUser = userMongoRepository.save(
            UserMongoEntity(
                name = userName
            )
        )

        // when
        val foundUser1 = userMongoRepository.findById(savedUser.id).orElseThrow()
        val foundUser2 = userMongoRepository.findById(savedUser.id).orElseThrow()

        // then
        assertThat(foundUser1 === foundUser2).isFalse()
    }
}

N:1 (Order -> User, 순방향)

MongoDB 엔티티를 N대1로 구성하고, OrderMongoEntity.userMongoEntity@DocumentReference(lazy = true)를 적용한 뒤 아래 테스트를 실행하면 예외가 발생한다.

kt
@Document(collection = "user")
class UserMongoEntity(
    @Id
    val id: String = ObjectId().toHexString(),

    val name: String,
)
kt
@Document(collection = "order")
class OrderMongoEntity(
    @Id
    val id: String = ObjectId().toHexString(),

    @DocumentReference(lazy = true)
    val userMongoEntity: UserMongoEntity,
)
kt
@DataMongoTest
@Testcontainers
class MongoTest {
    @Autowired
    lateinit var userMongoRepository: UserMongoRepository

    @Autowired
    lateinit var orderMongoRepository: OrderMongoRepository

    companion object {
        @Container
        @JvmStatic
        val mongo = MongoDBContainer("mongo:8.0")

        @JvmStatic
        @DynamicPropertySource
        fun mongoProps(registry: DynamicPropertyRegistry) {
            registry.add("spring.data.mongodb.uri") { mongo.replicaSetUrl }
        }
    }

    @BeforeEach
    fun beforeEach() {
        userMongoRepository.deleteAll()
        orderMongoRepository.deleteAll()
    }

    @Test
    fun test() {
        // given
        val userName = "Alice"

        val savedUser = userMongoRepository.save(
            UserMongoEntity(
                name = userName
            )
        )

        val saveOrder = orderMongoRepository.save(
            OrderMongoEntity(
                userMongoEntity = savedUser
            )
        )

        // when
        // EXCEPTION
        //  Cannot subclass final class UserMongoEntity
        //  java.lang.IllegalArgumentException: Cannot subclass final class UserMongoEntity
        val foundOrder = orderMongoRepository.findById(saveOrder.id)
            .orElseThrow()

        // then
        assertThat(foundOrder.userMongoEntity.name).isEqualTo(userName)
    }
}

원인은 Kotlin의 기본 상속 불가(final) 특성과 Spring Data MongoDB의 지연 로딩 방식이 충돌하기 때문이다. Spring Data MongoDB는 @DocumentReference(lazy = true)로 선언된 참조를 실제 접근 시점까지 늦추기 위해 CGLIB 기반 클래스 프록시(서브클래싱 + 게터 오버라이드)를 생성한다. 이때 대상 타입이 final이면 하위 클래스를 만들 수 없어 위와 같은 예외가 발생한다. Kotlin은 불변 프로퍼티(val)를 권장하지만, 여기서 문제의 직접 원인은 불변성이 아니라 상속 불가다.

따라서 엔티티 클래스와 해당 프로퍼티를 open 으로 선언하면, 프록시가 서브클래싱과 게터 오버라이드가 가능해져 지연 로딩이 정상 동작하고 테스트도 통과한다.

kt
@Document(collection = "user")
open class UserMongoEntity(
    @Id
    open val id: String = ObjectId().toHexString(),

    open val name: String,
)

지연 로딩 프록시를 쓰지 않으려면 OrderMongoEntity에는 userId만 저장하고, 필요할 때 명시적으로 사용자 문서를 조회하는 방식도 선택할 수 있다. 이 접근은 프록시 상속 제약을 피하면서 가독성이 좋다.

1:N (User -> Orders, 역방향)

MongoDB 또한 1:N 매핑이 가능하다.

kt
@Document(collection = "user")
open class UserMongoEntity(
    @Id
    open val id: String = ObjectId().toHexString(),

    open val name: String,

    @ReadOnlyProperty
    @DocumentReference(lookup = "{ 'userMongoEntity' : ?#{#self._id} }", lazy = true)
    open var orderMongoEntities: List<OrderMongoEntity> = emptyList(),
)
kt
@Document(collection = "order")
class OrderMongoEntity(
    @Id
    val id: String = ObjectId().toHexString(),

    @DocumentReference(lazy = true)
    val userMongoEntity: UserMongoEntity,
)
kt
@DataMongoTest
@Testcontainers
class MongoTest {
    @Autowired
    lateinit var userMongoRepository: UserMongoRepository

    @Autowired
    lateinit var orderMongoRepository: OrderMongoRepository

    companion object {
        @Container
        @JvmStatic
        val mongo = MongoDBContainer("mongo:8.0")

        @JvmStatic
        @DynamicPropertySource
        fun mongoProps(registry: DynamicPropertyRegistry) {
            registry.add("spring.data.mongodb.uri") { mongo.replicaSetUrl }
        }
    }

    @BeforeEach
    fun beforeEach() {
        userMongoRepository.deleteAll()
        orderMongoRepository.deleteAll()
    }

    @Test
    fun test() {
        // given
        val userName = "Alice"

        val savedUser = userMongoRepository.save(
            UserMongoEntity(
                name = userName,
            )
        )

        orderMongoRepository.save(
            OrderMongoEntity(
                userMongoEntity = savedUser,
            )
        )
        orderMongoRepository.save(
            OrderMongoEntity(
                userMongoEntity = savedUser,
            )
        )

        // when
        val foundUser = userMongoRepository.findById(savedUser.id)
            .orElseThrow()

        // then
        assertThat(foundUser.orderMongoEntities).hasSize(2)
    }
}

저장 형태와 lookup 경로 및 타입을 반드시 일치시켜야 한다. 예를 들어 userId가 문자열이면 #self.id(문자열)로, DBRef를 쓴다면 userMongoEntity.$id로 비교한다.

JPA와 마찬가지로 기본 전략으로는 권장하지 않는다. 영속성 컨텍스트가 없어서 대량 조회 시 N+1 위험이 크고, 페이징 및 정렬 시 추가 쿼리나 집계 파이프라인이 필요하다. 또한 lookup 경로가 조금만 어긋나도 빈 리스트가 되기 쉽고, ID 저장 형태(단순 문자열 또는 ObjectId) 차이로 매칭 실패가 빈번하다. 따라서 이 방식은 조회 전용으로 제한적으로 사용하고, 기본 경로는 자식 -> 부모 단방향 모델을 권한다.

Kotlin Data Class와 MongoDB 그리고 Lazy Loading

드디어 본론이다. 우리가 재현한 환경에서 data class@DocumentReference(lazy = true)를 함께 쓰면 다음 오류를 확인할 수 있다.

Unable to lazily resolve DBRef org.springframework.data.mongodb.LazyLoadingException: Unable to lazily resolve DBRef

Cannot subclass final class UserMongoEntity java.lang.IllegalArgumentException: Cannot subclass final class UserMongoEntity

원인은 단순하다. Spring Data MongoDB의 지연 로딩은 CGLIB 클래스 프록시(서브클래싱 + 게터 오버라이드)로 동작하는데, Kotlin의 data class는 기본이 final이고, 생성자 val 프로퍼티는 오버라이드 대상이 아니다. 프록시가 끼어들 자리가 없어 확장이 막히고 예외가 발생한다. 여기에 역방향 컬렉션은 문서에 존재하지 않는 조회 전용 값이라 생성자 바인딩 단계에서 채울 수 없고, 저장 타입, 경로 등이 조금만 어긋나도 로딩이 실패한다. 즉, MongoDB에서도 data class + 지연 로딩 조합의 궁합은 나쁘다.

정리하면, 기본은 일반 클래스를 사용하고 참조나 지연 로딩이 필요한 엔티티에만 선택적으로 open을 적용한다. 프로젝트 전반을 일괄 개방하는 all-open 플러그인은 지양하려고 한다. 연관 관계는 가급적 자식에서 부모 단방향으로 모델링하고, 역방향 요구는 명시적 쿼리나 필요 시 Aggregation $lookup으로 처리한다. 이렇게 하면 프록시 전제가 필요한 구간에서만 지연 로딩을 통제하고, 나머지 영역은 MongoDB의 특성에 맞는 단순하고 예측 가능한 모델을 유지할 수 있다.

Reference

Released under the MIT License.