Published on

Spring Boot 대용량 트랜잭션 전파 규칙 완전 정복

Authors

대용량 트랜잭션을 다루는 Spring Boot 서비스에서는 @Transactional 하나로 모든 걸 해결하려는 순간, 예상치 못한 락 경합, 커넥션 고갈, 데드락, 롤백 누락 같은 장애가 연쇄적으로 발생합니다. 특히 배치 정산, 대량 주문 생성, 포인트 적립, 대량 메시지 발행 같은 작업은 “한 번에 크게 묶는 트랜잭션”이 아니라 “어떻게 잘게 쪼개고, 어디까지 원자성을 보장할지”가 핵심입니다.

이 글은 전파 규칙(Propagation)을 옵션 설명 수준이 아니라, 대용량 처리에서 실제로 어떤 트랜잭션 경계가 만들어지는지, 그리고 장애를 줄이기 위한 설계 패턴을 중심으로 정리합니다.

관련해서 DB 락과 데드락을 함께 이해하면 도움이 됩니다. 대용량 트랜잭션에서 전파 규칙을 잘못 잡으면 데드락이 더 쉽게 터집니다: PostgreSQL 데드락 40P01 재현·탐지·해결

대용량 트랜잭션에서 전파 규칙이 중요한 이유

대용량 처리에서 흔히 겪는 문제는 다음과 같습니다.

  • 트랜잭션이 너무 큼: 한 트랜잭션에서 수천 건을 갱신하면 락 유지 시간이 길어져 동시성이 급감합니다.
  • 외부 호출을 같은 트랜잭션에 포함: 결제 API, HTTP 호출, Kafka 발행 등을 DB 트랜잭션과 한 덩어리로 묶으면 커넥션 점유 시간이 폭증합니다.
  • 부분 실패 처리 미흡: 1,000건 중 3건만 실패했는데 전체 롤백을 원하지 않을 수 있습니다.
  • 롤백 규칙 오해: 체크 예외는 기본적으로 롤백되지 않고 커밋되는 경우가 발생합니다.

전파 규칙은 위 문제들에 대한 “기술적 스위치”입니다. 하지만 스위치를 잘못 켜면 더 큰 장애가 납니다.

기본 전제: Spring 트랜잭션이 실제로 적용되는 조건

전파 규칙을 논하기 전에, 아래를 먼저 확인해야 합니다.

프록시 기반 AOP와 self-invocation 함정

Spring의 @Transactional은 기본적으로 프록시(AOP)로 동작합니다. 같은 클래스 내부에서 this.someMethod()로 호출하면 프록시를 거치지 않아 트랜잭션이 적용되지 않습니다.

@Service
public class PaymentService {

    @Transactional
    public void outer() {
        // ...
        inner(); // 같은 클래스 내부 호출: 프록시 미경유, 트랜잭션 어노테이션 무시될 수 있음
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() {
        // 기대: 새 트랜잭션
        // 현실: 적용 안 될 수 있음
    }
}

해결책은 대표적으로 3가지입니다.

  • 메서드를 다른 빈으로 분리
  • 프록시를 통한 호출(ApplicationContext에서 자기 자신 프록시를 가져오기 등)
  • AspectJ 모드(상황에 따라)

대용량 트랜잭션에서는 “분리”가 가장 안전합니다.

전파 규칙 한 장 요약

Spring의 전파 규칙은 “현재 트랜잭션이 있을 때/없을 때”를 기준으로 동작이 갈립니다.

  • REQUIRED: 있으면 참여, 없으면 생성(기본값)
  • REQUIRES_NEW: 무조건 새 트랜잭션, 기존은 일시 중단
  • NESTED: 기존 트랜잭션 안에 세이브포인트 기반 중첩(DB 및 설정 의존)
  • SUPPORTS: 있으면 참여, 없으면 비트랜잭션
  • NOT_SUPPORTED: 비트랜잭션으로 실행, 기존은 일시 중단
  • MANDATORY: 트랜잭션 없으면 예외
  • NEVER: 트랜잭션 있으면 예외

대용량 처리에서 주로 쓰는 건 REQUIRED, REQUIRES_NEW, NESTED, NOT_SUPPORTED입니다.

REQUIRED: 대용량에서 가장 위험한 기본값

REQUIRED는 “상위가 트랜잭션이면 합류”합니다. 문제는 대용량 처리에서 상위 트랜잭션이 커지는 순간, 하위 로직까지 모두 같은 DB 커넥션과 같은 락 범위를 공유한다는 점입니다.

안티패턴: 한 트랜잭션에 모든 걸 밀어 넣기

@Service
public class SettlementService {

