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
'BindProject' 카테고리의 다른 글
| [Backend] 유저 활동 로그 수집 모듈 개발기 4편 : 로그 수집 서비스 구현 (0) | 2025.06.22 |
|---|---|
| [Backend] 유저 활동 로그 수집 모듈 개발기 3편 : 로그 메타데이터 구현 (0) | 2025.06.22 |
| [Backend] 유저 활동 로그 수집 모듈 개발기 2-2편 :Kafka 전송 최적화를 위한 배치 전송 구조 개선기 (0) | 2025.06.21 |
| [Backend] 유저 활동 로그 수집 모듈 개발기 2-1편 : Kafka Outbox 패턴 기반 유저 활동 로그 수집기 리팩토링기(문제 확인) (0) | 2025.06.21 |
| [Backend] 유저 활동 로그 수집 모듈 개발기 2편 – 메모리 버퍼 기반 로그 수집기 설계와 구현 (0) | 2025.06.21 |