본문으로 바로가기
COMMUNITY모든 이야기

테이블을 버린 데이터베이스 — MongoDB는 왜 '문서'에 베팅했나

빗방울 2026.10.30 20:58 12분 읽기 조회 7 · 추천 2

관계형 데이터베이스는 40년 넘게 표준이었습니다. 데이터를 행과 열로 자르고, 테이블끼리 외래키로 연결하고, JOIN으로 다시 붙입니다. 이 모델은 강력합니다. 대신 비용이 있습니다. 데이터 모양이 조금만 바뀌어도 스키마를 고치고 마이그레이션해야 합니다.

MongoDB는 여기서 정반대 선택을 했습니다. 테이블을 버리고 문서(document)를 저장합니다. 그리고 그 문서는 JSON과 거의 같은 모양입니다.

이 선택이 무엇을 가능하게 하고, 무엇을 잃게 했는지가 이 글의 주제입니다.

출발점: 2007년, 'JOIN이 병목이다'

MongoDB는 2007년 뉴욕의 스타트업 10gen에서 시작했습니다. 원래는 웹 서비스를 위한 플랫폼을 만들다가, 데이터베이스 부분만 떼어내 제품으로 키웠습니다.

당시의 문제의식은 구체적이었습니다. 웹 애플리케이션은 읽기가 압도적으로 많고, 데이터 구조가 자주 바뀌며, 트래픽이 수평으로 늘어납니다. 관계형 모델은 이 세 가지에 모두 마찰이 있었습니다.

특히 JOIN이 문제였습니다. 여러 테이블을 붙이는 연산은 한 서버 안에서는 빠르지만, 데이터가 여러 서버에 흩어지면 네트워크를 건너뛰며 조립해야 합니다. 샤딩 환경에서 JOIN은 특히 비쌉니다.

핵심 발상: '함께 읽는 것은 함께 저장한다'

MongoDB의 데이터 단위는 컬렉션(collection)과 도큐먼트(document)입니다. 관계형의 테이블·행에 대응되지만 결정적 차이가 있습니다. 도큐먼트 안에 또 다른 도큐먼트와 배열을 넣을 수 있습니다.

예를 들어 블로그 글 하나를 저장한다고 해봅시다. 관계형이라면 글 테이블, 댓글 테이블, 태그 테이블을 만들고 JOIN합니다. MongoDB는 이렇게 넣습니다.

{ title: "...", tags: ["db","mongo"], comments: [ {user:"a", body:"..."}, {user:"b", body:"..."} ] }

글을 읽을 때 댓글과 태그가 이미 그 안에 있습니다. JOIN이 필요 없습니다. 한 번의 읽기로 화면 하나를 완성합니다. 이것이 '문서 모델'의 실질적 이득입니다.

m1s.jpg

데이터센터의 서버 랙. MongoDB의 확장은 이 랙을 가로질러 데이터를 나누는 문제입니다. (CC0, NERSC)

저장 형식: BSON

내부 저장 형식은 JSON이 아니라 BSON(Binary JSON)입니다. JSON에 바이너리 타입을 추가한 형식입니다. 날짜(Date), 정수 32/64비트, 10진수(Decimal128), 바이너리 데이터, ObjectId 같은 타입이 들어 있습니다.

왜 JSON을 그대로 쓰지 않을까요. 두 가지 이유입니다. 파싱 비용과 타입 정밀도입니다. JSON은 텍스트라 매번 해석해야 하고, 숫자는 전부 부동소수점입니다. 금액을 다루는 시스템에서 이건 치명적입니다. BSON은 길이 정보를 앞에 두어 필요한 필드만 건너뛸 수 있게 했고, 10진수 타입을 별도로 제공합니다.

모든 도큐먼트에는 _id 필드가 있고, 기본값은 ObjectId입니다. 12바이트로, 타임스탬프 4바이트 + 랜덤 5바이트 + 카운터 3바이트로 구성됩니다. 즉 생성 시각이 ID 안에 들어 있습니다. 별도 컬럼 없이 시간 순 정렬이 가능한 이유입니다.

대가 1: 스키마 없음은 '자유'가 아니라 '부채'다

MongoDB는 스키마를 강제하지 않습니다. 같은 컬렉션에 서로 다른 필드를 가진 도큐먼트가 들어갈 수 있습니다. 이건 초기 개발에서 엄청난 속도입니다. 마이그레이션 없이 필드를 추가할 수 있으니까요.

그런데 1년 뒤를 생각해 봅시다. 어떤 문서는 user_name, 어떤 문서는 username, 또 어떤 문서는 user를 씁니다. 애플리케이션 코드는 세 가지를 모두 처리해야 합니다. 스키마는 사라진 게 아니라 애플리케이션 안으로 숨은 것입니다.

