Spring Data JPA
와 마찬가지로 Spring Data MongoDB
에도 컬렉션 간에 연관관계 설정이 가능합니다.
JPA에서는 @OneToOne
, @OneToMany
, @ManyToOne
등의 어노테이션을 통해 설정할 수 있었습니다. MongoDB에서는 @DBRef
를 통해서 설정을 할 수 있습니다.
모든 케이스를 다 다루지는 못하겠지만 추후 사용해보면서 내용을 채워나가도록 해보겠습니다.
우선 기본적으로 연관관계 설정을 해보도록 하겠습니다.
연관관계 설정을 위해 기본적인 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,
)
JPA에서는 간단하게 @OneToOne
어노테이션을 통해 연관관계를 설정했다면, JPA에서도 @DBRef
어노테이션을 통해 진행할 수 있습니다.
한 명의 사람이 하나의 주소를 가지는 경우로 진행해보겠습니다. (물론 집이 여러개인 사람도 있겠지마는...)
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
이 발생하게 됩니다. 참조할 객체의 id
가 NULL
이기 때문에 발생하는 에러입니다.
Custom Cascade
를 설정하는 방법이 있습니다. 해당 방법에 따르면 설정은 가능하지만, Cascade
를 필요로 하는 모든 경우에 대해 설정을 해줘야 합니다.
개인적으로는 매번 Custom Cascade
를 설정하는 것보단 두 번의 save를 로직으로 수행하는 것이 나아보입니다. Cascade
를 설정하기 위한 코드 길이도 적지 않을 뿐더러, Custom Cascade
를 설정하는 것보단 두 번의 save를 수행하는 것이 코드도 직관적이며 덜 귀찮기 때문입니다.
한 명의 사람이 여러 대의 차를 갖고 있는 경우를 생각해보겠습니다. (나도 그러고 싶다...)
@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쪽에 설정을 해도 가능합니다.
@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("부릉부릉 오너")
}
지금까지는 단방향 연관관계만을 구성해보았습니다. 그럼 양방향은 안 되는 걸까요?
사람과 주소가 서로 외래키를 갖고 있도록, 양쪽에 @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 상태를 보겠습니다.
{
"_id": {"$oid": "64328083bf0cde597a587e43"},
"_class": "com.practice.kotlinmongo.entity.Person",
"address": "{ \"$ref\" : \"Address\", \"$id\" : \"64328083bf0cde597a587e44\" }",
"age": 27,
"name": "mizz",
"sex": "MALE"
}
{
"_id": {"$oid": "64328083bf0cde597a587e44"},
"_class": "com.practice.kotlinmongo.entity.Address",
"person": "{ \"$ref\" : \"Person\", \"$id\" : \"64328083bf0cde597a587e43\" }",
"city": "용산",
"country": "한국",
"state": "서울"
}
Person
과 Address
모두 연관관계에 있는 객체에 대한 매핑 정보를 들고 있습니다.
JPA
와는 다른 모습입니다. DB 상에서는 외래키가 한 쪽에만 있고, 다른 쪽에는 존재하지 않았기 때문입니다. 즉, DB 상에서 양방향 매핑이 아니라, 객체간의 양방향 매핑이기 때문입니다.
조회 시점에 에러가 발생하는 것은 어찌보면 당연합니다. Person
이 Address
를 들고 있고, Address
도 Person
을 들고 있기 때문에, Person
을 조회하면서 담을 Address
를 조회하게 되고, 그 Addree
에 담길 Person
을 또 조회하게 되는 무한루프가 발생하게 됩니다.
JPA
를 떠올려보면, 연관관계를 맺을 때, JoinColumn
과 mappedBy
등을 이용했던 것을 알 수 있습니다. 양쪽에서 외래키를 들지 않고, 한 쪽은 연관관계로 매핑되었다는 사실만을 나타낼 수 있게끔 했습니다.
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를 쓰면서 양방향 매핑이 불가피하다면 위와 같은 앱조인 방식을 사용하는 것도 좋은 대안이 될 수 있을 거라 생각합니다.
@DbRef (lazy = true) 설정하면 StackOverflowError 를 막을수있지 않을까여