감사 로그와 독립 커밋
독립 감사 커밋의 보장 한계를 구분하고 business row와 outbox를 원자적으로 저장한 뒤 감사 투영을 비동기로 전달합니다.
“감사는 따로 커밋한다”는 문장만으로는 어떤 사실을 보장하는지 알 수 없습니다.
REQUIRES_NEW 감사 행은 외부 롤백 뒤에도 남을 수 있지만, 바로 그 때문에 실제로 커밋되지 않은 업무 변경을 설명할 위험이 있습니다. 업무 상태가 커밋됐다는 이벤트라면 business row와 outbox row를 같은 로컬 트랜잭션에 넣고, 커밋된 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: 효과 한 번 | 중복 전달이 중복 업무 효과로 번지지 않습니다. |
먼저 기록의 의미를 나눈다
업무 이벤트, 실패 시도, 규제 감사, 디버그 로그는 이름이 비슷해도 커밋 요구가 다릅니다.
| 기록 | 메인 롤백과 운명 | 대표 구조 | 기록이 주장하는 사실 |
|---|---|---|---|
| 업무 이벤트 | 함께 롤백 | 같은 트랜잭션의 outbox | 업무 상태가 커밋됨 |
| 일반 실패 시도 | 독립 보존 가능 | REQUIRES_NEW 후보 | 누가 무엇을 시도함 |
| 규제 보안 감사 | DB 장애까지 분리 필요 | 별도 영속적 추가 전용 대상 | 정책이 요구하는 보존 사실 |
| 캐시 무효화 | 커밋 뒤 전달 | 가벼운 afterCommit 또는 outbox | 파생 상태를 새로 고쳐야 함 |
디버그 로그 한 줄은 규제 감사가 아닙니다. 보존 기간, 변조 방지, 접근 권한, 주체 식별, 시계 기준과 삭제 정책이 별도로 필요합니다.
하나의 AuditService가 네 의미를 모두 받지 않게 합니다. 인터페이스와 메서드 이름에서 “시도”, “커밋된 업무 이벤트”, “규제 기록”을 구분해야 잘못된 전파를 코드 리뷰에서 발견할 수 있습니다.
독립 감사 커밋이 보장하지 않는 것
실패 시도 감사는 외부가 보유한 관리 엔티티 대신 요청 ID, 행위자 ID, 작업 코드, 결과 코드와 발생 시각 같은 불변 스냅샷을 받습니다. 외부 미커밋 행의 외래 키를 참조하면 내부 연결에서 행이 보이지 않거나 잠금을 기다릴 수 있습니다.
독립 감사가 먼저 커밋된 뒤 외부가 실패하면 감사 행은 남습니다. 그 행은 “게시글이 생성됐다”가 아니라 “게시글 생성을 시도했다”라고 기록해야 정확합니다. 독립 커밋은 두 결과의 원자성을 만들지 않습니다.
내부 예외도 자동으로 사라지지 않습니다. 감사 저장 실패를 외부가 허용하는 정책이라면 AuditUnavailable처럼 기대한 실패만 좁게 처리하고 실패 카운터와 경보를 남깁니다. 필수 보안 기록이면 fail-closed로 메인 작업을 차단할 수 있지만 가용성 비용을 명시해야 합니다.
같은 데이터베이스와 풀을 사용하면 DB 전체 장애 때 메인과 감사가 함께 실패합니다. 외부 롤백 격리와 장애 도메인 격리는 서로 다른 보장입니다.
afterCommit은 영속적 전달 장치가 아니다
afterCommit()은 커밋 뒤 캐시 무효화나 프로세스 내부 알림을 실행하는 데 유용합니다. 하지만 콜백에서 브로커 전송이 실패해도 이미 끝난 DB 커밋을 되돌릴 수 없습니다. DB 커밋 직후 프로세스가 종료되면 재시도할 전달 의도도 남지 않습니다.
beforeCommit에서 원격 API를 먼저 호출해도 원자성이 생기지 않습니다. 원격 성공 뒤 로컬 커밋이 실패할 수 있습니다. 트랜잭션 동기화 훅은 로컬 생명 주기 콜백이지 분산 트랜잭션 프로토콜이 아닙니다.
느린 콜백은 트랜잭션 스레드에서 연결 반환과 응답을 늦출 수도 있습니다. 재시도가 필요한 전달은 반드시 데이터베이스에 durable한 작업 항목을 남깁니다.
business row와 outbox row를 한 번에 저장한다
업무 명령은 같은 REQUIRED 물리 트랜잭션에서 business row와 outbox event를 함께 삽입합니다. 커밋하면 둘 다 보이고, 실패하면 둘 다 사라집니다. 이것이 로컬 원자성 경계입니다.
커밋 뒤 별도 relay가 outbox를 읽어 브로커 또는 감사 투영기로 전달합니다. relay는 네트워크 성공 뒤 발행 상태를 갱신하기 전에 중단될 수 있으므로 적어도 한 번 전달을 전제로 합니다.
소비자는 안정적인 event_id를 inbox 고유 키로 먼저 기록하고 감사 효과를 같은 소비자 트랜잭션에 적용합니다. 같은 이벤트가 다시 오면 고유 키 충돌 트랜잭션은 효과 없이 롤백됩니다. 전달 횟수가 여러 번이어도 감사 효과는 한 번만 남습니다.
이 구조가 보장하는 것은 다음처럼 두 구간으로 나뉩니다.
- 명령 트랜잭션: business row + outbox row의 원자성
- 전달 구간: 커밋된 outbox를 비동기로 재시도하고 소비자 inbox로 중복 제거
두 구간 전체가 하나의 분산 원자 커밋인 것은 아닙니다. 그 대신 durable intent, 재시도, idempotency로 최종 전달을 운영합니다.
전달과 완료 기록 사이에서 멈추면 어떻게 되나요? 업무 명령이 posts와 outbox를 한 행씩 커밋했습니다. 소비자는 동일 event ID의 inbox와 audit를 한 행씩 커밋했지만, relay가 발행 완료 상태를 기록하기 전에 멈췄습니다. 재시작 뒤 같은 이벤트를 다시 전달합니다. 원 고유 키와 소비자 트랜잭션 조건에서 각 테이블의 행 수와 재시도 시 필요한 식별자를 답하세요. 이번에는 최초 audit 삽입이 실패해 소비자 트랜잭션이 롤백된 경우도 비교하세요.
전달 중복과 소비자 원자성의 풀이
첫 경우에는 posts=1, outbox=1, inbox=1, audit=1입니다. 원 샘플은 outbox를 삭제하지 않으므로 outbox 행 수는 전달 완료 횟수를 뜻하지 않습니다. 같은 event ID로 재전달하면 inbox 고유 키에서 충돌하고 그 소비자 트랜잭션은 효과 없이 롤백돼 inbox·audit가 각각 한 행으로 유지됩니다. 재시도마다 새 event ID를 만들면 이 중복 방지가 깨집니다.
둘째 경우에는 최초 소비자 실패 뒤 inbox=0, audit=0이지만 명령의 posts=1, outbox=1은 유지됩니다. inbox 삽입과 감사 효과가 함께 롤백됐기 때문에 같은 event ID를 정상 재시도할 수 있습니다. relay가 이후 다시 실행된다는 조건과 성공하기 전의 지연을 보장 설명에서 생략하지 않습니다. 이는 새 브로커·DB 실행이 아닌 명시한 단계의 상태 추적입니다.
실행으로 원자성과 중복을 검증한다
다음 Java 25 테스트는 Spring Boot 4.1.1, Spring Framework 7.0.9, JUnit 6.0.3 기준입니다. 첫 테스트는 의도된 실패 전후에 business row와 outbox row가 항상 함께 남거나 함께 사라지는지 확인합니다.
둘째 테스트는 커밋 이후 relay 호출을 별도 단계로 실행하고 같은 outbox event를 두 번 전달합니다. AuditProjector는 프록시를 통과하는 독립 소비자 트랜잭션에서 inbox와 audit row를 함께 쓰므로 두 번째 고유 키 충돌은 감사 효과를 늘리지 않습니다. 실제 운영에서는 스케줄러나 메시지 소비자가 이 relay 단계를 비동기로 호출합니다.
여기서는 relay 호출 시 기존 외부 트랜잭션이 없어서 기본 REQUIRED인 project가 자기 경계를 시작합니다. 코드가 REQUIRES_NEW를 강제하는 것은 아닙니다. 트랜잭션을 가진 다른 호출자가 relay를 호출하면 참여 관계를 다시 확인해야 합니다. 또한 이 테스트는 두 번 직접 호출해 중복 효과를 검사하며 브로커 장애·스케줄러 재시도·발행 상태 갱신까지 실제 실행하는 테스트는 아닙니다.
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);
}
}
}relay와 소비자를 운영한다
여러 relay 인스턴스가 같은 행을 집지 않도록 for update skip locked, 임대 또는 명시적 상태 전이 가운데 사용하는 데이터베이스에 맞는 선점 전략을 선택합니다. 전송 성공 후 상태 갱신 전에 장애가 나면 중복이 생길 수 있다는 전제를 유지합니다.
outbox에는 미발행 개수, 가장 오래된 이벤트의 나이, 발행 지연, 재시도 횟수와 영구 실패 개수를 둡니다. 소비자에는 inbox 중복 수, 투영 지연과 실패 원인을 둡니다. 성공 행 수만으로는 멈춘 전달을 찾을 수 없습니다.
event payload에는 스키마 버전을 넣고 개인 정보를 최소화합니다. 영구 실패는 마지막 오류와 재시도 횟수를 보존한 격리 상태로 옮기며, 운영자가 재처리한 행위 자체도 감사 기록에 남깁니다.
트레이스 ID는 명령, outbox와 감사 투영을 연결하는 관찰 정보이지 데이터베이스 기본 키가 아닙니다. 서버가 발급한 안정적인 event_id와 별도로 관리합니다.
실패 정책을 이름으로 드러낸다
- 필수 규제 기록: 기록 실패 시 업무를 차단하는 fail-closed 정책과 별도 장애 도메인
- 분석용 시도 기록: fail-open + 실패 메트릭 + 재처리 또는 허용된 유실 문서화
- 커밋된 업무 감사: business + outbox 원자 저장, 비동기 relay, idempotent inbox
- 캐시·프로세스 내부 후처리: 유실 허용 범위가 작고 재생이 불필요할 때만
afterCommit
REQUIRES_NEW를 원자성 해결책으로 과장하지 않고, 로컬 원자 구간과 비동기 전달 구간을 명확히 나누는 것이 설계의 핵심입니다.