Spring AOP와 @Transactional 동작 원리 완벽 가이드

Spring AOP와 @Transactional 동작 원리 완벽 가이드

@Transactional을 메서드 위에 붙이면 트랜잭션이 동작한다. 그런데 어떻게? 이 어노테이션이 실제로 어떤 메커니즘으로 트랜잭션을 시작하고, 커밋하고, 롤백하는지를 이해하지 못하면 “왜 @Transactional이 안 먹히죠?”라는 질문에 답할 수 없다.

이전 글에서 트랜잭션의 ACID 원칙과 격리 수준을 다루었다. 이번 글에서는 Spring이 트랜잭션을 관리하는 내부 메커니즘 — AOP, 프록시, TransactionInterceptor, PlatformTransactionManager — 을 깊이 있게 다룬다. 더불어 AOP의 핵심 개념과 커스텀 Aspect 구현까지 포함한다.


1. AOP(Aspect-Oriented Programming)란

1.1 횡단 관심사 문제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// AOP 없이 트랜잭션, 로깅, 보안을 직접 처리하는 코드
public class OrderService {
    public void createOrder(OrderRequest request) {
        // 보안 검사
        if (!SecurityContext.hasPermission("ORDER_CREATE")) {
            throw new UnauthorizedException();
        }

        // 로깅
        log.info("주문 생성 시작: {}", request);
        long startTime = System.currentTimeMillis();

        // 트랜잭션 시작
        Connection conn = dataSource.getConnection();
        conn.setAutoCommit(false);

        try {
            // === 비즈니스 로직 (진짜 하고 싶은 코드) ===
            Order order = new Order(request);
            orderRepository.save(order);
            // === 비즈니스 로직 끝 ===

            conn.commit();
        } catch (Exception e) {
            conn.rollback();
            throw e;
        } finally {
            conn.close();
        }

        // 로깅
        log.info("주문 생성 완료: {}ms", System.currentTimeMillis() - startTime);
    }
}

비즈니스 로직은 2줄인데, 보안/로깅/트랜잭션 코드가 대부분을 차지한다. 이 패턴이 수십 개의 서비스 메서드에 반복된다. 이것이 횡단 관심사(Cross-Cutting Concerns) 문제다.

1
2
3
4
5
6
7
8
9
10
11
12
// AOP가 적용된 코드
@Service
public class OrderService {

    @Secured("ORDER_CREATE")  // 보안 → AOP
    @Transactional            // 트랜잭션 → AOP
    @Timed                    // 로깅 → AOP
    public void createOrder(OrderRequest request) {
        Order order = new Order(request);
        orderRepository.save(order);
    }
}

AOP는 횡단 관심사를 비즈니스 로직에서 분리하여 별도의 모듈(Aspect)로 관리하는 프로그래밍 패러다임이다. OOP가 객체 단위로 관심사를 분리한다면, AOP는 횡단 관심사 단위로 분리한다.

1.2 AOP 핵심 용어

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌─────────────────────────────────────────────────┐
│                   Aspect                         │
│  (횡단 관심사를 모듈화한 것. 예: 트랜잭션 관리)     │
│                                                  │
│  ┌──────────┐    ┌──────────────┐               │
│  │ Pointcut │    │    Advice    │               │
│  │ (어디에?) │    │  (무엇을?)   │               │
│  │          │    │              │               │
│  │ 메서드    │    │ Before       │               │
│  │ 실행 지점 │    │ After        │               │
│  │ 선택     │    │ Around       │               │
│  └──────────┘    └──────────────┘               │
└─────────────────────────────────────────────────┘
                      ↓ Weaving (적용)
┌─────────────────────────────────────────────────┐
│                 Target Object                    │
│            (Aspect가 적용되는 대상)                │
│                                                  │
│    ● Join Point (메서드 실행 지점)                 │
│    ● Join Point                                  │
│    ● Join Point                                  │
└─────────────────────────────────────────────────┘
용어 설명 예시
Aspect 횡단 관심사를 모듈화한 클래스 @Aspect 클래스, 트랜잭션 관리
Join Point Aspect를 적용할 수 있는 실행 지점 메서드 실행, 예외 발생 시점
Pointcut Join Point를 선택하는 표현식 execution(* com.example.service.*.*(..))
Advice 실제로 실행되는 코드 Before, After, Around
Weaving Aspect를 Target에 적용하는 과정 컴파일/로드/런타임 시점
Target Aspect가 적용되는 대상 객체 OrderService

1.3 Advice 종류

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Aspect
@Component
public class LoggingAspect {

    // Before: 메서드 실행 전
    @Before("execution(* com.example.service.*.*(..))")
    public void logBefore(JoinPoint joinPoint) {
        log.info("호출: {}", joinPoint.getSignature().getName());
    }

    // AfterReturning: 메서드 정상 완료 후
    @AfterReturning(pointcut = "execution(* com.example.service.*.*(..))",
                    returning = "result")
    public void logAfterReturning(JoinPoint joinPoint, Object result) {
        log.info("완료: {} → {}", joinPoint.getSignature().getName(), result);
    }

    // AfterThrowing: 예외 발생 시
    @AfterThrowing(pointcut = "execution(* com.example.service.*.*(..))",
                   throwing = "ex")
    public void logAfterThrowing(JoinPoint joinPoint, Exception ex) {
        log.error("예외: {} → {}", joinPoint.getSignature().getName(), ex.getMessage());
    }

    // After: 정상/예외 관계없이 항상 실행 (finally와 유사)
    @After("execution(* com.example.service.*.*(..))")
    public void logAfter(JoinPoint joinPoint) {
        log.info("종료: {}", joinPoint.getSignature().getName());
    }

    // Around: 메서드 실행 전후를 모두 제어 (가장 강력)
    @Around("execution(* com.example.service.*.*(..))")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            Object result = joinPoint.proceed();  // 실제 메서드 실행
            return result;
        } finally {
            long elapsed = System.currentTimeMillis() - start;
            log.info("{} 실행시간: {}ms",
                joinPoint.getSignature().getName(), elapsed);
        }
    }
}

@Around가 가장 강력하다. 메서드 실행 전후를 모두 제어할 수 있고, 반환값을 변경하거나, 예외를 삼키거나, 메서드 실행 자체를 건너뛸 수도 있다. @Transactional은 내부적으로 Around Advice로 구현되어 있다.


2. Spring AOP vs AspectJ

비교 항목 Spring AOP AspectJ
Weaving 시점 런타임 (프록시 기반) 컴파일/로드 타임
Join Point 메서드 실행만 메서드, 필드, 생성자, 정적 초기화 등
성능 프록시 오버헤드 존재 직접 바이트코드 수정 → 빠름
설정 복잡도 간단 (Spring 기반) 복잡 (별도 컴파일러/에이전트 필요)
Self-invocation 동작하지 않음 (프록시 우회) 동작함
private 메서드 적용 불가 적용 가능
적용 범위 Spring Bean만 모든 Java 객체

Spring AOP가 런타임 프록시 방식을 선택한 이유는 간결함이다. 별도의 컴파일러나 에이전트 없이, Spring 컨테이너만으로 AOP를 사용할 수 있다. 대부분의 엔터프라이즈 애플리케이션에서 메서드 레벨 AOP로 충분하기 때문이다.


3. Spring AOP의 프록시 메커니즘

Spring AOP의 핵심은 프록시 패턴이다. 클라이언트가 실제 객체(Target)를 직접 호출하는 것이 아니라, 프록시 객체를 통해 호출한다. 프록시는 호출을 가로채어 Advice를 실행한 후 Target의 메서드를 호출한다.

1
2
3
4
5
6
클라이언트 → [프록시] → [Advice 체인] → [Target 메서드]
              │
              ├── Before Advice 실행
              ├── Target 메서드 호출
              ├── AfterReturning / AfterThrowing 실행
              └── After Advice 실행

3.1 JDK Dynamic Proxy

인터페이스가 있는 경우 사용된다. java.lang.reflect.ProxyInvocationHandler를 기반으로 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// JDK Dynamic Proxy 직접 구현 예시
public interface OrderService {
    void createOrder(OrderRequest request);
}

