
데이터베이스를 공부하면서 다들 트랜잭션에 대해 들어보셨을겁니다. 엄청 중요하대서 열심히 배웠지만 실제 개발에서는 Service 클래스에 @Transactional 로 트랜잭션을 “관리” 중이실거에요. 저도 마찬가지입니다.
그런데 개발을 하다 보면 문득 이런 의문이 들곤 합니다.
"트랜잭션이 자동으로 적용된다는 점은 편리하지만, 내부적으로 어떻게 동작하는지 정확히 알지 못한 채 사용해도 괜찮을까?"
만약 계좌 관리 서비스를 개발할 때 트랜잭션의 원리를 제대로 모르고 개발해 다음과 같은 상황이 발생한다면??? 아주 아찔합니다.

그래서 오늘은 Spring의 @Transactional 에 대해 자세히 알아보겠습니다.
우선 Transaction에 대해 다시 한번 살펴봅시다.
Transaction은 DB의 논리적 작업 단위 입니다. DB의 여러 쿼리가 하나의 Transaction으로 묶여 실행되며 전체가 성공해야 DB에 반영됩니다. 하나라도 실패하면 전체 작업이 취소(롤백) 됩니다.
이런 Transaction은 ACID 원칙을 만족해야합니다.
Spring 공식 docs에서는 Spring의 Transanction이 왜 좋은지 이전에 사용하던 방식을 통해 반증하고 있습니다.
Connection conn = dataSource.getConnection()
try(connection) {
connection.setAutoCommit(false); // 오토커밋 X
// 로직 수행
connection.commit(); // 수동 커밋
}catch(SQLException){
connection.rollback(); // 에러 발생 시 롤백
}finally{
connection.close();
}
구와악 나의 Service가!!!!!!!!!!!!!!!! 라는 생각이 드는 코드입니다.
문제점은 다음과 같은데
Connection 객체 생성 후DAO 호출할 때 마다 사용// 이런 느낌?
orderRepository.createOrder(connection, order);
paymentRepository.chargePayment(connection, orderId);
JDBC에 의존적 ➡️ MyBatis, JPA, Hibernate 등이 되면?
서비스 로직에 DB 접근 코드가 섞임
이런 문제를 Spring의 Transaction이 해결해줍니다.
1️⃣ Connection 객체 생성 후DAO 호출할 때 마다 사용
트랜잭션 동기화로 해결
DataSourceTransactionManager가 트랜잭션 시작하면서 Connection 생성Connection을 TransactionSynchronizationManager에 등록 후 스레드별로 공유Connection 사용 및 트랜잭션 완료 시 해당 리소스 정리2️⃣ JDBC에 의존적 ➡️ MyBatis, JPA, Hibernate 등이 되면?
트랜잭션 추상화로 기술 종속성 해결

PlatformTransactionManager.java
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
Spring은 Transaction을 다양한 방식으로 관리할 수 있도록 추상화를 통해 구현하였습니다. JPA를 자주 사용했기에 EntityManager를 알고 있었는데 JDBC, JTA 등 다른 트랜잭션 관리 전략을 사용 시 표와 같이 다양한 방법이 존재합니다.
| 전략 | 설명 | 예제 |
|---|---|---|
JDBC 트랜잭션 관리 (DataSourceTransactionManager) | 기본적인 JDBC 환경에서 트랜잭션을 관리 | Connection 객체를 활용한 수동 트랜잭션 처리 |
JTA (Java Transaction API) 트랜잭션 관리 (JtaTransactionManager) | 여러 데이터 소스를 사용하는 분산 트랜잭션 관리 | XA 트랜잭션 (2PC) 사용 |
Hibernate 트랜잭션 관리 (HibernateTransactionManager) | Hibernate를 직접 사용할 때 트랜잭션 관리 | SessionFactory 활용 |
JPA 트랜잭션 관리 (JpaTransactionManager) | Spring Data JPA와 함께 사용되는 트랜잭션 관리 | EntityManager 사용 |
선언적 트랜잭션 관리 (@Transactional) | AOP 기반으로 선언적으로 트랜잭션을 관리 | @Transactional 적용 |
3️⃣ 서비스 로직에 DB 접근 코드가 섞임
이 자식을 해결하기 위해 AOP를 활용한 @Transactional이 나오게 됩니다.
✅ 선언적 Transaction
@Service
public class OrderService {
@Transactional
public void processOrder(Long orderId) {
Order order = orderRepository.findById(orderId);
order.updateStatus("SHIPPED");
paymentService.processPayment(order.getPaymentId());
}
}
흔히 사용하는 방식으로 @Transactional 어노테이션 선언만으로 트랜잭션 관리가 가능해집니다.

