티스토리 뷰

사실 @Transactional 은 Spring Boot 로 진행한 첫 프로젝트부터 지금까지 정말 많이 써온 어노테이션이다. 지금까지 사용하면서 이해하고 있었던 바를 먼저 정리해보면 아래와 같다.

 

  1. DB 에 접근하는 서비스 로직에 Transaction 을 적용하고 싶을 때 사용한다.

  2. AOP 를 이용한 선언적 트랜잭션 방식이다.

  3. Aspect 에서 PlatformTransactionManager 가 트랜잭션의 Commit, Rollback 을 처리한다.

 

이번에 회사에서 진행하고 있는 프로젝트에 @Transactional 의 propagation 속성을 이용해 Transaction 전파를 하는 과정에서 조금 더 제대로 알고 사용하고 싶은 마음에 정리해보려고 한다.

 

늘 회사 프로젝트에 새로운 기술을 사용하는 것은 조심스럽다. 최대한 깊이있게 이해하고 적용하고자 하지만 계속해서 느껴지는 압박은 아무리 공부해도 어쩔 수 없는 것 같다.

그래도 나는 상황이 들어맞는다면 최대한 새로운 기술을 사용해보고자 노력한다.

이런 압박이 더 깊이있게 공부할 수 있는 원동력이 될 뿐만 아니라 동료 개발자들에게 설명하고 설득하는 과정에서 많은 것들을 배울 수 있다. 그리고 그 과정에서 서로에 대한 신뢰가 쌓이는 느낌을 받고있다.

 

 

이번 포스팅에서는 공식문서와 구글 서치를 통해 공부한 내용을 우선적으로 정리하고, @Transactional 전파수준을 통해 문제를 해결한 경험을 공유하고자 한다.

 

 

Spring 의 영속성 프로그래밍 모델


Spring 은 개발자가 어떤 환경에서든 일관된 영속성 프로그래밍이 가능하도록 해준다. 즉, 각각 다른 환경에서 다른 Transaction 전략을 사용하더라도 서비스 코드를 변경할 필요가 없다.

 

여기서 말하는 각각 다른 환경이라고 하는 것은 JPA, JDBC 와 같이 Transaction 을 걸고싶은 리소스(DB 등) 와 동기화된 환경을 말하는 것이다.

 

어떻게 이런게 가능할까?

 

Spring Transaction Abstraction


Spring Transaction 추상화에서는 TransactionManager 라는 핵심 개념을 사용하는데, 이는 환경에 따라서 PlatformTransactionManger, ReactiveTransactionManager 로 확장된다.

 

공식문서에서는 환경에 대해서 명령형 & 반응형이라고 표현했는데, 이해를 돕기 위해서 아주 간단히 Spring MVC & Spring WebFlux 의 차이로 이해해도 괜찮을 것 같다. (물론 모든 상황을 포함하지는 않는다)

PlatformTransactionManager 는 Thread 에 바인딩되어 동작한다!

이 사실이 중요한 이유는 Transaction 내부에서 별도의 Thread 가 생성되거나 작업중인 Thread 가 변경(WebFlux, Coroutine 등) 된다면 Transaction 이 제대로 전파되지 않는다.

반면에 ReactiveTransactionManager 이 관리하는 반응형 Transaction 은 ThreadLocal 대신 Reactive Context 를 사용하기 때문에 Thread 와 관련없이 Transaction 이 하나의 컨텍스트 내에서 제대로 전파된다.

이 내용은 실제로 이슈가 생기기 전까지 식별하기 쉽지않고 디버깅도 어려울 것으로 예상되서 반드시 인지하고 넘어가야 할 것 같아서 별도로 내용을 추가했다!

 

 

해당 포스팅에서는 PlatformTransactionManager 를 기준으로 설명할 예정이다.

 

PlatformTransactionManager 는 SPI(Service Provider Interface) 으로 인터페이스다.

각 서비스들은 해당 인터페이스를 구현해 각자의 방법으로 추상메소드를 구한하는 방식으로 확장한다.

 

JDBC 는 DataSourceTranscationManager, JPA 는 JpaTransactionManager 로 PlatformTransactionManager 를 구현한다.

JpaTransactionManager 에 대한 TMI

This transaction manager also supports direct DataSource access within a transaction (i.e. plain JDBC code working with the same DataSource). This allows for mixing services which access JPA and services which use plain JDBC (without being aware of JPA)! Application code needs to stick to the same simple Connection lookup pattern as with DataSourceTransactionManager (i.e. DataSourceUtils.getConnection(javax.sql.DataSource) or going through a TransactionAwareDataSourceProxy). Note that this requires a vendor-specific JpaDialect to be configured.

위 내용에서 JpaTransactionManager 는 DataSource 가 동일하다면 별도의 추가적인 처리 없이도 JDBC 를 이용한 DataSource 접근에 대해서 Transaction 처리를 해준다!!

