문서 데이터베이스의 관계형 쿼리는 외래 키 대신 참조와 룩업으로 수행하거나, 임베딩으로 아예 피하는 조인입니다. 이 주제의 전부는 그 “~거나”에 있습니다. 문서 데이터베이스는 데이터를 연결하는 세 가지 방법을 제공하며, 설계 결정의 핵심은 조인이 언제 일어나느냐입니다. 쓰기 시점(임베딩), 서버에서의 쿼리 시점(룩업), 또는 애플리케이션에서의 쿼리 시점(참조와 후속 조회)입니다.
핵심 요약
| 질문 | 답변 |
|---|---|
| 문서 DB에서도 조인이 가능합니까? | 예 – 룩업은 왼쪽 외부 조인을 수행하며, 행이 아니라 배열을 반환 |
| 진짜 결정 | 임베딩 vs. 참조 – 조인은 언제 일어납니까? |
| 기본 규칙 | 함께 읽히고 크기가 제한된 것은 임베딩, 독립적이거나 계속 커지는 것은 참조 |
| 성능의 진실 | 서버 측 룩업은 중첩 루프 조인 – 요청 단위로는 괜찮지만 분석에는 부적합 |
| 대표적인 버그 | N+1 쿼리 – 배칭, include, 임베딩으로 해결 |
문서를 연결하는 세 가지 방법
// 1 · 임베딩 — "조인"은 쓰기 시점에 이미 끝남
{ _id: 1, title: "Dune", author: { name: "Frank Herbert", born: 1920 } }
// 2 · 참조 + $lookup — 조인은 쿼리 시점에 서버 측에서 일어남
db.books.aggregate([
{ $lookup: { from: "authors", localField: "authorId",
foreignField: "_id", as: "author" } }, // 왼쪽 외부 조인 → 배열
{ $unwind: "$author" } // 평탄화 → 내부 조인
])
// 3 · 참조 + 애플리케이션 측 조인 — ODM/SDK 방식(아래)
대부분의 애플리케이션 코드는 세 번째 경로를 따릅니다. 타입이 지정된 참조를 한 번의 요청에서 즉시 따라가므로, 파이프라인 없이도 관련 문서가 함께 도착합니다:
// JavaScript / Node.js — Back4app JS SDK
// A join in a document database: Pointer + include, one request
const query = new Parse.Query('Comment');
query.equalTo('post', postPointer); // Comment.post is a Pointer<Post>
query.include('author'); // "join" the author document in
const comments = await query.find();
const name = comments[0].get('author').get('username'); // already loaded — no N+1 // Flutter / Dart — Back4app Flutter SDK
// A join in a document database: Pointer + include, one request
final query = QueryBuilder<ParseObject>(ParseObject('Comment'))
..whereEqualTo('post', postPointer)
..includeObject(['author']); // "join" the author document in
final response = await query.query();
final author = (response.results!.first as ParseObject)
.get<ParseObject>('author'); // already loaded — no N+1 // iOS / Swift — Back4app Swift SDK
// A join in a document database: Pointer + include, one request
let query = Comment.query("post" == postPointer)
.include("author") // "join" the author document in
query.find { result in
if case .success(let comments) = result {
print(comments.first?.author?.username ?? "") // already loaded — no N+1
}
} // Android / Kotlin — Back4app Android SDK
// A join in a document database: Pointer + include, one request
val query = ParseQuery.getQuery<ParseObject>("Comment")
query.whereEqualTo("post", postPointer)
query.include("author") // "join" the author document in
query.findInBackground { comments, e ->
if (e == null) {
val name = comments[0].getParseObject("author")?.getString("username")
Log.d("Comments", "by $name") // already loaded — no N+1
}
} 임베딩 vs. 참조: 결정 기준
| 구분 | 임베딩 | 참조 |
|---|---|---|
| 읽기 패턴 | 항상 부모와 함께 조회 | 단독으로 또는 필요할 때 조회 |
| 쓰기 패턴 | 부모와 함께 원자적으로 업데이트 | 독립적으로 업데이트 |
| 카디널리티 | 일대소수 | 일대다 이상 |
| 증가 | 제한됨(한 사람의 주소) | 무제한(게시물의 댓글) |
| 공유 | 하나의 부모에 속함 | 여러 부모가 공유 |
| 조인 비용 | 0 – 쓰기 시점에 이미 지불 | 쿼리마다 지불 – 룩업, include, 배칭 |
카디널리티 단계는 같은 표를 경험칙으로 바꿔 줍니다. 일대소수는 배열을 임베딩하고, 일대다는 ID로 참조하며, 일대초대량은 방향을 뒤집습니다. 어떤 부모 문서도 끝없이 커지는 배열을 담을 수 없으므로 자식이 부모 참조를 저장합니다. 이는 문서 모델링 장애 대부분의 배후에 있는 두 가지 안티패턴으로 이어집니다. 크기 제한 없는 배열과, 그 배열이 결국 위협하게 되는 16MB 문서 상한입니다. 표준 해법인 참조로 전환하기, 크기가 제한된 일부만 임베딩하기(최신 N개와 오버플로 컬렉션), 자식을 그룹 문서로 묶는 버킷 패턴은 모두 “문서의 증가를 멈춘다”는 원칙의 변형입니다.
룩업에 대한 솔직한 설명
$lookup은 진짜 조인이지만, 솔직히 말해 두 가지 단서가 붙습니다. 첫째는 형태입니다. 입력 문서마다 일치 결과를 임베딩된 배열로 반환하며, $unwind는 이를 행으로 평탄화하고, 빈 값 보존 옵션 없이 쓰면 왼쪽 조인을 내부 조인 동작으로 바꿉니다. 둘째는 성능입니다. 문서 엔진은 중첩 루프 조인만 실행하며, 공개 벤치마크의 결과는 냉정합니다. 100만 개 문서를 조인하는 데 인덱스가 있어도 수십 초가 걸렸고, 임베딩한 동일 구조는 0.5초였습니다. 여기서 나오는 운영 규칙은 다음과 같습니다. 외래 필드에는 항상 인덱스를 겁니다(인덱스가 없으면 입력 문서마다 컬렉션 스캔이 일어납니다). 룩업은 몇 개 안 되는 문서에 대한 요청 단위 조인에 쓰고, 분석용 팬아웃은 절대 그 위에 구축하지 않습니다. 그런 워크로드는 데이터 웨어하우스나 관계형 엔진의 몫입니다.
N+1 문제와 네 가지 탈출구
대표적인 버그는 이렇습니다. N개의 부모를 하나의 쿼리로 가져온 다음, 루프에서 부모마다 자식을 가져오는 쿼리를 하나씩 보냅니다. 페이지 크기에 비례해 늘어나는 N+1번의 왕복입니다. 탈출구를 좋은 순서대로 들면 다음과 같습니다. 배칭은 부모 ID를 모아 포함 조건(contained-in) 쿼리 하나로 모든 자식을 가져옵니다(좋은 ODM의 populate는 이를 대신 처리합니다). include는 위 탭처럼 참조된 문서를 같은 요청에서 반환하는 SDK 수준의 즉시 로딩입니다. 룩업은 하나의 서버 측 파이프라인입니다. 임베딩을 쓰면 조인 자체가 사라집니다. N+1을 단순한 버그에서 아키텍처 문제로 키우는 원인은 이를 알아차리지 못한다는 데 있습니다. 테스트 레코드 10개에서는 빠르게 동작해 그대로 출시되지만 1,000개에서는 무너집니다. 이 병리의 전체 모습은 이 용어집의 별도 항목에서 다룹니다.
일반적인 사용 사례
- 작성자가 있는 콘텐츠. 게시물, 댓글, 작성자입니다. 목록 화면에는 include를 곁들인 참조를, 표시 전용 스냅샷 필드에는 임베딩을 씁니다.
- 카탈로그와 주문. 대표적인 확장 참조(extended reference) 패턴입니다. 주문은 판매 시점의 상품명과 가격을 임베딩하고(변경되지 않는 스냅샷), 현재 상품에 대한 참조도 함께 둡니다.
- 활동 및 이벤트 스트림. 일대초대량 관계입니다. 자식 문서가 부모 참조를 갖고, 부모에는 배열을 두지 않습니다.
- 사용자 프로필. 임베딩의 모범 사례입니다. 주소, 환경설정, 설정 항목은 함께 읽히고, 크기가 제한되며, 소유 관계가 분명합니다.
- 소셜 그래프. 다대다 참조 배열입니다. 그리고 어느 지점을 넘으면 이 형태가 관계형 엔진이나 그래프 엔진을 원한다는 솔직한 신호이기도 합니다.
임베딩, 참조, 아니면 엔진 교체? 결정 매트릭스
| 임베딩해야 하는 경우… | 참조해야 하는 경우… | 관계형 DB를 써야 하는 경우… |
|---|---|---|
| 함께 읽고 함께 업데이트 | 단독으로 접근 | 조인이 예외가 아니라 워크로드 자체 |
| 작고 크기가 제한됨 | 크기 제한 없음 또는 높은 카디널리티 | 엔터티를 가로지르는 임시 분석 |
| 하나의 부모가 소유 | 여러 부모가 공유 | 엄격한 참조 무결성이 필요 |
| 부모와의 원자적 업데이트가 중요 | 업데이트 주기가 독립적 | 복잡한 다중 행 트랜잭션 |
| 대표 사례: 프로필 필드 | 대표 사례: 댓글 | 대표 사례: 다대다가 많은 도메인 |
세 번째 열은 벤더 페이지가 쓰지 않는 부분입니다. 모든 화면에 룩업이 세 번씩 필요하고 참조 무결성 때문에 밤잠을 설친다면, 데이터는 테이블을 원하고 있는 것입니다. 문서 모델은 접근 패턴이 알려져 있고 계층적일 때 유리한 것이지, 보편적인 대체재가 아닙니다.
한계와 트레이드오프
- 비정규화는 일종의 부채입니다. 복제된 필드는 조인을 없애지만 업데이트할 때 갚아야 합니다. 팬아웃 쓰기, 데이터가 낡은 채로 남는 기간, 일관성 유지 코드(트랜잭션이나 변경 스트림 트리거)가 그 이자입니다.
- 외래 키가 없으니 안전망도 없습니다. 참조는 대상의 존재를 강제하지 않습니다. 플랫폼이나 코드가 정리하지 않으면, 저자를 삭제할 때 책의 참조가 조용히 고아가 됩니다.
- 룩업은 최적화되지 않습니다. 조인 순서 재배치도, 해시 전략도 없습니다. 파이프라인의 순서가 곧 실행 계획입니다.
- 형태 간 마이그레이션은 실제 프로젝트입니다. 임베딩에서 참조로 바꾸는 작업(계속 커지는 배열 문제를 바로잡는 작업)은 컬렉션을 백필하고 쿼리를 다시 작성해야 한다는 뜻입니다. 오늘이 아니라 내일의 카디널리티를 기준으로 모델링해야 합니다.
- 16MB 상한은 경고가 아니라 절벽입니다. 상한에 다가가는 증가 패턴은 상한에 닿기 훨씬 전부터 성능을 떨어뜨립니다.
Back4app에서의 관계형 쿼리
Back4app은 관리형 데이터베이스, 자동 생성되는 REST 및 GraphQL API, 인증, 파일 스토리지, Cloud Code 서버리스 함수를 결합한 오픈소스 Backend as a Service(BaaS) 플랫폼입니다. Back4app의 문서 데이터베이스는 설계 단계부터 관계형 어휘를 사용합니다. Pointer는 일대다 연결을 선언하고 Relation은 다대다를 처리하며, include()는 한 번의 요청으로 그 연결을 따라갑니다. 위의 코드 탭에서 보듯 N+1의 탈출구가 SDK에 내장되어 있는 셈입니다. 자동 생성 GraphQL API는 추가 작업 없이 관련 객체를 하나의 쿼리로 중첩하고, 삭제는 Cloud Code 트리거를 통해 연쇄적으로 처리할 수 있습니다. 문서 엔진이 빠뜨린 참조 무결성의 안전망입니다.
자주 묻는 질문
MongoDB는 조인을 지원합니까?
SQL 방식의 조인은 아니지만, 관계형으로 쓸모 있는 조인은 지원합니다. $lookup 집계 스테이지는 왼쪽 외부 조인을 수행하며, 다른 컬렉션에서 일치하는 문서를 평탄한 행이 아니라 임베딩된 배열로 붙여 줍니다. 여기에 $unwind를 더하면 내부 조인 의미로 바뀝니다. SQL과 다른 점은 결과의 형태, 성능 특성, 그리고 조인이 기본이 아니라 예외라는 사실입니다.
관련 데이터는 임베딩해야 합니까, 참조해야 합니까?
합의된 규칙은 이렇습니다. 함께 읽고, 함께 업데이트하고, 함께 보관하는 데이터, 즉 작고 크기가 제한되며 밀접하게 결합된 데이터는 임베딩합니다. 독립적으로 존재하는 데이터, 즉 여러 부모가 공유하거나, 따로 업데이트되거나, 카디널리티가 높거나, 크기 제한이 없는 데이터는 참조합니다. 벤더 가이드는 설득력 있는 이유가 없다면 임베딩을 우선하라고 권하며, 그 설득력 있는 이유가 바로 이 네 가지입니다.
카디널리티는 모델링 결정을 어떻게 바꿉니까?
고전적인 단계 구분이 있습니다. 일대소수(한 사람의 주소)는 배열을 임베딩합니다. 일대다(수백에서 수천 개)는 참조 배열을 씁니다. 일대초대량(한 장비의 로그 이벤트)은 방향을 뒤집어 각 자식에 부모 참조를 저장합니다. 부모가 끝없이 커지는 배열을 담을 수는 없기 때문입니다. 다대다는 참조 배열을 쓰며, 때로는 양쪽에 모두 둡니다.
$lookup은 느립니까?
관계형 조인과 비교하면 일관되게 그렇습니다. 문서 엔진은 관계형 옵티마이저가 갖춘 병합 조인이나 해시 조인 전략 없이 중첩 루프 조인을 실행합니다. 100만 개 문서를 조인한 공개 벤치마크에서는 인덱스가 있어도 수십 초가 걸렸고, 임베딩한 동일 구조는 0.5초였습니다. 운영 규칙은 이렇습니다. 외래 필드에는 항상 인덱스를 걸고, $lookup은 몇 개 안 되는 문서를 조인하는 요청 단위 경로에만 쓰며, 분석용 팬아웃은 절대 그 위에 구축하지 않습니다.
16MB 문서 크기 제한이란 무엇이며, 왜 중요합니까?
단일 문서 크기의 절대 상한이며, "그냥 전부 임베딩하자"는 접근이 실패하는 이유입니다. 크기 제한 없이 임베딩된 배열(댓글, 로그, 이벤트)은 상한을 향해 커지며, 상한에 닿기 훨씬 전부터 캐시와 인덱스 효율을 떨어뜨립니다. 표준 해법은 참조로 전환하기, 크기가 제한된 일부(최신 N개)만 임베딩하고 나머지는 오버플로 컬렉션에 두기, 또는 자식을 그룹 문서로 묶는 버킷 패턴입니다.
문서 데이터베이스에서 N+1 문제란 무엇입니까?
하나의 쿼리로 N개의 부모를 가져온 뒤, 관련 데이터를 위해 부모마다 쿼리를 하나씩 더 보내는 것입니다. 결과 집합이 커질수록 N+1번의 왕복도 늘어납니다. 해법을 선호도 순으로 들면, 수집한 ID에 대한 단일 쿼리로 두 번째 단계를 배칭하기, 하나의 파이프라인에서 서버 측 룩업 사용하기, SDK의 include 메커니즘으로 관련 문서를 한 번의 요청에 가져오기, 또는 임베딩해서 "조인"이 쓰기 시점에 이미 끝나게 하기입니다.
ODM과 백엔드 SDK는 관계를 어떻게 표현합니까?
즉시 로딩 연산자를 갖춘 타입 지정 참조로 표현합니다. 문서 ODM은 참조 필드를 선언하고 배칭된 후속 쿼리로 이를 채웁니다(populate). 백엔드 SDK는 다른 객체를 가리키는 타입 지정 참조인 Pointer와, 참조된 문서를 같은 요청에서 가져오는 include 연산자를 사용하며, 대규모 다대다 집합에는 Relation 타입을 씁니다. 어디서나 발상은 같습니다. 연결을 선언하고, 그 연결을 언제 따라갈지 선택합니다.
관계형 데이터베이스가 그냥 더 나은 선택인 경우는 언제입니까?
조인이 예외가 아니라 워크로드 자체일 때입니다. 다대다가 많은 도메인, 엔터티를 가로지르는 임시(ad-hoc) 분석, 엄격한 참조 무결성, 복잡한 다중 행 트랜잭션이 그렇습니다. 문서 모델은 접근 패턴이 알려져 있고 계층적일 때, 즉 화면 하나의 데이터가 문서 하나에 대응할 때 유리합니다. 모든 쿼리에 룩업이 세 번씩 필요하다면, 데이터가 테이블을 원한다고 말하고 있는 것입니다.