AOP를 활용한 @Transactional 동작 원리
TransactionAdvisor 로 요청을 전달TransactionInterceptor 가 동작하여 트랜잭션을 시작@Transactional 이외에 사용자가 정의한 AOP 인터셉터(Custom Interceptor)가 있을 경우 추가적으로 실행 가능✅ 프로그래밍 Transaction
TransactionTemplate 또는 PlatformTransactionManager를 직접 사용하여 트랜잭션을 수동 관리하는 방식@Service
public class OrderService {
private final PlatformTransactionManager transactionManager;
public OrderService(PlatformTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
public void processOrder(Long orderId) {
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
Order order = orderRepository.findById(orderId);
order.updateStatus("SHIPPED");
paymentService.processPayment(order.getPaymentId());
transactionManager.commit(status); // 성공 시 commit
} catch (Exception e) {
transactionManager.rollback(status); // 예외 발생 시 rollback
throw e;
}
}
}
기존 코드에서 단 하나의 어노테이션으로 만들어지기까지의 과정을 살펴봤습니다. 얘기를 하다보니 스프링 어노테이션 개쩐다는 생각뿐인데 아무 생각 없이 사용해도 되는걸까요? 이제 @Transactional을 더 자세히 알아보겠습니다.
Transaction Propagation
현재 실행 중인 트랜잭션이 있을 때, 새로운 트랜잭션을 생성할지, 기존 트랜잭션을 사용할지를 결정하는 정책
스프링의 @Transactional의 장점 중 하나는 트랜잭션의 경계를 설정할 수 있다는 것 입니다. 하지만 기존 진행 중인 트랜잭션이 있을 때 추가 트랜잭션 진행을 어떻게 할까요?

