[Spring/Mongo] @DBRef를 통한 연관관계 설정

민찬기·2023년 3월 26일
1

Spring & MongoDB

목록 보기
2/4

Spring Data JPA와 마찬가지로 Spring Data MongoDB에도 컬렉션 간에 연관관계 설정이 가능합니다.

JPA에서는 @OneToOne, @OneToMany, @ManyToOne 등의 어노테이션을 통해 설정할 수 있었습니다. MongoDB에서는 @DBRef를 통해서 설정을 할 수 있습니다.

모든 케이스를 다 다루지는 못하겠지만 추후 사용해보면서 내용을 채워나가도록 해보겠습니다.

우선 기본적으로 연관관계 설정을 해보도록 하겠습니다.

Entity

연관관계 설정을 위해 기본적인 Entity를 설정해보겠습니다.

@Document("Person")
data class Person(
	@Id
	val id: ObjectId? = null,
	val name: String? = null,
	val age: Int? = null,
	val sex: Sex? = null,
)
@Document("Address")
data class Address (
	@Id
	val id: ObjectId? = null,
	val country: String? = null,
	val state: String? = null,
	val city: String? = null,
)
@Document("Car")
data class Car(
	@Id
	val id: ObjectId? = null,
	val name: String? = null,
)

기본적인 연관관계 설정

1:1

JPA에서는 간단하게 @OneToOne 어노테이션을 통해 연관관계를 설정했다면, JPA에서도 @DBRef 어노테이션을 통해 진행할 수 있습니다.

Entity 구성

한 명의 사람이 하나의 주소를 가지는 경우로 진행해보겠습니다. (물론 집이 여러개인 사람도 있겠지마는...)

Person이 Address를 참조하면 다음과 같은 Entity를 갖게 됩니다.

@Document("Person")
data class Person(
	@Id
	val id: ObjectId? = null,
	val name: String? = null,
	val age: Int? = null,
	val sex: Sex? = null,

	@DBRef
	val address: Address? = null,
)

테스트

실제로 매핑이 되는 지 확인을 위해 테스트 코드를 작성해보겠습니다.

@Test
fun `연관관계가 설정이 되나요`() {
    // given
    val address = addressRepository.save(Address(country = "한국", state = "서울", city = "강남"))
    val person = Person(name = "강백호", age = 18, sex = Sex.MALE, address = address)

    val savedPerson = personRepository.save(person)

    // when
    addressRepository.save(address.copy(city = "용산"))

	// then
    val findPerson = personRepository.findByIdOrNull(savedPerson.id!!)
	assertThat(findPerson!!.address!!.city).isEqualTo("용산")
}
@Test
fun `조회해도 잘 가져와 질까요`() {
	// given
    val savedAddress = addressRepository.save(Address(country = "한국", state = "서울", city = "강남"))

	// when
	val savedPerson =
        personService.addPerson(Person(name = "강백호", age = 18, sex = Sex.MALE, address = savedAddress))

	// then
    val findAddress = addressRepository.findByIdOrNull(savedPerson.address!!.id!!)
	assertThat(savedPerson.address).isEqualTo(findAddress)
}

이건 좀 귀찮지 않나요?

위의 코드를 자세히 보면, address를 먼저 저장한 뒤, 이를 person에 넣어 저장하고 있습니다. Spring Data JPA에서는 cascade 옵션을 통해, 이를 한 번에 처리했습니다. MongoDB에는 이런 기능이 없을까요?

우선 별도의 추가적인 설정 없이 진행해보겠습니다.

@Test
fun `미리 Address를 저장해놓지 않는다면 어떻게 되나`() {
	// given
	val address = Address(country = "한국", state = "서울", city = "강남")
	val person = Person(name = "강백호", age = 18, sex = Sex.MALE, address = address)

	// when
	val savedPerson = personService.addPerson(person)

	// then
	assertThat(savedPerson.address).isNotNull
	assertThat(savedPerson.address!!.id).isNotNull
}

Cannot create a reference to an object with a NULL id
org.springframework.data.mapping.MappingException: Cannot create a reference to an object with a NULL id