public class OrderServiceImpl implements OrderService {
    @Override
    public void createOrder(OrderRequest request) {
        System.out.println("주문 생성: " + request);
    }
}

// InvocationHandler: 프록시의 모든 메서드 호출을 가로챈다
public class TransactionHandler implements InvocationHandler {
    private final Object target;

    public TransactionHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("트랜잭션 시작");
        try {
            Object result = method.invoke(target, args);  // 실제 메서드 호출
            System.out.println("트랜잭션 커밋");
            return result;
        } catch (Exception e) {
            System.out.println("트랜잭션 롤백");
            throw e;
        }
    }
}

// 프록시 생성
OrderService target = new OrderServiceImpl();
OrderService proxy = (OrderService) Proxy.newProxyInstance(
    target.getClass().getClassLoader(),
    new Class[]{OrderService.class},
    new TransactionHandler(target)
);

proxy.createOrder(request);  // 프록시를 통해 호출

JDK Dynamic Proxy의 제한: 반드시 인터페이스가 있어야 한다. 인터페이스 없이 구체 클래스만 있으면 사용할 수 없다.

3.2 CGLIB Proxy

인터페이스가 없는 경우(또는 Spring Boot의 기본 설정에서) 사용된다. 대상 클래스를 상속(subclassing)하여 프록시를 생성한다. 바이트코드 조작 라이브러리인 CGLIB을 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// CGLIB Proxy 동작 원리 (개념적)
public class OrderServiceImpl {
    public void createOrder(OrderRequest request) {
        System.out.println("주문 생성: " + request);
    }
}

// CGLIB이 생성하는 프록시 (개념적으로 이런 서브클래스를 만든다)
public class OrderServiceImpl$$EnhancerBySpringCGLIB extends OrderServiceImpl {
    private MethodInterceptor interceptor;

    @Override
    public void createOrder(OrderRequest request) {
        // Interceptor를 통해 호출
        interceptor.intercept(this, method, args, methodProxy);
    }
}

CGLIB의 제한: 상속 기반이므로 final 클래스나 final 메서드는 프록시할 수 없다.

3.3 Spring Boot의 기본 설정

1
2
3
4
5
6
7
Spring Framework (전통):
  - 인터페이스가 있으면 → JDK Dynamic Proxy
  - 인터페이스가 없으면 → CGLIB

Spring Boot 2.0+ (기본):
  - 항상 CGLIB (proxyTargetClass = true가 기본)
  - 이유: 인터페이스 유무에 관계없이 일관된 동작을 보장하기 위해
1
2
# Spring Boot에서 JDK Proxy로 변경하고 싶다면
spring.aop.proxy-target-class=false

4. Pointcut 표현식

Pointcut은 “어떤 메서드에 Advice를 적용할 것인가”를 결정하는 표현식이다.

4.1 execution 표현식

가장 많이 사용되는 Pointcut 지시자. 메서드 실행 지점을 매칭한다.

1
2
3
4
5
6
7
8
execution(접근제어자? 반환타입 패키지.클래스.메서드명(파라미터) throws 예외?)

execution(* com.example.service.*.*(..))
         │  │                    │ │ └── (..) 모든 파라미터
         │  │                    │ └── * 모든 메서드
         │  │                    └── * 모든 클래스
         │  └── com.example.service 패키지
         └── * 모든 반환 타입
1
2
3
4
5
6
// 다양한 execution 패턴
@Pointcut("execution(public * *(..))")                        // 모든 public 메서드
@Pointcut("execution(* com.example.service.*Service.*(..))")  // *Service 클래스의 모든 메서드
@Pointcut("execution(* com.example..*.*(..))") // com.example 하위 모든 패키지의 모든 메서드
@Pointcut("execution(String com.example.service.*.*(..))")    // String 반환 메서드만
@Pointcut("execution(* *(Long, ..))")                         // 첫 파라미터가 Long인 메서드

4.2 @annotation 표현식

특정 어노테이션이 붙은 메서드를 매칭한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 커스텀 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Timed {}

// 해당 어노테이션이 붙은 메서드에 Aspect 적용
@Around("@annotation(com.example.annotation.Timed)")
public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
    long start = System.currentTimeMillis();
    try {
        return joinPoint.proceed();
    } finally {
        log.info("{}: {}ms", joinPoint.getSignature().getName(),
            System.currentTimeMillis() - start);
    }
}

// 사용
@Timed
public void heavyOperation() { ... }

4.3 Pointcut 조합

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Aspect
@Component
public class SecurityAspect {

    @Pointcut("execution(* com.example.service.*.*(..))")
    private void serviceLayer() {}

    @Pointcut("@annotation(org.springframework.transaction.annotation.Transactional)")
    private void transactionalMethod() {}

    // AND: 서비스 레이어이면서 트랜잭셔널한 메서드
    @Before("serviceLayer() && transactionalMethod()")
    public void beforeTransactionalService(JoinPoint jp) { ... }

    // OR: 서비스 레이어이거나 리포지토리 레이어
    @After("serviceLayer() || repositoryLayer()")
    public void afterDataAccess(JoinPoint jp) { ... }

    // NOT: 트랜잭셔널이 아닌 메서드
    @Before("serviceLayer() && !transactionalMethod()")
    public void beforeNonTransactional(JoinPoint jp) { ... }
}

5. @Transactional의 내부 동작 원리

이제 핵심이다. @Transactional이 실제로 어떻게 동작하는지 전체 흐름을 추적한다.

5.1 전체 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
클라이언트 코드
    │
    ▼
┌─────────────────────────────────────────┐
│          CGLIB Proxy                     │
│  (BeanPostProcessor가 생성)              │
│                                          │
│  ┌────────────────────────────────────┐  │
│  │   TransactionInterceptor          │  │
│  │   (MethodInterceptor 구현체)       │  │
│  │                                    │  │
│  │   invoke() {                       │  │
│  │     1. 트랜잭션 속성 조회            │  │
│  │     2. TransactionManager 획득      │  │
│  │     3. 트랜잭션 시작/참여            │  │
│  │     4. Target 메서드 실행            │  │
│  │     5. 성공 → 커밋 / 실패 → 롤백     │  │
│  │   }                                │  │
│  └────────────────────────────────────┘  │
│                    │                     │
│                    ▼                     │
│  ┌────────────────────────────────────┐  │
│  │    Target (실제 빈)                 │  │
│  │    @Transactional                  │  │
│  │    public void method() { ... }    │  │
│  └────────────────────────────────────┘  │
└─────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────┐
│   PlatformTransactionManager             │
│   (JpaTransactionManager 등)             │
│                                          │
│   getTransaction() → 트랜잭션 시작        │
│   commit()         → 커밋                │
│   rollback()       → 롤백                │
└─────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────┐
│   DataSource → Connection                │
│   connection.setAutoCommit(false)        │
│   connection.commit() / rollback()       │
└─────────────────────────────────────────┘

5.2 단계별 상세 흐름

1단계: 프록시 생성 (애플리케이션 시작 시)

Spring 컨테이너가 빈을 생성할 때, @Transactional이 붙은 클래스/메서드를 감지하면 BeanPostProcessor(구체적으로 InfrastructureAdvisorAutoProxyCreator)가 해당 빈의 프록시를 생성한다. 이후 컨테이너에 등록되는 것은 원본 빈이 아닌 프록시 빈이다.

2단계: 메서드 호출 가로채기

클라이언트가 orderService.createOrder()를 호출하면, 실제로는 프록시의 메서드가 호출된다. 프록시는 TransactionInterceptor.invoke()를 실행한다.

3단계: 트랜잭션 속성 조회

TransactionInterceptorTransactionAttributeSource를 통해 해당 메서드의 트랜잭션 속성(전파, 격리수준, 타임아웃, readOnly, rollbackFor)을 조회한다.

1
2
3
4
5
6
7
8
// TransactionAttribute 예시
@Transactional(
    propagation = Propagation.REQUIRED,
    isolation = Isolation.DEFAULT,
    timeout = 30,
    readOnly = false,
    rollbackFor = Exception.class
)

4단계: 트랜잭션 시작

