Kafka DLT 작업하면서 배운 것들

2026. 6. 11. 22:06·IL/TIL

1. Kafka topic을 자동 생성에 맡기면 안심할 수 없다

처음에는 topic이 없으면 Kafka가 알아서 만들어주는 게 편하다고 생각했다.
그런데 다시 생각해 보니 이건 운영 관점에서는 꽤 위험하다.

자동 생성에 맡기면 partition 수, replication factor, DLT topic 존재 여부가 우리가 의도한 대로 보장되지 않는다.
특히 DLT를 쓰려면 원본 topic만 있으면 되는 게 아니라 {원본 topic}.DLT도 있어야 한다.

그래서 이번에는 로컬 Kafka에서 자동 topic 생성을 끄고, 필요한 topic을 직접 만들도록 했다.

  • docker-compose-kafka.yml에는 로컬용 topic 생성 설정을 추가했다.
  • k8s/overlays/eks/kafka-topics-job.yaml에는 EKS 환경용 topic 생성 job을 보강했다.
  • source topic과 .DLT topic을 같이 만들도록 했다.

오늘 배운 점은 이거다.

Kafka topic은 애플리케이션이 우연히 만들게 두는 값이 아니라, 운영 정책으로 관리해야 하는 인프라 자원이다.

2. Consumer에서 예외를 삼키면 DLT까지 가지 않는다

이 부분이 제일 중요했다.

Kafka consumer에서 JSON 파싱에 실패했는데 catch만 하고 끝내면, Kafka 입장에서는 처리가 끝난 것처럼 보일 수 있다.
그러면 retry도 안 되고, DLT로도 안 간다.

즉, DLT를 쓰려면 "이 메시지는 처리에 실패했다"는 사실을 consumer가 분명하게 알려야 한다.
그래서 역직렬화 실패나 예상하지 못한 시스템 예외는 다시 예외를 던지도록 했다.

반대로 모든 실패를 DLT로 보내면 안 된다.
예를 들어 잔액 부족처럼 도메인적으로 예상 가능한 실패는 실패 이벤트를 발행해서 saga 흐름 안에서 처리하는 게 맞다.

오늘 정리한 기준은 이렇다.

  • JSON 역직렬화 실패: consumer 처리 실패로 보고 retry/DLT 대상
  • 예상하지 못한 시스템 예외: consumer 처리 실패로 보고 retry/DLT 대상
  • 잔액 부족 같은 비즈니스 실패: 도메인 실패 이벤트로 처리
  • 이미 처리한 메시지: 멱등성으로 방어하고 조용히 skip

3. DLT랑 도메인 실패 상태는 다른 문제다

처음에는 FAILED, PENDING, DLT가 다 비슷하게 "실패 처리"처럼 느껴졌다.
그런데 작업하면서 이건 층위가 다르다는 걸 배웠다.

DLT는 Kafka 메시지 자체를 더 이상 정상 처리할 수 없을 때 격리하는 곳이다.
반면 PENDING, FAILED, UNKNOWN 같은 상태는 각 도메인 DB 안에서 업무 흐름을 복구하기 위한 상태다.

예를 들어 item 구매가 PENDING이라는 건 item 도메인의 구매 상태 문제다.
Kafka 메시지가 DLT로 갔다는 건 메시지 소비 자체가 실패했다는 뜻이다.

둘 다 실패와 관련 있지만, 책임지는 위치가 다르다.

4. IllegalArgumentException으로 던져도 동작은 하지만 의도가 흐리다

처음에는 consumer에서 파싱 실패가 나면 IllegalArgumentException을 던졌다.
동작 자체는 된다.
Spring Kafka error handler가 예외를 보고 retry/DLT로 보낼 수 있기 때문이다.

그런데 코드를 다시 보니 이 예외가 왜 던져지는지 의도가 잘 안 보였다.
"잘못된 인자"라는 의미인지, "Kafka consumer 처리 실패"라는 의미인지 애매했다.

