[영상후기][10분 테코톡] 키아라의 스프링 트랜잭션 전파

박철현·2025년 11월 22일

스프링부트

목록 보기
9/9

movie

목차

  • 용어 정리
  • 스프링 트랜잭션 전파 핵심 원리
  • 스프링 트랜잭션 전파 절망편
  • 해결 : REQUIRES_NEW
  • 정리

용어정리

트랜잭션 전파 속성

  • 트랜잭션이 진행중일 때 추가 트랜잭션 진행을 어떻게 할지 결정하는 것
  • 스프링 어노테이션 기반 트랜잭션 할땐 @Transactional(propagaion = Propagation.전파타입) 형태로 사용함

트랜잭션 전파 타입

타입실행 중인 트랜잭션 있음실행 중인 트랜잭션 없음
REQUIRED기존 트랜잭션 참여새 트랜잭션 생성
MANDATORY기존 트랜잭션 참여예외 발생
REQUIRED_NEW기존 트랜잭션 보류, 새 트랜잭션 생성새 트랜잭션 생성
SUPPORTS기존 트랜잭션 참여트랜잭션 없이 진행
NOT_SUPPORTED기존 트랜잭션 참여트랜잭션 없이 진행
NEVER예외 발생트랜잭션 없이 진행
NESTED중첩 트랜잭션 생성새 트랜잭션 생성

물리 트랜잭션, 논리 트랜잭션

  • 기존에 트랜잭션이 진행중일때 새로운 트랜잭션이 시작되면 분류하기 위해 외부 트랜잭션과 내부 트랜잭션으로구분
    • 외부 트랜잭션이 상대적으로 바깥에 있기 때문

  • 스프링에서는 이 둘을 하나의 트랜잭션으로 묶어줌
    • 내부 트랜잭션이 외부 트랜잭션에 참여한다 고 표현

  • 스프링에서는 이해를 돕기 위해 물리 트랜잭션, 논리 트랜잭션이라는 개념 사용
    • 물리 트랜잭션 : 실제 데이터베이스에 적용되는 트랜잭션
      • 실제 커넥션을 통해 트랜잭션을 시작하고 종료(커밋, 롤백)하는 단위
      • 여러 논리 트랜잭션을 하나의 물리 트랜잭션으로 묶는다고 생각해도 됨

  • 논리 트랜잭션도 커밋과 롤백을 요청할 수 있지만 실제 데이터베이스에 적용되진 않음

    • 한 논리 트랜잭션에서 롤백이 된다면 나머지 논리 트랜잭션이나 전체를 아우르는 물리 트랜잭션에는 어떤 영향을 미칠까?
  • 모든 논리 트랜잭션이 커밋돼야 물리 트랜잭션이 커밋된다.

    • 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.
  • 신규 트랜잭션만이 물리 트랜잭션을 종료(커밋, 롤백)할 수 있다.

스프링 트랜잭션 전파

비즈니스 요구사항

  • 영화를 예매한다.
  • 영화 예매에 대한 이력을 DB 로그 테이블에 저장한다.

  • 영화 서비스에서 예매와 로그저장이 동시에 이뤄져야 해서 트랜잭션 달기
  • 예매와 로그저장이 각각 다른곳에서 호출될 수 있어 트랜잭션 달기
    • 전파 속성 옵션 지정 안하면 기본값 : REQUIRED

Case1 : 영화 예메와 로그 저장 성공적 커밋

  • 영화 Service는 신규 트랜잭션인 상황
  • 영화 예매와 로그 저장도 신규 트랜잭션일까? 어떻게 묶일까? 영상에서 로그 분석