PlatformTransactionManager.getTransaction()이 호출된다. 전파 속성에 따라:

  • REQUIRED: 기존 트랜잭션이 있으면 참여, 없으면 새로 시작
  • REQUIRES_NEW: 기존 트랜잭션을 일시 중단하고 새로 시작
  • NESTED: 세이브포인트를 생성

새 트랜잭션을 시작하면 DataSource에서 Connection을 획득하고 connection.setAutoCommit(false)를 설정한다. 이 Connection은 ThreadLocal에 바인딩되어, 같은 스레드 내의 모든 데이터 접근이 같은 Connection을 사용하게 된다.

1
2
3
4
5
6
TransactionSynchronizationManager (ThreadLocal 기반)
┌──────────────────────────────────────────┐
│ Thread-1: Connection@0x1234              │
│ Thread-2: Connection@0x5678              │
│ Thread-3: Connection@0x9abc              │
└──────────────────────────────────────────┘

5단계: Target 메서드 실행

실제 비즈니스 로직이 실행된다. JPA의 EntityManager나 JDBC의 JdbcTemplateTransactionSynchronizationManager에서 현재 스레드에 바인딩된 Connection을 가져와 사용한다.

6단계: 커밋 또는 롤백

메서드가 정상 완료되면 PlatformTransactionManager.commit()이 호출된다. RuntimeException이나 Error가 발생하면 rollback()이 호출된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// TransactionAspectSupport.invokeWithinTransaction() 핵심 로직 (간략화)
protected Object invokeWithinTransaction(Method method, Class<?> targetClass,
        InvocationCallback invocation) throws Throwable {

    // 1. 트랜잭션 속성 조회
    TransactionAttribute txAttr = getTransactionAttributeSource()
        .getTransactionAttribute(method, targetClass);

    // 2. TransactionManager 획득
    PlatformTransactionManager tm = determineTransactionManager(txAttr);

    // 3. 트랜잭션 시작
    TransactionStatus status = tm.getTransaction(txAttr);

    Object retVal;
    try {
        // 4. Target 메서드 실행
        retVal = invocation.proceedWithInvocation();
    } catch (Throwable ex) {
        // 5a. 롤백 규칙에 해당하면 롤백
        completeTransactionAfterThrowing(status, txAttr, ex);
        throw ex;
    }

    // 5b. 정상 완료 → 커밋
    commitTransactionAfterReturning(status);
    return retVal;
}

6. PlatformTransactionManager

PlatformTransactionManager는 Spring 트랜잭션 추상화의 핵심 인터페이스다.

1
2
3
4
5
6
public interface PlatformTransactionManager {
    TransactionStatus getTransaction(TransactionDefinition definition)
        throws TransactionException;
    void commit(TransactionStatus status) throws TransactionException;
    void rollback(TransactionStatus status) throws TransactionException;
}

구현체별 특징

구현체 사용 환경 Connection 관리
DataSourceTransactionManager 순수 JDBC, MyBatis DataSource에서 직접 Connection 관리
JpaTransactionManager JPA/Hibernate EntityManager + Connection 통합 관리
JtaTransactionManager 분산 트랜잭션 (2PC) JTA를 통한 글로벌 트랜잭션

Spring Boot는 classpath에 있는 의존성을 보고 자동으로 적절한 TransactionManager를 설정한다.

1
2
spring-boot-starter-jdbc → DataSourceTransactionManager
spring-boot-starter-data-jpa → JpaTransactionManager

6.1 Connection 바인딩과 ThreadLocal 상세

앞서 트랜잭션 시작 시 Connection이 ThreadLocal에 바인딩된다고 설명했다. 이 과정이 구체적으로 어떻게 동작하는지를 깊이 들여다보자. Spring 트랜잭션의 가장 핵심적인 인프라가 바로 이 ThreadLocal 기반의 Connection 바인딩 메커니즘이다.

DataSourceUtils.doGetConnection()의 내부 동작

트랜잭션이 시작되면 PlatformTransactionManager가 DataSource에서 Connection을 획득한다. 이때 직접 dataSource.getConnection()을 호출하는 것이 아니라 DataSourceUtils.doGetConnection()을 통해 가져온다. 이 메서드가 핵심이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// DataSourceUtils.doGetConnection() 내부 로직 (간략화)
public static Connection doGetConnection(DataSource dataSource) throws SQLException {
    // 1. 현재 스레드에 바인딩된 Connection이 있는지 확인
    ConnectionHolder conHolder =
        (ConnectionHolder) TransactionSynchronizationManager
            .getResource(dataSource);

    // 2. 이미 바인딩된 Connection이 있으면 그것을 반환
    if (conHolder != null && conHolder.hasConnection()) {
        conHolder.requested();  // 참조 카운트 증가
        return conHolder.getConnection();
    }

    // 3. 바인딩된 Connection이 없으면 새로 획득
    Connection con = dataSource.getConnection();

    // 4. 트랜잭션 동기화가 활성화되어 있으면 ThreadLocal에 바인딩
    if (TransactionSynchronizationManager.isSynchronizationActive()) {
        ConnectionHolder holderToUse = new ConnectionHolder(con);
        TransactionSynchronizationManager.bindResource(dataSource, holderToUse);
    }

    return con;
}

이 로직 덕분에 같은 트랜잭션 내에서 JdbcTemplate, JpaRepository, MyBatis가 각각 Connection을 요청해도 모두 같은 물리적 Connection을 받게 된다. 같은 Connection이어야 같은 트랜잭션에 참여하는 것이므로, 이 바인딩이 트랜잭션의 원자성을 보장하는 근간이 된다.

TransactionSynchronizationManager의 내부 구조

TransactionSynchronizationManager는 Spring 트랜잭션 인프라의 중추다. 이 클래스는 여러 개의 ThreadLocal 필드를 가지고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// TransactionSynchronizationManager 내부 (간략화)
public abstract class TransactionSynchronizationManager {

    // 리소스 바인딩: DataSource → Connection, EntityManagerFactory → EntityManager
    private static final ThreadLocal<Map<Object, Object>> resources =
        new NamedThreadLocal<>("Transactional resources");

    // 동기화 콜백: 트랜잭션 커밋/롤백 시 실행할 콜백들
    private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
        new NamedThreadLocal<>("Transaction synchronizations");

    // 현재 트랜잭션 이름
    private static final ThreadLocal<String> currentTransactionName =
        new NamedThreadLocal<>("Current transaction name");

    // 현재 트랜잭션 readOnly 여부
    private static final ThreadLocal<Boolean> currentTransactionReadOnly =
        new NamedThreadLocal<>("Current transaction read-only status");

    // 현재 트랜잭션 격리 수준
    private static final ThreadLocal<Integer> currentTransactionIsolationLevel =
        new NamedThreadLocal<>("Current transaction isolation level");

    // 현재 트랜잭션 활성 여부
    private static final ThreadLocal<Boolean> actualTransactionActive =
        new NamedThreadLocal<>("Actual transaction active");
}

resources ThreadLocal은 Map<Object, Object> 구조다. 키는 DataSource 또는 EntityManagerFactory이고, 값은 ConnectionHolder 또는 EntityManagerHolder다. 하나의 트랜잭션 안에서 JPA와 JDBC를 동시에 사용하면 이 Map에는 두 개의 엔트리가 들어간다.

1
2
3
4
5
6
7
8
Thread-1의 resources Map:
┌─────────────────────────────────────────────────────────┐
│  Key: HikariDataSource@0x1234                           │
│  Value: ConnectionHolder { connection=HikariProxyConn@a }│
│                                                          │
│  Key: LocalContainerEntityManagerFactoryBean@0x5678      │
│  Value: EntityManagerHolder { em=SessionImpl@b }         │
└─────────────────────────────────────────────────────────┘

REQUIRES_NEW와 ThreadLocal 스택 — 트랜잭션 일시 중단

