12/19

졸용·2025년 12월 19일

TIL

목록 보기
139/144

🔹 outbox 엔티티 알아보기

import com.klp.common.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "p_order_outbox_events")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderOutboxEvent extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(nullable = false)
    private UUID orderId;

    @Column(nullable = false)
    private String eventType;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String payload;

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

    @Column(nullable = false)
    private Integer retryCount = 0;

    private LocalDateTime publishedAt;

    private LocalDateTime lastRetryAt;

    public static OrderOutboxEvent create(UUID orderId,
        String eventType, String payload) {
        OrderOutboxEvent event = new OrderOutboxEvent();
        event.orderId = orderId;
        event.eventType = eventType;
        event.payload = payload;
        event.status = OrderOutboxStatus.PENDING;
        return event;
    }

    private static final int MAX_RETRY_COUNT = 20;           // 최대 20회
    private static final long INITIAL_BACKOFF_MILLIS = 1000L;  // 초기 1초
    private static final long MAX_BACKOFF_MILLIS = 300000L;    // 최대 5분

    public void markAsPublishing() {
        this.status = OrderOutboxStatus.PUBLISHING;
        this.lastRetryAt = LocalDateTime.now();
        this.retryCount++;
    }

    public void markAsPublished() {
        this.status = OrderOutboxStatus.PUBLISHED;
        this.publishedAt = LocalDateTime.now();
    }

    public void markAsFailed() {
        this.retryCount++;
        this.lastRetryAt = LocalDateTime.now();

        // 20회 초과 시 FAILED 처리
        if (this.retryCount >= MAX_RETRY_COUNT) {
            this.status = OrderOutboxStatus.FAILED;
        }
    }

    public boolean canRetry() {
        return this.status != OrderOutboxStatus.PUBLISHED
            && this.status != OrderOutboxStatus.FAILED;
    }

    /**
     * AWS/Google Cloud 권장 전략 1초 → 2초 → 4초 → 8초 → ... → 최대 5분
     */
    // 1초 2초 ... 최대 5분 -> AWS, GOOGLE 방식
    public long getBackoffMillis() {
        // Exponential: 1초 * 2^(retryCount-1)
        long exponentialBackoff = INITIAL_BACKOFF_MILLIS * (1L << (this.retryCount - 1));

        // 최대값 제한
        long cappedBackoff = Math.min(exponentialBackoff, MAX_BACKOFF_MILLIS);

        // Jitter 추가 (0~20% 랜덤 변동)
        double jitterFactor = 0.8 + (Math.random() * 0.4); // 0.8 ~ 1.2
        return (long) (cappedBackoff * jitterFactor);
    }

    public boolean shouldRetryNow() {
        if (!canRetry()) {
            return false;
        }

        // 첫 시도이거나 backoff 시간이 지났으면 재시도
        if (this.lastRetryAt == null) {
            return true;
        }

        long elapsed = java.time.Duration.between(this.lastRetryAt, LocalDateTime.now())
            .toMillis();
        return elapsed >= getBackoffMillis();
    }

    public void resetToPending() {
        if (this.status == OrderOutboxStatus.PUBLISHING) {
            this.status = OrderOutboxStatus.PENDING;
        }
    }

}


🔹 이 클래스의 역할 (왜 존재하는가)

이 코드는 Order 서비스에서 Outbox Pattern을 구현한 엔티티로,
“주문 트랜잭션과 이벤트 발행을 분리하면서도 정합성을 보장”하기 위한 핵심 구성요소다.
전체 구조를 기준 → 필드 → 상태 전이 → 재시도/백오프 전략 순서로 알아봤다.

OrderOutboxEvent는 다음 목적을 가진다.

  • Order DB 트랜잭션 안에서 이벤트를 안전하게 저장
  • Kafka / 메시지 브로커 발행 실패 시에도 이벤트 유실 방지
  • 비동기 재시도 + 지수 백오프를 통해 안정적인 이벤트 발행 보장
  • Saga / 이벤트 기반 아키텍처에서 Exactly-once에 가까운 동작 기반

즉,

“주문은 성공했는데 이벤트는 못 보냈다”
절대 발생하지 않게 만드는 구조



🔹 테이블 / 엔티티 구조

@Entity
@Table(name = "p_order_outbox_events")
public class OrderOutboxEvent extends BaseEntity
  • p_order_outbox_events

  • 일반 도메인 엔티티가 아니라 이벤트 로그 / 메시지 큐 대체 테이블

  • BaseEntity:

    • 보통 createdAt, updatedAt 같은 audit 필드 포함


