Skip to Content
← 블로그
개발기

그룹명 한 줄을 바꿨더니, 열흘치 결제 전문이 다시 나갔다

방아쇠를 범인으로 지목하면 총은 그대로 남는다

만화로 보는 요약 — 먼저 읽어보세요
만화로 보는 요약 — 먼저 읽어보세요

제가 운영하는 QR 결제 중계 플랫폼에는 결제 승인이 끝난 뒤 거래 내역을 외부 연동 대리점으로 실시간 전송하는 경로가 있습니다. Kafka 토픽에 거래번호가 실려 오면 컨슈머가 받아 전문을 만들고 TCP로 내보냅니다.

오전에 컨슈머 그룹명을 바꿔 배포했습니다. 설정 한 줄입니다. 그리고 열흘치 거래 전문이 다시 나갔습니다.

이 글은 그 사고를 추적하고, 방어 설계를 세 번 갈아엎고, 결국 한 줄도 반영하지 않기로 결정하기까지의 기록입니다.

결론부터 요약합니다.

  • 사고: 컨슈머 그룹명 변경 → 신규 그룹으로 인식 → auto.offset.reset=earliest 적용 → retention에 남아 있던 열흘치 전량 재소비 → 처리 완료된 전문이 외부로 재전송
  • 설계: DB 유니크 제약 기반 멱등 가드 → (신규 테이블 부담) → Redis 선점 가드 + 신규 그룹 되감기의 2계층으로 재설계
  • 결정: 구현하지 않기로 했습니다. 왜 그게 합리적인 결론이었는지가 이 글의 마지막 절입니다

그룹명 한 줄을 바꿨고, 열흘치가 다시 나갔다

로그는 명확했습니다. 초당 수십 건씩 같은 패턴이 반복됐습니다.

11:00:44 [실시간 거래 전송 수신] transactionNo=0039...4288 | ack for partition-1@773
11:00:44 [전송 성공 종료]
11:00:45 [실시간 거래 전송 수신] transactionNo=0039...6592 | ack for partition-1@774
11:00:45 [전송 성공 종료]

오프셋이 773, 774, 775로 연속해서 올라갑니다. 신규 유입이 아니라 처음부터 다시 읽고 있는 모습입니다. 설정을 열어 보니 원인은 한 줄에 있었습니다.

kafka:
  auto-offset-reset: earliest   # 전 토픽 공통
  topics:
    realtime-transaction:
      group-id: "realtime-transaction-group-${spring.application.name}"

Kafka 입장에서 컨슈머 그룹은 이름이 곧 정체성입니다. 이름을 바꾸면 커밋된 오프셋이 0건인 완전히 새로운 그룹입니다. 새 그룹에는 "어디서부터 읽을지" 알려줄 기준이 없으므로 auto.offset.reset이 발동하고, 값이 earliest였으니 retention에 남아 있는 가장 오래된 오프셋부터 읽습니다.

여기서 곁가지 하나가 눈에 들어옵니다. group-id가 ${spring.application.name}에 묶여 있습니다. 애플리케이션 이름만 바꿔도 그룹이 갈립니다. 그룹 정체성이 배포 산출물 이름에 묶여 있는 건 그 자체로 설계 결함입니다.

"그룹명 변경이 원인"에서 멈추면 총은 그대로 남는다

여기서 멈추고 싶은 유혹이 있었습니다. 원인이 명확하고, 고칠 곳도 한 줄이고, "다음부터 그룹명 바꿀 땐 오프셋을 먼저 옮기자"는 운영 규칙 하나면 정리되는 것처럼 보였으니까요.

그런데 질문을 하나 더 던져 봤습니다. 그룹명을 안 바꿨으면 이 사고는 안 났을까?

답은 "아니오"였습니다. Kafka는 at-least-once입니다. 중복 배달은 예외 상황이 아니라 계약 조건입니다. 그리고 이 경로에는 중복이 들어올 문이 이미 여러 개 열려 있었습니다.

