포폴/일정관리앱

일정알림기능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, 모니터링 등과 쉽게 통합 가능

3.프로젝트에 적용

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

 

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

 

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

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

2.일정 DB 저장

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

3.이벤트 발행 및 Outbox 저장

  • @TransactionalEventListener(phase = AFTER_COMMIT)을 통해 이벤트 리스너가 트랜잭션 커밋 직후 동작합니다.
  • 이 리스너는 Kafka로 전송할 데이터를 JSON으로 직렬화하여 Outbox 테이블에 저장합니다.
  • 일정 DB와 Outbox 저장은 같은 트랜잭션 내에서 처리됩니다. (다이어그램 초록색 영역)

4.트랜잭션 Commit

 

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

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

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 전송 스케줄러

@Scheduled(fixedDelay = 3000)
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);
}

4.후기

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

  • 배포후 장애 대응을 위해서 Slack 알림, Sentry 연동 등 운영 모니터링 연결하기