    @Transactional // REQUIRED
    public void settleAll(List<Long> orderIds) {
        for (Long orderId : orderIds) {
            // 주문 정산 업데이트
            updateSettlement(orderId);

            // 외부 시스템 알림(HTTP)
            notifyExternal(orderId);

            // 이벤트 발행
            publishEvent(orderId);
        }
    }

    @Transactional // REQUIRED로 상위에 합류
    public void updateSettlement(Long orderId) {
        // JPA update
    }

    public void notifyExternal(Long orderId) {
        // HTTP call
    }

    public void publishEvent(Long orderId) {
        // Kafka send
    }
}

이 구조의 문제:

  • 네트워크 호출 시간만큼 DB 트랜잭션이 열린 채로 유지
  • 락 점유 시간 증가로 동시성 급락
  • 커넥션 풀 고갈 가능
  • 중간 실패 시 전체 롤백으로 재시도 비용 폭증

대용량에서 REQUIRED는 “의도적으로 작게 유지할 때만” 안전합니다.

REQUIRES_NEW: 부분 커밋과 격리를 위한 핵심 도구

REQUIRES_NEW는 상위 트랜잭션이 있더라도 무조건 새 트랜잭션을 시작합니다. 상위 트랜잭션은 일시 중단됩니다.

대용량 처리에서 이 옵션이 필요한 대표 시나리오:

  • 작업 단위를 레코드 단위로 커밋하고 싶다
  • 실패한 건만 따로 기록하고 나머지는 진행하고 싶다
  • 감사 로그/실패 로그는 반드시 남기고 싶다

패턴 1: 대량 처리에서 실패 로그는 무조건 남기기

@Service
public class BulkJobService {

    private final ItemProcessor processor;
    private final JobLogService jobLogService;

    public BulkJobService(ItemProcessor processor, JobLogService jobLogService) {
        this.processor = processor;
        this.jobLogService = jobLogService;
    }

    public void run(List<Long> itemIds) {
        for (Long id : itemIds) {
            try {
                processor.processOne(id); // 아이템 단위 트랜잭션
            } catch (Exception e) {
                jobLogService.writeFailLog(id, e.getMessage()); // 실패 로그는 별도 트랜잭션
            }
        }
    }
}

@Service
class ItemProcessor {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processOne(Long id) {
        // 1건 처리, 여기서만 원자성 보장
        // 실패하면 이 1건만 롤백
    }
}

@Service
class JobLogService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void writeFailLog(Long id, String reason) {
        // 실패 로그는 반드시 커밋
    }
}

주의점:

  • REQUIRES_NEW는 매번 새 트랜잭션을 열기 때문에 TPS가 높으면 오버헤드가 있습니다.
  • 대량 처리에서는 보통 chunk 단위(예: 100건)로 묶어 커밋하거나, 정말로 1건 단위가 필요한지 검토해야 합니다.

패턴 2: 외부 호출은 트랜잭션 밖으로

외부 호출은 DB 트랜잭션과 분리하는 게 원칙입니다. NOT_SUPPORTED를 쓰거나 애초에 트랜잭션 경계 밖으로 빼세요.

@Service
public class OrderService {

    private final ExternalClient externalClient;

    public OrderService(ExternalClient externalClient) {
        this.externalClient = externalClient;
    }

    @Transactional
    public void placeOrder(Long orderId) {
        // DB 저장은 트랜잭션 안
        saveOrder(orderId);

        // 외부 호출은 트랜잭션 밖에서
        callExternalWithoutTx(orderId);
    }

    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void callExternalWithoutTx(Long orderId) {
        externalClient.notify(orderId);
    }

    private void saveOrder(Long orderId) {
        // repository.save
    }
}

이렇게 하면 DB 커넥션 점유 시간이 줄고, 락 유지 시간도 줄어듭니다.

NESTED: 세이브포인트로 부분 롤백하기

NESTED는 “상위 트랜잭션 안에서 세이브포인트를 만들고, 하위 실패 시 세이브포인트까지 롤백”하는 개념입니다.

장점:

  • 상위 트랜잭션을 유지하면서도 일부 작업만 롤백 가능