1. (신규 생성) Creating new transaction ... : PROPAGATING_REQURED, ...
2. 영화 예매 트랜잭션 시작 
3. Participatin in existing transaction  (영화 예매 Repo에 트랜잭션 전파 속성 지정 안함 -> 기본값인 REQUIRED라 기존 부모 트랜잭션에 참가)
4. 로그 저장 트랜잭션 시작
5. Participating in existing transaction (로그 저장 Repo에 트랜잭션 전파 속성 지정 안함 -> 기본값인 REQUIRED라 기존 부모 트랜잭션에 참가)
6. 영화 예매 트랜잭션 커밋
7.Completing transation for ... Initiating transaction commit
  • @Transactional 옵션 지정 안해서 전파 속성 기본인 Required 지정
      1. 영화 Service는 트랜잭션을 새로 생성 -> 신규 O
      1. 영화 예매 트랜잭션은 기존 트랜잭션을 참여한다고 로그 찍힘 -> 신규 X
      1. 로그 저장 트랜잭션도 기존 트랜잭션을 참여한다고 로그 찍힘 -> 신규 X
      1. 로그 저장 + 영화 예매가 각각 트랜잭션 커밋
      1. 영화 서비스까지 잘 커밋이 된다면
      1. 영화 서비스는 커밋을 호출 -> 영화 서비스는 신규 트랜잭션 이기 때문에 물리 커밋이 진행됨

Case2 : 로그 저장에서 예외가 발생해 롤백 되는 경우

  • case 1과 중복 제외하고 종료 사례만 설명
...(생략)
1. RuntimeException: 로그 저장 실패
2. 로그 저장 실패 -> 신규 트랜잭션이 아니기 때문에 물리 롤백 진행 불가 -> Rollback Only 지정만
3. 발생한 예외는 영화 Service로 넘어감 (호출부)
4. 영화 Service에서 따로 예외 처리를 해주지 않으면 Transaction Manager에게 롤백 요청 -> 영화 서비스는 신규 트랜잭션이기 때문에 물리 Rollback
  • 영화 예매 트랜잭션은 성공적 커밋 가정 -> 로그 저장 트랜잭션 시작 & 예외 발생
    • 로그 저장 트랜잭션은 신규 트랜잭션이 아니기 때문에 물리 롤백 진행 불가 -> rollback-only 속성 적어두기만 함
    • 발생한 예외는 영화 Service로 전파
    • 영화 Service에서 따로 예외 처리를 해주지 않으면 밖으로 던짐 -> AOP에서 트랜잭션 매니저에게 롤백 요청 -> 영화 Service는 신규 트랜잭션 이기 때문에 물리 롤백 진행됨
    • (급 궁금증) 그렇다면 예외를 잡아주면 롤백은 안일어나나? 밑에서 실험
  • 비즈니스 오류
    • 사용자 입장에서 로그 저장 문제가 발생한다해서 영화 예매가 안되면..?
    • 서비스 이탈의 문제 발생

비즈니스 요구사항 변경

  • 로그 저장에 실패하더라도 영화 예매는 유지되어야 한다 는 비즈니스 요구사항 추가
  • REQUIRES_NEW 타입을 지정하여 물리 트랜잭션을 완전 분리
  • 분리되면 각 커밋과 롤백은 서로에게 영향을 미치지 않음

Case3 : 트랜잭션 분리 후 로그 저장에서 예외

(생략)
1. 로그 저장 트랜잭션 시작
2. Suspending current transaction, creating new transaction ..
(기존 트랜잭션 보류하고 신규 트랜잭션 생성)
3. RuntimeException: 로그 저장 실패
-> 신규 트랜잭션이기 때문에 물리 롤백
4. 던저진 예외는 서비스로감 -> 예외를 적절히 처리 -> 물리 커밋
  • 로그 저장 실패해도 영화 예매는 커밋이 그대로 반영, 로그는 롤백
  • 로그 저장 트랜잭션에서 예외 발생 시 AOP는 트랜잭션 매니저에게 롤백 요청
    • 로그 저장 트랜잭션은 신규 트랜잭션 이기 때문에 물리 롤백 진행
    • 던져진 예외는 서비스까지 감 서비스에서는 예외 적절히 처리
    • 서비스에서 적절히 잘 처리되면 물리 커밋 진행
  • (급 궁금증2) 새로 발급한 트랜잭션에서 발생한 예외를 부모에서 잡아주지 않아도 롤백이 되는가?

