서론: 이전 학습과의 차별점
지금까지 스프링 트랜잭션의 기본적인 사용법과 예외 처리의 기초를 다뤘다. @Transactional 애노테이션을 사용하는 방법, 체크 예외와 언체크 예외의 차이, 예외 전환과 추상화, JdbcTemplate을 통한 반복 코드 제거 등을 배웠다.
이번 글에서는 그 기초 지식을 바탕으로 더 깊이 있는 주제를 다룬다. 예외가 발생했을 때 트랜잭션이 실제로 어떻게 동작하는지, 스프링이 예외 종류에 따라 자동으로 커밋과 롤백을 결정하는 원리, 그리고 실무에서 마주치는 다양한 예외 상황에 대한 처리 전략을 심층적으로 살펴본다.
이전 내용과의 가장 큰 차별점은 트랜잭션의 '자동' 동작 메커니즘을 이해하는 것이다. 단순히 @Transactional을 붙이는 수준을 넘어, 예외 발생 시 실제로 어떤 로직이 실행되는지, 왜 그런 결정이 내려지는지, 필요할 때 어떻게 이를 제어할 수 있는지를 배우게 된다.
1. 스프링 트랜잭션 AOP의 기본 정책
1.1. 기본 원칙: 예외 타입에 따른 자동 결정
스프링의 @Transactional AOP는 예외 발생 시 다음과 같은 기본 정책을 따른다. 이 정책은 스프링 트랜잭션 관리의 핵심으로, 개발자가 명시적으로 처리하지 않아도 자동으로 적용된다.

