Spring @Transactional 동작 원리
트랜잭션 경계에 관한 이야기
여는 글
Spring Framework를 다루시는 분이라면, @Transactional 어노테이션을 많이 사용하실겁니다. @Transactional 어노테이션을 붙이면, 해당 메소드의 트랜잭션을 보장해주는 것으로 알고 있는데요. 저는 이 어노테이션의 동작원리를 설명할 줄 모르고 머릿속에 떠다니는 상상으로 사용했던 것 같습니다.
면접에서 “@Transactional이 어떻게 동작하나요?“라는 질문을 받았을 때, 제대로 대답하지 못했던 창피한 기억이 있습니다. 이번 포스팅을 통해 동작 원리를 확실히 정리하고, 다시는 그런 실수를 반복하지 않으려 합니다.
핵심 답변: 프록시(Proxy) 패턴
@Transactional의 동작 원리를 한 문장으로 요약하면
Spring은 프록시 객체를 생성하여, 실제 메소드 호출 전후에 트랜잭션 시작/커밋/롤백 로직을 끼워넣는다.
1. 프록시가 뭔데?
프록시(Proxy)는 대리인이라는 뜻입니다. 실제 객체를 감싸는 래퍼(Wrapper) 객체라고 생각하면 됩니다.
[클라이언트] → [프록시 객체] → [실제 객체]
Spring이 @Transactional이 붙은 클래스를 만나면
- 실제 객체를 생성합니다
- 그 객체를 감싸는 프록시 객체를 생성합니다
- 스프링 컨테이너에는 프록시 객체가 빈(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이 해주는 일:
getConnection()- 커넥션 획득setAutoCommit(false)- 트랜잭션 시작commit()- 성공 시 커밋rollback()- 예외 발생 시 롤백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)에서는 트랜잭션이 적용되지 않는 한계가 있습니다.”
이 질문을 받았을 때, 위 정도로만 답했다면 좋았을텐데. 무지함이 아쉽네요.
마치며
이번 포스팅을 정리하면서 “아, 이걸 면접 때 이렇게 말했어야 했는데…“라는 후회가 밀려왔습니다. 하지만 이제는 확실히 이해했으니, 다음에는 자신있게 설명할 수 있을 것 같습니다.
면접 준비하시는 분들, 화이팅입니다!