[Spring] Spring Transactional Propagation에 대하여

mylime·2024년 6월 27일
1

이 포스팅은 2024.06.26에 작성되었습니다.



서론

이번에는 Transaction의 propagation에 대해 정리해보려고 한다.

스프링에서 트랜잭션은 전파라는 특징을 가지고 있다. 이 때 전파속성을 지정해줌으로써, 트랜잭션이 진행 중인 코드 내에서 또 다른 트랜잭션을 진행하는 코드를 호출할 때 동작방식을 설정할 수 있다. 예를 들어 트랜잭션이 지정된 메서드가 다른 트랜잭션이 지정된 메서드를 호출할 경우, 새로운 트랜잭션이 시작되게 할 수 있고, 기존 트랜잭션에 합쳐지게 할 수 있고, 예외를 발생시킬 수도 있다.

트랜잭션의 전파는 어떤 식으로 이루어질까? 예외가 발생했을 때는 어떻게 rollback될 수 있을까? 이 포스팅에서 다 정리해보려고 한다!



Transactional Propagation이란

트랜잭션 전파 속성은 이미 트랜잭션이 진행중일 때 추가 트랜잭션 진행을 어떻게 할지 결정하는 것이다. 전파속성의 종류는 다양한데, 자주 사용되는 속성은 다음 두 가지가 있다.
(자세한 내용은 아래에 더 정리하였다)

  • REQUIRED
    • 존재하지 않는 경우 새로 만들고, 외부 트랜잭션이 있다면 합류
  • REQUIRES_NEW
    • 새로운 transaction을 생성함. 외부 transaction이 있는 경우 일시 중지됨



물리 트랜잭션과 논리 트랜잭션

트랜잭션의 전파속성을 보기 전, 물리 트랜잭션과 논리 트랜잭션에 대한 구분이 필요하여 정리하고 가겠다.


트랜잭션은 데이터베이스에서 제공하는 기술이므로, 커넥션 객체를 통해 처리한다.
하나의 트랜잭션을 사용한다는 것은 하나의 커넥션 객체를 사용한다는 것이고, 실제 데이터베이스의 트랜잭션을 사용한다는 점에서 물리 트랜잭션이라고도 한다.

트랜잭션 전파 속성에 따라 외부 트랜잭션과 내부 트랜잭션이 동일한 물리 트랜잭션을 사용할 수 있다. 하지만 스프링 입장에서는 Transaction manager를 통해 트랜잭션을 처리하는 곳이 2곳이다. 그래서 실제 데이터베이스 트랜잭션스프링이 처리하는 트랜잭션 영역을 구분하기 위해 스프링은 논리 트랜잭션 개념을 추가하였다.

  • 물리 트랜잭션: 실제 데이터베이스에 적용되는 트랜잭션으로, 커넥션을 통해 커밋/롤백하는 단위
  • 논리 트랜잭션: 스프링이 트랜잭션 매니저를 통해 트랜잭션을 처리하는 단위

즉, 트랜잭션 전파 없이 기존 트랜잭션에 합류하는 전파속성의 경우, 개별 논리 트랜잭션 2개가 하나의 물리 트랜잭션을 공유할 수 있다.
(즉, 코드 상으로 분리되어 보이는 두 트랜잭션이 사실 한 트랜잭션 안에서 실행되고 있는 경우)

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



스프링에서 @Transactional propagation 속성

@Transactional
public Member addMember(MemberDto.CreateRequest request) {
    Member member = Member.createMember(request.getLoginId(), request.getPassword(), request.getName());
    Member savedMember = memberRepository.save(member);

    return savedMember;
}

스프링의 @Transactional 어노테이션에는 속성을 지정해줄 수 있는데, 목록을 보면 다음과 같다.

isolation, label, noRollbackFor과 오늘 알아볼 propagation 등 다양한 속성이 있는 걸 볼 수 있다. 이제 @Transactional을 타고 들어가보자.

@Transactional

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Reflective
public @interface Transactional {
	@AliasFor("transactionManager")
	String value() default "";

	@AliasFor("value")
	String transactionManager() default "";

