실수 케이스 · 7
재처리했더니 결제가 두 번 됐다
누락된 4시간분을 복구하려고 오프셋을 되돌렸습니다. 결과는 이중 청구 3,180건이었습니다. 팀은 "우리는 exactly-once를 켰는데요"라고 했습니다. 맞습니다 — 켰습니다. 다만 EOS의 경계는 Kafka 토픽까지이고, 외부 결제 API는 그 밖에 있었습니다.
이 케이스에서 얻어 갈 것
- Kafka EOS가 성립하는 정확한 경계를 한 문장으로 말할 수 있습니다.
- Kafka의 기본 전달 보장이 at-least-once이며, 그것이 무엇을 요구하는지 압니다.
- 멱등 프로듀서 · 트랜잭션 · 컨슈머 멱등성의 역할이 서로 다르다는 것을 구분할 수 있습니다.
- 외부 시스템에 대한 멱등성을 실제로 구현하는 세 가지 패턴을 압니다.
상황
결제 확정 파이프라인입니다. payment-approved 토픽은
파티션 12개, 복제 계수 3, 브로커 3대에서 일 40만 건을 받습니다.
컨슈머는 이벤트를 받아 PG사 결제 확정 API를 호출하고,
응답을 payment-settled 토픽에 발행합니다.
아키텍처는 Kafka Streams 기반이고 processing.guarantee=exactly_once_v2가 설정되어 있었습니다.
팀은 이 설정 덕분에 "중복 걱정은 없다"고 이해하고 있었습니다.
application.id=payment-settlement
processing.guarantee=exactly_once_v2
# Streams 가 내부적으로 트랜잭션을 쓰고, 컨슈머는 read_committed 로 동작합니다
public void process(Record<String, PaymentApproved> record) {
PaymentApproved approved = record.value();
// ★ 외부 호출. Kafka 트랜잭션 바깥입니다.
PgResult result = pgClient.confirmPayment(
approved.getOrderId(),
approved.getAmount()); // 멱등 키 없음
context().forward(record.withValue(toSettled(result)));
}
금요일 04:00~08:00 사이 업스트림 장애로 payment-approved 소비가 멈췄고,
복구 과정에서 오프셋이 잘못 전진해 68,000건이 처리되지 않은 채 건너뛰어졌습니다
(케이스 1과 같은 유형).
11:00, 팀은 누락을 복구하기로 결정했습니다.
--reset-offsets --to-datetime 2026-07-24T04:00:00.000 --execute로 4시간분을 되돌렸습니다.
그런데 그 4시간 안에는 이미 정상 처리된 3,180건도 포함되어 있었습니다.
재처리가 시작되자 PG사 결제 확정 API가 그 3,180건에 대해 두 번째로 호출됐습니다.
13:40에 PG사에서 "동일 주문 중복 승인 요청" 알림을 받았습니다. 환불 처리와 고객 안내에 4일이 걸렸습니다.
관측된 증상
메트릭이 어떻게 보였는가
- Kafka 지표 전부 정상. lag이 68,000에서 0으로 정상 감소, 에러율 0, 리밸런스 없음.
payment-settled토픽 유입: 재처리 구간에서 정상보다 높음. 이것이 유일한 Kafka 쪽 단서였습니다.- PG API 호출 수: 11:00~11:40 사이 평소의 4.2배.
- PG사 중복 승인 알림: 3,180건. 이것이 사고 인지 경로였습니다.
출력 토픽에서 본 증거
payment-settled에는 트랜잭션 덕분에 중복 레코드가 없지만,
PG 승인 번호가 다른 두 개의 레코드가 같은 주문에 대해 존재했습니다.
같은 주문을 두 번 승인받았다는 뜻입니다.
$ kafka-console-consumer.sh --bootstrap-server kafka-1:9092 \
--topic payment-settled --from-beginning --timeout-ms 60000 \
--isolation-level read_committed \
--property print.partition=true --property print.offset=true \
--property print.key=true | grep ORD-77412
Partition:5 Offset:1180224 ORD-77412 {"orderId":"ORD-77412","pgApprovalNo":"A-8812043","amount":34900}
Partition:5 Offset:1204881 ORD-77412 {"orderId":"ORD-77412","pgApprovalNo":"A-9014772","amount":34900}
원인 분석
1단계 — 세 가지 전달 보장을 정확히 구분한다
| 보장 | 정의 | 만드는 방법 |
|---|---|---|
| at-most-once | 메시지가 유실될 수 있지만 재전달되지 않음 | 프로듀서 재시도를 끄고, 컨슈머는 처리 전에 오프셋을 커밋 |
| at-least-once Kafka 기본 |
메시지가 유실되지 않지만 재전달될 수 있음 | 컨슈머가 처리 후에 오프셋을 커밋. 별도 설정이 필요 없는 기본 동작 |
| exactly-once | 각 메시지가 정확히 한 번 처리됨 | Kafka 토픽 → Kafka 토픽 경계에서 트랜잭션 사용. 외부 시스템은 별도 협력 필요 |
2단계 — EOS가 실제로 무엇을 원자적으로 묶는가
Kafka 트랜잭션의 특이한 점은 프로듀서만 트랜잭션 주체라는 것입니다. 컨슈머는 트랜잭션에 참여하지 않고, 대신 프로듀서가 컨슈머의 커밋 오프셋까지 트랜잭션 안에서 갱신합니다.
producer.initTransactions(); // transactional.id 등록, 이전 인스턴스의 트랜잭션 abort
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
if (records.isEmpty()) continue;
producer.beginTransaction();
try {
for (ConsumerRecord<String, String> r : records) {
// ① 출력 토픽 쓰기 — 트랜잭션 안
producer.send(new ProducerRecord<>("output", r.key(), transform(r.value())));
}
// ② 입력 오프셋 커밋 — 트랜잭션 안
producer.sendOffsetsToTransaction(offsetsOf(records), consumer.groupMetadata());
producer.commitTransaction(); // ①과 ②가 함께 성공하거나 함께 실패
} catch (AbortableException e) {
producer.abortTransaction();
// ★ 중요: abort 후에는 컨슈머 위치를 명시적으로 되돌려야 합니다.
// 컨슈머는 자동으로 rewind 하지 않습니다.
resetConsumerPosition(consumer, records);
}
}
| 대상 | 원자성 보장 | 비고 |
|---|---|---|
| 출력 Kafka 토픽 쓰기 | 보장 | 여러 토픽·파티션에 걸쳐도 원자적 |
| 입력 오프셋 커밋 | 보장 | sendOffsetsToTransaction으로 같은 트랜잭션에 포함 |
| 외부 DB 쓰기 | 보장 없음 | Kafka 트랜잭션과 분리된 트랜잭션 |
| 외부 HTTP·gRPC 호출 | 보장 없음 | abort 해도 호출은 취소되지 않음 |
| 이메일·푸시 발송 | 보장 없음 | 되돌릴 수단이 아예 없음 |
| 파일·오브젝트 스토리지 | 보장 없음 | Connect처럼 오프셋을 출력과 같은 곳에 저장하는 패턴이 필요 |
3단계 — 멱등 프로듀서 · 트랜잭션 · 컨슈머 멱등성은 다른 것이다
| 기능 | 해결하는 문제 | 범위 | 설정 |
|---|---|---|---|
| 멱등 프로듀서 | 프로듀서 재시도로 인한 중복 append | 파티션 단위, 하나의 프로듀서 세션 | enable.idempotence=true (4.x 기본값) |
| 트랜잭션 | 여러 파티션 쓰기 + 오프셋 커밋의 원자성 | Kafka 내부. 여러 토픽·파티션에 걸침 | transactional.id (프로듀서 전용), 컨슈머는 isolation.level=read_committed + enable.auto.commit=false |
| 컨슈머 멱등성 | 외부 시스템에 대한 중복 부작용 | 애플리케이션이 직접 구현 | Kafka 설정이 아님. 코드로 만들어야 함 |
4단계 — abort 는 외부 호출을 되돌리지 않는다
4.x에서는 트랜잭션 프로듀서의 예외 처리가 카테고리로 표준화되었습니다. 공식 문서가 정의하는 6개 범주입니다.
| 범주 | 애플리케이션까지 올라오는가 | 필요한 처리 |
|---|---|---|
RetriableException | 아니요 | 클라이언트가 자동 재시도 |
RefreshRetriableException | 아니요 | 메타데이터 갱신 후 클라이언트가 재시도 |
AbortableException | 예 | 트랜잭션 abort + 컨슈머 위치 재설정 후 재처리 |
ApplicationRecoverableException | 예 | 애플리케이션이 복구 전략 구현. 프로듀서 재시작 포함 |
InvalidConfigurationException | 예 | 설정 수정. 프로듀서 재시작은 필수 아님 |
KafkaException | 예 | 위 범주에 속하지 않는 일반 예외 |
중요한 것은 AbortableException을 만나 abort 했을 때
"그 트랜잭션 안에서 이미 실행한 외부 호출"은 그대로 남는다는 점입니다.
공식 문서도 abort 후에는 애플리케이션 상태와 컨슈머 위치를 명시적으로 재설정해
재처리해야 한다고 명시합니다. 재처리하면 외부 호출은 다시 일어납니다.
전달 보장과 트랜잭션의 전체 그림은 6장 전달 보장과 트랜잭션에서, 동작하는 파이프라인은 예제 5 · Exactly-Once 파이프라인에서 다룹니다.
재현 방법
단일 노드 KRaft 클러스터로 재현합니다. 아래 compose는 Apache Kafka의 공식 단일 노드 예제입니다 (3노드 구성은 예제 1). 트랜잭션을 쓰므로 내부 토픽 복제 계수를 1로 낮춰 둡니다.
services:
broker:
image: apache/kafka:4.3.1
hostname: broker
container_name: broker
ports:
- '9092:9092'
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: 'broker,controller'
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
KAFKA_LISTENERS: 'CONTROLLER://:29093,PLAINTEXT://:19092,PLAINTEXT_HOST://:9092'
KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://broker:19092,PLAINTEXT_HOST://localhost:9092'
KAFKA_CONTROLLER_QUORUM_VOTERS: '1@broker:29093'
KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT'
CLUSTER_ID: '4L6g3nShT-eMCtK--X86sw'
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.nio.file.*;
import java.time.Duration;
import java.util.List;
import java.util.Properties;
public class SideEffectConsumer {
public static void main(String[] args) throws Exception {
Path ledger = Path.of("external-ledger.txt"); // "외부 시스템" 역할
Properties p = new Properties();
p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
p.put(ConsumerConfig.GROUP_ID_CONFIG, "settler");
p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
p.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed");
try (KafkaConsumer<String, String> c = new KafkaConsumer<>(p)) {
c.subscribe(List.of("payment-approved"));
while (true) {
ConsumerRecords<String, String> records = c.poll(Duration.ofSeconds(1));
for (ConsumerRecord<String, String> r : records) {
// ★ 멱등성 없는 외부 부작용
Files.writeString(ledger, "CHARGE " + r.key() + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
if (!records.isEmpty()) c.commitSync();
}
}
}
}
docker compose up -d
K=/opt/kafka/bin
# 1. 토픽 생성
docker exec -it broker $K/kafka-topics.sh --create --topic payment-approved \
--partitions 1 --replication-factor 1 --bootstrap-server localhost:9092
# 2. 결제 승인 이벤트 5건 (키 = 주문 ID)
for i in 1 2 3 4 5; do
echo "ORD-$i:{\"orderId\":\"ORD-$i\",\"amount\":10000}" | \
docker exec -i broker $K/kafka-console-producer.sh --topic payment-approved \
--bootstrap-server localhost:9092 \
--property parse.key=true --property key.separator=:
done
# 3. 컨슈머를 돌려 전부 처리한다
java -cp kafka-clients-4.3.1.jar:. SideEffectConsumer &
sleep 10 && kill %1
wc -l external-ledger.txt # → 5
# 4. 오프셋을 되돌린다 (그룹이 Empty 여야 합니다)
docker exec -it broker $K/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--group settler --topic payment-approved --reset-offsets --to-earliest --execute
# 5. 다시 돌린다 → 외부 원장에 같은 청구가 또 들어갑니다
java -cp kafka-clients-4.3.1.jar:. SideEffectConsumer &
sleep 10 && kill %1
wc -l external-ledger.txt # → 10 ★ 이중 청구
sort external-ledger.txt | uniq -c
# 2 CHARGE ORD-1
# 2 CHARGE ORD-2
# ...
해결
즉시 조치 — 되돌리기 전에 범위를 좁힌다
멱등성 구현은 배포가 필요합니다. 그 전에 재처리를 해야 한다면 "이미 처리한 것을 다시 처리하지 않도록" 범위를 좁히는 것이 유일한 방어입니다.
# 1. 외부 시스템에서 "처리 완료된 키 목록"을 먼저 확보한다
psql -c "\copy (SELECT order_id FROM pg_confirmations
WHERE created_at BETWEEN '2026-07-24 04:00' AND '2026-07-24 08:00')
TO 'processed.csv' CSV"
# 2. 전체 구간을 되돌리는 대신, 별도 그룹으로 재처리 전용 컨슈머를 띄운다.
# 그 컨슈머가 processed.csv 를 읽어 이미 처리된 키를 건너뛰게 만듭니다.
kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
--group settler-replay-20260724 --topic payment-approved \
--reset-offsets --to-datetime 2026-07-24T04:00:00.000 --execute
# 3. 재처리 컨슈머는 반드시 dry-run 모드를 먼저 지원해야 합니다.
# "몇 건이 실제로 외부 호출 대상인가"를 사람이 확인한 뒤 실행합니다.
근본 해결 1 — 외부 API의 멱등 키를 쓴다
가장 깨끗한 방법입니다. 대부분의 결제·정산 API는 멱등 키(idempotency key)를 지원합니다. Kafka 레코드에서 안정적으로 파생되는 값을 키로 써야 합니다.
같은 주문에 대해 호출할 때마다 PG사는 새 승인 요청으로 처리합니다. 재처리는 곧 이중 청구입니다.
PgResult result = pgClient.confirmPayment(
approved.getOrderId(),
approved.getAmount());
멱등 키를 전달하면 PG사가 두 번째 요청에 첫 번째 결과를 그대로 반환합니다. 몇 번 재처리해도 승인은 한 번입니다.
// 레코드에서 결정적으로 파생되는 키를 씁니다.
// UUID.randomUUID() 는 절대 쓰지 마세요 — 재처리 때 값이 달라집니다.
String idempotencyKey = "payment-approved:" + approved.getOrderId()
+ ":" + approved.getApprovalVersion();
PgResult result = pgClient.confirmPayment(
approved.getOrderId(),
approved.getAmount(),
idempotencyKey);
근본 해결 2 — 처리 이력 테이블 (외부 API가 멱등 키를 지원하지 않을 때)
공식 문서가 제시하는 방향, 즉 "오프셋(또는 처리 이력)을 출력과 같은 장소에 저장"하는 패턴입니다. DB 트랜잭션 하나로 "부작용 기록"과 "처리 표시"를 묶습니다.
CREATE TABLE processed_events (
event_key TEXT PRIMARY KEY, -- 예: 'payment-approved:ORD-77412:1'
topic TEXT NOT NULL,
partition_id INT NOT NULL,
"offset" BIGINT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
result JSONB
);
void handle(ConsumerRecord<String, PaymentApproved> record) {
String eventKey = eventKeyOf(record); // 비즈니스 식별자 기반
// ① 선점: 이미 처리했으면 여기서 끝난다 (0 rows affected)
int claimed = jdbc.update(
"INSERT INTO processed_events (event_key, topic, partition_id, \"offset\") " +
"VALUES (?,?,?,?) ON CONFLICT (event_key) DO NOTHING",
eventKey, record.topic(), record.partition(), record.offset());
if (claimed == 0) {
metrics.counter("settlement.skipped_duplicate").increment();
return; // ★ 외부 호출을 하지 않습니다
}
// ② 외부 호출
PgResult result = pgClient.confirmPayment(...);
// ③ 결과 기록
jdbc.update("UPDATE processed_events SET result = ?::jsonb WHERE event_key = ?",
toJson(result), eventKey);
}
근본 해결 3 — 부작용을 경계 밖으로 밀어낸다
구조적으로 가장 견고한 방법은 외부 호출을 파이프라인에서 분리하는 것입니다. Kafka 안에서는 EOS를 온전히 쓰고, 외부 호출은 전용 컨슈머 하나가 담당하며 그 컨슈머에만 멱등성을 구현합니다.
payment-approved
│
├─(Streams, exactly_once_v2)─→ payment-command ← Kafka 안. EOS 온전히 성립
│ │
│ └─(전용 컨슈머 1개)─→ PG API
│ · 멱등 키 사용
│ · 처리 이력 테이블
│ · 이 컨슈머만 검증하면 됨
└─→ ... 다른 소비자들
| 방법 | 적용 조건 | 평가 |
|---|---|---|
| 외부 API 멱등 키 | API가 멱등 키를 지원할 때 | 최선 — 코드가 가장 단순하고 빈틈이 없습니다 |
| 처리 이력 테이블 | 멱등 키가 없고 조회 API가 있을 때 | 구현 가능하지만 상태 전이·복구 배치가 필요합니다 |
| 부작용 지점 분리 | 항상 함께 적용 가능 | 권장 — 검증해야 할 지점이 하나로 줄어듭니다 |
| 되돌릴 수 없는 부작용 (이메일·푸시) |
— | 멱등성 외에 발송 이력 기반 차단이 필수. 재처리 정책을 별도로 문서화하세요 |
예방 체크리스트
시험 포인트
이어서 볼 곳
-
6장 · 전달 보장과 트랜잭션
EOS의 경계, 트랜잭션 코디네이터, LSO와
read_committed. - 예제 5 · Exactly-Once 파이프라인 consume-transform-produce 를 트랜잭션으로 구현.
- 케이스 1 · 데이터가 사라졌다 오프셋을 되돌리게 만든 원래 사고와 복구 절차.
- 케이스 2 · 무한 리밸런스 루프 커밋 실패가 같은 레코드 재처리를 유발하는 경로.
- 예제 6 · DLQ + 재시도 패턴 재시도를 안전하게 만드는 구조와 멱등 처리.
-
10장 · Kafka Streams와 ksqlDB
exactly_once_v2가 상태 저장소까지 묶는 방식.
공식 문서 출처
- Design — Message Delivery Semantics — 세 가지 보장의 정의, at-least-once가 기본, 컨슈머 커밋 순서에 따른 분기, 외부 시스템에 대한 한계와 "오프셋을 출력과 같은 곳에 저장" 권고
- Design — Using Transactions — 프로듀서만 트랜잭션 주체, 컨슈머 필수 설정, abort 후 위치 재설정 필요, 4.x 예외 6범주
- Consumer Configs —
isolation.level— 기본값read_uncommitted - Producer Configs —
transactional.id·enable.idempotence - Kafka Streams — Core Concepts —
processing.guarantee=exactly_once_v2 - Kafka Connect — 데이터와 오프셋을 함께 기록해 원자성을 얻는 패턴
- Apache Kafka 소스 (4.3) —
TransactionalMessageCopier,TransactionalClientDemo예제 코드