같은 스레드에서 REQUIRES_NEW로 새 트랜잭션을 시작하면, 기존 트랜잭션의 리소스는 어떻게 되는가? Spring은 기존 리소스를 일시 중단(suspend)한다. 구체적으로는 ThreadLocal에서 기존 Connection을 꺼내어 SuspendedResourcesHolder에 보관하고, 새 Connection을 ThreadLocal에 바인딩한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// AbstractPlatformTransactionManager.suspend() 내부 (간략화)
protected final SuspendedResourcesHolder suspend(Object transaction) {
    // 1. 현재 ThreadLocal에서 동기화 콜백들을 꺼냄
    List<TransactionSynchronization> suspendedSynchronizations =
        doSuspendSynchronization();

    // 2. 현재 ThreadLocal에서 Connection을 꺼냄 (unbind)
    Object suspendedResources = doSuspend(transaction);
    // → TransactionSynchronizationManager.unbindResource(dataSource)

    // 3. 꺼낸 것들을 묶어서 보관
    return new SuspendedResourcesHolder(
        suspendedResources, suspendedSynchronizations,
        name, readOnly, isolationLevel, wasActive);
}

이 과정을 ASCII 다이어그램으로 표현하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
ServiceA.methodA()           ServiceB.methodB()
@Transactional               @Transactional(REQUIRES_NEW)

[시점 1: methodA 시작]
ThreadLocal resources:
┌─────────────────────┐
│ Connection-1 (T1)   │  ← 현재 활성 트랜잭션
└─────────────────────┘

        │ methodB() 호출
        ▼
[시점 2: methodB 시작 → T1 일시 중단]
SuspendedResourcesHolder:
┌─────────────────────┐
│ Connection-1 (T1)   │  ← 보관됨 (ThreadLocal에서 제거됨)
│ synchronizations    │
│ readOnly, name...   │
└─────────────────────┘

ThreadLocal resources:
┌─────────────────────┐
│ Connection-2 (T2)   │  ← 새 트랜잭션 활성
└─────────────────────┘

        │ methodB() 완료 → T2 커밋
        ▼
[시점 3: methodB 종료 → T1 복원]
ThreadLocal resources:
┌─────────────────────┐
│ Connection-1 (T1)   │  ← 복원됨
└─────────────────────┘

        │ methodA() 계속 실행
        ▼
[시점 4: methodA 종료 → T1 커밋]
ThreadLocal resources:
┌─────────────────────┐
│ (비어 있음)           │  ← 정리 완료
└─────────────────────┘

REQUIRES_NEW가 중첩될 수 있다. 예를 들어 methodA → methodB(REQUIRES_NEW) → methodC(REQUIRES_NEW) 순서로 호출되면, SuspendedResourcesHolder가 스택처럼 쌓인다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[REQUIRES_NEW 중첩 시 논리적 스택]

┌─────────────────────────────────┐
│ ThreadLocal (현재 활성)          │
│ Connection-3 (T3) ← methodC    │
├─────────────────────────────────┤
│ Suspended #2                    │
│ Connection-2 (T2) ← methodB    │
├─────────────────────────────────┤
│ Suspended #1                    │
│ Connection-1 (T1) ← methodA    │
└─────────────────────────────────┘

→ methodC 완료: T3 커밋, Connection-2 복원
→ methodB 완료: T2 커밋, Connection-1 복원
→ methodA 완료: T1 커밋, 정리

이 구조에서 주의할 점이 있다. REQUIRES_NEW를 남발하면 동시에 여러 Connection을 점유하게 된다. 위의 예시에서는 한 스레드가 3개의 Connection을 동시에 잡고 있다. Connection Pool 크기가 충분하지 않으면 Pool 고갈로 인한 데드락이 발생할 수 있다. 예를 들어 Pool 크기가 10인데, 10개의 스레드가 각각 REQUIRES_NEW로 2개씩 Connection을 요구하면, 첫 10개를 잡은 후 두 번째 Connection을 기다리며 모든 스레드가 멈춘다.

@Async가 트랜잭션 전파를 깨는 이유

@Async가 붙은 메서드는 별도의 스레드에서 실행된다. 트랜잭션 컨텍스트는 TransactionSynchronizationManager의 ThreadLocal에 저장되어 있으므로, 새 스레드에서는 이 컨텍스트에 접근할 수 없다. 이것이 @Async가 트랜잭션 전파를 깨는 근본적인 이유다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[Thread-1: 호출 측]
TransactionSynchronizationManager ThreadLocal:
┌───────────────────────┐
│ Connection-1 (T1)     │  ← Thread-1에서만 접근 가능
│ EntityManager-1       │
│ readOnly=false        │
│ actualActive=true     │
└───────────────────────┘
        │
        │ @Async 메서드 호출
        │ (TaskExecutor가 새 스레드에 작업 할당)
        ▼
[Thread-pool-1: 비동기 실행 측]
TransactionSynchronizationManager ThreadLocal:
┌───────────────────────┐
│ (비어 있음)            │  ← 새 스레드이므로 아무것도 없음
└───────────────────────┘
→ @Transactional이 있으면 완전히 새로운 트랜잭션 시작
→ 호출 측(T1)과는 완전히 독립적

이 때문에 다음과 같은 코드는 기대와 다르게 동작한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Service
public class OrderService {

    @Transactional
    public void createOrder(OrderRequest request) {
        Order order = orderRepository.save(new Order(request));

        // 비동기로 알림 발송 — 이 메서드 안에서의 DB 작업은
        // createOrder의 트랜잭션(T1)과 무관한 별도 트랜잭션(T2)에서 실행된다
        notificationService.sendAsync(order.getId());

        // 여기서 예외가 발생해 T1이 롤백되어도,
        // sendAsync 내부의 T2는 이미 커밋되었을 수 있다
        throw new RuntimeException("주문 처리 중 오류");
    }
}

@Service
public class NotificationService {

    @Async
    @Transactional  // 새 스레드에서 새 트랜잭션(T2)으로 실행됨
    public void sendAsync(Long orderId) {
        Notification noti = new Notification(orderId, "주문 확인");
        notificationRepository.save(noti);
        // T2 커밋 — T1의 롤백과 무관하게 알림 레코드가 남는다
    }
}

이 문제를 해결하려면 @TransactionalEventListener를 사용하여 트랜잭션 커밋 후에만 비동기 작업이 시작되도록 설계해야 한다. 또는 비동기 메서드 내부에서 호출 측 트랜잭션의 성공 여부를 확인하는 보상 로직을 넣어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 해결 방법: TransactionalEventListener + @Async
@Service
public class OrderService {

    @Transactional
    public void createOrder(OrderRequest request) {
        Order order = orderRepository.save(new Order(request));
        // 이벤트 발행 — 트랜잭션 커밋 후에만 처리됨
        eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
    }
}

@Component
public class NotificationEventHandler {

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleOrderCreated(OrderCreatedEvent event) {
        // 이 시점에서는 T1이 이미 커밋 완료된 상태
        // 새 스레드에서 새 트랜잭션으로 안전하게 실행
        notificationService.send(event.getOrderId());
    }
}

7. @Transactional 전파(Propagation) 동작 원리

전파 속성은 “이미 트랜잭션이 진행 중일 때 새 @Transactional 메서드가 호출되면 어떻게 할 것인가”를 결정한다.

7.1 REQUIRED (기본값)

1
2
3
4
5
6
7
8
외부 트랜잭션 존재 → 참여 (같은 Connection 사용)
외부 트랜잭션 없음 → 새로 시작

ServiceA.methodA() {          // 트랜잭션 T1 시작
    serviceB.methodB();       // T1에 참여 (새 트랜잭션 아님)
}                             // T1 커밋

→ methodB에서 예외 발생하면 T1 전체가 롤백된다

7.2 REQUIRES_NEW

1
2
3
4
5
6
7
8
9
10
11
12
외부 트랜잭션 존재 → 기존 트랜잭션 일시 중단, 새 트랜잭션 시작
외부 트랜잭션 없음 → 새로 시작

ServiceA.methodA() {          // 트랜잭션 T1 시작
                              // T1 일시 중단 (Connection1 보관)
    serviceB.methodB();       // 트랜잭션 T2 시작 (새 Connection2)
                              // T2 커밋/롤백
                              // T1 재개 (Connection1 복원)
}                             // T1 커밋

