본문 바로가기

Spring DB

[Spring DB] @Transactional – 트랜잭션 전파 (Propagation)

스프링 트랜잭션의 핵심은 @Transactional을 통한 AOP 기반의 선언적 트랜잭션 관리다.

하지만 여러 트랜잭션이 복잡하게 얽힌 상황, 예를 들어 트랜잭션이 진행 중인 메서드 안에서 새로운 트랜잭션이 필요한 다른 메서드를 호출하는 경우에는 어떻게 동작해야 할까? 이때 필요한 개념이 바로 트랜잭션 전파다.

 


🧩 트랜잭션 전파란 무엇인가?

 

트랜잭션 전파(Propagation)란, 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때, 새로운 트랜잭션 메서드를 어떻게 동작시킬지 결정하는 규칙이다.

쉽게 말해, 하나의 트랜잭션 작업(예: 주문 처리) 중에 다른 트랜잭션 작업(예: 로그 기록)을 호출했을 때,

이 두 트랜잭션을 하나의 큰 트랜잭션으로 묶을 것인지, 아니면 별개의 트랜잭션으로 처리할 것인지 정의하는 것이다.

트랜잭션 전파를 이해하려면 우선 물리 트랜잭션논리 트랜잭션의 차이를 알아야 한다.

 


🧠 물리 트랜잭션 vs 논리 트랜잭션

 

스프링은 이해를 돕기 위해, 트랜잭션의 개념을 물리 트랜잭션과 논리 트랜잭션으로 나눈다.

 

  • 물리 트랜잭션
    • 데이터베이스 커넥션을 통해 직접 시작되는 실제 트랜잭션을 의미한다.
    • 실제 커넥션을 통해서 트랜잭션을 시작하고, 커밋 또는 롤백하는 단위다.
  • 논리 트랜잭션
    • 스프링의 트랜잭션 매니저를 통해 트랜잭션을 사용하는 단위다.
    • 즉, @Transactional을 기준으로 인식하는 트랜잭션의 단위다.
    • (논리 트랜잭션이 하나일 경우에는 따로 개념을 나누지 않는다.)


트랜잭션의 개념을 물리 트랜잭션논리 트랜잭션으로 나누면, 다음과 같은 단순한 규칙을 만들 수 있다.

1. 하나의 물리 트랜잭션 안에 여러 개의 논리 트랜잭션이 포함될 수 있다.
2. 하나의 논리 트랜잭션이라도 롤백되면, 물리 트랜잭션은 롤백된다.
(즉, 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션도 커밋된다.)

 


✅ 핵심 전파 옵션 – REQUIRED, REQUIRES_NEW

 

@Transactional의 propagation 옵션을 통해 다양한 전파 규칙을 설정할 수 있다.

실무에서는 대부분 다음 두 가지 옵션을 주로 사용한다.

 

  1. REQUIRED (기본값)
    • 의미: "트랜잭션이 필수적이다."
    • 기존 트랜잭션  : 새로운 물리 트랜잭션을 시작한다.
    • 기존 트랜잭션 ⭕️ : 해당 트랜잭션에 참여한다. (새로운 물리 트랜잭션을 시작하지 않고, 기존의 트랜잭션 범위 안으로 들어간다.)
    • 특징:
      • 모든 논리 트랜잭션이 하나의 물리 트랜잭션으로 묶여 동작한다.
      • 따라서 내부의 논리 트랜잭션에서 롤백이 발생하면, 외부의 논리 트랜잭션을 포함한 전체 물리 트랜잭션이 모두 롤백된다.
  2. REQUIRES_NEW
    • 의미: "항상 새로운 트랜잭션이 필요하다."
    • 기존 트랜잭션  : 새로운 물리 트랜잭션을 시작한다.
    • 기존 트랜잭션 ⭕️ : 진행 중인 기존 트랜잭션을 잠시 보류(suspend)시키고, 완전히 새로운 물리 트랜잭션을 시작한다.
    • 특징:
      • 내부 트랜잭션은 외부 트랜잭션과 완전히 분리된 별개의 물리 트랜잭션에서 동작한다.
      • 따라서 내부 트랜잭션에서 롤백이 발생하더라도, 외부 트랜잭션에는 아무런 영향을 주지 않는다.
      • 외부 트랜잭션은 자신의 로직을 계속 수행하고, 독자적으로 커밋 또는 롤백할 수 있다.

 


