코드 저장소.

일정알림 기능 5-Kafka Exactly-Once 보장하기 Outbox + EventId 기반 멱등 처리 본문

포폴/일정관리 프로젝트

일정알림 기능 5-Kafka Exactly-Once 보장하기 Outbox + EventId 기반 멱등 처리

slown 2025. 9. 1. 01:57

목차

1. 도입 배경

2. EOS(Exactly Once Semantics) 접근 방식

3. 설계 및 구현 방법

4. 후기

 

1. 도입 배경

현재 서비스는 Outbox + DLQ + Retry 구조로 이벤트 발행 및 복원력을 확보하고 있습니다. 이 방식은 at-least-once 전달 보장을 만족하여 메시지 유실은 방지할 수 있습니다. Outbox로 DB 트랜잭션과 메시지 발행의 원자성을 맞췄고, DLQ/Retry를 통해 장애 상황에서도 재처리가 가능하기 때문에 안정적으로 이벤트를 보낼 수 있습니다. 하지만 여전히 한 가지 문제가 남아 있습니다. 메시지의 중복 처리입니다.

 

문제의 소지는 동일한 Outbox 이벤트가 재발행되었을 때 Retry 스케줄러가 같은 이벤트를 재전송했을 때 Consumer가 재시작되면서 offset이 되돌아갔을 때 이런 상황에서는 같은 이벤트가 두 번 이상 처리될 수 있습니다.

 

예를 들어, 회원가입 이벤트가 중복 처리되면 동일한 회원이 여러 번 insert되거나, 알림 이벤트가 중복 발송될 수 있습니다. 메시지 유실은 막았지만, 중복 처리로 인한 데이터 정합성 문제가 여전히 발생할 수 있다는 뜻입니다. 그래서 이러한 현 구조를 개선을 하기 위해서는 exactly-once를 적용을 해기로 했습니다.

2. EOS(Exactly Once Semantics) 접근 방식

Kafka는 기본적으로 at-least-once 전달 보장을 제공하지만, Exactly Once Semantics(EOS)를 위한 기능도 내장하고 있습니다. 이를 구현하는 방법은 크게 두 가지로 나눌 수 있습니다.

 

2-1. Kafka 트랜잭션 기반 EOS

 

Kafka는 idempotent producer와 transactional.id 설정을 통해 메시지를 트랜잭션 단위로 처리할 수 있습니다. Producer 단계에서 여러 메시지를 하나의 트랜잭션으로 묶고, commit/abort 단위로 브로커에 기록 Consumer는 트랜잭션 경계를 인식하면서 메시지와 offset을 함께 커밋 이 방식은 이론적으로 메시지 중복·유실 모두를 방지할 수 있습니다. 하지만 단점도 명확합니다.

 

운영 복잡도 증가: transactional.id 관리, 상태 충돌 가능성

성능 오버헤드: 트랜잭션 단위 동기화로 TPS 저하 장애 시 복구 과정이 단순하지 않음

 

이방식을 실제 운영 환경에서 적용하기에는 부담이 컸습니다.

 

2-2. 애플리케이션 레벨 EOS (eventId 기반 멱등 처리)

 

운영 복잡도를 줄이기 위해, 애플리케이션 차원에서 EOS를 보장하는 방식을 선택했습니다. 핵심 아이디어는 eventId 기반 멱등성 보장입니다. Outbox 테이블의 PK(UUID)를 eventId로 활용 Producer는 메시지에 eventId를 포함해 전송 Consumer는 처리 전 eventId 중복 여부를 체크 이미 처리된 eventId라면 skip 신규 eventId라면 DB 반영 후 기록 이렇게 하면 DLQ/Retry로 같은 메시지가 재발행되더라도, eventId를 기준으로 중복 처리를 방어할 수 있습니다.

 

2-3. 선택 이유

 

Kafka 트랜잭션 기반 EOS는 강력하지만, 운영 및 성능 부담이 크다. 애플리케이션 레벨 EOS는 Outbox 패턴과 잘 어울리며, 단순하면서도 서비스 요구사항(EOS 보장)에 충분히 부합한다. 따라서 이번 프로젝트에서는 eventId 기반 멱등 처리 방식을 EOS 접근 방법으로 채택했습니다.

 

아래의 구조는 기존의 구조에서 eventId를 적용한 후의 구조입니다. 

3. 설계 및 구현 방법

3-1.kafkaDto에 공통적으로 적용을 할 Dto를 작성하기

 

