트랜잭션(Transaction)은 데이터베이스에서 하나의 논리적인 작업 단위를 의미한다.
여러 개의 DB 작업을 하나로 묶어서
전부 성공하면 저장하고,
하나라도 실패하면 전부 취소하는 것 .
대표적인 예시는 계좌이체로 이 두 작업은 반드시 함께 성공해야 한다.
철수 계좌에서 10,000원 차감
영희 계좌에 10,000원 추가
만약 철수 계좌에서는 돈이 빠져나갔는데, 영희 계좌에 돈이 들어가지 않는다면 큰 문제가 된다.
그래서 트랜잭션은 중간에 하나라도 실패하면 전체 작업을 취소하고 원래 상태로 되돌린다.
이를 Rollback이라고 한다.
반대로 모든 작업이 정상적으로 끝나면 변경 내용을 확정한다.
이를 Commit이라고 한다.
트랜잭션은 데이터의 안정성을 보장하기 위해 ACID 원칙을 따른다.
| 원칙 | 의미 | 예시 |
|---|---|---|
Atomicity, 원자성 | 전부 성공하거나 전부 실패해야 한다 | 송금 중 오류가 나면 전체 롤백 |
Consistency, 일관성 | 트랜잭션 전후 데이터 규칙이 유지되어야 한다 | 잔액은 음수가 되면 안 됨 |
Isolation, 격리성 | 동시에 실행되는 트랜잭션끼리 영향을 최소화해야 한다 | 다른 트랜잭션의 미완료 데이터를 함부로 읽지 않음 |
Durability, 지속성 | 커밋된 데이터는 영구적으로 저장되어야 한다 | 서버가 꺼져도 커밋된 데이터는 유지 |
이 원칙 덕분에 데이터베이스는 여러 사용자가 동시에 접근하더라도 안정적으로 데이터를 관리할 수 있다.
Spring에서는 @Transactional을 사용하면 트랜잭션 처리를 직접 작성하지 않아도 된다.
@Service
public class OrderService {
@Transactional
public void createOrder(OrderRequest request) {
orderRepository.save(order);
paymentRepository.save(payment);
stockRepository.decrease(itemId);
}
}
위 코드에서 createOrder() 메서드가 실행되면 Spring은 다음과 같이 동작한다.
메서드 시작 전 → 트랜잭션 시작
메서드 정상 종료 → COMMIT
예외 발생 → ROLLBACK
즉, 주문 저장, 결제 저장, 재고 감소 중 하나라도 실패하면 전체 작업이 롤백된다.
이런 방식 덕분에 개발자는 비즈니스 로직에 집중할 수 있고, 트랜잭션 처리는 Spring에게 맡길 수 있다.
조회 전용 메서드에는 readOnly = true 옵션을 사용할 수 있다.
@Transactional(readOnly = true)
public UserDto findUser(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
return new UserDto(user);
}
조회만 하는 메서드에 사용하면 불필요한 변경 감지나 flush 부담을 줄이는 데 도움이 된다.
그래서 조회 메서드에는 가능하면 @Transactional(readOnly = true)를 붙이는 것이 좋다.
기본적으로 @Transactional은 RuntimeException과 Error가 발생했을 때 롤백한다.
하지만 IOException 같은 CheckedException은 기본적으로 롤백 대상이 아니다.
@Transactional
public void process() throws IOException {
// IOException 발생 시 기본 설정으로는 롤백되지 않을 수 있음
}
CheckedException도 롤백하고 싶다면 rollbackFor 옵션을 명시해야 한다.
@Transactional(rollbackFor = Exception.class)
public void process() throws IOException {
// Exception 계열 예외가 발생하면 롤백
}
또는 특정 예외만 지정할 수도 있다.
@Transactional(rollbackFor = CustomException.class)
public void process() throws CustomException {
throw new CustomException();
}
| 예외 종류 | 기본 롤백 여부 |
|---|---|
| RuntimeException | 롤백됨 |
| Error | 롤백됨 |
| Checked Exception | 기본적으로 롤백 안 됨 |
| Checked Exception + rollbackFor 지정 | 롤백됨 |
timeout은 트랜잭션 수행 시간이 너무 길어지는 것을 막기 위한 옵션이다.
@Transactional(timeout = 3)
public void longProcess() {
// 3초를 초과하면 롤백 대상
}
DB 작업이 지나치게 오래 걸리면 락이 오래 유지되고, 다른 요청에도 영향을 줄 수 있다.
이런 상황을 방지하기 위해 timeout을 설정할 수 있다.
트랜잭션 전파는 트랜잭션 안에서 다른 트랜잭션 메서드를 호출할 때 어떻게 동작할지 정하는 규칙이다.
@Transactional
public void parentMethod() {
childMethod();
}
이때 childMethod()는 기존 트랜잭션에 참여할 수도 있고, 새로운 트랜잭션을 만들 수도 있다.
| 옵션 | 동작 | 사용 상황 |
|---|---|---|
| REQUIRED | 기존 트랜잭션이 있으면 참여, 없으면 새로 생성 | 기본값 |
| REQUIRES_NEW | 항상 새로운 트랜잭션 생성 | 부모가 롤백돼도 별도로 커밋해야 할 때 |
| NESTED | 중첩 트랜잭션 생성 | 일부 작업만 롤백하고 싶을 때 |
| SUPPORTS | 트랜잭션이 있으면 참여, 없으면 없이 실행 | 조회 메서드 |
| NOT_SUPPORTED | 트랜잭션 없이 실행 | 트랜잭션이 불필요한 작업 |
| NEVER | 트랜잭션이 있으면 예외 발생 | 트랜잭션을 금지해야 할 때 |
| MANDATORY | 트랜잭션이 반드시 있어야 함 | 반드시 상위 트랜잭션 안에서만 실행되어야 할 때 |
가장 많이 사용되는 기본값은 REQUIRED이다.
@Transactional
public void createOrder() {
orderRepository.save(order);
}
주문 생성 중 예외가 발생하더라도 로그는 남겨야 하는 경우가 있다.
이때는 로그 저장을 별도의 트랜잭션으로 분리할 수 있다.
@Service
public class OrderService {
private final OrderLogService orderLogService;
@Transactional
public void createOrder() {
orderRepository.save(order);
orderLogService.saveLog();
throw new RuntimeException("주문 처리 실패");
}
}
@Service
public class OrderLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog() {
logRepository.save(log);
}
}
여기서 중요한 점은 saveLog()를 다른 서비스 클래스로 분리했다는 것이다.
같은 클래스 내부에서 this.saveLog()처럼 호출하면 프록시를 거치지 않기 때문에 REQUIRES_NEW가 적용되지 않을 수 있다.
격리 수준은 여러 트랜잭션이 동시에 실행될 때 서로 얼마나 엄격하게 분리할지를 정하는 설정이다.
동시에 실행되는 트랜잭션 사이에서는 다음과 같은 문제가 발생할 수 있다.
| 문제 | 설명 |
|---|---|
| Dirty Read | 커밋되지 않은 데이터를 다른 트랜잭션이 읽는 문제 |
| Non-Repeatable Read | 같은 데이터를 두 번 읽었는데 중간에 값이 바뀌는 문제 |
| Phantom Read | 같은 조건으로 조회했는데 중간에 없던 데이터가 생기거나 사라지는 문제 |
격리 수준은 보통 다음 4단계로 나뉜다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read | 성능 |
|---|---|---|---|---|
| READ_UNCOMMITTED | 발생 가능 | 발생 가능 | 발생 가능 | 가장 빠름 |
| READ_COMMITTED | 방지 | 발생 가능 | 발생 가능 | 빠름 |
| REPEATABLE_READ | 방지 | 방지 | 발생 가능 | 중간 |
| SERIALIZABLE | 방지 | 방지 | 방지 | 가장 느림 |
격리 수준은 높일수록 데이터 정합성은 강해지지만 성능은 떨어질 수 있다.
예를 들어 포인트 차감처럼 정확성이 중요한 로직은 격리 수준을 높게 가져갈 수 있다.
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void deductPoint(Long userId, int amount) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("사용자가 없습니다."));
if (user.getPoint() < amount) {
throw new IllegalArgumentException("포인트가 부족합니다.");
}
user.deductPoint(amount);
}
반대로 실시간 대시보드처럼 약간의 오차보다 속도가 중요한 경우라면 낮은 격리 수준을 고려할 수도 있다.
다만 실무에서는 격리 수준을 무작정 높이기보다 비관적 락, 낙관적 락, Redis 분산락 등을 함께 고려하는 경우가 많다.
@Transactional을 제대로 이해하려면 프록시를 알아야 한다.
프록시는 쉽게 말해 진짜 객체 앞에 서 있는 대리인이다.
Controller → Service를 직접 호출하는 것처럼 보이지만
실제로는
Controller → Service 프록시 → 실제 Service
흐름은 다음과 같다.
Controller
↓
OrderService 프록시
↓
트랜잭션 시작
↓
진짜 OrderService 메서드 실행
↓
성공하면 COMMIT
예외가 발생하면 ROLLBACK
즉, @Transactional은 메서드 내부에 트랜잭션 코드를 직접 넣는 방식이 아니다.
Spring이 프록시 객체를 만들어두고, 메서드 호출 앞뒤로 트랜잭션 시작과 종료 로직을 끼워 넣는 방식이다.
@Service
public class OrderService {
public void createOrder() {
this.processPayment();
}
@Transactional
public void processPayment() {
// 결제 처리
}
}
겉으로 보기에는 processPayment()에 @Transactional이 붙어 있으므로 트랜잭션이 동작할 것 같다.
하지만 실제로는 동작하지 않을 수 있다.
이유는 this.processPayment()가 프록시를 거치지 않고 진짜 객체 내부에서 직접 메서드를 호출하기 때문이다.
외부 호출:
Controller → 프록시 → 트랜잭션 시작 → 실제 메서드 실행
내부 호출:
실제 객체 this → 실제 메서드 직접 호출
@Transactional은 프록시를 통해 들어오는 호출에 적용된다.
따라서 같은 클래스 내부에서 this로 호출하면 프록시를 우회하게 된다.
해결 방법은 트랜잭션이 필요한 메서드를 별도의 서비스로 분리하는 것이다.
@Service
public class OrderService {
private final PaymentService paymentService;
public void createOrder() {
paymentService.processPayment();
}
}
@Service
public class PaymentService {
@Transactional
public void processPayment() {
// 결제 처리
}
}
이렇게 하면 OrderService에서 PaymentService를 호출할 때 Spring 프록시를 거치게 되고, 트랜잭션이 정상적으로 적용된다.
this.method() 형태의 내부 호출은 프록시를 거치지 않기 때문에 트랜잭션이 적용되지 않을 수 있다.
트랜잭션 경계를 명확히 나누고 싶다면 별도의 서비스 클래스로 분리하는 것이 좋다.
@Transactional
private void process() {
// 적용되지 않을 수 있음
}
프록시는 외부에서 호출 가능한 메서드를 감싸서 부가 기능을 적용한다.
따라서 private 메서드에 @Transactional을 붙이는 방식은 피하는 것이 좋다.
보통 트랜잭션은 서비스 계층의 public 메서드에 선언한다.
RuntimeException은 롤백되지만, Checked Exception은 롤백되지 않는다.@Transactional(rollbackFor = Exception.class)
public void process() throws Exception {
throw new Exception();
}readOnly = true를 고려하자@Transactional(readOnly = true)
public List<OrderResponse> findOrders() {
return orderRepository.findAll()
.stream()
.map(OrderResponse::new)
.toList();
} 읽기 전용 트랜잭션을 사용하면 코드의 의도도 명확해지고, JPA 사용 시 성능 최적화에도 도움이 될 수 있다.@Transactional은 단순히 DB 작업에 붙이는 어노테이션이 아니라,
Spring이 트랜잭션을 자동으로 관리해주는 핵심 기능이다.
| 개념 | 핵심 내용 |
|---|---|
| 트랜잭션 | 여러 DB 작업을 하나의 작업 단위로 묶는 것 |
| COMMIT | 모든 작업이 성공했을 때 DB에 반영 |
| ROLLBACK | 중간에 실패했을 때 전체 작업 취소 |
| readOnly | 조회 전용 트랜잭션 설정 |
| rollbackFor | 롤백할 예외 지정 |
| propagation | 트랜잭션 전파 방식 설정 |
| isolation | 트랜잭션 격리 수준 설정 |
| 프록시 | @Transactional을 실제로 동작하게 하는 핵심 구조 |
| 내부 호출 | 프록시를 거치지 않아 트랜잭션이 적용되지 않을 수 있음 |
이번에 공부하면서 가장 중요하다고 느낀 부분은 @Transactional이 프록시 기반으로 동작한다는 점이다.
단순히 어노테이션을 붙였다고 무조건 동작하는 것이 아니라, Spring이 만든 프록시를 통해 메서드가 호출되어야 트랜잭션 기능이 적용된다.
따라서 트랜잭션을 사용할 때는 다음 세 가지를 꼭 기억해야 한다.
1. 서비스 계층의 public 메서드에 적용하자.
2. 같은 클래스 내부 호출은 주의하자.
3. 예외 종류에 따른 롤백 여부를 확인하자.
@Transactional을 제대로 이해하면 데이터 정합성을 지키는 코드를 더 안정적으로 작성할 수 있다.
특히 주문, 결제, 재고, 포인트처럼 여러 DB 작업이 하나의 흐름으로 묶이는 기능에서는 트랜잭션 설계가 매우 중요하다.