그래서 각 모듈에 KafkaConsumerProcessingException을 추가했다.

public class KafkaConsumerProcessingException extends RuntimeException {

    public KafkaConsumerProcessingException(String message, Throwable cause) {
        super(message, cause);
    }
}

이름이 길긴 하지만, 대신 의도는 확실해졌다.

이 예외는 "Kafka consumer에서 처리하지 못했고, retry/DLT 흐름으로 넘겨야 하는 실패"라는 의미를 가진다.

5. Kafka payload 원문을 로그에 찍으면 안 된다

이것도 오늘 꽤 크게 배운 부분이다.

역직렬화 실패가 나면 당연히 payload를 보고 싶어진다.
"뭐가 깨졌는지 보려면 원문을 찍어야 하는 거 아닌가?"라는 생각이 들었다.

그런데 payload 안에는 생각보다 많은 정보가 들어갈 수 있다.

  • userId
  • idempotencyKey
  • device token
  • 결제나 재화 관련 참조값
  • 나중에 추가될 수 있는 민감한 필드

그래서 raw payload를 error log에 남기는 건 위험하다.
이번에는 원문 payload 로그를 없애고 payloadLength만 남겼다.

log.error("[Kafka] 메시지 역직렬화 실패. payloadLength={}",
        messagePayload != null ? messagePayload.length() : 0, e);

사실 실무적으로 더 좋은 건 Kafka metadata를 남기는 방식이라고 배웠다.

  • topic
  • partition
  • offset
  • key
  • payloadLength

이러면 원문을 로그에 남기지 않아도, 권한 있는 사람이 Kafka나 DLT에서 해당 메시지를 추적할 수 있다.
이번에는 listener 시그니처를 전부 ConsumerRecord로 바꾸면 범위가 커져서 payloadLength까지만 정리했다.

오늘 기준으로는 이렇게 정리했다.

로그에는 원문이 아니라 추적 가능한 최소 정보만 남긴다.

6. AI 리뷰는 그대로 받는 게 아니라 현재 코드에 대입해서 봐야 한다

CodeRabbit이 처음에는 CharacterKafkaConsumer 한 파일을 찍었다.
처음엔 그 파일만 고치면 되나 싶었다.

그런데 검색해 보니 같은 문제가 다른 consumer에도 있었다.

  • raw payload를 찍는 consumer가 여러 개 있었다.
  • IllegalArgumentException, IllegalStateException을 직접 던지는 consumer도 여러 개 있었다.
  • 어떤 consumer는 이미 payloadLength만 찍고 있었다.

그래서 이번에는 한 파일만 고치지 않고, Kafka consumer 기준으로 같이 정리했다.

여기서 배운 건 이거다.

리뷰 코멘트는 정답지가 아니라 힌트다.
진짜 고칠 범위는 현재 코드 전체를 보고 판단해야 한다.

7. ADR은 발표용 문서가 아니다

이것도 실수하면서 배웠다.

처음 ADR을 쓸 때 "포트폴리오", "발표", "이번 PR" 같은 말을 넣었다.
그런데 다시 보니까 ADR은 그런 문서가 아니었다.

ADR은 기술 결정을 남기는 문서다.
누가 봐도 시간이 지나서도 "왜 이 선택을 했는지" 이해할 수 있어야 한다.

그래서 ADR에서는 이런 내용만 남기도록 고쳤다.

  • Kafka 도입은 통신 방식 변경이다.
  • Kafka topic은 도메인 소유권이 아니라 비동기 계약이다.
  • 각 모듈은 자기 DB만 직접 수정한다.
  • 다른 모듈의 책임은 Kafka event 또는 gRPC 계약으로 요청한다.
  • 도메인 소유권 재분리는 별도 결정으로 다룬다.

오늘 배운 기준은 간단하다.

ADR에는 발표 전략이 아니라 기술 결정의 이유를 써야 한다.

8. MSA에서 Kafka를 쓴다고 도메인 경계가 자동으로 좋아지지는 않는다