- 정상 종료(리턴) → 트랜잭션 커밋
- 메서드가 예외 없이 정상적으로 종료되면 트랜잭션은 자동으로 커밋된다.
- 언체크 예외 발생 → 트랜잭션 롤백
- RuntimeException이나 그 하위 예외가 발생하면 트랜잭션이 자동으로 롤백된다.
- Error와 그 하위 예외도 동일하게 롤백된다.
- 체크 예외 발생 → 트랜잭션 커밋
- Exception을 상속받은 체크 예외가 발생하면 트랜잭션이 커밋된다.
이 정책을 개념적으로 코드로 표현하면 다음과 같다:
// 스프링 트랜잭션 AOP의 내부 로직 (개념적 표현)
try {
// 비즈니스 로직 실행
Object result = method.invoke(target, args);
// 정상 종료: 커밋
transactionManager.commit(status);
return result;
} catch (RuntimeException | Error e) {
// 언체크 예외: 롤백
transactionManager.rollback(status);
throw e;
} catch (Exception e) {
// 체크 예외: 커밋
transactionManager.commit(status);
throw e;
}
1.2. 기본 정책의 논리적 근거
스프링이 이런 기본 정책을 채택한 이유는 예외의 성격에 따른 실용적인 구분에 있다.
1.2.1. 언체크 예외의 성격: 복구 불가능한 시스템 예외
RuntimeException과 Error는 대부분 시스템 레벨의 문제를 나타낸다. 예를 들어:
- NullPointerException: 객체 참조가 잘못된 경우
- IllegalArgumentException: 잘못된 인자가 전달된 경우
- OutOfMemoryError: 메모리 부족으로 인한 심각한 시스템 오류
이런 예외들은 일반적으로 애플리케이션 로직으로 복구하기 어렵거나 불가능하다. 따라서 트랜잭션을 롤백하여 데이터의 일관성을 유지하는 것이 합리적이다.
1.2.2. 체크 예외의 성격: 비즈니스 의미가 있는 예외
Exception을 상속받은 체크 예외는 주로 비즈니스 로직과 관련된 예외 상황을 나타낸다. 예를 들어:
- 사용자 입력 검증 실패
- 잔고 부족으로 인한 결제 실패
- 중복된 데이터 저장 시도
이런 예외들은 비즈니스 로직의 일부로 간주되며, 호출자가 반드시 처리하거나 선언해야 한다. 체크 예외가 발생했다고 해서 트랜잭션 전체를 롤백하는 것은 비즈니스 요구사항과 맞지 않을 수 있다.
2. 기본 정책 검증: 코드를 통한 실험
2.1. 실험 환경 구성
기본 정책이 실제로 동작하는지 확인하기 위해 간단한 테스트 코드를 작성한다. 먼저 로깅 설정을 통해 트랜잭션의 커밋과 롤백을 확인할 수 있도록 한다.
application.properties
properties
# 트랜잭션 인터셉터 로그 레벨 설정
logging.level.org.springframework.transaction.interceptor=TRACE
# 데이터소스 트랜잭션 매니저 로그 레벨 설정
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
# JPA 트랜잭션 매니저 로그 레벨 설정
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
# 하이버네이트 트랜잭션 로그 레벨 설정
logging.level.org.hibernate.resource.transaction=DEBUG
# JPA SQL 로그 설정
logging.level.org.hibernate.SQL=DEBUG
2.2. 테스트 코드 구현
다양한 예외 상황을 테스트하기 위한 클래스를 구현한다.
RollbackTest.java
package hello.springtx.exception;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.*;
@SpringBootTest
public class RollbackTest {
@Autowired
RollbackService service;
@Test
void runtimeException() {
// RuntimeException 발생 시 롤백되는지 테스트
assertThatThrownBy(() -> service.runtimeException())
.isInstanceOf(RuntimeException.class);
}
@Test
void checkedException() {
// 체크 예외 발생 시 커밋되는지 테스트
assertThatThrownBy(() -> service.checkedException())
.isInstanceOf(MyException.class);
}
@Test
void rollbackFor() {
// rollbackFor 옵션으로 체크 예외를 롤백하는지 테스트
assertThatThrownBy(() -> service.rollbackFor())
.isInstanceOf(MyException.class);
}
@TestConfiguration
static class RollbackTestConfig {
@Bean
RollbackService rollbackService() {
return new RollbackService();
}
}
@Slf4j
static class RollbackService {
// 런타임 예외 발생: 롤백
@Transactional
public void runtimeException() {
log.info("call runtimeException");
throw new RuntimeException();
}
// 체크 예외 발생: 커밋
@Transactional
public void checkedException() throws MyException {
log.info("call checkedException");
throw new MyException();
}
// 체크 예외 rollbackFor 지정: 롤백
@Transactional(rollbackFor = MyException.class)
public void rollbackFor() throws MyException {
log.info("call rollbackFor");
throw new MyException();
}
}
static class MyException extends Exception {
// 사용자 정의 체크 예외
}
}
2.3. 테스트 결과 분석
2.3.1. runtimeException() 테스트
코드:
@Transactional
public void runtimeException() {
log.info("call runtimeException");
throw new RuntimeException();
}
실행 결과 로그:
Getting transaction for [...RollbackService.runtimeException]
call runtimeException
Completing transaction for [...RollbackService.runtimeException] after exception: RuntimeException
Initiating transaction rollback
Rolling back JPA transaction on EntityManager
분석:
- RuntimeException이 발생했기 때문에 스프링은 트랜잭션을 롤백한다.
- 로그에서 Initiating transaction rollback과 Rolling back JPA transaction on EntityManager를 확인할 수 있다.
- 이는 기본 정책인 "언체크 예외 발생 시 롤백"이 정상적으로 동작함을 보여준다.
2.3.2. checkedException() 테스트
코드:
@Transactional
public void checkedException() throws MyException {
log.info("call checkedException");
throw new MyException();
}
실행 결과 로그:
Getting transaction for [...RollbackService.checkedException]
call checkedException
Completing transaction for [...RollbackService.checkedException] after exception: MyException
Initiating transaction commit
Committing JPA transaction on EntityManager
분석:
- MyException은 Exception을 상속받은 체크 예외이다.
- 체크 예외가 발생했음에도 불구하고 트랜잭션이 커밋된다.
- 로그에서 Initiating transaction commit과 Committing JPA transaction on EntityManager를 확인할 수 있다.
- 이는 기본 정책인 "체크 예외 발생 시 커밋"이 정상적으로 동작함을 보여준다.
2.3.3. rollbackFor() 테스트
코드:
@Transactional(rollbackFor = MyException.class)
public void rollbackFor() throws MyException {
log.info("call rollbackFor");
throw new MyException();
}
실행 결과 로그:
Getting transaction for [...RollbackService.rollbackFor]
call rollbackFor
Completing transaction for [...RollbackService.rollbackFor] after exception: MyException
Initiating transaction rollback
Rolling back JPA transaction on EntityManager
분석:
- @Transactional(rollbackFor = MyException.class) 옵션을 사용했다.
- 동일한 체크 예외(MyException)가 발생했지만, 이번에는 트랜잭션이 롤백된다.
- 이는 rollbackFor 옵션이 기본 정책을 오버라이드하여 특정 체크 예외를 롤백하도록 설정할 수 있음을 보여준다.
2.4. rollbackFor와 noRollbackFor 옵션
2.4.1. rollbackFor 옵션
rollbackFor 옵션은 기본 정책에 추가로 어떤 예외가 발생할 때 롤백할지를 지정한다.
// 특정 체크 예외를 롤백하도록 지정
@Transactional(rollbackFor = MyException.class)
public void method1() throws MyException {
throw new MyException(); // 롤백됨
}
// 여러 예외를 롤백하도록 지정
@Transactional(rollbackFor = {MyException.class, YourException.class})
public void method2() throws MyException, YourException {
// ...
}
// 모든 예외를 롤백하도록 지정
@Transactional(rollbackFor = Exception.class)
public void method3() throws Exception {
throw new Exception(); // 롤백됨 (기본 정책과 다름)
}
2.4.2. noRollbackFor 옵션
noRollbackFor 옵션은 기본 정책에 추가로 어떤 예외가 발생할 때 롤백하지 않을지를 지정한다.
// 특정 런타임 예외를 롤백하지 않도록 지정
@Transactional(noRollbackFor = IllegalArgumentException.class)
public void method4() {
throw new IllegalArgumentException(); // 커밋됨 (기본 정책과 다름)
}
// 여러 런타임 예외를 롤백하지 않도록 지정
@Transactional(noRollbackFor = {IllegalArgumentException.class, IllegalStateException.class})
public void method5() {
// ...
}
2.4.3. 옵션 적용 순서
옵션들이 충돌할 경우의 적용 순서는 다음과 같다:
- noRollbackFor가 가장 높은 우선순위를 가진다.
- rollbackFor가 그 다음 우선순위를 가진다.
- 기본 정책이 가장 낮은 우선순위를 가진다.
예를 들어:
@Transactional(
rollbackFor = Exception.class, // 모든 예외 롤백
noRollbackFor = IllegalArgumentException.class // IllegalArgumentException은 롤백 안함
)
public void method() {
throw new IllegalArgumentException(); // noRollbackFor가 우선, 커밋됨
}
3. 실전 적용: 비즈니스 예외 처리 시나리오
3.1. 비즈니스 요구사항 분석
실제 비즈니스 시나리오를 통해 트랜잭션과 예외 처리의 중요성을 이해해보자. 간단한 주문 시스템을 가정한다.
주문 시스템의 비즈니스 요구사항:
- 정상 시나리오:
- 주문 시 결제를 성공하면 주문 데이터를 저장한다.
- 결제 상태를 '완료'로 처리한다.
- 시스템 예외 시나리오:
- 주문 시 내부에 복구 불가능한 예외가 발생하면 전체 데이터를 롤백한다.
- 비즈니스 예외 시나리오:
- 주문 시 결제 잔고가 부족하면 주문 데이터는 저장한다.
- 결제 상태를 '대기'로 처리한다.
- 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내한다.
3.2. 도메인 모델 설계
3.2.1. 예외 클래스 정의
NotEnoughMoneyException.java
package hello.springtx.order;
/**
* 결제 잔고가 부족할 때 발생하는 비즈니스 예외
* Exception을 상속받아 체크 예외로 정의
*/
public class NotEnoughMoneyException extends Exception {
public NotEnoughMoneyException(String message) {
super(message);
}
public NotEnoughMoneyException(String message, Throwable cause) {
super(message, cause);
}
}
이 예외는 시스템 장애가 아닌 비즈니스 상황을 나타낸다. 체크 예외로 정의한 이유는 호출자가 반드시 이 예외를 인지하고 처리해야 하기 때문이다.
3.2.2. 엔티티 클래스 정의
Order.java
package hello.springtx.order;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;
@Entity
@Table(name = "orders") // order는 SQL 예약어이므로 orders로 테이블 이름 지정
@Getter
@Setter
public class Order {
@Id
@GeneratedValue
private Long id;
private String username; // 정상, 예외, 잔고부족
private String payStatus; // 대기, 완료
// 기본 생성자
public Order() {}
// 편의 생성자
public Order(String username) {
this.username = username;
}
}
주의할 점은 @Table(name = "orders")로 테이블 이름을 지정한 부분이다. order는 SQL의 예약어(ORDER BY)이기 때문에 테이블 이름으로 사용할 수 없다.
3.2.3. 리포지토리 인터페이스
OrderRepository.java
package hello.springtx.order;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrderRepository extends JpaRepository<Order, Long> {
}
스프링 데이터 JPA를 사용하여 기본적인 CRUD 연산을 제공받는다.
3.3. 서비스 계층 구현
OrderService.java
package hello.springtx.order;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
/**
* 주문 처리 메서드
* @param order 주문 정보
* @throws NotEnoughMoneyException 결제 잔고가 부족한 경우
*
* 트랜잭션 특성:
* - 정상: 주문 저장 후 결제 상태 '완료'로 설정
* - 시스템 예외: RuntimeException 발생 시 전체 롤백
* - 비즈니스 예외: NotEnoughMoneyException 발생 시 주문 저장 후 상태 '대기' 설정
*/
@Transactional
public void order(Order order) throws NotEnoughMoneyException {
log.info("order 호출");
// 1. 주문 저장 (JPA는 트랜잭션 커밋 시점에 실제 DB에 반영)
orderRepository.save(order);
log.info("주문 저장 완료: id={}, username={}", order.getId(), order.getUsername());
// 2. 결제 프로세스 진행
log.info("결제 프로세스 진입");
if (order.getUsername().equals("예외")) {
// 시스템 예외 시나리오
log.info("시스템 예외 발생");
throw new RuntimeException("시스템 예외");
} else if (order.getUsername().equals("잔고부족")) {
// 비즈니스 예외 시나리오
log.info("잔고 부족 비즈니스 예외 발생");
order.setPayStatus("대기"); // 상태를 대기로 설정
throw new NotEnoughMoneyException("잔고가 부족합니다");
} else {
// 정상 시나리오
log.info("정상 승인");
order.setPayStatus("완료");
}
log.info("결제 프로세스 완료");
}
}
3.4. 서비스 로직의 트랜잭션 동작 분석
위 서비스의 트랜잭션 동작을 자세히 분석해보자:
3.4.1. 정상 시나리오
// 사용자 이름이 "정상"인 경우
Order order = new Order("정상");
orderService.order(order);
// 동작 순서:
// 1. orderRepository.save(order) - 영속성 컨텍스트에 저장
// 2. order.setPayStatus("완료") - 엔티티 상태 변경
// 3. 메서드 정상 종료
// 4. 트랜잭션 커밋 시 JPA가 DB에 반영
3.4.2. 시스템 예외 시나리오
// 사용자 이름이 "예외"인 경우
Order order = new Order("예외");
try {
orderService.order(order);
} catch (RuntimeException e) {
// 예외 처리
}
// 동작 순서:
// 1. orderRepository.save(order) - 영속성 컨텍스트에 저장
// 2. throw new RuntimeException("시스템 예외") - 런타임 예외 발생
// 3. 스프링 트랜잭션 AOP가 RuntimeException을 감지
// 4. 기본 정책에 따라 트랜잭션 롤백
// 5. 영속성 컨텍스트가 초기화되어 저장된 데이터도 사라짐
3.4.3. 비즈니스 예외 시나리오
// 사용자 이름이 "잔고부족"인 경우
Order order = new Order("잔고부족");
try {
orderService.order(order);
} catch (NotEnoughMoneyException e) {
// 비즈니스 예외 처리
}
// 동작 순서:
// 1. orderRepository.save(order) - 영속성 컨텍스트에 저장
// 2. order.setPayStatus("대기") - 상태를 대기로 변경
// 3. throw new NotEnoughMoneyException("잔고가 부족합니다") - 체크 예외 발생
// 4. 스프링 트랜잭션 AOP가 NotEnoughMoneyException을 감지
// 5. 기본 정책에 따라 트랜잭션 커밋
// 6. JPA가 커밋 시점에 변경된 상태를 DB에 반영
3.5. 테스트 코드 작성
OrderServiceTest.java
package hello.springtx.order;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
@Slf4j
@SpringBootTest
class OrderServiceTest {
@Autowired
OrderService orderService;
@Autowired
OrderRepository orderRepository;
@Test
void complete() throws NotEnoughMoneyException {
// given: 정상 시나리오
Order order = new Order("정상");
// when: 주문 처리
orderService.order(order);
// then: 데이터 검증
Order findOrder = orderRepository.findById(order.getId()).get();
assertThat(findOrder.getPayStatus()).isEqualTo("완료");
log.info("정상 주문 완료: id={}, status={}", findOrder.getId(), findOrder.getPayStatus());
}
@Test
void runtimeException() {
// given: 시스템 예외 시나리오
Order order = new Order("예외");
// when, then: 런타임 예외 발생 확인
assertThatThrownBy(() -> orderService.order(order))
.isInstanceOf(RuntimeException.class)
.hasMessage("시스템 예외");
// then: 롤백되었으므로 데이터가 없어야 함
Optional<Order> orderOptional = orderRepository.findById(order.getId());
assertThat(orderOptional.isEmpty()).isTrue();
log.info("시스템 예외 발생: 주문 데이터 롤백됨");
}
@Test
void bizException() {
// given: 비즈니스 예외 시나리오
Order order = new Order("잔고부족");
// when: 비즈니스 예외 발생
try {
orderService.order(order);
fail("잔고 부족 예외가 발생해야 합니다.");
} catch (NotEnoughMoneyException e) {
// 비즈니스 예외 처리
log.info("고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내");
log.info("예외 메시지: {}", e.getMessage());
}
// then: 데이터는 저장되어야 함
Order findOrder = orderRepository.findById(order.getId()).get();
assertThat(findOrder.getPayStatus()).isEqualTo("대기");
log.info("비즈니스 예외 발생: 주문 데이터 저장됨, status={}", findOrder.getPayStatus());
}
}
3.6. 테스트 실행 결과
3.6.1. 정상 시나리오 테스트 결과
order 호출
주문 저장 완료: id=1, username=정상
결제 프로세스 진입
정상 승인
결제 프로세스 완료
정상 주문 완료: id=1, status=완료
분석:
- 모든 단계가 정상적으로 실행되었다.
- 트랜잭션이 커밋되어 주문 데이터가 저장되었다.
- 결제 상태가 '완료'로 올바르게 설정되었다.
3.6.2. 시스템 예외 시나리오 테스트 결과
order 호출
주문 저장 완료: id=2, username=예외
결제 프로세스 진입
시스템 예외 발생
시스템 예외 발생: 주문 데이터 롤백됨
분석:
- 주문 저장까지는 정상적으로 진행되었다.
- RuntimeException이 발생하여 스프링 트랜잭션 AOP가 이를 감지했다.
- 기본 정책에 따라 트랜잭션이 롤백되었다.
- 결과적으로 주문 데이터는 저장되지 않았다(데이터베이스에 존재하지 않음).
3.6.3. 비즈니스 예외 시나리오 테스트 결과
order 호출
주문 저장 완료: id=3, username=잔고부족
결제 프로세스 진입
잔고 부족 비즈니스 예외 발생
고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내
예외 메시지: 잔고가 부족합니다
비즈니스 예외 발생: 주문 데이터 저장됨, status=대기
분석:
- 주문이 저장되고 결제 상태가 '대기'로 설정되었다.
- NotEnoughMoneyException(체크 예외)이 발생했다.
- 기본 정책에 따라 트랜잭션이 커밋되었다.
- 결과적으로 주문 데이터는 저장되었으며, 상태는 '대기'로 설정되었다.
3.7. 비즈니스 예외 처리의 중요성
위 예제에서 비즈니스 예외(NotEnoughMoneyException)가 발생했을 때 트랜잭션이 커밋되는 이유는 비즈니스적으로 매우 중요하다.
만약 롤백된다면 어떤 문제가 발생할까?
- 주문 데이터 소실: 고객이 주문을 했는데 아무런 기록이 남지 않는다.
- 고객 대응 불가: 고객에게 "잔고가 부족합니다. 다른 계좌로 입금해주세요"라고 안내할 수 없다.
- 비즈니스 흐름 단절: 주문→결제 실패→재시도라는 비즈니스 흐름이 끊어진다.
커밋함으로써 얻는 이점:
- 주문 기록 보존: 고객의 주문 의도가 데이터베이스에 기록된다.
- 고객 대응 가능: 주문 번호를 통해 고객에게 정확한 안내가 가능하다.
- 비즈니스 흐름 유지: "대기" 상태의 주문을 나중에 처리할 수 있다.
- 통계 자료 확보: 잔고 부족으로 인한 실패 건수를 분석할 수 있다.
4. 실무 적용 가이드
4.1. 예외 분류 전략
실무에서 효과적인 예외 처리를 위해서는 예외를 체계적으로 분류하는 전략이 필요하다.
4.1.1. 시스템 예외 vs 비즈니스 예외
| 구분 | 시스템 예외 | 비즈니스 예외 |
| 의미 | 복구 불가능한 시스템 문제 | 비즈니스 규칙 위반 |
| 예시 | DB 연결 실패, 메모리 부족 | 잔고 부족, 중복 데이터 |
| 예외 타입 | 런타임 예외(RuntimeException) | 체크 예외(Exception) |
| 트랜잭션 | 롤백 | 커밋 (기본 정책) |
| 처리 방식 | 로깅 후 상위로 전파 | 비즈니스 로직에서 처리 |
4.1.2. 예외 계층 구조 설계
// 시스템 예외 계층
public class SystemException extends RuntimeException {
// 시스템 레벨의 예외
}
public class InfrastructureException extends SystemException {
// 인프라 관련 예외 (DB, 네트워크 등)
}
public class DatabaseException extends InfrastructureException {
// 데이터베이스 관련 예외
}
// 비즈니스 예외 계층
public class BusinessException extends Exception {
// 비즈니스 레벨의 예외
}
public class ValidationException extends BusinessException {
// 입력 검증 실패 예외
}
public class PaymentException extends BusinessException {
// 결제 관련 예외
}
public class NotEnoughMoneyException extends PaymentException {
// 잔고 부족 예외
}
4.2. 트랜잭션 전략 선택 가이드
4.2.1. 기본 정책 사용 시나리오
적합한 경우:
- 비즈니스 예외는 체크 예외로, 시스템 예외는 런타임 예외로 명확히 구분된 경우
- 대부분의 비즈니스 예외가 트랜잭션 커밋을 요구하는 경우
- 팀 내에 일관된 예외 처리 규칙이 확립된 경우
예시:
@Transactional
public void processOrder(Order order) throws BusinessException {
// 비즈니스 로직
if (invalidCondition) {
throw new BusinessException("비즈니스 규칙 위반"); // 체크 예외 → 커밋
}
if (systemError) {
throw new SystemException("시스템 오류"); // 런타임 예외 → 롤백
}
}
4.2.2. rollbackFor 옵션 사용 시나리오
적합한 경우:
- 체크 예외지만 트랜잭션 롤백이 필요한 경우
- 레거시 코드 통합 시 예외 타입이 일관되지 않은 경우
- 특정 비즈니스 예외에 대해 다른 트랜잭션 동작이 필요한 경우
예시:
// 특정 비즈니스 예외는 롤백
@Transactional(rollbackFor = CriticalBusinessException.class)
public void criticalOperation() throws CriticalBusinessException {
// 중요한 작업: 실패 시 롤백 필요
if (criticalFailure) {
throw new CriticalBusinessException("중요한 실패"); // 롤백
}
}
// 모든 예외를 롤백 (엄격한 정책)
@Transactional(rollbackFor = Exception.class)
public void strictOperation() throws Exception {
// 모든 실패는 롤백
throw new AnyException(); // 롤백
}
4.2.3. noRollbackFor 옵션 사용 시나리오
적합한 경우:
- 특정 런타임 예외는 비즈니스적으로 의미 있어 커밋해야 하는 경우
- 특정 시스템 예외가 복구 가능한 경우
- 예외를 통한 흐름 제어가 필요한 경우
예시:
// 특정 런타임 예외는 커밋
@Transactional(noRollbackFor = {IllegalArgumentException.class, IllegalStateException.class})
public void flexibleOperation() {
if (invalidInput) {
throw new IllegalArgumentException("잘못된 입력"); // 커밋
}
if (invalidState) {
throw new IllegalStateException("잘못된 상태"); // 커밋
}
}
4.3. 예외 처리 패턴
4.3.1. 예외 복구 패턴
@Service
@Transactional
@Slf4j
public class OrderService {
public OrderResult processOrder(OrderRequest request) {
try {
return tryProcessOrder(request);
} catch (NotEnoughMoneyException e) {
// 비즈니스 예외 복구
log.warn("잔고 부족: {}", request.getUserId());
return OrderResult.failure("잔고가 부족합니다. 다른 결제 수단을 선택해주세요.");
} catch (BusinessException e) {
// 다른 비즈니스 예외 처리
log.warn("비즈니스 예외: {}", e.getMessage());
return OrderResult.failure(e.getMessage());
} catch (Exception e) {
// 시스템 예외 처리
log.error("시스템 예외", e);
throw new SystemException("시스템 오류가 발생했습니다.", e);
}
}
@Transactional
private OrderResult tryProcessOrder(OrderRequest request) throws BusinessException {
// 실제 주문 처리 로직
// 체크 예외를 던질 수 있음
}
}
4.3.2 예외 전환 패턴
@Service
@Transactional(rollbackFor = Exception.class) // 모든 예외 롤백
public class PaymentService {
public void processPayment(PaymentRequest request) {
try {
// 외부 시스템 호출
externalPaymentService.process(request);
} catch (ExternalSystemException e) {
// 외부 시스템 예외를 내부 예외로 전환
log.error("외부 시스템 오류", e);
throw new PaymentException("결제 시스템 오류", e);
} catch (TimeoutException e) {
// 타임아웃 예외 처리
log.warn("결제 시스템 응답 시간 초과");
throw new PaymentTimeoutException("결제 시스템 응답이 지연되고 있습니다", e);
}
}
}
4.4. 주의사항과 모범 사례
4.4.1. 흔한 실수와 해결책
실수 1: 체크 예외를 무시하는 catch 블록
// ❌ 잘못된 예
@Transactional
public void process() {
try {
someOperation();
} catch (Exception e) {
// 아무것도 하지 않음 (침묵하는 버그)
}
}
// ✅ 올바른 예
@Transactional
public void process() {
try {
someOperation();
} catch (BusinessException e) {
log.warn("비즈니스 예외 발생", e);
throw e; // 다시 던져서 트랜잭션 정책 적용
}
}
실수 2: 트랜잭션 경계 안에서 너무 많은 작업
// ❌ 잘못된 예
@Transactional
public void processLargeBatch() {
// 수천 건의 데이터 처리
for (int i = 0; i < 10000; i++) {
repository.save(createData(i));
}
// 트랜잭션이 너무 길어져서 데드락이나 타임아웃 위험
}
// ✅ 올바른 예
public void processLargeBatch() {
for (int i = 0; i < 10000; i++) {
processSingleItem(i); // 각 항목별 트랜잭션
}
}
@Transactional
public void processSingleItem(int index) {
repository.save(createData(index));
}
4.4.2 모범 사례 체크리스트
- 예외 분류: 시스템 예외와 비즈니스 예외를 명확히 구분했는가?
- 트랜잭션 범위: 트랜잭션이 너무 길지 않은가? (일반적으로 1초 이내 권장)
- 예외 문서화: 메서드 시그니처에 던지는 예외를 명시했는가?
- 롤백 테스트: 예외 발생 시 롤백이 정상 동작하는지 테스트했는가?
- 로그 기록: 예외 발생 시 적절한 로그를 남겼는가?
- 재시도 전략: 네트워크 오류 등에 대한 재시도 로직을 고려했는가?
5. 정리
5.1. 핵심 개념 요약
- 기본 정책: 스프링 트랜잭션 AOP는 예외 타입에 따라 자동으로 커밋/롤백을 결정한다.
- 언체크 예외(RuntimeException, Error) → 롤백
- 체크 예외(Exception) → 커밋
- 정상 종료 → 커밋
- 정책의 논리적 근거:
- 언체크 예외는 대부분 복구 불가능한 시스템 문제를 나타낸다.
- 체크 예외는 비즈니스 의미가 있어 호출자가 처리해야 할 경우가 많다.
- 세밀한 제어 옵션:
- rollbackFor: 특정 예외를 롤백하도록 지정
- noRollbackFor: 특정 예외를 롤백하지 않도록 지정
- 실전 적용:
- 비즈니스 예외는 트랜잭션 커밋을 통해 데이터 보존이 중요한 경우가 많다.
- 시스템 예외는 트랜잭션 롤백을 통해 데이터 일관성을 유지한다.
5.2. 실무 적용 원칙
- 일관성 원칙: 팀 내에서 예외 분류와 트랜잭션 전략을 일관되게 적용한다.
- 명시성 원칙: @Transactional 애노테이션에 의도가 명확히 드러나도록 옵션을 사용한다.
- 단순성 원칙: 기본 정책으로 충분한 경우 불필요한 옵션을 추가하지 않는다.
- 테스트 원칙: 다양한 예외 상황에 대한 트랜잭션 동작을 반드시 테스트한다.
5.3. 마무리
트랜잭션과 예외 처리는 엔터프라이즈 애플리케이션 개발의 핵심 주제이다. 단순히 @Transactional을 붙이는 수준을 넘어, 예외의 종류에 따른 트랜잭션 동작 원리를 이해하고, 비즈니스 요구사항에 맞게 적절히 제어할 수 있어야 한다.
이 글에서 다룬 내용을 바탕으로 자신의 프로젝트에서:
- 현재의 예외 처리 전략을 평가해보고
- 비즈니스 예외와 시스템 예외를 명확히 구분하며
- 트랜잭션 동작이 비즈니스 요구사항을 정확히 반영하는지 확인해보기 바란다.
올바른 트랜잭션과 예외 처리 전략은 데이터의 정확성과 시스템의 안정성을 보장하는 기반이 된다. 이 기반 위에 견고한 비즈니스 로직을 구축할 수 있을 것이다.
'Spring > DB' 카테고리의 다른 글
| [Advanced-3] 트랜잭션 전파(2): 활용 (0) | 2026.01.04 |
|---|---|
| [Advanced-2] 트랜잭션 전파(1): 기본 (0) | 2026.01.04 |
| [Basic-6] 스프링과 문제 해결 - 예외 처리, 반복 (0) | 2026.01.03 |
| [Basic-5] 자바 예외 이해 (0) | 2026.01.03 |
| [Basic-4] 스프링과 문제 해결 - 트랜잭션 (0) | 2026.01.02 |