이런 이미지가 되겠죠? 이미 진행 중인 트랜잭션이 있는데 새로운 트랜잭션을 만나는 경우입니다. 이를 코드로 보면 아래와 같습니다.
@Service
public class OrderService {
@Transactional
public void placeOrder(Long orderId) {
orderRepository.createOrder(orderId);
paymentService.processPayment(orderId); // 내부 트랜잭션 호출
}
}
@Service
public class PaymentService {
@Transactional(propagation = Propagation.REQUIRED)
public void processPayment(Long orderId) {
paymentRepository.charge(orderId);
}
}
placeOrder()를 호출하면 트랜잭션이 하나 (외부) 실행되는 중에 paymentService()의 트랜잭션 (내부)을 하나 더 실행해야합니다.
이런 경우를 다루기 위해 스프링은 논리 트랜잭션이라는 개념을 도입합니다.
이러면 트랜잭션 범위가 2개라 개별 논리 트랜잭션이 있지만 실제로는 1개의 물리 트랜잭션(Connection 객체를 하나만 사용)을 사용하게 되는것이죠.
이 개념의 도입으로 내부 트랜잭션 로직에 대해 좀 더 단순하게 다가갈 수 있게 됩니다.
이제 Spring이 제공하는 Propagation 속성 종류를 알아보겠습니다.
| 전파 레벨 | 설명 |
|---|---|
REQUIRED (기본값) | 기존 트랜잭션이 있으면 참여, 없으면 새 트랜잭션 생성 |
REQUIRES_NEW | 항상 새로운 트랜잭션을 생성, 기존 트랜잭션은 보류 |
NESTED | 부모 트랜잭션 내에서 중첩된 트랜잭션 실행 (savepoint 사용) |
SUPPORTS | 트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행 |
NOT_SUPPORTED | 트랜잭션 없이 실행, 기존 트랜잭션이 있으면 일시 정지 |
MANDATORY | 기존 트랜잭션이 반드시 있어야 실행 가능, 없으면 예외 발생 |
NEVER | 트랜잭션이 있으면 예외 발생, 없으면 트랜잭션 없이 실행 |
7가지나..있다! 각 레벨을 예제를 통해 살펴봅시다.
1. REQUIRED : 기본값
@Service
public class OrderService {
@Transactional(propagation = Propagation.REQUIRED) // 기본값
public void placeOrder(Long orderId) {
paymentService.processPayment(orderId);
}
}
@Service
public class PaymentService {
@Transactional(propagation = Propagation.REQUIRED)
public void processPayment(Long orderId) {
paymentRepository.charge(orderId);
throw new RuntimeException("결제 실패!"); // 예외 발생
}
}
➡️ placeOrder() 실행 시 트랜잭션 시작
➡️ processPayment()도 같은 트랜잭션을 사용, 예외 발생 시 전체 롤백
2. REQUIRES_NEW
@Service
public class OrderService {
@Transactional
public void placeOrder(Long orderId) {
orderRepository.createOrder(orderId);
paymentService.processPayment(orderId); // 새로운 트랜잭션 실행
}
}
@Service
public class PaymentService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processPayment(Long orderId) {
paymentRepository.charge(orderId);
throw new RuntimeException("결제 실패!"); // 예외 발생
}
}
➡️ placeOrder() 실행 시 트랜잭션 시작
➡️ processPayment() 새로 트랜잭션 시작
➡️ processPayment() 예외 발생 시 이 트랜잭션만 롤백
데이터 불일치가 발생할 수 있습니다(예외 발생하면 그 트랜잭션만 롤백되고 기존의 트랜잭션(placeOrder())은 커밋되기 때문). 그럼 왜 사용하느냐? 결제 시스템처럼 독립적인 트랜잭션이 필요하기 때문에 사용됩니다.
3. NESTED
@Service
public class OrderService {
@Transactional
public void placeOrder(Long orderId) {
orderRepository.createOrder(orderId);
paymentService.processPayment(orderId);
}
}
@Service
public class PaymentService {
@Transactional(propagation = Propagation.NESTED)
public void processPayment(Long orderId) {
paymentRepository.charge(orderId);
throw new RuntimeException("결제 실패!"); // 예외 발생
}
}
➡️ placeOrder() 부모 트랜잭션 시작
➡️ processPayment() 부모 트랜잭션 내에서 중첩 트랜잭션 실행 : savepoint 생성
➡️ processPayment() 예외 발생 시 이 트랜잭션만 롤백
부모 트랜잭션이 롤백되지 않도록 특정 작업만 롤백하고 싶을 때 사용합니다.
4. SUPPORTS
@Service
public class OrderService {
@Transactional
public void placeOrder(Long orderId) {
notificationService.sendEmail(orderId); // 트랜잭션 없이 실행
}
}
@Service
public class NotificationService {
@Transactional(propagation = Propagation.SUPPORTS)
public void sendEmail(Long orderId) {
emailSender.send(orderId);
}
}
➡️ placeOrder() 실행 시 트랜잭션 시작
➡️ processPayment() 기존에 트랜잭션 있으므로 참여 (만약 placeOrder가 없었어도 (== 트랜잭션이 없이) 실행)
알림 시스템처럼 트랜잭션 여부와 상관없이 실행될 서비스에 사용합니다.
5. NOT_SUPPORTED
@Service
public class OrderService {
@Transactional
public void placeOrder(Long orderId) {
reportService.generateReport(orderId); // 트랜잭션 없이 실행
}
}
@Service
public class ReportService {
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void generateReport(Long orderId) {
reportGenerator.generate(orderId);
}
}
➡️ placeOrder() 실행 시 트랜잭션 시작
➡️ generateReport() 실행 시 트랜잭션 일시 정지 후 트랜잭션 없이 실행
트랜잭션이 필요 없는 배치 작업, 외부 API 호출 등에 사용
6. MANDATORY
IllegalTransactionStateException 발생@Service
public class OrderService {
@Transactional
public void placeOrder(Long orderId) {
loggingService.logOrder(orderId); // 트랜잭션을 유지해야 실행 가능
}
}
@Service
public class LoggingService {
@Transactional(propagation = Propagation.MANDATORY)
public void logOrder(Long orderId) {
logRepository.save(orderId);
}
}
➡️ placeOrder() 실행 시 트랜잭션 시작
➡️ logOrder() 실행 시 기존 트랜잭션 있으면 정상 실행, 트랜잭션 없이 실행되면 예외 발생
반드시 트랜잭션 내에서 실행해야 하는 로직에 적합
7. NEVER
IllegalTransactionStateException 발생@Service
public class ExternalService {
@Transactional(propagation = Propagation.NEVER)
public void callExternalApi() {
apiClient.call();
}
}
➡️ callExternalApi() 실행 시 트랜잭션 없어야 실행 가능
➡️ callExternalApi() 실행 시 트랜잭션 있으면 예외 발생
외부 API 호출처럼 트랜잭션이 있으면 안될때 사용
Transaction Isolation
트랜잭션이 동시 실행될 때 데이터 정합성을 보장하기 위해 특정 수준에서 고립 시키는 방법

