시작하며
우리가 Spring 프로젝트를 하면서 정말 많이 사용하는 어노테이션 중 하나인 @Transactional...
하지만 정말 제대로 알고 사용하고 있을까?
나 역시도 지금까지 @Transactional을 사용하면서 막연하게 어노테이션 붙였으니깐 ACID 알아서 다 해주겠지~ 라는 안일한 생각으로 개발을 해왔던 것 같다...
그러던 중, 현재 활동 중인 SOPT 동아리에서 전 36기 서버 파트장님이 진행해주신 미니 세미나를 통해 해당 내용을 깊이 있게 접할 기회가 있었다.
따라서 본 포스팅에서는 당시 세미나에서 배운 내용에 개인적인 공부를 덧붙여, @Transactional이 내부적으로 어떻게 동작하는지 그리고 어떤 한계와 주의사항이 있는지 정리해보고자 한다.
본론에 들어가기 앞서...
@Transactional에 대하여 알아보기 이전에 우리는 트랜잭션에 대해 자세히 알아볼 필요가 있다.
트랜잭션은 "데이터베이스의 상태를 변화시키는 작업의 단위"이다.
여기서 중요한 점은 단순한 쿼리 한 줄이 아니라, 여러 작업을 하나의 논리적 단위로 묶는다는 것이다. 이 묶음 안의 작업들은 '전부 성공하거나, 아니면 아예 실패해야 하는(All or Nothing)' 운명 공동체이며, 이를 보장하기 위해 ACID라는 네 가지 핵심 속성이 강조된다.
ACID
Atomicity(원자성), Consistency(일관성), Isolation(격리성), Durability(지속성)의 약자이다.
간략하게 요약하면 다음과 같이 설명할 수 있을 것 같다.
- Atomicity : 트랜잭션 내의 모든 작업이 하나로 묶여, 단 하나라도 실패하면 전체가 취소되어야 함 (중간 상태의 데이터가 DB에 남는 것을 절대 허용하지 않음)
- Consistency : 트랜잭션이 성공적으로 완료된 후에도 데이터베이스는 미리 정해진 규칙(제약 조건, 데이터 타입 등)을 반드시 만족, 즉 유효성을 유지해야 함.
- Isolation : 동시에 여러 트랜잭션이 수행될 때, 각 트랜잭션은 마치 혼자 실행되는 것처럼 다른 트랜잭션의 작업에 끼어들거나 영향을 받지 않아야 함
- Durability : 한 번 성공적으로 커밋된 트랜잭션의 결과는 시스템 장애나 전원 꺼짐 등의 상황이 발생하더라도 DB 로그 등을 통해 반드시 복구되어야 함
그럼 여기서 궁금증이 하나 생긴다. ACID는 누가, 또 어떻게 보장할까?
정답은 바로 "데이터베이스가 보장한다" 이다.
데이터베이스는 Redo log와 WAL을 통해 장애 상황에서도 데이터를 복구(Durability)해내고, Undo log를 활용해 작업 실패 시 원상복구(Atomicity)를 수행한다. 또한 Lock이나 MVCC 같은 기믹으로 동시성 문제(Isolation)를 해결하며, Constraint(제약 조건)를 통해 데이터의 무결성(Consistency)을 감시한다.
트랜잭션과 ACID에 대해 알아보았으니 이제 본격적으로 @Transactional 으로 넘어가보자...
@Transactional이 뭐하는 얜데??
본론으로 넘어가기 이전 @Transactional이 어떤 역할을 수행하는지 간략한 설명을 하자면,
"AOP 프록시 기반으로 동작하며 비즈니스 로직을 하나로 묶는 선언적 트랜잭션 관리 방식" 이라고 할 수 있을 것 같다.
Spring Docs에서도 이러한 메커니즘을 다음과 같이 설명하고 있다