중복 유입 경로규모빈도
컨슈머 그룹 변경수천 건드묾
리밸런싱 / 재기동1~2건상시
DLT 재처리 (전송 성공 후 후속 단계 실패)1건간헐
운영자 수동 재전송 API1건운영 판단

그리고 전송 경로 어디에도 "이 거래 이미 보냈나"를 확인하는 곳이 없었습니다. 리스너는 메시지를 받으면 곧장 전문을 만들어 TCP로 내보냈습니다.

그룹명 변경은 방아쇠였을 뿐 총은 원래 장전돼 있었습니다. 방아쇠에 안전장치를 다는 것으로는 나머지 세 개의 손가락을 막지 못합니다.

첫 번째 설계 — 전송하기 전에 자리를 선점한다

멱등 가드를 세우기로 하고 저장소를 DB로 골랐습니다. UNIQUE(거래번호, 전송유형) 제약을 건 전송 이력 테이블입니다.

기록을 언제 남기느냐가 첫 갈림길이었습니다.

(a) 전송에 성공한 뒤 기록한다. 직관적입니다. "보냈다"는 사실을 보낸 다음에 남기는 것이니까요.

(b) 전송하기 전에 자리를 선점하고, 결과가 나오면 상태를 갱신한다.

처음엔 (a)로 가려 했습니다. 그러다 프로듀서 코드를 열어 보고 방향을 바꿨습니다.

그런데 프로듀서가 메시지 키를 주지 않고 있었다

kafkaTemplate.send(topicName, dto);   // 키가 없다

키 없이 발행하면 파티셔너가 라운드로빈으로 파티션을 고릅니다. 즉 같은 거래번호가 재발행되면 이전과 다른 파티션에 실릴 수 있습니다. 파티션이 다르면 컨슈머 스레드도 다릅니다. 같은 거래를 두 스레드가 동시에 처리할 수 있다는 뜻입니다.

(a)에서 이 상황이 벌어지면 순서가 이렇게 됩니다.

유니크 제약이 위반을 잡아내긴 합니다. 그런데 그때는 이미 두 통이 나간 뒤입니다. 사후 감지는 감지지 방지가 아닙니다.

그래서 (b)로 갔습니다. 전송 전에 행을 INSERT해서 자리를 선점하고, 유니크 제약이 두 번째 스레드를 밀어냅니다. 핵심은 판정자가 애플리케이션이 아니라 DB 유니크 제약이라는 점입니다. 애플리케이션 조회는 "확인한 시점"과 "행동한 시점" 사이에 틈이 있지만, 유니크 제약에는 그 틈이 없습니다.

여기까지 구현하고 테스트까지 붙였습니다. 그리고 설계가 뒤집혔습니다.

두 번째 설계 — 제약이 설계를 되돌렸다

돌아온 피드백은 이랬습니다. "신규 테이블을 만드는 건 부담이다."

이 프로젝트는 망 분리 환경입니다. 인터넷 환경에서 작업한 것을 문서로 만들어 폐쇄망으로 옮기고, 거기서 구현합니다. 신규 테이블은 DDL 협의와 승인과 반영 절차를 통과해야 하고, 테이블이 하나 늘면 백업·정리·권한이 따라옵니다. 기술적으로 옳은 설계와 조직에서 실행 가능한 설계는 다릅니다.

여기에 조건이 하나 더 붙었습니다. 앞으로 하나의 거래를 여러 연동사에 실시간 전송하게 될 수 있다는 것이었습니다. 다행히 이건 구조를 바꾸지 않았습니다 — 연동사별로 이벤트를 나눠 발행하는 구조라면, 멱등 키에 연동사 코드를 넣는 것만으로 끝납니다. 부분 실패(A 성공 / B 실패)도 메시지 단위로 자연히 갈라집니다.

그래서 저장소를 다시 골라야 했습니다. 그리고 그 과정에서 제가 앞서 세운 근거가 틀렸다는 걸 알게 됐습니다.

내가 Redis를 배제한 근거는 틀렸다

첫 번째 설계 때 저는 Redis를 후보에서 뺐습니다. 근거는 이랬습니다.