DB를 공부하면서 한번쯤 마주쳤을 그 "격리 수준" 맞습니다.
트랜잭션이 동시에 실행될 때 어떤 데이터를 읽고 쓸 수 있는지를 제어하는 것을 격리 수준을 설정해준다 말합니다.
격리 수준과 데이터 정합성은 비례하고, 성능과는 반비례합니다.
이 내용은 기회가 된다면 다른 글에서 다루겠습니다
Spring에서 제공하는 @Transactional의 격리 수준은 다음과 같습니다.
| 격리 수준 | 설명 | 문제 해결 |
|---|---|---|
DEFAULT | 데이터베이스 기본 격리 수준 사용 | DB 설정에 따름 |
READ_UNCOMMITTED | 커밋되지 않은 데이터 읽기 허용 | Dirty Read 방지 X |
READ_COMMITTED | 커밋된 데이터만 읽기 허용 | Dirty Read 방지 O |
REPEATABLE_READ | 트랜잭션 내 동일한 조회 결과 보장 | Non-repeatable Read 방지 O |
SERIALIZABLE | 완전한 직렬화 (동시 실행 차단) | 모든 문제 방지 O |
1. READ_UNCOMMITTED
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public void readUncommittedExample() {
List<Order> orders = orderRepository.findAll();
}
💣시나리오
1. A 트랜잭션이 특정 행 업데이트했지만 커밋 X
2. B 트랜잭션이 해당 행 조회 시 A가 커밋하기 전 데이터 읽음
3. A 트랜잭션 롤백 시 B가 읽은 데이터는 잘못된 값 (Dirty Read)
데이터 정합성 중요한 서비스에서 사용 절대 금지
2. READ_COMMITTED
@Transactional(isolation = Isolation.READ_COMMITTED)
public void readCommittedExample() {
List<Order> orders = orderRepository.findAll();
}
💣시나리오
1. A 트랜잭션이 특정 행 업데이트, 커밋 전까지는 다른 트랜잭션에서 조회 불가능
2. B 트랜잭션이 해당 데이터 조회 시 A 트랜잭션이 커밋한 데이터만 읽기 가능
3. A 트랜잭션이 커밋 후 다시 조회하면 값이 변경될 수 있음 (Non-repeatable Read)
대부분 RDBMS의 기본값 (Oracle, PostgreSQL, SQL Server...)
3. REPEATABLE_READ
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void repeatableReadExample() {
List<Order> orders = orderRepository.findAll();
}
💣시나리오
1. A 트랜잭션이 특정 데이터 조회
2. B 트랜잭션이 해당 데이터 변경 후 커밋
3. A 트랜잭션이 다시 조회 시 변경된 데이터 반영 X (Non-Repeatable Read 방지)
4. 하지만, B 트랜잭션이 새로운 데이터를 INSERT 하면 A 트랜잭션에서 조회 시 반영 가능 (Phantom Read 발생)
같은 데이터를 여러 번 조회해도 일관된 결과 보장
4. SERIALIZABLE
@Transactional(isolation = Isolation.SERIALIZABLE)
public void serializableExample() {
List<Order> orders = orderRepository.findAll();
}
💣시나리오
1. A 트랜잭션 데이터 조회할 때 B 트랜잭션이 동일 데이터 NSERT/UPDATE/DELETE 불가능
2. A 트랜잭션 완료 후 B 트랜잭션 실행 가능
강력한 격리 수준, 미쳐버린 성능 저하
➡️ 실무에서 사용 X
여기까지 달려오신 모든 분들 고생하셨습니다. 이제 진짜 찐막 @Transactional의 한계에 대해 알아봅시다!
@Service
public class OrderService {
@Transactional
public void placeOrder(Long orderId) {
orderRepository.createOrder(orderId);
processPayment(orderId); // 내부 호출
}
@Transactional
public void processPayment(Long orderId) {
paymentRepository.charge(orderId);
}
}
바로 코드부터 봅시다.
1. placeOrder() 호출 : 트랜잭션 시작
2. processPayment() 내부에서 직접 호출
3. processPayment()는 기존 트랜잭션을 사용 ➡️ WHY????
이 코드에서 왜 내부 호출된 processPayment의 @Transactional이 무시될까요?
➡️AOP 기반의 어노테이션
@Transactional은 AOP 기반으로 동작하기 때문에 프록시 객체를 통해 관리됩니다. 하지만 같은 클래스 내부에서 메서드를 직접 호출 시(self-invocation) 프록시를 거치지 않기 때문에 @Transactional이 적용되지 않습니다. (내부에서 this.processPayment로 불러진다 생각하면 이해하기 편합니다!)
이 문제를 어떻게 해결할 수 있을까요?
@Service
public class OrderService {
private final OrderService self;
// 생성자 주입
public OrderService(OrderService self) {
this.self = self;
}
@Transactional
public void placeOrder(Long orderId) {
orderRepository.createOrder(orderId);
self.processPayment(orderId); // 자기 자신을 통해 호출
}
@Transactional
public void processPayment(Long orderId) {
paymentRepository.charge(orderId);
}
}
이제 processPayment()가 self 객체를 통해 호출되고, 그 과정에서 프록시를 거치기 때문에 @Transactional이 정상 적용됩니다.
하지만 순환 참조 문제가 발생할 가능성이 있고, 코드 가독성을 매우....엄청....매우엄청많이....떨어뜨립니다.
// 기존 서비스
@Service
public class OrderService {
private final PaymentService paymentService;
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
@Transactional
public void placeOrder(Long orderId) {
orderRepository.createOrder(orderId);
paymentService.processPayment(orderId); // 다른 클래스의 메서드를 호출
}
}
// 분리한 서비스
@Service
public class PaymentService {
@Transactional
public void processPayment(Long orderId) {
paymentRepository.charge(orderId);
}
}
이제 processPayment()는 인스턴스를 통해 호출되기 때문에 프록시를 거쳐 @Transactional이 정상 적용됩니다.