	String[] label() default {};
	Propagation propagation() default Propagation.REQUIRED;
	Isolation isolation() default Isolation.DEFAULT;
	int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
	String timeoutString() default "";
	boolean readOnly() default false;
	Class<? extends Throwable>[] rollbackFor() default {};
	String[] rollbackForClassName() default {};
	Class<? extends Throwable>[] noRollbackFor() default {};
	String[] noRollbackForClassName() default {};
}

propagation만 살펴보면, default는 Propagation.REQUIRED라고 나와있다.


Propagation

public enum Propagation {
	REQUIRED(TransactionDefinition.PROPAGATION_REQUIRED),
	SUPPORTS(TransactionDefinition.PROPAGATION_SUPPORTS),
	MANDATORY(TransactionDefinition.PROPAGATION_MANDATORY),
	REQUIRES_NEW(TransactionDefinition.PROPAGATION_REQUIRES_NEW),
	NOT_SUPPORTED(TransactionDefinition.PROPAGATION_NOT_SUPPORTED),
	NEVER(TransactionDefinition.PROPAGATION_NEVER),
	NESTED(TransactionDefinition.PROPAGATION_NESTED);

	private final int value;

	Propagation(int value) {
		this.value = value;
	}

	public int value() {
		return this.value;
	}
}

Propagation은 TransactionDefinition 인터페이스의 속성들을 담은 enum인 것을 확인할 수 있다. 속성들에 대한 설명은 다음과 같다.

  • REQUIRED
    • 현재 트랜잭션을 지원하며, 존재하지 않는 경우 새로 만듦
    • 일반적으로 트랜잭션 기본 설정, 일반적으로 트랜잭션 동기화 범위를 정의함
  • SUPPORTS
    • 현재 트랜잭션을 지원하며, 존재하지 않는 경우 트랜잭션 없이 실행
    • +) 트랜잭션 동기화를 사용하는 transaction manager의 경우, PROPAGATION_SUPPORTS는 동기화가 적용될 수 있는 트랜잭션 범위를 정의하므로, transaction이 없는 것과는 약간 다르다.
    • 결과적으로, 동일한 리소스가 지정된 전체 범위에 대해 공유되며, 정확한 동작은 transaction manager의 실제 동기화 configuration에 따라 다름
  • MANDATORY
    • 현재 트랜잭션을 지원하며, 존재하지 않는 경우 예외발생
    • 범위 내의 동기화는 항상 주변 transaction에 의해 주도됨
  • REQUIRES_NEW
    • 새로운 transaction을 생성하고, 현재 transaction이 있는 경우 일시 중지
    • REQUIRES_NEW의 범위는 항상 자체 트랜잭션 동기화를 정의함
    • 기존 트랜잭션은 동기화가 일시적으로 중단되고 적절하게 재시작됨
  • NOT_SUPPORTED
    • 현재 transaction을 지원하지 않고, 항상 트랜잭션 없이 실행됨.
    • 해당 범위 내에서는 트랜잭션 동기화를 사용할 수 없음
  • NEVER
    • 현재 transaction을 지원하지 않고, 존재하는 경우 예외를 발생시킴
    • 해당 범위 내에서는 트랜잭션 동기화를 사용할 수 없음
  • NESTED
    • 현재 transaction이 존재하는 경우 중첩된 트랜잭션 내에서 실행하고, 그렇지 않으면 REQUIRED처럼 동작
    • 중첩 Transaction의 실제 생성은 특정 transaction manager에서만 작동함
    • 이미 진행중인 트랜잭션에 중첩 트랜잭션을 만드는 것으로, 독립적인 트랜잭션을 만드는 REQUIRES_NEW와는 다르다.
    • 중첩 트랜잭션은 부모 트랜잭션의 영향(commit, rollback)을 받지만, 중첩 트랜잭션이 외부에는 영향을 주지 않는다. 즉, 중첩 트랜잭션이 롤백 되어도 외부 트랜잭션은 커밋이 가능하지만 외부 트랜잭션이 롤백되면 중첩 트랜잭션은 함께 롤백됨
    • NESTED는 JDBC의 savepoint 기능을 사용하는데, DB 드라이버가 이를 지원하는지 확인이 필요하며 JPA에서 사용이 불가능

(지원한다 = 해당 트랜잭션을 그대로 따른다, 같은 동기화 커넥션을 사용한다)

