도메인 이벤트와 커밋 이후 처리
앞 장까지의 요청 처리는 트랜잭션이 끝나면 응답을 돌려주는 동기 흐름이었습니다. 하지만 학습 세션 완료 뒤 보고서를 갱신하거나 알림을 보내는 작업까지 같은 트랜잭션에 넣으면, 부수 작업 장애가 원본 상태 변경까지 되돌릴 수 있습니다.
도메인 이벤트는 세션이 완료됐다는 사실을 트랜잭션 안에서 발행하고, @TransactionalEventListener(AFTER_COMMIT)은 원본 변경이 확정된 뒤에만 후속 작업을 시작합니다. 발행 시점과 listener 실행 시점을 분리해서 읽는 것이 중요합니다.
이벤트는 이미 일어난 사실을 표현한다
이벤트 이름은 CompleteSession 같은 명령형보다 SessionCompleted처럼 이미 일어난 사실을 표현합니다. listener가 필요한 값을 다시 조회하지 않아도 되도록 식별자, 과목, 분량, 발생 시각처럼 사건 당시의 최소 스냅샷을 담습니다.
완료 이벤트와 listener를 연결한다
이벤트 객체를 선언하는 것만으로는 아무 일도 일어나지 않습니다. 세션 상태를 바꾸는 서비스가 같은 트랜잭션 안에서 ApplicationEventPublisher로 사건을 발행해야 하고, Spring은 commit 결과를 본 뒤 listener 호출 여부를 결정합니다.
package com.andongmin.studylog.report;
import java.time.Instant;
import java.util.UUID;
public interface ReportRefreshRepository {
boolean insertIfAbsent(UUID eventId, String subject, Instant occurredAt);
}package com.andongmin.studylog.session;
import java.time.Instant;
import java.util.UUID;
public record SessionCompleted(UUID eventId, UUID sessionId,
String subject, int minutes, Instant occurredAt) {
}private final ApplicationEventPublisher events;
private final Clock clock;
@Transactional
public StudySession complete(UUID id) {
StudySession session = load(id);
session.complete();
events.publishEvent(new SessionCompleted(UUID.randomUUID(), session.getId(),
session.getSubject(), session.getMinutes(), Instant.now(clock)));
return session;
}package com.andongmin.studylog.report;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import com.andongmin.studylog.session.SessionCompleted;
@Component
public class ReportRefreshListener {
private final ReportRefreshWriter writer;
public ReportRefreshListener(ReportRefreshWriter writer) {
this.writer = writer;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCompleted(SessionCompleted event) {
writer.enqueue(event);
}
}package com.andongmin.studylog.report;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.andongmin.studylog.session.SessionCompleted;
@Service
public class ReportRefreshWriter {
private final ReportRefreshRepository repository;
public ReportRefreshWriter(ReportRefreshRepository repository) {
this.repository = repository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void enqueue(SessionCompleted event) {
repository.insertIfAbsent(event.eventId(), event.subject(), event.occurredAt());
}
}AFTER_COMMIT 시점에는 원본 트랜잭션이 이미 끝났습니다. 따라서 listener가 DB에 작업을 기록하려면 ReportRefreshWriter처럼 새 트랜잭션을 명시해야 합니다. insertIfAbsent가 실제 중복을 막으려면 구현 테이블에도 event_id UNIQUE 제약이 있어야 합니다.
commit과 rollback에서 listener 호출이 갈린다
컴파일만으로는 transaction phase를 검증할 수 없습니다. 먼저 아래 명령으로 발행자와 listener의 타입 연결을 확인하고, 통합 테스트를 추가할 때는 같은 서비스 호출을 commit한 경우와 rollback한 경우로 나누어 repository 호출 횟수를 비교합니다.
./mvnw -q compilecommit 시나리오에서는 새 트랜잭션에 eventId가 한 번 저장되고, rollback 시나리오에서는 listener가 실행되지 않아야 합니다. 이 문서에는 `ReportRefreshListenerIntegrationTest` 파일을 제공하지 않으므로 그 이름의 테스트가 이미 존재한다고 가정해 실행하지 않습니다.커밋 뒤 실패는 원본을 되돌리지 못한다
writer가 실패해도 세션 완료 트랜잭션은 이미 commit됐습니다. 후속 작업이 반드시 전달되어야 한다면 로그만 남기고 끝낼 수 없으며, 7장에서 다룬 outbox처럼 원본 변경과 이벤트 저장을 같은 트랜잭션에 넣고 별도 worker가 재시도하는 구조를 선택합니다.
커밋 경계를 분리했으므로 다음에는 listener가 시작한 무거운 작업을 별도 실행기로 넘길 때 큐와 실패 상태를 어떻게 보존할지 살펴봅니다.