Spring Boot - @Transactional과 속성들, 프록시 패턴

Kyu0·2023년 1월 1일
0

참고자료
https://dzone.com/articles/how-does-spring-transactional
https://www.baeldung.com/transaction-configuration-with-jpa-and-spring

@Transactional 이란?🤔

스프링 프레임워크에서는 AOP가 적용된 부분들이 많은데, @Transactional 어노테이션 또한 그 중 하나입니다. (AOP의 의미와 Spring Boot에 적용하기)

@Transactional 어노테이션이 적용된 메소드는 실행 중 예외가 발생하면 해당 메소드를 실행하면서 수행한 쿼리들을 모두 롤백하고, 예외가 발생하지 않으면 변경 사항을 저장(커밋)합니다.

이로써 트랜잭션의 관리를 용이하게 할 수 있도록 도와주는 어노테이션입니다. 다음으로는 @Transactional 어노테이션이 실제로 트랜잭션 관리를 도와주는지 테스트해보도록 하겠습니다.


테스트 수행✍🏻

테스트 전 MEMBER 테이블의 레코드 상태입니다. 아이디가 test@naver.com 인 레코드 하나만 저장되어 있음을 볼 수 있습니다.

// MemberService.java

@AllArgsConstructor
@Service
public class PostService {

	// .. 멤버 변수 선언 생략
    
	public String save(SaveRequest request) {
    	String savedId = memberRepository.save(request.toEntity()).getId();
        
        if (true) {
        	throw new Exception("예외 발생~");
        }
        else {
        	return savedId;
        }
    }
}

위와 같이 회원가입 요청이 들어오면 데이터베이스에 저장 후 무조건 예외가 발생하도록 코드를 작성했습니다. 현재는 @Transactional 어노테이션이 적용이 되지 않은 상태이기 때문에 INSERT 쿼리를 실행한 후 예외가 발생해도 데이터는 그대로 저장될 것임을 예상할 수 있습니다.

예상대로 예외가 발생했음에도 데이터베이스에 저장한 레코드가 그대로 남아있음을 확인할 수 있습니다.

이제 저장된 레코드를 삭제하고, save(SaveRequest request) 메소드에 @Transactional 어노테이션을 적용한 뒤 똑같이 테스트를 수행하겠습니다.

// MemberService.java

@AllArgsConstructor
@Service
public class PostService {

	// .. 멤버 변수 선언 생략
    
    @Transactional(rollbackFor = Exception.class)
	public String save(SaveRequest request) {
    	String savedId = memberRepository.save(request.toEntity()).getId();
        
        if (true) {
        	throw new Exception("예외 발생~");
        }
        else {
        	return savedId;
        }
    }
}

@Transactional 어노테이션을 적용했을 경우, INSERT 쿼리를 수행한 이후에 예외가 발생했더라도 정상적으로 롤백을 진행해 테이블을 원래의 상태로 되돌린 것을 확인할 수 있습니다.

이렇게 간편하게 트랜잭션을 관리해주는 @Transactional 어노테이션은 어떤 원리로 동작하는 걸까요?


관점 지향 프로그래밍과 프록시 패턴🙋🏻‍♂️

스프링 부트에는 곳곳에 관점 지향 프로그래밍(Aspect Oriented Programming, AOP)이 적용되어 있습니다. @Transactional 어노테이션은 그 중 하나입니다. (Spring Boot에 AOP 적용하기)

그리고 @Transactional 어노테이션이 붙은 메소드에 대해 트랜잭션을 관리하기 위해 프록시 패턴을 이용하며 다음과 같이 동작합니다.(참고글, Stackoverflow - Spring - @Transactional - What happens in background?)

출처 : https://www.codeusingjava.com/boot/trans/1

위의 그림은 @Transactional 어노테이션을 적용하지 않았을 경우의 Flow Chart입니다. Controller → Service → Repository 의 흐름으로, 우리가 설계한 그대로 메소드를 호출하고 있습니다.

하단의 그림은 @Transactional 어노테이션을 적용했을 경우의 Flow Chart입니다. ControllerService 레이어 사이에 Proxy 객체가 추가되어 Service 레이어를 대리 호출하고 있는 모습을 볼 수 있습니다.

public class MockMemberService extends MemberService {
	
    @PersistenceContext
    private final EntityManager em;
    
    public String save(SaveRequest request) {
    	try {
       		EntityTransaction tx = em.getTransaction();
            
			tx.begin();
            super.save(request); // Target의 메소드 호출
            tx.commit();
        }
        catch (Exception e) {
        	tx.rollback();
        }
    }
}

Proxy 객체는 위와 같은 흐름으로 실행되며, @Transactional 어노테이션이 이렇게 적용됨으로써 Service 레이어의 변화없이 트랜잭션 작업을 수행할 수 있습니다.


@Transactional 속성들💽

여타 어노테이션들과 같이 @Transactional 어노테이션도 개발자가 지정해줄 수 있는 속성들이 있습니다.

  • propagation(Propagation) : 트랜잭션의 전파 타입을 정하는 속성입니다. 아래는 전파 타입에 관해 설명한 표입니다.
