트랜잭션의 이해

Seung jun Cha·2022년 11월 29일
0

1. 트랙잭션 적용

  • @Transactional이 클래스에 있든 메서드에 있든 있으면 클래스 전체를 그대로 상속받은 프록시가 생성되어 스프링 빈으로 등록된다. 그리고 프록시가 진짜 대신에 주입되고 클라이언트는 이 프록시객체를 사용하게 된다.
    즉, 클라이언트가 트랜잭션이 적용된 서비스를 호출하면 프록시 객체가 트랜잭션, 커밋, 롤백 등의 부수적인 작업을 처리하고 실제 객체를 호출한다. 이를 Invocation이라고 한다.

  • 프록시 객체를 생성해서 사용하는 이유는 지연로딩을 구현해서 불필요한 데이터베이스 쿼리를 방지하고 성능을 최적화할 수 있습니다. 객체의 메서드 호출을 감싸서 보안과 관련된 작업을 수행할 수 있습니다. (사실 잘모르겠음)

  • JPA와는 달리 마이바티스는 트랜잭션 매니저를 bean으로 등록해주어야 트랜잭션을 사용할 수 있다.

<bean id="transactionManager" class="org.springframework.jdbc.datasource
.DataSourceTransactionManager">
  <constructor-arg ref="dataSource" />
</bean>

@Configuration
public class DataSourceConfig {
  @Bean
  public DataSourceTransactionManager transactionManager() {
    return new DataSourceTransactionManager(dataSource());
  }
}

  • 트랜잭션 규칙
  1. 클래스에 적용하면 이하의 모든 메서드에는 자동적용
  2. 트랜잭션의 기본 설정은 read와 write가 모두 가능
  3. 인터페이스와 인터페이스의 메서드에도 트랜잭션 적용가능 (잘 안쓴다)
  4. 트랜잭션은 public 메서드에만 적용된다.

프록시에서 트랜잭션을 처리한 후 실제 객체를 호출한다

2. 트랜잭션 주의사항

2-1 프록시 내부호출

  • @transactional가 트랜잭션을 적용하려면 무조건 실제 객체를 복사해서 만들어진 프록시 객체가 트랜잭션을 처리하고 실제객체를 호출하야한다. 만약에 프록시를 거치지 않고 실제객체를 직접 호출하면 트랙잭션은 적용되지 않는다. 프록시 객체가 빈으로 등록되기 때문에 실제 객체가 직접 호출되는 일은 많지 않다.
    하지만 문제는 실제 객체의 내부에서 @transactional 메서드가 호출되는 경우이다.(self-Invocation 문제)
    -> 트랜잭션이 적용된 메서드가 트랜잭션이 적용되지 않은 메서드의 내부에서 사용되는 경우

 @Transactional
 public void internal() {
   log.info("call internal");
   printTxInfo();
 }
 
public void external() {
   log.info("call external");
   printTxInfo();
   internal();  // 트랜잭션이 적용되지 않은 실제 객체의 메서드에서 내부호출
 }
  • 트랜잭션이 적용되지 않은 external() 메서드를 호출하면 실제 객체의external() 메서드를 호출하고 그 안에 있는 @Transactional이 적용되지 않은 internal() 메서드를 호출해서 트랜잭션이 적용되지 않는 것이다.

이를 해결하기 위해 트랜잭션이 적용되는 메서드를 따로 분리해서 클래스를 만든다 그리고 만들어진 클래스는 주입해서 사용한다.

