BindProject

[Backend] 유저 활동 로그 수집 모듈 개발기 2-2편 :Kafka 전송 최적화를 위한 배치 전송 구조 개선기

dding-shark 2025. 6. 21. 21:52
728x90

설계 동기

앞서 1편에서는 boolean 기반 sent 필드로 Kafka 전송 여부를 관리하는 기존 구조의 한계를 진단했습니다. 특히 다음 세 가지 문제를 해결하고자 리팩토링이 시작되었습니다:

  • 전송 결과의 모호함: 실패와 성공을 구분할 수 없음
  • 재시도 전략 부재: Kafka 전송 실패 시 복구 불가
  • 배치 최적화 미흡: Kafka로 단일 이벤트를 순차 처리 → TPS 병목

설계 목표

이번 개선의 핵심 목표는 다음과 같습니다.

목표 항목 개선 전 개선 후
상태 표현 방식 boolean sent enum OutboxStatus
Kafka 전송 전략 단일 전송 배치 전송 + 상태 기반
오류 처리 실패 감지 불가 실패 정보 저장 및 재시도 가능
전송 주기 없음 스케줄러로 관리
삭제 정책 수동 삭제 SENT + 시간 조건 기반 자동 삭제

상태 모델링 - OutboxStatus

Kafka 전송 결과에 따라 상태를 분기해야 하므로 아래와 같이 enum 타입을 도입합니다.

public enum OutboxStatus {
    PENDING,     // 초기 상태, 전송 대기
    SENT,        // Kafka 전송 성공
    FAILED,      // 실패했지만 재시도 가능
    DEAD         // 5회 이상 실패 시 재시도 중단
}

→ 이제 “왜 안 보냈는지, 몇 번 실패했는지”까지 상태 기반으로 관리할 수 있습니다.


엔티티 구조 리팩토링

이전 구조:

private boolean sent;

리팩토링 후:

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OutboxStatus status;

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

→ 이 필드들을 통해 재시도 로직, 장애 모니터링, dead-letter 감지가 가능해졌습니다.


전송 스케줄러 구조

스프링의 @Scheduled 기능을 통해 5초마다 전송 시도하도록 구성합니다.

@Scheduled(fixedDelay = 5000)
public void relayPendingEvents() {
    List<OutboxEventEntity> events = repository.findByStatus(OutboxStatus.PENDING);

    for (OutboxEventEntity event : events) {
        try {
            kafkaProducer.send(event); // Kafka 발송
            event.markSuccess();       // SENT 전환
        } catch (Exception e) {
            event.markFailure(e.getMessage()); // FAILED 또는 DEAD 전환
        }
    }

    repository.saveAll(events);
}

 전송 전 status를 변경하지 않고, 실제로 Kafka에 보낸 이후에만 SENT로 변경합니다. 이게 정합성 보장의 핵심입니다.


상태 전이 흐름도

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

DEAD 상태는 장애를 외부 시스템에서 감지할 수 있는 실패 임계선 역할을 합니다.


자동 삭제 스케줄러

@Scheduled(cron = "0 0 * * * *") // 매 시 정각
public void deleteOldSentEvents() {
    LocalDateTime cutoff = LocalDateTime.now().minusHours(1);
    repository.deleteSentBefore(cutoff);
}

SENT 상태이면서 1시간 이상 경과한 이벤트는 자동 클린업 대상이 됩니다.


테스트 전략 요약

시나리오 기대 결과
Kafka 전송 성공 시 status → SENT
Kafka 전송 실패 시 retryCount 증가, status → FAILED
5회 이상 실패 시 status → DEAD
SENT 1시간 후 삭제 스케줄러 DB에서 제거됨

이 테스트들은 다음 편에서 코드와 함께 자세히 다룹니다.


운영 고려 사항

  • Kafka 장애 시 메시지 유실 없음status = PENDING 으로 유지
  • 전송 중단 감지status = DEAD 이벤트를 통해 모니터링 가능
  • 관리자용 Dead Letter 조회 API 도 추후 고려 가능

향후 확장 방향

  • Kafka 전송 재시도 backoff 정책 도입
  • 이벤트 우선순위 처리 (예: 결제 → 우선 전송)
  • Dead 상태 자동 Slack 알림 또는 관리자 대시보드 연결
  • 1인 백엔드 개발이라... 뭔가... 맘대로 할수있다는게 ... 좋으면서도 좋지않은...
    기능만 어거지로 구현하는것보단... 차근차근 고쳐나가면서 서비스를 유지하는게 개발자의 덕목이니까!
    힘들고 뭔가... 자괴감들어도,... ㅠㅠ 파이팅!

728x90