[TIL - 2024-09-20] @Transaction 정리
@Transactional 개념
@Transactional은 스프링이 제공하는 어노테이션으로, 이전에 사용되던 트랙잭션 매니저나 트랜잭션 템플릿과 달리 더 간단하고 선언적인 방식으로 트랜잭션을 관리할 수 있도록 해주고 서비스 계층에서 트랜잭션 기술에 의존하지 않게 도와준다.
기존에 트랜잭션 매니저나 트랜잭션 템플릿을 사용할 때, 트랜잭션 시작, 커밋, 롤백 같은 작업을 코드로 명시적으로 작성해주어야 했다. 하지만 `@Transactional`은 이런 작업을 애노테이션 하나로 간단하게 처리할 수 있도록 해준다.
주요 기능
1. 트랜잭션 시작 및 종료
- `@Transactional`이 적용된 메서드가 호출되면 스프링은 자동으로 트랜잭션을 시작한다.
- 메서드가 성공적으로 완료되면 트랜잭션이 커밋되고, 실패 시(예외발생)에는 롤백된다.
2. 격리 수준
- 트랜잭션 간 데이터 접근 충돌을 제어하는 방법을 지정한다.
- READ_UNCOMMITTED: 다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽을 수 있다.
- READ_COMMITTED: 다른 트랜잭션이 커밋한 데이터만 읽을 수 있다.
- REPEATABLE_READ: 트랜잭션이 시작될 때 조회한 데이터는 트랜잭션이 끝날 때까지 변경되지 않다.
- SERIALIZABLE: 가장 엄격한 격리 수준으로, 트랜잭션 간 충돌을 완전히 방지한다.
3. 전파 옵션
- 트랜잭션을 어떻게 전파할지를 설정할 수 있다.
- REQUIRED: 기본 설정, 트랜잭션이 이미 존재하면 해당 트랜잭션을 사용하고, 없으면 새로운 트랜잭션을 생성한다.
- REQUIRES_NEW: 항상 새로운 트랜잭션을 생성하며, 기존 트랜잭션이 있으면 이를 일시 정지시킨다.
- MANDATORY: 기존 트랜잭션이 없으면 예외를 발생시킨다.
- SUPPORTS: 트랜잭션이 있으면 이를 사용하지만, 없으면 트랜잭션 없이 동작한다.
- NOT_SUPPORTED: 트랜잭션이 있는 경우 이를 일시 중지하고 트랜잭션 없이 메서드를 실행한다.
- NEVER: 트랜잭션이 있으면 예외를 발생시킨다.
- NESTED: 중첩 트랜잭션을 지원하며, 기존 트랜잭션 내에서 별도의 트랜잭션을 시작할 수 있다.
4. Rollback 조건
- 기본적으로 RuntimeException이 발생하면 롤백하고, Checked Exception은 롤백하지 않는다. 하지만 특정 예외에 대해 롤백을 제어할 수 있다.
- rollbackFor: 롤백할 예외 클래스를 지정한다.
- noRollbackFor: 롤백하지 않을 예외 클래스를 지정한다.
5. Read-Only 속성
- 데이터베이스의 조회만 수행하고, 수정이나 삽입 작업을 하지 않을 때는 readOnly = true 속성을 설정하여 성능을 최적화할 수 있다.
- 예를 들어, 조회 작업에서만 트랜잭션을 사용하고 싶다면, 이를 통해 불필요한 쓰기 잠금을 방지할 수 있다.
6. 적용 위치
- 클래스 레벨: 클래스 전체에 트랜잭션을 적용하며, 모든 메서드에 동일한 트랜잭션 설정이 적용된다.
- 메서드 레벨: 특정 메서드에만 트랜잭션을 적용하며, 클래스 레벨에서 설정한 트랜잭션을 메서드 레벨에서 재정의할 수 있다.
@Transactional 사용 전후
TransactionManager
다음은 트랜잭션 매니저를 사용하여 트랜잭션을 처리하는 소스코드이다. 현재 트랜잭션과 시작, 커밋, 롤백 같은 작업을 코드로 명시적으로 작성하여 처리하고 있다.
@Service
@RequiredArgsConstructor
public class TradeService {
private final UserRepository userRepository;
private final PlatformTransactionManager transactionManager;
public void trade(String senderName, String receiverName, int amount) {
// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
// 송신자와 수신자 조회
User sender = userRepository.findByName(senderName);
User receiver = userRepository.findByName(receiverName);
// 예외 조건 체크: 송신자의 메소가 부족한 경우
if (sender.getMeso() < amount) {
throw new IllegalArgumentException("송신자의 메소가 부족합니다.");
}
// 예외 조건 체크: 수신자의 메소가 초과한 경우
if (receiver.getMeso() + amount < 0) {
throw new IllegalArgumentException("수신자의 보유 메소 한도를 초과하였습니다.");
}
// 송신자와 수신자의 메소 업데이트
sender.setMeso(sender.getMeso() - amount);
receiver.setMeso(receiver.getMeso() + amount);
// 데이터베이스 업데이트
userRepository.update(sender);
userRepository.update(receiver);
// 트랜잭션 커밋
transactionManager.commit(status);
} catch (Exception e) {
// 예외 발생 시 롤백
transactionManager.rollback(status);
throw e; // 예외 다시 발생시켜 상위 호출로 전달
}
}
}
TransactionTemplate
트랜잭션 템플릿을 사용하여 트랜잭션을 시작, 커밋, 롤백하는 코드를 제거할 수 있었다. 하지만 이 코드에서도 트랜잭션 기술에 의존하고 있기 때문에 순수한 비즈니스 로직만을 남기지 못하였다.
@Service
@RequiredArgsConstructor
public class TradeService {
private final UserRepository userRepository;
private final TransactionTemplate transactionTemplate;
public void trade(String senderName, String receiverName, int amount) {
// 트랜잭션 템플릿 사용하여 트랜잭션 실행
transactionTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(TransactionStatus status) {
try {
// 송신자와 수신자 조회
User sender = userRepository.findByName(senderName);
User receiver = userRepository.findByName(receiverName);
// 예외 조건 체크: 송신자의 메소가 부족한 경우
if (sender.getMeso() < amount) {
throw new IllegalArgumentException("송신자의 메소가 부족합니다.");
}
// 예외 조건 체크: 수신자의 메소가 초과한 경우
if (receiver.getMeso() + amount < 0) {
throw new IllegalArgumentException("수신자의 보유 메소 한도를 초과하였습니다.");
}
// 송신자와 수신자의 메소 업데이트
sender.setMeso(sender.getMeso() - amount);
receiver.setMeso(receiver.getMeso() + amount);
// 데이터베이스 업데이트
userRepository.update(sender);
userRepository.update(receiver);
} catch (Exception e) {
// 트랜잭션 실패 시 예외 처리
status.setRollbackOnly();
throw e; // 예외 다시 발생시켜 상위 호출로 전달
}
}
});
}
}
@Transactional
@Transactional 어노테이션을 사용하면 기존 트랜잭션과 관련된 코드가 모두 제거되었다. 이 코드의 동작 과정은 다음과 같다.
@Service
@RequiredArgsConstructor
public class TradeService {
private final UserRepository userRepository;
@Transactional
public void trade(String senderName, String receiverName, int amount) {
// 송신자와 수신자 조회
User sender = userRepository.findByName(senderName);
User receiver = userRepository.findByName(receiverName);
// 예외 조건 체크: 송신자의 메소가 부족한 경우
if (sender.getMeso() < amount) {
throw new IllegalArgumentException("송신자의 메소가 부족합니다.");
}
// 예외 조건 체크: 수신자의 메소가 초과한 경우
if (receiver.getMeso() + amount < 0) {
throw new IllegalArgumentException("수신자의 보유 메소 한도를 초과하였습니다.");
}
// 송신자와 수신자의 메소 업데이트
sender.setMeso(sender.getMeso() - amount);
receiver.setMeso(receiver.getMeso() + amount);
// 데이터베이스 업데이트
userRepository.update(sender);
userRepository.update(receiver);
}
}
동작 방식
- TradeService.trade() 메서드 호출 :
- 클라이언트가 `TradeService`의 `trade()`메서드를 호출한다.
- 클라이언트가 `TradeService`의 `trade()`메서드를 호출한다.
- 프록시 객체가 호출을 가로챈다 :
- ` TradeService$$CGLIB` 프록시 객체가 트랜잭션 처리를 위해 메서드 호출을 가로챈다.
- ` TradeService$$CGLIB` 프록시 객체가 트랜잭션 처리를 위해 메서드 호출을 가로챈다.
- 스프링 컨테이너에서 트랜잭션 매니저 흭득 :
- 프록시 객체는 스프링 컨테이너에서 트랜잭션 매니저를 가져온다.
- 프록시 객체는 스프링 컨테이너에서 트랜잭션 매니저를 가져온다.
- 트랜잭션 매니저를 통해 트랜잭션 시작 :
- 트랜잭션 매니저는 `getTransaction()`메서드를 호출하여 트랜잭션을 시작한다.
- 트랜잭션 매니저는 `getTransaction()`메서드를 호출하여 트랜잭션을 시작한다.
- DataSource에서 커넥션 흭득 :
- 트랜잭션 매니저는 `DataSource`를 통해 데이터베이스 커넥션을 흭득한다.
- 트랜잭션 매니저는 `DataSource`를 통해 데이터베이스 커넥션을 흭득한다.
- 커넥션을 트랜잭션 동기화 매니저에 보관 :
- 흭득한 커넥션을 TransactionSynchronizationManager에 저장한다. 이후 트랜잭션 내에서 동일한 커넥션이 사용될 수 있도록 관리한다.
- 흭득한 커넥션을 TransactionSynchronizationManager에 저장한다. 이후 트랜잭션 내에서 동일한 커넥션이 사용될 수 있도록 관리한다.
- 원본 객체의 trace() 메서드 호출 :
- 트랜잭션이 시작된 후, 프록시는 실제 원본 객체의 `trade()`메서드를 호출한다.
- 트랜잭션이 시작된 후, 프록시는 실제 원본 객체의 `trade()`메서드를 호출한다.
- 트랜잭션 동기화 매니저에서 커넥션 흭득 :
- 원본 객체의 메서드에서는 트랜잭션 동기화 매니저를 통해 이미 시작된 트랜잭션 내에서 동일한 커넥션을 사용하여 데이터베이스 작업을 수행한다.
프록시 객체로 등록되었는지 확인하기
애플리케이션을 실행 시, 스프링 컨테이너는 `@ComponentScan`을 사용하여 `@Component` 어노테이션이 붙은 클래스를 스프링 빈으로 등록하게 된다. 이때, 클래스나 메서드에 `@Transaction`이 선언되어 있으면 스프링은 해당 빈을 트랜잭션 처리를 위하여 프록시 객체로 감싸서 스프링 컨테이너에 등록한다고 한다.
실제로 스프링 컨테이너에서 프록시 객체로 감싸 스프링 빈으로 등록되는지 확인하기 위해 다음과 같은 소스 코드를 작성하였다. 먼저 `TransactionService` 클래스를 선언하고, 스프링 빈으로 등록될 수 있도록 `@Service` 어노테이션을 추가하였다. 클래스 내부에는 트랜잭션 처리가 필요한 `transactionLogic` 메서드를 정의하고, 해당 메서드에 트랜잭션 기능을 적용하기 위해 `@Transactional` 어노테이션을 사용하였다. 또한, `@PostConstruct` 어노테이션을 활용하여 의존관계 주입이 완료된 후, 실제로 현재 클래스가 프록시 객체로 감싸졌는지 확인할 수 있도록 코드를 작성하였다.
@Service
public class TransactionService {
@Transactional
public void transactionLogic() {
System.out.println("TransactionService.transactionLogic");
}
@PostConstruct
public void init() {
System.out.println("TransactionService.class = " + this.getClass());
}
}
실제 애플리케이션을 실행하여 콘솔에 출력되는 메세지를보면 프록시 객체가 아닌 원본 객체가 등록되는것을 확인할 수 있다. 왜 이런 문제가 발생하는걸까?
문제 발생
프록시 객체로 등록되는것을 기대하였는데 원본 객체가 등록되었다. 정확하게 말하면 원본 객체가 출력되었다. 이런 문제가 발생하는 이유는 스프링 빈이 초기화할 때, 클래스 내부에서 자신의 메서드를 호출하기 때문이다. `@PostConstruct` 어노테이션은 스프링 컨테이너가 로드되고 의존관계가 모두 주입된 상태에서 클래스 내부에서 초기화 메서드를 사용한다.
바로 여기서 문제가 발생한다. 클래스 내부에서 초기화 메서드를 사용하고 있다. 스프링에서 프록시 기반 AOP는 외부에서 메서드가 호출될 때만 작동하며, 내부에서 메서드를 호출할 때는 프록시를 통하지 않고 원본 객체가 호출된다. 이로 인해 트랜잭션이나 AOP가 적용된 메서드라 하더라도 클래스 내부에서 호출되면 프록시의 기능이 적용되지 않게 되는것이다.
문제 해결
위 문제를 해결하면서 프록시 객체는 클래스 외부에서 호출되는 메서드를 가로채서 실행한다는것을 이해할 수 있었다. 그렇다면 정상적으로 프록시 객체로 조회하기 위해서는 `TransactionService` 클래스 외부에서 클래스의 정보를 조회해야 한다. 그래서 다음과 같이 `TransactionTest` 클래스를 생성하였고 `TransactionService`를 외부에서 주입받도록하였다. 다음으로 초기화 메서드를 통해 클래스 정보를 확인해보면 프록시 객체로 출력되는것을 확인해볼 수 있었다.
@Component
public class TransactionTest {
@Autowired
TransactionService transactionService;
@PostConstruct
public void testProxy() {
System.out.println(transactionService.getClass());
}
}
다음으로 `@springBooTest` 어노테이션을 사용하여 실제 스프링 컨테이너를 로드하고 애플리케이션 빈만 조회한 결과 프록시 객체가 등록된것을 확인할 수 있었다.
@SpringBootTest
class TranServiceTest {
@Autowired
ConfigurableApplicationContext ac;
@Test
void findAllMyBean(){
String[] names = ac.getBeanDefinitionNames();
for (String name : names) {
// BeanDefinition 가져오기
BeanDefinition beanDefinition = ac.getBeanFactory().getBeanDefinition(name);
// 애플리케이션 빈만 출력
if (beanDefinition.getRole() == BeanDefinition.ROLE_APPLICATION) {
Object bean = ac.getBean(name);
System.out.println("Bean name: " + name + ", Bean class: " + bean.getClass().getName());
}
}
}
}