감사 로그와 독립 커밋
독립 감사 커밋의 보장 한계를 구분하고 business row와 outbox를 원자적으로 저장한 뒤 감사 투영을 비동기로 전달합니다.
“감사는 따로 커밋한다”는 문장만으로는 어떤 사실을 보장하는지 알 수 없습니다.
REQUIRES_NEW 감사 행은 외부 롤백 뒤에도 남을 수 있지만, 바로 그 때문에 실제로 커밋되지 않은 업무 변경을 설명할 위험이 있습니다. 업무 상태가 커밋됐다는 이벤트라면 business row와 outbox row를 같은 로컬 트랜잭션에 넣고, 커밋된 outbox를 나중에 비동기로 감사 저장소까지 전달해야 합니다.
AUDIT · TRANSACTIONAL OUTBOX
업무 사실은 함께 저장하고 감사는 커밋 뒤 전파한다
독립 감사 행은 시도를 남길 뿐 업무 커밋을 증명하지 않습니다. 커밋된 사실은 business row와 outbox를 한 트랜잭션에 넣어 비동기로 전달합니다.
독립 감사에는 “시도”를 기록
REQUIRES_NEW 행은 외부 롤백 뒤에도 남으므로 커밋되지 않은 업무 성공을 주장하면 안 됩니다.
business + outbox를 한 트랜잭션에 저장
둘 다 커밋하거나 둘 다 롤백하여 존재하지 않는 업무 이벤트가 남지 않게 합니다.
커밋 뒤 relay가 재시도
브로커 장애와 프로세스 재시작에도 durable outbox가 전달 의도를 보존합니다.
inbox event ID로 중복 제거
inbox와 audit projection을 같은 소비자 트랜잭션에 적용해 여러 delivery의 효과를 한 번으로 제한합니다.
보장 수준에 맞는 실패 정책
분석용은 fail-open과 메트릭, 필수 규제 감사는 fail-closed와 별도 장애 도메인을 검토합니다.
- 원자 저장과 durable intent
- 비동기·중복 가능 전달
- 업무 사실 과장 위험
핵심: REQUIRES_NEW는 시도 감사를 분리할 뿐이고, 커밋된 업무 감사는 outbox와 idempotent consumer로 전파합니다.
먼저 기록의 의미를 나눈다
업무 이벤트, 실패 시도, 규제 감사, 디버그 로그는 이름이 비슷해도 커밋 요구가 다릅니다.
| 기록 | 메인 롤백과 운명 | 대표 구조 | 기록이 주장하는 사실 |
|---|---|---|---|
| 업무 이벤트 | 함께 롤백 | 같은 트랜잭션의 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로 최종 전달을 운영합니다.
실행으로 원자성과 중복을 검증한다
다음 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 단계를 비동기로 호출합니다.
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를 원자성 해결책으로 과장하지 않고, 로컬 원자 구간과 비동기 전달 구간을 명확히 나누는 것이 설계의 핵심입니다.