🤔 언제 REQUIRES_NEW를 사용할까?

하나의 비즈니스 로직 안에서, 앞선 작업의 성공 여부와 관계없이, 반드시 독립적으로 처리되어야 하는 작업이 있을 때 사용한다.

 

예를 들어, 주문 처리 로직이 실패하더라도, 해당 사실을 기록하는 로그는 반드시 데이터베이스에 저장되어야 하는 경우, 로그를 기록하는 메서드에 REQUIRES_NEW 옵션을 적용할 수 있다.

 

 

 

코드를 통해 이해해 보자


1. 각각 별도의 물리 트랜잭션 사용

@Test
void 커밋_커밋() {
    log.info("트랜잭션1 시작");
    TransactionStatus status1 = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("트랜잭션1 커밋");
    txManager.commit(status1);

    log.info("트랜잭션2 시작");
    TransactionStatus status2 = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("트랜잭션2 커밋");
    txManager.commit(status2);
}

  • 트랜잭션1을 시작하고, 히카리 커넥션 풀에서 conn0 커넥션을 획득했다.
  • 트랜잭션1을 커밋하고, 히카리 커넥션 풀에 conn0 커넥션을 반납했다.
  • 트랜잭션2를 시작하고, 히카리 커넥션 풀에서 conn0 커넥션을 획득했다.
  • 트랜잭션2를 커밋하고, 히카리 커넥션 풀에 conn0 커넥션을 반납했다.

로그를 보면 두 트랜잭션 모두 같은 커넥션을 사용한다. 트랜잭션1이 사용하고 반납한 커넥션을 바로 트랜잭션2가 획득했기 때문이다.

하지만 두 트랜잭션은 서로 독립적이므로, 서로 완전히 다른 커넥션으로 인지해야 한다.

 

히카리 커넥션 풀은 실제 커넥션을 반환하지 않고, 내부 관리를 위해 히카리 프록시 커넥션이라는 객체를 생성하여 반환한다.

이 객체의 주소를 확인하면 커넥션 풀에서 획득한 커넥션을 구분할 수 있다.

  • 트랜잭션1의 커넥션 = HikariProxyConnection@852481826 wrapping conn0
  • 트랜잭션2의 커넥션 = HikariProxyConnection@1847703288 wrapping conn0

결론적으로 두 트랜잭션의 커넥션은 물리적으로는 같은 커넥션이지만, 각각 커넥션 풀에서 조회한 것이므로 논리적으로는 다른 커넥션이다.

따라서 두 트랜잭션의 커밋/롤백 결과는 서로에게 영향을 주지 않는다.

 


2. 이미 진행 중인 트랜잭션 내에서, 추가로 트랜잭션 수행 (REQUIRED)