단점/주의:

  • JDBC 드라이버, DB, 트랜잭션 매니저 설정에 따라 동작이 제한될 수 있습니다.
  • JPA 환경에서는 기대와 다르게 동작하거나 제약이 생길 수 있어, 실서비스에서는 REQUIRES_NEW가 더 예측 가능할 때가 많습니다.

예시: 한 트랜잭션에서 여러 단계 중 일부만 되돌리기

@Service
public class MultiStepService {

    @Transactional
    public void runAll() {
        step1();

        try {
            step2Nested();
        } catch (RuntimeException e) {
            // step2만 롤백하고 step1은 유지한 채로 계속 진행하고 싶을 때
        }

        step3();
    }

    public void step1() {
        // ...
    }

    @Transactional(propagation = Propagation.NESTED)
    public void step2Nested() {
        // 실패하면 세이브포인트까지 롤백
        throw new RuntimeException("fail");
    }

    public void step3() {
        // ...
    }
}

대용량 처리에서는 “부분 롤백” 요구가 자주 나오지만, 운영 난이도를 고려하면 NESTED는 사전 검증이 필수입니다.

NOT_SUPPORTED: 커넥션 점유 시간을 줄이는 실전 옵션

NOT_SUPPORTED는 현재 트랜잭션이 있으면 중단하고, 비트랜잭션으로 실행합니다.

대용량 트랜잭션에서 특히 유용한 곳:

  • 외부 API 호출
  • 대용량 파일 IO
  • 오래 걸리는 CPU 작업(암호화, PDF 생성 등)

단, 비트랜잭션이므로 DB 쓰기가 섞이면 일관성이 깨질 수 있습니다. “DB를 건드리지 않는 작업”에만 사용하세요.

SUPPORTS, MANDATORY, NEVER: 운영 정책을 코드로 강제하기

이 옵션들은 대용량 처리 최적화보다는 규칙 위반을 조기에 발견하는 데 유용합니다.

  • MANDATORY: 반드시 트랜잭션 안에서만 호출되어야 하는 저장 로직에 적용
  • NEVER: 트랜잭션이 있으면 안 되는 외부 호출/락 민감 로직에 적용

예:

@Service
public class StrictRepositoryFacade {

    @Transactional(propagation = Propagation.MANDATORY)
    public void mustBeInTxWrite() {
        // 트랜잭션 없이 호출되면 즉시 예외
    }

    @Transactional(propagation = Propagation.NEVER)
    public void mustNotBeInTx() {
        // 트랜잭션이 열려 있으면 즉시 예외
    }
}

대규모 조직에서는 이런 “가드레일”이 장애를 줄입니다.

롤백 규칙: 전파만큼 자주 터지는 함정

전파 규칙을 잘 설계해도, 롤백 규칙을 오해하면 데이터가 “커밋돼버리는” 사고가 납니다.

  • 기본적으로 Spring은 RuntimeExceptionError에만 롤백
  • 체크 예외(Exception)는 기본 커밋
@Transactional
public void doWork() throws Exception {
    // ...
    throw new Exception("checked"); // 기본 설정이면 롤백 안 될 수 있음
}

의도적으로 롤백하려면:

@Transactional(rollbackFor = Exception.class)
public void doWork() throws Exception {
    throw new Exception("checked");
}

대용량 작업에서는 “부분 성공/부분 실패”가 빈번하므로, 어떤 예외에서 롤백할지 명시적으로 정하는 것이 안전합니다.

대용량 처리 설계 패턴 3가지

1) Chunk 트랜잭션: REQUIRES_NEW를 묶어서 쓰기

1건당 REQUIRES_NEW는 트랜잭션 오버헤드가 큽니다. 흔히는 50~500건 단위로 커밋합니다.

@Service
public class ChunkedBulkService {

    private final ChunkWorker chunkWorker;

    public ChunkedBulkService(ChunkWorker chunkWorker) {
        this.chunkWorker = chunkWorker;
    }

    public void run(List<Long> ids, int chunkSize) {
        for (int i = 0; i < ids.size(); i += chunkSize) {
            List<Long> chunk = ids.subList(i, Math.min(ids.size(), i + chunkSize));
            chunkWorker.processChunk(chunk);
        }
    }
}

@Service
class ChunkWorker {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processChunk(List<Long> chunk) {
        for (Long id : chunk) {
            // 업데이트
        }
    }
}