여기서 핵심은 바로 "Proxy" 이다.
스프링에서는 트랜잭션과 같은 공통 관심사(AOP)를 Proxy로 구현한다.
Proxy를 사용하는 이유는 반복되는 코드를 공통 관심사로 묶어서 간편하게 처리하기 위함이다.
이해하기 쉽게 @Transactional을 사용하지 않고 트랜잭션을 구현하는 코드를 작성해보자.
Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);
try {
repository.withdraw(from, amount);
repository.deposit(to, amount);
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.close();
}
다음과 같이 작성할 경우, 매 비즈니스 로직마다 직접 커넥션을 가져오고, try-catch-finally를 열어서 커밋·롤백·자원 해제 코드를 일일이 작성해야 한다..
이런 반복적인 부가 작업을 AOP로 분리하면, 개발자는 비즈니스 로직 두 줄에만 집중할 수 있고 나머지는 @Transactional 한 줄로 끝난다.
그렇기에 @Transactional이 붙은 메서드 호출 시, 실제 객체 대신 Proxy 객체가 해당 호출을 대신 받는다.

위 사진에서 볼 수 있듯이, Caller(호출자)가 실제 객체 대신 Proxy를 호출하고, 트랜잭션 생성 이후 실제 객체인 target의 비즈니스 로직을 수행한다, 비즈니스 로직이 정상적으로 수행되었거나 CheckedException이 발생했다면 rollback 하지 않고, RuntimeException 또는 Error가 발생했다면 rollback 후 발생한 예외를 그대로 다시 throw한다.


