안녕하세요.
오늘은 Spring의 annotation 중에 @Transactional이 무엇이며 내부적으로 어떻게 동작하는지를 정리해보려고 합니다.
사용법은 단순해 보이지만 동작 방식을 모르고 쓰면 데이터 정합성 사고로 이어지기 쉬운 어노테이션입니다.
이 글에서는 트랜잭션의 기본 개념부터 @Transactional이 내부적으로 어떤 방식으로 동작하는지까지 차근차근 살펴보겠습니다.
Transaction이란?
Transaction은 하나의 논리적 작업 단위로 묶인 데이터베이스 연산들의 집합입니다.
핵심은 "전부 성공하거나, 전부 실패한다"는 점입니다. 가장 자주 인용되는 예시는 계좌 이체입니다.
1. A 계좌에서 10만원 출금
2. B 계좌에 10만원 입금
1번이 성공한 뒤 2번이 실패하면 A의 돈만 사라지고 B는 받지 못합니다.
트랜잭션은 이런 상황을 막기 위해 두 작업을 한 단위로 묶어, 2번이 실패하면 1번도 되돌립니다(롤백).
데이터베이스 트랜잭션은 다음 네 가지 속성(ACID)을 보장합니다.
| 속성 | 의미 |
| Atomicity (원자성) | 모두 성공하거나 모두 실패 |
| Consistency (일관성) | 트랜잭션 전후 데이터 무결성 유지 |
| Isolation (격리성) | 동시 실행 트랜잭션 간 간섭 방지 |
| Durability (지속성) | 커밋된 결과는 영속적으로 저장 |
@Transactional은 이 보장을 코드에서 손쉽게 활용할 수 있게 해주는 도구입니다.
Spring 이전의 Transaction 관리
@Transactional이 없을 때 트랜잭션을 어떻게 관리했는지 보면, 이 어노테이션의 가치가 더 명확해집니다.
public void transfer(Long fromId, Long toId, long amount) {
Connection conn = null;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false); // 트랜잭션 시작
accountDao.withdraw(conn, fromId, amount);
accountDao.deposit(conn, toId, amount);
conn.commit(); // 커밋
} catch (Exception e) {
if (conn != null) {
try {
conn.rollback(); // 롤백
} catch (SQLException ex) {
log.error("롤백 실패", ex);
}
}
throw new RuntimeException(e);
} finally {
if (conn != null) {
try {
conn.close(); // 리소스 정리
} catch (SQLException ex) {
log.error("커넥션 종료 실패", ex);
}
}
}
}
위의 코드를 보면 문제점이 명확합니다.
- 비즈니스 로직과 트랜잭션 코드의 혼재 : 실제 비즈니스 로직은 withdraw와 deposit 두 줄뿐
- 반복적인 보일러플레이트 : 메서드마다 같은 try-catch-finally 구조
- 휴먼 에러 가능성 : commit() 누락, rollback() 누락, close() 누락
- 트랜잭션 전파의 어려움 : 다른 메서드와 같은 트랜잭션을 유지하려면 Connection을 직접 들고 다녀야 함
같은 코드를 메서드마다 반복하는 것은 유지보수 관점에서도 좋지 않습니다.
@Transactional이란?
@Transactional은 Spring에서 트랜잭션을 선언적으로 관리할 수 있게 해주는 어노테이션입니다.
메서드(또는 클래스)에 붙이기만 하면 해당 메서드를 실행하는 동안 자동으로 트랜잭션이 시작되고, 정상 종료 시 커밋, 예외 발생 시 롤백됩니다.
앞서 살펴본 절차적 트랜잭션 관리 코드는 다음과 같이 단순해집니다.
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
@Transactional
public void transfer(Long fromId, Long toId, long amount) {
accountRepository.withdraw(fromId, amount);
accountRepository.deposit(toId, amount);
}
}
어노테이션 한 줄로 다음이 모두 자동 처리됩니다.
- 메서드 시작 시 트랜잭션 시작 (setAutoCommit(false))
- 정상 종료 시 커밋
- 예외 발생 시 롤백
- 메서드 종료 시 커넥션 반환
비즈니스 로직만 남고, 트랜잭션 관리 코드는 사라집니다. 이를 선언적 트랜잭션 관리(Declarative Transaction Management)라고 부릅니다.
@Transactional 선언 위치
| 위치 | 동작 |
| 메서드 레벨 | 특정 메서드에만 적용 (가장 일반적) |
| 클래스 레벨 | 클래스 내 모든 public 메서드에 일괄 적용 |
| 인터페이스 레벨 | JDK 프록시일 때만 동작, CGLIB(Spring Boot 2.x 기본)에서는 무시됨. 구현체에 붙여야 함. |
우선순위: 메서드 레벨 > 클래스 레벨
@Service
@Transactional(readOnly = true) // 클래스 레벨: 기본은 readOnly
public class UserService {
public UserDto findUser(Long id) { ... } // readOnly = true 상속
@Transactional // 메서드 레벨: 클래스 레벨을 override
public void register(UserDto dto) { ... } // 일반 트랜잭션
}
조회 서비스에서 자주 쓰는 패턴입니다. 클래스 전체를 readOnly로 두고, 쓰기 메서드만 별도로 표시합니다.
Spring AOP 프록시
@Transactional의 진짜 핵심은 Spring AOP 프록시에 있습니다.
어노테이션을 붙이기만 했는데 트랜잭션이 자동으로 처리되는 동작은 사실 스프링이 우리 빈을 프록시로 감싸서 가능한 것입니다.
프록시 객체란?
스프링 컨테이너는 @Transactional이 붙은 빈을 그대로 등록하지 않고, 프록시 객체로 감싸서 등록합니다.
| 구분 | 설명 |
| 실제 객체 | AccountService 인스턴스 |
| 프록시 객체 | AccountService를 감싼 트랜잭션 처리용 객체 |
| 빈으로 등록되는 것 | 프록시 객체 |
외부에서 빈을 호출하면 항상 프록시가 먼저 호출되고, 프록시는 트랜잭션 처리 후 실제 객체로 위임합니다.
[Caller] → [프록시] → 트랜잭션 시작 → [실제 Bean] → 결과 → [프록시] → 커밋/롤백
프록시 생성 방식
| 방식 | 조건 | 비고 |
| JDK Dynamic Proxy | 인터페이스 구현 시 | 인터페이스 기반 호출 |
| CGLIB | 인터페이스 없을 시 | 클래스 상속 기반 |
Spring Boot 2.x부터는 인터페이스 유무와 관계없이 CGLIB를 기본으로 사용합니다.
이 프록시 기반 동작 방식이 @Transactional의 가장 중요한 특성입니다.
@Transactional을 붙였는데도 트랜잭션이 동작하지 않는 대부분의 경우는, 호출이 프록시를 거치지 않았기 때문입니다.
기본 롤백 정책
@Transactional의 기본 롤백 정책은 다음과 같습니다.
| 예외 종류 | 롤백 여부 |
| RuntimeException 및 하위 | ✅ 롤백 |
| Error 및 하위 | ✅ 롤백 |
| Exception 및 하위 (체크 예외) | ❌ 롤백 안 됨 |
체크 예외가 롤백되지 않는 이유는, Spring이 체크 예외를 "복구 가능한 비즈니스 예외"로 간주하기 때문입니다.
롤백 정책 변경
@Transactional(rollbackFor = Exception.class) // 모든 예외에 롤백
public void register(UserDto dto) throws IOException { ... }
@Transactional(noRollbackFor = MailSendException.class) // 특정 예외만 제외
public void notify(...) { ... }
체크 예외도 롤백되기를 원한다면 rollbackFor를 명시해야 합니다.
@Transactional의 특징
| 특징 | 설명 |
| 선언적 관리 | 어노테이션 한 줄로 트랜잭션을 시작/커밋/롤백 |
| AOP 프록시 기반 | 내부적으로 Spring AOP 프록시가 호출을 가로채 트랜잭션 처리 |
| 다양한 적용 위치 | 메서드, 클래스, 인터페이스 레벨 모두 적용 가능 |
| 세밀한 제어 옵션 | propagation, isolation, readOnly, timeout, rollbackFor 등 다양한 속성 지원 |
| 기본 롤백 정책 존재 | RuntimeException과 Error만 자동 롤백, 체크 예외는 별도 지정 필요 |
선언적이라는 점과 AOP 프록시로 동작한다는 점이 가장 중요한 특징입니다.
@Transactional의 장점
| 장점 | 설명 |
| 코드 분리 | 비즈니스 로직과 트랜잭션 코드를 깔끔하게 분리 |
| 보일러플레이트 제거 | try-catch-finally, commit/rollback 코드가 사라짐 |
| 휴먼 에러 감소 | commit(), rollback(), close() 누락 가능성 차단 |
| 트랜잭션 전파 자동 처리 | Connection을 메서드 간에 직접 들고 다니지 않아도 됨 |
| 세밀한 제어 | readOnly, isolation, timeout 등으로 상황에 맞게 조정 가능 |
비즈니스 로직만 보이도록 만들어준다는 점이 가장 큰 장점입니다.
@Transactional의 단점
쉬워 보이는 만큼 주의할 점도 많습니다.
| 단점 | 설명 |
| 프록시 미경유 시 무시됨 | self-invocation, private/final 메서드 등에서는 트랜잭션이 적용되지 않음 |
| 직관과 다른 롤백 정책 | 체크 예외는 기본적으로 롤백되지 않음 |
| 적용 범위 파악의 어려움 | 클래스/메서드 레벨 우선순위, 전파 옵션이 얽혀 코드만 보고 동작 예측이 어려움 |
| 커넥션 점유 문제 | 트랜잭션 안에 외부 API 호출이나 긴 작업을 두면 DB 커넥션 풀 고갈 위험 |
| 디버깅의 복잡함 | 호출 스택에 프록시·AOP 인터셉터가 끼어들어 추적이 까다로움 |
여기서 말하는 "단점"은 어노테이션의 결함이라기보다, 사용법이 쉬운 만큼 모르고 쓰면 의도와 다르게 동작하기 쉬운 특성에 가깝습니다.
마무리 정리
이번 글에서는 @Transactional의 개념과 동작 원리를 정리했습니다. 핵심을 다시 짚어보면 다음과 같습니다.
- 트랜잭션은 하나의 논리적 작업 단위이며 ACID를 보장한다.
- @Transactional은 트랜잭션 관리를 선언적으로 처리해 비즈니스 로직과 분리해준다.
- 내부적으로 Spring AOP 프록시가 메서드 호출을 감싸 트랜잭션을 시작·커밋·롤백한다.
- 기본 롤백 정책은 RuntimeException과 Error만 해당된다.
@Transactional은 사용법이 단순한 만큼 함정도 많은 어노테이션입니다.
그 함정들의 대부분은 위에서 살펴본 프록시 기반 동작 방식과 기본 롤백 정책 두 가지에서 비롯됩니다.
이를 염두에 두면 실무에서 마주치는 대부분의 트랜잭션 이슈를 빠르게 진단할 수 있습니다.
읽어주셔서 감사합니다.
참조
- https://docs.spring.io/spring-framework/docs/5.3.x/reference/html/data-access.html#transaction-declarative
- https://docs.spring.io/spring-framework/docs/5.3.x/reference/html/data-access.html#tx-decl-explained
- https://docs.spring.io/spring-framework/docs/5.3.x/reference/html/core.html#aop-understanding-aop-proxies
'Framework > Spring' 카테고리의 다른 글
| [Spring] Spring Security 인증 처리 (0) | 2026.05.31 |
|---|---|
| [Spring] Spring Security (0) | 2026.05.30 |
| [Spring] @Transactional의 8가지 함정 (0) | 2026.05.24 |