자바에서는 예외 처리를 크게 Checked Exception과 Unchecked Exception으로 나누는데 @Transactional은 Unckecked Exception만 자동으로 롤백 합니다.
@Service
public class PaymentService {
@Transactional
public void processPayment(Long orderId) throws Exception { // Checked Exception
paymentRepository.charge(orderId);
throw new Exception("결제 실패!"); // ❌ 롤백되지 않음
}
}
이 코드에서 Exception은 Checked Exception이라 트랜잭션 중 예외가 발생해도 롤백되지 않습니다!!!! why? 과거의 스프링 트랜잭션 관리 정책이었던 EJB의 정책을 그대로 답습했기 때문....
하지만 물론 해결책은 있습니다! 속성 중 rollbackFor = 예외.class를 작성해주면 해결할 수 있습니다.
@Service
public class PaymentService {
@Transactional(rollbackFor = Exception.class)
public void processPayment(Long orderId) throws Exception {
paymentRepository.charge(orderId);
throw new Exception("결제 실패!"); // ✅ 롤백됨
}
}
private,protected 사용 불가능같은 클래스 안에서 내부 메서드 호출 시 @Transactional이 적용 안되는 것과 똑같은 예제입니다.
CGLIB은 상속 기반이기 때문에 private, protected 메서드는 AOP에 적용되지 않습니다 ➡️ @Transactional 적용이 안됨
@Service
public class OrderService {
@Transactional
public void placeOrder(Long orderId) {
orderRepository.createOrder(orderId);
// 여기서 DB에 즉시 반영하고 싶은 경우 불가능
paymentService.processPayment(orderId);
}
}
트랜잭션은 시작 후 메서드 종료될 때 트랜잭션이 커밋 or 롤백 되기 때문에 중간에 커밋되지 않습니다. 이런 경우 JPA에서는 flush를 사용합니다. ➡️ 관련 내용은 영속성 컨텍스트를 참고해주세요!
@Transactional
public void placeOrder(Long orderId) {
orderRepository.createOrder(orderId);
entityManager.flush(); // 즉시 반영
paymentService.processPayment(orderId);
}
@Transactional(readOnly = true)
public void updateOrder(Long orderId) {
Order order = orderRepository.findById(orderId);
order.setStatus("SHIPPED"); // 변경됨
}
@Transactional의 속성 중 readOnly 속성을 사용하면 트랜잭션을 읽기 전용으로 설정할 수 있지만 JPA에서는 일부 상황에서 데이터 변경이 가능합니다.
그 이유는 영속성 컨텍스트가 변경을 감지하기 때문입니다.
엄청..엄청난 분들이 논의를 하신 내용입니다. 토비님과 재민님의 토론 내용인데 매우 재밌어요. 궁금하신 분들은 한번씩 찾아보세요!
후에 이 글을 다시 읽을 때 제 의견을 살짝 첨언해보겠습니다.
제미니의 개발실무 - 테스트에서 @Transactional 을 사용해야 할까?
토비님 Facebook 게시글
테스트 데이터 초기화에 대한 다른 분 - 향로님 의 생각

뭔가 엄청 긴 글을 작성하게 되었는데요? @Transactional에 대해 많은 지식을 얻어가시길 바랍니다. 저도 글 쓰면서 엄청 많이 배웠어요. AOP 관련 공부를 최근에 해서 이해하는데 많은 도움이 됐습니다.
스프링 진짜 잘만들었네요. 선배 개발자님들은 왤케 멋질까.
그럼 긴 글 읽어주셔서 감사합니다.
글 잘읽었습니다. Transactional 알고 쓰는 것과 모르고 쓰는 건 정말 차이가 많이 나는군요.
이제 의미 없이 Transactional 남발 보다는 생각하고 써보겠습니다.