스프링 @Transactional, 어떻게 동작하는지 알아보자
· spring, java, transaction, jpa, backend
목차
프론트엔드 개발자로 일하다 처음 백엔드 코드를 열어봤을 때, 서비스 메서드마다
붙어 있는 @Transactional이 눈에 들어왔어요. 검색해 보니 JPA 글에서도 나오고
스프링 글에서도 나와서 이게 누구 문법인지부터 헷갈리더라고요. 이 글에서는
트랜잭션이 뭔지부터 시작해서, @Transactional이 정확히 누구 소속이고
어떤 원리로 동작하는지까지 순서대로 따라가 볼게요.
트랜잭션: 쪼갤 수 없는 작업 단위
@Transactional을 이해하려면 먼저 트랜잭션부터 알아야 해요.
교과서 예시인 계좌 이체로 시작해 볼게요.
A가 B에게 1만 원을 보낸다.
- A 계좌에서 1만 원을 뺀다.
- B 계좌에 1만 원을 더한다.
DB 입장에서는 UPDATE 쿼리 2번이에요. 그런데 1번이 성공한 직후 서버가 죽으면 어떻게 될까요? A의 돈은 사라졌는데 B는 받지 못한 상태가 돼요. 이런 사고를 막으려면 두 쿼리를 "둘 다 성공하거나, 둘 다 없던 일로 하거나(All or Nothing)"로 묶어야 하는데, 이 묶음이 바로 트랜잭션이에요.
트랜잭션의 핵심 동작은 딱 두 가지예요.
- commit: 전부 성공했으니 DB에 확정 반영
- rollback: 중간에 실패했으니 전부 취소, 시작 전 상태로 복구
(ACID라는 4가지 성질이 있지만, 입문 단계에서는 "All or Nothing"이 곧 원자성(Atomicity)이라는 것만 기억해도 충분해요.)
문제: 트랜잭션 코드를 손으로 짜면
트랜잭션은 DB 기능이라서 자바 코드에서 직접 제어할 수 있어요. 스프링 없이 JDBC로 이체 로직을 짜면 이렇게 돼요.
public void transfer(Long fromId, Long toId, long amount) throws SQLException {
Connection conn = dataSource.getConnection();
try {
conn.setAutoCommit(false); // 트랜잭션 시작
accountDao.withdraw(conn, fromId, amount); // 출금
accountDao.deposit(conn, toId, amount); // 입금
conn.commit(); // 성공 → 확정
} catch (Exception e) {
conn.rollback(); // 실패 → 전부 취소
throw e;
} finally {
conn.close();
}
}
진짜 비즈니스 로직은 딱 2줄(출금, 입금)인데 트랜잭션 관리 코드가 더 많아요.
메서드를 만들 때마다 이 try-catch-finally를 복붙해야 하고,
rollback()을 깜빡하면 조용히 데이터가 깨져요.
해결: @Transactional 한 줄
스프링은 이 반복 코드를 어노테이션 하나로 대체해요.
@Service
public class TransferService {
private final AccountRepository accountRepository; // (생성자 주입 생략)
@Transactional
public void transfer(Long fromId, Long toId, long amount) {
accountRepository.withdraw(fromId, amount);
accountRepository.deposit(toId, amount);
}
}
스프링이 메서드 실행 전에 트랜잭션을 시작하고, 정상 종료하면 commit, 예외가 터지면 rollback을 대신 해줘요. (사실 "모든" 예외가 롤백되는 건 아닌데, 이 얘기는 뒤에서 함정으로 다뤄요.) "트랜잭션을 어떻게 처리할지"를 코드로 쓰는 게 아니라 "이 메서드는 트랜잭션 안에서 실행돼야 해"라고 선언만 한다고 해서 선언형(declarative) 트랜잭션이라고 불러요.
옵션도 붙일 수 있어요. 실무에서 가장 자주 보게 되는 건 조회 전용 메서드에
붙이는 readOnly예요. 쓰기가 없다고 알려주면 JPA가 변경 감지 준비를 생략하는
등의 최적화가 가능해요. 다만 쓰기를 원천 차단하는 잠금장치라기보다는
"이 트랜잭션은 읽기 전용"이라고 알려주는 힌트에 가까워요.
@Transactional(readOnly = true)
public Member findMember(Long id) { ... }
그래서 JPA예요, 스프링이에요?
결론부터 말하면 JPA 문법이 아니에요. JPA 스펙에는 이 어노테이션이 없어요. 게다가 같은 이름의 어노테이션이 2개 존재해요.
| 패키지 | 소속 | 사용해야 할까? |
|---|---|---|
org.springframework.transaction.annotation.Transactional | 스프링 | ✅ 이걸 써요 |
jakarta.transaction.Transactional (구 javax.transaction) | 자바 표준(JTA) | ❌ 옵션이 부족해요 |
IDE 자동 import에서 jakarta(또는 javax) 쪽을 잘못 선택하는 게 단골 실수예요.
스프링 버전에는 readOnly, timeout, isolation 같은 세부 옵션이 있지만
표준 버전에는 없어요. import 문이 org.springframework로 시작하는지 꼭 확인하세요.
그럼 JPA는 트랜잭션과 무관하냐면, 그건 아니에요. JPA에도 자체 트랜잭션 API인
EntityTransaction이 있고, 스프링의 @Transactional은 이런 저수준 API를
대신 호출해 주는 지휘자예요. 지휘 대상이 JPA든 MyBatis든 순수 JDBC든
@Transactional 사용법은 그대로라는 것 — 이게 스프링이 유명해진 이유 중 하나예요.
원리: 프록시가 내 코드를 감싼다
어노테이션 하나 붙였다고 커밋/롤백이 저절로 실행되는 건 마법이 아니에요.
스프링은 클래스나 메서드에 @Transactional이 붙은 빈(bean)을 발견하면,
그 객체를 감싸는 프록시(대리인) 객체를 만들어서 원본 대신 등록해요.
프록시가 하는 일을 의사코드로 보면 이래요.
// 스프링이 런타임에 자동 생성하는 프록시의 (개념적) 모습
class TransferServiceProxy extends TransferService {
@Override
public void transfer(Long fromId, Long toId, long amount) {
tx.begin(); // ① 트랜잭션 시작
try {
super.transfer(fromId, toId, amount); // ② 내가 작성한 진짜 코드
tx.commit(); // ③ 성공 → 커밋
} catch (RuntimeException e) {
tx.rollback(); // ④ 실패 → 롤백
throw e; // (왜 RuntimeException만? → 아래 함정 2)
}
}
}
어디서 본 모양이죠? 맞아요, 위에서 손으로 짰던 JDBC 코드예요. 그 보일러플레이트를 스프링이 대신 만들어서 내 코드 바깥에 씌워주는 것이에요. 호출자는 진짜 객체가 아니라 프록시를 호출하게 되고, 트랜잭션 처리는 프록시 안에서 일어나요.
프론트엔드로 비유하면 axios interceptor예요. API 호출 코드는 한 줄도 안 고치고 interceptor가 모든 요청 앞뒤에 토큰 주입과 에러 처리를 끼워 넣듯이, 프록시는 비즈니스 로직을 한 줄도 안 고치고 앞뒤에 트랜잭션 시작과 커밋을 끼워 넣어요. 컴포넌트를 감싸서 기능을 더하는 React의 HOC를 떠올려도 좋아요.
프록시를 알면 보이는 함정 세 가지
이 프록시 원리를 알면, 유명한 함정들이 전부 자연스럽게 이해돼요.
같은 클래스 안에서 자기 메서드 호출
@Service
public class OrderService {
public void placeOrder() {
saveOrder(); // ⚠️ 트랜잭션이 안 걸려요!
}
@Transactional
public void saveOrder() { ... }
}
placeOrder() 안에서의 saveOrder() 호출은 사실 this.saveOrder()예요.
this는 프록시가 아니라 원본 객체 자신이라서, 프록시의 트랜잭션 코드를
그냥 건너뛰어요. 트랜잭션은 프록시를 거쳐 밖에서 들어오는 호출에만 걸려요.
체크 예외는 롤백되지 않는다
프록시 의사코드의 catch 문을 다시 보면 RuntimeException만 잡고 있어요.
(의사코드에서는 단순화했지만, 실제 프록시는 예외를 받으면 종류를 검사해서
롤백할지 커밋할지 결정해요.) 스프링 공식 문서의 기본 규칙은 이래요.
Any
RuntimeExceptionorErrortriggers rollback, and any checkedExceptiondoes not.
즉 IOException 같은 체크 예외(checked exception)는 던져져도 커밋돼요.
"예외가 터졌는데 왜 DB에 반영됐지?"의 범인은 대부분 이 규칙이에요.
체크 예외에도 롤백이 필요하면 명시해야 해요.
@Transactional(rollbackFor = Exception.class) // 체크 예외도 롤백
public void process() throws IOException { ... }
private 메서드에는 못 붙인다
프록시는 원본 클래스를 상속(또는 인터페이스 구현)해서 메서드를 가로채는
방식이에요. 오버라이드할 수 없는 private 메서드는 가로챌 수가 없죠.
그래서 @Transactional은 public 메서드에 붙이는 게 기본이에요.
한 가지 최신 정보를 덧붙이면, 스프링 6.0(Spring Boot 3.x)부터는 클래스 기반
프록시에 한해 protected와 패키지 가시성 메서드도 기본 지원돼요. 하지만
private은 상속으로 오버라이드하는 것 자체가 불가능해서 버전과 무관하게
안 되고, 인터페이스 기반 프록시라면 여전히 public만 돼요.
직접 확인해 보기
설명만 읽는 것보다 직접 보는 게 확실하니, 간단한 실험으로 롤백을 관찰해 봐요.
@Service
public class ExperimentService {
private final MemberRepository memberRepository; // (생성자 주입 생략)
@Transactional
public void saveThenFail() {
memberRepository.save(new Member("A")); // ① 저장
throw new IllegalStateException("일부러 실패!"); // ② 예외 발생
}
}
@Transactional이 있을 때: 예외로 롤백 → 멤버 A는 DB에 없어요@Transactional을 지우면: ①이 즉시 커밋 → 예외가 터져도 A는 DB에 남아요
어노테이션을 지웠는데 어떻게 저장이 되냐고요? Spring Data JPA의 save()
자체에 @Transactional이 붙어 있어서, 자기만의 짧은 트랜잭션을 열고 바로
커밋하기 때문이에요. 그래서 어노테이션을 지우면 ①과 ②가 각자 다른
트랜잭션이 되어 버려서, "하나의 작업 단위로 묶는다"는 목적이 깨지는 거예요.
길게 알아봤지만 결국 @Transactional은 "JPA가 아니라 스프링이, 프록시로 내
코드를 감싸서 commit/rollback을 대신해 준다"는 한 문장으로 요약돼요. 프록시라는
원리 하나만 기억하면 자기 호출·체크 예외·private 함정은 저절로 따라와요.
다음 글에서는 트랜잭션 안에서 트랜잭션을 또 시작하면 어떻게 되는지, 전파(Propagation) 속성을 다뤄 볼게요.