본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
11장 : 예외와 트랜잭션 심화

감사 로그와 독립 커밋

업무 이벤트·실패 감사·보안 기록을 구분하고 REQUIRES_NEW, TransactionSynchronization, 트랜잭션 아웃박스 가운데 올바른 경계를 선택해 유실·중복·개인 정보·운영 복구 정책을 완성합니다.

“감사는 따로 커밋한다”는 문장만으로 설계가 끝나지 않습니다. 메인 트랜잭션이 실패해도 남아야 하는지, 메인 행과 반드시 함께 존재해야 하는지, DB 전체가 사용 불가해도 보존해야 하는지에 따라 도구가 달라집니다. 회원 게시판의 PostCreated 업무 이벤트, 관리자 변경 이력, 로그인 실패 보안 감사는 이름은 비슷해도 커밋 요구가 서로 다릅니다.


업무 이벤트·시도 감사

업무 이벤트는 실제 상태 변경이 커밋됐다는 사실을 나타냅니다. 애그리거트 행이 롤백됐는데 이벤트만 남으면 하위 시스템이 존재하지 않는 게시글을 처리합니다. 따라서 같은 로컬 트랜잭션의 아웃박스 행으로 저장하는 것이 자연스럽습니다.

시도 감사는 성공 여부와 관계없이 누가 어떤 동작을 시도했는지 남깁니다. 권한 거절, 낙관적 충돌, 검증 실패도 관찰 대상일 수 있습니다. 메인 변경과 커밋 결과를 공유하지 않으므로 독립 트랜잭션이나 별도 감사 시스템이 필요합니다.

디버그 로그는 규제 감사가 아닙니다. 보존 기간, 변조 방지, 접근 권한, 시계, 주체 식별, 삭제 정책이 다릅니다. 애플리케이션 로그 한 줄을 남겼다는 이유로 보존 의무를 충족했다고 판단하지 않습니다.

REQUIRES_NEW 감사 격리

독립 감사 작성기는 별도 빈으로 만들고 불변 스냅샷만 받습니다. 외부가 보유한 관리되는 엔티티나 미커밋 외래 키를 전달하면 내부가 읽지 못하거나 잠금을 기다릴 수 있습니다.

src/main/java/board/audit/IndependentAuditService.java
package board.audit;

import java.time.Clock;
import java.time.Instant;

import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

public class IndependentAuditService {
    private final AuditStore store;
    private final Clock clock;

