Kafka Consumer는 실패한 메시지를 어떻게 처리해야 할까?
Kafka Consumer 실패를 무조건 재시도하지 않고 Retry·DLQ·멱등성으로 나눠 처리한 기준과 운영 절차를 적었다.
Kafka Consumer에서 예외가 나면 처음에는 다시 시도하면 된다고 생각하기 쉬웠다. 실제 운영에서는 해결되지 않는 메시지가 같은 파티션을 계속 막았고, 재시도 횟수를 늘릴수록 복구가 늦어졌다.
실패 메시지를 Retry와 DLQ로 나누기 전에 어떤 실패가 시간이 지나면 해결되고, 어떤 실패가 코드나 데이터 수정 없이는 해결되지 않는지 분류했다.
실패를 세 종류로 나눴다
| 종류 | 예시 | 처리 |
|---|---|---|
| 일시적 실패 | 네트워크, 잠시 잠긴 자원 | 간격을 둔 Retry |
| 영구 실패 | 계약 위반, 잘못된 상태 | DLQ와 조사 |
| 알 수 없는 실패 | 새로운 예외 | 제한된 Retry 뒤 DLQ |
모든 예외를 같은 정책으로 처리하지 않았다. 재시도 가능한 예외를 코드에서 명시하고, 나머지는 제한된 횟수 뒤 격리했다.
예외 클래스와 처리 정책을 직접 연결했다
메시지 문자열을 검사해 재시도 여부를 정하면 라이브러리와 외부 응답이 바뀔 때 정책이 흔들렸다. 애플리케이션 경계에서 실패를 일시적, 영구적, 불명확으로 변환하고 Consumer 정책은 그 분류를 사용했다.
ConsumerFailure
├─ RetryableFailure
│ ├─ TemporaryNetworkFailure
│ └─ ResourceBusyFailure
├─ NonRetryableFailure
│ ├─ InvalidContractFailure
│ └─ InvalidStateFailure
└─ UnknownFailure
외부 라이브러리 예외를 그대로 정책에 노출하지 않았다. Timeout이라도 호출 대상과 작업 성격에 따라 재시도가 위험할 수 있었다. 비즈니스 부작용이 있었는지 알 수 없는 경우에는 상태 조회나 대사가 먼저였다.
새로운 예외는 무한 재시도하지 않았다. 제한된 시도 뒤 DLQ로 보내고 분류가 빠졌다는 사실을 알람으로 남겼다. 원인을 확인한 뒤 명시적인 정책에 추가했다.
Retry는 횟수보다 간격이 중요했다
즉시 반복하면 장애가 난 의존성에 더 큰 부하를 줬다. 재시도 사이에 간격을 두고 횟수에 상한을 뒀다. 처리 순서가 중요한 메시지는 별도 Retry 토픽으로 옮길 때 순서가 어떻게 달라지는지도 확인했다.
재시도 로그에는 이벤트 ID, 토픽, 파티션, 오프셋, 예외 종류, 시도 횟수를 남겼다. 같은 실패가 계속되는지와 다른 메시지까지 막는지를 확인할 수 있었다.
재시도를 원본 파티션 안에서 계속할지 결정했다
Consumer 스레드에서 기다리며 재시도하면 같은 파티션의 뒤 메시지는 처리되지 않았다. 순서가 중요한 짧은 일시 오류에는 도움이 됐지만, 장애가 길어지면 파티션 전체가 멈췄다.
main topic
→ immediate retry
→ retry topic with delay
→ final attempt
→ DLQ
Retry 토픽으로 옮기면 원본 파티션은 진행할 수 있지만 처리 순서는 달라질 수 있었다. 결제 상태 전이처럼 앞 사건이 반드시 필요한 메시지는 뒤 사건을 그대로 처리하지 않고 상태 검증에서 보류했다.
정책은 처리량과 순서 중 무엇이 중요한지에 따라 달랐다. 모든 Consumer에 같은 횟수와 간격을 복사하지 않고 토픽의 비즈니스 계약과 복구 방법을 함께 봤다.
Spring Kafka 설정에도 분류 기준을 드러냈다
예시는 실제 내부 이름을 제외하고 단순화했다. 재시도하지 않을 예외와 간격, 복구 동작이 설정만 읽어도 보이게 했다.
DefaultErrorHandler handler = errorHandler(
fixedBackOff(retryInterval, maxAttempts)
);
handler.addNotRetryableExceptions(
InvalidContractFailure.class,
InvalidStateFailure.class
);
handler.setRecoverer(deadLetterRecoverer);
설정의 숫자를 근거 없이 늘리지 않았다. 의존성이 회복되는 시간, 메시지 처리 시간, 파티션 지연, 운영자가 감지할 시점을 함께 고려했다. 해결되지 않는 오류를 오래 붙잡는 것은 안정성이 아니라 지연이었다.
역직렬화처럼 listener에 도달하기 전에 발생하는 실패도 같은 흐름에서 처리되는지 확인했다. payload를 읽지 못하더라도 원본 바이트와 토픽 메타데이터를 조사할 수 있게 격리했다.
DLQ는 쓰레기통이 아니라 작업 대기열이었다
DLQ로 보낸 뒤 끝내면 데이터는 조용히 누락됐다. 원본 메시지와 실패 메타데이터를 함께 보관하고, 담당자가 원인을 확인할 수 있는 조회 기준을 만들었다.
재처리할 때는 원본 이벤트 ID를 유지했다. 메시지를 새 사건처럼 만들면 중복 방지 이력이 끊겼다. 수정한 코드가 배포됐거나 잘못된 데이터가 바로잡힌 뒤에만 다시 처리했다.
DLQ 메시지에 실패 맥락을 함께 넣었다
원본 payload만 복사하면 왜 실패했고 몇 번 시도했는지 다시 로그에서 찾아야 했다. 원본 메시지를 바꾸지 않으면서 별도 헤더나 envelope에 실패 정보를 남겼다.
{
"originalTopic": "payment-events",
"partition": "original-partition",
"offset": "original-offset",
"eventId": "stable-event-id",
"failureType": "classified-failure",
"attempt": "attempt-count",
"failedAt": "failure-time"
}
예외 스택 전체와 민감한 payload를 운영 화면에 무조건 노출하지 않았다. 담당자가 분류에 필요한 정보와 상세 로그로 이동할 안전한 식별자만 제공했다.
DLQ 보관 기간도 무한하지 않았다. 보관 기간 안에 조사되지 않은 메시지가 사라지지 않도록 적체와 가장 오래된 실패 시각을 관찰했다. DLQ가 증가하지 않아도 오래 방치된 한 건이 있을 수 있었다.
멱등성이 없으면 Retry도 위험했다
Consumer가 외부 API 호출과 DB 저장 사이에서 멈추면 같은 메시지가 다시 들어왔다. 이벤트 ID와 비즈니스 키를 사용해 같은 사건의 결과가 한 번만 반영되게 했다. 외부 API에는 가능한 경우 멱등 키를 전달했다.
Retry와 DLQ는 실패를 이동시키는 장치였고, 멱등성은 다시 실행해도 결과를 지키는 장치였다. 셋의 역할을 섞지 않았다.
처리 이력과 비즈니스 변경을 한 트랜잭션으로 묶었다
메시지를 처리했다고 먼저 기록한 뒤 정산 저장이 실패하면 다음 전달은 이미 처리된 것으로 건너뛰었다. 반대로 정산을 저장하고 처리 이력을 남기지 못하면 같은 금액이 다시 반영될 수 있었다.
BEGIN
INSERT INTO processed_event(event_id) VALUES (:eventId)
APPLY business change
COMMIT
두 작업을 같은 데이터베이스 트랜잭션에 두고 event ID 유일성 제약으로 경쟁 처리도 막았다. 사전 조회는 빠른 종료를 위한 최적화였고 정확성은 저장소 제약이 담당했다.
외부 API 호출은 로컬 트랜잭션으로 묶을 수 없었다. 가능한 경우 이벤트 ID에서 파생한 멱등 키를 전달하고, 결과가 불명확하면 상태 조회를 거쳤다. 외부 부작용이 있는 Consumer는 단순 DB Consumer보다 더 보수적으로 재시도했다.
독성 메시지 한 건이 전체 파티션을 막지 않게 했다
계약 위반처럼 재시도로 해결되지 않는 메시지는 빠르게 격리했다. 다만 순서가 중요한 상태 흐름에서 앞 메시지를 건너뛰고 뒤 메시지를 처리하면 데이터가 틀릴 수 있었다.
| 조건 | 선택 |
|---|---|
| 서로 독립적인 사건 | 실패 건 격리 후 파티션 진행 |
| 같은 키의 순서가 중요 | 뒤 사건 상태 검증 또는 키 단위 보류 |
| 외부 의존성 전체 장애 | 소비 속도 제한과 재시도 간격 확대 |
| 계약 자체가 잘못됨 | DLQ 격리 후 생산자·소비자 계약 수정 |
파티션을 계속 진행할지 멈출지는 Kafka 설정 하나가 아니라 비즈니스 순서 규칙이었다. 결제 ID별 상태 전이 검증을 두고 오래된 이벤트나 앞 단계가 없는 이벤트를 적용하지 않았다.
재처리는 별도 권한과 검증을 거쳤다
DLQ 메시지를 main topic에 그대로 복사하는 기능만 만들면 같은 실패를 반복하기 쉬웠다. 재처리 대상 선택, 원인과 수정 상태 확인, 실행, 결과 검증을 한 흐름으로 만들었다.
선택
→ 현재 데이터 조회
→ 실패 원인과 수정 여부 확인
→ dry-run 가능한 항목 검증
→ 원본 eventId로 재처리
→ 처리 이력·비즈니스 결과 확인
누가 어떤 메시지를 왜 재처리했는지 감사 이력을 남겼다. 벌크 재처리는 대상 범위와 예상 영향을 먼저 보여주고, 진행 결과에서 성공·중복 건너뜀·다시 실패를 나눴다.
실패 처리 자체를 테스트했다
listener의 정상 결과만 테스트하지 않았다. 일시 오류가 정한 간격과 상한 안에서 재시도되는지, 영구 오류가 즉시 DLQ로 가는지, 재처리에도 같은 event ID가 유지되는지 확인했다.
| 테스트 | 확인한 결과 |
|---|---|
| 일시 오류 뒤 회복 | 한 번만 비즈니스 반영 |
| 영구 오류 | 불필요한 반복 없이 격리 |
| Consumer 중단 뒤 재전달 | 중복 결과 없음 |
| DLQ 재처리 | 원본 식별자와 처리 이력 유지 |
| 순서 역전 | 잘못된 상태 전이 거절 |
브로커와 실제 직렬화를 사용하는 통합 테스트도 뒀다. Mock 호출 횟수만으로는 offset commit, 역직렬화 실패, 헤더 전달을 확인할 수 없었다.
운영 절차까지 있어야 끝났다
- DLQ 증가 알람으로 실패를 확인했다.
- 이벤트 ID와 예외 유형으로 영향 범위를 찾았다.
- 재시도로 해결되는지, 수정이 필요한지 분류했다.
- 조치 뒤 같은 이벤트 ID로 재처리했다.
- 비즈니스 결과와 처리 이력을 함께 확인했다.
운영 지표에는 재시도 횟수만 두지 않았다. 가장 오래된 실패, DLQ 적체, 실패 분류별 증가, 재처리 성공과 재실패, Consumer lag을 함께 봤다. 메시지가 이동하는 동안 비즈니스 처리가 얼마나 늦어졌는지 연결했다.
자동 재시도가 끝났다는 것이 사건의 종료는 아니었다. DLQ에 들어간 순간부터는 운영 작업이 시작됐다. 담당자와 확인 기한이 없는 DLQ는 결국 조용한 데이터 누락이 됐다.
남은 한계를 분명히 했다
Retry, DLQ, 멱등성을 넣어도 모든 실패가 자동으로 고쳐지지는 않았다. 외부 결과가 불명확하거나 비즈니스 상태가 사람이 보정한 값과 충돌하면 자동 재처리를 멈춰야 했다.
또한 event ID 멱등성은 같은 사건의 재전달을 막지만, 생산자가 논리적으로 같은 사건을 서로 다른 ID로 두 번 만들면 충분하지 않았다. 비즈니스 키 제약과 대사가 마지막 방어선으로 남았다.
결국 Consumer 실패 처리의 목표는 예외를 없애는 것이 아니었다. 실패한 메시지가 어디에 있고, 왜 멈췄고, 다시 실행해도 안전하며, 최종 돈의 상태가 맞는지 설명할 수 있게 만드는 것이었다.
Consumer 실패 처리는 예외를 잡는 코드 한 줄로 끝나지 않았다. 실패를 분류하고, 격리하고, 다시 실행하고, 결과를 확인하는 전체 흐름을 운영 기능으로 만들어야 했다.