grep

Engineering

데이터 통합— MongoDB 원칙으로 document를 통합하고 동기화를 재설계하다 (3/3)

Joy여기어때

2026년 7월 21일

원문에서 보기 ↗

글. 우재호(Joy) / 전시개발팀

이 글은 3부작입니다.

안녕하세요, 전시개발팀 백엔드 개발자 조이입니다.

이번 글에서는 전시 데이터의 저장 구조를 v1에서 v2로 옮기면서 왜 옮겼고, 어떤 문제를 만났으며, 어떻게 풀었는지를 이야기해 보려 합니다.

들어가며

저희 팀은 전시 데이터를 외부 소스에서 받아 가공하고, 서비스를 빠르게 조회할 수 있는 형태로 저장하는 일을 합니다. 그런데 이 데이터를 어떤 구조로 저장하느냐가 줄곧 문제였습니다.

기존 구조는 정반대 방향으로 두 번 크게 바뀌었습니다. 한때는 한 숙소의 모든 정보를 문서 하나에 담았다가 문서가 너무 커져서 느려졌고, 그 반작용으로 데이터를 잘게 쪼갰다가 이번엔 조회할 때마다 여러 조각을 합치느라 다시 느려졌습니다.

저는 이 기존 구조의 한계를 짚고 , 함께 읽히는 데이터를 도메인 단위로 묶는 v2 구조로 개선하는 작업을 맡았습니다.

그런데 통합으로 조회 성능은 좋아졌지만, 새로운 숙제가 따라왔습니다. 한 문서에 여러 정보가 모이자, 작은 변경에도 큰 문서 전체를 다시 쓰는 문제가 생겼습니다. 조회를 빠르게 만든 통합 구조가 쓰기 비용으로 되돌아온 셈입니다. 그래서 저장 구조를 바꾸는 데 그치지 않고, 그 구조에 맞춰 동기화 방식까지 다시 설계해야 했습니다.

이전 편에서는 통합으로 조회 성능을 끌어올린 이야기를 다뤘습니다. 이번 편은 그렇게 달라진 document에 맞춰 동기화를 어떻게 재설계했는지를 정리합니다.

1. 정반대 방향으로 두 번 바뀐 데이터 구조

지금의 v2 구조를 이해하려면, 그 앞에 있었던 두 번의 시행착오를 먼저 봐야 합니다. “한 문서에 다 담기”와 “잘게 쪼개기”라는 정반대 선택을 차례로 거친 끝에, v2에 이르렀습니다

ES판 — 한 문서에 전부 담기

초기 구조는 한 숙소의 모든 데이터를 문서 하나에 담는 방식이었습니다. 조회는 단순했지만, 데이터가 누적되며 문서 하나가 수십 MB까지 커지는 케이스가 나왔습니다. 이런 거대 문서는 직렬화, 전송, 역직렬화 비용이 커서 응답이 눈에 띄게 느려졌습니다. “한 덩어리”의 한계를 단적으로 보여주는 사례였습니다.

v1 — MongoDB로 옮기며 RDB와 1:1로 잘게 찢기

기존 구조의 한계에 부딪힌 뒤, 이번엔 정반대 방향으로 갔습니다. 저장소를 ES에서 MongoDB로 옮기고 , RDB CDC(Change Data Capture)로 변경을 받아 원본 테이블과 거의 1:1로 컬렉션을 구성한 구조입니다. 그 결과 컬렉션이 수십 개로 늘었습니다.

문서 하나하나는 작아졌지만, 한 숙소를 표현하는 데이터가 수십 개 컬렉션에 흩어졌습니다.

이번엔 반대쪽 비용이 문제가 됐습니다. 한 화면을 그리려면 여러 컬렉션을 aggregation으로 모아야 했고, 그 조인 비용이 조회 성능을 깎아먹었습니다. “잘게 찢기”의 한계였습니다.

사실 이 구조는 MongoDB가 가장 권장하지 않는 설계 방식 이기도 합니다. 공식 문서를 읽다 보면 반복해서 나오는 경고가 있습니다 — RDB 테이블 구조를 그대로 컬렉션으로 옮기지 말라는 것입니다. 예를 들어 이렇게요.

