Spring 에서 Transaction 전파옵션 다루기

허준현·2023년 10월 6일
0

Spring

목록 보기
2/3
post-thumbnail

프로젝트 내부에서 파일 시스템 연계를 맡아서 진행하는데 파일 내부에서 협의된 Interface를 지키지 못한 데이터가 넘어오는 경우가 발생하였다.
이런 경우에 해당 파일에서 앞에서 저장되었던 내용까지 롤백이 되는 경우는 방지해야 한다. 해당 문제를 개선하기 위해 공부했던 트랜잭션 내용을 다루고자 한다.

필자가 원하는 상황 :
파일이 수천개가 존재하고 각 파일에는 천 개 이상의 데이터가 존재하므로 파일 하나에 오류 데이터가 있는 경우에 전부 롤백이 되는 경우 보정 이후에 처리하기에 너무 많은 데이터가 쌓인다. 따라서
1. 오류가 있는 파일만 롤백되고 이후 프로세스 진행하여 나머지 파일은 커밋
2. 오류가 있는 데이터만 넣지 않고 오류가 있는 파일내에 다른 데이터들 또한 커밋

해당 문제를 해결하기 전에 Transaction에 대해서 간략하게 집고 넘어가자.

@Transactional을 사용시에 주의해야 할 점

먼저 인터넷에도 설명이 잘 되어있는 @Transactional을 사용시에 주의해야 할 점에 대해 알아보자. 김영한님의 강의 및 블로그에서 다루는 것은 크게 2가지이다.

1. public 이외의 모든 접근제어자는 @Transactional을 선언해도 적용되지 않는다.

이는 프록시를 설정하는 방법중에 cglib proxy 방식을 사용함으로서 프록시 객체는 기존 클래스를 상속하여 구현되는데 public이 아닌 메소드는 상속할 수 없기 때문이다.

2. 동일한 클래스 내에서 @Transactional이 선언되지 않은 메소드에서 @Transactional이 선언된 메소드를 호출해도 트랜잭션이 적용되지 않는다.

이는 클래스 내부 메소드를 호출 할 떄에는 트랜잭션 프록시가 호출되지 않기 때문에 적용되지 않는다. 그러면 반대로 @Transactional이 선언되어 있는 메소드에서 선언부가 없는 메소드를 호출하거나 동일하게 선언이 되어있는 메소드를 호출시에는 어떻게 작용할까?

이 곳 에서도 잘 설명이 되어있는데 내부 메소드를 호출하다 보니 이후에 실행되는 내부 메소드는 트랜잭션 프록시가 적용되지 않는다.

다만 첫번째 메소드를 호출하면서 트랜잭션이 시작되었고 메소드가 종료되지 않았기 때문에 두번째에 실행된 메소드는 첫번째 메소드의 트랜잭션 내부에 포함 된다.

하지만 내부 메소드에 구현하게 되는 경우

@Transactional(transactionManager = "*txManager", propagation=Propagation.REQUIRES_NEW)

를 비롯한 다른 Transaction을 쓰거나 다른 전파 옵션을 선언한 메소드를 내부 클래스에 선언하게 된다면 위에서 언급한 것 처럼 별도의 트랜잭션 프록시가 적용되지 않고 기존의 트랜잭션 안에 포함되어 전파 옵션이 적용되지 않는다. 따라서 해당 경우에는 별도의 클래스 안에 선언하여 호출하는 방식으로 구현해야 한다.

이상으로 트랜잭션을 선언함에 있어서 주의점에 대해서 알아보았다.
이제 각 업무에서 원하는 방향으로 파일 데이터를 처리하는 방안에 대해서 알아보자.

파일처리하는 방식

파일을 읽을 시에 파일 내부에 문제가 발생하는 데이터가 넘어오더라도 해당 작업이 멈추지 말고 다음 파일을 읽는 작업을 해야 한다. 따라서 생각했던 방안은 크게 2가지 이다.

1. 모든 insert 문에 try-catch을 작성하는 방법
해당 경우에는 오류가 발생한 데이터만 db에 들어가지 않고 나머지 데이터들이 들어갈 것이다.

2. 트랜잭션 전파 옵션중에 자식 서비스에 Propagtion.REQUIRES_NEW 를 사용하는 방법
오류가 발생한 파일에 대해서만 insert를 하지 않고 나머지 파일에 대해서만 데이터가 들어갈 것이다.

해당 경우들에 대해서 진행하면서 발생했던 문제와 어떤 방식을 선택하면 좋을지 다뤄보고자 한다.

첫번째 방법

오류가 부모 서비스 까지 전파되는 것을 막기 위해 try-cath를 사용할 것이다.
그러면 try-catch를 어디에 선언하는 것이 좋을까?

1. 부모 서비스에서 자식 서비스를 호출하는 부분에 try-catch
2. 자식 서비스에서 insert, update가 발생하는 쿼리에 try-catch

해당 경우의 차이점은 무엇일까?

첫번째 경우

부모 서비스에서 에러를 잡고 있기 때문에 자식 서비스의 여러개의 insert에 대해서 하나라도 에러가 발생하게 된다면 앞에서 실행된 insert가 롤백 될 것이다.
또 한 부모에서 에러를 잡게되면 트랜잭션 내부에 rollbackonly값이 true로 변경함에 따라서 부모의 트랜잭션 또 한 롤백이 진행된다.
자세한 내용은 우아한 기술블로그를 참고하도록 하자.
해당 경우에는 에러가 발생시에 에러를 저장하는 쿼리 또한 작동하지 않기 때문에 사용하지 않았다.

두번째 경우

다음과 같은 상황이라고 가정하자.

