Kafka 장애 상황에서 메시지 유실 없이 처리하기 위한 설계 전략

송현진·2025년 7월 4일
0

Architecture

목록 보기
14/18

시스템 설계에서 왜 이 문제가 중요한가?

Kafka를 사용하는 이유는 비동기 메시지 처리, 처리량 증가, 서비스 간 결합도 감소 등을 통해 시스템의 안정성과 확장성을 확보하기 위함이다. 하지만 Kafka가 죽으면 데이터는 어떻게 되나?라는 질문을 받았을 때 “Kafka가 안정적이니까 괜찮다”는 식의 답변은 치명적이다. 이 질문은 단순한 신뢰성 논의가 아니라 "너는 장애 상황을 사전에 설계하고 복구 가능한 시스템을 만들 수 있느냐?"를 묻는 것이다.

문제 상황 설명: Kafka가 죽음

Kafka를 사용하는 시스템의 일반적인 흐름은 다음과 같다.

사용자 요청 → API 서버 → Kafka Producer → Kafka Broker → Kafka Consumer → DB 저장

여기서 Kafka Broker가 죽는 타이밍에 따라 다양한 문제가 발생한다.

1. Producer가 메시지를 보내는 도중 Kafka가 다운되면?
메시지는 브로커에 도달하지 못하고 실패하지만 API 서버는 응답 처리 로직에서 실패 여부를 무시하고 사용자에게 200 OK를 반환할 수 있다.

2. Kafka가 메시지를 받은 직후 장애가 나면?
메시지가 리더 파티션에는 쓰였지만 Follower에 복제되기 전이라면 장애 복구 시 메시지가 유실될 수 있다.

3. Consumer가 메시지를 처리하지 못한 상태에서 장애가 발생하면?
Consumer 쪽 문제가 아니라면 Kafka 자체에 메시지는 남아 있지만 Consumer 장애나 오프셋 커밋 방식에 따라 중복 처리 혹은 유실 문제가 발생한다.

왜 이런 문제가 발생할까?

1. acks=1 기본 설정

Kafka는 기본적으로 acks=1로 설정되어 있어 리더 파티션만 쓰기를 완료하면 Producer에 OK를 반환한다. 이 상태에서 Kafka가 죽으면 해당 메시지가 Follower에 복제되기 전이라 데이터가 사라진다. 복제는 되었지만 커밋되지 않은 상태에서 리더가 변경되면 메시지는 유실된다.

2. Future 결과 확인 누락

Java Kafka Producer는 비동기로 send()를 수행하므로 Future.get()이나 콜백으로 예외를 처리하지 않으면 전송 실패를 감지할 수 없다. 많은 경우 이 부분을 생략한 코드가 많고 전송 실패를 기록하거나 재처리하지 않기 때문에 조용히 유실된다.

3. API 처리와 Kafka 전송 간 트랜잭션 분리

DB에 데이터를 저장하고 Kafka에 메시지를 전송하는 작업은 보통 서로 다른 트랜잭션이다. 따라서 다음과 같은 문제가 생긴다.

  • DB 저장은 성공했지만 Kafka 전송 실패 → 이후 로직이 진행되지 않음
  • Kafka 전송은 성공했지만 DB 저장 실패 → Consumer는 처리가 불가능한 메시지를 읽음

이를 Dual Write Problem이라고 하며 메시지 기반 아키텍처에서 가장 자주 나오는 함정이다.

어떻게 해결할 수 있을까?

1. acks=all + replication 설정

Kafka에서 데이터 손실을 막기 위한 가장 기초적인 설정은 다음과 같다.

acks=all
retries=10
max.in.flight.requests.per.connection=1
enable.idempotence=true
  • acks=all: 리더 + 모든 ISR 복제 완료 시 OK 반환
  • retries: 네트워크나 일시적 장애 시 재전송
  • enable.idempotence: 동일 메시지 재전송에도 중복 전송 방지
  • max.in.flight.requests=1: 재시도 중 순서 뒤바뀜 방지

최소한 위 설정을 사용하지 않으면 신뢰성 있는 전송을 보장할 수 없다.

2. Kafka 전송 실패 감지 및 재처리 로직

전송 실패를 콜백이나 Future로 감지하고, 재시도 로직 또는 보류 저장소를 활용해야 한다.

producer.send(record, (metadata, exception) -> {
    if (exception != null) {
        log.error("Kafka 전송 실패", exception);
        // 1. 보류 큐(Redis, DB) 저장
        // 2. Dead Letter Queue (DLQ) 전송
        // 3. 재시도 백오프 적용
    }
});
  • 실패 시 메시지를 다른 큐에 넣고 별도 워커가 재전송 시도
  • 백오프(backoff), 지수적 재시도 전략 활용

3. Exactly Once: Kafka 트랜잭션 사용

Kafka는 Exactly-Once Semantics (EOS)를 지원한다.
API 서버가 DB 저장과 Kafka 전송을 트랜잭션으로 묶고 싶다면 다음과 같이 구성한다.

