[Spring] 트랜잭션
1. 트랜잭션 코드와 비즈니스 로직의 결합 문제
다음 코드는 트랜잭션 관리 로직과 비즈니스 로직이 혼재되어 있어 코드의 복잡성과 책임이 증가하는 예입니다.
public void addUsers(List<User> userList) {
TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
for (User user : userList) {
if (isEmailNotDuplicated(user.getEmail())) {
userRepository.save(user);
}
}
this.transactionManager.commit(status);
} catch (Exception e) {
this.transactionManager.rollback(status);
throw e;
}
}
위 코드에는 두 가지 주요 문제가 있습니다:
- 책임 분리 부족: 트랜잭션 관리 로직과 핵심 비즈니스 로직이 혼합되어 있습니다.
- 유지보수 어려움: 코드 수정 시 트랜잭션 로직과 비즈니스 로직을 동시에 고려해야 하므로 복잡도가 높아집니다.
2. 트랜잭션 코드 분리: AOP 적용
Spring은 이러한 문제를 해결하기 위해 AOP(Aspect-Oriented Programming)를 사용합니다. 트랜잭션 코드와 같은 부가 기능은 핵심 비즈니스 로직 외부로 분리하여 관리할 수 있도록 설계되었습니다. 이를 통해 트랜잭션 관련 코드는 마치 존재하지 않는 것처럼 처리할 수 있습니다.
Spring에서는 이를 위해 @Transactional 어노테이션을 제공합니다. 아래는 트랜잭션 로직을 분리한 리펙링된 코드입니다.
@Service
@RequiredArgsConstructor
@Transactional
public class UserService {
private final UserRepository userRepository;
public void addUsers(List<User> userList) {
for (User user : userList) {
if (isEmailNotDuplicated(user.getEmail())) {
userRepository.save(user);
}
}
}
}
- 트랜잭션 관리 코드 제거: 트랜잭션 시작, 커밋, 롤백 같은 작업은 @Transactional 어노테이션에 의해 자동으로 처리됩니다.
- 책임 분리: 비즈니스 로직은 핵심 작업만 수행하며, 트랜잭션은 AOP를 통해 관리됩니다.
3. 트랜잭션 세부 설정
Spring의 @Transactional 어노테이션은 다양한 트랜잭션 속성을 설정할 수 있습니다. 대표적으로 트랜잭션 전파, 격리 수준, 제한 시간, 읽기 전용과 같은 속성이 있습니다.
트랜잭션 전파 (Propagation)
트랜잭션 전파는 이미 진행 중인 트랜잭션이 있을 때 새로운 트랜잭션을 어떻게 처리할지를 정의합니다.
- PROPAGATION REQUIRED
이미 진행 중인 트랜잭션이 있으면 그 트랜잭션에 참여하고, 없으면 새로운 트랜잭션을 시작합니다.
- ex) A 작업이 진행 중일 때 B 작업이 시작되면 A와 B는 하나의 트랜잭션으로 묶입니다. A, B 중 하나라도 실패하면 전체 트랜잭션이 롤백됩니다.
- PROPAGATION REQUIRES NEW
새로운 트랜잭션을 시작하고, 기존 트랜잭션은 일시 정지합니다. - ex) A 작업 중 B 작업이 시작되면 B는 독립적으로 처리됩니다. A가 롤백되더라도 B는 영향을 받지 않습니다.
- PROPAGATION NOT SUPPORTED
트랜잭션 없이 동작합니다. - ex) 단순 조회 작업처럼 트랜잭션이 필요 없는 경우
스프링은 총 7개의 전파 속성을 지원합니다.
1) REQUIRED (기본값)
- 트랜잭션이 있으면 참여하고, 없으면 새로 시작.
- 대부분의 트랜잭션 관리에 충분하며 가장 자연스럽고 간단한 방식.
2) SUPPORTS
- 트랜잭션이 있으면 참여하고, 없으면 트랜잭션 없이 실행.
- 트랜잭션 여부와 상관없이 로직을 실행할 수 있을 때 적합.
3) MANDATORY
- 반드시 기존 트랜잭션이 존재해야 하며, 없으면 예외 발생.
- 독립적으로 실행되지 않아야 하는 로직에 적합.
4) REQUIRES_NEW
- 항상 새로운 트랜잭션을 시작하며, 기존 트랜잭션은 일시 중지.
- 독립적으로 실행되어야 하는 로직에 적합.
5) NOT_SUPPORTED
- 기존 트랜잭션이 있으면 일시 중지하고, 트랜잭션 없이 실행.
- 트랜잭션이 필요하지 않은 작업에 적합.
6) NEVER
- 트랜잭션이 진행 중이면 예외 발생.
- 트랜잭션 없이 실행되어야 하는 작업에 강제 사용.
7) NESTED
- 기존 트랜잭션이 있으면 중첩 트랜잭션을 시작.
- 부모 트랜잭션의 커밋과 롤백에는 영향을 받지만, 중첩 트랜잭션 자체는 독립적으로 커밋/롤백 가능.
- DataSourceTransactionManager만 지원.
격리 수준 (Isolation Level)
격리 수준은 트랜잭션이 다른 트랜잭션과 상호작용하는 방식을 정의합니다.
모든 DB 트랜잭션은 격리수준을 가지고 있어야 합니다.
서버에서는 여러 개의 트랜잭션이 동시에 진행될 수 있는데, 모든 트랜잭션을 독립적으로 만들고 순차 진행 한다면
안전하지만 성능이 크게 떨어질 수 밖에 없습니다. 따라서 적절하게 격리수준을 조정해서
가능한 많은 트랜잭션을 동시에 진행시키면서 문제가 발생하지 않도록 제어해야 합니다.
1) DEFAULT
- 사용 중인 데이터 액세스 기술 또는 DB 드라이버의 기본 설정을 따릅니다.
- 일반적으로 대부분의 데이터베이스는 READ_COMMITTED를 기본 격리 수준으로 사용합니다.
- 일부 데이터베이스는 다른 기본값을 가질 수 있으므로, DEFAULT를 사용할 경우 드라이버와 데이터베이스의 문서를 참고하여 기본 격리 수준을 확인해야 합니다.
2) READ_UNCOMMITTED
- 가장 낮은 격리 수준으로, 커밋되지 않은 변경 사항도 다른 트랜잭션에서 읽을 수 있습니다.
- 데이터 정확성보다 성능이 절대적으로 중요한 경우에만 신중하게 사용합니다.
- Dirty Read 현상이 발생할 수 있습니다.
3. READ_COMMITTED
- 다른 트랜잭션에서 커밋된 데이터만 읽을 수 있습니다.
- Dirty Read를 방지하지만, Non-Repeatable Read는 발생할 수 있습니다.
- 하나의 트랜잭션에서 동일한 쿼리를 실행해도 결과가 달라질 수 있습니다.
4) REPEATABLE_READ
- 트랜잭션 동안 읽은 데이터에 대한 수정을 다른 트랜잭션에서 할 수 없습니다.
- Non-Repeatable Read를 방지합니다.
- 새로운 로우의 삽입은 막지 않기 때문에 Phantom Read가 발생할 수 있습니다.
5) SERIALIZABLE
- 가장 높은 격리 수준으로, 트랜잭션을 순차적으로 실행하여 완벽한 격리를 보장합니다.
- Dirty Read, Non-Repeatable Read, Phantom Read 모두 방지합니다.
- 성능이 가장 낮으며, 동시성이 크게 감소합니다.
- 데이터 무결성이 최우선이며, 성능 저하를 감수할 수 있는 경우에만 사용합니다.
제한 시간 (Timeout)
트랜잭션 수행 시간의 상한선을 설정합니다.
- 제한 시간 초과 시 트랜잭션이 강제로 롤백됩니다.
- 설정 방법: @Transactional(timeout = 5) (단위: 초)
읽기 전용 (Read-Only)
읽기 전용으로 설정하면 트랜잭션 내에서 데이터 변경 시도를 막습니다.
- 성능 최적화 가능: 데이터베이스 및 Hibernate 캐시에서 읽기 전용으로 동작.
- 읽기 전용으로 설정하면 데이터 변경을 시도할 경우 예외를 발생시킵니다.
noRollbackFor
- 트랜잭션이 롤백되지 않아야 할 예외 클래스를 지정합니다.
- 기본적으로 예외가 발생하면 트랜잭션은 롤백됩니다.
- 여기서 지정한 예외는 롤백되지 않도록 예외 처리를 커스터마이징합니다.