이 부분이 있어서 JPA + JDBC 를 조합해 JPA 의 Bulk Insert 를 구현하는데 어려움이 없었다 :)

 

 

@Transactional 의 Aspect 는 추상화 타입인 PlatformTransactionManager 를 의존하기 때문에 OCP 를 지킨다. 그렇기 때문에 제일 위에서 언급한 'Spring 의 영속성 프로그래밍 모델에서는 각각 다른 환경에서 다른 Transaction 전략을 사용하더라도 서비스 코드를 변경할 필요가 없다' 는게 가능한 것이다.

 

OCP 를 지키는 것 이외에도 추상화 타입을 의존한다는 것의 장점은 정말 많지만 너무 객체지향에 대한 이야기라 해당 포스팅에서는 설명하지 않는다.

 

Transaction Rollback 제어


Transaction 에 Rollback 해야함을 알리는 방법으로는 Transaction Context 내에서 현재 실행중인 코드로 Exception 을 을 던지는 것이다. 따로 처리하지 않은 Exception 은 Call Stack 에 쌓이고 Transaction 코드가 이 Exception 을 잡아 Transaction 을 Rollback 으로 마킹할지 말지를 결정한다.

 

기본적으로 '따로 처리하지 않은 Exception' 이 맞지만 그렇지 않은 상황도 있다.

내가 정리해서 설명하는 것보다 잘 정리된 포스팅을 소개하는게 더 나을 것 같아 구체적인 설명은 넘어가도록 한다.

https://techblog.woowahan.com/2606/

 

응? 이게 왜 롤백되는거지? | 우아한형제들 기술블로그

{{item.name}} 이 글은 얼마 전 에러로그 하나에 대한 호기심과 의문으로 시작해서 스프링의 트랜잭션 내에서 예외가 어떻게 처리되는지를 이해하기 위해 삽질을 해본 경험을 토대로 쓰여졌습니다.

techblog.woowahan.com

 

기본적으로 Spring 의 @Transactional 은 RuntimeException 과 Error 에 대해서만 Rollback 하도록 마킹한다는 것을 기억하자. Exception(CheckedException) 에 대해서는 Rollback 을 하지 않는기 기본설정이다.

 

물론 이 기본 설정은 @Transactional 의 rollbackFor & noRollbackFor 속성을 통해 변경할 수 있다.

 

AOP 와 @Transactional


Spring 에서@Transactional 이 AOP 방식으로 동작하는 것은 알고있는 사실일 것이다.

그렇기에 당연히 AOP 를 사용할 때 조심해야할 것들에 대해서 @Transactional 을 사용할 때 조심해야하는데 종종 놓치고 지나가는 부분들이 있다.

 

  • private & protected 접근제어자를 가지는 메소드에는 적용되지않는다.
  • 내부호출시에는 적용되지 않는다. (AOP Self-Invocation 문제)

해당 포스팅에서 구체적인 설명은 하지 않겠지만 항상 인지하고 프로그래밍하는 습관을 갖도록 하자!

 

@Transactional 전파방식(Propagation)


Spring 의 @Transactional 에서는 7개의 전파방식을 제공한다.

(REQUIRED - default, REQUIRES_NEW, MANDATORY, NESTED, NEVER, SUPPORTS, NOT_SUPPORTS)

 

Transaction 이 전파한다는 것은 @Transactional 이 붙은 메소드가 실행되다가 또 다른 @Transactional 메소드를 호출했을 때 해당 Transaction 을 어떻게 처리할지에 대한 내용이다.

 

하나씩 살펴볼텐데 Rollback 의 범위를 눈여겨본다면 좀 더 눈에 잘 들어올 것이다. (개인적으로 그랬다)

 

1. REQUIRED (Default)

부모 Transaction 이 있다면 거기에 합류하고, 없다면 새로운 Transaction 을 생성한다.

Default 옵션으로 여러 Depth 의 Transaction 이 호출되더라도 최상위에서 만든 Transaction 만 존재한다.

하나의 Transaction 만 존재하기 때문에 당연히 Rollback 범위도 전체다!

 

2. REQUIRES_NEW

무조건 새로운 Transaction 을 만든다.

직관적으로 봤을 때 부모 Transaction 과 자식 Transaction 은 독립적이어서 서로 어떠한 영향도 주지 않을 것처럼 보인다. 하지만 실제로 자식 Transaction 영역에서 Exception 이 발생하면 부모 Transaction 까지 전파되어 함께 Rollback 된다.

자식 Transaction 에서 발생한 Exception 을 핸들링하지 않으면 당연히 해당 Exception 은 Call Stack 에 쌓이면서 상위 메소드로 전파되고, 당연히 부모 Transaction 도 Rollback 되는 것이다.

