여는 글

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) 방식으로 트랜잭션 로직이 끼워지는 것입니다.


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

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

방식 조건 특징
JDK Dynamic Proxy 인터페이스가 있을 때 인터페이스를 구현한 프록시 생성
CGLIB 인터페이스가 없을 때 클래스를 상속한 프록시 생성

Spring Boot 2.0부터는 기본적으로 CGLIB를 사용합니다.

// JDK Dynamic Proxy - 인터페이스 필요
public interface UserService { ... }

@Service
public class UserServiceImpl implements UserService { ... }
// CGLIB - 인터페이스 없이 클래스만
@Service
public class UserService { ... }  // 이 클래스를 상속한 프록시 생성

4. 면접 단골 질문: Self-Invocation 문제

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

답: 아니요!

@Service
public class UserService {

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

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

왜 안 될까?

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

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

해결 방법

@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)에서는 트랜잭션이 적용되지 않는 한계가 있습니다.”

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


마치며

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

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