정리

  • 논리 트랜잭션이 하나라도 롤백되면 관련된 물리 트랜잭션은 롤백된다.

  • 해결 : REQUIRES_NEW 전파 타입을 사용해 트랜잭션을 분리해야한다.

  • 주의 : 트랜잭션 수 만큼 DB 커넥션이 생성된다.

    • 성능이 중요하다면 주의해서 사용할만 함
  • 원하는 의도대로 자식이 롤백되도 부모에서 영향 없도록 하려면 4번만 가능하다

    • 하단 실험 3번 참고
케이스전파 타입자식 예외부모 catch자식 결과부모 결과
1REQUIREDthrow롤백롤백
2REQUIREDthrow롤백롤백 (UnexpectedRollbackException)
3REQUIRES_NEWthrow롤백롤백 (예외 전파)
4REQUIRES_NEWthrow롤백커밋 ✅

실험 1 : Case2에서 예외를 정상적으로 잡아줘도 롤백이 안일어나는가?

  • 논리 트랜잭션에서 발생한 예외가 상위로 전파되며 해당 서비스에서 예외를 처리하면 롤백을 하지 않는다 라고 했는데, 과연 맞을지 실험해보자
  • 상황 : 영화 예매 성공 -> 로그 쌓던 중 예외 발생 -> 호출한 Service에서 Catch로 잡은 상황

Controller

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/movie")
@Tag(name = "영화예매", description = "영화예매 API")
public class MovieController {

    private final MovieService movieService;

    @PostMapping("/reserve")
    @Operation(summary = "영화예매", description = "영화예매 + 로그 저장")
    public String reserve() {
        movieService.reserveWithLog();
        return "OK";
    }
}

Service

@Service
@RequiredArgsConstructor
@Slf4j
public class MovieService {

    private final MovieReservationService reservationService;
    private final ReservationLogService logService;

    @Transactional  // 여기서 물리 트랜잭션 시작
    public void reserveWithLog() {
        reservationService.reserve("짱구는 못말려 극장판", "테스터유저");

        try {
            logService.saveLog("철현 예약 성공 로그 기록 시도");
        } catch (Exception e) {
            log.info("논리 트랜잭션 예외 catch 처리 완료  과연 커밋이 될까?");
            System.out.println("outer에서 로그 예외 캐치: " + e.getMessage());
        }

        log.info("=== 영화 예매 완료 ===");

        // 여기까지는 정상적으로 끝난 것처럼 보이지만...
        // 메서드가 리턴되는 시점에 커밋을 시도하다가
        // 이미 rollback-only 상태라 UnexpectedRollbackException 터짐
    }
}

-------

@Service
@RequiredArgsConstructor
public class MovieReservationService {

    private final MovieReservationRepository reservationRepository;

    @Transactional  // 기본: PROPAGATION_REQUIRED
    public void reserve(String movieTitle, String username) {
        MovieReservation reservation = new MovieReservation();
        reservation.setMovieTitle(movieTitle);
        reservation.setUsername(username);
        reservationRepository.save(reservation);
    }
}

----------

@Service
@RequiredArgsConstructor
@Slf4j
public class ReservationLogService {

    private final ReservationLogRepository logRepository;

    @Transactional  // 기본: PROPAGATION_REQUIRED (outer 트랜잭션에 참여)
    public void saveLog(String message) {
        ReservationLog reservationLog = new ReservationLog();
        reservationLog.setMessage(message);
        logRepository.save(reservationLog);

        // 테스트용으로 일부러 예외 발생
        if (true) {
            log.info("[영화 예매 로그 저장]논리 트랜잭션에서 예외 발생, rollback-only 표기");
            throw new RuntimeException("로그 저장 중 에러!");
        }
    }
}

------

Repository

  • JPA Repository 인터페이스

실행 결과

  • 논리 트랜잭션에서 발생한 예외가 상위로 전파되어 예외를 처리해줘도, 발생한 논리 트랜잭션의 rollback-only 표기를 수정되지 않아 UnexpectedRollbackException 가 발생한다.