→ T2의 결과는 T1과 독립적이다
→ T1이 롤백되어도 T2는 이미 커밋되었으면 유지된다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
public class OrderService {
    @Transactional
    public void processOrder(Long orderId) {
        orderRepository.updateStatus(orderId, "PROCESSING");
        auditService.log("ORDER_PROCESSING", orderId);  // REQUIRES_NEW
        // auditService.log가 성공한 후 여기서 예외가 발생하면
        // 주문 상태 변경은 롤백되지만, 감사 로그는 남는다
    }
}

@Service
public class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void log(String action, Long targetId) {
        auditRepository.save(new AuditLog(action, targetId));
    }
}

7.3 NESTED

1
2
3
4
5
6
7
8
9
10
11
12
외부 트랜잭션 존재 → 세이브포인트 생성, 중첩 트랜잭션 실행
외부 트랜잭션 없음 → 새로 시작

ServiceA.methodA() {          // 트랜잭션 T1 시작
                              // Savepoint SP1 생성
    serviceB.methodB();       // 중첩 실행 (같은 Connection!)
                              // 예외 시 SP1까지만 롤백
}                             // T1 커밋 (중첩 트랜잭션도 함께)

→ REQUIRES_NEW와의 차이: 같은 Connection을 사용한다
→ 중첩 롤백: 세이브포인트까지만 롤백, 외부 트랜잭션은 계속 진행 가능
→ 외부 롤백: 중첩 트랜잭션도 함께 롤백된다

주의: JPA(Hibernate)는 NESTED를 지원하지 않는다. JDBC 기반의 DataSourceTransactionManager에서만 사용 가능하다.


8. @Transactional 주의사항과 함정

함정 1: 내부 호출 (Self-Invocation)

가장 흔한 실수다. 같은 클래스 내에서 this.method()로 호출하면 프록시를 거치지 않아 @Transactional이 무시된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class OrderService {

    public void processOrder(Long orderId) {
        // this.createOrder()는 프록시를 거치지 않는다!
        this.createOrder(orderId);  // @Transactional 무시!
    }

    @Transactional
    public void createOrder(Long orderId) {
        // 트랜잭션이 적용되지 않음
        orderRepository.save(new Order(orderId));
    }
}

왜? Spring이 주입하는 것은 프록시 객체다. 외부에서 orderService.createOrder()를 호출하면 프록시를 거치지만, 클래스 내부에서 this.createOrder()를 호출하면 프록시를 우회하여 원본 객체의 메서드를 직접 호출한다.

1
2
외부 호출:  Controller → [Proxy] → TransactionInterceptor → Target.createOrder() ✅
내부 호출:  Target.processOrder() → Target.createOrder() (프록시 우회!) ❌

해결 방법:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 방법 1: 별도 빈으로 분리 (권장)
@Service
public class OrderService {
    private final OrderCreator orderCreator;

    public void processOrder(Long orderId) {
        orderCreator.createOrder(orderId);  // 프록시를 거침 ✅
    }
}

@Service
public class OrderCreator {
    @Transactional
    public void createOrder(Long orderId) {
        orderRepository.save(new Order(orderId));
    }
}

// 방법 2: Self-injection
@Service
public class OrderService {
    @Lazy @Autowired
    private OrderService self;

    public void processOrder(Long orderId) {
        self.createOrder(orderId);  // 프록시를 거침 ✅
    }

    @Transactional
    public void createOrder(Long orderId) {
        orderRepository.save(new Order(orderId));
    }
}

함정 2: Checked Exception은 롤백하지 않는다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 기본 동작: RuntimeException과 Error만 롤백
@Transactional
public void method() throws IOException {
    repository.save(entity);
    throw new IOException("파일 오류");
    // IOException은 Checked Exception → 롤백하지 않고 커밋된다!
}

// 해결: rollbackFor 명시
@Transactional(rollbackFor = Exception.class)
public void method() throws IOException {
    repository.save(entity);
    throw new IOException("파일 오류");
    // 이제 롤백된다 ✅
}

Spring이 이렇게 설계한 이유: Checked Exception은 “복구 가능한 비즈니스 예외”로 간주하기 때문이다. 하지만 실무에서는 rollbackFor = Exception.class를 기본으로 사용하는 팀이 많다.

함정 3: readOnly의 진짜 효과

1
2
3
4
@Transactional(readOnly = true)
public List<Order> findOrders(Long userId) {
    return orderRepository.findByUserId(userId);
}

readOnly = true가 주는 효과:

레벨 효과
JPA/Hibernate FlushMode를 MANUAL로 설정 → 변경 감지(Dirty Checking) 생략 → 스냅샷 메모리 절약
JDBC connection.setReadOnly(true) 힌트 전달
MySQL Replication DataSource 라우팅으로 Replica(읽기 전용 서버)에 쿼리 전달 가능

주의: readOnly = true라고 해서 쓰기가 물리적으로 차단되는 것은 아니다. Hibernate는 flush를 생략하지만, Native Query로 UPDATE를 실행하면 반영된다.

함정 4: public 메서드에만 적용된다

Spring AOP는 프록시 기반이므로 public 메서드에만 @Transactional이 적용된다. protected, package-private, private 메서드에는 적용되지 않는다. 컴파일 에러가 아니라 조용히 무시되므로 더 위험하다.

1
2
3
4
@Transactional
private void internalMethod() {  // 트랜잭션 적용 안 됨! (조용히 무시)
    repository.save(entity);
}

함정 5: 트랜잭션 안에서 외부 API 호출

1
2
3
4
5
6
7
8
9
10
@Transactional
public void createOrder(OrderRequest request) {
    Order order = orderRepository.save(new Order(request));

    // 외부 PG사 결제 API 호출 (3~5초 소요)
    paymentGateway.charge(order.getTotalAmount());

    // 이 시간 동안 DB Connection을 점유하고 있다!
    // Connection Pool이 고갈될 위험
}

문제점:

  1. Connection 점유 시간 증가: 외부 API 응답을 기다리는 동안 DB Connection을 불필요하게 잡고 있다.
  2. 타임아웃 위험: 외부 API가 느리면 트랜잭션 타임아웃이 발생할 수 있다.
  3. 롤백 불일치: 결제 성공 후 DB 커밋 단계에서 실패하면, 결제는 되었는데 주문은 생성되지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// 해결: 이벤트 기반 분리