위와 같은 MappingException이 발생하게 됩니다. 참조할 객체의 idNULL이기 때문에 발생하는 에러입니다.

그럼 이렇게 귀찮게 써야 하나요..?

Custom Cascade를 설정하는 방법이 있습니다. 해당 방법에 따르면 설정은 가능하지만, Cascade를 필요로 하는 모든 경우에 대해 설정을 해줘야 합니다.

개인적으로는 매번 Custom Cascade를 설정하는 것보단 두 번의 save를 로직으로 수행하는 것이 나아보입니다. Cascade를 설정하기 위한 코드 길이도 적지 않을 뿐더러, Custom Cascade를 설정하는 것보단 두 번의 save를 수행하는 것이 코드도 직관적이며 덜 귀찮기 때문입니다.

1:N

Entity 구성

한 명의 사람이 여러 대의 차를 갖고 있는 경우를 생각해보겠습니다. (나도 그러고 싶다...)

@Document(collection = "Person")
data class Person(
	@Id
	val id: ObjectId? = null,
	val name: String? = null,
	val age: Int? = null,
	val sex: Sex? = null,

	@DBRef
	val cars: List<Car>? = emptyList(),
)

테스트

1:1과 마찬가지로 매핑이 되었는 지 테스트를 해보도록 하겠습니다.

@Test
fun `BMW와 BENZ를 가진 사람`() {
	// given
    val bmw = carRepository.save(Car(name = "bmw"))
	val benz = carRepository.save(Car(name = "benz"))

    val savedPerson = personRepository.save(Person(name = "mizz", age = 27, sex = Sex.MALE, cars = listOf(bmw, benz)))

	// when
    val audi = carRepository.save(bmw.copy(name = "audi"))

	// then
    val findPerson = personRepository.findByIdOrNull(savedPerson.id)
	assertThat(findPerson!!.cars).contains(audi)
}

연관관계의 주인은 N이 아닌가요?

물론 N쪽에 설정을 해도 가능합니다.

@Document("Car")
data class Car(
	@Id
	val id: ObjectId? = null,
	val name: String? = null,

	@DBRef
	val person: Person? = null
)
@Test
fun `자동차 부릉부릉`() {
	// given
    val savedPerson = personRepository.save(Person(name = "mizz", age = 27, sex = Sex.MALE))
	val bmw = carRepository.save(Car(name = "bmw", person = savedPerson))
    val benz = carRepository.save(Car(name = "benz", person = savedPerson))
        
	// when
    personRepository.save(savedPerson.copy(name = "부릉부릉 오너"))

	// then
    val findBmw = carRepository.findByIdOrNull(bmw.id)
	val findBenz = carRepository.findByIdOrNull(benz.id)
    assertThat(findBmw!!.person!!.name).isEqualTo("부릉부릉 오너")
	assertThat(findBenz!!.person!!.name).isEqualTo("부릉부릉 오너")
}

양방향은 안 되나요?

지금까지는 단방향 연관관계만을 구성해보았습니다. 그럼 양방향은 안 되는 걸까요?

Entity 구성

사람과 주소가 서로 외래키를 갖고 있도록, 양쪽에 @DBRef를 걸어보도록 하겠습니다.

@Document(collection = "Address")
data class Address(
	@Id
	val id: ObjectId? = null,
	val country: String? = null,
	val state: String? = null,
	val city: String? = null,

	@DBRef
	val person: Person? = null
)
@Document(collection = "Person")
data class Person(
	@Id
	val id: ObjectId? = null,
	val name: String? = null,
	val age: Int? = null,
	val sex: Sex? = null,

	@DBRef
	val address: Address? = null
)

테스트

@Test
fun `양방향은 안 될 걸?`() {
	// given
    val savedPerson = personRepository.save(Person(name = "mizz", age = 27, sex = Sex.MALE))
	val savedAddress = addressRepository.save(Address(country = "한국", state = "서울", city = "용산"))

    // when
	val updatedPerson = personRepository.save(savedPerson.copy(address = savedAddress))
    val updatedAddress = addressRepository.save(savedAddress.copy(person = savedPerson))

	// then
    val findPerson = personRepository.findByIdOrNull(savedPerson.id) // Error
	val findAddress = addressRepository.findByIdOrNull(savedAddress.id)

    assertThat(updatedPerson.address).isEqualTo(findAddress)
	assertThat(updatedAddress.person).isEqualTo(findPerson)
}