[RDB 테이블]          [그대로 옮긴 컬렉션 — 안티패턴]
Hotel          ────▶  hotel
Room           ────▶  room
RoomImage      ────▶  room_image
RoomPolicy     ────▶  room_policy
RoomAmenity    ────▶  room_amenity
RoomTheme      ────▶  room_theme
...                   ...정규화된 테이블을 1:1로 컬렉션화하면, 조회할 때마다 그 조각들을 다시 합쳐야 합니다. v1이 정확히 이 함정에 빠진 구조였습니다.

다만 짚고 넘어갈 게 있습니다. 이 1:1 구조는 사실 MongoDB가 권장하는 방식과는 거리가 있습니다. 공식 문서에도 반복해서 나오는 경고가 있죠 — RDB 테이블 구조를 그대로 컬렉션으로 옮기지 말라는 것입니다.

다만 이게 잘못된 선택이었다고만 보긴 어렵습니다. 당시 최우선 과제는 저장소를 빠르고 안전하게 MongoDB로 옮기는 것이었고, RDB 변경(CDC)을 받아 그대로 1:1로 적재하는 방식은 그 목표에 가장 빠른 길이었습니다. 스키마를 새로 고민하는 비용 없이 기존 데이터 흐름을 그대로 태울 수 있으니까요. 전환을 일단 끝낸다는 관점에서는 합리적인 출발점이었던 셈입니다.

문제는 그 출발점이 조회 패턴과는 어긋나 있었다는 점입니다. 솔직히 구현 단계에서도 “조회할 때마다 이 조각들을 다시 합쳐야 하는데 괜찮을까” 하는 의문이 있었지만, 그 위화감이 실제 성능 비용으로 또렷해진 건 트래픽이 쌓인 뒤였습니다. 그리고 그 깨달음이 v2 재설계의 출발점이 됐습니다.

v2 — 도메인 단위로 통합

한 덩어리는 조회가 느렸고, 1:1 파편화는 aggregation이 느렸습니다. 두 방향 모두 한쪽 비용이 터지는 구조였죠. 그래서 저는 단순히 그 중간을 절충하는 대신, MongoDB가 권장하는 설계 원칙에 따라 다시 설계했습니다.

Model your schema according to your application’s queries.

— MongoDB 공식 문서

이 한 문장에 담긴 원칙을 풀면 이렇습니다.

이 원칙에 따라, 함께 읽히는 데이터를 도메인 단위로 묶었습니다. 그렇게 숙소, 객실·요금 단위의 소수 컬렉션이 생겼습니다.

한 숙소가 곧 한 문서지만, MongoDB의 단일 문서 제한(16MB)과 실제 조회 payload 크기를 함께 고려해 도메인별로 문서를 나눴습니다. ES판처럼 모든 정보를 한 덩어리에 담지 않으므로, 한 문서가 다시 거대해질 일은 없습니

다.

v1의 1:1 파편화는 이 원칙에 정면으로 어긋난 구조였고, v2는 이 원칙을 따라 설계한 결과인 셈입니다.

이렇게 통합한 document가 조회 성능을 어떻게 끌어올렸는지는 이전 편에서 다뤘습니다. 저는 이 글에서 아래의 주제에 더 자세하게 설명을 하려 합니다.

”그렇게 달라진 document에 맞춰 동기화를 어떻게 재설계했는가?”

왜 동기화까지 다시 짜야 했나

v1에서는 데이터가 바뀌면 관련 컬렉션을 각각 갱신해야 했습니다. 컬렉션이 수십 개면 동기화 경로도 그만큼 갈라집니다. v2로 도메인 통합을 하면서 이 갈래는 줄었지만, 대신 한 문서에 여러 정보가 모이게 됐습니다.

그로 인하여 그 안의 한 조각만 바뀌어도, 문서 전체를 통째로 다시 쓰게되는 상황이 발생합니다. 하지만 리뷰 점수 하나 갱신하자고 숙소, 이미지, 요금까지 매번 다시 쓸 수는 없는 노릇이었습니다.

앞서 본 MongoDB의 원칙대로 Read를 최적화한 대가입니다. 읽기를 단일 문서로 끝내는 대신, 쓰기는 그만큼 무거워질 위험을 안게 된 것이죠. 그래서 통합의 이점을 지키려면, 이 무거워진 쓰기를 가볍게 만드는 동기화 방식이 반드시 필요했습니다. v2 동기화의 설계 목표는 분명했습니다.

통합된 문서를, 바뀐 것만 골라 한 번에 갱신한다.

