BindProject

[Backend] 유저 활동 로그 수집 모듈 개발기 2-3편 : Outbox 설계 이후: 테스트 전략과 실제 구현 결과

dding-shark 2025. 6. 21. 22:30
728x90

 


3편. 상태 기반 Outbox 설계 이후: 테스트 전략과 실제 구현 결과

 리뷰: 우리는 왜 상태 기반으로 바꿨는가?

앞선 2편에서는 다음 세 가지 문제를 해결하고자 boolean sent 필드를 enum OutboxStatus로 교체했다:

  • Kafka 전송 성공/실패 구분 불가
  • 재시도 및 장애 감지 불가능
  • 배치 전송 전략 미흡

테스트 전략 요약

테스트 시나리오 기대 결과
Kafka 전송 성공 status → SENT
Kafka 전송 실패 retryCount 증가, status → FAILED
5회 이상 실패 status → DEAD
SENT + 1시간 경과 자동 삭제

단위 테스트 코드

1. Kafka 전송 성공 → SENT 상태로 저장

@Test
@DisplayName("이벤트를 PENDING 상태로 저장한다")
void publish_shouldStoreEventWithPendingStatus() {
    CustomEvent event = new TestCreatedEvent(1L, "test");
    when(snowflake.nextId()).thenReturn(123L);

    publisher.publish(event);

    ArgumentCaptor<OutboxEventEntity> captor = ArgumentCaptor.forClass(OutboxEventEntity.class);
    verify(repository).save(captor.capture());
    OutboxEventEntity saved = captor.getValue();

    assertEquals("TEST_CREATED", saved.getEventType());
    assertEquals(OutboxStatus.PENDING, saved.getStatus());
}

2. Kafka 전송 실패 시 FAILED 상태 + retry 증가

@Test
@DisplayName("Kafka 전송 실패 시 FAILED 상태로 저장된다")
void relayEvents_shouldMarkAsFailedOnException() {
    OutboxEventEntity event = mockEvent(OutboxStatus.PENDING);
    doThrow(new RuntimeException("Kafka 오류")).when(sender).send(any(), any(), any());

    when(repository.findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus.PENDING))
        .thenReturn(List.of(event));

    scheduler.relayEvents();

    assertEquals(OutboxStatus.FAILED, event.getStatus());
    assertEquals(1, event.getRetryCount());
}

3. 5회 이상 실패 → DEAD 전환

@Test
@DisplayName("5회 이상 Kafka 실패 시 DEAD 상태로 전환된다")
void event_shouldBeMarkedAsDeadAfterRetries() {
    OutboxEventEntity event = mockEvent(OutboxStatus.FAILED);
    event.markFailure("에러"); // retryCount = 1
    event.markFailure("에러");
    event.markFailure("에러");
    event.markFailure("에러");
    event.markFailure("에러"); // retryCount = 5 → DEAD

    assertEquals(OutboxStatus.DEAD, event.getStatus());
}

4. 1시간 경과된 SENT 이벤트 자동 삭제

@Test
@DisplayName("1시간 이상 지난 SENT 상태의 이벤트는 삭제된다")
void deleteOldSentEvents() {
    scheduler.deleteOldSentEvents();
    verify(repository).deleteByStatusAndUpdatedAtBefore(eq(OutboxStatus.SENT), any());
}

실제 코드 변경 요약

변경 전

private boolean sent;

변경 후

@Enumerated(EnumType.STRING)
private OutboxStatus status;

private int retryCount;
private String lastErrorMessage;
private LocalDateTime lastAttemptAt;

→ 이제 실패 사유, 재시도 횟수, 마지막 시도 시각까지 추적 가능


상태 전이 흐름 (정리)

[PENDING]
   │
   ├── Kafka 전송 성공 ─▶ [SENT]
   │
   └── Kafka 전송 실패 ─▶ [FAILED] (retry++)
                              │
                              └── retryCount ≥ 5 ─▶ [DEAD]

자동 삭제 스케줄러

@Scheduled(cron = "0 0 * * * *") // 매 정시마다
public void deleteOldSentEvents() {
    LocalDateTime cutoff = LocalDateTime.now().minusHours(1);
    repository.deleteByStatusAndUpdatedAtBefore(OutboxStatus.SENT, cutoff);
}

회고 및 확장 가능성

  • 장애 시 메시지 유실 없이 PENDING 유지
  • DEAD 상태 → 장애 탐지 지표로 활용 가능
  • 추후 개선 방향:
    • Kafka 재전송 backoff 도입
    • 이벤트 우선순위 (ex: 결제 로그 우선)
    • DEAD 이벤트 Slack 알림 연동

마무리하며

Outbox는 단순 메시지 큐잉을 넘어서 “신뢰할 수 있는 비동기 통신”을 구현하는 기초입니다. 무조건 배치, 무조건 enum이 정답은 아니지만, 작은 장애가 큰 문제로 이어질 수 있는 서비스에선 이런 구조가 안전장치가 됩니다.


728x90