    public IndependentAuditService(AuditStore store, Clock clock) {
        this.store = store;
        this.clock = clock;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recordAttempt(Attempt command) {
        store.append(new AuditRow(
                command.requestId(),
                command.actorId(),
                command.operation(),
                command.outcome(),
                Instant.now(clock)));
    }

    public record Attempt(
            String requestId,
            long actorId,
            String operation,
            String outcome
    ) {
        public Attempt {
            if (requestId.isBlank() || operation.isBlank() || outcome.isBlank()) {
                throw new IllegalArgumentException("audit fields are blank");
            }
        }
    }

    public record AuditRow(
            String requestId,
            long actorId,
            String operation,
            String outcome,
            Instant occurredAt
    ) { }

    public interface AuditStore {
        void append(AuditRow row);
    }
}

requestId 고유 제약 조건을 두면 외부 재시도가 같은 시도를 반복 기록하는 문제를 제어할 수 있습니다. 같은 요청 안의 여러 단계가 필요하면 (request_id, sequence)를 키로 사용합니다. 게시글 제목·본문 원문 대신 허용된 작업 코드와 내부 행위자 ID만 기록합니다.

감사 실패 전파

감사 작성기가 타임아웃일 때 메인 작업을 막을지는 요구 수준에 달렸습니다. 필수 보안 기록이라면 실패 시 차단이 맞을 수 있지만 가용성이 낮아집니다. 분석용 시도 메트릭이라면 실패 시 통과시키고 별도 실패 카운터를 올릴 수 있습니다. 예외 처리해서 숨기기 전에 정책 이름과 경보를 둡니다.

REQUIRES_NEW 호출 예외는 자동으로 외부와 격리되지 않습니다. 내부 트랜잭션은 롤백됐어도 예외가 외부까지 전파되면 런타임 기본값에 따라 외부도 롤백됩니다. 메인을 계속하려면 호출자가 AuditUnavailable만 좁게 예외 처리하고, 프로그래밍 결함은 전파합니다.

동일 데이터베이스가 중단되면 메인 작업과 독립 감사 모두 실패합니다. 연결 풀도 공유합니다. “독립 커밋”이 장애 도메인까지 분리한다는 오해를 버리고, 정말 필요한 감사는 별도 영속적 전송과 저장소를 사용합니다.

트랜잭션 동기화 한계

afterCommit()은 커밋된 뒤 캐시 무효화나 프로세스 내부 알림을 실행하는 데 유용합니다. 콜백에서 메시지 브로커 전송이 실패하면 DB를 롤백할 수 없고 프로세스 장애 시 재시도 정보도 남지 않습니다. 영속적 전달이 필요하면 아웃박스 행이 있어야 합니다.

beforeCommit에서 원격 API를 호출해도 원자성이 생기지 않습니다. 원격 성공 뒤 로컬 커밋이 실패할 수 있습니다. 동기화 단계는 로컬 트랜잭션 생명주기 훅일 뿐 분산 트랜잭션 프로토콜이 아닙니다.

콜백은 트랜잭션 스레드에서 실행되므로 느린 작업을 넣으면 연결 반환과 요청 응답을 늦출 수 있습니다. 필요한 동일성만 대기열에 전달하고, 관리되는 엔티티를 콜백 이후 다른 스레드에서 접근하지 않습니다.

트랜잭셔널 아웃박스

게시글 행과 아웃박스 이벤트를 같은 필수 트랜잭션에서 삽입하면 둘 다 남거나 둘 다 사라집니다. 중계기는 커밋된 이벤트를 읽어 브로커로 보내고 발행 상태를 갱신합니다. 전송 성공 후 상태 갱신 전에 장애가 나면 중복 발행될 수 있으므로 사용자가 이벤트 ID로 멱등성을 보장해야 합니다.

src/main/java/board/outbox/OutboxRelayDemo.java
package board.outbox;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public final class OutboxRelayDemo {
    record Event(String id, String type, String aggregateId) { }

    static final class IdempotentConsumer {
        private final Set<String> handled = new HashSet<>();
        private final List<String> effects = new ArrayList<>();

        void accept(Event event) {
            if (handled.add(event.id())) {
                effects.add(event.type() + ":" + event.aggregateId());
            }
        }

        int effectCount() {
            return effects.size();
        }
    }

    public static void main(String[] args) {
        var consumer = new IdempotentConsumer();
        var event = new Event("evt-41", "PostCreated", "post-9");
        consumer.accept(event);
        consumer.accept(event);
        System.out.printf("deliveries=2 effects=%d%n", consumer.effectCount());
    }
}
OutboxRelayDemo 실행
deliveries=2 effects=1

폴러는 for update skip locked, 임대, 상태 전이 가운데 데이터베이스에 맞는 선점 전략을 사용합니다. 이벤트 페이로드에는 스키마 버전을 넣고 개인 정보를 최소화합니다. 영구 실패는 재시도 횟수와 마지막 오류를 보존한 배달 실패 상태로 옮깁니다.

유실·중복·지연 관찰

아웃박스에는 미발행 개수, 가장 오래된 이벤트의 경과 시간, 발행 지연 시간, 재시도 횟수, 배달 실패 개수가 필요합니다. 감사에는 추가 실패, 정책별 장애 허용 횟수, 저장소 지연, 무결성 확인 결과를 둡니다. 성공 행 수만 보면 발행 지연을 발견하지 못합니다.

트레이스 ID는 메인 명령과 감사·아웃박스를 연결하지만 데이터베이스 기본 키 대신 사용하지 않습니다. 표본 추출 때문에 트레이스가 없을 수 있고 외부 입력에서 온 ID를 그대로 신뢰하면 충돌과 카디널리티 문제가 생깁니다. 서버가 발급한 요청 ID와 안정적인 이벤트 ID를 나눕니다.

복구 대응 절차에는 멈춘 이벤트 재처리, 유해 페이로드 격리, 감사 저장소 사용 불가 시 모드 전환, 보존 기간에 따른 제거 확인을 포함합니다. 재처리 명령 자체도 감사 대상일 수 있으므로 연산자와 이유를 기록합니다.

보장 범위 선택

기록메인과 함께 롤백메인 실패 뒤 보존DB 장애 격리적합한 구조
업무 이벤트아니오아니오로컬 아웃박스
일반 실패 시도아니오제한적REQUIRES_NEW
규제 보안 감사아니오별도 영속적 감사 수집 대상
캐시 무효화커밋 뒤불필요아니오afterCommit 또는 아웃박스

하나의 AuditService가 네 종류를 모두 받지 않게 합니다. 인터페이스 이름과 메서드 시그니처에서 요구 수준을 드러내야 검토자가 잘못된 전파를 발견할 수 있습니다.

연습 문제

관리자가 다른 회원의 게시글을 삭제하는 기능을 설계하세요. 삭제 행과 업무 이벤트는 함께 커밋되어야 하고, 권한 거절 시도도 보존해야 하며, 브로커는 일시적으로 사용 불가할 수 있습니다. 필요한 트랜잭션과 저장소, 중복 처리 키를 그리세요.

해설 보기

권한 확인 결과가 거절이면 독립 시도 감사만 기록합니다. 허용이면 게시글 삭제와 PostDeleted 아웃박스를 필수로 묶고, 중계가 브로커 가용성과 무관하게 나중에 발행합니다.

package board.application;

import org.springframework.transaction.annotation.Transactional;

public class AdministrativeDeletion {
    private final PostStore posts;
    private final OutboxStore outbox;

    public AdministrativeDeletion(PostStore posts, OutboxStore outbox) {
        this.posts = posts;
        this.outbox = outbox;
    }

    @Transactional
    public void delete(long postId, long operatorId, String eventId) {
        posts.deleteOwnedByAdministrator(postId, operatorId);
        outbox.append(eventId, "PostDeleted", Long.toString(postId));
    }

    public interface PostStore {
        void deleteOwnedByAdministrator(long postId, long operatorId);
    }

    public interface OutboxStore {
        void append(String eventId, String type, String aggregateId);
    }
}

아웃박스의 event_id와 소비자 수신함의 같은 ID에 고유 제약 조건을 둡니다. 감사 페이로드에는 삭제 전 제목·본문 원문 대신 대상 게시글 ID와 정책 코드를 기록합니다.