왜 안 돼..?

java.lang.StackOverflowError
at java.base/java.util.concurrent.ConcurrentLinkedDeque.unlink(ConcurrentLinkedDeque.java:391)
at java.base/java.util.concurrent.ConcurrentLinkedDeque.pollLast(ConcurrentLinkedDeque.java:942)
...

에러메세지를 보면 StackOverflowError가 발생했습니다. 에러는 person을 조회하는 시점에서 에러가 발생했습니다. 그 시점의 DB 상태를 보겠습니다.

Person

{
    "_id": {"$oid": "64328083bf0cde597a587e43"},
    "_class": "com.practice.kotlinmongo.entity.Person",
    "address": "{ \"$ref\" : \"Address\", \"$id\" : \"64328083bf0cde597a587e44\" }",
    "age": 27,
    "name": "mizz",
    "sex": "MALE"
  }

Address

{
    "_id": {"$oid": "64328083bf0cde597a587e44"},
    "_class": "com.practice.kotlinmongo.entity.Address",
    "person": "{ \"$ref\" : \"Person\", \"$id\" : \"64328083bf0cde597a587e43\" }",
    "city": "용산",
    "country": "한국",
    "state": "서울"
}

PersonAddress 모두 연관관계에 있는 객체에 대한 매핑 정보를 들고 있습니다.

JPA와는 다른 모습입니다. DB 상에서는 외래키가 한 쪽에만 있고, 다른 쪽에는 존재하지 않았기 때문입니다. 즉, DB 상에서 양방향 매핑이 아니라, 객체간의 양방향 매핑이기 때문입니다.

조회 시점에 에러가 발생하는 것은 어찌보면 당연합니다. PersonAddress를 들고 있고, AddressPerson을 들고 있기 때문에, Person을 조회하면서 담을 Address를 조회하게 되고, 그 Addree에 담길 Person을 또 조회하게 되는 무한루프가 발생하게 됩니다.

JPA를 떠올려보면, 연관관계를 맺을 때, JoinColumnmappedBy등을 이용했던 것을 알 수 있습니다. 양쪽에서 외래키를 들지 않고, 한 쪽은 연관관계로 매핑되었다는 사실만을 나타낼 수 있게끔 했습니다.

Spring Data MongoDB 에서는 그러한 기능을 제공하지 않는 것으로 보입니다. (혹시 있다면 댓글로 알려주세요)

사실 생각해보면, MongoDB는 RDB가 아니기 때문에 연관관계를 맺는다는 것이 웃기기도 합니다.

결론은 "양방향은 불가능하다!"가 되겠습니다.

그럼 대안은요?

JPA를 사용하는 경우에도 연관관계 매핑을 하지 않는 경우가 있습니다. 연관관계 매핑은 하나의 제약이 되기 때문에, DB 상에서 매핑을 하지 않고 외래키만을 들고있는 경우가 있습니다.

@Document(collection = "Address")
data class Address(
	@Id
	val id: ObjectId? = null,
	val country: String? = null,
	val state: String? = null,
	val city: String? = null,

	val personId: ObjectId = null
)
@Document(collection = "Person")
data class Person(
	@Id
	val id: ObjectId? = null,
	val name: String? = null,
	val age: Int? = null,
	val sex: Sex? = null,

	val addressId: ObjectId = null
)

위와 같이 id만 들고 있는 상태로, Join이 필요하는 경우 앱 단에서 진행합니다. 물론 앱에서 꼼꼼하게 작성을 해야한다는 단점이 있을 수 있지만, Mongo를 쓰면서 양방향 매핑이 불가피하다면 위와 같은 앱조인 방식을 사용하는 것도 좋은 대안이 될 수 있을 거라 생각합니다.

profile
https://github.com/devmizz

2개의 댓글

comment-user-thumbnail
2024년 5월 6일

@DbRef (lazy = true) 설정하면 StackOverflowError 를 막을수있지 않을까여

1개의 답글