//SubService.java
try{
	*Dao.insert1();
    *Dao.insert2();
} catch(Exception e){
	throw e;
}

위와 같은 경우에는 try-catch문 안에는 트랜잭션이 적용되지 않기 떄문에 insert1()가 정상작동하면 insert되고 insert2에서 오류가 발생하더라도 롤백되지 않는다.
따라서 파일 안에 데이터들을 정상 insert하다가 오류 건을 만나게 되더라도 기존의 데이터는 롤백되지 않는다.
따라서 try-catch 문을 사용한다면 insert문에 전부 try-catch를 사용하여 해결해야 한다. 해당 방법을 사용한다면 우리가 원하는 첫번쨰 방식을 구현할 수 있다.

번외편 : try-catch문을 사용하게 된다면 프록시가 해당 쿼리에 대해 알수 없다고 하였다. 그러면 try 내부 말고 catch문 안에 로깅 쿼리를 작성하게 되면 해당 에러에 대해서 추적 가능할 것이다.

두번째 방법

전 포스팅에서 배웠듯이 트랜잭션에는 여러가지 전파 옵션을 사용자에게 제공한다. 그 중에서 새로운 Transaction을 사용하고 진행중인 Transaction은 보류되는 Propagtion.REQUIRES_NEW 를 사용해보자.

단순하게 외부 클래스의 메소드에서 선언

Propagtion.REQUIRES_NEW 전파 옵션을 메소드에만 선언하면 자식 서비스에서 일어나는 오류가 부모 서비스까지 오류가 전파되는지 확인 해야 한다.
대부분의 사람들이 오해하는 것이 해당 전파 옵션을 사용하면 오류가 전파되지 않는다고 생각하는데 해당 옵션은 동일 스레드 내에서 별도의 커넥션을 잡아 트랜잭션을 생성하는 것이다.

즉 자식 서비스에서 에러가 발생해 자식 서비스가 롤백이 된다면 부모 메소드 또한 롤백이 된다. 따라서 단순하게 전파옵션만을 추가하는 것이 아닌 별도의 작업이 필요하다.

TransactionalListener를 사용

부모 트랜잭션 진행이 완료된 이후에 해당 트랜잭션을 진행하고자 하는 경우 해당 어노테이션을 사용하면 된다. 해당 어노테이션은 ApplicationEventPublisher를 이용하여 이벤트를 저장할 수 있게 구현 할 수 있으며 부모 서비스가 커밋된 이후에 진행 할수 있다.
다만 주의해야 하는 점은 기존에 단순하게 외부 클래스의 메소드에서 선언하는 경우에는 기존의 트랜잭션을 보류하나 listener의 경우에는 부모의 트랜잭션을 commit 한 이후에 자식 서비스를 호출하게 된다.
즉 부모 트랜잭션은 종료된 이후에 자식 서비스를 호출하는데 이 때 DDL(insert,update,delete)문을 실행하게 된다면 정상작동 하지 않는다.
이유는 @TransactionalEventListener의 경우 event publisher의 트랜잭션 안에서 동작하며, 커밋이 된 이후 추가 커밋을 허용하지 않기 때문이다.
따라서 REQUIRES_NEW 전파 옵션을 사용하거나 @async를 사용를 해야 한다.

try-catch 사용

위에서 알아본 것처럼 자식, 부모 쪽에 try-cath를 선언하는 방법이 있다.
자식 쪽에 try-catch문을 사용한다면 에러를 바로 핸들링해서 별도의 전파옵션이 필요하지 않다. 따라서 부모쪽에 try-catch를 하는 것을 생각해 볼 수 있다.

REQUIRES_NEW 옵션을 사용하게 되면 동일한 쓰레드에 별도의 트랜잭션이 생성된다는 것을 배웠다. 따라서 자식 서비스에서 오류가 발생한다고 하더라도 자식 트랜잭션에 rollbackonly값이 True로 변경되는 것이고 부모 서비스는 try-catch로 묶여있다 보니 rollback 유무를 모르게 되는 것이다. 따라서 부모 클래스에서 try-catch, 자식 클래스에서 옵션을 설정해 주면 우리가 원하는 방식 2번째를 구현할 수 있다.

참조

@async 사용

전 포스트에서 @async 에 대해서 다루는 방법에 대해 배웠다. 사용자가 미리 설정한 쓰레드 풀에서 쓰레드를 가져와 해당 작업을 진행하는 것이다.
@async를 사용하게 된다면 기존과 다른 쓰레드가 생성되고 해당 쓰레드 안에서 트랜잭션은 전의 트랜잭션과 분리된다.
따라서 별도로 전파 옵션을 설정하지 않고 별도의 transaction을 사용할 수 있다.
해당 경우에 대해서 Test를 진행 할 때 전파 옵션 MANDATYORY를 사용한다면 해당 트랜잭션의 부모 트랜잭션이 존재 하지 않기 때문에 오류가 발생하게 된다.
참조

어떤 방식을 활용하는 것이 좋을까?

사실 어느정도의 차이가 있냐고 찾으면 첫번째 방법 자식에서 try-catch두번째 Required-new 는 트랜잭션을 생성하냐의 유무에 따르겠지만 부모쪽에서 try-catch를 신경써야 하는지를 확인하는 것이 더 차이가 있다고 생각한다.

어떤 방법을 선택할지는 각자의 업무에 맞게 선택하면 되며 확실한 차이가 있다면 댓글로 알려주길 바란다. 😃

참고 :

REQUIRES_NEW
REQUIRES_NEW 와 try-catch
우아한 기술 블로그
TransactionalEventListener

profile
best of best

0개의 댓글