코드 저장소.

일정알림기능4-아웃 박스 패턴을 적용하기(트랜잭션 일관성과 장애 복원력 강화) 본문

포폴/일정관리 프로젝트 vol.01

일정알림기능4-아웃 박스 패턴을 적용하기(트랜잭션 일관성과 장애 복원력 강화)

slown 2025. 5. 24. 15:16

목차

1.왜 Outbox가 필요했는가?

2.Outbox 패턴이란?

3.프로젝트에 적용

4.후기

 

1.왜 Outbox가 필요했는가?

기존의 프로젝트에서는 회원가입, 일정 등록 등 주요 도메인 이벤트 발생 시 ApplicationEventPublisher를 통해 비동기로 알림이나 이메일을 발송하고 있었습니다. 하지만 이 방식은 트랜잭션과 이벤트 발행 시점이 분리되어 있어 다음과 같은 문제의 소지가 있습니다.

  • 이벤트 발행 시점과 DB 트랜잭션 커밋 시점이 분리되어 있어, 데이터의 일관성이 깨질 수 있습니다.
  • Kafka의 이벤트는 발행이 되었지만 DB 저장은 실패를 한 경우 -> 시스템간의 불일치
  • Kafka를 직접 발행하는 구조로 바꾸더라도 KafkaTemplate.send()는 비동기 Future 기반이라 동기 트랜잭션과 연계하는 것이 어렵다는 점
  • 장애 발생 시 복구나 상태 추적이 불가능하다는 점

이런 문제들을 해결하기 위해 Outbox 패턴을 도입하게 되었습니다. 이 패턴을 통해 이벤트 유실 방지, 트랜잭션과 메시지 일관성 확보, 재처리 가능한 구조를 갖출 수 있었습니다.

2.Outbox 패턴이란?

Outbox 패턴은 이벤트를 외부 메시지 브로커(Kafka 등)로 바로 발행하지 않고, 먼저 DB에 Outbox 테이블 형태로 저장한 뒤,
이후 서비스의 데이터 변경을 반영하는 이벤트를 메시지 브로커에 안전하게 발행하면서, 트랜잭션의 일관성을 유지하고 장애 상황에서도 데이터 손실을 방지하는 데 중요한 역할을 합니다. 이를 통해 데이터베이스 트랜잭션과 이벤트 발행을 안전하게 묶어서 관리할 수 있습니다.

 

아웃박스 방식의 장점과 한계은 다음과 같습니다

 

장점

  • 트랜잭션 안에서 이벤트를 저장하므로 DB와 메시지 발행 시점의 일관성 보장
  • Kafka 발행 실패 시에도 sent=false 상태로 남기 때문에 재시도 가능
  • 실패 횟수(retryCount)나 발행 시점(sentAt)을 기록하면 장애 추적이 가능
  • DLQ, 모니터링 등과 쉽게 통합 가능
  • 배포 시 인스턴스가 일시적으로 중단되더라도 sent=false 이벤트가 DB에 남아있어 재시작 후 자동 복구 가능

한계

  • 스케줄러 주기(3초)만큼 발행 지연이 발생할 수 있어 실시간성이 중요한 도메인에는 부적합
  • Outbox 테이블이 쌓이면 DB 부하가 증가할 수 있어 주기적인 정리 정책 필요
  • 다중 인스턴스 환경에서 스케줄러 중복 실행 방지를 위해 ShedLock 같은 분산 락이 필요
  • At-least-once 전달은 보장되지만, 재시도 시 동일한 메시지가 중복 처리될 수 있음. 이문제는  eventId 기반 멱등 처리로 해결 (다음 포스팅 참고)

3.프로젝트에 적용

이번 일정 관리 프로젝트에서는 다음과 같은 방식으로 Outbox 패턴을 적용했습니다. 우선 아래는 프로젝트에 적용할 구조도입니다.

 

 

위의 사진을 설명하자면 다음과 같습니다.

 

1.사용자 요청 → 비즈니스 로직 수행

  • 사용자가 일정을 등록하면, ScheduleDomainService에서 일정 생성 로직이 실행됩니다.

2.일정 DB 저장

  • 로직 실행 후 일정 데이터를 Schedule 테이블에 저장합니다.
  • 아직까지는 트랜잭션이 끝나지 않은 상황입니다.