모든 Kafka 이벤트 DTO는 BaseKafkaEvent 추상 클래스를 상속하도록 변경했습니다.

Outbox PK(UUID)를 eventId로 활용하기 위해 필드만 공통화했습니다.

@Getter
@Setter
public abstract class BaseKafkaEvent {
    private String eventId;
}

public class MemberSignUpKafkaEvent extends BaseKafkaEvent

public class NotificationEvents extends BaseKafkaEvent

 

3-2. 이미 처리된 eventId를 저장하기 위한 엔티티 작성

 

이미 처리된 eventId 저장을 하기 위한 ProccessEvent 엔티티작성했습니다. DB unique index를 걸어 동시성 상황에서도 안전하게 멱등성을 보장합니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Table(
        name = "processed_event",
        indexes = {
                @Index(name = "idx_event_id", columnList = "eventId", unique = true)
        }
)
public class ProcessedEventEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 100)
    private String eventId; // Outbox PK or 비즈니스 키

    @Column(nullable = false)
    private LocalDateTime processedAt;

}

 

3-3. ProccessEvent에 관한 서비스 로직 작성

 

이미 처리된 eventId인지를 조회와 저장을 위한 메서드를 작성했습니다. 

  @Transactional(readOnly = true)
    public boolean isAlreadyProcessed(String eventId) {
        return processedEventRepository.existsByEventId(eventId);
    }

    public void saveProcessedEvent(String eventId) {
        try {
            ProcessedEventEntity entity = ProcessedEventEntity.builder()
                    .eventId(eventId)
                    .processedAt(LocalDateTime.now())
                    .build();
            processedEventRepository.save(entity);
        } catch (DataIntegrityViolationException e) {
            // 이미 처리된 이벤트라면 무시
            log.warn("이벤트 중복 저장 시도 감지됨: {}", eventId);
        }
    }

 

3-4. OutboxPublisher에 eventId를 확인을 하기 위해서 로직을 변경 

 

Outbox에서 Kafka로 이벤트를 발행할 때, Outbox PK를 eventId로 주입합니다. 이렇게 하면 Outbox, Retry, DLQ 등 어떤 경로로 이벤트가 다시 발행되더라도 동일한 eventId가 유지가 됩니다.

// eventId 주입 (Outbox PK → Kafka eventId)
if (payload instanceof BaseKafkaEvent baseEvent && baseEvent.getEventId() == null) {
    baseEvent.setEventId(event.getId());
}

 

3-5. Consumer에서 eventId를  unique key로 검증

 

메시지를 수신하면 가장 먼저 중복 체크를 수행하고 신규 이벤트라면 비지니스 로직으로 처리를 하고 saveProcessedEvent()로 저장을 합니다.

 

if (processedEventService.isAlreadyProcessed(event.getEventId())) {
    log.info("⚠️ 이미 처리된 이벤트 무시: {}", event.getEventId());
    return;
}

// 비즈니스 로직 실행
handleNotification(event);

// 처리 완료 후 기록
processedEventService.saveProcessedEvent(event.getEventId());

4. 후기

이번 EOS 적용과 성능 측정은 단순히 기능 구현을 넘어, 저 자신이 개발자로서 어떤 관점으로 문제를 바라보고 해결해야 하는지를 배운 과정이었습니다.

  • 중복과 유실 사이의 균형
    기존 Outbox + DLQ + Retry 구조는 메시지 유실은 막아줬지만, 여전히 중복이라는 문제를 안고 있었습니다. 이 지점을 개선하기 위해 EOS를 고민하면서, 저는 “시스템은 완벽하지 않다. 다만 그 안에서 우리가 보장할 수 있는 것은 어디까지인가?”라는 질문을 스스로 던지게 되었습니다. 결국 eventId 기반 멱등 처리를 선택했고, 실험을 통해 중복 없는 결과를 직접 확인하면서 문제 해결에 대한 자신감을 얻었습니다.

Outbox + DLQ + Retry 구조는 유실은 막아줬지만 중복이라는 문제가 여전히 남아 있었습니다. "시스템은 완벽하지 않다. 다만 우리가 보장할 수 있는 건 어디까지인가?" 이 질문이 eventId 기반 멱등 처리로 이어졌고, Consumer 레벨에서 중복을 차단하는 구조를 완성했습니다. 설계한 구조가 실제 부하에서도 버티는지, 다음 글에서 수치로 검증합니다.