@Transactional
public void createOrder(OrderRequest request) {
    Order order = orderRepository.save(new Order(request));
    // 트랜잭션 커밋 후 이벤트 처리
    applicationEventPublisher.publishEvent(new OrderCreatedEvent(order));
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCreated(OrderCreatedEvent event) {
    // 트랜잭션 밖에서 결제 API 호출
    paymentGateway.charge(event.getOrder().getTotalAmount());
}

함정 6: @Transactional + @Async

1
2
3
4
5
6
7
@Async
@Transactional
public void asyncMethod() {
    // @Async는 새 스레드에서 실행된다
    // 트랜잭션 컨텍스트는 ThreadLocal 기반이므로 전파되지 않는다
    // 이 메서드는 새 트랜잭션에서 실행된다 (기존 트랜잭션과 무관)
}

@Async는 새 스레드에서 실행되고, 트랜잭션 컨텍스트는 ThreadLocal에 저장되므로 호출 측의 트랜잭션이 전파되지 않는다. 이것은 사실상 REQUIRES_NEW와 유사하게 동작한다.

8.1 함정 심화: 롤백 마킹과 UnexpectedRollbackException

REQUIRED 전파 속성에서 내부 트랜잭션과 외부 트랜잭션이 같은 물리적 트랜잭션을 공유할 때, 가장 이해하기 어려운 함정이 발생한다. 내부 메서드에서 예외가 발생하여 롤백이 마킹되었는데, 외부 메서드가 그 예외를 잡아서 정상 처리하려고 하면 UnexpectedRollbackException이 터진다.

이 현상의 핵심은 “rollback-only” 마킹 메커니즘이다. REQUIRED로 참여한 내부 트랜잭션은 독립적으로 커밋/롤백할 수 없다. 대신, 롤백이 필요하면 공유하고 있는 물리적 트랜잭션에 “이 트랜잭션은 반드시 롤백되어야 한다”는 표시만 남긴다. 이후 외부 트랜잭션이 커밋을 시도하면, Spring은 이 마킹을 확인하고 “예상치 못한 롤백”이라는 예외를 던진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
[롤백 마킹 전체 흐름]

ServiceA.methodA()                 ServiceB.methodB()
@Transactional(REQUIRED)          @Transactional(REQUIRED)

┌────────────────────────┐
│ T1 시작 (물리 트랜잭션)  │
│ rollback-only = false  │
│                        │
│  methodB() 호출 ──────────→ ┌──────────────────────────┐
│                        │   │ T1에 참여 (논리 트랜잭션)   │
│                        │   │                            │
│                        │   │ repository.save(...)       │
│                        │   │ throw RuntimeException ──┐ │
│                        │   │                          │ │
│                        │   │ TransactionInterceptor:  │ │
│                        │   │ "롤백 규칙에 해당하므로    │ │
│                        │   │  rollback-only = true    │ │
│                        │   │  로 마킹"                 │ │
│                        │   └──────────────────────────┘ │
│                        │                                 │
│  try { methodB() }     │   ← 예외를 catch로 삼킴         │
│  catch (Exception e) { │                                 │
│    // "괜찮아, 계속"     │                                 │
│  }                     │                                 │
│                        │                                 │
│  // 정상 흐름으로 계속    │                                 │
│  // ...                │                                 │
│                        │                                 │
│  TransactionInterceptor│                                 │
│  "정상 완료 → 커밋 시도" │                                 │
│                        │                                 │
│  BUT! rollback-only    │                                 │
│  = true 이므로          │                                 │
│                        │                                 │
│  → UnexpectedRollback  │                                 │
│    Exception 발생!      │                                 │
└────────────────────────┘

이 문제를 코드로 재현해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Service
@RequiredArgsConstructor
public class OrderService {

    private final PaymentService paymentService;
    private final OrderRepository orderRepository;

    @Transactional  // 외부 트랜잭션 (REQUIRED)
    public void createOrder(OrderRequest request) {
        Order order = orderRepository.save(new Order(request));

        try {
            // 내부 트랜잭션 (REQUIRED → T1에 참여)
            paymentService.processPayment(order);
        } catch (PaymentException e) {
            // 결제 실패해도 주문은 "대기" 상태로 저장하려는 의도
            log.warn("결제 실패, 주문을 대기 상태로 저장: {}", e.getMessage());
            order.setStatus(OrderStatus.PENDING);
        }

        // 여기까지 정상 도달했지만...
        // TransactionInterceptor가 커밋을 시도하면
        // → UnexpectedRollbackException 발생!
        // → 주문도 결제도 모두 롤백됨
    }
}

@Service
public class PaymentService {

    @Transactional  // REQUIRED → OrderService의 트랜잭션에 참여
    public void processPayment(Order order) {
        // 결제 처리 중 예외 발생
        if (order.getTotalAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new PaymentException("결제 금액이 0 이하입니다");
        }
        // PaymentException은 RuntimeException을 상속
        // → TransactionInterceptor가 rollback-only를 마킹
    }
}

실행하면 다음과 같은 로그가 출력된다.

1
2
3
WARN  - 결제 실패, 주문을 대기 상태로 저장: 결제 금액이 0 이하입니다
ERROR - org.springframework.transaction.UnexpectedRollbackException:
        Transaction silently rolled back because it has been marked as rollback-only

개발자는 catch 블록에서 예외를 잡아 처리했다고 생각하지만, Spring 트랜잭션 인프라는 이미 rollback-only 마킹을 해놓은 상태다. 외부 트랜잭션의 TransactionInterceptor가 커밋을 시도할 때, AbstractPlatformTransactionManager.commit() 내부에서 rollback-only 플래그를 확인하고 UnexpectedRollbackException을 던진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// AbstractPlatformTransactionManager.commit() 내부 (간략화)
public final void commit(TransactionStatus status) {
    // rollback-only 플래그 확인
    if (status.isRollbackOnly()) {
        // 커밋 대신 롤백 수행
        rollback(status);
        // 그리고 예외를 던짐
        throw new UnexpectedRollbackException(
            "Transaction silently rolled back because it has been marked as rollback-only");
    }
    // 정상 커밋 진행
    doCommit(status);
}

해결 방법 1: REQUIRES_NEW로 분리

내부 트랜잭션을 독립시키면 rollback-only 마킹이 외부에 전파되지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class PaymentService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)  // 독립 트랜잭션
    public void processPayment(Order order) {
        // 이 트랜잭션(T2)은 외부(T1)와 독립적
        // T2가 롤백되어도 T1에는 영향 없음
        if (order.getTotalAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new PaymentException("결제 금액이 0 이하입니다");
        }
    }
}

해결 방법 2: 예외를 던지지 않고 결과 객체로 반환

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Service
public class PaymentService {

    @Transactional
    public PaymentResult processPayment(Order order) {
        try {
            // 결제 처리
            return PaymentResult.success();
        } catch (Exception e) {
            // 예외를 던지지 않고 실패 결과를 반환
            // → TransactionInterceptor가 rollback-only를 마킹하지 않음
            return PaymentResult.failure(e.getMessage());
        }
    }
}

// 호출 측
@Transactional
public void createOrder(OrderRequest request) {
    Order order = orderRepository.save(new Order(request));
    PaymentResult result = paymentService.processPayment(order);

    if (result.isFailed()) {
        order.setStatus(OrderStatus.PENDING);
    }
    // 정상 커밋 가능
}

해결 방법 3: noRollbackFor 지정

특정 예외에 대해 롤백하지 않도록 설정할 수 있다.

1
2
3
4
5
@Transactional(noRollbackFor = PaymentException.class)
public void processPayment(Order order) {
    // PaymentException이 발생해도 rollback-only 마킹하지 않음
    // 주의: 이 방법은 해당 예외가 정말로 롤백이 필요 없는 경우에만 사용
}

8.2 함정 심화: 테스트에서의 @Transactional 함정

Spring 테스트에서 @Transactional을 붙이면 테스트 종료 시 자동으로 롤백된다. 이 기능은 테스트 데이터 정리의 편의성을 제공하지만, 동시에 실제로는 실패할 코드를 성공한 것처럼 보이게 만드는 위험을 내포하고 있다.

자동 롤백이 버그를 숨기는 메커니즘

JPA에서 엔티티의 변경 사항은 트랜잭션이 커밋될 때 flush되어 실제 SQL이 실행된다. 그런데 테스트에서 @Transactional은 커밋 대신 롤백을 수행하므로, flush 자체가 생략될 수 있다. 이때 제약 조건 위반(Unique, Not Null, FK 등)이나 잘못된 매핑 등의 오류가 감지되지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 엔티티 정의
@Entity
@Table(name = "members",
    uniqueConstraints = @UniqueConstraint(columnNames = "email"))
public class Member {
    @Id @GeneratedValue
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String name;

    // 생성자, getter 생략
}

// 테스트 — 이 테스트는 통과하지만, 프로덕션에서는 실패한다!
@SpringBootTest
@Transactional  // ← 테스트 종료 시 자동 롤백
class MemberServiceTest {

    @Autowired
    private MemberService memberService;

    @Autowired
    private MemberRepository memberRepository;

    @Test
    void 이메일_중복_저장이_성공하는_것처럼_보인다() {
        // given
        Member member1 = new Member("test@example.com", "홍길동");
        Member member2 = new Member("test@example.com", "김철수");  // 중복 이메일!

        // when
        memberRepository.save(member1);
        memberRepository.save(member2);  // INSERT SQL이 아직 실행되지 않음!

        // then — 이 테스트는 통과한다
        // 왜? 롤백되므로 flush가 일어나지 않아 DB에 INSERT가 안 갔기 때문
        // 프로덕션에서는 커밋 시점에 Unique constraint violation 발생!
        List<Member> members = memberRepository.findAll();
        // 1차 캐시에서 반환되므로 2건이 조회된다
    }
}

위 테스트가 통과하는 이유를 단계별로 분석하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[테스트 @Transactional 동작 흐름]