TTL이 만료되면 가드도 함께 사라진다. 이번 사고는 열흘치가 밀려온 건이었는데, TTL을 며칠로 잡아야 안전한지 판단할 근거가 없다.

이 판단이 틀렸습니다.

가드가 커버해야 할 시간 범위는 무한이 아닙니다. Kafka retention 입니다. retention을 지난 메시지는 애초에 브로커에서 사라져 재배달될 수 없습니다. 그러니 TTL을 retention보다 길게 잡으면 논리적으로 빈틈이 없습니다. "며칠로 잡아야 할지 모르겠다"가 아니라 "retention보다 크게 잡으면 된다"는 명확한 기준이 있었는데, 그걸 못 보고 DB를 권했습니다.

그리고 Redis는 이미 이 프로젝트의 1급 인프라였습니다. 배포모듈 네 개가 전부 Redis 설정을 로드하고 있었고, 결제 상태 캐시로 실제로 쓰고 있었습니다. 새로 들일 것이 하나도 없었습니다.

SET key value NX EX 300 한 줄이 DB 유니크 제약과 동일한 원자성을 줍니다. 오히려 더 간단합니다.

세 번째 설계 — 되감기와 선점, 두 계층

여기서 한 가지를 더 깨달았습니다. 멱등 가드는 중복이 도착한 뒤에 작동합니다. 열흘치가 밀려오면 가드가 열흘치를 전부 걸러내긴 하지만, 그동안 컨슈머는 열흘치를 읽고 열흘치만큼 Redis를 두드립니다. 사고는 막지만 사고의 부하는 그대로 받습니다.

그래서 애초에 열흘치를 읽지 않게 하는 계층을 앞에 두기로 했습니다.

사고의 인과는 신규 그룹 → 커밋 이력 없음 → earliest 발동 입니다. 그런데 "커밋된 오프셋이 없다"는 사실은 앱이 직접 확인할 수 있습니다. 리밸런스 시점에 조회하면 됩니다.

var committed = consumer.committed(new HashSet<>(partitions));
boolean brandNewGroup = committed.values().stream().allMatch(Objects::isNull);

신규 그룹으로 판정되면 되감습니다. 처음엔 seekToEnd를 떠올렸는데, 다시 보니 위험했습니다. 컨슈머를 오래 내렸다가 새 그룹으로 올리면 그 사이 유입분이 통째로 사라집니다. 외부 전송은 누락되면 안 된다는 것이 이 작업의 첫 번째 원칙이었으니, 그건 자기모순입니다.

대신 시점 기준으로 되감기로 했습니다. offsetsForTimes로 "N시간 전 시각"의 오프셋을 찾아 거기로 seek합니다.

  • 열흘치가 밀려와도 최근 N시간분만 읽습니다 → 사고 차단
  • 짧은 다운타임 후 재기동이면 그 사이 유입분은 정상 처리됩니다 → 유실 방지

Before — 방어 없음

After — 2계층

팩토리를 공유하고 있다는 사실이 설계를 또 바꿨다

되감기를 어디에 붙일지 확인하다가 멈칫했습니다.

defaultKafkaListenerContainerFactory리스너 아홉 개가 공유하고 있었습니다. 실시간 거래·환불뿐 아니라 결제 결과 콜백, 앱 엔드포인트 콜백, 감사 로그, 통합거래까지 배포모듈 두 개에 걸쳐서요.

여기에 되감기를 붙였으면 결제 결과 콜백도 백로그를 건너뛰었을 겁니다. 결제 이벤트 유실입니다. 막으려던 것보다 훨씬 큰 사고를 만들 뻔했습니다.

그래서 전용 팩토리를 새로 만들고 실시간 거래·환불 본 리스너 두 개만 붙이는 것으로 정리했습니다. 기존 팩토리는 손대지 않습니다.

DLT 리스너에는 되감기를 붙이지 않기로 했습니다. DLT에 있는 메시지는 실패해서 재처리를 기다리는 거래입니다. 되감아 건너뛰면 그게 진짜 유실입니다.

선점에 실패하면 ACK 해도 되는가

설계 문서를 쓰다가 구멍을 하나 더 찾았습니다. 첫 번째 설계에도 있던 것입니다.