2025-11-22T15:52:06.776+09:00  INFO 10959 --- [io-8080-exec-10] p6spy                                    : #1763794326776 | took 4ms | statement | connection 5| url jdbc:mysql://localhost:3306/test?characterEncoding=UTF-8
insert into movie_reservation (movie_title,username) values (?,?)
insert into movie_reservation (movie_title,username) values ('짱구는 못말려 극장판','테스터유저');
2025-11-22T15:52:06.784+09:00  INFO 10959 --- [io-8080-exec-10] p6spy                                    : #1763794326784 | took 3ms | statement | connection 5| url jdbc:mysql://localhost:3306/test?characterEncoding=UTF-8
insert into reservation_log (message) values (?)
insert into reservation_log (message) values ('철현 예약 성공 로그 기록 시도');
2025-11-22T15:52:06.784+09:00  INFO 10959 --- [io-8080-exec-10] i.t.d.m.service.ReservationLogService    : [영화 예매 로그 저장]논리 트랜잭션에서 예외 발생, rollback-only 표기
2025-11-22T15:52:06.785+09:00  INFO 10959 --- [io-8080-exec-10] i.t.domain.movie.service.MovieService    : 논리 트랜잭션 예외 catch 처리 완료 -> 과연 커밋이 될까?
outer에서 로그 예외 캐치: 로그 저장 중 에러!
2025-11-22T15:52:06.785+09:00  INFO 10959 --- [io-8080-exec-10] i.t.domain.movie.service.MovieService    : === 영화 예매 완료 ===
2025-11-22T15:52:06.787+09:00  INFO 10959 --- [io-8080-exec-10] p6spy                                    : #1763794326787 | took 2ms | rollback | connection 5| url jdbc:mysql://localhost:3306/test?characterEncoding=UTF-8

;
2025-11-22T15:52:06.792+09:00 ERROR 10959 --- [io-8080-exec-10] i.t.c.e.GlobalExceptionRestAdvice        : [500] POST /api/v1/movie/reserve - Transaction silently rolled back because it has been marked as rollback-only

org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
	at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:804) ~[spring-tx-6.2.11.jar:6.2.11]
	at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:758) ~[spring-tx-6.2.11.jar:6.2.11]
	at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:698) ~[spring-tx-6.2.11.jar:6.2.11]
	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:416) ~[spring-tx-6.2.11.jar:6.2.11]
	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-6.2.11.jar:6.2.11]

이유?

  • 로그 저장 로직 내부에서 RuntimeException 던짐 → rollback-only = true
  • 바깥에서 예외를 잡아도, “표시 자체는” 그대로라서
    • 마지막에 외부 트랜잭션 커밋 시도 → UnexpectedRollbackException 발생

해결책?

  • 영상의 마지막에서 나온것과 같이 로그 저장 부분을 propagation = Propagation.REQUIRES_NEW 전파 속성을 주어 별도의 물리 트랜잭션을 구성하도록 하면 된다.

실험2 : Requires_New 옵션에서 전파된 예외를 잡아주지 않으면 롤백이 되는가?

  • Case3 경우에서 예외를 잡아주지 않는 경우에 롤백이 되는지 실험

Controller

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/movie")
@Tag(name = "영화예매", description = "영화예매 트랜잭션 테스트 API")
public class MovieController {

    private final MovieService movieService;

    @PostMapping("/reserve")
    @Operation(summary = "REQUIRES_NEW + try-catch 없음", description = "자식이 REQUIRES_NEW이고 부모에서 catch 안 했을 때 동작 확인")
    public String reserve() {
        movieService.reserveWithLog();
        return "OK";
    }
}

Service

  • reserveWithLog
    • 부모도 롤백되는가?
@Service
@RequiredArgsConstructor
@Slf4j
public class MovieService {

    private final MovieReservationService reservationService;
    private final ReservationLogService logService;