장점:

  • 락 유지 시간과 롤백 범위를 줄이면서도 오버헤드를 통제

2) Outbox 패턴: DB 트랜잭션과 이벤트 발행 분리

대용량 트랜잭션에서 Kafka 같은 메시지 발행을 같은 트랜잭션에 넣으면, 재시도/중복/순서 문제가 복잡해집니다. 보통은 Outbox 테이블에 이벤트를 기록하고, 별도 퍼블리셔가 발행합니다.

중복 이벤트/정확히 한 번 이슈는 이 글과 함께 보면 설계 감이 빨리 옵니다: Kafka 정확히-한번? MSA 중복이벤트 5분 진단

간단 예시:

@Entity
class OutboxEvent {
    @Id @GeneratedValue
    private Long id;
    private String eventType;
    private String payload;
    private boolean published;
}

@Service
public class OrderTxService {

    private final OutboxRepository outboxRepository;

    public OrderTxService(OutboxRepository outboxRepository) {
        this.outboxRepository = outboxRepository;
    }

    @Transactional
    public void placeOrderWithOutbox(Long orderId) {
        // 주문 저장
        // ...

        // 같은 트랜잭션에 outbox 기록
        outboxRepository.save(new OutboxEvent(/*...*/));
    }
}

3) Saga와 보상 트랜잭션: “크게 묶지 말고, 되돌릴 수 있게”

대용량 분산 환경에서는 애초에 하나의 DB 트랜잭션으로 끝내려는 시도가 한계에 부딪힙니다. 서비스 간 작업은 Saga로 풀고, 로컬 트랜잭션은 작게 가져가는 편이 운영에 유리합니다.

실제로 보상 트랜잭션이 꼬이는 지점과 디버깅 관점은 다음 글이 실전적입니다: MSA Saga 보상 트랜잭션 꼬임 디버깅 실전

격리 수준과 락: 전파 규칙만 바꿔도 락 양상이 달라진다

전파 규칙으로 트랜잭션 경계가 바뀌면 락이 잡히는 시간, 순서, 범위가 달라집니다.

  • REQUIRED로 크게 묶으면 락이 오래 유지되어 데드락 가능성이 증가
  • REQUIRES_NEW로 쪼개면 락 점유 시간은 줄지만, 처리 순서가 바뀌며 다른 형태의 경합이 생길 수 있음

특히 “같은 테이블을 서로 다른 순서로 업데이트”하면 데드락이 잘 납니다. 전파 규칙 설계와 함께 업데이트 순서 통일, 인덱스 정비, 짧은 트랜잭션 원칙을 같이 적용해야 합니다.

운영에서 바로 쓰는 체크리스트

  • 트랜잭션 안에서 외부 호출(HTTP, Kafka 동기 send, 파일 IO)을 하고 있지 않은가
  • 한 트랜잭션에서 처리하는 레코드 수가 과도하지 않은가(Chunk로 줄일 수 없는가)
  • 실패 로그/감사 로그는 REQUIRES_NEW로 분리되어 있는가
  • self-invocation으로 REQUIRES_NEW가 무시되고 있지 않은가
  • 체크 예외에서 롤백이 필요한데 rollbackFor를 누락하지 않았는가
  • 데드락이 난다면 “락 순서”와 “트랜잭션 길이”를 먼저 의심했는가

마무리: 대용량 트랜잭션의 정답은 전파 옵션 조합이다

대용량 트랜잭션에서 @Transactional 전파 규칙은 단순 문법이 아니라, 락/커넥션/재시도 비용/장애 전파 범위를 결정하는 설계 도구입니다.

  • 기본은 REQUIRED지만, 대용량에서는 “기본값이 가장 위험”할 수 있습니다.
  • 부분 성공을 허용하거나 실패 로그를 남겨야 하면 REQUIRES_NEW가 강력합니다.
  • 외부 호출과 오래 걸리는 작업은 NOT_SUPPORTED로 트랜잭션 밖으로 빼는 것이 안전합니다.
  • NESTED는 부분 롤백에 매력적이지만, 환경 제약을 반드시 검증해야 합니다.

전파 규칙을 바꾸기 전에 먼저 “업무적으로 어떤 원자성이 필요한지”를 정의하고, 그 다음에 트랜잭션 경계를 코드로 표현하는 순서로 접근하면 대용량에서도 예측 가능한 시스템을 만들 수 있습니다.