Kafka를 붙이면 뭔가 MSA스러워지는 느낌이 있다.
그런데 오늘 작업하면서 그게 착각일 수 있다는 걸 느꼈다.

Kafka는 통신을 비동기로 바꿔줄 뿐이다.
어떤 모듈이 어떤 데이터를 소유하는지, 어떤 실패를 누가 복구하는지, 어떤 이벤트가 계약인지까지 정리하지 않으면 경계가 흐려질 수 있다.

이번에 정리한 기준은 이렇다.

  • user는 별조각 지갑을 소유한다.
  • item은 아이템과 구매 상태를 소유한다.
  • mission은 미션과 보상 요청 상태를 소유한다.
  • character는 캐릭터 성장 상태를 소유한다.
  • notification은 알림 저장과 발송 상태를 소유한다.
  • event-log는 이벤트 로그 적재를 책임진다.

Kafka topic은 이 소유권을 바꾸는 게 아니라, 모듈 사이의 요청과 결과를 주고받는 계약이다.

9. 검증도 작업의 일부다

이번에는 고치고 끝내지 않고 계속 검증했다.
중간에 테스트가 깨졌는데, 알고 보니 예외 메시지를 한국어로 바꾸면서 테스트 기대값이 영어로 남아 있던 문제였다.

또 포트원 환경변수 없이 user:test를 돌리면 컨텍스트 테스트가 깨지는 것도 다시 확인했다.
그래서 전체 테스트를 돌릴 때는 필요한 환경변수를 같이 넣어야 했다.

검증한 명령은 다음과 같다.

./gradlew.bat compileJava compileTestJava --console=plain
PORTONE_STORE_ID=test-store PORTONE_CHANNEL_ID=test-channel PORTONE_API_SECRET=test-secret KAFKA_BOOTSTRAP_SERVERS=localhost:9097 ./gradlew.bat test --console=plain
docker compose -f docker-compose-kafka.yml config --quiet

오늘의 결론

오늘 제일 크게 배운 건 이거다.

Kafka를 붙이는 것보다, Kafka가 실패했을 때 어떻게 복구하고 관찰할지를 정하는 게 더 어렵다.

topic을 어떻게 만들지, consumer가 실패를 어떻게 드러낼지, DLT로 무엇을 보낼지, 로그에 무엇을 남기면 안 되는지, 도메인 실패와 메시지 실패를 어떻게 구분할지까지 생각해야 했다.

결국 Kafka 안정화는 코드 몇 줄의 문제가 아니라 운영 기준을 코드에 녹이는 작업이었다.

저작자표시 비영리 (새창열림)

'IL > TIL' 카테고리의 다른 글

프론트... 백엔드... 매핑이란 멀까  (0) 2026.06.05
별친구 대화에 멀티턴 맥락과 기억 검색 붙이기  (0) 2026.06.04
알림 및 장애 대응 Runbook  (1) 2026.06.02
RAG 개인화 리뷰... 튜터님의...?  (0) 2026.06.01
AI 외부 Provider 호출 보호와 Fallback 설계  (0) 2026.05.29
'IL/TIL' 카테고리의 다른 글
  • 프론트... 백엔드... 매핑이란 멀까
  • 별친구 대화에 멀티턴 맥락과 기억 검색 붙이기
  • 알림 및 장애 대응 Runbook
  • RAG 개인화 리뷰... 튜터님의...?
견지
견지
개발로 개발하는지 새발로 개발하는지 내가 개인 건지 새인 건지 사람인 건지
  • 견지
    개발새발
    견지
  • 전체
    오늘
    어제
    • 분류 전체보기 (34) N
      • ... (0)
      • IL (34) N
        • TIL (30) N
        • WIL (4)
        • MIL (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    DB
    HTML
    git
    java
    oracle
    JavaScript
    CSS
    JSP
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
견지
Kafka DLT 작업하면서 배운 것들
상단으로

티스토리툴바