@Test
void 외부_트랜잭션_내부_트랜잭션() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("outer.isNewTransaction() = {}", outer.isNewTransaction());

    log.info("내부 트랜잭션 시작");
    TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("inner.isNewTransaction() = {}", inner.isNewTransaction());
    log.info("내부 트랜잭션 커밋");
    txManager.commit(inner);

    log.info("외부 트랜잭션 커밋");
    txManager.commit(outer);
}
  1. 트랜잭션 매니저를 통해 외부 트랜잭션이 시작한다.
    • 트랜잭션 매니저는 트랜잭션 동기화 매니저를 통해 기존 트랜잭션이 존재하는지 확인한다.
    • 기존 트랜잭션이 존재하지 않으므로, 신규 트랜잭션을 생성한다.
    • 데이터소스를 통해 커넥션을 생성하고, 수동 커밋 모드로 설정하여, 트랜잭션 동기화 매니저에 보관한다.
    • 이후 로직에서 커넥션이 필요하면 트랜잭션 동기화 매니저를 통해 트랜잭션이 적용된 커넥션을 획득하여 사용한다.
  2. 트랜잭션 매니저를 통해 내부 트랜잭션이 시작한다.
    • 트랜잭션 매니저는 트랜잭션 동기화 매니저를 통해 기존 트랜잭션이 존재하는지 확인한다.
    • 기존 트랜잭션이 존재하므로, 기존 트랜잭션에 참여한다. (기본 트랜잭션 전파 동작)
    • 이후 로직은 기존에 외부 트랜잭션이 트랜잭션 동기화 매니저에 보관한 커넥션을 사용한다.
  3. 트랜잭션 매니저를 통해 내부 트랜잭션이 커밋한다.
    • 트랜잭션 매니저는 커밋 시점에 신규 트랜잭션 여부에 따라 다르게 동작한다.
    • 내부 트랜잭션은 신규 트랜잭션이 아니므로, 실제 커밋을 호출하지 않는다.
    • 실제 커넥션에 커밋이나 롤백을 호출할 경우 물리 트랜잭션이 종료되어 버린다.
    • 하지만 아직 트랜잭션이 끝난 것이 아니기 때문에 실제 커밋을 호출하면 안 된다.
    • 물리 트랜잭션은 외부 트랜잭션을 종료할 때까지 이어져야 한다.
  4. 트랜잭션 매니저를 통해  트랜잭션이 커밋한다.
    • 외부 트랜잭션은 신규 트랜잭션이므로, DB 커넥션에 실제 커밋을 호출한다.
    • 데이터베이스에 실제로 커밋 결과가 반영되고, 물리 트랜잭션도 종료된다.
    • 트랜잭션 매니저에 커밋하는 것을 논리 커밋이라 하면, 실제 DB 커넥션에 커밋하는 것은 물리 커밋이라 할 수 있다.

  • 외부 트랜잭션(outer)이 실행 중인데, 내부 트랜잭션(inner)을 시작했다.
  • 외부 트랜잭션은 처음 실행된 트랜잭션이므로, 신규 트랜잭션이다. (isNewTransaction() == true)
  • 내부 트랜잭션이 시작하는 시점에는 이미 외부 트랜잭션이 실행 중인 상태이므로, 내부 트랜잭션은 외부 트랜잭션에 참여하게 된다.
  • 따라서 내부 트랜잭션은 신규 트랜잭션이 아니다. (isNewTransaction() == false)
  • 내부 트랜잭션이 커밋을 호출했지만, 로그를 보면 실제 커넥션이 커밋된 기록은 없다.
  • 외부 트랜잭션이 커밋을 호출하자, 그제야 실제로 커넥션을 통해 커밋된 기록이 보인다.
  • 트랜잭션 기본 전파 옵션인 REQUIRED는 외부 트랜잭션과 내부 트랜잭션을 묶어서 하나의 물리 트랜잭션으로 관리한다.
  • 즉, 처음 트랜잭션을 시작한 외부 트랜잭션이 실제 물리 트랜잭션을 관리한다.

 

✅ 정리

  • 신규 트랜잭션인 경우에만 실제 커넥션을 통해 물리 커밋(또는 롤백)을 수행한다.
  • 신규 트랜잭션이 아니면 실제 물리 커넥션을 사용하지 않는다.
  • 따라서 트랜잭션 매니저에 커밋하는 것이 항상 실제 커넥션에 물리 커밋하는 것으로 이어지지는 않는다.
  • 이 경우 논리 트랜잭션물리 트랜잭션으로 개념을 나누게 된다.
  • 트랜잭션 매니저를 통해 논리 트랜잭션을 관리하며, 모든 논리 트랜잭션이 커밋되면 물리 트랜잭션이 커밋된다.

 


3. 내부 트랜잭션 커밋, 외부 트랜잭션 롤백

@Test
void 외부_트랜잭션_롤백() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());

    log.info("내부 트랜잭션 시작");
    TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("내부 트랜잭션 커밋");
    txManager.commit(inner);

    log.info("외부 트랜잭션 롤백");
    txManager.rollback(outer);
}

  • 외부 트랜잭션이 물리 트랜잭션을 시작하고, 내부 트랜잭션이 해당 트랜잭션에 참여한다.
    • 즉, 외부 트랜잭션이 시작한 물리 트랜잭션의 범위가 내부 트랜잭션까지 확장되어 사용된다.
    • 따라서 내부 트랜잭션은 물리 트랜잭션에 직접적으로 관여하지 않는다.
  • 내부 트랜잭션이 트랜잭션 매니저를 통해 커밋을 호출한다.
    • 내부 트랜잭션은 신규 트랜잭션이 아니므로, 실제 커넥션에 물리 커밋이 호출되지 않는다.
  • 외부 트랜잭션이 트랜잭션 매니저를 통해 롤백을 호출한다.
    • 외부 트랜잭션은 신규 트랜잭션이므로, 실제 커넥션에 물리 롤백이 호출된다.

 