그래서 실무에서는 스키마 검증(schema validation)을 켭니다. MongoDB는 컬렉션 단위로 JSON Schema 검증을 지원합니다. '자유롭게 넣되, 규칙은 명시한다'가 성숙한 사용법입니다.

대가 2: 중복 저장과 문서 크기 한계

댓글을 글 안에 넣으면 읽기가 빨라집니다. 대신 댓글 하나를 고칠 때 글 전체를 다시 씁니다. 그리고 같은 사용자 정보가 수천 개 문서에 중복될 수 있습니다. 이름이 바뀌면 수천 곳을 고쳐야 합니다.

여기에 하드 리밋이 있습니다. 도큐먼트 하나는 16MB를 넘을 수 없습니다. 배열이 무한히 자라는 구조(예: 로그, 알림)를 한 문서에 넣으면 언젠가 벽에 부딪힙니다.

그래서 설계 원칙이 나옵니다. '함께 읽고, 함께 자라지 않는 것'은 안에 넣고, '무한히 자라거나 따로 읽는 것'은 분리합니다. 이 판단이 MongoDB 설계의 핵심 기술입니다.

인덱스: B+ 트리는 그대로다

문서 모델이 달라도 인덱스의 원리는 같습니다. MongoDB도 B+ 트리를 씁니다. 앞서 데이터베이스 원리 글에서 다룬 그 구조입니다.

특징적인 것은 복합 인덱스의 순서와 다중키(multikey) 인덱스입니다. 배열 필드에 인덱스를 걸면, MongoDB가 배열의 각 원소마다 인덱스 항목을 만듭니다. 태그 배열에 인덱스를 걸면 'db'로도 'mongo'로도 찾을 수 있습니다.

주의할 점은 ESR 규칙입니다. 복합 인덱스는 Equality, Sort, Range 순서로 컬럼을 배치하는 것이 유리합니다. 등호 조건을 앞에, 정렬을 중간에, 범위를 뒤에. 순서를 틀리면 인덱스가 있어도 정렬을 메모리에서 해야 합니다.

m2s.jpg

서버 랙에서 작업하는 기술자. 레플리카 세트는 여러 대의 서버가 같은 데이터를 유지하는 구조입니다. (CC0, NERSC)

가용성: 레플리카 세트와 oplog

MongoDB의 기본 배포 단위는 레플리카 세트(replica set)입니다. 같은 데이터를 가진 서버 여러 대를 묶고, 그중 하나가 프라이머리로 쓰기를 받습니다.

복제 방식이 흥미롭습니다. 프라이머리는 모든 변경을 oplog(operations log)라는 고정 크기 순환 로그에 기록합니다. 세컨더리는 이 로그를 읽어서 자기에게 재생합니다. 즉 명령의 순서를 복제하는 방식입니다.

여기서 중요한 성질이 나옵니다. oplog는 크기가 정해져 있습니다. 세컨더리가 너무 오래 죽어 있으면 필요한 로그가 덮여 사라집니다. 그러면 증분 복제가 불가능해져 전체 초기 동기화를 다시 해야 합니다. 그래서 oplog 크기 산정이 운영에서 중요합니다.

프라이머리가 죽으면 선출(election)로 새 프라이머리를 뽑습니다. 이때 과반수 투표가 필요합니다. 그래서 레플리카 세트는 홀수 구성이 권장됩니다. 3대면 1대 장애까지, 5대면 2대까지 견딥니다.

확장: 샤딩과 샤드 키

데이터가 한 서버에 안 들어가면 샤딩(sharding)을 합니다. 구조는 세 가지 역할로 나뉩니다. mongos(라우터), config 서버(메타데이터), 샤드(실제 데이터).

샤딩의 성패는 샤드 키 선택에 달려 있습니다. 샤드 키는 두 가지 조건을 만족해야 합니다.

1) 카디널리티가 높아야 한다. 값의 종류가 적으면 청크를 나눌 수 없습니다.
2) 쓰기가 분산되어야 한다. 타임스탬프를 샤드 키로 쓰면 모든 쓰기가 마지막 청크 하나로 몰립니다. 이를 hot shard라고 합니다.

그래서 흔히 쓰는 방식이 해시 샤드 키입니다. 분산은 균등해지지만, 범위 검색이 모든 샤드로 뿌려집니다(scatter-gather). 균등 분산과 범위 질의는 동시에 얻기 어렵습니다.

트랜잭션: 원래 없었다

여기서 균형이 필요합니다. MongoDB는 초기에 다중 문서 트랜잭션을 지원하지 않았습니다. 단일 문서의 원자성만 보장했습니다. 문서 모델이 '함께 저장한 것은 함께 원자적'이기 때문에, 설계로 우회하라는 철학이었습니다.