선점에 실패하면 "이미 누가 처리했다"는 뜻이니 ACK하고 넘어가면 될 것 같습니다. 그런데 이 순서를 보면 아닙니다.

선점자 A가 전송 전에 프로세스째 죽음
  → A의 메시지는 미커밋 → 리밸런싱 후 재배달
  → 재배달 시점이 선점 TTL 안이면 자기 선점에 막힘
  → 무조건 ACK 하면 그대로 유실

선점 상태를 만났다는 것은 "지금 누군가 처리 중"이거나 "처리하던 주체가 죽었다"인데, 둘을 구분할 방법이 없습니다. 구분할 수 없으면 판정하지 않는 게 맞습니다.

그래서 선점 실패를 두 갈래로 나눴습니다.

기존 값의미처리
전송 완료 / 종결이미 끝남ACK + 스킵 (진짜 중복)
선점 중 / 값 없음판정 불가예외 → DLT

DLT는 잠시 뒤 다시 보므로 그때는 완료 상태이거나 TTL이 만료돼 재선점됩니다. 판정을 미룰 뿐 버리지 않습니다.

에러 처리에서도 한 가지가 정리됐습니다. DB 설계에서는 경합이 예외로 표현돼서 그것만 골라 삼켜야 했는데, Redis SETNX에서는 경합이 예외가 아니라 반환값입니다. 그러니 삼킬 예외가 아예 없습니다. 남는 예외는 연결 실패·타임아웃뿐이고 그건 전부 진짜 장애이므로 그대로 던집니다.

여기서 지켜야 할 선은 이겁니다. Redis가 죽었을 때 "보내지 마라"를 반환하면 보내야 할 전문이 "이미 보냈다"로 위장돼 사라집니다. 조용한 유실보다 시끄러운 DLT 적재가 낫습니다.

곁가지 — 제가 과대평가했던 두 번째 결함

로그를 뒤지다 별개의 문제를 하나 더 찾았습니다. 미지원 대리점 메시지를 처리하는 분기에 acknowledge()가 없었습니다.

provider.get(agencyCode).ifPresentOrElse(
    service -> { /* ... 여기선 ACK 한다 ... */ },
    () -> log.warn("미지원 메시지 스킵")     // ← ACK 도 예외도 없다
);

AckMode가 MANUAL인데 ACK도 예외도 없이 빠져나갑니다. 처음엔 이걸 두고 **"그 오프셋은 영구 미커밋으로 남아 재기동마다 그 지점부터 다시 읽는다"**고 판단했습니다. 사고의 두 번째 원인이라고 봤습니다.

틀렸습니다. Spring Kafka 구현을 확인해 보니 이렇습니다.

// KafkaMessageListenerContainer#addOffset
this.offsets.computeIfAbsent(record.topic(), v -> new ConcurrentHashMap<>())
    .compute(record.partition(), (k, v) -> v == null ? record.offset() : Math.max(v, record.offset()));

Math.max입니다. 파티션별로 ACK된 오프셋의 최댓값을 취해 +1로 커밋합니다. 764를 건너뛰고 765를 ACK하면 커밋은 766으로 전진하고, 764는 그대로 지나갑니다. 영구 고착이 아닙니다.

정확한 영향은 더 좁습니다. ACK되지 않은 메시지가 파티션의 마지막 레코드로 남으면, 그 구간이 커밋되지 못한 채 재기동·리밸런싱마다 다시 배달됩니다. 미지원 대리점이라 실제 전송이 없어 눈에 띄지 않았을 뿐입니다.

고쳐야 할 결함인 건 같습니다. 다만 이번 열흘치 재처리의 원인은 아니었습니다. 원인이 두 개라고 믿고 회고를 썼으면 다음 사람이 엉뚱한 곳을 경계했을 겁니다.

그리고 한 줄도 반영하지 않았다

설계가 여기까지 왔을 때, 최종 결정은 구현하지 않는 것이었습니다.

