이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Spring 메시지 listener에서 transaction, ack, idempotency 경계를 어떻게 잡아야 하는가?
@KafkaListener나 Rabbit listener가 성능, 정합성, 장애 격리, 운영 복잡도에 어떤 영향을 주는가?- 운영 가능한 메시지 consumer를 만들기 위해 어떤 코드 경계와 설정을 남겨야 하는가?
개요
Spring에서 메시지를 처리하는 코드는 간단해 보인다. @KafkaListener를 붙이고 payload를 받아 service를 호출하면 된다. 하지만 운영에서 중요한 것은 annotation이 아니라 listener 안의 transaction, ack, retry, idempotency, timeout 경계다.
메시지 consumer는 HTTP controller와 다르다. 사용자가 바로 기다리지 않기 때문에 실패가 조용히 쌓일 수 있고, 같은 메시지가 다시 들어올 수 있으며, consumer 처리량이 downstream 장애를 증폭할 수 있다.
이 문서는 Spring 기반 백엔드에서 메시지 처리 코드를 설계 문서와 운영 기준으로 연결하는 방법을 다룬다.
Listener가 바꾸는 것
| 축 | 영향 |
|---|---|
| 성능 | HTTP 요청 경로에서 후속 작업을 분리하지만 consumer 처리량과 lag가 새 병목이 된다 |
| 정합성 | 처리 완료가 나중에 발생하므로 idempotency와 상태 추적이 필요하다 |
| 장애 격리 | downstream 장애를 요청에서 떼어낼 수 있지만 consumer backlog로 쌓인다 |
| 운영 복잡도 | listener concurrency, ack mode, retry, DLQ, metric을 관리해야 한다 |
Spring 설정 하나가 이 네 축에 영향을 준다. 예를 들어 concurrency를 올리면 처리량은 늘 수 있지만 ordering, DB connection, 외부 API rate limit 문제가 생긴다.
기본 코드 경계
Listener는 얇게 두고 업무 처리는 service로 넘기는 것이 좋다. 단, 예외를 삼켜서 ack되게 만들면 안 된다.
@Component
class OrderPaidListener {
private final PointGrantService pointGrantService;
@KafkaListener(topics = "order-paid", groupId = "point-service")
public void handle(OrderPaidMessage message) {
pointGrantService.grant(message);
}
}Listener는 메시지 수신 경계다. Service는 transaction, idempotency, 업무 상태 변경을 책임진다.
@Service
class PointGrantService {
@Transactional
public void grant(OrderPaidMessage message) {
if (processedMessageRepository.existsById(message.messageId())) {
return;
}
pointRepository.save(PointGrant.from(message));
processedMessageRepository.save(ProcessedMessage.from(message));
}
}existsById와 pointRepository.save, 처리 기록 저장은 같은 transaction 안에 있어야 한다. 그래야 중복 메시지가 들어와도 업무 결과가 한 번만 남는다.
Ack와 Transaction
Ack 시점은 유실과 중복의 trade-off를 만든다.
처리 전 ack -> 실패 시 유실 위험 증가
처리 후 ack -> 재시작 시 중복 처리 가능
commit 후 ack -> 중복 가능성을 idempotency로 방어실무에서는 “중복은 허용하고 유실은 줄인다”는 방향을 자주 택한다. 그래서 업무 DB commit 후 ack하고, 같은 메시지가 다시 들어오면 idempotency 저장소로 막는다.
Spring Kafka를 쓴다면 ack mode, error handler, retry topic, DLQ 설정이 문서에 있어야 한다. RabbitMQ를 쓴다면 manual ack, nack, dead-letter exchange 정책이 드러나야 한다.
예외 처리
Consumer에서 모든 예외를 catch하고 로그만 남기면 broker는 처리가 끝났다고 볼 수 있다.
public void handle(OrderPaidMessage message) {
try {
pointGrantService.grant(message);
} catch (Exception e) {
log.error("failed to grant point", e);
}
}이 코드는 위험하다. 실패 메시지가 retry나 DLQ로 가지 않고 사라질 수 있다. 복구 가능한 예외와 불가능한 예외를 나누고, framework의 error handler가 처리하게 해야 한다.
public void handle(OrderPaidMessage message) {
pointGrantService.grant(message);
}단순해 보이지만 의도가 명확하다. 처리 실패는 listener container의 retry/DLQ 정책으로 이동한다.
Concurrency 설정
Consumer concurrency는 처리량을 늘리는 손쉬운 방법처럼 보이지만 주변 자원을 함께 계산해야 한다.
consumer concurrency = 20
처리당 DB connection = 1
외부 API timeout = 2s
point-service DB pool = 30
외부 API rate limit = 100 rps동시성 20은 DB pool에는 가능해 보여도 외부 API가 느려지면 connection 점유 시간이 늘고 retry가 쌓일 수 있다. 또한 Kafka에서는 partition 수보다 큰 concurrency가 항상 처리량을 늘리지는 않는다.
설정은 다음처럼 문서화한다.
consumer:
topic: order-paid
group-id: point-service
concurrency: 8
max-attempts: 4
backoff: 5s,30s,5m
dlq: order-paid.point.dlq이 값의 이유가 없으면 운영 중 숫자만 바꾸는 대응이 반복된다.
External Call 경계
Consumer 안에서 외부 API를 부르면 timeout과 circuit breaker가 필요하다.
public void sendReceipt(OrderPaidMessage message) {
receiptClient.send(
ReceiptRequest.from(message),
message.idempotencyKey()
);
}외부 API가 idempotency key를 지원하면 중복 호출 위험을 낮출 수 있다. 지원하지 않는다면 내부 발송 기록을 먼저 남기고 중복 발송을 막아야 한다.
create table receipt_send_request (
message_id varchar(100) primary key,
order_id varchar(50) not null,
status varchar(20) not null,
requested_at timestamp not null
);외부 API 호출은 transaction 안에 오래 묶지 않는 편이 안전하다. DB lock을 잡은 채 외부 timeout을 기다리면 consumer 전체 처리량이 떨어진다.
관측 코드
Consumer 로그는 HTTP access log보다 더 많은 식별자가 필요하다.
eventType=OrderPaid
messageId=msg-1001
aggregateId=ord-1001
consumerGroup=point-service
attempt=3
result=failed
errorCode=POINT_API_TIMEOUTMetric도 업무와 시스템을 나눠야 한다.
- listener poll rate, processing latency, success count
- retry count, DLQ count, duplicate skipped count
- downstream timeout count, business failure count
중복 skip이 갑자기 늘면 producer 재발행 문제일 수 있고, business failure가 늘면 payload 의미나 상태 전이 문제가 생긴 것일 수 있다.
테스트해야 할 실패
- 같은 messageId를 두 번 보내도 한 번만 처리되는가?
- service 예외와 DB commit 후 ack 실패가 retry나 중복 방어로 이어지는가?
- 외부 API timeout과 schema version 차이를 안전하게 처리하는가?
위험 신호
- listener에서 예외를 catch하고 로그만 남긴다.
- idempotency 저장소 없이 “Kafka가 한 번만 줄 것”이라고 가정한다.
- concurrency를 올리면서 DB pool과 외부 API rate limit을 계산하지 않는다.
- DLQ topic은 있지만 alert와 재처리 절차가 없다.
- messageId, aggregateId, correlationId가 로그에 없다.
- 외부 API 호출을 긴 DB transaction 안에서 수행한다.
개인 프로젝트 기준
- listener, service, repository 책임을 나눈다.
- messageId 기반 중복 방어를 구현한다.
- 실패 시 retry되는지 로그나 테스트로 확인한다.
- concurrency와 retry 값을 문서에 남긴다.
- DLQ를 생략했다면 실패 테이블이나 실패 로그 검색 방법을 둔다.
기업 운영 기준
- listener 설정을 코드 리뷰 대상에 포함한다.
- retry/DLQ 정책을 eventType별로 분리한다.
- consumer group, lag, DLQ, duplicate skip 지표를 dashboard에 둔다.
- 재처리 도구에 권한, 감사 로그, dry-run을 둔다.
- 배포 시 old/new schema를 모두 처리하는 기간을 가진다.
확인 질문
확인 질문
- 확인 질문: Spring listener에서 예외를 catch하고 끝내면 왜 위험한가?
- 답변: broker나 container가 처리를 성공으로 판단해 retry나 DLQ 없이 메시지가 사라질 수 있기 때문이다.
- 확인 질문: consumer transaction 안에 idempotency 기록을 함께 저장해야 하는 이유는 무엇인가?
- 답변: 업무 변경과 처리 기록이 따로 성공하면 중복 처리나 누락을 막을 수 없기 때문이다.
- 확인 질문: concurrency 설정을 처리량 숫자만 보고 정하면 안 되는 이유는 무엇인가?
- 답변: DB pool, partition 수, 외부 API rate limit, ordering 요구가 함께 병목과 장애 증폭을 만들 수 있기 때문이다.