정합성 : 중복, 유실, 값 드리프트
저번 포스팅에서는, 감당할 수 있는 양을 넘어설 때의 흐름 문제를 공부하였습니다. 이번에는 정합성입니다. 데이터가 중복되거나 빠지는 일이 왜 생기는지, 순서와 시간을 어떻게 다뤄야 하는지, 그리고 틀어진 것을 어떻게 찾아내는지를 정리하겠습니다.
개인적으로 이 편이 가장 어려웠습니다. 성능 문제는 느려질 뿐이지만, 정합성 문제는 틀린 값을 내놓고도 아무 일 없는 것처럼 보이기 때문입니다.
1. 왜 정확히 한 번이 어려운가
1.1. 응답이 없다는 것의 의미
A 가 B 에게 요청을 보냈는데 응답이 오지 않았습니다. A 는 무엇을 알 수 있을까요.
- 요청이 B 에 도달하지 못했을 수 있습니다.
- B 가 처리했는데 응답만 유실되었을 수 있습니다.
- B 가 처리 도중 죽었을 수 있습니다.
- B 는 멀쩡한데 그냥 느린 것일 수도 있습니다.
A 는 이 넷을 구분할 방법이 없습니다. 구현을 잘못해서가 아니라 원리적으로 불가능합니다. 이것을 두 장군 문제라고 부릅니다.
상황. 이 플랫폼에서 이것이 얼마나 자주 일어나는지 계산해보면 이렇습니다.
주문 API 초당 4,000 요청, 응답 유실 비율 0.01%
→ 초당 0.4건, 장중 6시간이면 약 8,600건
전 서비스 합산 (초당 3만) → 하루 약 26만 건
왜 위험한가. 이 26만 건 각각에 대해 재시도하면 중복, 안 하면 유실입니다. 개발 환경에서는 초당 20 요청이라 하루 17건이고 "어쩌다 그런 것" 으로 넘어갑니다. 규모는 확률을 바꾸지 않고 빈도를 바꿉니다. 하루 26만 건이면 그중 실제 중복 주문이 수십 건씩 생기고, 금융에서는 이 수십 건이 사고입니다.
1.2. 그래서 선택지는 둘뿐이다
상황. Kafka 로 초당 3만 건, 피크 18만 건을 보냅니다. 브로커 응답이 타임아웃 나는 비율이 0.1% 만 되어도 피크 한 시간 동안 약 65만 건이 "보냈는지 모르는" 상태가 됩니다. 이 65만 건을 어떻게 할지 정하는 것이 곧 아래 선택입니다.
- 최대 한 번 : 재시도하지 않습니다. 중복은 없지만 유실이 가능합니다.
- 최소 한 번 : 될 때까지 재시도합니다. 유실은 없지만 중복이 가능합니다.
- 정확히 한 번 : 둘 다 없습니다.
중복과 유실 중 하나를 고르는 것이지, 둘 다 피하는 방법은 없습니다.
1.3. 정확히 한 번의 정체
그럼 세 번째는 어떻게 가능할까요. 전달 자체를 정확히 한 번으로 만드는 것은 여전히 불가능합니다. 실제로 하는 일은 최소 한 번으로 전달하되, 중복된 것을 받는 쪽에서 걸러내는 것입니다. 전달이 아니라 결과가 한 번인 것처럼 보이게 만듭니다.
왜 이 구분이 중요한가. 범위를 오해하면 막았다고 생각한 것이 안 막힙니다. Kafka 의 트랜잭션은 "Kafka 에서 읽어 Kafka 에 쓰는" 구간을 원자적으로 묶어줍니다. 그런데 그 사이에서 외부 API 를 호출했다면 그것은 되돌려지지 않습니다. 트랜잭션이 롤백되어도 외부 시스템에는 이미 요청이 갔습니다.
어떻게 하나. "정확히 한 번" 을 쓰는 구간과 그 바깥을 구분하고, 바깥으로 나가는 호출은 2장의 멱등성으로 따로 처리합니다.
2. 멱등성
결국 실무의 해법은 여러 번 해도 결과가 같게 만드는 것입니다.
2.1. 자연히 멱등한 것과 아닌 것
상황. 주문 이벤트 하루 2억 건을 받아 재고를 깎습니다. 컨슈머가 재시작하면서 오프셋이 되감겨 같은 구간 40만 건을 다시 처리했습니다. "재고에서 1 뺀다" 로 짜여 있으면 재고가 40만 만큼 잘못 깎이고, "재고는 N 이어야 한다" 로 짜여 있으면 아무 일도 일어나지 않습니다.
잔액을 1000원으로 설정한다 → 몇 번을 해도 1000원. 멱등
잔액에서 1000원을 뺀다 → 할 때마다 줄어듦. 멱등 아님
덮어쓰기는 멱등이고 증감은 멱등이 아닙니다. 가능하면 "얼마를 더해라" 대신 "결과가 얼마여야 한다" 로 표현하는 것이 안전합니다.
2.2. 멱등 키
상황. 주문 API 가 타임아웃을 냈고 클라이언트가 재시도했는데, 첫 요청도 사실은 처리되어 있었습니다.
왜 위험한가. 같은 주문 두 건이 들어갑니다. 금액이 두 배로 나가고 원장과 체결이 어긋납니다. 그리고 주문 시스템은 아무 오류도 내지 않습니다. 두 요청 모두 정상 처리로 보입니다.
어떻게 푸나. 요청마다 고유한 키를 클라이언트가 만들어 함께 보냅니다.
POST /orders
Idempotency-Key: 7f3a9c2e-...
서버 그 키를 주문 테이블의 고유 제약으로 둠
같은 키가 다시 오면 제약에 걸려 기존 주문을 그대로 반환
두 가지를 꼭 지켜야 합니다.
- 키는 클라이언트가 만들어야 합니다. 서버가 만들면 재시도할 때마다 새 키가 생겨 의미가 없습니다.
- 키 기록과 실제 처리가 같은 트랜잭션 안에 있어야 합니다. 처리는 됐는데 키 기록이 실패하면 중복이 그대로 생깁니다.
2.3. 조건부 쓰기
상황. 동일한 주문 상태 변경 요청이 재시도로 두 번 들어옵니다. 멱등 키 저장소를 따로 두기엔 초당 3만 건에 대해 조회가 한 번 더 붙는 것이 부담이었습니다. 이미 레코드를 읽고 있다면 그 값 자체를 조건으로 쓰면 됩니다.
"지금 값이 X 일 때만 Y 로 바꿔라" 형태입니다. 앞서 정리한 쿠버네티스의 resourceVersion 이 정확히 이 방식이었습니다.
왜 안전해지나. 두 번째 시도가 조건에 걸려 실패하기 때문입니다. 이미 적용된 변경이 또 적용되지 않습니다. 별도의 키 저장소가 필요 없다는 것도 장점입니다.
한계. 현재 값을 알아야 하므로 읽고 나서 쓰는 구조여야 합니다. 그리고 경합이 심하면 재시도가 잦아집니다.
2.4. 중복 제거 창
스트림 처리에서 자주 쓰는 방식입니다. 최근 처리한 메시지의 ID 를 일정 기간 또는 개수만큼 들고 있다가 같은 것이 오면 버립니다.
한계와 위험. 창을 벗어난 중복은 걸러지지 않습니다. 무한정 들고 있을 수 없으니 어딘가에서 잘라야 하고 그 경계 밖은 포기하는 셈입니다. 그리고 창을 크게 잡으면 메모리를 그만큼 씁니다. 초당 3만 건에 10분 창이면 1,800만 개의 ID 를 들고 있어야 합니다.
어떻게 정하나. "얼마나 늦게까지 중복이 올 수 있는가" 를 근거로 정합니다. 재시도 정책의 최대 대기 시간보다 길게 잡습니다.
2.5. 저장 단계에서 흡수하기
상황. Kafka 에서 Flink 로, Flink 에서 S3 로, S3 에서 Redshift 로 가는 네 단계 경로입니다. 단계마다 중복 제거를 넣으면 상태 저장소가 네 개 늘고, 하루 2억 건에 대해 각각 TTL 과 크기를 관리해야 합니다.
가장 실용적인 방법일 때가 많습니다. 최종 저장소가 멱등하면 중간이 중복돼도 상관없습니다.
- 기본 키를 기준으로 upsert 합니다.
- 키가 있으면 무시하는 삽입을 씁니다.
- 앞서 Redshift 에서 정리한 대로 스테이징 테이블에 모은 뒤 한 번에 병합합니다.
"중복을 막는다" 가 아니라 "중복이 와도 결과가 같다" 로 문제를 옮기는 것입니다. 중간 단계마다 중복 제거를 넣는 것보다 훨씬 단순하고, 새는 구멍도 적습니다.
3. 이중 쓰기 문제
3.1. 두 곳에 쓰면 반드시 틀어진다
상황. 주문 서비스가 Aurora 에 저장한 뒤 Kafka 로 이벤트를 발행합니다.
db.save(order); // 1
kafka.send(orderEvent); // 2
두 호출 사이의 구간 약 8ms
하루 주문 1,100만 건 → 초당 약 127건
Pod 종료 횟수 20 Pod x 하루 배포 3회 = 60회
종료 시점에 그 구간에 있을 확률
127건/초 x 0.008초 = 종료 1회당 약 1건
하루 60회 → 실제 이벤트 유실 하루 3~5건
왜 위험한가. DB 에는 주문이 있는데 이벤트가 없으므로 웨어하우스에 안 들어갑니다. 다음 날 대사에서 1,100만 건 대 1,099만 9,997건으로 0.00003% 차이가 납니다. 비율로는 무시할 만해 보이는데 그 3건이 실제 고객의 주문입니다.
그리고 작을 때는 안 보입니다. 하루 주문이 1만 건이고 배포가 주 1회라면 몇 달에 한 번 1건이라 대사를 돌려도 대부분의 날에 0 이 나와 문제가 없다고 결론 내리게 됩니다.
어떻게 푸나. 순서를 바꿔도 반대 문제가 생깁니다. 분산 트랜잭션은 코디네이터가 죽으면 참여자들이 락을 쥔 채 멈추는 문제가 있어 잘 쓰지 않습니다. 3.2 의 아웃박스가 실무의 답입니다.
3.2. 아웃박스 패턴
상황. 주문을 DB 에 저장하고 Kafka 로 이벤트를 보내는 코드입니다. DB 커밋은 성공했는데 Kafka 전송 직전에 인스턴스가 내려가면 주문은 있는데 이벤트는 없는 상태가 됩니다. 하루 2억 건에 배포가 잦은 서비스라면 이 틈이 매일 걸립니다.
같은 데이터베이스 안에 이벤트도 함께 저장합니다.
BEGIN;
INSERT INTO orders (...);
INSERT INTO outbox (event_type, payload);
COMMIT;
둘이 하나의 로컬 트랜잭션이므로 함께 성공하거나 함께 실패합니다. 그리고 별도 프로세스가 outbox 를 읽어 Kafka 로 보냅니다.
왜 이것으로 충분한가. 발행 과정은 최소 한 번이라 중복이 생길 수 있습니다. 하지만 2장의 방법으로 소비 쪽에서 흡수하면 됩니다. 유실은 구조적으로 막고 중복은 소비 쪽에서 흡수하는 형태입니다. 앞서 본 대로 중복과 유실 중 하나를 골라야 한다면, 막기 쉬운 쪽을 남기는 선택입니다.
대가. 테이블이 하나 늘고 아웃박스를 읽어 보내는 프로세스를 운영해야 합니다. 그리고 아웃박스 테이블이 계속 커지므로 처리된 행을 정리하는 작업이 필요합니다.
3.3. CDC 로 대신하기
상황. 아웃박스 테이블을 읽어 보내는 프로세스를 따로 운영하기가 부담스러운 상황입니다. 이 플랫폼에는 이미 DMS 가 운영 DB 를 보고 있으니, 같은 경로를 재사용할 수 있습니다.
아웃박스를 따로 읽는 대신 트랜잭션 로그를 읽는 방식을 쓸 수도 있습니다. 이 플랫폼에는 이미 DMS 가 있으므로 아웃박스 테이블만 CDC 로 읽어 Kafka 로 보낼 수 있습니다.
장점. DB 에 커밋된 것만 로그에 남으므로 커밋되지 않은 것이 새어나갈 일이 없습니다. 그리고 애플리케이션이 발행을 신경 쓰지 않아도 됩니다.
단점과 주의. 테이블 전체를 CDC 로 읽으면 DB 스키마가 곧 이벤트 형태가 되어 내부 구조가 밖으로 드러납니다. 그래서 아웃박스 테이블만 읽는 절충안을 많이 씁니다. 그리고 앞서 정리한 대로 복제 슬롯을 정리하지 않으면 WAL 이 쌓여 운영 DB 디스크가 찹니다.
4. 시간과 순서
4.1. 두 개의 시간
- 이벤트 시간 : 그 일이 실제로 일어난 시각. 데이터 안에 들어 있습니다.
- 처리 시간 : 시스템이 그것을 처리한 시각.
왜 구분해야 하나. 처리 시간 기준으로 집계하면 장애로 3시간 밀린 날의 통계가 통째로 틀어집니다. 그리고 재처리를 하면 결과가 또 달라집니다. 같은 입력에 같은 결과가 나오려면 이벤트 시간으로 계산해야 합니다.
증권에서는 "9시 1분 거래대금" 이 9시 4분에 잡히면 그냥 틀린 값입니다.
4.2. 그럼 언제 마감하는가
상황. 5분 단위 집계를 돌리는데 모바일 클라이언트가 오프라인이었다가 붙으면서 40분 늦은 이벤트가 하루 300만 건쯤 들어옵니다. 3시 창을 3시 5분에 닫으면 이 300만 건이 전부 빠지고, 40분을 기다리면 대시보드가 40분 늦어집니다.
이벤트 시간을 쓰기로 하면 새로운 문제가 생깁니다. 3시 데이터가 다 왔는지 어떻게 아는가 입니다. 여기서 워터마크가 나옵니다. "이 시각 이전의 데이터는 대체로 다 왔다고 보겠다" 는 선언입니다.
무엇을 맞바꾸나. 완결성과 지연입니다. 워터마크를 늦게 올리면 정확하지만 결과가 늦고, 빨리 올리면 빠르지만 늦게 온 것을 놓칩니다. 실시간 대시보드라면 빠른 쪽이, 마감 집계라면 정확한 쪽이 맞습니다. 같은 데이터로 두 경로를 따로 두는 구성도 씁니다.
4.3. 늦게 온 데이터
상황. Flink 가 1분 단위로 종목별 거래대금을 집계하고, 워터마크 지연 허용이 10초입니다.
하루 체결 8.6억 건
10초 넘겨 도착하는 비율 0.03% → 하루 약 26만 건
그중 1분 이상 늦은 것 약 3만 건
버릴 경우 거래대금 차이 하루 12조 기준 약 36억
왜 위험한가. 비율로는 0.03% 인데 절대값으로는 36억입니다. 그리고 이 값은 집계에서 조용히 빠지므로 어디서도 오류가 나지 않습니다. 작을 때는 하루 30건에 수십만 원 차이라 반올림 오차처럼 보입니다.
어떻게 푸나. 증권 데이터라면 버리는 선택지가 없습니다.
- 워터마크를 넘긴 것은 별도 경로로 모읍니다.
- 장 마감 후 정정 배치에서 반영합니다.
- 버려진 건수와 금액을 지표로 남깁니다. 평소 26만 건인데 어느 날 200만 건이 되면 상류에 문제가 생긴 것입니다.
세 번째가 없으면 늦게 오는 비율이 늘어도 아무도 모릅니다.
4.4. 순서 보장의 범위
Kafka 는 파티션 안에서만 순서를 보장합니다. 그래서 같은 주문의 이벤트가 순서대로 처리되어야 한다면 그 주문 ID 를 파티션 키로 써야 합니다.
여기서 앞 편의 쏠림과 만납니다. 순서를 보장하려고 키를 고정하면 그 키로 쏠립니다. 순서와 분산이 서로를 밀어냅니다.
어떻게 푸나. "정말 전역 순서가 필요한가" 를 먼저 묻습니다. 대개는 특정 단위 안에서의 순서만 필요합니다.
전역 순서 필요 파티션 1개 → 병렬도 1 → 초당 8,000건이 상한
계좌 단위 순서 계좌 ID 로 파티셔닝 → 병렬도 48 → 상한 38만건
요구를 좁히는 것만으로 처리 능력이 48배가 됩니다. 그리고 계좌 단위로도 쏠린다면 앞 편의 솔팅을 적용하되 순서가 필요한 처리만 원래 키로 묶어 처리합니다.
5. 틀어진 것을 어떻게 찾는가
지금까지가 예방이라면, 이번 장은 이미 틀어진 것을 발견하는 방법입니다.
5.1. 조용히 실패하는 것들
상황. DMS 로 Aurora 의 주문 테이블을 Redshift 로 복제하는데, 메모 컬럼의 LOB 상한을 32KB 로 두었습니다.
하루 주문 1,100만 건
메모 길이 분포 p50 180바이트, p99 2.1KB, p99.99 41KB
32KB 초과 하루 약 90건 (0.0008%)
→ 90건의 뒷부분이 오류 없이 잘림
왜 위험한가. DMS 작업은 초록색이고 지연도 0 입니다. 몇 달 뒤 감사에서 발견됩니다. 그리고 그때는 원본 로그도 이미 지워져 복구할 수 없습니다. 같은 성격의 것들입니다.
- 적용에 실패한 행이 예외 테이블에 쌓이는데 작업 상태는 정상입니다.
- 타입 변환에서 정밀도가 깎이는데 아무 경고가 없습니다.
- 늦게 온 데이터가 버려지는데 로그에 남지 않습니다.
작업이 성공했다는 것과 데이터가 맞다는 것은 다른 이야기입니다.
어떻게 막나. 적재 전에 실제 분포를 확인하고 상한을 정합니다. 위 경우라면 p99.99 가 41KB 이므로 64KB 로 잡거나 인라인 모드로 큰 것만 따로 처리합니다. 그리고 대사에 문자열 길이의 최댓값 비교를 넣어 타깃 최댓값이 상한에 딱 붙어 있으면 잘린 것으로 판단합니다.
5.2. 대사
금융에서 오래 써온 방법입니다. 두 곳의 값을 맞춰보는 것입니다.
- 건수 대조 : 가장 기본입니다.
- 합계 대조 : 건수가 같아도 값이 다를 수 있습니다.
- 구간별 대조 : 전체 합만 보면 상쇄되어 안 보일 수 있습니다.
- 경계값 대조 : 최솟값과 최댓값을 비교하면 시간대 문제나 잘림을 잡아냅니다.
왜 구간별이 중요한가. 실제로 찾아 들어가는 과정을 보면 분명합니다.
원장 12조 4,821억 3,400만
웨어하우스 12조 4,817억 6,100만
차이 3억 7,300만 (0.003%)
1단계 30분 단위 12구간 → 15:00~15:30 에서만 차이
2단계 1분 단위 → 15:21~15:23
3단계 브로커별 → 특정 채널
4단계 그 시각 인프라 이벤트 → Kafka 브로커 재시작, 동기화 복제본 3→1
전체 합만 있으면 "어딘가 틀어졌다" 까지만 알 수 있습니다. 8.6억 건에서 눈으로 훑어 찾는 것은 불가능합니다.
어떻게 하나. 대사를 전체 합계 하나로 두지 말고 처음부터 구간별로 저장합니다. 시간대 × 채널 단위로 건수와 금액을 남겨두면 차이가 났을 때 즉시 어느 칸인지 드러납니다. 그리고 자동으로 돌리고 틀어지면 알림이 오게 만듭니다. 사람이 가끔 확인하는 방식으로는 반드시 놓칩니다.
5.3. 재처리할 수 있게 만들어두기
상황. 집계 로직에 버그가 있었다는 것을 6개월 뒤에 발견했습니다. 고쳐야 할 범위가 Iceberg 테이블 42TB 중 180일치이고, 그동안 이 테이블을 읽어 만든 하위 테이블이 일곱 개 더 있습니다.
틀어진 것을 찾아도 고칠 수 없으면 소용이 없습니다.
- 원본을 보존합니다. 가공 결과만 남기면 되돌릴 수 없습니다.
- 처리가 멱등해야 합니다. 같은 구간을 다시 돌려도 중복이 생기지 않아야 합니다.
- 구간 단위로 나눠 돌릴 수 있어야 합니다. "3월 2일만" 다시 돌릴 수 있어야 합니다.
왜 세 번째가 중요한가. 날짜로 파티션을 나눠두면 그 파티션만 지우고 다시 쓰면 되므로 재처리가 안전하고 간단합니다. 전체를 다시 돌려야 한다면 6개월치 재처리가 며칠짜리 작업이 되고 그동안 정규 배치와 자원을 다툽니다.
언제 정해야 하나. 이 세 가지는 처음 설계할 때 정해집니다. 급해진 뒤에 붙일 수 없습니다.
5.4. 세어야 할 것들
상황. 하루 2억 건을 처리하면서 "몇 건이 버려졌는가" 를 세고 있지 않았습니다. 스키마가 바뀐 뒤 파싱에 실패한 이벤트가 하루 40만 건씩 조용히 버려지고 있었는데 성능 지표는 전부 정상이었습니다.
정합성 지표는 성능 지표만큼 챙기지 않는 경우가 많은데 최소한 이 정도는 있어야 합니다.
- 버려진 건수 : 늦게 와서, 형식이 안 맞아서, 중복이라서
- 중복 제거된 건수
- 대사 불일치 건수와 금액
- 예외 테이블의 행 수
왜 두 번째가 유용한가. 중복이 평소보다 많아졌다는 것은 어딘가에서 재시도가 늘고 있다는 신호이고 앞 편에서 본 재시도 폭풍의 전조일 수 있습니다. 정합성 지표가 성능 문제를 먼저 알려주는 경우입니다.
결론
정합성을 정리하면서 가장 크게 와닿은 것은, 중복과 유실 중 하나를 골라야 한다는 사실이었습니다. 응답이 없을 때 처리가 됐는지 안 됐는지 알 수 없다는 것이 원리적인 한계라는 것을 알고 나니 "정확히 한 번" 이 실제로는 중복을 허용하고 결과에서 지우는 것이라는 구조가 이해되었습니다. 이 구분을 하고 나서야 어떤 시스템이 무엇을 보장하는지를 제대로 읽을 수 있게 되었습니다.
두 번째는 이중 쓰기가 얼마나 흔한 구조인지였습니다. DB 에 저장하고 이벤트를 발행하는 코드는 어디에나 있는데 그 두 줄 사이에 틈이 있다는 것을 평소에는 의식하지 않습니다. 아웃박스 패턴이 그 틈을 하나의 로컬 트랜잭션으로 끌어들여 없앤다는 발상이 깔끔했습니다.
세 번째는 순서와 분산이 서로를 밀어낸다는 점이었습니다. 순서를 지키려면 같은 키를 같은 곳에 보내야 하고 그러면 앞 편에서 본 쏠림이 생깁니다. 두 요구가 정면으로 부딪히는데 결국 "어느 범위에서 순서가 필요한가" 를 좁히는 것이 해법이었습니다. 전역 순서가 필요하다고 믿었던 요구가 실제로는 계좌 단위 순서였던 경우가 대부분이라는 이야기가 인상 깊었습니다.
마지막으로, 조용히 실패하는 것이 가장 무섭다는 것을 다시 확인했습니다. 성능 문제는 느려져서라도 알려주는데 정합성 문제는 아무 신호 없이 틀린 답을 내놓습니다. 그래서 틀어졌는지 확인하는 절차를 따로 만들어두는 것이 선택이 아니라 필수라는 결론에 이르렀습니다.
공부 참고 자료
📑 3. Exactly-once Semantics are Possible: Here's How Kafka Does it
📑 4. Transactional Outbox Pattern
📑 5. Flink - Event Time and Watermarks