처음엔 김이 빠졌습니다. 세 번을 갈아엎어 도달한 설계였으니까요. 그런데 근거를 하나씩 놓고 보니 반박하기 어려웠습니다.

첫째, 방아쇠가 희귀합니다. 컨슈머 그룹명을 바꿀 일은 사실상 없습니다. 이번은 예외적인 배포였습니다. 남는 중복 경로(리밸런싱·DLT)는 건당 1~2건 규모입니다.

둘째, 이 코드는 검증할 수 없습니다. 망 분리 환경이라 인터넷 쪽에서는 컴파일과 단위 테스트가 한계입니다. 동시 선점 경합이 실제로 걸러지는지, 리밸런스 시 seek이 의도대로 도는지는 개발망에 넣어봐야 압니다. 검증하지 못한 방어 장치를 결제 전송 경로에 넣는 것 자체가 위험입니다. 선점 TTL을 한 자리 잘못 잡으면 살아 있는 컨슈머의 선점을 빼앗아 이중 전송이 나고, 예외 처리를 한 군데 잘못하면 가드가 유실 장치로 돌변합니다. 이 글에서만 그런 구멍을 두 개 찾았습니다.

셋째, 그리고 이게 결정적인데 — 막으려는 사고와 도입하는 위험의 크기가 역전돼 있습니다. 막으려는 건 "거의 오지 않는 트리거로 인한 대량 중복"이고, 도입하는 건 "상시 돌아가는 결제 전송 경로에 검증되지 않은 분기 세 개"입니다.

그래서 설계는 문서로만 남기고 코드는 손대지 않기로 했습니다. 되돌리기 전에 만들어 뒀던 엔티티, 리포지토리, 가드, 테스트 열한 건, DDL을 전부 지웠습니다. 작업 트리는 사고 직전 상태로 돌아갔습니다.

설계를 실행하지 않는 것도 설계 결정입니다. 그리고 그 결정은 설계를 끝까지 해봐야 내릴 수 있습니다. 여기까지 오지 않았으면 "위험이 이익보다 크다"는 판단 자체가 불가능했을 테니까요.

남는 것

이번 일에서 오래 남을 건 세 가지입니다.

원인을 하나 찾으면 거기서 멈추고 싶어집니다. 설명이 되고, 고칠 곳이 명확하고, 보고할 문장이 만들어지니까요. 그런데 "그것만 아니었으면 안 났을까"를 한 번 더 물으면 대개 답이 달라집니다. 이번엔 그 질문이 설정 한 줄짜리 수정을 구조 검토로 바꿨습니다.

내가 세운 근거도 검증 대상입니다. Redis를 배제한 이유가 오판이었다는 걸, 저는 제약에 부딪혀 되돌아가고 나서야 알았습니다. 확신이 선 근거일수록 다시 안 봅니다. Spring의 ACK 커밋 동작을 잘못 안 것도 같은 자리에서 나왔습니다. 구현을 확인하는 습관이 없었으면 이 글에는 틀린 원인이 사실처럼 적혔을 겁니다.

그리고 안 하기로 하는 데도 근거가 필요합니다. "일단 두자"와 "위험이 이익보다 크니 두자"는 다릅니다. 앞의 것은 방치고 뒤의 것은 결정입니다. 후자로 남기려면 무엇을 안 하는지, 대신 무엇이 남아 있는지를 적어둬야 합니다. 이 글이 그 기록입니다.

남아 있는 것을 명시해 둡니다. 실시간 전송 경로에는 여전히 멱등 가드가 없습니다. 리밸런싱과 DLT 재처리로 인한 소량 중복은 그대로 발생합니다. 컨슈머 그룹명을 다시 바꾸면 같은 사고가 다시 납니다. 그때는 그룹명 변경 전에 오프셋을 먼저 옮기는 것으로 대응합니다. 그리고 근본적으로는 프로듀서에 메시지 키를 주는 것 — 거래번호를 키로 주면 같은 거래는 항상 같은 파티션으로 가고 동시 처리 자체가 사라집니다 — 이 남아 있지만, 기존 메시지의 파티션 분포가 바뀌는 변경이라 별도 과제로 미뤄뒀습니다.