✅ 정리

  • 물리 트랜잭션은 모든 논리 트랜잭션(외부 트랜잭션과 내부 트랜잭션)이 커밋되어야 커밋된다.
    • 논리 트랜잭션이 하나라도 롤백되면 물리 트랜잭션은 롤백된다.
    • 위 경우, 내부 트랜잭션은 커밋했지만 외부 트랜잭션은 롤백되었으므로, 전체 물리 트랜잭션은 롤백된다.

 


4. 내부 트랜잭션 롤백, 외부 트랜잭션 커밋

@Test
void 내부_트랜잭션_롤백() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());

    log.info("내부 트랜잭션 시작");
    TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("내부 트랜잭션 롤백");
    txManager.rollback(inner);

    log.info("외부 트랜잭션 커밋");
    assertThatThrownBy(() -> txManager.commit(outer))
            .isInstanceOf(UnexpectedRollbackException.class);
}
  • 내부 로직이 실패하더라도, 외부 로직은 정상적으로 커밋하고 싶을 수 있다.
  • 예를 들어, 회원 가입 로그를 남기는 데 실패하더라도, 회원 가입은 유지해야 한다.
  • 따라서 내부 로직이 발생한 예외를 외부 로직에서 잡아서 정상적으로 처리했다고 가정해 보자.

  • 외부 트랜잭션이 물리 트랜잭션을 시작하고, 내부 트랜잭션이 해당 트랜잭션에 참여한다.
  • 내부 트랜잭션이 트랜잭션 매니저를 통해 롤백을 호출한다.
    • 내부 트랜잭션은 신규 트랜잭션이 아니므로, 실제 커넥션에 물리 롤백이 호출되지 않는다.
    • 그 대신, 트랜잭션 동기화 매니저에 기존 트랜잭션을 롤백 전용으로 표시한다.
  • 외부 트랜잭션이 트랜잭션 매니저를 통해 커밋을 호출한다.
    • 외부 트랜잭션은 신규 트랜잭션이므로, 실제 커넥션에 물리 커밋을 호출해야 한다.
    • 그 전에 우선 트랜잭션 동기화 매니저에 해당 트랜잭션에 롤백 전용 표시가 있는지 확인한다.
    • 롤백 전용으로 표시되어 있는 경우, 비록 커밋을 호출했지만, 물리 트랜잭션을 롤백하게 된다.
    • 트랜잭션 매니저에 커밋을 호출한 개발자 입장에서는 커밋되기를 기대했는데 롤백이 되었다.
    • 이 사실은 반드시 개발자에게 알려야 한다!
    • 따라서 스프링은 UnexpectedRollbackException을 던져서 기대하지 않은 롤백이 발생한 사실을 명확하게 알려준다.

 

✅ 정리

  • 논리 트랜잭션이 하나라도 롤백되면, 물리 트랜잭션은 롤백된다.
  • 내부 트랜잭션이 롤백되면 트랜잭션에 롤백 전용을 표시한다.
  • 외부 트랜잭션은 커밋할 때 롤백 전용 표시를 확인하고, 롤백 전용 표시가 되어있으면 물리 트랜잭션을 롤백하고 UnexpectedRollbackException을 던진다.

 


5. 내부 트랜잭션과 외부 트랜잭션을 별도의 트랜잭션으로 관리 (REQUIRES_NEW)