2. 통합의 짝 — 필드 단위 부분 갱신

통합으로 문서가 커진 만큼, 이제는 작게 갱신하는 방법이 필요했습니다.

한 숙소 문서에는 기본정보, 이미지, 광고, 엠블럼, 리뷰 등의 정보가 모두 들어 있습니다. 리뷰 점수 하나 바뀌었다고 이 큰 문서를 통째로 다시 쓴다면, 애써 통합한 이점이 사라지게 됩니다.

그래서 v2는 “한 숙소 문서를 여러 동기화가 나눠 쓰되, 각자 자기 필드만 갱신한다” 는 규칙을 세웠습니다. 리뷰처럼 자주, 독립적으로 바뀌는 데이터는 그 필드만 담당하는 동기화를 따로 두고, 나머지는 “전체 동기화”가 한꺼번에 책임지는 식입니다.

이 규칙에는 두 가지 핵심이 있습니다.

3. 부분 갱신은 실제로 어떻게 동작하나?

각 동기화가 “이번에 갱신할 필드”를 정하면, 그 필드들은 공용 저장 함수 하나를 거쳐 실제 DB 쓰기로 바뀝니다. 이 함수가 v2 동기화의 심장입니다. 동작은 네 단계입니다.

설계상 챙긴 포인트는 이렇습니다.

  1. 변환 로직은 한 곳에서 처리합니다. 데이터를 문서 형태로 바꾸는 작업은 동기화마다 따로 만들지 않고, 공용 함수 하나로 공유합니다. 각 동기화는 이 함수에 “이번엔 어떤 필드만 갱신할지”만 알려 주면 됩니다. 덕분에 동기화 종류가 늘어도 변환 코드는 한 벌만 관리하면 됩니다.
  2. 중첩 필드는 그것만 갱신합니다. 예컨대 객실 문서에서 가격만 갱신하면 같은 문서의 나머지 필드는 손대지 않습니다.
  3. 비어 버린 객체는 명시적으로 제거합니다. 갱신하려는 필드의 부모 객체가 통째로 비면, 그 자리를 빈 채로 두지 않고 unset으로 지워 상태를 분명히 합니다.
  4. bulk로 묶고 순서는 보장하지 않습니다. N건을 한 번의 bulk 쓰기로 묶되 순서를 강제하지 않으므로, 한 건이 실패해도 나머지가 막히지 않고 처리 순서도 자유롭습니다.

글로만 보면 추상적이니, 실제로 만들어지는 갱신 명령을 개념적으로 그리면 이런 모습입니다. “가격만 갱신하고, 비어 버린 프로모션 배지는 지운다”를 한 문서에 대해 표현한 것입니다.

// 한 문서에 대한 부분 갱신 (개념 예시)
{
 $set: {
 "rooms.$[room].price": nextPrice, // 가격만 콕 집어 갱신
 "updatedAt": now
 },
 $unset: {
 "promotion.expiredBadge": "" // 부모가 비면 그 자리를 지움
 }
}
// 위와 같은 갱신 N건을 한 번에 묶어 쓴다
bulkWrite(operations, { ordered: false })

건드리지 않은 필드는 그대로 남고, 바뀐 값만 갈아끼웁니다. 이런 단위 갱신 N개를 `ordered: false` bulk write로 한 번에 흘려보내는 것이 이 함수의 실제 동작입니다.

이 한 함수 덕분에 각 동기화는 “이번에 갱신할 필드가 무엇인지”만 정하면 됩니다.

4. 같은 문서, 서로 다른 출처의 데이터로 채우는 경우

2장의 “동기화별로 자기 필드만 갱신한다”가 가장 잘 드러나는 곳이 객실·요금 문서입니다. 이 문서는 출처가 다른 두 동기화가 함께 채웁니다.

여기서 핵심은 “변경 빈도”가 아니라 데이터의 출처입니다. 가격 정보는 숙소 동기화가 들고 오는 응답에 담겨 있고, 객실 구성 정보는 객실 동기화 쪽 응답에 담겨 있습니다.

두 동기화는 같은 문서를 바라보지만, 각자 자기 응답에 담겨 있는 필드만 갱신합니다.

