주문 정보를 DB에 저장한 뒤 재고를 감소시키는 프로그램을 생각해보자. 주문 저장은 성공했는데 재고 변경에 실패하면 데이터가 모순될 수 있다. 이런 여러 변경을 성공하면 함께 확정하고 실패하면 함께 취소하도록 묶는 단위가 트랜잭션(Transaction)이다.
Spring은 @Transactional로 트랜잭션 경계를 선언하고, 실제 DB 기술의 차이는 추상화로 감춘다.
1. 주문 INSERT 성공
2. 재고 UPDATE 실패
3. 주문만 남아 있는 불일치 상태
트랜잭션으로 감싸면, 같은 트랜잭션의 DB 변경 작업이 함께 롤백될 수 있다. 단, 모든 외부 작업이 자동으로 되돌려지는 것은 아니다. 예를 들어 DB와 별개로 발송된 이메일이나 외부 결제 승인까지 @Transactional 하나로 취소되지는 않는다.
아래 예제는 orders 테이블과 inventory 테이블이 이미 준비되어 있다고 가정한다. 프로젝트에는 Spring JDBC와 실제로 사용하는 JDBC 드라이버가 필요하다. 스키마와 데이터 소스 설정은 환경에 맞춰 준비해야 한다.
package com.example.shop.order;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final JdbcTemplate jdbcTemplate;
public OrderService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public void place(long memberId, String sku, int quantity, int unitPrice) {
if (quantity <= 0 || unitPrice < 0) {
throw new IllegalArgumentException("잘못된 주문 수량 또는 가격");
}
int updated = jdbcTemplate.update(
"UPDATE inventory SET stock = stock - ? "
+ "WHERE sku = ? AND stock >= ?",
quantity, sku, quantity
);
if (updated != 1) {
throw new IllegalStateException("재고가 부족합니다.");
}
jdbcTemplate.update(
"INSERT INTO orders (member_id, sku, quantity, total_price) "
+ "VALUES (?, ?, ?, ?)",
memberId, sku, quantity, (long) quantity * unitPrice
);
}
}
첫 번째 UPDATE는 충분한 재고가 있을 때만 감소시키고, 변경한 행 수가 1이 아니면 예외를 발생시킨다. 뒤이어 주문 INSERT를 실행한다. 같은 데이터 소스·트랜잭션 매니저로 묶인 정상적인 트랜잭션 환경에서 INSERT가 실패하면 앞의 재고 변경도 롤백된다.
금액 계산은 예제의 이해를 위한 단순화다. 실제 서비스에서는 통화의 최소 단위와 오버플로, 정밀도 정책을 설계하고 검증해야 한다.
컨트롤러 → 트랜잭션 프록시 → 실제 OrderService
│
트랜잭션 시작/참여
↓
메서드 실행
↙ ↘
성공 예외
↓ ↓
COMMIT ROLLBACK (규칙에 따라)
@Transactional 애노테이션 자체가 데이터베이스 연결을 직접 관리하는 것이 아니다. 기본적으로 Spring은 AOP 프록시와 트랜잭션 인터셉터를 통해 트랜잭션을 제어한다.
이 과정에서 실제 트랜잭션 처리는 Spring의 PlatformTransactionManager 인터페이스에 추상화되어 있다. JDBC 기반이면 JDBC용 트랜잭션 매니저, JPA 기반이면 JpaTransactionManager 등을 사용할 수 있다. 즉 서비스는 보통 구체적인 매니저 구현체를 직접 호출하지 않는다.
이것이 Spring의 PSA(Portable Service Abstraction, 이식 가능한 서비스 추상화)가 발휘되는 지점이다. 구현체를 바꾸더라도 상위의 선언적 트랜잭션 코드가 동일한 형태를 유지할 수 있다. 물론 JDBC→JPA 이전에서 Repository 코드까지 무조건 변경이 사라지는 것은 아니다.
@Transactional의 기본 동작에 대한 오해1. 예외가 나면 항상 롤백될까?
기본적으로 RuntimeException과 Error는 롤백 대상이다. 일반적인 checked exception은 기본값만으로 롤백되지 않는다. 필요한 정책이라면 다음처럼 선언한다.
@Transactional(rollbackFor = Exception.class)
public void process() throws Exception {
// ...
}
이 설정은 모든 예외를 무조건 잡아서 처리한다는 뜻이 아니다. 예외가 트랜잭션 경계를 벗어날 때 적용되는 롤백 규칙을 설정하는 것이다. Spring 버전과 전역 롤백 기본값 설정에 따라 정책을 다르게 구성할 수 있으므로 프로젝트 설정을 확인해야 한다.
2. 같은 클래스 내부에서 호출해도 트랜잭션이 걸릴까?
@Service
public class ExampleService {
public void outer() {
inner(); // 프록시를 통과하지 않는 직접 호출
}
@Transactional
public void inner() {
// ...
}
}
기본 프록시 방식에서는 이런 self-invocation으로 inner()의 @Transactional 처리가 새롭게 적용되지 않는다. 트랜잭션 경계를 가진 로직을 별도의 서비스 빈으로 분리해 외부에서 호출하는 방식이 보통 더 명확하다.
3. 애노테이션만 붙이면 항상 적용될까?
트랜잭션 관리 인프라와 적절한 매니저가 구성되어야 한다. Spring Boot는 관련 스타터와 데이터 소스 조건이 맞으면 많은 구성을 자동 처리하지만, 올바른 의존성과 설정 없이 애노테이션만 붙인다고 트랜잭션이 만들어지는 것은 아니다.
트랜잭션을 검증하려면 성공 케이스만 테스트해서는 부족하다. 저장 두 단계 중 두 번째에서 예외를 발생시키고, 첫 번째 변경도 실제로 롤백되었는지 통합 테스트로 확인해야 한다. 외부 API 호출까지 한 트랜잭션으로 묶여 있다고 착각하지 않는 것도 중요하다.
@Transactional은 트랜잭션 정책을 선언하는 메타데이터다.PlatformTransactionManager는 실제 트랜잭션 기술을 추상화하는 핵심 인터페이스다.