일반적으로 서비스에서 한 테이블만 보고 데이터를 조회하는 경우는 흔치 않습니다. 몽고도 마찬가지로 하나의 컬렉션만 보고 조회하는 경우는 많지 않습니다. 대부분의 경우 조인을 해서, 두 개 이상의 컬렉션을 조회해야 합니다.
몽고에는 여러 컬렉션에 거쳐 조회하는 방식이 크게 세 가지 있습니다.
- 연관관계 설정
- 앱조인
- MongoTemplate Pipeline 구성
위의 방법들을 차례대로 살펴보며 비교해보겠습니다.
이전 포스팅에서도 한 번 다룬 적이 있지만, Spring Data Mongo에서는 @DBRef
어노테이션을 지원하고 있습니다.
class DbRefUser(
nickname: String,
age: Int,
school: School? = null
) : Base() {
var nickname: String = nickname
var age: Int = age
@DBRef
var school: School? = school
}
위와 같이 설정하면 DbRefUser
와 School
사이에 연관관계가 설정이 됩니다. 간단하게 연관관계 설정에 관한 테스트를 해보겠습니다.
class JoinTest @Autowired constructor(
private val dbRefUserRepository: DbRefUserRepository,
private val schoolRepository: SchoolRepository
) : DescribeSpec({
describe("DBRef 테스트") {
context("학교 이름을 변경하는 경우") {
it("유저로 조회한 학교의 이름도 변경된 상태다.") {
val school = schoolRepository.save(
School(name = "물감초등학교", grade = SchoolGrade.ELEMENT)
)
val user = dbRefUserRepository.save(
DbRefUser(nickname = "오징어", age = 12, school = school)
)
school.name = "오징어초등학교"
schoolRepository.update(school)
val findUser = dbRefUserRepository.findByIdOrNull(user.id)
findUser?.school?.name shouldBe "오징어초등학교"
}
}
}
})
JPA에서 보던 연관관계와 크게 다를 바가 없습니다. 이는 @DBRef
가 적용된 필드에 이너 도큐먼트 형식으로 저장되는 것이 아니라, 해당 도큐먼트의 ID
값을 들고 있고, 조회 시에 이를 통해 다시 한 번 도큐먼트를 조회하기 때문에 가능한 일입니다.
앱조인은 몽고에만 한정된 개념은 아닙니다. 말 그대로 앱에서 조인을 실행합니다. 빠르게 예시를 통해 살펴보겠습니다.
@Document
class AppJoinUser(
nickname: String,
age: Int,
schoolId: ObjectId? = null
) : Base() {
var nickname: String = nickname
var age: Int = age
var schoolId: ObjectId? = schoolId
}
앱조인에는 별도의 어노테이션 없이, 외래키를 하나의 필드처럼 들고 있습니다. 이후, 조인이 필요하면 서비스에서 해당 컬렉션에 다시 접근하는 방식입니다.
class JoinTest @Autowired constructor(
private val dbRefUserRepository: DbRefUserRepository,
private val schoolRepository: SchoolRepository
) : DescribeSpec({
describe("조인 테스트") {
context("앱 조인을 진행해야 하는 경우") {
it("ID로 한 번 더 조회해야한다.") {
val school = schoolRepository.save(
School(name = "물감초등학교", grade = SchoolGrade.ELEMENT)
)
val user = appJoinUserRepository.save(
AppJoinUser(nickname = "오징어", age = 12, schoolId = school.id)
)
val findUser = appJoinUserRepository.findByIdOrNull(user.id)
val findSchool = schoolRepository.findByIdOrNull(findUser?.schoolId)
}
}
}
}
})
외래키 하나만으로 연관관계에 대한 정보를 저장하고, 이를 바탕으로 조인을 진행합니다. 조인을 진행해야 하는 별도의 코드가 필요하다는 특징이 있습니다.
파이프라인은 Spring Data Mongo에서 제공해주는 기능입니다. 조금 더 복잡한 쿼리를 수행할 수 있습니다.
Pipeline에서 조인은 Lookup을 통해 진행됩니다. 간단한 조회 Pipeline을 구성해보겠습니다.
@Document
class AppJoinUser(
nickname: String,
age: Int,
schoolId: ObjectId? = null
) : Base() {
var nickname: String = nickname
var age: Int = age
var schoolId: ObjectId? = schoolId
var school: List<School>? = null
}
class JoinTest @Autowired constructor(
private val dbRefUserRepository: DbRefUserRepository,
private val schoolRepository: SchoolRepository
) : DescribeSpec({
describe("조인 테스트") {
context("MongoTemplate Pipeline을 구성하는 경우") {
it("Opertaion을 작성해야 한다.") {
val school = schoolRepository.save(
School(name = "물감초등학교", grade = SchoolGrade.ELEMENT)
)
val user = appJoinUserRepository.save(
AppJoinUser(nickname = "오징어", age = 12, schoolId = school.id)
)
val match = MatchOperation(
Criteria.where("_id").`is`(user.id)
)
val lookup = LookupOperation.newLookup()
.from("school")
.localField("schoolId")
.foreignField("_id")
.`as`("school")
val findUser = mongoTemplate.aggregate(
Aggregation.newAggregation(match, lookup),
"appJoinUser",
AppJoinUser::class.java
).mappedResults
findUser.first().school?.get(0) shouldBe school
}
}
}
})
파이프라인을 구축하는 데 꽤나 많은 코드가 필요합니다. MatchOperation, LookupOperation에 지금은 생략했지만 UnwindOperation까지 추가된다면, 단순한 조인에도 많은 코드가 필요하게 됩니다.
지금까지 조인할 수 있는 세 가지 방법을 확인했습니다. 그렇다면 조회 성능은 어떨까요? 단건 조회, 모두 조회, 90세 초과 조회라는 세 가지 기준으로 조회를 진행해보겠습니다. 그리고 각 조회 기준 별로 5번의 실행을 해보고 조회 시간의 평균을 내보겠습니다.
수행하는데 쓰인 MongoDB 스펙은 M10 클러스터입니다.
회차 | DBRef | 앱조인 | 파이프라인 |
---|---|---|---|
1 | 0.19s | 0.16s | 0.11s |
2 | 0.18s | 0.15s | 0.10s |
3 | 0.18s | 0.15s | 0.12s |
4 | 0.23s | 0.16s | 0.36s |
5 | 0.20s | 0.18s | 0.12s |
평균 | 0.196s | 0.16s | 0.162s |
회차 | DBRef | 앱조인 | 파이프라인 |
---|---|---|---|
1 | 41.276s | 0.89s | 0.198s |
2 | 37.922s | 0.176s | 0.210s |
3 | 39.72s | 0.288s | 0.117s |
4 | 34.493s | 0.145s | 0.217s |
5 | 34.850s | 0.71s | 0.199s |
평균 | 37.652s | 0.441s | 0.172s |
회차 | DBRef | 앱조인 | 파이프라인 |
---|---|---|---|
1 | 3.653s | 0.34s | 0.27s |
2 | 3.312s | 0.32s | 0.30s |
3 | 2.991s | 0.33s | 0.59s |
4 | 2.644s | 0.40s | 0.30s |
5 | 2.466s | 0.34s | 0.27s |
평균 | 3.013s | 0.346s | 0.346s |
5회의 수행이 충분하다고 볼 수는 없지만, 그래도 유의미한 지표를 볼 수 있었습니다. 단건 수행에서는 세 방식에 큰 차이가 없었지만(0.03초), 다건 조회에서 큰 차이를 보이고 있습니다. 특히 조회 데이터가 많을 수록 연관관계를 설정하는 방식이 가장 성능이 좋지 않았습니다.
앱조인과 파이프라인 방식의 경우 아주 유의미한 차이를 보이지는 않았습니다. 2천 건의 전체 데이터를 조회할 때는 0.3초의 차이가 났지만, 평균적으로 170건 정도가 조회되는 90세 초과 유저 조회에서 차이가 없었습니다.
그렇다면 결론적으로 어떤 조회 방식을 선택해야 할까요?
연관관계 설정 방식은 숫자가 보여주듯이 좋지 못한 성능을 보여주며 후보에서 제외됩니다. 앱조인과 파이프라인에서 선택해야 한다면 어떤 방식을 선택해야 할까요?
저는 웬만해서는 앱조인 방식을 선택할 거 같습니다. 우선 성능 차이가 크게 유의미하지 않습니다. 페이지네이션을 위한 다건의 데이터를 조회하더라도 100건, 200건 이상의 데이터를 조회하는 경우는 흔치 않기 때문입니다.
무엇보다 코드 가독성에 있어서 파이프라인은 최악에 가깝습니다. 조건 쿼리나 페이지네이션을 위한 skip, limit 등이 추가되면 더욱 더 코드는 복잡해지고 양도 많아질 겁니다.
물론, 일간 통계나 월간 통계를 뽑아내면서 많은 양의 데이터를 추출해야 한다면 파이프라인을 구성해볼 수 있을 거 같습니다. 배치 같은 작업들 말이죠. 하지만 어플리케이션 서비스 수준에서는 앱조인으로 훨씬 좋은 가독성을 유지하며 성능을 뽑아낼 수 있을 거 같습니다.
물론 앱조인에는 많은 신경을 써야 합니다. 연관된 컬렉션이 생성, 삭제되는 경우에 어떻게 처리할 것인지 꼼꼼한 코드 작성이 필요합니다.
이상으로 MongoDB에서 선택할 수 있는 다양한 조인에 대해 살펴보았습니다.