영호

[spring] Transactional outbox pattern 을 활용해 이벤트 유실 개선하기 본문

Spring

[spring] Transactional outbox pattern 을 활용해 이벤트 유실 개선하기

0h0 2024. 4. 27. 23:54

PR 링크: 링크

들어가면서

진행했던 프로젝트에서 이벤트를 기반으로 핵심 도메인 로직과 이에 따른 부가로직을 이벤트로 분리하였습니다. 그러나 기존의 구조에선 도메인 완료 이벤트에 따른 부가로직의 수행까진 보장하지 못합니다.

 

핵심 도메인 로직: 쿠폰에 스탬프 적립

부가 로직: 스탬프 적립 시 방문 기록 저장

기존 구조

2번 과정(방문 기록 저장)이 실패하면 스탬프는 적립 되었지만 이에 해당하는 방문 기록이 없어져 데이터 정합성이 틀어집니다. 즉, 스탬프 적립 이벤트가 유실됩니다.

문제 상황

[도메인 로직 완료 이벤트 유실]

위에서 설명했듯이 스탬프 적립 이벤트에 따른 부가기능을 수행하는 로직 실패 시 데이터 정합이 틀어지는 문제가 존재합니다.

 

스탬프 적립 시 부가로직은 스탬프 적립 알림, 방문 기록 추가 등이 있습니다. 알림의 경우 완벽한 정합성이 요구되진 않지만 방문 기록의 경우 정합성을 맞춰야 하지만, 이를 실시간으로 처리할 필요는 없습니다.

 

그러나, 방문 기록 저장 과정에서 예외가 발생할 경우 정합성을 유지할 수 없고 스탬프 적립 완료 이벤트는 그대로 유실됩니다.

문제 해결

이벤트 기반으로 프로젝트를 구성할 때 이벤트 유실을 방지하여 결과적 일관성을 맞추기 위해 Transactional outbox pattern 을 활용할 수 있습니다.

 

문제 해결을 위해 유실되는 것을 방지하고 결과적 일관성을 맞추기 위해 Transactional outbox pattern을 사용했습니다.

 

스탬프 적립 완료 이벤트를 저장하는 event outbox 테이블을 활용해 해당 이벤트의 부가로직 성공여부를 관리하고, 실패 이벤트가 있다면 이를 다시 처리하여 결과적 일관성을 맞추도록 했습니다.

  1. 1. 스탬프 적립 로직 수행
  2. 1번 로직 커밋 이전에 event outbox 저장
  3. 스탬프 적립 이벤트 발행
  4. 방문 기록 저장
    • 저장 성공 시 event outbox 는 true 로 업데이트
  • 주기적으로 fail 상태의 event outbox 를 조회하여 해당 이벤트 재발행

코드

아래 코드는 스탬프 생성 로직이 저장되기 전 스탬프 생성 이벤트를 저장합니다. BEFORE_COMMIT 을 활용하여 비즈니스 로직과, event outbox 저장을 원자적으로 관리합니다.

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
@Transactional
public void saveStampAccumulateOutbox(StampCreateEvent stampCreateEvent) {
    UUID eventId = stampCreateEvent.getEventId();
    Long cafeId = stampCreateEvent.getCafeId();
    Long customerId = stampCreateEvent.getCustomerId();
    int stampCount = stampCreateEvent.getStampCount();

    StampAccumulateEventOutbox stampOutbox = new StampAccumulateEventOutbox(eventId, cafeId, customerId, stampCount);
    stampAccumulateEventOutboxRepository.save(stampOutbox);
}

 

아래 코드는 스탬프 생성 로직 커밋 이후 이에 따른 부가기능인 방문기록을 저장하고, outbox 의 상태를 성공으로 바꿉니다.

@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createVisitHistory(StampCreateEvent stampCreateEvent) {
    Cafe cafe = findCafe(stampCreateEvent.getCafeId());
    Customer customer = findCustomer(stampCreateEvent.getCustomerId());
    VisitHistory visitHistory = new VisitHistory(cafe, customer, stampCreateEvent.getStampCount());
    visitHistoryRepository.save(visitHistory);

    StampAccumulateEventOutbox stampAccumulateEventOutbox = findStampCreateEvent(stampCreateEvent);
    stampAccumulateEventOutbox.success();
}

 

아래 코드는 스케줄링을 통해 fail 상태의 outbox 를 조회하고 재발행합니다.

@Transactional(readOnly = true)
@Scheduled(cron = "*/10 * * * * *")
public void scheduledStampCreateEvent() {
    List<StampAccumulateEventOutbox> falseStampCreateEvents = stampAccumulateEventOutboxRepository.findByStateIsFalse();
    falseStampCreateEvents.forEach(event -> applicationEventPublisher.publishEvent(event));
}

 

구현 중 겪은 문제점

이벤트 식별자가 없다.

 

현재 이벤트를 저장하는 로직도 @TransactionalEventListener(BEFORE_COMMIT) 을 통해 비즈니스 로직과 분리한 상황입니다. 즉, 도메인 완료 이벤트를 발행하는 service 의 비즈니스 로직에선 이벤트의 식별자를 모르는 상황입니다.

 

도메인 이벤트 Listener 가 자신의 로직을 수행한 후 event 의 상태를 success 로 변경하기 위해선 해당 이벤트의 식별자가 있어야 됩니다. 예를 들어, 쿠폰 생성에 관한 이벤트라면 해당 이벤트를 식별하기 위해 생성된 쿠폰의 ID 를 쓸 수 있을것입니다.

 

그러나 현재 스탬프 적립의 경우 CASCADE.PERSIST 옵션을 통해 저장하고 있습니다. 즉 별도의 repository.save 를 호출하여 저장하지 않기 때문에 생성된 스탬프의 ID 를 알 수 없습니다.

쿠폰과 스탬프는 1 : N 관계입니다. 
@Entity
public class Coupon extends BaseDate {

	@Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;
    
    @OneToMany(mappedBy = "coupon", fetch = LAZY, cascade = CascadeType.ALL)
    private List<Stamp> stamps = new ArrayList<>();
    
    // 나머지 코드
}

 

이를 해결하기 위해, Event 엔티티의 식별자의 경우 @GeneratedValue 를 사용하지 않고 UUID 를 통해 직접 식별자를 주입해서 해결했습니다.

public class StampAccumulateEventOutbox {

    @Id
    private UUID id;
    
    // 나머지 코드
}
public class StampCreateEventCommand {

    private StampCreateEventCommand() {
    }

    public static StampCreateEvent createEvent(Coupon coupon, int stampCount) {
        return new StampCreateEvent(UUID.randomUUID(), coupon.getCafe().getId(), coupon.getCustomer().getId(), stampCount);
    }
}

 

이렇게 UUID 를 통해 이벤트 식별자를 만들고 이를 활용하여 문제를 해결했습니다.

Comments