1. 트랜잭션 시작 (auto-rollback 예약됨)
2. save(member1) → 영속성 컨텍스트에 저장, INSERT SQL은 쓰기 지연 저장소에 보관
3. save(member2) → 영속성 컨텍스트에 저장, INSERT SQL은 쓰기 지연 저장소에 보관
4. findAll() → 영속성 컨텍스트에 있는 엔티티 반환 (DB 미접근 가능)
5. 테스트 종료 → 롤백 실행 → flush 생략 → INSERT SQL이 실행되지 않음
                              → Unique 제약 조건 검증이 일어나지 않음
                              → 테스트 통과!

[프로덕션에서는]
1. 트랜잭션 시작
2. save(member1) → 영속성 컨텍스트에 저장
3. save(member2) → 영속성 컨텍스트에 저장
4. 트랜잭션 종료 → 커밋 시도 → flush 발생 → INSERT SQL 실행
   → DataIntegrityViolationException: Unique constraint violation!

해결 방법 1: @Commit 어노테이션

@Commit을 사용하면 테스트 트랜잭션이 롤백 대신 커밋된다. 실제 DB에 반영되므로 제약 조건 검증이 수행된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@SpringBootTest
@Transactional
class MemberServiceTest {

    @Test
    @Commit  // 테스트 종료 시 커밋 (롤백하지 않음)
    void 이메일_중복_저장_테스트() {
        Member member1 = new Member("test@example.com", "홍길동");
        Member member2 = new Member("test@example.com", "김철수");

        memberRepository.save(member1);
        memberRepository.save(member2);

        // @Commit이므로 트랜잭션 커밋 시 flush 발생
        // → Unique constraint violation 감지!
    }
}

단점은 테스트 데이터가 DB에 남으므로 테스트 간 격리가 깨질 수 있다. @Sql이나 @DirtiesContext로 정리해야 한다.

해결 방법 2: flush()를 명시적으로 호출

EntityManager.flush()를 직접 호출하여 커밋 전에 SQL을 강제 실행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@SpringBootTest
@Transactional
class MemberServiceTest {

    @Autowired
    private EntityManager em;

    @Test
    void flush를_호출하면_제약조건_위반이_감지된다() {
        Member member1 = new Member("test@example.com", "홍길동");
        Member member2 = new Member("test@example.com", "김철수");

        memberRepository.save(member1);
        memberRepository.save(member2);

        // flush() 호출 → INSERT SQL이 즉시 실행됨
        assertThrows(DataIntegrityViolationException.class, () -> {
            em.flush();
        });
    }
}

해결 방법 3: TestEntityManager 사용

Spring Boot Test가 제공하는 TestEntityManagerpersistAndFlush() 메서드를 제공하여 저장과 동시에 flush를 수행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@DataJpaTest  // @Transactional이 포함되어 있음
class MemberRepositoryTest {

    @Autowired
    private TestEntityManager testEntityManager;

    @Test
    void TestEntityManager로_제약조건을_검증한다() {
        Member member1 = new Member("test@example.com", "홍길동");
        testEntityManager.persistAndFlush(member1);  // 즉시 INSERT 실행

        Member member2 = new Member("test@example.com", "김철수");
        // 두 번째 persistAndFlush에서 Unique 위반 발생
        assertThrows(PersistenceException.class, () -> {
            testEntityManager.persistAndFlush(member2);
        });
    }
}

해결 방법 4: 통합 테스트에서 @Transactional 제거

가장 근본적인 해결책이다. 통합 테스트에서는 @Transactional을 사용하지 않고, 테스트 데이터 정리를 별도로 수행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@SpringBootTest
// @Transactional 없음 — 프로덕션과 동일한 트랜잭션 동작
class MemberServiceIntegrationTest {

    @Autowired
    private MemberService memberService;

    @Autowired
    private MemberRepository memberRepository;

    @AfterEach
    void tearDown() {
        memberRepository.deleteAllInBatch();  // 테스트 후 데이터 정리
    }

    @Test
    void 실제_트랜잭션_커밋을_검증한다() {
        // 서비스 메서드 호출 → 내부에서 @Transactional로 커밋
        memberService.register("test@example.com", "홍길동");

        // 새 트랜잭션에서 조회 → DB에 실제 반영되었는지 확인
        Member found = memberRepository.findByEmail("test@example.com")
            .orElseThrow();
        assertEquals("홍길동", found.getName());
    }
}

이 방식은 테스트가 프로덕션 환경과 동일한 트랜잭션 라이프사이클을 따르므로 flush/commit 관련 버그를 놓치지 않는다. 대신 @AfterEach에서 데이터 정리를 해주어야 하는 번거로움이 있다.


9. 프로그래밍 방식 트랜잭션 관리

선언적(@Transactional)이 아닌 프로그래밍 방식으로 트랜잭션을 관리할 수 있다. 트랜잭션 경계를 더 세밀하게 제어해야 할 때 사용한다.

TransactionTemplate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
public class OrderService {
    private final TransactionTemplate transactionTemplate;

    public OrderService(PlatformTransactionManager transactionManager) {
        this.transactionTemplate = new TransactionTemplate(transactionManager);
        this.transactionTemplate.setIsolationLevel(
            TransactionDefinition.ISOLATION_READ_COMMITTED);
        this.transactionTemplate.setTimeout(10);
    }

    public Order createOrder(OrderRequest request) {
        return transactionTemplate.execute(status -> {
            Order order = new Order(request);
            orderRepository.save(order);

            if (someCondition) {
                status.setRollbackOnly();  // 프로그래밍 방식 롤백
            }

            return order;
        });
    }
}

사용 시나리오

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 트랜잭션 범위를 메서드 일부에만 적용
public void processWithPartialTransaction(Long orderId) {
    // 트랜잭션 밖: 외부 API 호출 (Connection 미점유)
    PaymentResult paymentResult = paymentGateway.charge(orderId);

    // 트랜잭션 안: DB 업데이트만
    transactionTemplate.executeWithoutResult(status -> {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.updatePaymentStatus(paymentResult);
    });

    // 트랜잭션 밖: 알림 발송
    notificationService.sendOrderConfirmation(orderId);
}

10. 실무 트랜잭션 설계 패턴

10.1 파사드 패턴으로 트랜잭션 경계 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 파사드: 트랜잭션 경계를 담당
@Service
public class OrderFacade {
    private final OrderService orderService;
    private final PaymentService paymentService;
    private final NotificationService notificationService;

    @Transactional
    public OrderResult processOrder(OrderRequest request) {
        Order order = orderService.create(request);
        Payment payment = paymentService.process(order);
        return new OrderResult(order, payment);
    }
    // 개별 서비스에는 @Transactional을 붙이지 않거나 REQUIRED로 참여
}

10.2 @TransactionalEventListener

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Service
public class OrderService {
    @Transactional
    public void createOrder(OrderRequest request) {
        Order order = orderRepository.save(new Order(request));
        eventPublisher.publishEvent(new OrderCreatedEvent(order));
        // 이벤트는 등록만 되고, 트랜잭션 커밋 후 처리된다
    }
}

@Component
public class OrderEventHandler {

    // 트랜잭션 커밋 후에만 실행 (롤백되면 실행되지 않음)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderCreated(OrderCreatedEvent event) {
        emailService.sendConfirmation(event.getOrder());
    }

    // 롤백 후 실행
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void onOrderFailed(OrderCreatedEvent event) {
        alertService.notifyFailure(event.getOrder());
    }
}

10.3 실무에서 반드시 구분해야 하는 것들

트랜잭션 경계와 비즈니스 경계는 비슷하지만 완전히 같지 않다

좋은 설계에서는 서비스 메서드가 비즈니스 유스케이스와 트랜잭션 경계를 함께 형성하는 경우가 많다. 하지만 외부 API 호출, 메시지 발행, 파일 업로드처럼 DB 트랜잭션과 묶기 어려운 작업까지 무조건 한 메서드에 넣으면 문제가 생긴다.

  • DB Connection을 오래 점유한다.
  • 락 보유 시간이 길어진다.
  • 외부 시스템 지연이 전체 트랜잭션 지연으로 번진다.
  • 롤백 가능한 작업과 불가능한 작업이 섞인다.

그래서 면접에서는 “트랜잭션은 짧게, 외부 연동은 가능하면 트랜잭션 밖으로”라는 원칙을 같이 말하는 편이 좋다.

@Transactional은 마법이 아니라 프록시 진입점에서만 동작한다