굉장히 많아보이는데, 일반적으로는 REQUIREDREQUIREDS_NEW 옵션을 사용한다고 한다.


새 트랜잭션이 시작되지 않는 속성의 경우 로컬 격리수준, timeout 설정, readonly 플래그는 적용되지 않는다. 기본적으로 참여하는 transaction은 이 속성들을 자동으로 무시하고 외부 범위의 특성에 조인한다.

(* 다른 격리 수준으로 기존 트랜잭션에 참여할 때 격리 수준 선언을 거부하려면 transaction manager에서 verifyExistingTransactions 플래그를 true로 전환할 수 있다)



Propagation 더 자세히 알아보기

Spring에서의 Transaction Propagation에 대해 더 자세히 알아보자.


PROPAGATION_REQUIRED

PROPAGATION_REQUIRED의 동작 방식은 다음과 같다.

  • 트랜잭션이 아직 존재하지 않는 경우에는 현재 범위에 대해 local로 물리적 트랜잭션을 시행
  • 더 큰 범위에 대해 정의된 '외부' 트랜잭션이 있다면, 해당 트랜잭션에 참여하여 물리적 트랜잭션을 시행

PROPAGATION_REQUIRED은 동일한 스레드 내의 일반적인 호출 스택 배열에서 좋은 기본값이다. (ex. 모든 기본 리소스가 서비스 레벨의 트랜잭션에 참여해야하는 여러 repository method가 있는 경우 등)


PROPAGATION_REQUIRED를 사용할 때 어떤 transaction 안에서 TransactionTemplate를 통해 트랜잭션을 열려고 시도할 경우, AbstractPlatformTransactionManager.getTransaction()은 이미 열려있는 기존 트랜잭션을 반환한다.
즉, 외부의 기존 트랜잭션이 있다면 해당 트랜잭션에 참여하게 된다.

@Transactional 관련 코드를 타고 들어가다보면 getTransaction()이 호출되는 걸 볼 수 있는데, 현재 메서드에 붙은 트랜잭션이 REQUIRED 속성이면서 외부 Transaction이 이미 존재한다면 이미 있는 곳에 합류한다고 생각하면 되겠다.



rollback 관련

PROPAGATION_REQUIRED의 경우 모든 범위는 동일한 물리적 트랜잭션에 매핑되고, 설정이 적용된 각 메서드에는 논리적 트랜잭션 scope가 생성된다. 그리고 논리 트랜잭션들 중에서 1개라도 롤백되었다면 전체 물리트랜잭션이 롤백된다고 하였다.


만약 내부 트랜잭션에서 예외가 발생한 경우, 내부 트랜잭션에서 rollback-only 마커를 설정하게 된다. rollback-only 마커가 설정되어 있다면 외부 트랜잭션은 커밋 직전 예외가 발생한다. 즉, 내부 트랜잭션이 롤백되는 경우 바깥쪽 트랜잭션도 롤백된다.
(내부에서 롤백이 발생했는데도 외부 호출자는 여전히 커밋을 호출하게 된다. 내부 트랜잭션에서 롤백을 하여도 즉시 롤백되지 않는다. 커밋을 호출할 때 exception이 발생함)

예시를 보자

transactionTemplate.execute(status -> {
    memberDao.saveMember(name);

    try {
        transactionTemplate.execute (s -> {
            throw new RuntimeException("some unexpected exception");
        });
    } catch(RuntimeException e){
    }

    return "";
});

위 코드가 실행되더라도 Member는 DB에 저장되지 않는다.

안쪽 트랜잭션에서 예외를 던지면 해당 쓰레드에 rollback-only 마커가 남고, 이 상태에서 바깥쪽 트랜잭션이 커밋되려고 하면 UnexpectedRollbackException 예외가 던져지면서 트랜잭션이 커밋되지 않고 롤백된다. rollback-only 마커가 있다면 무조건 해당 물리 트랜잭션이 롤백된다.

Transaction silently rolled back because it has been marked as rollback-only



내부 트랜잭션을 열 때 사용한 TransactionDefinition이 적용되지 않음

@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.REPEATABLE_READ)
public void test1() {
    memberRepository.save(new Member("라임이"));
    test2();
}