    @Transactional
    public void reserveWithLog() {
        reservationService.reserve("짱구는 못말려 극장판", "테스터유저");

        // REQUIRES_NEW인데 try-catch 안 함
        // → 자식 트랜잭션은 독립적으로 롤백되지만
        // → 예외가 그대로 부모로 전파되어 부모 트랜잭션도 롤백됨
        logService.saveLog("철현 예약 성공 로그 기록 시도");

        log.info("=== 이 로그는 출력되지 않음 (위에서 예외 터져서) ===");
    }
}
@Service
@RequiredArgsConstructor
public class MovieReservationService {

    private final MovieReservationRepository reservationRepository;

    @Transactional
    public void reserve(String movieTitle, String username) {
        MovieReservation reservation = new MovieReservation();
        reservation.setMovieTitle(movieTitle);
        reservation.setUsername(username);
        reservationRepository.save(reservation);
    }
}
  • ReservationLogService : REQUIRES_NEW 전파 속성
    • 부모 트랜잭션에서 예외 잡아주지 않으면 부모도 롤백되는지 테스트
@Service
@RequiredArgsConstructor
@Slf4j
public class ReservationLogService {

    private final ReservationLogRepository logRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveLog(String message) {
        ReservationLog reservationLog = new ReservationLog();
        reservationLog.setMessage(message);
        logRepository.save(reservationLog);

        if (true) {
            log.info("[REQUIRES_NEW] 자식 트랜잭션에서 예외 발생 → 자식만 롤백");
            throw new RuntimeException("로그 저장 중 에러!");
        }
    }
}

결과

  • 부모도 롤백된다 테이블에 데이터 없음
2026-04-26 16:20:11.382 INFO  [http-nio-8080-exec-1] [MOVIE] p6spy - #1777188011382 | took 8ms | statement | connection 4| url jdbc:mysql://localhost:3306/sample?characterEncoding=UTF-8
insert into movie_reservation (movie_title,username) values (?,?)
insert into movie_reservation (movie_title,username) values ('짱구는 못말려 극장판','테스터유저');
2026-04-26 16:20:11.442 INFO  [http-nio-8080-exec-1] [MOVIE] p6spy - #1777188011442 | took 9ms | statement | connection 5| url jdbc:mysql://localhost:3306/sample?characterEncoding=UTF-8
insert into reservation_log (message) values (?)
insert into reservation_log (message) values ('철현 예약 성공 로그 기록 시도');
2026-04-26 16:20:11.443 INFO  [http-nio-8080-exec-1] [MOVIE] i.t.d.m.s.ReservationLogService - [REQUIRES_NEW] 자식 트랜잭션에서 예외 발생 → 자식만 롤백
2026-04-26 16:20:11.449 INFO  [http-nio-8080-exec-1] [MOVIE] p6spy - #1777188011449 | took 4ms | rollback | connection 5| url jdbc:mysql://localhost:3306/sample?characterEncoding=UTF-8

;
2026-04-26 16:20:11.454 INFO  [http-nio-8080-exec-1] [MOVIE] p6spy - #1777188011454 | took 1ms | rollback | connection 4| url jdbc:mysql://localhost:3306/sample?characterEncoding=UTF-8

;
2026-04-26 16:20:11.458 ERROR [http-nio-8080-exec-1] [MOVIE] i.t.c.e.GlobalExceptionRestAdvice - [SERVER_EXCEPTION] POST /api/v1/movie/reserve - 로그 저장 중 에러!
java.lang.RuntimeException: 로그 저장 중 에러!
	at io.sample.domain.movie.service.ReservationLogService.saveLog(ReservationLogService.java:26)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:360)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163)
	at org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint.proceed(MethodInvocationProceedingJoinPoint.java:89)
	at io.sample.common.aop.DomainLogAspect.handleDomainLog(DomainLogAspect.java:27)

..

2026-04-26 16:20:11.491 WARN  [http-nio-8080-exec-1] [ETC] o.s.w.s.m.m.a.ExceptionHandlerExceptionResolver - Resolved [java.lang.RuntimeException: 로그 저장 중 에러!]

