여는 글

Spring Framework를 다루시는 분이라면, @Transactional 어노테이션을 많이 사용하실겁니다. @Transactional 어노테이션을 붙이면, 해당 메소드의 트랜잭션을 보장해주는 것으로 알고 있는데요. 저는 이 어노테이션의 동작원리를 설명할 줄 모르고 머릿속에 떠다니는 상상으로 사용했던 것 같습니다.

면접에서 “@Transactional이 어떻게 동작하나요?“라는 질문을 받았을 때, 제대로 대답하지 못했던 창피한 기억이 있습니다. 이번 포스팅을 통해 동작 원리를 확실히 정리하고, 다시는 그런 실수를 반복하지 않으려 합니다.


핵심 답변: 프록시(Proxy) 패턴

@Transactional의 동작 원리를 한 문장으로 요약하면

Spring은 프록시 객체를 생성하여, 실제 메소드 호출 전후에 트랜잭션 시작/커밋/롤백 로직을 끼워넣는다.


1. 프록시가 뭔데?

프록시(Proxy)는 대리인이라는 뜻입니다. 실제 객체를 감싸는 래퍼(Wrapper) 객체라고 생각하면 됩니다.

[클라이언트] → [프록시 객체] → [실제 객체]

Spring이 @Transactional이 붙은 클래스를 만나면

  1. 실제 객체를 생성합니다
  2. 그 객체를 감싸는 프록시 객체를 생성합니다
  3. 스프링 컨테이너에는 프록시 객체가 빈(Bean)으로 등록됩니다

따라서 우리가 @Autowired로 주입받는 것은 실제 객체가 아니라 프록시 객체입니다.


2. 프록시가 하는 일

프록시 객체는 메소드 호출을 가로채서 다음과 같이 동작합니다.

// 우리가 작성한 코드
@Service
public class UserService {
    @Transactional
    public void createUser(User user) {
        userRepository.save(user);
    }
}
// Spring이 생성한 프록시의 동작 (의사 코드)
public class UserService$$Proxy {
    private UserService target;  // 실제 객체

    public void createUser(User user) {
        // 1. 트랜잭션 시작
        TransactionStatus status = transactionManager.getTransaction();

        try {
            // 2. 실제 메소드 호출
            target.createUser(user);

            // 3. 커밋
            transactionManager.commit(status);
        } catch (RuntimeException e) {
            // 4. 롤백
            transactionManager.rollback(status);
            throw e;
        }
    }
}

이렇게 AOP(Aspect-Oriented Programming) 방식으로 트랜잭션 로직이 끼워지는 것입니다.


Spring 없는 Plain Java라면?

레거시 시스템에서의 배치 코드는 Spring Framework마저 그리워지는 경우가 많습니다. Spring의 @Transactional이 얼마나 편한지 알려면, Spring 없이 트랜잭션을 관리하는 코드를 봐야 합니다.

JDBC로 직접 트랜잭션 관리

public void createUser(User user) {
    Connection conn = null;
    try {
        // 1. 커넥션 획득
        conn = dataSource.getConnection();

        // 2. 자동 커밋 끄기 (트랜잭션 시작)
        conn.setAutoCommit(false);

        // 3. 비즈니스 로직 실행
        PreparedStatement ps = conn.prepareStatement(
            "INSERT INTO users (name, email) VALUES (?, ?)"
        );
        ps.setString(1, user.getName());
        ps.setString(2, user.getEmail());
        ps.executeUpdate();

        // 4. 성공하면 커밋
        conn.commit();

    } catch (SQLException e) {
        // 5. 실패하면 롤백
        if (conn != null) {
            try {
                conn.rollback();
            } catch (SQLException rollbackEx) {
                rollbackEx.printStackTrace();
            }
        }
        throw new RuntimeException(e);

    } finally {
        // 6. 커넥션 반환
        if (conn != null) {
            try {
                conn.setAutoCommit(true);  // 원상 복구
                conn.close();
            } catch (SQLException closeEx) {
                closeEx.printStackTrace();
            }
        }
    }
}

비교해보면

// Plain JDBC - 40줄
public void createUser(User user) {
    Connection conn = null;
    try {
        conn = dataSource.getConnection();
        conn.setAutoCommit(false);
        // ... 비즈니스 로직 ...
        conn.commit();
    } catch (SQLException e) {
        if (conn != null) { try { conn.rollback(); } catch ... }
        throw new RuntimeException(e);
    } finally {
        if (conn != null) { try { conn.close(); } catch ... }
    }
}

// Spring @Transactional - 5줄
@Transactional
public void createUser(User user) {
    userRepository.save(user);
}