텍스트로는 살짝 이해가 힘들 수도 있기에 실제 코드로 한번 살펴보도록 해보자.
public class BankServiceProxy implements BankService { // 스프링에서 만든 프록시
private final BankService target; // 실제 비즈니스 로직이 있는 진짜 객체
private final PlatformTransactionManager txManager;
@Override
public void transfer(Long from, Long to, Long amount) {
// 1. 프록시가 먼저 트랜잭션 시작
TransactionStatus tx = txManager.getTransaction(definition);
try {
target.transfer(from, to, amount); // 실제 객체 비즈니스 로직 수행
txManager.commit(tx); // 정상 수행 시 commit
} catch (RuntimeException e | Error e) {
txManager.rollback(tx); // rollback이후 다시 throw
throw e;
}
}
}
위 코드 플로우에서 확인할 수 있듯이 getTransaction으로 트랜잭션을 시작하고, 실제 객체인 target의 비즈니스 로직을 수행, 이후 정상 수행 여부에 따라 commit 혹은 rollback을 해주는 것을 볼 수 있다.
여기까지가 Caller -> AOP Proxy 이후 동작과정에 대해 살펴본 내용이다.
@Transactional과 영속성 컨텍스트
JPA에는 EntityManager가 관리하는 영속성 컨텍스트라는 개념이 존재한다.
영속성 컨텍스트가 하는 일은 다음 4가지와 같다.
- First-Level Cache (1차 캐시)
- Dirty Checking (변경 감지)
- Write-Behind (쓰기 지연)
- Identity (동일성 보장)
First-Level Cache
First-Level Cache는 마치 Redis와 같은 캐시 저장소를 사용할때 사용되는 Cache Aside 전략과 비슷하다.
매 쿼리 시 데이터베이스와 매번 상호작용하는 것이 아닌, 메모리에 데이터를 캐싱해두었다가 필요할 때 꺼내쓰는 전략이다.
동작 과정은 다음과 같다.
1. 쿼리 시 EntityManager가 영속성 컨텍스트에 해당 엔티티가 있는지 확인
2. 있으면 → 컨텍스트의 엔티티를 그대로 반환
3. 없으면 → DB에서 조회 → 컨텍스트에 저장 → 반환
Identity
First-Level Cache 덕분에 같은 엔티티를 여러번 조회하게 되는 경우에도 이전에 캐싱했던 데이터를 재사용하는 것이 가능하다.
동일한 데이터가 영속성 컨텍스트에 저장이 되기때문에 == 비교연산자까지도 통과한다.
이게 바로 Identity, 동일성 보장이다.
User a = userRepository.findById(1L).get();
User b = userRepository.findById(1L).get();
a == b; // true
Dirty Checking
영속성 컨텍스트에 엔티티가 처음으로 저장될 때, snapshot이라는 걸 찍어서 엔티티의 상태 저장을 한다.
이후 비즈니스 로직에서 해당 데이터가 변경된 시점에, 이 변경을 감지해 다시 snapshot을 찍는다.
이후 flush 시점에 snapshot끼리 비교하여 변경된 데이터에 대해 UPDATE쿼리를 자동 생성하게 되는데, 이게 바로 Dirty Checking이다.
우리가 post.update(request);를 작성한 이후에 따로 postRepository.save(post);를 하지 않아도 자동으로 데이터베이스에 변경 내용이 저장되는 이유가 바로 이 이유이다.
Write-Behind
여러개의 INSERT/UPDATE/DELETE 작업이 있을 때, 영속성 컨텍스트에서는 매번 쿼리를 날리는게 아닌, 쿼리를 한번에 모았다가 flush 시점에 한번에 SQL을 보낸다.
@Transactional과의 관계
그래서 @Transactional과 영속성 컨텍스트가 무슨 관계일까?
영속성 컨텍스트는 @Transactional에 의해 생성된다.
@Transactional이 붙은 메서드 시작 시 생성되어, 메서드 종료 시점에 flush되고 소멸한다.
영속성 컨텍스트의 생명 주기가 @Transactional의 동작 범위에 따라 결정된다고 볼 수 있다.
@Transactional은 만능인가?
앞서 살펴본 내용에서 확인할 수 있었듯이 @Transactional은 정말 강력한 기능을 제공한다.
하지만 강력한 기능을 제공하는 만큼 어떤 한계가 있는지 잘 알아야 이 기능을 제대로 사용할 수 있을 것이다.
@Transactional은 Proxy 객체 기반으로 동작한다고 했는데, 이 때문에 다음 4가지 주요 문제점이 발생할 수 있다.
- Self-invocation
- private method
- Checked Exception
- try catch에서 예외를 삼킴
Self-invocation (자기 호출)
같은 클래스 내부에서 메서드를 호출하면 트랜잭션이 적용되지 않는다.
다음과 같은 코드가 있다고 해보자.
@Service
public class MyService {
public void methodA() {
methodB();
}
@Transactional
public void methodB() { ... }
}
해당 코드에 대한 플로우는 다음과 같다.
methodA() 호출
↓
프록시 객체.methodA() 진입 (외부 호출이므로 프록시를 거침)
↓
methodA에는 @Transactional이 없음 → 트랜잭션을 시작하지 않고 실제 객체로 위임
↓
실제 객체.methodA() 실행
↓
methodA() 내부에서 methodB() 호출 → this.methodB()로 해석됨
↓
이미 실제 객체 위에서 실행 중이므로 프록시를 거치지 않음 (self-invocation)
↓
실제 객체.methodB() 실행 — @Transactional이 무시된 채 실행
Proxy가 외부 호출을 가로채는 경우는 오직 Proxy를 통해 들어오는 호출만 해당된다.
하지만 위와 같은 경우는 실제 객체를 통해 들어온 호출이기 때문에 프록시가 해당 호출을 가로채지 않는다.
결국 실제 객체 위에서 동작하는 것이기 때문에 @Transactional이 적용될 수가 없는 것이다.
또한 Proxy가 완전히 초기화된 후에야 정상 동작하기에, @PostConstruct 같은 초기화 코드에서는 의존하면 안 된다고 권장한다.

private method
private 접근자로 선언된 메서드에는 @Transactional이 적용되지 않는다.
그 이유는 바로 private으로 선언된 메서드는 오버라이딩이 불가능하기 때문이다.
앞에서 @Transactional이 붙은 메서드는 Proxy 객체가 실제 객체 대신 호출을 대신 받는다고 설명한 바가 있다.
이때 Proxy 객체는 기존 메서드를 오버라이드하여 트랜잭션 로직(시작 → 호출 → 커밋/롤백)을 끼워넣는 방식으로 동작한다.
그런데 기존 메서드가 private으로 선언되어 있다면, 오버라이드 자체가 불가능하므로 Proxy 객체가 해당 메서드를 가로채서 트랜잭션 로직을 적용할 수 없다.
결과적으로 private 메서드에 @Transactional을 붙여도 트랜잭션은 동작하지 않는다.
final로 선언된 클래스나 메서드 역시 같은 이유로 @Transactional이 동작하지 않는다.
final 클래스는 상속이 불가능하여 Proxy 객체 자체를 만들 수 없고, final 메서드는 오버라이드가 불가능하기 때문이다.
공식문서에도 위와 같은 내용을 다음과 같이 설명하고 있다.