@Slf4j
 static class InternalService {
   @Transactional
   public void internal() {
   log.info("call internal");
   printTxInfo();
 }
 
 @TestConfiguration
 static class InternalCallV2Config {
 @Bean
   CallService callService() {
   return new CallService(innerService());
 }
 @Bean
   InternalService innerService() {
   return new InternalService();
 	}
 }

3. 트랜잭션 옵션

  • 트랜잭션 AOP에서 예외가 발생할 경우 커밋, 롤백 여부
  1. 런타임 예외 발생 : rollback

  2. 체크 예외 발생 : commit
    비즈니스 로직에서 예외가 발생한 경우 체크 예외로 처리하여 기존의 정보를 커밋하고 예외를 처리하는 방식. 이 경우에 런타임 예외를 발생시켜서 rollback이 일어나면 이전까지 해온 비즈니스가 모두 날아가기 때문에 체크예외를 사용한다. 비즈니스 상황에서 발생한 문제를 예외를 통해 알려주는 즉, 예외가 return값으로 활용된다.

  3. 체크예외 rollbackFor 지정 : rollback
    @Transactional(rollbackFor = ) : 비즈니스 로직이지만 커밋이 아니라 롤백을 해야하는 상황인 경우 사용

4. 트랜잭션 전파

  • 트랜잭션이 진행 중인데 이 트랜잭션이 커밋이나 롤백을 하기 전에 내부에서(다른) 트랜잭션이 일어나는 경우로 외부와 내부 트랜잭션을 논리 트랜잭션, 둘을 묶은 하나의 트랜잭션을 물리 트랜잭션이라고 한다.
    같은 물리 트랜잭션을 사용하는 경우에 같은 커넥션을 사용하게 된다.

  • 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다. 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.

  • 내부 트랜잭션은 외부 트랜잭션을 그대로 이어 받아 진행하므로 새로운 트랜잭션이 아니다. 내부 트랜잭션은 새로운 트랜잭션이 아니라서 트랜잭션은 하나뿐이다. 따라서 커밋이나 롤백은 딱 한 번만 가능한데 어떻게 모든 논리 트랜잭션이 커밋되어야 한다고 말할 수 있을까?
    내부 트랜잭션이 물리 트랜잭션을 커밋하면 트랜잭션 전체가 종료되므로, 사실은 내부 트랜잭션에서 커밋을 호출해도 아무 일이 일어나지 않는다. 가장 처음 호출된 외부 트랜잭션의 커밋만 물리 트랜잭션을 관리할 수 있다.

내부 트랜잭션 코드와 트랜잭션 매니저는 하는 일이 거의 없기 때문에 로직1에서 로직 2로 넘어간다고 생각해도 무방하다.

4-1 내부 트랜잭션 롤백

  • 내부 트랜잭션의 커밋, 롤백은 물리 트랜잭션에 영향을 주지 않는데 내부의 롤백이 어떻게 물리 트랜잭션의 커밋을 막을까?
- 내부 트랜잭션에서 롤백코드 실행시
Participating transaction failed - marking existing transaction 
as rollback only

내부에서 롤백을 했는데 외부에서 커밋을 실행했을 시
Global transaction is marked as rollback-only 
but transactional code requested commit

4-2 Requires_new

  • 이 옵션을 사용하면 외부, 내부 트랜잭션이 각각 별도의 물리 트랜잭션과 커넥션을 가져서 서로의 커밋과 롤백에 영향을 주지 않는다. 아주 가끔 사용한다.

  • 로직1에서 로직2로 넘어갈 때 커넥션1은 트랜잭션 동기화 매니저 안에 남아있는 상태로 다른 커넥션2가 사용된다.(커넥션 풀에 반납되는 것이 아님) 커넥션2에서 커밋 또는 롤백이 일어나면 커넥션2는 종료되고, 다시 커넥션 1이 사용된다.

4-3 트랜잭션 전파 예시

  1. service 클래스에는 트랜잭션 설정이 없고, 각 repository에는 되어있는 경우 각 repsoitory에 새로운 트랜잭션이 만들어져서 실행되므로 커밋과 롤백도 서로 영향을 주지 않는다
service class {
	private final RepositoryA a;
    private final RepositoryB b;
    
    public void save(){ a.save }
    public void save(){	b.save }
  }
    
- repositoryA class
	
    @Transactional
    public void save(){ em.persist }
    
    
- repositoryB class
	
    @Transactional
    public void save(){ em.persist }
  1. 각자가 다른 트랜잭션이므로 영향을 주지 않지만 하나는 커밋되고, 다른 하나가 롤백되는 경우 데이터 정합성에 문제가 생길 수 있다. service에만 @Transactional을 쓰면 이하 repository들은 모두 service와 같은 트랜잭션으로 묶이게 된다
@Transactional
service class {
	private final RepositoryA a;
    private final RepositoryB b;
    
    public void save(){ a.save }
    public void save(){	b.save }
  }
    
- repositoryA class
	
    public void save(){ em.persist }
    
    
- repositoryB class
	
    public void save(){ em.persist }
  1. 클라이언트마다 사용하고 싶은 트랜잭션의 범위가 다를 때. 이런 상황에서 트랜잭션 전파가 필요하다.

    일단 모든 논리 트랜잭션이 커밋되는 상황을 보자. 이전에 배웠던 내용과 동일하다.

    이번에는 논리 트랜잭션 중 하나가 롤백되는 경우이다. 이것도 앞에서 배운 것을 그림으로 표현했다.

    논리 트랜잭션에서 발생한 예외가 service에서 처리되지 않고 클라이언트까지 가게 된다.
  1. 여기서 예외가 발생한 트랜잭션은 새로운 트랜잭션(=가장 먼저 만들어진 트랜잭션)이 아니므로 롤백을 하지 않고 rollbackOnly만 기록하고 예외를 던진다. 따라서 service단에서 예외를 잡더라도 물리트랜잭션은 기록된 rollbackOnly를 읽고 롤백을 해버린다.(UnExpectedRollbackException 발생)

  2. REQUIRED_NEW를 사용하면 항상 새로운 트랜잭션을 만들게되고 그 트랜잭션은 새로운 커넥션을 사용하게 된다. 신규 트랜잭션으로 인식되기 때문에 커밋과 롤백에 있어서 다른 물리트랜잭션과 별도로 작동한다.
    주의해야할 점은 REQUIRED_NEW를 사용할 경우 하나의 HTTP 요청에 두 개이상의 커넥션을 사용하게 된다. 성능이 중요한 경우 조심해야한다.

0개의 댓글