실험 3 : 위 예시를 부모에서 잡으면 커밋이 되는가? 눈으로 보자

Service 수정

  • MovieService 에서 자식 예외 잡도록
@Service
@RequiredArgsConstructor
@Slf4j
public class MovieService {

    private final MovieReservationService reservationService;
    private final ReservationLogService logService;

    @Transactional
    public void reserveWithLog() {
        reservationService.reserve("짱구는 못말려 극장판", "테스터유저");

        // REQUIRES_NEW → 자식은 독립 트랜잭션
        // 부모에서 catch → 예외 전파 차단 → 부모 트랜잭션 정상 커밋
        try {
            logService.saveLog("철현 예약 성공 로그 기록 시도");
        } catch (Exception e) {
            log.info("자식 트랜잭션 롤백됨, 부모는 계속 진행. 예외: {}", e.getMessage());
        }

        log.info("=== 부모 트랜잭션 정상 커밋 ===");
    }
}
  • ReservationLogService 전파 속성 REQUIRES_NEW
@Service
@RequiredArgsConstructor
@Slf4j
public class ReservationLogService {

    private final ReservationLogRepository logRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveLog(String message) {
        ReservationLog reservationLog = new ReservationLog();
        reservationLog.setMessage(message);
        logRepository.save(reservationLog);

        if (true) {
            log.info("[REQUIRES_NEW] 자식 트랜잭션에서 예외 발생 → 자식만 롤백");
            throw new RuntimeException("로그 저장 중 에러!");
        }
    }
}
  • 결과: 부모는 반영되고, 자식은 예외를 잡아줬기에 자식은 롤백됨
2026-04-26 16:29:59.211 INFO  [http-nio-8080-exec-1] [ETC] o.s.web.servlet.DispatcherServlet - Completed initialization in 10 ms
2026-04-26 16:29:59.245 INFO  [http-nio-8080-exec-1] [MOVIE] p6spy - #1777188599245 | took 3ms | statement | connection 12| url jdbc:mysql://localhost:3306/sample?characterEncoding=UTF-8
insert into movie_reservation (movie_title,username) values (?,?)
insert into movie_reservation (movie_title,username) values ('짱구는 못말려 극장판','테스터유저');
2026-04-26 16:29:59.258 INFO  [http-nio-8080-exec-1] [MOVIE] p6spy - #1777188599258 | took 4ms | statement | connection 13| url jdbc:mysql://localhost:3306/sample?characterEncoding=UTF-8
insert into reservation_log (message) values (?)
insert into reservation_log (message) values ('철현 예약 성공 로그 기록 시도');
2026-04-26 16:29:59.259 INFO  [http-nio-8080-exec-1] [MOVIE] i.t.d.m.s.ReservationLogService - [REQUIRES_NEW] 자식 트랜잭션에서 예외 발생 → 자식만 롤백
2026-04-26 16:29:59.262 INFO  [http-nio-8080-exec-1] [MOVIE] p6spy - #1777188599262 | took 1ms | rollback | connection 13| url jdbc:mysql://localhost:3306/sample?characterEncoding=UTF-8

;
2026-04-26 16:29:59.265 INFO  [http-nio-8080-exec-1] [MOVIE] i.t.d.movie.service.MovieService - 자식 트랜잭션 롤백됨, 부모는 계속 진행. 예외: 로그 저장 중 에러!
2026-04-26 16:29:59.265 INFO  [http-nio-8080-exec-1] [MOVIE] i.t.d.movie.service.MovieService - === 부모 트랜잭션 정상 커밋 ===
2026-04-26 16:29:59.269 INFO  [http-nio-8080-exec-1] [MOVIE] p6spy - #1777188599269 | took 3ms | commit | connection 12| url jdbc:mysql://localhost:3306/sample?characterEncoding=UTF-8

;
profile
비슷한 어려움을 겪는 누군가에게 도움이 되길

0개의 댓글