Post

Spring Transaction과 AOP

스프링이 AOP를 통해 트랜잭션을 처리하는 원리

Spring Transaction과 AOP

Spring Transaction

트랜잭션 제어 방식

Spring은 트랜잭션을 전통적인 JDBC 방식과 유사하게, try-catch-finally 구문을 사용하여 제어한다. 즉 Connection 객체를 DataSource에서 직접 얻어와 트랜잭션의 시작, 커밋, 롤백을 명시적으로 호출하고, 모든 작업이 끝나면 리소스를 해제한다.

단계별 트랜잭션 제어 방식

  1. 커넥션 획득: DataSource로부터 Connection을 가져온다.
  2. 트랜잭션 시작: connection.setAutoCommit(false)를 호출하여 자동 커밋 모드를 비활성화한다. 이 시점부터 모든 SQL 문은 즉시 반영되지 않고 하나의 논리적인 작업 단위로 묶인다.
  3. 비즈니스 로직 실행 (try 블록): 데이터베이스 작업을 포함한 실제 비즈니스 로직을 실행한다.
  4. 커밋 (try 블록 끝): 모든 작업이 예외 없이 성공적으로 완료되면 connection.commit()을 호출하여 모든 변경 사항을 데이터베이스에 영구적으로 반영한다.
  5. 롤백 (catch 블록): 로직 실행 중 예외가 발생하면 connection.rollback()을 호출하여 트랜잭션 시작 이후의 모든 변경 사항을 취소한다.
  6. 자원 해제 (finally 블록): 트랜잭션의 성공 여부와 관계없이, 사용한 Connection, PreparedStatement, ResultSet 등의 리소스를 반드시 닫아주어 리소스 누수를 방지한다.

트랜잭션 수동 제어 코드

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@Service
public class UserServiceManual {

    private final DataSource dataSource;

    // DataSource를 주입받아 Connection을 얻는 데 사용
    public UserServiceManual(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void createUserManually(String name, String email) {
        Connection conn = null;
        PreparedStatement pstmt = null;
        String sql = "INSERT INTO users (name, email) VALUES (?, ?)";

        try {
            // 1. 커넥션 획득
            conn = dataSource.getConnection();
            
            // 2. 트랜잭션 시작 (자동 커밋 비활성화)
            conn.setAutoCommit(false);

            // 3. 비즈니스 로직 실행
            pstmt = conn.prepareStatement(sql);
            pstmt.setString(1, name);
            pstmt.setString(2, email);
            pstmt.executeUpdate();
            
            // 로직상 에러 상황을 가정
            if (email.contains("error")) {
                throw new SQLException("이메일 형식 오류로 강제 롤백합니다.");
            }

            // 4. 성공 시 커밋
            conn.commit();
            
        } catch (SQLException e) {
            // 5. 예외 발생 시 롤백
            System.err.println("DB 작업 중 예외 발생, 트랜잭션을 롤백합니다.");
            if (conn != null) {
                try {
                    conn.rollback();
                } catch (SQLException ex) {
                    // 롤백 중 예외 처리... (로깅 등)
                }
            }
            // 애플리케이션의 비즈니스 예외로 전환하여 던질 수 있음
            throw new RuntimeException(e);
            
        } finally {
            // 6. 자원 해제
            if (pstmt != null) {
                try {
                    pstmt.close();
                } catch (SQLException e) { /* 무시 */ }
            }
            if (conn != null) {
                try {
                    // 커넥션 풀을 사용하는 경우, close()는 연결을 끊는 게 아니라 풀에 반납
                    conn.close(); 
                } catch (SQLException e) { /* 무시 */ }
            }
        }
    }
}

동작 방식

Transaction과 AOP Proxy

@Transactional 어노테이션을 붙인 메서드를 호출하면, 원본 객체 대신 Spring이 동적으로 생성한 AOP 프록시(Proxy) 객체가 먼저 호출된다.

프록시란 무엇일까? Spring에서 어떤 일을 대신해야 하기에 프록시를 도입했을까?

프록시란 대리자이다. 트랜잭션을 예로 들자면, 트랜잭션 처리하는 전체 과정(DB Connection 얻고, Connection 연결 관련 설정을 하고, 비즈니스 로직을 처리하고, 예외 여부에 따라 커밋 또는 롤백하는 과정)을 매번 개발자가 반복해서 작성하는 수고를 프록시가 덜어준다.

@Transactional 역할은 다음과 같다.