@Transactional이 해주는 일:

  1. getConnection() - 커넥션 획득
  2. setAutoCommit(false) - 트랜잭션 시작
  3. commit() - 성공 시 커밋
  4. rollback() - 예외 발생 시 롤백
  5. close() - 커넥션 반환

이 모든 보일러플레이트 코드를 프록시가 대신 처리해주는 겁니다.

MyBatis만 쓴다면?

Spring이 없어도 MyBatis를 사용중이라면 자체적으로 트랜잭션 관리가 가능합니다. SqlSession을 통해 commit/rollback을 직접 호출합니다.

public void createUser(User user) {
    // autoCommit = false로 SqlSession 생성
    SqlSession session = sqlSessionFactory.openSession(false);
    try {
        UserMapper mapper = session.getMapper(UserMapper.class);
        mapper.insertUser(user);

        session.commit();  // 성공 시 커밋
    } catch (Exception e) {
        session.rollback();  // 실패 시 명시적 롤백
        throw new RuntimeException(e);
    } finally {
        session.close();  // 세션 반환
    }
}

참고: SqlSession.close()는 commit되지 않은 변경사항을 롤백하긴 하지만, 명시적으로 rollback()을 호출하는 것이 권장됩니다. 예외 상황을 명확히 처리하고, 코드 의도를 분명히 하기 위해서입니다.

JDBC보다는 깔끔하지만, 여전히 매 메소드마다 session 관리 코드가 필요합니다.

Spring + MyBatis 조합

Spring과 함께 쓰면 @Transactional로 통합 관리됩니다.

@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;  // MyBatis Mapper

    @Transactional  // Spring이 트랜잭션 관리
    public void createUser(User user) {
        userMapper.insertUser(user);  // SqlSession 관리 불필요!
    }
}

어떻게 가능할까요? Spring-MyBatis 연동 시 SqlSessionTemplate이 핵심 역할을 합니다.

@Transactional 시작
    ↓
TransactionManager가 Connection 획득
    ↓
SqlSessionTemplate이 해당 Connection과 연결된 SqlSession 생성
    ↓
Mapper 메소드 실행 (같은 트랜잭션 내에서 같은 SqlSession 재사용)
    ↓
@Transactional 종료 시 commit/rollback

SqlSessionTemplate의 역할:

  • Thread-safe: 여러 스레드에서 안전하게 공유 가능
  • 트랜잭션 동기화: Spring 트랜잭션과 MyBatis SqlSession을 연결
  • 예외 변환: MyBatis 예외를 Spring의 DataAccessException으로 변환

결국 @Transactional이 Connection을 관리하고, SqlSessionTemplate이 그 Connection에 맞는 SqlSession을 자동으로 바인딩해주는 구조입니다.

정리

방식 트랜잭션 관리 코드량
Plain JDBC Connection.commit/rollback 많음
MyBatis 단독 SqlSession.commit/rollback 중간
Spring + MyBatis @Transactional 최소

3. 프록시 생성 방식: JDK Dynamic Proxy vs CGLIB

Spring은 두 가지 방식으로 프록시를 생성합니다.

방식 원리 특징
JDK Dynamic Proxy 인터페이스 기반 인터페이스를 구현한 프록시 생성
CGLIB 클래스 상속 기반 클래스를 상속한 서브클래스 프록시 생성

Spring Framework vs Spring Boot 기본값

환경 기본 프록시 방식
Spring Framework (Spring MVC) JDK Dynamic Proxy
Spring Boot 2.0+ CGLIB

Spring Framework는 원래 인터페이스가 있으면 JDK Dynamic Proxy, 없으면 CGLIB를 사용했습니다. 하지만 Spring Boot 2.0부터는 인터페이스 유무와 관계없이 CGLIB가 기본입니다.

그래서 인터페이스를 꼭 만들어야 해?

아니요, 안 만들어도 됩니다.

// 이렇게만 해도 CGLIB가 알아서 프록시 생성
@Service
public class UserService {
    @Transactional
    public void createUser(User user) {
        userRepository.save(user);
    }
}

예전에는 “Service 인터페이스 + ServiceImpl 구현체” 패턴이 관례였습니다.

// 과거 관례 - 지금은 불필요한 경우가 많음
public interface UserService {
    void createUser(User user);
}

@Service
public class UserServiceImpl implements UserService {
    @Transactional
    public void createUser(User user) { ... }
}

인터페이스가 필요한 경우

  • 여러 구현체가 존재할 때 (전략 패턴)
  • 테스트에서 Mock 객체로 교체할 때 (하지만 Mockito는 클래스도 Mock 가능)
  • 외부에 API 계약을 명시할 때

결론 Spring Boot 환경이라면 인터페이스 없이 클래스만 작성해도 @Transactional이 정상 동작합니다.