이 문장을 정확히 이해하면 대부분의 함정을 설명할 수 있다.

  • 외부 호출이어야 프록시를 탄다.
  • 프록시가 가로챌 수 있는 가시성과 메서드 형태여야 한다.
  • 같은 스레드 문맥에서만 같은 트랜잭션 컨텍스트를 공유한다.
  • 최종적으로는 PlatformTransactionManager가 실제 시작/커밋/롤백을 수행한다.

영속성 컨텍스트와 트랜잭션은 구분해야 한다

둘은 함께 움직이는 경우가 많아서 자주 혼동된다.

  • 트랜잭션은 DB 작업의 원자성/격리성을 보장하는 경계다.
  • 영속성 컨텍스트는 엔티티를 관리하고 변경 감지를 수행하는 1차 캐시다.
  • 보통 Spring + JPA에서는 하나의 트랜잭션 안에서 하나의 영속성 컨텍스트가 연결되어 동작한다.
  • 하지만 개념적으로는 서로 다른 계층이며, 면접에서는 이를 분리해서 설명해야 한다.

10.4 면접형 답변 프레임

Q: @Transactional이 어떻게 동작하나요?

Spring은 @Transactional이 붙은 빈에 프록시를 생성합니다. 클라이언트가 해당 메서드를 호출하면 실제 객체 대신 프록시를 먼저 호출하고, 프록시는 TransactionInterceptor를 통해 트랜잭션 속성을 확인한 뒤 PlatformTransactionManager로 트랜잭션을 시작합니다. 이후 대상 메서드를 실행하고, 정상 종료면 커밋, 런타임 예외가 나면 롤백합니다. 따라서 프록시를 우회하는 self-invocation이나 private 메서드에는 기대한 대로 적용되지 않을 수 있습니다.

Q: 왜 내부 호출에서는 안 되나요?

내부 호출은 프록시가 아니라 대상 객체의 this 호출이기 때문입니다. Spring AOP는 프록시 기반이라 외부 진입점에서만 Advice를 적용할 수 있습니다.

11. 면접에서 자주 나오는 AOP/@Transactional 질문

Q1: AOP란 무엇이고 왜 필요한가요?

AOP는 횡단 관심사(로깅, 트랜잭션, 보안 등)를 비즈니스 로직에서 분리하여 모듈화하는 프로그래밍 패러다임입니다. OOP만으로는 여러 클래스에 걸친 공통 기능이 중복되는 문제를 해결하기 어렵습니다. AOP를 사용하면 관심사 분리가 깔끔해지고, 코드 중복이 줄며, 비즈니스 로직에 집중할 수 있습니다.

Q2: Spring AOP와 AspectJ의 차이는?

Spring AOP는 런타임에 프록시를 생성하여 메서드 실행 지점만 지원하고, AspectJ는 컴파일/로드 타임에 바이트코드를 직접 수정하여 필드 접근, 생성자 등 모든 Join Point를 지원합니다. Spring AOP는 설정이 간단하지만 self-invocation 문제가 있고, AspectJ는 별도 컴파일러가 필요하지만 기능이 완전합니다.

Q3: JDK Dynamic Proxy와 CGLIB의 차이는?

JDK Dynamic Proxy는 인터페이스 기반으로 Proxy 클래스를 생성하며, CGLIB은 클래스를 상속(subclassing)하여 프록시를 생성합니다. JDK Proxy는 인터페이스가 필수이고, CGLIB은 final 클래스/메서드를 프록시할 수 없습니다. Spring Boot는 기본적으로 CGLIB을 사용합니다.

Q4: @Transactional이 동작하지 않는 경우를 아는 대로 설명하세요.

1) 같은 클래스 내부에서 this로 호출하면 프록시를 거치지 않아 무시됩니다. 2) private/protected 메서드에 적용하면 프록시가 가로채지 못합니다. 3) Checked Exception은 기본적으로 롤백하지 않습니다. 4) @Async와 함께 사용하면 트랜잭션 컨텍스트가 새 스레드로 전파되지 않습니다.

Q5: 전파 속성(Propagation) REQUIRED와 REQUIRES_NEW의 차이는?

REQUIRED는 기존 트랜잭션이 있으면 참여하고 없으면 새로 시작합니다. 하나의 물리적 트랜잭션으로 묶이므로 내부에서 예외가 발생하면 전체가 롤백됩니다. REQUIRES_NEW는 항상 새 트랜잭션을 시작하고 기존 트랜잭션을 일시 중단합니다. 독립적인 트랜잭션이므로 외부가 롤백되어도 내부 커밋은 유지됩니다. 감사 로그처럼 항상 남아야 하는 기록에 REQUIRES_NEW를 사용합니다.

Q6: readOnly = true의 효과는?

JPA에서는 FlushMode를 MANUAL로 설정하여 변경 감지(Dirty Checking)를 생략하고 스냅샷 메모리를 절약합니다. JDBC에서는 connection.setReadOnly(true) 힌트를 전달합니다. MySQL Replication 환경에서는 읽기 쿼리를 Replica로 라우팅하는 데 활용할 수 있습니다. 단, 물리적으로 쓰기를 차단하는 것은 아닙니다.

Q7: 같은 클래스 내부 호출에서 @Transactional이 적용되지 않는 이유와 해결 방법은?

Spring AOP가 프록시 기반이기 때문입니다. 외부에서 호출하면 프록시 → TransactionInterceptor → Target 순서로 실행되지만, 내부에서 this.method()로 호출하면 프록시를 거치지 않고 Target의 메서드를 직접 호출합니다. 해결 방법으로는 트랜잭션 로직을 별도 빈으로 분리하거나, self-injection(@Lazy @Autowired private MyService self)을 사용합니다.


Q8: 트랜잭션 안에서 외부 API를 호출하면 왜 안 좋나요?

외부 API 지연 동안 DB Connection과 락을 오래 잡을 수 있기 때문입니다. 이는 커넥션 풀 고갈, 락 경합, 데드락 확률 증가로 이어집니다. 가능하면 외부 호출은 트랜잭션 밖으로 빼고, 꼭 필요하면 보상 트랜잭션이나 이벤트 기반 설계를 검토해야 합니다.

Q9: @Transactional(readOnly = true)면 쓰기가 완전히 막히나요?

아닙니다. 이는 주로 최적화 힌트에 가깝습니다. JPA flush 최적화나 JDBC read-only 힌트는 줄 수 있지만, DB와 드라이버 조합에 따라 물리적 쓰기 차단이 보장되지는 않습니다. 그래서 “readOnly면 insert가 절대 안 된다”고 답하면 틀릴 수 있습니다.

Q10: 트랜잭션 전파와 격리 수준은 어떻게 다른가요?

전파는 “기존 트랜잭션이 있을 때 어떻게 참여할 것인가”에 대한 정책이고, 격리 수준은 “동시에 실행되는 트랜잭션 간 어떤 현상을 허용할 것인가”에 대한 정책입니다. 둘을 같은 개념처럼 설명하면 감점 포인트가 됩니다.


결론

Spring의 @Transactional은 단순한 어노테이션이 아니라, AOP 프록시 → TransactionInterceptor → PlatformTransactionManager → DataSource/Connection으로 이어지는 정교한 아키텍처 위에서 동작한다.

핵심을 정리하면 다음과 같다. AOP는 횡단 관심사를 분리하는 패러다임이고, Spring AOP는 런타임 프록시(CGLIB/JDK Dynamic Proxy)로 구현된다. @Transactional은 Around Advice로 구현되며, TransactionInterceptor가 메서드 호출을 가로채어 트랜잭션을 시작하고 커밋/롤백한다. 트랜잭션 컨텍스트는 ThreadLocal에 바인딩되므로 같은 스레드 내의 모든 데이터 접근이 같은 트랜잭션을 공유한다.

실무에서 가장 중요한 것은 프록시의 한계를 이해하는 것이다. 내부 호출(self-invocation)은 프록시를 우회하고, private 메서드에는 적용되지 않으며, Checked Exception은 기본적으로 롤백하지 않는다. 이 세 가지 함정만 확실히 기억해도 @Transactional 관련 버그의 대부분을 예방할 수 있다.