🔹 핵심 필드 알아보기

🔸 식별자 및 이벤트 기본 정보

@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
  • Outbox 이벤트 자체의 ID
  • orderId와 1:N 관계 가능 (한 주문에서 여러 이벤트)
@Column(nullable = false)
private UUID orderId;
  • 어떤 주문에서 발생한 이벤트인지 식별
@Column(nullable = false)
private String eventType;
  • 예: OrderAfterCreateV1, OrderCanceledV1
  • Consumer가 분기 처리할 수 있는 기준값
@Column(columnDefinition = "TEXT", nullable = false)
private String payload;
  • Kafka로 보낼 JSON 직렬화 데이터
  • 스키마 변경에도 유연 (TEXT)

🔸 상태 관리 필드 (Outbox의 핵심)

@Enumerated(EnumType.STRING)
private OrderOutboxStatus status;

일반적인 상태 흐름:

PENDING → PUBLISHING → PUBLISHED
                   ↘ FAILED
private Integer retryCount = 0;
  • 발행 시도 횟수
  • 백오프 계산의 기준값
private LocalDateTime publishedAt;
private LocalDateTime lastRetryAt;
  • 성공 시각 / 마지막 시도 시각 기록
  • 모니터링, 운영 분석에 매우 중요


🔹 객체 생성 방식 (팩토리 메서드)

public static OrderOutboxEvent create(UUID orderId, String eventType, String payload)

의도:

  • 무조건 PENDING 상태로 시작
  • 생성 규칙을 한 곳에서 통제
  • 생성자 노출 차단 (protected)

👉 트랜잭션 내부에서 다음처럼 사용됨

OrderOutboxEvent event =
    OrderOutboxEvent.create(orderId, "OrderAfterCreateV1", payload);
outboxRepository.save(event);


🔹 재시도 / 백오프 정책 (매우 중요한 부분)

🔸 재시도 제한 상수

private static final int MAX_RETRY_COUNT = 20;
private static final long INITIAL_BACKOFF_MILLIS = 1000L;
private static final long MAX_BACKOFF_MILLIS = 300000L;
  • 최대 20회 시도
  • 1초 → 2초 → 4초 → … → 최대 5분
  • AWS / Google Cloud 권장 전략과 동일

🔸상태 전이 메서드

발행 시도 시작

public void markAsPublishing() {
    this.status = PUBLISHING;
    this.lastRetryAt = now;
    this.retryCount++;
}
  • 실제 Kafka 전송 직전에 호출
  • 중복 실행 방지용 락 역할

발행 성공

public void markAsPublished()
  • 최종 성공 상태
  • 이후 절대 재시도하지 않음

발행 실패

public void markAsFailed()
  • retryCount 증가
  • 20회 초과 시 FAILED 확정
  • 운영자가 수동 개입해야 하는 상태


🔹 재시도 가능 여부 판단

public boolean canRetry()
  • PUBLISHED / FAILED → ❌
  • PENDING / PUBLISHING → ⭕
public boolean shouldRetryNow()
  • 최초 시도 → 즉시 가능

  • 이후:

    • 현재 시간 - lastRetryAt >= backoffMillis

👉 스케줄러가 이 메서드만 믿고 동작 가능



🔹 지수 백오프 + Jitter 구현

long exponentialBackoff =
    1* 2^(retryCount - 1);
double jitterFactor = 0.8 ~ 1.2;

왜 Jitter를 넣었나?

  • 여러 이벤트가 동시에 재시도되는 Thundering Herd 방지
  • 클라우드 환경에서 권장되는 방식

이 구현은 실무 기준에서도 매우 잘 짜인 편이다. (권장되는 방식)



🔹 resetToPending()의 의미

public void resetToPending()

사용 시점:

  • PUBLISHING 상태에서

    • 앱 크래시
    • 트랜잭션 롤백
    • 락 타임아웃

같은 상황이 발생했을 때

👉 다시 잡아서 재처리 가능하도록 복구



🔹 이 엔티티가 보장하는 것

이 설계로 인해 보장되는 특성:

항목보장 여부
이벤트 유실 방지
재시도 자동화
트랜잭션 정합성
장애 내성
클라우드 친화적


🔹 한 줄 요약

OrderOutboxEvent
주문 서비스에서 Kafka 이벤트 발행을 “운영 수준으로 안전하게” 만드는 Outbox 엔티티이며,
지수 백오프 + 재시도 + 상태 머신이 결합된 실전형 구현이다.

profile
꾸준한 공부만이 답이다

0개의 댓글