Checked Exception
앞서 target(실제 객체)에서 비즈니스 로직을 수행하는 중, Checked Exception의 경우에는 rollback을 하지 않는다고 설명했다.
때문에 비즈니스 로직 동작 중, Checked Exception이 발생하게되면 에러 발생 전까지 수행된 로직은 실행이 되는 반면에, 에러 발생 이후 로직은 실행되지 않는다.
이로 인해 아주 심각한 데이터 정합성 문제가 발생할 수 있다.
이런 중대한 문제가 발생할 수 있다면, 왜 Spring에서는 Checked Exception에 대해서 rollback처리를 하지 않는 걸까?
그 이유는 EJB (Enterprise JavaBeans)의 관례를 그대로 따랐기 때문이다.
여기서 EJB란?
Spring 등장 이전, 2000년대 초반에 사용하던 Java로 구현된 서버 측 컴포넌트 모델이다.
초기에는 활발히 사용되었지만, 낮은 생산성, 과도한 복잡성, 무거운 설정 등의 이유로 현재는 Spring이 표준이 된 상태이다.
EJB에 대한 자세한 내용을 보고싶다면 아래 블로그를 참고하면 좋을 것 같다.
https://yummy0102.tistory.com/550
[Spring] Spring vs EJB 📌
🖐 들어가기 전에 EJB(Enterprise Java Beans)를 공부하기 전에 먼저 Java Beans에 대해 간단하게 알아보자 🥜 Java Bean(자바 빈) Java Bean이란 Java로 작성된 소프트웨어 컴포넌트를 말한다 Java는 프로그램 기
yummy0102.tistory.com
다시 돌아와서, Spring에서는 EJB의 여러 관례를 이어받았는데, Checked Exception의 경우에도 여기에 해당된다.
EJB에서는 CheckedException을 비즈니스 예외로 간주하였으며, 호출자가 복구 가능한 상황이라는 가정 하에 자동 rollback 대신 호출자에게 처리 권한을 위임했다.
Spring에서도 역시 EJB의 관례를 따르기 때문에 동일하게 호출자에게 에러 처리를 위임한다.
공식 문서에서도 다음과 같이 명시하고 있다.