설정으로 변경하기

Spring Boot에서 JDK Dynamic Proxy를 쓰고 싶다면,

# application.yml
spring:
  aop:
    proxy-target-class: false  # JDK Dynamic Proxy 사용

Spring Framework(XML 설정)에서 CGLIB 강제:

<aop:config proxy-target-class="true"/>

4. Self-Invocation 문제

“같은 클래스 내에서 @Transactional 메소드를 호출하면 트랜잭션이 적용될까요?”

답: 아니요!

@Service
public class UserService {

    public void outer() {
        // 같은 클래스 내부에서 호출
        inner();  // ❌ 트랜잭션 적용 안 됨!
    }

    @Transactional
    public void inner() {
        // DB 작업
    }
}

왜 안 될까?

inner()프록시를 거치지 않고 실제 객체를 직접 호출하기 때문입니다.

참고: inner()this.inner()는 동일합니다. Java에서 this는 생략해도 암묵적으로 현재 인스턴스를 가리킵니다. 어느 쪽으로 호출해도 프록시를 거치지 않습니다.

외부에서 호출:  [클라이언트] → [프록시] → [실제 객체]  ✅ 트랜잭션 적용
내부에서 호출:  [실제 객체] → [실제 객체]              ❌ 프록시를 안 거침

해결 방법

@Service
public class UserService {

    @Autowired
    private UserService self;  // 자기 자신을 주입 (프록시 객체가 주입됨)

    public void outer() {
        self.inner();  // ✅ 프록시를 통해 호출
    }

    @Transactional
    public void inner() {
        // DB 작업
    }
}

또는 별도의 클래스로 분리하는 것이 더 깔끔합니다.


5. private 메소드에 @Transactional을 붙이면?

동작하지 않습니다.

CGLIB 프록시는 클래스를 상속해서 만들어지는데, private 메소드는 상속이 불가능하기 때문입니다.

@Transactional
private void doSomething() {  // ❌ 컴파일은 되지만, 트랜잭션 적용 안 됨
    // ...
}

6. 트랜잭션 전파(Propagation)

@Transactional에서 가장 많이 물어보는 속성입니다.

@Transactional(propagation = Propagation.REQUIRED)  // 기본값
전파 옵션 설명
REQUIRED (기본값) 트랜잭션이 있으면 참여, 없으면 새로 생성
REQUIRES_NEW 항상 새 트랜잭션 생성 (기존 트랜잭션 일시 정지)
NESTED 중첩 트랜잭션 생성 (savepoint 활용)
SUPPORTS 트랜잭션이 있으면 참여, 없으면 없이 실행
NOT_SUPPORTED 트랜잭션 없이 실행 (기존 트랜잭션 일시 정지)
MANDATORY 트랜잭션 필수 (없으면 예외 발생)
NEVER 트랜잭션이 있으면 예외 발생

자주 쓰는 예시

@Transactional
public void processOrder(Order order) {
    orderRepository.save(order);

    // 로그 저장은 별도 트랜잭션 (메인 트랜잭션 실패해도 로그는 남김)
    logService.saveLog(order);
}

@Service
public class LogService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveLog(Order order) {
        logRepository.save(new Log(order));
    }
}

7. 롤백 규칙

기본적으로 Unchecked Exception (RuntimeException)에서만 롤백됩니다.

@Transactional
public void method() {
    throw new RuntimeException();  // ✅ 롤백됨
}

@Transactional
public void method() throws Exception {
    throw new Exception();  // ❌ 롤백 안 됨 (Checked Exception)
}

Checked Exception도 롤백하려면?

@Transactional(rollbackFor = Exception.class)
public void method() throws Exception {
    throw new Exception();  // ✅ 이제 롤백됨
}

정리: 면접에서 이렇게 답하자

“@Transactional은 Spring AOP를 기반으로 동작합니다. Spring은 해당 클래스의 프록시 객체를 생성하고, 메소드 호출 시 프록시가 이를 가로채서 트랜잭션 시작, 커밋, 롤백 로직을 수행합니다. 프록시는 CGLIB을 사용해 클래스를 상속하여 생성되며, 이 때문에 private 메소드나 같은 클래스 내부 호출(self-invocation)에서는 트랜잭션이 적용되지 않는 한계가 있습니다.”

이 질문을 받았을 때, 위 정도로만 답했다면 좋았을텐데. 무지함이 아쉽네요.


마치며

이번 포스팅을 정리하면서 “아, 이걸 면접 때 이렇게 말했어야 했는데…“라는 후회가 밀려왔습니다. 하지만 이제는 확실히 이해했으니, 다음에는 자신있게 설명할 수 있을 것 같습니다.

면접 준비하시는 분들, 화이팅입니다!