2026-07-04
개요
커머스 서비스의 딜(Deal) 도메인을 MongoDB 기반 v1에서 PostgreSQL 기반 v2로 순차적으로 전환해왔는데, 마지막까지 v1에 남아 있던 도메인이 타임딜이었습니다.
대상 데이터가 1,420만 건 이상이고, 셀러가 실시간으로 생성/수정/삭제하는 라이브 도메인이라 단순 덤프-복원으로는 옮길 수 없었습니다. 서비스는 계속 돌아가야 했고, 이관 중에도 원본 데이터는 계속 변했습니다.
이 글은 동적이관(Kafka) → Dual Write → 정적이관 → 정식 오픈으로 이어진 4단계 무중단 전환과, 1,420만 건 테이블에서 만난 조회 성능·온라인 DDL 문제까지의 대장정을 정리합니다.
본문
배경: 마지막까지 남은 타임딜
딜 도메인 v2 전환이 진행되면서 대부분의 딜 타입은 PostgreSQL의 deal / deal_product / deal_item 테이블로 옮겨간 상태였습니다. 타임딜만 MongoDB의 deal_options_info 컬렉션에 남아 있었습니다.
두 시스템이 공존하면서 문제가 계속 쌓였습니다.
•
딜 조회(winner 계산) 로직이 v1/v2 두 경로로 갈라져 유지보수 비용이 두 배
•
MongoDB 문서 안에 중첩된 옵션 구조와 RDB 정규화 모델 간 정합성 관리가 어려움
•
신규 기능(슬롯 기반 노출, 정산 정책 등)은 v2 기준으로 설계되는데 타임딜만 예외 처리 필요
결국 타임딜을 v2로 옮기는 것이 딜 도메인 통합의 마지막 퍼즐이었습니다.
전략: 4단계 무중단 전환
살아있는 데이터의 이관은 "스냅샷 복사"가 아니라 "변경 스트림 + 초기 적재"의 조합으로 설계해야 합니다. 원본이 계속 변하는 상황에서 벌크 복제부터 시작하면, 복제가 도는 동안 쌓인 변경분을 영원히 따라잡지 못합니다.
그래서 순서를 뒤집었습니다. 변경 동기화 파이프라인을 먼저 깔고, 벌크 적재를 나중에 하는 구조입니다.
1.
동적이관 — 조회 트래픽을 활용해 v1 데이터를 Kafka로 흘려보내 v2에 적재
2.
Dual Write — 셀러 어드민의 v1 쓰기(생성/수정/삭제)를 v2에 실시간 동기화
3.
정적이관 — 나머지 전체(1,420만 건+)를 벌크로 채우기
4.
정식 오픈 — 읽기 경로를 v2로 전환하고 v1 fallback 제거
핵심은 1~3단계 내내 사용자 노출은 v1 경로를 그대로 유지한다는 점입니다. v2는 데이터만 쌓고, 검증이 끝난 마지막 단계에서 스위치를 올립니다.
Step 1. 동적이관 — 조회 트래픽을 이관 파이프라인으로
BFF에서 v1 타임딜이 조회될 때마다 해당 문서를 Kafka 토픽으로 발행하고, worker가 이를 소비해 v2 테이블에 upsert하도록 했습니다. 자주 조회되는(=중요한) 상품일수록 먼저 이관되는 자연스러운 우선순위가 생깁니다.
사실 동적이관을 도입한 진짜 이유는 따로 있었습니다. MongoDB 타임딜에는 같은 상품이 서로 다른 가격으로 여러 건 등록되어 있는 케이스가 존재했습니다. 그중 무엇을 노출할지 고르는 규칙이 단순하지 않아서, BFF 조회 로직에는 이 엣지 케이스를 처리하는 코드만 300줄이 넘게 쌓여 있는 상태였습니다.
정적이관만으로 옮기려면 이 300줄을 이관 코드에 그대로 다시 구현해야 합니다. 원본 규칙과 조금이라도 어긋나면 가격이 틀린 채로 1,420만 건이 적재됩니다. 이관 전체에서 가장 위험한 부분이었습니다.
그래서 정적이관을 BFF 호출 → 동적이관(이벤트 발행)으로 치환했습니다. BFF가 이미 그 규칙대로 계산해둔 결과를 조회 시점에 그대로 이벤트로 흘려보내면, 복잡한 선택 로직을 다시 짤 필요 없이 정답만 v2로 넘어옵니다. 300줄짜리 문제를 이관 코드에서 통째로 없앤 셈입니다.
설계에서 신경 쓴 포인트는 두 가지입니다.
•
Fire-and-forget 발행: Kafka 장애가 사용자 요청 처리에 영향을 주지 않도록 발행 실패는 무시
•
Redis 기반 중복 발행 억제: 같은 상품이 수시로 조회되므로, 옵션 가격 정보의 SHA-1 지문(fingerprint)을 Redis에 저장하고 지문이 달라졌을 때만 재발행. TTL 3시간에 0~10% jitter를 더해 동시 만료로 인한 발행 폭주(thundering herd)를 분산
지문 비교 방식이라 A→B→A로 가격이 돌아오는 변경도 정확히 잡아내고, TTL이 만료되면 1회 재발행되면서 유실이 있어도 self-heal 됩니다.
한 가지 트릭은, 동적이관으로 적재된 row의 노출 기간(start/end_date_time)을 의도적으로 과거 시점으로 고정한 것입니다. v2에 데이터는 쌓이지만 liveOngoing 조건에 걸리지 않아 winner 계산에서 제외됩니다. 적재와 노출을 분리한 덕분에 이관 중 사용자 화면에는 아무 영향이 없었습니다.
이관 대상에 복잡한 선택·가공 규칙이 붙어 있다면, 그 규칙을 이관 코드에 다시 구현하지 마세요. 이미 그 규칙을 태우고 있는 조회 경로를 이관 파이프라인으로 재활용하면 로직이 한 벌로 유지되고, 원본과 어긋날 여지가 사라집니다.
Step 2. Dual Write — 쓰기 구간에서 이벤트 발행
조회 기반 동적이관만으로는 셀러가 수정했지만 아직 조회되지 않은 데이터가 구멍으로 남습니다. 그래서 셀러 어드민의 쓰기 경로에도 이벤트 발행을 추가했습니다.
타임딜을 변경할 수 있는 쓰기 구간은 두 개였습니다.
•
단건 수정 — 셀러가 어드민에서 개별 타임딜을 생성/수정/삭제하는 경로
•
엑셀 대량 업로드 — 한 번에 수백 건 이상을 일괄 등록/변경하는 경로
이 두 구간 모두에서 동일한 이벤트를 발행했습니다. live 타임딜이 남아 있으면 UPSERTED, 없으면 DELETED입니다. 어느 경로로 바뀌었든 v2가 받는 이벤트의 모양이 같기 때문에, 소비하는 worker는 발행 출처를 구분할 필요가 없습니다.
worker는 멱등하게 설계해서, 타임딜이 없던 상품에 DELETED가 와도 no-op으로 처리됩니다. 배포 순서도 중요했는데, 이벤트를 소비할 worker를 먼저 배포한 뒤 발행 측(셀러 어드민)을 배포해 유실을 막았습니다.
Step 3. 정적이관 — BFF를 훑어 나머지 1,420만 건 채우기
변경 동기화 파이프라인이 도는 상태에서, 아직 v2에 없는 나머지 데이터를 채웠습니다. 여기서도 적재 코드를 따로 만들지 않고 Step 1의 동적이관에 그대로 태우는 방식을 썼습니다. Python 스크립트는 "조회를 일으키는 것"만 책임지고, 가격 산출·중복 deal 해소·upsert는 전부 BFF와 worker의 기존 로직이 처리합니다.
스크립트는 두 단계로 나뉩니다.
1.
열거(enumerate) — MongoDB deal_options_info에서 삭제되지 않은 타임딜을 _id 오름차순으로 훑어 대상 itemId를 SQLite에 적재
2.
호출(call) — SQLite의 pending 대상을 chunk 단위로 꺼내 BFF GraphQL 호출
프로덕션 대상이 약 1,420만 개의 고유 itemId라 파이썬 프로세스 메모리에 통째로 들고 있을 수 없었습니다. 그래서 두 단계 모두 SQLite를 진행 상태 저장소로 썼습니다. 열거는 마지막으로 읽은 문서 _id를 메타 테이블에 기록해두고 재실행 시 _id > last부터 이어가고, 호출은 대상마다 pending / ok / not_found 상태를 들고 있어 중간에 죽어도 남은 pending부터 재개됩니다.
별도의 시간 필터는 두지 않았습니다. 필터는 { dealType: "timeDeal", deletedAt: null } 하나뿐이고, Dual Write를 먼저 켜둔 상태에서 열거를 시작하기 때문에 열거가 훑는 전량과 그 이후 변경분을 Dual Write가 이어받는 구조로 경계가 맞물립니다. 열거 자체는 readPreference=secondaryPreferred로 세컨더리에서 읽어 프라이머리 부하를 피했습니다.
호출 단계의 운영 파라미터는 두 가지입니다.
•
chunk 크기 — 요청당 itemId 50건
•
쿨링(cooling) — chunk 사이에 0.3초 sleep. 조회는 실제 사용자와 같은 BFF를 타고, 그 뒤에 이어지는 worker의 v2 쓰기는 운영 DB에 그대로 쌓입니다. 양쪽 모두에 부하가 몰리지 않도록 속도를 의도적으로 눌렀습니다.
5xx와 타임아웃은 지수 백오프로 3회까지 재시도하고, 그래도 실패한 chunk는 pending으로 남겨 다음 실행에서 재시도합니다. 응답에 없는 itemId는 상품 자체가 삭제된 경우라 not_found로 종결시켰습니다. 적재 로직이 동적이관 하나로 모이기 때문에, "벌크로 넣은 것과 조회로 들어온 것의 결과가 다를 수 있다"는 문제 자체가 생기지 않습니다.
1,420만 건 테이블의 조회 성능: 55초에서 0.3초로
데이터를 다 옮기고 나니 새로운 문제가 시작됐습니다. 타임딜 v2의 노출 요구사항은 "1,420만 건 중 시간대(슬롯)별로 서로 다른 약 1만 개를 결정적(deterministic)으로 노출"이었습니다.
순진하게 ORDER BY md5(id) 같은 방식으로 셔플하면 매 요청마다 1,420만 건 풀스캔이 발생합니다. 초기 구현은 노출 풀 전체(1~2만 건)를 일괄 로드하는 방식이었는데, 최악의 경우 응답에 55초가 걸리는 행(hang)이 발생했습니다.
해결은 세 가지 조합이었습니다.
•
각 row에 [0, 1) 범위의 고정 셔플 키를 부여하고 인덱스를 태워 레인지 스캔으로 전환
•
커서 페이지네이션: WHERE slot_key > :cursor ORDER BY slot_key LIMIT :size + 1 — 단일 인덱스 레인지 스캔, 청크 상한 2,000건
•
(seed, category)별 누적 캐시를 전 유저가 공유 (TTL 1시간)
결과적으로 조회가 0.3~1초로 내려왔습니다. 참고로 1,420만 건 규모에서 셔플 키 백필은 한 방 UPDATE가 아니라 PK 범위로 분할한 배치 UPDATE로 진행해야 합니다.
무중단 DDL: STORED generated column의 함정
셔플 키를 컬럼으로 추가하려다 만난 함정이 이 프로젝트에서 가장 기억에 남는 부분입니다.
처음엔 GENERATED ALWAYS AS (...) STORED 컬럼을 생각했습니다. 그런데 deal_product는 타임딜만 쓰는 테이블이 아니라 모든 딜 타입이 함께 쓰는 3,000만 건짜리 hot 테이블입니다.
PostgreSQL에서 STORED generated column을 ADD하면 테이블 전체 rewrite + ACCESS EXCLUSIVE 락이 걸립니다. 타임딜뿐 아니라 전체 딜 조회가 락이 풀릴 때까지 블로킹됩니다. hot 테이블에서는 사실상 장애입니다.
대안은 IMMUTABLE 함수 + functional index였습니다. 컬럼을 추가하는 대신 값을 함수로 계산하고, 그 함수식에 인덱스를 겁니다.
-- md5 앞 13 hex(52bit) → bigint → 2^52로 나눠 [0, 1) 균등 분포
CREATE OR REPLACE FUNCTION deal_product_slot_key(id text)
RETURNS double precision
LANGUAGE sql IMMUTABLE PARALLEL SAFE
AS $$
SELECT ('x' || left(md5(id), 13))::bit(52)::bigint / 2 ^ 52;
$$;
CREATE INDEX CONCURRENTLY idx_deal_product_time_deal_slot_key
ON deal_product (deal_product_slot_key(id))
WHERE deal_type = 'TIME_DEAL' AND deal_deleted_at IS NULL;
SQL
복사
CREATE INDEX CONCURRENTLY는 테이블 rewrite도, 쓰기 블로킹도 없이 온라인으로 적용됩니다. partial index 조건으로 타임딜 row만 인덱싱해 크기도 줄였습니다. JPA 쪽에서는 Hibernate FunctionContributor로 이 함수를 등록해 JPQL에서도 자연스럽게 호출할 수 있게 했습니다.
슬롯 경계와 캐시 누수
슬롯 기반 노출(운영시간 내 시간대별 1슬롯)로 전환하고 나서 미묘한 버그가 하나 나왔습니다. winner 계산 결과를 Redis에 캐싱하는데, 캐시 TTL이 슬롯 경계(정시)를 넘어가면 직전 슬롯의 타임딜이 다음 슬롯에 최대 TTL만큼 계속 노출되는 누수였습니다.
해결은 TTL을 슬롯 경계에 맞춰 클램프하는 것이었습니다.
// 정각 동시 만료로 인한 캐시 스탬피드 방지를 위해 0~30초 jitter 추가
val ttl = minOf(baseTtl, secondsUntilNextSlotBoundary + (0..30).random().seconds)
Kotlin
복사
여기서도 jitter가 등장합니다. 모든 캐시가 정각에 동시에 만료되면 그 순간 DB로 요청이 몰리기 때문에, 만료 시점을 30초 범위로 흩어놓았습니다. 동적이관의 dedup TTL, 캐시 TTL 모두 같은 이유로 jitter를 갖고 있습니다.
Step 4. 정식 오픈 — v1 fallback 제거
데이터 적재와 성능 문제가 정리된 뒤, 마지막 스위치를 순서대로 올렸습니다.
•
winner 가격 계산에서 v1 경로 제거 → v2 단일화
•
타임딜 winner 계산 제외 게이트 해제 (정식 오픈)
•
BFF GraphQL의 v1 fallback 제거
•
실험/게이트용 임시 코드 삭제
각 단계는 독립적으로 배포/롤백 가능하게 쪼갰고, 문제가 생기면 게이트만 되돌리면 v1 경로로 복귀할 수 있는 상태를 오픈 직전까지 유지했습니다.
마치며
1,420만 건 이관의 교훈을 정리하면 다음과 같습니다.
•
살아있는 데이터는 스냅샷이 아니라 스트림으로: 변경 동기화(동적이관 + dual write)를 먼저 깔고 벌크(정적이관)를 나중에 하는 순서가 무중단의 핵심이었습니다.
•
복잡한 가공 규칙은 이관 코드에 복제하지 말 것: 동일 상품이 다른 가격으로 중복 등록된 케이스를 처리하는 300줄짜리 BFF 로직을 다시 구현하는 대신, 그 로직을 이미 태우고 있는 조회 경로를 이관 파이프라인으로 재활용했습니다. 단, 사용자 트래픽과 같은 경로를 쓰게 되므로 배치 사이 쿨링이 필요하고, 그 API의 내부 가드까지 읽어야 커버리지에 구멍이 생기지 않습니다.
•
쓰기 구간은 빠짐없이: 단건 수정과 엑셀 대량 업로드 두 구간 모두에서 같은 이벤트를 발행해야 구멍이 생기지 않습니다.
•
적재와 노출의 분리: 데이터는 미리 채우되 노출 게이트는 마지막에 올리면, 이관 전 기간 동안 사용자 영향이 0이 됩니다.
•
급할 이유가 없으면 천천히: 정적이관은 7일에 걸쳐 돌렸습니다. 노출이 아직 v1이고 Dual Write가 돌고 있으면 이관 속도는 아무것도 압박하지 않으니, 하루 만에 끝내려다 운영 DB를 틀어지게 하는 것보다 훨씬 나은 선택이었습니다.
•
hot 테이블 DDL은 rewrite 여부부터 확인: STORED generated column 대신 IMMUTABLE 함수 + CREATE INDEX CONCURRENTLY로 온라인 적용이 가능합니다.
•
jitter는 어디에나: dedup TTL, 캐시 TTL 등 동시 만료가 herd를 만드는 곳에는 무조건 흩어놓기.
이 이관으로 딜 도메인은 완전히 v2로 통합됐고, MongoDB 시절에는 어려웠던 슬롯 기반 노출 같은 기능을 RDB 인덱스 위에서 안정적으로 구현할 수 있게 됐습니다.