그렇지만 Spring 공식 문서 스스로도 "이 기본 동작은 종종 커스터마이즈할 필요가 있다"고 인정하고 있다.
While the Spring default behavior for declarative transaction management follows EJB convention (roll back is automatic only on unchecked exceptions), it is often useful to customize this behavior.
rollback 하지 않음으로써 발생할 수 있는 데이터 정합성 불일치 문제가 너무나도 크기 때문이다.
그래서 일반적으로는 Checked Exception도 다른 Exception과 마찬가지로 자동 롤백 대상에 포함시키는 방법을 사용한다.
@Transactional 옆에 (rollbackFor = 자동롤백할 Exception)를 명시함으로써 간편하게 자동 롤백 대상에 포함시킬 수 있다.
@Transactional(rollbackFor = Exception.class)
public void processPayment(Order order) {
}
위와 같이 작성하면 proccessPayment에서 CheckedException이 발생해도, 자동으로 rollback하게 된다.
try-catch에서 예외를 삼킴
@Transactional의 4가지 주요 문제 중 가장 위험한 케이스이다.
앞서 살펴본 Proxy 객체가 동작하는 과정에서 볼 수 있었듯이, try-catch로 target(실제 객체)의 비즈니스 로직을 감싸고 수행하는 형식이었다.
target의 메서드 수행 중, Exception이 발생하면 rollback, 없으면 commit 한다.
하지만 만약 target의 비즈니스 로직에서 자체적으로 예외처리를 하는 경우에는 어떻게 될까?
이 경우에는 target 내부적으로 이미 예외를 처리했기 때문에, Proxy 내부적으로는 예외 발생 여부를 알 수가 없다.
Proxy에서는 예외 발생 여부를 알 수 없으니, 당연히 비즈니스 로직이 정상 수행되었음이라 판단하고 commit을 해버린다.
이는 아주 심각한 데이터 정합성 문제를 초래할 수 있다.
다음과 같이 A의 계좌에서 출금 후 B 계좌로 이체를 하는 코드가 있다고 해보자.
@Transactional
public void transfer() {
try {
accountA.withdraw();
accountB.deposit();
} catch (RuntimeException e) {
// 예외를 잡아서 로그만 남기고 끝
log.error("실패", e);
}
}
위 코드에서 만약 A의 계좌에서 출금은 정상적으로 수행되었지만, B 계좌로 이체하는 중 RuntimeException이 발생했다고 해보자.
일반적으로라면 Proxy가 예외를 감지하여 rollback을 수행하고, 출금된 금액이 원상복구되어야 한다.
하지만 위 코드에서는 target 내부의 try-catch가 예외를 잡아버렸기 때문에, 예외는 메서드 밖으로 빠져나가지 못한다.
결국 Proxy 입장에서는 메서드가 정상적으로 끝났다고 판단하여 commit을 수행한다.
결과적으로
1. A 계좌에서는 출금이 commit됨 (돈이 빠져나감)
2. B 계좌에는 입금이 실행되지 않음
즉, 돈이 증발한다.
위 문제를 해결할 수 있는 방법은 다음과 같이 크게 두 가지가 존재한다.
1. setRollbackOnly()
명시적으로 현재 트랜잭션이 rollback 대상임을 선언한다.
transfer 메서드에 이 방법을 적용하면 다음과 같이 작성할 수 있다.
@Transactional
public void transfer() {
try {
accountA.withdraw();
accountB.deposit();
} catch (RuntimeException e) {
log.error("실패", e);
// rollback 대상임을 명시
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}
catch문 내부에 다음과 같은 코드를 추가하면 Exception을 catch해도 현재 트랜잭션이 rollback 대상임을 명시했기 때문에 Proxy에서 target의 비즈니스 로직을 모두 수행한 이후 rollback 처리를 하게 된다.
2. 예외를 다시 던지기
try-catch 내부에서 예외를 catch 했을때 해당 에러를 다시 throw하는 방법이다.
transfer 메서드에 이 방법을 적용하면 다음과 같이 작성할 수 있다.
@Transactional
public void transfer() {
try {
accountA.withdraw();
accountB.deposit();
} catch (RuntimeException e) {
log.error("실패", e);
throw e; // 예외를 다시 throw
}
}
catch문에서 예외를 다시 던져줌으로써 예외가 transfer 메서드 밖으로 빠져나갈 수 있게 되고, Proxy 객체도 예외가 발생하였음을 인지할 수 있기때문에 이전처럼 commit을 하는 것이 아닌 rollback을 수행하게 된다.
Spring 공식 문서에서는 rollback시 프로그래밍 방식(Programmatic rollback)은 필요할 때만 사용하고, rollback은 선언적 접근(Declarative approach)를 통하는걸 강력하게 권장한다고 한다.

여기서 setRollbackOnly()는 Programmatic rollback에 해당하고, 예외를 다시 던지는 방식은 declarative approach에 해당한다.
setRollbackOnly()는 프로그램 내부에서 자체적으로 rollback 처리를 하고, 예외를 다시 던지는 방식은 @Transactional 을 선언했고 예외가 메서드 밖으로 전파되어 @Transactional 이 자동으로 롤백 여부를 판단하게 두기 때문이다.
마치며...
이번 글에서는 @Transactional 이 Proxy 기반으로 어떻게 동작하는지, 그리고 그로 인해 발생할 수 있는 주요 한계점에 초점을 맞춰 정리해보았다.
사실 @Transactional은 워낙 깊이 있는 주제라, 이번 포스팅 한 편으로 모든 내용을 다루기에는 분량이 너무 방대했다.
때문에 트랜잭션 전파, 동시성, 격리 수준 등의 내용까지 한번에 다루기에는 무리가 있었다.
남은 주제들은 시간이 나는 대로 추후 별도의 포스팅으로 이어가볼 예정이다.