3.같은 트랜잭션 안에서 Outbox 저장

  • 일정 DB와 Outbox 저장은 같은 트랜잭션 내에서 처리됩니다.

4.트랜잭션 Commit

  • DB + Outbox가 원자적으로 커밋이 됩니다. 

5.Outbox Publisher 스케줄러 실행 (@Scheduled + ShedLock)

  • 3초마다 실행되며, sent=false이고 retryCount < 5인 Outbox 이벤트를 최대 100개까지 조회합니다.
  • 장애로 실패한 이벤트도 자동 재시도됩니다.
  • 5회 재시도에 실패했으면 해당 이벤트는 dlq로 이동을 합니다.

6.Kafka 발행 및 상태 업데이트

  • KafkaTemplate을 통해 동적으로 결정된 토픽(예: schedule-events)으로 발행합니다.
  • 성공 시 sent=true, 실패 시 retryCount++로 업데이트되어 추후 재전송됩니다.

 

핵심 코드 예시

 

아웃박스 엔티티

@Entity
public class OutboxEventEntity {
    @Id @GeneratedValue private String id;
    private String aggregateType; // MEMBER, SCHEDULE 등
    private String aggregateId;
    private String eventType;
    private String payload;
    private Boolean sent = false;
    private Integer retryCount = 0;
    private LocalDateTime createdAt;
    private LocalDateTime sentAt;

    public void markSent() { ... }
    public void increaseRetryCount() { ... }

    public String resolveTopic() {
        return switch (aggregateType) {
            case "MEMBER" -> "member-signup-events";
            case "SCHEDULE" -> "schedule-events";
            default -> throw new IllegalArgumentException("Unknown type: " + aggregateType);
        };
    }
}

 

Kafka 이벤트 대신 해당 테이블에 payload를 JSON으로 직렬화해서 저장

public void saveEvent(Object dto, String aggregateType, String id, String eventType) {
    String payload = objectMapper.writeValueAsString(dto);
    outboxEventRepository.save(new OutboxEventEntity(...));
}

 

Kafka 전송 스케줄러

 

코드리뷰 과정에서 updateEvents()의 더티체크 기반 상태 변경이 스케줄러 중복 실행 시 동시성 문제를 유발할 수 있다는 피드백을 받았습니다. 이를 해결하기 위해 ShedLock을 적용하여 스케줄러의 단일 실행을 보장했습니다. 현재는 단일 인스턴스 환경이지만, 향후 다중 인스턴스 확장 시에도 추가 수정 없이 동시성 제어가 가능합니다. 

@Scheduled(fixedDelay = 3000)
@SchedulerLock(name = "OutboxPublisherLock", lockAtMostFor = "PT10M", lockAtLeastFor = "PT2S")
public void publishOutboxEvents() {
    List<OutboxEventEntity> events = repository.findTop100BySentFalseOrderByCreatedAtAsc();
    for (OutboxEventEntity event : events) {
        try {
            kafkaTemplate.send(event.resolveTopic(), event.getPayload());
            event.markSent();
        } catch (Exception e) {
            event.increaseRetryCount(); // 실패 횟수 누적
        }
    }
    repository.saveAll(events);
}

 

실제 구현에서는 eventId 주입, retryCount > 5 시 DLQ 자동 전송, 상태 업데이트 로직이 포함되어 있습니다. 상세 코드는 GitHub를 참고해주세요.

 

4.후기

Outbox 패턴을 직접 적용하면서 이벤트 유실 문제에 대한 구조적 해결 방법을 배웠습니다. Kafka 기반 아키텍처에서 트랜잭션과 메시지의 일관성을 보장하는 것이 얼마나 중요한지 체감했고, retryCount나 sentAt 필드처럼 상태 기반 설계를 통해 운영자 시야에서도 추적 가능하게 만들 수 있음을 경험했습니다.

 

한계 및 개선점

 

현재 단일 브로커 구성으로 인해 Outbox 스케줄러가 Kafka에 발행하는 시점에 브로커가 다운되면 sent=false 상태로 남아 재시도는 가능하지만, 브로커 자체가 복구되기 전까지 발행이 중단됩니다. 프로덕션 환경이라면 브로커 3대 클러스터, Replication Factor 3으로 이 위험을 최소화했을 것입니다.