  1. 트랜잭션 시작: 우리가 작성한 비즈니스 로직을 실행하기 전, 데이터베이스 커넥션을 얻어 트랜잭션을 시작한다.
  2. 비즈니스 로직 위임: 작성한 실제 메서드를 호출한다.
  3. 리프레시 토큰: 메서드 실행이 끝나면, 예외 발생 여부에 따라 트랜잭션을 커밋하거나 롤백힌다.

Connection과 ThreadLocal

생성한 데이터베이스 Connection 객체를 어떻게 비즈니스 로직(특히 그 안의 Repository)에 전달할 수 있을까? 모든 메서드에 Connection 객체를 인자로 넘기는 것일까?

Spring은 모든 메서드에 Connection 객체를 인자로 넘기지 않고, ThreadLocal을 사용한다. ThreadLocal은 말 그대로 ‘스레드 지역 변수‘로 각 스레드마다 자신만 접근할 수 있는 독립된 저장 공간을 갖게 해준다. 웹 애플리케이션에서는 보통 사용자 요청 하나당 하나의 스레드가 할당되므로, ThreadLocal은 “이번 요청 처리 과정에서만 유효한 데이터를 담아두는” 역할을 한다. 따라서 AOP 프록시는 트랜잭션을 시작할 때 얻은 Connection 객체를 바로 이 ThreadLocal에 저장해 같은 스레드에서 실행되는 코드는 어디서든 이 Connection 객체를 꺼내 쓸 수 있게 된다.

TransactionSynchronizationManager

ThreadLocalConnection을 넣고, 필요할 때 꺼내고, 트랜잭션이 끝나면 정리하는 실질적인 작업은 어떻게 하는 걸까?

ThreadLocal은 그저 저장소이다. TransactionSynchronizationManager 가 실질적인 작업을 수행한다.

TransactionSynchronizationManager는 실질적으로 Spring 트랜잭션 처리를 다음과 같이 한다.

  1. 보관: AOP 프록시의 요청을 받아 DataSource에서 Connection을 가져온 뒤, ThreadLocal에 보관한다.
  2. 공유: 비즈니스 로직의 Repository가 데이터베이스 접근을 위해 Connection을 요청하면, ThreadLocal에서 현재 스레드에 보관된 Connection을 꺼내 전달한다. 이 덕분에 모든 DB 작업이 동일한 트랜잭션 내에서 처리될 수 있다.
  3. 정리: 트랜잭션이 끝나면 ThreadLocal에서 Connection을 제거하고, 커넥션 풀에 반납하는 등 뒷정리를 수행한다.

DataSource (ConnectionPool)

TransactionSynchronizationManagerConnection을 어디에서 어떻게 가져올까?

매너저는 DataSource에서 Connection을 가져오고, 작업이 끝나면 다시 DataSource에 반납한다. DataSource는 데이터베이스 커넥션을 얻는 방법을 표준화한 인터페이스이다. 과거에는 필요할 때마다 DB와 통신해 새로운 Connection을 만드는 비효율적인 방식을 사용했다. 현대적인 애플리케이션은 대부분 Connection Pool 방식을 사용한다.

  • Connection Pool: 미리 일정 개수의 Connection을 만들어 놓고 풀(Pool)에 보관한다. 요청이 오면 풀에서 하나를 빌려주고, 사용이 끝나면 반납받아 재사용한다.

동작 흐름

사용자 요청이 들어왔을 때부터 커밋될 때까지의 전체적인 트랜잭션 흐름은 다음과 같다.

