트랜잭션은 하나의 작업 단위로 묶인 여러 데이터베이스 작업들을 말한다.
논리적인 이유로 여러 SQL이 단일 작업으로 묶여서 나눠질 수 없게 만든 것 !
하나의 작업 단위로 묶여있기 때문에
트랜잭션 내 SQL문들은 모두 성공하는 경우 commit되고,
하나라도 실패하는 경우에는 전체가 rollback된다.
ACID 속성
1. 원자성(Atomicity)
내부적으로 어떻게 보장될까 ?
2. 일관성(Consistency)
3. 격리성(Isolation)
4. 지속성(Durability)
내부적으로 어떻게 보장될까 ?
트랜잭션 설정이 성능에 영향을 미치는 이유에는 잘못된 트랜잭션 범위와 불필요한 트랜잭션 기능이 있다.
우선 잘못된 트랜잭션 범위가 성능에 영향을 주는 이유는
트랜잭션을 단순히 데이터를 묶는 기능이 아니라
DB Connection을 오래 잡고 있는 상태라고 보면 되는데
범위를 넓게 잡을 경우 아래와 문제가 생긴다.
(1) DB 커넥션을 너무 오래 점유한다.
(2) 쓸데없이 영속성 컨텍스트가 길게 유지된다.
(3) 트랜잭션 격리 수준이 영향을 더 크게 준다.
두번째로는 불필요한 트랜잭션 기능이 성능에 영향을 주는 이유에 대해 살펴보겠다.
스프링 트랜잭션은 기능이 많다.
하지만 “기능이 많다 = 오버헤드가 발생한다”는 의미도 있다.
(1) readOnly=false 를 쓸 필요 없는 곳에서 사용
(2) 불필요한 nested 트랜잭션 (REQUIRES_NEW 등) 사용
일반 @Transactional의 내부 동작
Spring의 @Transactional은 AOP(Aspect-Oriented Programming)를 기반으로 동작한다.
메서드 호출 시 프록시 객체가 중간에 개입해서 트랜잭션 처리를 담당하는 구조이다.
@Service
public class OrderService {
@Transactional
public void createOrder(OrderRequest request) {
// 비즈니스 로직
}
}
위 코드에서 실제로 OrderService의 프록시 객체가 생성된다.
프록시는 다음과 같은 순서로 동작한다:
1. 같은 클래스 내부 호출 문제
@Service
public class UserService {
public void outerMethod() {
this.innerMethod(); // 프록시를 거치지 않음!
}
@Transactional
public void innerMethod() {
// 트랜잭션이 적용되지 않음
}
}
this로 호출하면 프록시를 거치지 않아서 @Transactional이 무시된다.
이 경우 별도의 클래스로 분리하거나 self-injection을 사용해야 한다.
2. private 메서드 문제
@Transactional
private void saveData() {} // 작동하지 않음
프록시는 public 메서드만 가로챌 수 있다.
private 메서드에는 @Transactional을 붙여도 의미가 없다.
3. 트랜잭션 전파 옵션
@Transactional은 여러 옵션을 제공하는데, 가장 중요한 게 전파(Propagation) 설정이다.
@Transactional(propagation = Propagation.REQUIRED) // 기본값
public void method1() {
method2(); // 같은 트랜잭션 사용
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void method2() {
// 새로운 트랜잭션 생성
}
트랜잭션 범위가 중요한 이유
1. 데이터 정합성 보장
트랜잭션 범위가 제대로 설정되지 않으면 데이터 불일치가 발생한다.
// 나쁜 예: 트랜잭션 범위가 너무 좁음
public void processOrder(Long orderId) {
updateOrderStatus(orderId); // 트랜잭션 1
decreaseStock(orderId); // 트랜잭션 2
createPayment(orderId); // 트랜잭션 3
}
각 메서드가 독립적인 트랜잭션이면, 재고 감소는 성공했는데 결제 생성이 실패하는 경우 데이터가 꼬인다.
// 좋은 예: 하나의 트랜잭션으로 묶음
@Transactional
public void processOrder(Long orderId) {
updateOrderStatus(orderId);
decreaseStock(orderId);
createPayment(orderId);
// 모두 성공하거나 모두 실패
}
2. 영속성 컨텍스트 범위와 일치
JPA의 영속성 컨텍스트는 트랜잭션과 생명주기를 같이한다.
@Transactional
public void updateUser(Long userId, String name) {
User user = userRepository.findById(userId).get();
user.changeName(name); // dirty checking으로 자동 업데이트
// 트랜잭션 종료 시 변경사항 자동 반영
}
트랜잭션 범위 내에서 조회한 엔티티는 변경 감지(dirty checking)가 가능하다. 트랜잭션이 없으면 LazyInitializationException이 발생할 수 있다.
3. 성능 최적화 포인트
트랜잭션이 너무 길면 DB 커넥션을 오래 점유해서 성능이 떨어진다.
// 안 좋은 예: 외부 API 호출까지 트랜잭션에 포함
@Transactional
public void processPayment(PaymentRequest request) {
Payment payment = createPayment(request);
paymentRepository.save(payment);
// 외부 API 호출 - 3초 소요
externalPaymentGateway.process(payment);
payment.complete();
} // 3초 이상 DB 커넥션 점유
// 개선: 트랜잭션 범위 최소화
public void processPayment(PaymentRequest request) {
Payment payment = createAndSavePayment(request);
// 트랜잭션 밖에서 외부 API 호출
externalPaymentGateway.process(payment);
completePayment(payment.getId());
}
@Transactional
public Payment createAndSavePayment(PaymentRequest request) {
Payment payment = createPayment(request);
return paymentRepository.save(payment);
}
@Transactional
public void completePayment(Long paymentId) {
Payment payment = paymentRepository.findById(paymentId).get();
payment.complete();
}
4. 격리 수준에 따른 동시성 제어
트랜잭션 격리 수준(Isolation Level)은 동시성과 정합성의 트레이드오프다.
@Transactional(isolation = Isolation.READ_COMMITTED) // 기본값
public void method1() { }
@Transactional(isolation = Isolation.SERIALIZABLE)
public void criticalOperation() {
// 가장 엄격한 격리, 성능은 낮지만 정합성 보장
}
실무에서는 대부분 READ_COMMITTED를 사용하고, 필요한 경우 비관적/낙관적 락으로 추가 제어한다.
결국 @Transactional의 핵심은 '어디서부터 어디까지를 하나의 작업 단위로 볼 것인가'를 명확히 정의하는 것이다.
이게 제대로 설정되지 않으면 데이터 정합성 문제부터 성능 저하까지 다양한 이슈가 발생한다.
프록시 기반이라는 동작 원리를 이해하면 왜 특정 상황에서 트랜잭션이 작동하지 않는지도 파악할 수 있다.