전파 타입설명
REQUIRED기본값으로 설정되는 전파 타입입니다. 기존에 활성화된 트랜잭션에 자식 트랜잭션이 합류하여 하나의 트랜잭션으로 취급하는 타입으로, 둘 중 하나의 트랜잭션에서 예외가 발생하면 모두 롤백이 수행됩니다.
REQUIRED_NEW기존에 활성화된 트랜잭션이 있더라도 합류하지 않고 별개의 트랜잭션으로 취급하여 수행되는 전파 타입입니다. 예외가 발생한 트랜잭션에서만 롤백이 수행됩니다.
SUPPORTS기존에 활성화된 트랜잭션이 있다면 합류를 하고, 활성화된 트랜잭션이 없다면 합류하지 않고 트랜잭션 없이 그대로 작업을 수행합니다. 트랜잭션이 그다지 필요없는 SELECT 쿼리에 적용하면 성능 향상을 기대할 수 있다고도 합니다.
NOT_SUPPORTED기존에 활성화된 트랜잭션 유무에 상관없이 트랜잭션 없이 작업을 수행합니다. 활성화된 트랜잭션이 존재한다면 일시정지 후 작업을 완료하고 재시작을 하는 동작을 거치게 됩니다.
MANDATORY기존에 활성화된 트랜잭션이 존재할 경우 해당 트랜잭션에 합류하며, 존재하지 않을 경우 예외를 발생시킵니다.
NEVER기존에 활성화된 트랜잭션이 존재할 경우 예외를 발생시키며, 활성화된 트랜잭션이 없을 경우 활성화된 트랜잭션에 합류하지 않고 작업을 수행합니다.
NESTED기존에 활성화된 트랜잭션이 존재할 경우 save point를 표시하며 예외가 바생할 경우 해당 save point 지점으로 롤백됩니다. 활성화된 트랜잭션이 존재하지 않을 경우 REQUIRED와 같이 동작합니다.
  • isolation(Isolation) : 트랜잭션의 고립 수준을 정하는 속성입니다. 고립 수준에 따라 쿼리 실행 속도와 안정성에 차이가 날 수 있습니다. 이 글을 참고하시면 이해하는데에 도움이 될 것 같습니다.

  • timeout(int) : 트랜잭션의 타임아웃 시간을 초(sec) 단위로 설정하는 속성입니다.
    트랜잭션을 수행하면서 타임아웃으로 설정한 시간을 넘어가게 되면 롤백을 수행하며 실행 이전의 상태로 되돌립니다.
    기본 설정값은 Transaction System의 값을 따른다고 설명이 되어 있으며, JpaTransactionManager 의 경우 기본값으로 -1로 지정되어 있어 별도로 타임아웃 시간을 설정하지 않고 있습니다.

  • timeoutString(String) : timeout 속성과 동일한 속성이며 인자로 int 타입 변수가 아닌 String 타입의 변수를 입력 받습니다.

  • readOnly(boolean) : 읽기 전용 트랜잭션 여부를 지정하는 속성입니다.
    Spring JPA의 경우 데이터베이스에서 조회한 엔티티에 대해서 영속성(Persistence)을 가지고 있습니다.
    여기서, 단순히 조회 결과를 반환하는 기능의 경우 엔티티 추가, 수정 작업이 이뤄지지 않기 때문에 엔티티의 영속성을 가지고 있는 것이 불필요하다고 볼 수 있습니다.
    readOnly 속성을 true로 설정하게 되면 이러한 불필요한 작업들을 수행하지 않아 효율적으로 조회 트랜잭션을 수행할 수 있도록 합니다. 기본값은 false입니다.

  • rollbackFor(Class<? extends Throwable>[]) : 어떤 예외가 발생했을 때 롤백을 수행할 지 지정하는 속성입니다.
    기본적으로 RuntimeException, Error 가 발생했을 경우에만 롤백을 수행하며 Checked Exception 에 대해서는 롤백을 수행하지 않습니다.
    우선 RuntimeException 에는 ArrayIndexOutOfBoundsException, ClassCastException 등 런타임 중에 발생되는 예외들이 있으며 코드 작성 단계에서는 해당 예외에 대해서 처리를 해줄 수 없습니다.
    반면, Checked Exception 에는 IOException, SQLException 등이 있으며 반드시 try-catch 혹은 throws 구절을 통해 해당 예외를 처리하는 로직을 작성해야 합니다.
    때문에 코드 작성 및 컴파일 단계에서 해당 예외가 발생할 수 있는 가능성이 있다는 것을 확인할 수 있고, 예외 처리에 관한 로직도 작성할 수 있습니다.
    @Transactional 어노테이션은 프록시 객체를 통하여 서비스 클래스의 메소드를 실행하기 때문에 Checked Exception 이 발생했을 경우에도 롤백을 수행하게 된다면 컨트롤러 클래스에게까지 예외가 전파되지 않아 잘못된 동작을 할 가능성이 높아지기 때문에 개발자가 처리할 수 있는 예외인 Checked Exception 에 대해서는 기본적으로 롤백을 수행하지 않는 것으로 보입니다.

  • rollbackForClassName(String[]) : rollbackFor 속성과 동일한 속성이며 인자로 Class 가 아닌 String 배열을 입력 받습니다.

  • noRollbackFor(Class<? extends Throwable>[]) : 롤백을 수행하지 않을 예외 클래스를 지정하는 속성입니다. 이 속성에 지정된 예외들은 발생하더라도 롤백이 수행되지 않습니다.

  • noRollbackForClassName(String[]) : noRollbackFor 속성과 동일한 속성이며 Class 가 아닌 String 배열을 입력 받습니다.


마무리

기존에는 readOnly 속성이 트랜잭션 성능 튜닝에 큰 영향을 끼칠 것이라 생각했는데, 자세히 알아보니 propagation, isolation level 등 여러 요인들도 같이 설정해주면 효율적인 쿼리 사용이 될 수 있겠다는 생각이 들었습니다. ㅎㅎ

잘못된 내용이나 오타 지적 언제나 환영입니다.

profile
개발자

0개의 댓글