  1. 요청 수신 및 스레드 할당
    • 웹 서버(Tomcat)가 사용자 요청을 받고 스레드 풀에서 스레드 하나를 할당한다. (e.g., Thread-1)
  2. AOP 프록시 호출
    • 컨트롤러가 @Transactional이 붙은 메서드를 호출하면 AOP 프록시가 요청을 가로챈다.
      • Spring Context가 생성될 때 @Transactional 애너테이션이 붙은 메서드를 스캔한다. 스캔한 메서드에 대한 프록시를 JDK 동적 프록시 (인터페이스 구현체인 경우) 또는 CGLIB (인터페이스 구현체가 아닌 경우, 기본) 방식으로 생성한다.
  3. 트랜잭션 시작
    • 프록시는 TransactionSynchronizationManager에게 트랜잭션 시작을 알린다.
    • 매니저는 DataSource(Connection Pool)에서 대기 중인 Connection-A를 빌려온다.
    • 매니저는 Connection-AThreadLocal에 저장하여 Thread-1에 귀속시킨다.
  4. 비즈니스 로직 실행
    • 서비스 내부의 Repository 메서드가 호출된다.
    • Repository는 DB 작업이 필요하므로 Connection을 요청한다.
    • 이때 TransactionSynchronizationManagerThreadLocal에서 Connection-A를 꺼내 전달한다.
  5. 트랜잭션 종료
    • 서비스 메서드가 성공적으로 실행을 마친다.
    • AOP 프록시는 TransactionSynchronizationManager에게 트랜잭션 커밋을 지시한다.
    • 매니저는 ThreadLocalConnection-A를 이용해 DB에 커밋 명령을 보낸다.
    • 마지막으로 Connection-AThreadLocal에서 제거하고 DataSource(Connection Pool)에 반납한다.

AOP

Aspect-Oriented Programming

AOPAspect-Oriented Programming의 약자로, 관점 지향 프로그래밍을 뜻한다. OOP는 객체라는 단위로 관심사를 수직적으로 분리한다. 반면 AOP는 트랜잭션, 로깅, 보안과 같은 여러 클래스에 걸쳐 관심사를 수평적으로 분리한다. AOP는 OOP에 반하는 관계가 아닌, 부가 기능의 모듈화OOP를 보완하는 관계다. 서비스 내의 횡단 관심사(Cross-Cutting Concerns)를 처리함으로써 중복 코드 제거하고 핵심 로직을 순수하게 유지할 수 있다.

AOP 개념은 ServletFilter와 Spring Interceptor에도 적용되어있다. Servlet Filter는 DispatcherServlet 실행 전후로, HTTP 요청/응답(HttpServletRequest, Response)에 대한 부가 기능을 적용한다. Spring Interceptor는 Dispatcher Servlet 내부에서 Controller 호출 전후로, Spring MVC 흐름(Handler, ModelAndView)에 대한 부가 기능을 적용한다. Spring AOP(@Aspect)는 메서드 전후로, 모든 Spring Bean(비즈니스 로직)에 대한 부가 기능을 적용한다.


Proxy

AOP는 프록시 패턴을 사용한다. 프록시프록시 패턴은 다르다. 프록시는 ‘대리자’라는 의미로, 네트워킹 환경에서는 클라이언트(사용자)와 서버 사이의 중개자 역할을 하는 서버를 의미한다. 사용자가 웹사이트에 접속할 때 직접 접속하는 것이 아니라, 프록시 서버를 거쳐서 접속하게 된다. 프록시 패턴타겟에 대한 접근 방법을 제어하기 위해 프록시 객체를 대리인으로 세우는 디자인 패턴이다. 클라이언트는 실제 객체가 아닌 프록시 객체로 작업을 요청해 접근을 제어하고 지연을 초기화한다. 지연 초기화(Lazy Initialization)는 실제 객체가 필요한 시점에만 생성하도록 가벼운 프록시 객체를 사용하도록 해 시스템 부하를 줄인다.


AOP 용어