@Test
void 내부_트랜잭션을_별도의_트랜잭션으로() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("outer.isNewTransaction() = {}", outer.isNewTransaction());

    log.info("내부 트랜잭션 시작");
    DefaultTransactionAttribute definition = new DefaultTransactionAttribute();
    definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
    TransactionStatus inner = txManager.getTransaction(definition);
    log.info("inner.isNewTransaction() = {}", inner.isNewTransaction());
    log.info("내부 트랜잭션 롤백");
    txManager.rollback(inner);

    log.info("외부 트랜잭션 커밋");
    txManager.commit(outer);
}
  • 내부 트랜잭션과 외부 트랜잭션을 완전히 분리하려면, 내부 트랜잭션의 전파 옵션을 REQUIRES_NEW로 설정하면 된다.
  • 이렇게 하면 내부 트랜잭션과 외부 트랜잭션이 각각 별도의 물리 트랜잭션을 사용하게 된다.
  • 즉, 서로 다른 DB 커넥션을 사용한다는 뜻이다.
  • 따라서 각 트랜잭션은 서로 영향을 주지 않게 된다.

  • 외부 트랜잭션이 시작하면서 conn0 커넥션을 획득하고, manual commit으로 변경하여 물리 트랜잭션을 시작한다.
    • 따라서 외부 트랜잭션은 신규 트랜잭션이다. (outer.isNewTransaction() = true)
  • 내부 트랜잭션도 시작하면서 conn1 커넥션을 획득하고, manual commit으로 변경하여 물리 트랜잭션을 시작한다.
    • 즉, 기존 트랜잭션에 참여하지 않고, 새로운 물리 트랜잭션을 생성하여 시작한다.
    • 따라서 내부 트랜잭션도 신규 트랜잭션이다. (inner.isNewTransaction() = true)
  • 내부 트랜잭션이 트랜잭션 매니저를 통해 롤백을 호출한다.
    • 내부 트랜잭션은 신규 트랜잭션이므로, conn1 커넥션에 실제 물리 트랜잭션을 롤백한다.
  • 외부 트랜잭션이 트랜잭션 매니저를 통해 커밋을 호출한다.
    • 외부 트랜잭션도 신규 트랜잭션이므로, conn0 커넥션에 실제 물리 트랜잭션을 커밋한다.

 

✅ 정리

  • REQUIRES_NEW 옵션을 사용하면 물리 트랜잭션이 명확하게 분리된다.
  • ⚠️ 단, DB 커넥션이 동시에 2개가 사용된다는 점을 주의해야 한다.
  • REQUIRES_NEW를 사용하지 않고 문제를 해결할 수 있다면 그 방법을 선택하는 것이 더 좋다.
    • 예를 들어, 별도의 트랜잭션을 사용하는 두 로직을 별도의 계층에서 각각 호출하는 방법이 있다.

 


📚 트랜잭션 전파 옵션 정리

전파 옵션 기존 트랜잭션 존재 ⭕️ 기존 트랜잭션 없음 ❌
REQUIRED 현재 트랜잭션에 참여 새 트랜잭션을 생성
REQUIRES_NEW 새 트랜잭션을 생성하고, 현재 트랜잭션을 일시 중단 새 트랜잭션을 생성
SUPPORTS 현재 트랜잭션에 참여 트랜잭션 없이 실행
NOT_SUPPORTED 현재 트랜잭션을 일시 중단하고 실행 트랜잭션 없이 실행
MANDATORY 현재 트랜잭션에 참여 IllegalTransactionStateException 발생
NEVER IllegalTransactionStateException 발생 트랜잭션 없이 실행
NESTED 중첩 트랜잭션 내에서 실행 새 트랜잭션을 생성

 

  • REQUIRED
    • 현재 트랜잭션을 지원하고, 트랜잭션이 없으면 새로 생성한다.
  • REQUIRES_NEW
    • 새 트랜잭션을 생성하고, 현재 트랜잭션이 있는 경우 일시 중단한다.
  • SUPPORTS
    • 현재 트랜잭션을 지원하고, 트랜잭션이 없으면 트랜잭션 없이 실행한다.
  • NOT_SUPPORTED
    • 트랜잭션이 아닌 방식으로 실행하며, 현재 트랜잭션이 있는 경우 일시 중단한다.
  • MANDATORY
    • 현재 트랜잭션을 지원하고, 트랜잭션이 없으면 예외를 발생시킨다.
  • NEVER
    • 트랜잭션이 아닌 방식으로 실행하며, 현재 트랜잭션이 있는 경우 예외를 발생시킨다.
  • NESTED
    • 현재 트랜잭션이 존재하는 경우 중첩 트랜잭션 내에서 실행하고, 그렇지 않은 경우 REQUIRED처럼 동작한다.
    • 중첩 트랜잭션은 외부 트랜잭션의 영향은 받지만, 외부 트랜잭션에 영향을 주지는 않는다.

 

참고: isolation, timeout, readOnly는 트랜잭션이 처음 시작될 때만 적용된다. 트랜잭션에 참여하는 경우에는 적용되지 않는다.