Kafka 트랜잭션(Exactly-Once Semantics)이란?
Kafka 트랜잭션은 간단히 말하면 Kafka에 여러 개의 메시지를 하나의 트랜잭션으로 묶어서 전송하고 이 트랜잭션이 전부 성공해야만 실제로 전송되도록 보장해주는 기능이다. 즉, 중간에 실패하면 한 건도 Kafka에 안 들어간 것처럼 무효화 시킬 수 있다. 그래서 메시지 중복도 막고 정확히 한 번만 처리되도록 해준다.

  1. Kafka 트랜잭션 시작
  2. DB 트랜잭션 시작
  3. DB 저장
  4. Kafka 전송
  5. 둘 다 성공 시 commit, 하나라도 실패 시 rollback
producer.initTransactions();
producer.beginTransaction();
try {
    // DB 저장 (같은 트랜잭션 커넥션 사용 or Outbox)
    producer.send(...);
    producer.commitTransaction();
} catch (Exception e) {
    producer.abortTransaction();
}

Producer ID 설정, idempotence 설정, 브로커 버전 등 여러 전제조건이 필요하다. 트랜잭션 유지 시간이 너무 길면 성능 이슈가 발생할 수 있다.

4. Outbox 패턴: 메시지 먼저 저장 후 전송

Outbox 패턴은 DB와 Kafka 전송을 하나의 트랜잭션으로 묶을 수 없다는 한계를 우회하는 전략이다. 핵심은 메시지를 Kafka에 바로 전송하지 않고 먼저 DB 테이블에 저장한 뒤, 비동기 워커가 Kafka로 전송하는 것이다. 이렇게 하면 Kafka가 일시적으로 죽었을 때도 데이터 손실 없이 재전송이 가능하다.

[API 서버]
1. 트랜잭션 시작
2. 비즈니스 데이터 저장 (예: 주문 정보)
3. Outbox 테이블에 메시지 insert
4. 트랜잭션 커밋

[Kafka 전송 Worker]
5. status = 'PENDING' 메시지 조회
6. Kafka 전송 시도
7. 전송 성공 → status = 'SENT'
8. 전송 실패 → status = 'FAILED' 또는 재시도 큐 등록

Outbox 테이블 예시

컬럼명설명
id메시지 고유 ID (UUID 등)
event_type이벤트 타입 (예: ORDER_CREATED)
payloadKafka로 보낼 JSON 데이터
statusPENDING, SENT, FAILED
created_at생성 시각
updated_at상태 변경 시각

이 테이블은 주문 등의 비즈니스 데이터와 함께 같은 트랜잭션으로 커밋되므로 Kafka가 죽어도 데이터 유실이 발생하지 않는다.

Spring 기반 Outbox 구현 예시

@Transactional
public void createOrder(OrderRequest request) {
    // 1. 주문 저장
    Order order = orderRepository.save(new Order(request));

    // 2. 메시지 저장 (같은 트랜잭션 내)
    OutboxMessage message = new OutboxMessage(
        "order-topic", "ORDER_CREATED", order.toPayload()
    );
    outboxRepository.save(message);
}

비동기 KafkaSenderWorker

@Scheduled(fixedDelay = 3000)
public void sendPendingMessages() {
    List<OutboxMessage> messages = outboxRepository.findByStatus("PENDING");

    for (OutboxMessage msg : messages) {
        try {
            kafkaTemplate.send(msg.getTopic(), msg.getPayload());
            msg.markSent(); // SENT 상태로 변경
        } catch (Exception e) {
            log.error("Kafka 전송 실패", e);
            msg.markFailed(); // 또는 retry 카운트 증가
        }
        outboxRepository.save(msg);
    }
}

장점

  • 메시지가 Kafka로 바로 가지 않기 때문에 Kafka가 죽어도 유실 없음
  • DB 트랜잭션과 함께 메시지도 안전하게 저장됨
  • Kafka가 복구되면 Worker가 자동 전송 → 장애 복구 용이
  • 메시지 상태를 기준으로 모니터링 및 재처리 가능

단점

  • 별도 테이블 및 전송 워커 구성 필요
  • 실시간성이 중요한 시스템에는 전송 지연이 문제될 수 있음
  • 메시지 중복 전송 방지 로직이 Worker에 필요할 수 있음

📝 느낀점

Kafka는 기본적으로 빠르고 유연한 메시지 브로커이지만 신뢰성을 확보하기 위해선 반드시 Producer → Broker → Consumer → DB 흐름의 장애 지점을 찾아 예방책을 설계해야 한다. 특히 실무에서는 성공 응답을 먼저 반환한 뒤 실제 처리가 실패하거나 유실되는 경우 단순한 기술적 문제를 넘어서 비즈니스 신뢰를 잃는 치명적 문제로 이어질 수 있다. 이번 학습을 통해 단순한 Kafka 설정이 아닌 시스템 전체의 복원력과 데이터 일관성을 확보하는 아키텍처 설계 능력이 더 중요하다는 사실을 다시금 깨달았다.

profile
개발자가 되고 싶은 취준생

0개의 댓글