감사 로그와 독립 커밋
독립 감사 커밋의 보장 한계를 구분하고 business row와 outbox를 원자적으로 저장한 뒤 감사 투영을 비동기로 전달합니다.
감사는 따로 커밋한다는 문장만으로는 무엇을 보장하는지 알 수 없습니다. REQUIRES_NEW로 남긴 감사 행은 바깥이 롤백된 뒤에도 남을 수 있지만, 바로 그 때문에 실제로 커밋되지 않은 업무 변경을 설명하게 될 위험이 있습니다. 업무 상태가 커밋되었다는 이벤트라면 업무 행과 아웃박스(outbox) 행을 같은 로컬 트랜잭션에 넣고, 커밋된 아웃박스를 나중에 비동기로 감사 저장소까지 전달해야 합니다.
커밋된 업무 감사는 outbox에서 비동기로 전파한다
business와 outbox 원자 저장 후 relay 전달·inbox 중복 제거·projection 저장으로 소유 트랜잭션이 바뀐다.
TRANSACTIONAL OUTBOX · ATOMIC SAVE → ASYNC DELIVERY
업무와 outbox를 함께 커밋한 뒤 전달한다
업무 사실을 주장하는 이벤트는 business row와 같은 트랜잭션에 저장합니다. relay는 커밋 뒤 전달하고 소비자는 안정적인 event ID로 중복 효과를 막습니다.
- 명령 트랜잭션 · business + outbox
같은 REQUIRED 경계에 두 행을 넣어 둘 다 커밋하거나 둘 다 롤백합니다.
- 커밋 뒤 relay · 중복 가능
커밋된 outbox를 읽어 적어도 한 번 전달합니다. 네트워크 성공 뒤 상태 기록 전 장애로 같은 event가 다시 올 수 있습니다.
- 소비자 트랜잭션 · inbox + 감사 효과
고유 event ID inbox와 audit projection을 함께 저장합니다. 중복 event는 효과 없이 롤백되어 한 번만 반영됩니다.
REQUIRES_NEW 감사의 의미
독립 감사 커밋은 외부가 롤백해도 남을 수 있는 “시도”입니다. 커밋되지 않은 업무가 성공했다고 주장하지 않습니다. 두 결과의 원자성을 만들지 않습니다.
보장 수준에 맞는 전달
afterCommit은 영속 재시도 장치가 아닙니다. 필수 규제 감사는 fail-closed와 별도 장애 도메인, 분석용 기록은 허용 실패와 메트릭을 명시합니다.
durable outbox가 전달 의도를 보존합니다. relay와 소비자의 지연·재시도·영구 실패를 각각 운영합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 왼쪽 명령 트랜잭션은 business row와 outbox event를 원자적으로 저장한다. 커밋 후 outbox를 읽은 relay가 중복 가능한 전달을 한다. 오른쪽 소비자 트랜잭션은 고유 event ID inbox와 audit projection을 함께 저장해 중복 전달에 한 번만 효과를 낸다.
- 원래 가로 도표의 두 TX 영역/5노드/3방향 간선과 같다. 명령 영역의 business와 outbox는 같이 완료하지만 두 노드 사이에 전송 화살표를 만들지 않는다. outbox를 commit 뒤 relay가 읽고 relay→소비자 inbox→audit 효과로 전달한다. 소비자 inbox와 효과는 같은 로컬 TX이고 같은 event ID의 재전달은 효과를 늘리지 않는다. 두 영역 전체가 하나의 원자적 commit은 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 커밋된 업무 감사는 outbox에서 비동기로 전파한다 |
| 24 | 원래 가로 도표의 두 TX 영역/5노드/3방향 간선과 같다. 명령 영역의 business와 outbox는 같이 완료하지만 두 노드 사이에 전송 화살표를 만들지 않는다. outbox를 commit 뒤 relay가 읽고 relay→소비자 inbox→audit 효과로 전달한다. 소비자 inbox와 효과는 같은 로컬 TX이고 같은 event ID의 재전달은 효과를 늘리지 않는다. 두 영역 전체가 하나의 원자적 commit은 아니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 업무와 outbox commit 명령 transaction이 business row와 outbox를 함께 저장합니다. | 명령 TX: business·outbox · relay: commit 뒤 | 업무와 전달 근거가 함께 확정됩니다. |
| 중복 가능한 전달 relay가 outbox 사건을 소비자에게 전달합니다. | 전달: 중복 가능 · identity: event ID | DB 완료와 메시지 전달이 분리됩니다. |
| inbox와 projection 소비자 transaction이 고유 inbox와 projection을 함께 저장합니다. | 소비 TX: inbox·projection · 동일 event: 효과 한 번 | 중복 전달이 중복 업무 효과로 번지지 않습니다. |
커밋된 업무 감사는 outbox에서 비동기로 전파한다
business와 outbox 원자 저장 후 relay 전달·inbox 중복 제거·projection 저장으로 소유 트랜잭션이 바뀐다.
TRANSACTIONAL OUTBOX · ATOMIC SAVE → ASYNC DELIVERY
업무와 outbox를 함께 커밋한 뒤 전달한다
업무 사실을 주장하는 이벤트는 business row와 같은 트랜잭션에 저장합니다. relay는 커밋 뒤 전달하고 소비자는 안정적인 event ID로 중복 효과를 막습니다.
- 명령 트랜잭션 · business + outbox
같은 REQUIRED 경계에 두 행을 넣어 둘 다 커밋하거나 둘 다 롤백합니다.
- 커밋 뒤 relay · 중복 가능
커밋된 outbox를 읽어 적어도 한 번 전달합니다. 네트워크 성공 뒤 상태 기록 전 장애로 같은 event가 다시 올 수 있습니다.
- 소비자 트랜잭션 · inbox + 감사 효과
고유 event ID inbox와 audit projection을 함께 저장합니다. 중복 event는 효과 없이 롤백되어 한 번만 반영됩니다.
REQUIRES_NEW 감사의 의미
독립 감사 커밋은 외부가 롤백해도 남을 수 있는 “시도”입니다. 커밋되지 않은 업무가 성공했다고 주장하지 않습니다. 두 결과의 원자성을 만들지 않습니다.
보장 수준에 맞는 전달
afterCommit은 영속 재시도 장치가 아닙니다. 필수 규제 감사는 fail-closed와 별도 장애 도메인, 분석용 기록은 허용 실패와 메트릭을 명시합니다.
durable outbox가 전달 의도를 보존합니다. relay와 소비자의 지연·재시도·영구 실패를 각각 운영합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 왼쪽 명령 트랜잭션은 business row와 outbox event를 원자적으로 저장한다. 커밋 후 outbox를 읽은 relay가 중복 가능한 전달을 한다. 오른쪽 소비자 트랜잭션은 고유 event ID inbox와 audit projection을 함께 저장해 중복 전달에 한 번만 효과를 낸다.
- 원래 가로 도표의 두 TX 영역/5노드/3방향 간선과 같다. 명령 영역의 business와 outbox는 같이 완료하지만 두 노드 사이에 전송 화살표를 만들지 않는다. outbox를 commit 뒤 relay가 읽고 relay→소비자 inbox→audit 효과로 전달한다. 소비자 inbox와 효과는 같은 로컬 TX이고 같은 event ID의 재전달은 효과를 늘리지 않는다. 두 영역 전체가 하나의 원자적 commit은 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 커밋된 업무 감사는 outbox에서 비동기로 전파한다 |
| 24 | 원래 가로 도표의 두 TX 영역/5노드/3방향 간선과 같다. 명령 영역의 business와 outbox는 같이 완료하지만 두 노드 사이에 전송 화살표를 만들지 않는다. outbox를 commit 뒤 relay가 읽고 relay→소비자 inbox→audit 효과로 전달한다. 소비자 inbox와 효과는 같은 로컬 TX이고 같은 event ID의 재전달은 효과를 늘리지 않는다. 두 영역 전체가 하나의 원자적 commit은 아니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 업무와 outbox commit 명령 transaction이 business row와 outbox를 함께 저장합니다. | 명령 TX: business·outbox · relay: commit 뒤 | 업무와 전달 근거가 함께 확정됩니다. |
| 중복 가능한 전달 relay가 outbox 사건을 소비자에게 전달합니다. | 전달: 중복 가능 · identity: event ID | DB 완료와 메시지 전달이 분리됩니다. |
| inbox와 projection 소비자 transaction이 고유 inbox와 projection을 함께 저장합니다. | 소비 TX: inbox·projection · 동일 event: 효과 한 번 | 중복 전달이 중복 업무 효과로 번지지 않습니다. |
그림의 왼쪽은 명령 트랜잭션으로, 업무 행과 아웃박스 이벤트를 같은 REQUIRED 안에 넣어 둘 다 커밋하거나 둘 다 롤백합니다. 커밋된 뒤에 릴레이(relay)가 아웃박스를 읽어 중복될 수 있는 전달을 하고, 오른쪽의 소비자 트랜잭션은 고유한 이벤트 ID의 인박스(inbox)와 감사 투영을 함께 저장해, 같은 이벤트가 다시 와도 효과를 한 번만 남깁니다. 두 영역은 각각 로컬로 원자적일 뿐, 전체가 하나의 분산 원자 커밋인 것은 아닙니다.
먼저 기록의 의미를 나눕니다
업무 이벤트와 실패한 시도, 규제 감사, 캐시 무효화는 모두 커밋과 함께 남기는 기록처럼 보여도 커밋의 요구가 다릅니다.
| 기록 | 메인 롤백과의 운명 | 대표적인 구조 | 기록이 주장하는 사실 |
|---|---|---|---|
| 업무 이벤트 | 함께 롤백 | 같은 트랜잭션의 아웃박스 | 업무 상태가 커밋됨 |
| 일반적인 실패 시도 | 따로 남을 수 있음 | REQUIRES_NEW를 검토 | 누가 무엇을 시도함 |
| 규제 보안 감사 | DB 장애와도 분리가 필요 | 별도의 영속적인 추가 전용 대상 | 정책이 요구하는 보존 사실 |
| 캐시 무효화 | 커밋 뒤에 전달 | 가벼운 afterCommit이나 아웃박스 | 파생 상태를 새로 고쳐야 함 |
디버그 로그 한 줄은 규제 감사가 아닙니다. 보존 기간과 변조 방지, 접근 권한, 주체 식별, 시계의 기준, 삭제 정책이 따로 필요합니다. 하나의 AuditService가 이 네 의미를 모두 받지 않게 합니다. 인터페이스와 메서드 이름에서 시도와 커밋된 업무 이벤트, 규제 기록을 구분해야 잘못된 전파를 코드 리뷰에서 찾을 수 있습니다.
따로 하는 감사 커밋이 보장하지 않는 것
실패한 시도의 감사는 바깥이 쥔 관리 엔티티 대신, 요청 ID와 행위자 ID, 작업 코드, 결과 코드, 발생 시각 같은 불변 스냅샷을 받습니다. 바깥의 커밋되지 않은 행을 외래 키로 참조하면, 안쪽 연결에서 그 행이 보이지 않거나 잠금을 기다릴 수 있습니다.
따로 하는 감사가 먼저 커밋된 뒤 바깥이 실패하면 감사 행은 남습니다. 그 행은 게시글이 생성되었다가 아니라 게시글 생성을 시도했다고 기록해야 정확합니다. 따로 하는 커밋은 두 결과의 원자성을 만들지 않습니다.
안쪽 예외도 저절로 사라지지 않습니다. 감사 저장의 실패를 바깥이 허용하는 정책이라면 AuditUnavailable처럼 예상한 실패만 좁게 처리하고, 실패 카운터와 경보를 남깁니다. 반드시 남아야 하는 보안 기록이라면 실패할 때 메인 작업을 막는(fail-closed) 정책을 쓸 수 있지만, 가용성의 비용을 명시해야 합니다.
같은 데이터베이스와 풀을 쓴다면 DB 전체의 장애 때 메인과 감사가 함께 실패합니다. 바깥의 롤백과 떨어져 있는 것과 장애의 범위에서 떨어져 있는 것은 서로 다른 보장입니다.
afterCommit은 영속적인 전달 장치가 아닙니다
afterCommit()은 커밋 뒤의 캐시 무효화나 프로세스 안의 알림에 쓸모 있습니다. 하지만 콜백에서 브로커 전송이 실패해도 이미 끝난 DB 커밋을 되돌릴 수 없고, 커밋 직후 프로세스가 끝나면 다시 시도할 전달 의도도 남지 않습니다.
beforeCommit에서 원격 API를 먼저 호출해도 원자성이 생기지 않습니다. 원격이 성공한 뒤 로컬 커밋이 실패할 수 있기 때문입니다. 트랜잭션 동기화 훅은 로컬 생명 주기의 콜백일 뿐 분산 트랜잭션 프로토콜이 아닙니다. 느린 콜백은 트랜잭션 스레드에서 연결의 반환과 응답을 늦출 수도 있으므로, 재시도가 필요한 전달은 반드시 데이터베이스에 영속적인 작업 항목을 남깁니다.
더 깊이: @TransactionalEventListener가 조용히 하지 않는 일
동기화 훅을 직접 등록하는 대신 @TransactionalEventListener를 써도 성질은 같습니다. 이 애노테이션은 같은 커밋 단계 콜백 위에서 동작하며, API 문서에 따르면 기본 단계는 AFTER_COMMIT입니다. 그리고 두 가지 동작은 예외 없이 조용히 일어납니다. 첫째, 이벤트를 발행한 곳에 활성 트랜잭션이 없으면 fallbackExecution을 켜지 않는 한 이벤트는 처리되지 않고 버려집니다. 트랜잭션 밖의 배치나 테스트에서 발행한 이벤트의 리스너가 실행되지 않았다면 이 규칙부터 의심합니다.
둘째, AFTER_COMMIT이나 AFTER_ROLLBACK, AFTER_COMPLETION 단계의 리스너가 실행될 때 트랜잭션은 이미 끝났지만, 트랜잭션 자원은 아직 스레드에 묶여 있을 수 있습니다. 같은 문서는 이때 실행한 데이터 접근 코드가 원래 트랜잭션에 참여한 것처럼 동작하지만, 그 변경은 자원에 커밋되지 않는다고 경고합니다. 그래서 커밋 뒤 리스너에서 감사 행을 저장했는데 남지 않았다면 이 경우이고, 그런 저장이 정말 필요하다면 리스너가 새 물리 트랜잭션을 열게 하거나 처음부터 이 글의 아웃박스처럼 커밋 전에 같은 트랜잭션에 남겨야 합니다.
업무 행과 아웃박스 행을 한 번에 저장합니다
업무 명령은 같은 REQUIRED 물리 트랜잭션에서 업무 행과 아웃박스 이벤트를 함께 삽입합니다. 커밋하면 둘 다 보이고, 실패하면 둘 다 사라지며, 이것이 로컬 원자성의 범위입니다.
커밋된 뒤에는 별도의 릴레이가 아웃박스를 읽어 브로커나 감사 투영기로 전달합니다. 릴레이는 네트워크 전송에 성공한 뒤 발행 상태를 갱신하기 전에 멈출 수 있으므로, 적어도 한 번 전달된다(at-least-once)는 것을 전제로 합니다.
소비자는 바뀌지 않는 event_id를 인박스의 고유 키로 먼저 기록하고, 감사 효과를 같은 소비자 트랜잭션에 적용합니다. 같은 이벤트가 다시 오면 고유 키 충돌로 그 트랜잭션은 효과 없이 롤백되므로, 여러 번 전달되어도 감사 효과는 한 번만 남습니다. 이 구조가 보장하는 것은 두 구간으로 나뉩니다.
- 명령 트랜잭션은 업무 행과 아웃박스 행의 원자성을 보장합니다.
- 전달 구간은 커밋된 아웃박스를 비동기로 다시 시도하고, 소비자의 인박스로 중복을 없앱니다.
두 구간 전체가 하나의 분산 원자 커밋인 것은 아닙니다. 그 대신 영속적인 의도와 재시도, 멱등성으로 최종 전달을 운영합니다.
실행으로 원자성과 중복을 검증합니다
다음 Java 25 테스트는 Spring Boot 4.1.2와 Spring Framework 7.0.10, JUnit 6.0.3을 기준으로 합니다. 첫 테스트는 의도한 실패 전후에 업무 행과 아웃박스 행이 언제나 함께 남거나 함께 사라지는지 확인합니다.
둘째 테스트는 커밋 뒤의 릴레이 호출을 별도의 단계로 실행하고, 같은 아웃박스 이벤트를 두 번 전달합니다. AuditProjector는 프록시를 지나는 독립된 소비자 트랜잭션에서 인박스와 감사 행을 함께 쓰므로, 두 번째의 고유 키 충돌은 감사 효과를 늘리지 않습니다. 실제 운영에서는 스케줄러나 메시지 소비자가 이 릴레이 단계를 비동기로 호출합니다.
여기서는 릴레이를 호출할 때 바깥 트랜잭션이 없어, 기본값 REQUIRED인 project가 자신의 트랜잭션을 시작합니다. 코드가 REQUIRES_NEW를 강제하는 것은 아니므로, 트랜잭션을 가진 다른 호출자가 릴레이를 부르면 참여 관계를 다시 확인해야 합니다. 이 테스트는 두 번 직접 호출해 중복 효과를 검사할 뿐, 브로커 장애나 스케줄러의 재시도, 발행 상태의 갱신까지 실제로 실행하지는 않습니다.
package board.tx.propagation.outbox;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import javax.sql.DataSource;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.Transactional;
class TransactionalOutboxAuditTest {
private AnnotationConfigApplicationContext context;
private JdbcTemplate jdbc;
@BeforeEach
void openContext() {
context = new AnnotationConfigApplicationContext(Config.class);
jdbc = context.getBean(JdbcTemplate.class);
jdbc.execute("drop table if exists audit_log");
jdbc.execute("drop table if exists audit_inbox");
jdbc.execute("drop table if exists outbox");
jdbc.execute("drop table if exists posts");
jdbc.execute("create table posts(id bigint primary key, title varchar(120))");
jdbc.execute("create table outbox("
+ "event_id varchar(48) primary key, "
+ "aggregate_id bigint not null, "
+ "event_type varchar(48) not null)");
jdbc.execute("create table audit_inbox(event_id varchar(48) primary key)");
jdbc.execute("create table audit_log("
+ "event_id varchar(48) primary key, summary varchar(160) not null)");
}
@AfterEach
void closeContext() {
context.close();
}
@Test
void businessRowAndOutboxCommitOrRollbackTogether() {
var business = context.getBean(PostCreation.class);
assertThrows(
ExpectedBusinessFailure.class,
() -> business.create(41L, "failed", "evt-41", true));
assertEquals(0, count("posts"));
assertEquals(0, count("outbox"));
business.create(42L, "committed", "evt-42", false);
assertEquals(1, count("posts"));
assertEquals(1, count("outbox"));
}
@Test
void duplicateDeliveryProjectsOneAuditEffect() {
context.getBean(PostCreation.class)
.create(51L, "once", "evt-51", false);
var relay = context.getBean(OutboxRelay.class);
relay.deliver("evt-51");
relay.deliver("evt-51");
assertEquals(1, count("audit_inbox"));
assertEquals(1, count("audit_log"));
}
private int count(String table) {
return jdbc.queryForObject(
"select count(*) from " + table,
Integer.class);
}
record OutboxEvent(String eventId, long aggregateId, String eventType) { }
public static class PostCreation {
private final JdbcTemplate jdbc;
PostCreation(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public void create(
long postId,
String title,
String eventId,
boolean failBeforeCommit
) {
jdbc.update(
"insert into posts(id, title) values (?, ?)",
postId,
title);
jdbc.update(
"insert into outbox(event_id, aggregate_id, event_type) "
+ "values (?, ?, 'PostCreated')",
eventId,
postId);
if (failBeforeCommit) {
throw new ExpectedBusinessFailure();
}
}
}
public static class AuditProjector {
private final JdbcTemplate jdbc;
AuditProjector(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public void project(OutboxEvent event) {
jdbc.update(
"insert into audit_inbox(event_id) values (?)",
event.eventId());
jdbc.update(
"insert into audit_log(event_id, summary) values (?, ?)",
event.eventId(),
event.eventType() + ":post-" + event.aggregateId());
}
}
public static class OutboxRelay {
private final JdbcTemplate jdbc;
private final AuditProjector projector;
OutboxRelay(JdbcTemplate jdbc, AuditProjector projector) {
this.jdbc = jdbc;
this.projector = projector;
}
public void deliver(String eventId) {
OutboxEvent event = jdbc.queryForObject(
"select event_id, aggregate_id, event_type "
+ "from outbox where event_id = ?",
(resultSet, rowNumber) -> new OutboxEvent(
resultSet.getString("event_id"),
resultSet.getLong("aggregate_id"),
resultSet.getString("event_type")),
eventId);
try {
projector.project(event);
} catch (DuplicateKeyException alreadyProjected) {
// The projector transaction has already rolled back cleanly.
}
}
}
static final class ExpectedBusinessFailure extends RuntimeException {
private static final long serialVersionUID = 1L;
}
@Configuration
@EnableTransactionManagement(proxyTargetClass = true)
static class Config {
@Bean
DataSource dataSource() {
var source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:b92_outbox;DB_CLOSE_DELAY=-1");
return source;
}
@Bean
PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
@Bean
JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
@Bean
PostCreation postCreation(JdbcTemplate jdbc) {
return new PostCreation(jdbc);
}
@Bean
AuditProjector auditProjector(JdbcTemplate jdbc) {
return new AuditProjector(jdbc);
}
@Bean
OutboxRelay outboxRelay(JdbcTemplate jdbc, AuditProjector projector) {
return new OutboxRelay(jdbc, projector);
}
}
}릴레이와 소비자를 운영합니다
여러 릴레이 인스턴스가 같은 행을 가져가지 않도록, for update skip locked나 임대, 명시적인 상태 전이 가운데 쓰는 데이터베이스에 맞는 선점 방법을 고릅니다. 전송에 성공한 뒤 상태를 갱신하기 전에 장애가 나면 중복이 생길 수 있다는 전제는 유지합니다.
아웃박스에는 발행되지 않은 개수와 가장 오래된 이벤트의 나이, 발행 지연, 재시도 횟수, 영구 실패의 개수를 둡니다. 소비자에는 인박스의 중복 수와 투영 지연, 실패 원인을 둡니다. 성공한 행의 수만으로는 멈춘 전달을 찾을 수 없습니다.
이벤트 페이로드에는 스키마 버전을 넣고 개인 정보를 최소로 줄입니다. 영구 실패는 마지막 오류와 재시도 횟수를 보존한 격리 상태로 옮기며, 운영자가 다시 처리한 행위 자체도 감사 기록에 남깁니다. 트레이스 ID는 명령과 아웃박스, 감사 투영을 잇는 관찰 정보일 뿐 데이터베이스의 기본 키가 아니므로, 서버가 발급한 바뀌지 않는 event_id와 따로 관리합니다.
정리
- 기록은 업무 이벤트와 실패한 시도, 규제 감사, 캐시 무효화로 의미를 나누며, 하나의 서비스가 모든 의미를 받지 않게 합니다.
REQUIRES_NEW감사는 바깥이 롤백되어도 남는 시도의 기록일 뿐, 두 결과의 원자성을 만들지 않고 같은 DB 장애와도 떨어져 있지 않습니다.afterCommit은 영속적인 재시도 장치가 아니므로, 재시도가 필요한 전달은 업무 행과 같은 트랜잭션의 아웃박스에 남깁니다.- 릴레이는 적어도 한 번 전달하고, 소비자는 바뀌지 않는 이벤트 ID의 인박스와 효과를 같은 트랜잭션에 저장해 중복을 없앱니다.
- 필수 규제 기록은 실패하면 막고 장애 범위를 나누며, 분석용 기록은 실패를 허용하되 메트릭을 남기는 식으로 정책의 이름을 드러냅니다.
연습 문제
1. 전달과 완료 기록 사이에서 멈추면
업무 명령이 posts와 outbox에 한 행씩 커밋했습니다. 소비자는 같은 이벤트 ID의 inbox와 audit에 한 행씩 커밋했지만, 릴레이가 발행 완료 상태를 기록하기 전에 멈췄고, 다시 시작한 뒤 같은 이벤트를 또 전달합니다. 각 테이블의 행 수와 재시도할 때 필요한 식별자를 답하세요. 이번에는 처음의 audit 삽입이 실패해 소비자 트랜잭션이 롤백된 경우와도 비교하세요.
해설 보기
첫 경우에는 posts 1행, outbox 1행, inbox 1행, audit 1행입니다. 예제는 아웃박스를 지우지 않으므로 outbox의 행 수가 전달 완료 횟수를 뜻하지는 않습니다. 같은 이벤트 ID로 다시 전달하면 inbox의 고유 키에서 충돌하고 그 소비자 트랜잭션은 효과 없이 롤백되어, inbox와 audit은 각각 한 행으로 유지됩니다. 재시도마다 새 이벤트 ID를 만들면 이 중복 방지가 깨집니다.
둘째 경우에는 처음의 소비자 실패 뒤 inbox와 audit이 0행이지만, 명령의 posts와 outbox는 1행씩 남아 있습니다. 인박스의 삽입과 감사 효과가 함께 롤백되었으므로, 같은 이벤트 ID로 정상적으로 다시 시도할 수 있습니다. 다만 릴레이가 나중에 다시 실행된다는 조건과, 성공하기 전까지의 지연은 보장을 설명할 때 빼먹지 않습니다.
2. 실패 정책을 이름으로 드러내기
필수 규제 기록과 분석용 시도 기록, 커밋된 업무 감사, 캐시나 프로세스 안의 후처리에 대해 각각 어떤 실패 정책과 구조를 고르는지 정리해 보세요.
해설 보기
필수 규제 기록은 기록에 실패하면 업무를 막는 정책과 별도의 장애 범위를 둡니다. 분석용 시도 기록은 실패해도 업무를 계속하되 실패 메트릭을 남기고, 다시 처리하거나 허용된 유실을 문서로 남깁니다. 커밋된 업무 감사는 업무 행과 아웃박스를 원자적으로 저장하고, 비동기 릴레이와 멱등한 인박스로 전달합니다. 캐시나 프로세스 안의 후처리는 유실을 허용하는 범위가 작고 다시 재생할 필요가 없을 때만 afterCommit을 씁니다. REQUIRES_NEW를 원자성의 해결책으로 부풀리지 않고, 로컬로 원자적인 구간과 비동기 전달 구간을 분명히 나누는 것이 설계의 핵심입니다.
이것으로 11장을 마칩니다. 11장에서는 검사 예외를 어댑터에서 바꾸는 방법과 Spring의 데이터 접근 예외를 다음 행동으로 분류하는 방법에서 시작해, @Transactional 프록시와 롤백 규칙, 프록시 우회, 전파와 REQUIRES_NEW의 자원, 아웃박스를 쓰는 감사까지 예외와 트랜잭션이 맞물리는 방식을 살펴봤습니다. 다음 장의 동시 요청과 공유 상태부터는 여러 요청이 같은 객체를 함께 쓸 때 생기는 문제와, 스레드 로컬과 템플릿, 프록시가 그 문제를 어떻게 다루는지 다룹니다.