@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.SERIALIZABLE) 
public void test2() {
    messageRepository.save(new Message("하잉"));
}

여기서 SERIALIZABLE로 실행되길 바라지만, 두 코드는 사실 같은 트랜잭션 안에서 실행된다. 코드상의 트랜잭션(논리 트랜잭션)은 2개지만, 물리트랜잭션은 하나이다. 이 때 REQUIRED의 경우 외부 트랜잭션의 설정을 따라가게 된다. (getTransaction()을 호출함)



REQUIRES_NEW

@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.REPEATABLE_READ)
public void test1() {
    memberRepository.save(new Member("라임이"));
    test2();
}

@Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE)
public void test2() {
    messageRepository.save(new Message("하잉"));
}

이 속성의 경우, 새로운 물리적 커넥션에서 새로운 Entity Manager를 가지고 새로운 트랜잭션을 열게된다. 격리수준도 외부 트랜잭션과 다른 SERIALIZABLE로 설정된다. 롤백도 완전히 독립적으로 이루어진다.

하지만 완전히 새로운 트랜잭션이 열린다는 점에서 단점이 생긴다.

1) 커넥션 낭비

서로 다른 물리 트랜잭션을 가진다는 것은 서로 다른 디비 커넥션이 사용된다는 것이다.
즉, 이 속성은 connection pool의 connection을 하나 더 차지한다. 하나의 HTTP요청에서 두 개의 커넥션을 차지한다는 거다.

또한 내부 트랜잭션이 처리 중일때는 꺼내진 외부 트랜잭션이 대기하는데, 커넥션이 아무것도 안하고 대기한다는 건 낭비라고 생각된다.


2) 데드락 발생 가능

독립적으로 열린 두 트랜잭션 사이에 데드락이 걸릴 수 있어서 유의해야한다


3) 쿼리 실행의 비효율

두 트랜잭션은 entity manager를 공유하지 않기 때문에 영속성 컨텍스트 역시 공유하지 않고, 이로 인해 쿼리 실행의 비효율이 발생할 수 있다. (수정을 위해 다시 조회해야하는 등의 상황 발생)

REQUIRES_NEW는 조심해서 사용해야 하며, 만약 REQURES_NEW 없이 해결 가능하다면 대안책(별도의 클래스를 두기 등)을 사용하는 것이 좋다.


꼭 사용해야하는 곳?

이렇게 비효율적인 REQUIRES_NEW를 꼭 사용해야만하는 상황이 있는지 궁금해서 찾아봤다.
하지만 비즈니스 로직과 로그를 따로 저장하는 것 외에는 딱히 예시가 보이지 않았다. 그리고 대부분은 REQUIRES_NEW를 대체할 대안책이 있다면 이를 사용하는 게 좋다고 하였다.

즉, 웬만하면 REQUIRES_NEW는 사용하지 않는 게 좋을 것 같다.



마치며...

트랜잭션 propagation에 대해 알아봤다. 트랜잭션은 공부하면 할수록 꼭 알아야하는 개념이라는 게 느껴졌다. 트랜잭션에 대해 모르고 계속 사용한다면 분명 문제가 발생할거다. 트랜잭션을 예측 가능하게 사용하는 것은 어플리케이션 개발에서 매우 중요하므로, 깊게 공부하고 이해해서 예상치 못한 예외가 발생하지 않도록 노력해야겠다.



참고자료

https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/tx-propagation.html
https://suhwan.dev/2020/01/16/spring-transaction-common-mistakes/

https://mangkyu.tistory.com/269

profile
깊게 탐구하는 것을 좋아하는 백엔드 개발자 지망생 lime입니다! 게시글에 틀린 정보가 있다면 지적해주세요. 감사합니다. 이전블로그 주소: https://fladi.tistory.com/

2개의 댓글

comment-user-thumbnail
2024년 6월 30일

오! 트랜잭션의 종류가 물리적 트랜잭션과 논리적 트랜잭션 두개가 있는지 처음 알았네요! 전파 속성에 대해서도 구체적으로 알아갑니다! 그럼 nested 속성과 required 속성의 차이점은 내부 트랜잭션이 외부에 의존적이냐 아니냐의 차이점인건가요?

1개의 답글