  • Aspect: Pointcut + Advice → 인터페이스: Advisor

    • Pointcut: Advice를 적용할 Joinpoint 선별 → 인터페이스: Pointcut

    • Advice: 특정 Joinpoint에 실행되는 코드 → 인터페이스: Interceptor

      • 자주 사용하는 @ControllerAdvice도 컨트롤러 계층의 공통 작업을 처리하기 위한 AOP 도구다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    @RestControllerAdvice
    public class GlobalExceptionHandler {
    
      @ExceptionHandler(DomainException.class)
      public CustomApiResponse<Void> handleDomainException(final DomainException e) {
          log.warn(e.getLogMessage());
          return CustomApiResponse.badRequest(e.getUserMessage());
      }
    }
    
  • JoinPoint: Advice를 적용할 위치 → 인터페이스: Invocation

  • Target: 부가 기능(advice)을 적용할 대상

  • Weaving: 코드의 적절한 위치에 aspect를 추가하는 과정

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
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class PerformanceAspect {

    // Pointcut: 어디에 적용할지 정의
    // com.example.service 패키지 아래의 모든 클래스의 모든 메서드에 적용
    @Pointcut("execution(* com.example.service..*.*(..))")
    private void allServiceMethods() {}

    // Advice: 무엇을 할지 정의
    // allServiceMethods() Pointcut에 해당하는 메서드 실행 전후에 이 로직 실행
    @Around("allServiceMethods()")
    public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();

        // Target 메서드 실행
        Object result = joinPoint.proceed();

        long endTime = System.currentTimeMillis();
        long executionTime = endTime - startTime;

        System.out.println(joinPoint.getSignature() + " executed in " + executionTime + "ms");

        return result;
    }
}

Aspect 추가하기

동적 AOP (Spring AOP)

런타임에 aspect를 추가하는 방식으로, 실제 코드에는 변경이 없어 마치 가면을 쓰는 것과 같다. Spring를 사용한 대부분의 웹 애플리케이션 개발 상황에 해당한다. 런타임에 프록시를 이용해 동작하므로 설정이 간단하고 직관적이다. @Transactional처럼 대표적인 부가 기능을 구현할 때, Spring Bean에만 적용할 때, 개발 속도가 중요할 때 사용한다.

JDK Dynamic Proxy (interface base)

  • 동작 원리

    자바에 내장된 java.lang.reflect.Proxy를 사용하여 런타임에 지정된 인터페이스를 구현하는 새로운 프록시 클래스를 동적으로 생성한다. 클라이언트는 실제 객체가 아닌 이 프록시 객체를 통해 메서드를 호출하고, 프록시는 부가 기능(Advice)을 실행한 뒤 실제 객체에 요청을 위임한다.

  • 핵심 특징

    • 반드시 인터페이스 필요함: 프록시를 만들 대상 클래스가 최소 하나 이상의 인터페이스를 구현하고 있어야만 한다. 인터페이스가 없는 클래스에는 적용할 수 없다.
    • 자바 표준 기술: 자바 언어 자체에 포함된 기능이므로 별도의 라이브러리가 필요 없고 안정적이다.
    • 원칙적인 설계: ‘인터페이스에 의존’하는 객체 지향의 좋은 설계 원칙(DIP)을 따를 때 자연스럽게 사용된다