하지만 현실은 그렇게 깔끔하지 않습니다. 계좌 이체처럼 두 문서를 반드시 함께 바꿔야 하는 경우가 있습니다. 결국 MongoDB는 4.0에서 레플리카 세트 트랜잭션을, 4.2에서 샤딩 클러스터 트랜잭션을 추가했습니다.

즉 'NoSQL은 트랜잭션이 없다'는 말은 이제 틀렸습니다. 다만 트랜잭션은 비용이 큽니다. 여러 샤드에 걸치면 2단계 커밋에 가까운 조정이 필요합니다. 문서 모델로 해결할 수 있는 것을 트랜잭션으로 억지로 풀면 성능을 잃습니다.

집계: aggregation pipeline

JOIN 대신 MongoDB가 밀어붙인 것은 집계 파이프라인입니다. 여러 단계(stage)를 순서대로 연결해 데이터를 변환합니다.

$match(필터) → $group(그룹화) → $sort → $project(필드 선택). SQL의 WHERE, GROUP BY, ORDER BY, SELECT에 대응합니다.

중요한 것은 파이프라인에서 인덱스를 타는 단계는 주로 맨 앞의 $match라는 점입니다. 뒤쪽 단계는 이미 줄어든 데이터를 메모리에서 처리합니다. 따라서 필터를 앞에 두는 것이 성능의 핵심입니다.

관계가 필요하면 $lookup으로 다른 컬렉션을 붙일 수 있습니다. 다만 이는 제한적 조인이고, 대량에서는 느립니다. 'JOIN이 필요하면 모델을 다시 봐라'는 신호로 읽는 것이 좋습니다.

언제 쓰고, 언제 쓰지 말아야 하나

잘 맞는 경우 — 읽기 중심의 콘텐츠 서비스, 카탈로그, 사용자 프로필, 이벤트·로그 수집, 스키마가 자주 바뀌는 초기 제품, 지리 데이터(GeoJSON 인덱스), 전문 검색이 가벼운 수준일 때.

피해야 하는 경우 — 복잡한 다중 테이블 조인이 핵심인 회계·ERP, 강한 정합성이 절대적인 금융 원장, 대량의 다대다 관계, 그리고 팀에 관계형 모델 경험이 깊고 문서 설계 경험이 없을 때.

마지막 항목이 실무에서 가장 자주 무시됩니다. 도구의 문제가 아니라 모델링 습관의 문제인 경우가 많습니다.

반전: MongoDB의 진짜 승부수는 '개발자 경험'이었다

성능이나 확장성만 보면 MongoDB가 압도적이지 않습니다. 오히려 관계형도 충분히 빠르고, 샤딩도 합니다. MongoDB가 이긴 지점은 다른 곳입니다.

애플리케이션의 객체와 저장 형식이 같다는 것입니다. 자바스크립트 객체를 그대로 넣고, 꺼내면 그대로 객체입니다. ORM 매핑 계층이 사라집니다. 이 마찰 제거가 개발 속도를 바꿨습니다.

대가는 뒤에 옵니다. 모델링을 애플리케이션 개발자가 직접 하게 되었고, 그 판단이 틀리면 몇 년 뒤에 청구서가 옵니다. 문서 모델은 설계를 잘하면 매우 빠르고, 잘못하면 매우 느립니다.

그래서 뭘 보면 되나

첫째, 도큐먼트 경계입니다. '이 데이터는 항상 함께 읽히는가, 함께 자라는가'를 먼저 물어보세요.

둘째, 인덱스와 ESR입니다. 느린 쿼리는 대개 인덱스가 없거나 순서가 틀렸습니다. explain()으로 COLLSCAN이 뜨는지 확인하세요.

셋째, oplog와 샤드 키입니다. 운영 단계에서 장애를 만드는 것은 대개 이 두 가지입니다.

여러분은 MongoDB를 쓰고 계신가요? 혹은 관계형에서 넘어가려다 멈춘 경험이 있나요? 어떤 지점에서 망설였는지 댓글로 남겨주세요.

참고 자료

  • MongoDB 공식 문서 — 도큐먼트 모델, BSON 타입, 인덱스(ESR), 레플리카 세트, 샤딩
  • WiredTiger 스토리지 엔진 — 문서 단위 동시성 제어와 압축
  • MongoDB 4.0 레플리카 세트 트랜잭션, 4.2 샤딩 클러스터 트랜잭션 — 다중 문서 ACID 도입
  • 본 글은 제품 사용법이 아니라 설계 사상과 트레이드오프를 정리한 것입니다. 버전별 세부 동작은 공식 문서를 확인하세요.

모든 이야기1455

가벼운 안부부터 깊은 생각까지, 함께 나눠요.

글쓰기
총 1455개의 이야기
댓글