이 문제를 해결하는 것은 매우 간단한데 자식 Transaction 에서 발생한 Exception 을 try-catch 문으로 잡아 부모 Transaction 으로 전파하지 않는 것이다. (제일 밑에서 예시를 통해 확인할 수 있다)

 

해당 전파방식은 부모 Transaction 과 자식 Transaction 을 분리해 독립적으로 관리하고 싶을 때 사용한다.

 

3. MANDATORY

부모 Transaction 이 있다면 REQUIRED 전파 방식과 동일하게 합류시키고, 없다면 에러를 발생시킨다.

발생하는 에러는 IllegalTransactionStateException 이다.

 

REQUIRED 전파방식만큼 동작방식은 쉽게 이해가되는데 실제 사용된 사례가 있는지 궁금하다!!

 

4. NESTED

부모 Transaction 이 있다면 해당 Transaction 에 중첩 Transaction 을 생성한다.

 

Ref) https://deveric.tistory.com/86

위 이미지의 5개의 설명 중 밑에 3개를 읽으면 바로 이해가 갈 것같다.

 

5. NEVER

Transaction 을 허용하지 않는다. 부모 Transaction 이 있다면 예외를 발생시킨다.

부모 Transaction 이 없어도 새로운 Transaction 을 만들지 않고 그냥 로직을 실행시킨다.

 

6. SUPPORTS

부모 Transaction 이 있다면 REQUIRED 와 동일하게 합류하지만, 없다면 NEVER 와 동일하게 Transaction 없이 그냥 실행한다.

 

7. NOT_SUPPORTS

부모 Transaction 이 있다면 보류시키고 로직을 실행한다. 없다면 NEVER, SUPPORTS 와 동일하게 Transaction 없이 로직을 실행한다.

 

쉽게 이야기하면 '늘 Transaction 없이 실행' 된다는 것이다.

 

SUPPORTS 와 NOT_SUPPORTS 도 REQUIRES_NEW 와 마찬가지로 자식 Transaction 로직에서 발생한 Exception 이 처리되지 않고 부모 Transaction 으로 전파되었을 때 Rollback 되는 상황이 발생될 것 같다.

 

NEVER, SUPPORTS, NOT_SUPPORTS 는 동작방식 자체가 어렵지는 않지만,,,

@Transactional 이 붙어있는데 Transaction 으로 동작하지 않는다는 점에서 코드를 읽을 때 약간 헷갈리만한 포인트가 될 수도 있겠다는 생각이든다. 물론 모든 팀원이 해당 전파방식에 대한 이해도가 올라와있는 상태라면 상관없겠지만 그렇지 못한 상황이 있을 수도 있기 때문에 고려하도록 하자.

 

 

여기까지가 Spring 에서의 @Transactional 에 대해서 정리한 내용이다.

마지막으로 @Transactional 의 전파방식을 실제 회사 프로젝트에 적용한 사례를 소개하며 포스팅을 마치려고 한다.

 

@Transactional 전파방식 활용사례


거래소 연동을 해지하면 해지한 사용자 정보(member_id) 와 거래소 정보(exchange_id) 를 disconnected_exchange 테이블에 적재한다.

 

그리고 새벽 3시에 도는 Batch 가 해당 테이블을 Chunk 기반으로 읽어서 실제 사용자의 자산정보를 DB 에서 Hard Delete 한다. 이렇게 배치로 삭제하는 이유는 자산정보를 저장하는 테이블이 누적테이블이기 때문에 가입한 기간이 길어질 수록 많은 수의 ROW 가 쌓이기 때문이다. (물론 파티션닝이 되어있어 최대 4달치의 데이터만 저장한다)

override fun write(itesm: MutableList<out DisconnectedExchange>) {
    items.onEach {
    	balanceDomainService.delete(it)
    }.let {
    	try {
            disconnectedExchangeRepository.saveAll(it)
        } catch (ex: RuntimeException) {
    	    logger.error(...)
    	}
    }
}

위 코드는 Spring Batch 의 Writer 의 일부코드이다.

 

Spring Batch 는 기본적으로 Chunk 단위로 Transaction 을 묶어서 처리한다.

1000개의 ROW 를 읽었을 때 999번째에서 Exception 이 발생한다면 기껏지운 998개가 Rollback 되는 상황인 것이다.

 

이러한 문제를 해결하기 위해 balanceDomainService.delete(it) 의 Transaction 전파방식을 REQUIRES_NEW 로 변경했다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
fun delete(disconnectedExchange: DisconnectedExchange) {
    ...
    // 보유 가상자산 정보 삭제 로직
    ...
}

그 결과 1000개의 ROW 를 읽었을 때 중간에 하나가 실패하더라도 그 실패한 ROW 에 대해서만 Rollback 을 수행하는 것을 확인했다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
글 보관함