CGLIB Proxy (subclass base)

  • 동작 원리

    CGLIB(Code Generation Library)라는 외부 라이브러리를 사용하여 런타임에 대상 클래스를 상속받는 자식 클래스를 동적으로 생성한다. 이 자식 클래스가 프록시 역할을 하며 부가 기능이 포함된 메서드를 오버라이딩(재정의)하여 동작한다. 자식 클래스는 부가 기능을 실행한 후 super 키워드를 통해 부모(원본)의 메서드를 호출한다.

  • 핵심 특징

    • 인터페이스 필요 없음: 일반 클래스에도 AOP를 적용할 수 있어 매우 유연하다.
    • 상속 이용: final 키워드가 붙은 클래스나 메서드는 상속 및 오버라이딩이 불가능하므로 프록시를 생성할 수 없다는 한계가 있다.
    • 더 강력한 성능 (과거 기준): 과거에는 리플렉션을 사용하는 JDK Dynamic Proxy보다 CGLIB의 바이트코드 조작 방식이 더 빨랐다. (현재는 JVM의 발전으로 성능 차이가 거의 무의미해졌다.)

비교

과거 스프링에서는 인터페이스 유무에 따라 두 방식을 자동으로 전환했다. 스프링 부트 2.0부터는 CGLIB를 기본 프록시 생성 방식으로 채택했다. 인터페이스 유무를 신경 쓸 필요 없이 항상 AOP가 동작하게 하여 개발자의 편의성을 높였기 때문이다.

구분JDK Dynamic ProxyCGLIB
기반인터페이스 구현클래스 상속 (서브클래싱)
필요 조건반드시 인터페이스가 있어야 함인터페이스 없어도 가능
한계인터페이스 없는 클래스 적용 불가final 클래스/메서드 적용 불가
핵심 기술자바 리플렉션 (Reflection)바이트코드 조작 (Bytecode Manipulation)

정적 AOP (AspectJ)

컴파일 시점에 바이트코드에 aspect를 추가하는 방식으로, 실제 코드에 변경이 있기 때문에 직접 유전자 조작을 하는 것과 같다. 동적 AOP의 한계를 극복해야하는 상황에서 주로 사용한다. 금융 거래 시스템과 같이 런타임에 프록시 생성 및 호출 과정의 미세한 오버헤드도 허용할 수 없을 때, 메서드 실행 외 지점에 AOP를 적용해야 할 때, Spring Bean이 아닌 객체(new로 직접 생성한 객체, POJO)에 AOP를 적용하고 싶을 때 사용한다.

AspectJ

  • 동작 원리

    컴파일러(ajc)가 직접 나서서 자바 소스코드를 컴파일한 결과물인 바이트코드(.class 파일)를 열어보고, 부가 기능 코드를 수술하듯 직접 삽입해 버린다. 그래서 컴파일이 끝나면 이미 원본 코드와 부가 기능 코드가 하나로 합쳐진 완전한 클래스 파일이 만들어진다. 이러한 방식을 정적 AOP 또는 컴파일 시점 위빙(Compile-Time Weaving)이라고 부릅니다.

  • 핵심 특징

    • 유연한 JoinPoint(적용 지점): Spring AOP는 메서드 실행 지점에만 AOP를 적용할 수 있는 반면 AspectJ는 필드 값 변경, 객체 생성, 예외 처리 등 다양하고 정교환 지점에서 개입할 수 있다.
    • 안정적인 성능: 런타임에 프록시 객체를 생성하고, 프록시를 거쳐 원본을 호출하는 과정에서 발생하는 미세한 성능 오버헤드가 전혀 없다.
    • 모든 자바 객체 적용 가능: Spring AOP는 Spring Bean에만 적용할 수 있는 반면 AspectJ는 new 키워드로 생성한 POJO를 포함한 모든 객체, final 클래스까지 AOP를 적용할 수 있다.

비교

구분동적 AOP (스프링 AOP)정적 AOP (AspectJ)
설정spring-boot-starter-aop 의존성 추가AspectJ 라이브러리 + 컴파일러 플러그인 설정
Aspect@Component로 스프링 빈으로 등록일반 자바 클래스로 작성
Target스프링 빈이어야 함 (@Service, @Component 등)어떤 자바 객체(POJO)든 상관없음
동작 시점런타임에 프록시 객체를 생성하여 적용컴파일 타임에 바이트코드를 직접 수정하여 적용
This post is licensed under CC BY 4.0 by the author.