만약 한 동기화가 문서 전체를 덮어쓴다면, 자기가 갖고 있지 않은 다른 출처의 필드(예: 객실 동기화가 가격을)를 빈 값으로 밀어버릴 수 있습니다. 필드 단위로 책임을 나눠 두니, 서로의 영역을 침범하지 않고 같은 문서를 안전하게 공유합니다.

5. 증분 동기화와 수동 동기화

v2는 두 가지 트리거를 명확히 나눴습니다.

수동 동기화에서는 동기화 대상을 골라서 돌릴 수 있게 했습니다. 숙소 기본정보만, 객실·요금만, 혹은 전부 가능합니다. 정합성이 깨진 한 부분만 콕 집어 보정할 수 있어 운영이 가벼워집니다.

또 대상 전체를 일정 건수 단위로 끊어 처리합니다. 수만 건을 한 번에 메모리에 올리지 않고, 청크로 나누어 bulk 쓰기를 반복해 부하를 평탄하게 가져갑니다.

6. 결과 — 더 안정적이고, 더 빨라진 동기화

이렇게 재설계한 동기화는 실제 지표로도 효과가 드러났습니다.

![동기화 성능 전/후 비교 — 적용 전(빨간 박스) vs 적용 후(파란 박스)]

7. 저장 다음은 발행 — 변경 이벤트로 전파

왜 굳이 변경을 알려야 할까요? 이 데이터는 한 번 만들어 두고 자주 읽기만 하는 정적 데이터에 가깝습니다. 그래서 읽기 성능을 끝까지 끌어올리기 위해, 동기화로 만든 문서를 다시 Redis에 캐시해 두고 조회합니다.

문제는 여기서 생깁니다. 원본을 아무리 잘 갱신해도 Redis 캐시가 갱신되지 않는다면, 사용자에게는 여전히 예전 데이터로 보이게 됩니다. 저장으로 끝내면 안 되고, 바뀌었다는 사실을 하위로 흘려보내야 합니다.

그래서 v1에는 없던 단계가 하나 더 생겼습니다. DB에 쓴 직후 변경 이벤트를 발행합니다. 청크를 저장할 때마다 “이 키들이 이렇게 바뀌었다”를 곧바로 흘려보내는 식입니다.

청크 저장 → 그 청크의 변경 발행 → 다음 청크 저장 → 발행 …

발행 메시지는 변경된 키 목록, 도메인 종류, 변경 유형(생성·수정 또는 삭제)을 담습니다. 비활성 상태는 삭제로, 나머지는 생성·수정으로 구분합니다.

이렇게 하면 이 워커가 데이터를 저장하는 동시에, 그 변경을 구독하는 쪽에서 Redis 캐시를 최신화합니다. 동기화가 “한 군데에 쓰고 끝” 이 아니라 “쓰고 알린다” 가 된 것입니다.

다만 발행 실패가 동기화 전체를 막지는 않도록 했습니다. 저장이 본질이고 발행은 부가 전파이므로, 발행은 논블로킹으로 처리하고 실패는 로깅과 알림으로 남깁니다.

8. 마이그레이션을 안전하게 한 방법

가장 현실적인 고민은 “v1을 쓰는 동안 v2를 어떻게 켜느냐” 였습니다. 답은 둘을 동시에 굴리는 것이었습니다.

증분 이벤트가 들어오면 v1 문서를 저장하는 동시에 v2 문서들도 함께 갱신했습니다. 삭제 이벤트도 양쪽을 함께 정리했습니다.

덕분에 v2 컬렉션을 실제 트래픽으로 채우고 검증하는 동안에도, 기존 v1 경로는 그대로 살아 있었습니다.

여기에 앞서 본 대상을 골라 돌리는 수동 동기화가 더해져, 신규 컬렉션을 처음 채우거나 검증 중 어긋난 부분만 골라 다시 맞출 수 있었습니다. 한 번에 갈아끼우는 대신, v2의 비중을 단계적으로 올리며 안전하게 이행했습니다.

9. 회고 — 무엇을 배웠나

돌아보면 v1에서 v2로의 전환은 새 기능을 더한 일이라기보다, MongoDB가 권하는 방향대로 데이터 구조를 다시 잡고, 그 설계가 요구하는 동기화까지 맞춰 간 과정에 가까웠습니다. 비슷한 고민을 하는 분들께 이 기록이 한 번의 시행착오라도 줄여 드릴 수 있다면 좋겠습니다. 읽어 주셔서 감사합니다.