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는 다음 목적을 가진다.
즉,
“주문은 성공했는데 이벤트는 못 보냈다”
→ 절대 발생하지 않게 만드는 구조
@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;
@Column(nullable = false)
private UUID orderId;
@Column(nullable = false)
private String eventType;
OrderAfterCreateV1, OrderCanceledV1@Column(columnDefinition = "TEXT", nullable = false)
private String payload;
@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)
의도:
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;
public void markAsPublishing() {
this.status = PUBLISHING;
this.lastRetryAt = now;
this.retryCount++;
}
public void markAsPublished()
public void markAsFailed()
FAILED 확정public boolean canRetry()
public boolean shouldRetryNow()
최초 시도 → 즉시 가능
이후:
현재 시간 - lastRetryAt >= backoffMillis👉 스케줄러가 이 메서드만 믿고 동작 가능
long exponentialBackoff =
1초 * 2^(retryCount - 1);
double jitterFactor = 0.8 ~ 1.2;
왜 Jitter를 넣었나?
이 구현은 실무 기준에서도 매우 잘 짜인 편이다. (권장되는 방식)
resetToPending()의 의미public void resetToPending()
사용 시점:
PUBLISHING 상태에서
같은 상황이 발생했을 때
👉 다시 잡아서 재처리 가능하도록 복구
이 설계로 인해 보장되는 특성:
| 항목 | 보장 여부 |
|---|---|
| 이벤트 유실 방지 | ✅ |
| 재시도 자동화 | ✅ |
| 트랜잭션 정합성 | ✅ |
| 장애 내성 | ✅ |
| 클라우드 친화적 | ✅ |
이
OrderOutboxEvent는
주문 서비스에서 Kafka 이벤트 발행을 “운영 수준으로 안전하게” 만드는 Outbox 엔티티이며,
지수 백오프 + 재시도 + 상태 머신이 결합된 실전형 구현이다.