사상

테스트가 관리하는 트랜잭션 - 향로 님의 @Transactional 글을 읽고

PaxCaelo 2024. 3. 4. 08:40

@Transactional의 테스트 사용에 관한 부정적인 이야기를 언제부터인가 듣기 시작했고, 예를 들어 안티패턴이니까 쓰지 말라는, 그게 무슨 얘기인가 궁금하던 중에 재민 님의 @Transactional 테스트 사용에 관한 영상을 보고 생각을 간단한 남겼다. 전에 향로 님이 @Transactional 롤백 테스트에 대해서 반대한다는 얘기를 어디선가 들은 기억이 나서 향로 님의 이야기도 자세히 들어보고 싶다고 남겼는데. 

 

얼마 지나지 않아 이런 장문의 글을, 예제 코드까지 만들어서 공개를 해주셨다. 이런 감동은 오랜만이다. 함께 일하는 사람들과 오프라인에서 기술과 개발에 관한 이야기를 깊이 있게 나눌 기회가 거의 없는 나에겐 온라인에서라도 이야기를 이어갈 수 있는 이런 기회는 무척 소중하다.

 

https://jojoldu.tistory.com/761

 

테스트 데이터 초기화에 @Transactional 사용하는 것에 대한 생각

얼마 전에 2개의 핫한 컨텐츠가 공유되었다. 존경하는 재민님의 유튜브 - 테스트에서 @Transactional 을 사용해야 할까? 존경하는 토비님의 페이스북 2개의 컨텐츠에서 테스트 데이터 초기화에 @Transa

jojoldu.tistory.com

 

글을 읽으면서 들었던 여러 생각을 잘 정리해서 올리려고 마음을 먹었는데, 연말 바쁜 일정에, 안 쓰면 계속 안 쓰게 된다는 관성 탓에 계속 미루다가 이제 적어본다. 

 

나는 스프링의 @Transactional을 이용한 롤백 테스트를 DB를 사용하는 테스트에 주로 사용한다. 필요한 경우엔 테스트 중에 커밋시키고 이를 클린업하는 방식의 테스트도 사용한다. 

 

스프링 테스트의 트랜잭션 관리

스프링 테스트(TestContext Framework)는 TransactionalTestExecutionListener를 통해서 테스트가 실행되는 동안 트랜잭션을 관리한다. 스프링의 컨텍스트 테스트를 사용하고, PlatformTransactionManager 타입의 애플리케이션 빈이 구성되어 있고, 클래스 또는 메서드 레벨에 @Transactional이 지정되어 있고, 테스트가 실행하는 애플리케이션 코드가 스프링의 트랜잭션을 이용한다게 조건이다. 이 경우 스프링 테스트는 현재 테스트 메서드를 실행하는 스레드에 바인딩되는 트랜잭션을 생성하고, 테스트 메서드 실행이 끝나면 해당 트랜잭션을 롤백시킨다. 이게 흔히 말하는 @Transactional 롤백 테스트이다.

 

단순히 테스트 실행하고 롤백한다는 식으로만 알고 있다면, 향로 님이 만든 실패하는 테스트 케이스에서처럼 롤백이 안 된다거나 테스트가 기대한대로 동작하지 않는 경우를 만나게 될 수도 있다. 기본적으로 두 가지를 염두에 둬야한다.

 

첫째는 @Transactional 테스트는 애플리케이션 코드에 정의된 트랜잭션 전파(propagation) 속성이 REQUIRED와 SUPPORT 외의 것이 사용된 경우이다. 만약 REQUIRES_NEW가 사용되면 TransactionalTestExecutionListener에 의해서 시작된 테스트 트랜잭션 외에 별개의 트랜잭션이 만들어질테니 테스트 트랜잭션의 롤백으로 테스트 이전 상태로 돌리는 건 불가능할 수 있다.

 

두 번째는 스프링 테스트가 만드는 트랜잭션은 ThreadLocal을 이용해서 테스트 메소드를 실행하는 스레드에 바인딩되기 때문에 애플리케이션 코드에서 다른 스레드를 만들어 DB 작업을 수행하는 경우도 테스트에서 시작된 트랜잭션에 참여하도록 코드를 작성하지 않으면 추가 트랜잭션이 만들어지고, 여기서 다루는 코드는 롤백되지 않는다. 역시 향로 님이 간단한 예를 만들어서 어떤 일이 일어날 수 있는지 잘 보여주셨다. 테스트 방식에 따라 테스트 쪽에서 스레드를 추가로 만들기도 한다. 이것도 테스트 실행 스레드에 바인딩된 트랜잭션을 이용하는 롤백이 원하는 최종 상태를 만들지 못하게 할 수 있다.

 

이 두 가지는 스프링 테스트의 트랜잭션 관리 문서의 가장 앞부분에 등장하는, @Transactional 테스트를 사용하는 개발자라면 숙지하고 있어야할 기본적인 사항이다. 스프링 테스트에서 애플리케이션 컨텍스트를 변경하면 @DirtiesContext를 붙여야 하고, 스프링 빈은 기본적으로 싱글톤이다 수준의 상식이 아니던가? 

 

여기에 하나를 더한다면 트랜재션의 시작과 종료가 일어나는 지점, 즉 트랜잭션 경계설정(demarcation) 위치가 테스트 메서드 실행 전후로 확장된다는 점이다. 이건 트랜잭션과 연결되는 JPA의 엔티티 매니저 생명주기를 연장시키기 때문에 테스트 결과가 거짓 음성(false negative)이 될 수 있다. 그래서 테스트에선 전혀 문제없던 코드가 사실은 JPA 관련 라이프 사이클 버그가 있어서 애플리케이션을 실행할 때 에러가 날 수도 있다. 이것도 @Transactional을 사용할 때 기억하고 주의해야 한다. 

 

이 세 가지에 대한 주의를 기울인다면, 거의 대부분의 데이터를 사용하는 테스트에서 테스트 실행 후에 트랜잭션을 롤백한다는 방법은 매우 단순하고, 편리하고, 빠르게 데이터를 사용하는 테스트를 작성하고 수행하게 해준다. 스프링이 초기부터 테스트 트랜잭션을 관리하고 테스트 수행 후에 롤백하는 기능을 스프링 테스트에 넣은 이유이다. 

 

요즘 개발자들은 @Transactional 테스트라고 기억하겠지만, 내가 이 방식의 스프링 테스트 트랜잭션 관리 방법을 만난 건 스프링 1.1이었고, 그때는 아직 자바 언어에 애노테이션도 없는 버전이 주로 사용되었고, JUnit 3.8로 만들어진 스프링 테스트 프레임워크를 사용하고 있을 때였다. KSUG의 기원이 된 Epril 1회 세미나에서 영회가 발표한 세션에도 이 내용이 들어있다. @Transactional이라는 애노테이션을 포인트컷 설정 방식의 하나로 추가하고 테스트의 트랜잭션 관리 방식용으로 활용하기 시작한 건 한참 뒤였다. 

 

스프링의 테스트 트랜잭션 관리 방법은TransactionalTestExecutionListener 말고 TestTransaction을 이용하는 것도 있다. 이걸 쓰면 코드를 이용해서 세밀한 트랜잭션을 제어하면서 테스트를 수행하는 것도 가능하다. 테스트 코드 실행 중에 트랜잭션을 롤백 또는 커밋으로 세팅하고, 트랜잭션을 완료하고 다음 트랜잭션을 시작하는 등의 복잡한 작업을 간단히 수행할 수 있게 해 준다. 이걸 잘 활용하는 개발자는 아직 본 적이 없긴 하지만. 

 

스프링 레퍼런스의 테스트 관련 문서를 찾아보면 테스트의 트랜잭션과 관련된 이런 내용이 아주 잘 정리되어있다. 스프링으로 개발하면서 테스트를 만드는 개발자라면 한 번쯤 읽어봤겠지.

 

우리는 트랜잭션을 테스트 하는가?

향로 님의 글에 대한 본격적인 피드백을 하기 전에 이 이야기를 먼저 하고 싶었다.

 

우리는 트랜잭션을 테스트하나? @Transactioanl 테스트를 쓰든 말든 상관없이, 어쨌든 DB를 이용하는 코드라면 어떤 식으로든 트랜잭션을 사용할 것이다. 그럼 정말 트랜잭션이 제대로 설정되어 있는지 테스트는 하고 있을까? 한다면 어떻게 하고 있는 걸까?

 

애플리케이션 코드에서 트랜잭션 위치를 @Transactional로 지정하든 TransactionTemplate을 사용해서 코드로 트랜잭션을 다루든 상관없이, 테스트에서 실행하는 애플리케이션 코드가 여러 번 데이터를 쓰거나 수정한다면, 이게 하나의 트랜잭션으로 묶여있는지 뭘로 확인하고 있나? 에러 나지 않고 JPA 관련 코드가 다 실행이 됐으니 트랜잭션이 시작되겠지라고 추정하는 건가? 당연히 트랙잭션이 없으면 엔티티 매니저의 기능이 제대로 동작하지 않고 에러가 날 테니 트랜잭션이 만들어졌겠지. 근데 그게 하나의 트랜잭션인 건 어찌 알 수 있을까? 중간에 부가적인 작업이 REQUIRES_NEW 속성으로 별개의 트랜잭션으로 돌아갔는지, NESTED 트랜잭션으로 설정해서 해당 부분만 롤백되어도 전체 트랜잭션은 커밋될 수 있는지는 어찌 아는 걸까? 

 

나는 트랜잭션을 거의 테스트하지 않는다. 사실은 테스트 못한다. 엔티티를 5개 만드는데, 3개 만들고 에러가 나면 전체가 하나의 트랜잭션으로 묶여있어서 앞에서 추가한 데이터는 DB에 남지 않고 롤백되는지 테스트에서 검증하지 않는다. 

 

왜냐하면 트랜잭션 자체에 대한 테스트는 매우 어렵고 번거롭고 때론 불가능하기 때문이다. 어떤 기술의 트랜잭션 관리 기능을 테스트하는 거야 적절한 학습 테스트를 만들면 된다. 하나의 트랜잭션 이어야지만 통과하는 학습용 코드와 테스트를 만드는 것이다. 중간에 예외를 강제로 던지게 만들고, 롤백되게 만드는 것이지.

 

근데 이걸 애플리케이션 코드는 어찌 검증할 수 있을까? 트랜잭션 매니저의 내부를 파고 들어가서 정말 모든 DB 관련 작업이 동일한 트랜잭션 안에서 일어나고 있는지 어찌어찌 확인할 방법이 있긴하겠지. 말도 안 되게 복잡한 테스트를 만들어야 하고, 프레임워크의 트랜잭션 기능을 직접 뜯어고쳐야 하겠지만. 혹은 트랜잭션이 만들어지고 사용되는 걸 확인하기 위해서 TRACE 레벨의 로그를 남겨서 눈으로 확인해 볼 수도 있긴 하다. 

 

그런데 이걸 내가 만든, 반드시 하나의 트랜잭션으로 묶여서 동작해야 하는 코드마다 어떻게 검증할 수 있나. 하나의 트랜잭션으로 성공하는 줄 알았던 코드가 알고 보니 리포지토리 메소드 호출에서 트랜잭션이 없으면 자동으로 새로운 트랜잭션이 만들어서 에러가 나지 않아 테스트는 성공을 했는데, 실제로 여러 개의 트랜잭션으로 쪼개져 있었다면. 그래서 운영중에 발생한 문제 때문에 업데이트 중간에 예외가 발생하는 특별한 상황이 발행되면 앞의 작업은 롤백되지 않을 것이고, 심각한 데이터 오류가 방치되기도 한다. 다들 이런 경험 한 번쯤은 있지 않나? 나만 그런가. 

트랜잭션 테스트는 어렵다. 차라리 @Transactional을 붙여서 잘 돌아갔는데, 애플리케이션 코드의 특정 메서드에는 @Transactional이 없어서 실제로 lazy loading으로 읽어와야 하는 엔티티를 못 가져와서 에러가 나는 상황은 쉽게 파악할 수 있고 해결하기 쉬운 것 아닌가. 그것보다 훨씬 심각할 수 있으면서 잘 드러나지 않는 트랜잭션 관련 버그는, @Transactional 테스트를 쓰지 않는 향로 님 같은 스타일의 테스트를 만든다고 쉽게 잡아낼 수 있는 게 아니다. 

 

그래서 트랜재션 관련 실수를 하지 않기 위해서 여러 가지 가이드를 만들 수밖에 없다. 트랜잭션이 불필요하게 만들어지는 경우가 일부 있더라도 클래스 레벨에 @Transactional을 걸고 시작하고, 정적 도구와 코드 리뷰를 통해서 잘못 설정된 게 없는지, 전파 속성이나 고립도에는 문제가 없는지, 데드락이 발생할 수 있는 위험이 있지 않는지 계속 살피는 수 밖에 없다. Spring Data처럼 트랜잭션을 앞에서 시작하지 않아도 알아서 트랜잭션을 매번 따로 만들어주는 오지랍 기술 따위는... 하아, 이걸 끄는 방법을 찾겠다고 마음먹은 지 한참인데 요즘 너무 게을렀지.

 

아무튼 테스트에서 트랜잭션과 관련된 얘기가 나오고, @Transactional 때문에 예상치 못한 버그를 미리 못 막아서 놀랐다는, 그래서 아예 안 쓰겠다는 선택을 했다는 분들을 보면, 그 PTSD가 한편 이해가 되면서도, 과연...

 

향로 님의 예제에 나온 @Transactional 테스트가 실패하는 경우

REQUIRES_N EW 

스프링의 테스트 관리 트랜잭션은 REQUIRED, SUPPORT 외의 전파 속성을 사용하는 경우에 별개의 트랜잭션이 만들어져 그쪽 데이터는 롤백이 되지 않을 수 있다. 그러면 테스트 메소드에서 트랜잭션을 커밋시키고(@Transactional(propagation=NEVER), @Commit 등등) 테스트에서 생성한 데이터를 정리하는 방법을 쓰면 된다. 

 

그런데, 테스트를 작성하는 개발자가 자신이 다루는 코드에서 REQUIRES_NEW를 사용하는지 굳이 알 필요가 있을까. 굳이 그걸 의식하고 어떤 경우에는 테스트 후 커밋된 데이터를 정리하는 코드를 넣어야 하는거냐고 질문을 할 수 있을 텐데. 나는 알아야 한다고 생각한다. 자신이 만든 코드를 테스트하는 개발자 테스트를 작성하는 사람이라면, 기본적으로 블랙박스 테스트를 지향해야 하지만, 사용 기술에 관해선 화이트 박스 테스트로 만들 수도 있어야 한다. 

 

REQUIRES_NEW를 쓰는 경우가 흔한가? 배치 특성을 가진 작업이나, 특별한 상황에서도 분리시켜서 성공 시키거나 독립시켜야 하는 멀티 트랜잭션 전략이 필요한 경우에 아주 가끔 사용될 수 있다. 일반 서비스 애플리케이션을 개발하는 경우 한 번도 경험해보지 못한 개발자도 많을 것이다. 그래도 JPA 학습할 때 필수로 한 번쯤 들어보는 것이니 필요한 경우에 사용하면 되긴 하지. 

 

REQUIRES_NEW가 호출되기 전에 이미 REQUIRED 트랜잭션이 있거나, 반복적으로 REQUIRES_NEW를 호출하기도 할테니, 대부분 여러 개의 트랜잭션이 만들어진다. 트랜잭션이 어떤 식으로 만들어지고 동작하는지에 대한 검증은 없이 최종 결과만 달랑 테스트하는 코드를 만들고 만족할 것인가? 이 정도 수준의 트랜잰션 관리를 한다면 한 차원 높은 테스트 대상 코드에 대한 이해가 있어야 할 것이고, 그에 대한 테스트도 치밀하게 만들어야 한다. 

 

몰랐는데 REQUIRES_NEW가 @Transactional 테스트가 실행하는 코드 내부에 있어서, 롤백이 안 되고 데이터가 남아서 다음 테스트에서 문제가 될 수도 있으니 @Transactional 테스트를 쓰지 말자, 이건 너무 성급한 결론 아닐까. 문제를 해결할 간단한 방법(REQUIRES 사용 테스트에선 커밋 테스트 후 데이터 정리)이 있는데 전체를 다 포기한다는 건, 영한이 말대로 빈대 잡으려다 초가삼간 다 태운다는 게 아닐런지.

 

비동기 메서드

간단한 예를 보여주는 용도로 충분하다고 인정해 주기에 향로 님의 예제에 나오는 CompletableFuture를 이용하는 코드는 많이 아쉽다. 기껏 CompletableFuture를 async로 만들고는 get()으로 블록킹 하는 코드라니. 이게 무슨 비동기 코드란 말인가. 스레드만 바꿔치기한 거 아닌가. WebFlux 였다면 BlockHound가 에러라도 냈을텐데 말이지. 차라리 스프링의 @Async를 써서 백그라운드에서 동작하는 별개의 작업을 병렬로 돌리는 테스트를 했다면 코드 레벨에서 여러 시도를 해보면서 흥미로웠을 것이고. 게다가 스프링 데이터가 만드는 리포지토리가 아니라면, 다른 스레드에서 리포지토리 사용하려고 하는 순간 트랜잭션이 없다고 에러가 나서 문제가 뭔지 금세 알게 될 거다. 

 

스프링 테스트 관리 트랜잭션의 두 번째 주의사항인, 테스트에서 만들어지는 트랜잭션은 현재 테스트 스레드에 바인딩된다는 조건을 알고 있다면 이런 경우에 대해서 다른 대책을 가질 수 있었을 것이다.

 

테스트와 비동기라고 하면 내 생각엔 두 가지 경우가 떠오른다.

 

이런 식의 CompletaleFuture가 아니라 본격적인 WebFlux의 스케줄러가 사용되는 비동기 코드를 테스트한다고 해보자. 리액티브는 동기 스프링 MVC에서도 90% 기능이 사용될 수 있다. 굳이 이런 식의 비동기 코드가 필요하다면 스프링 리액티브 코드를 쓰면 된다. 스프링은 WebFlux에서 여러 스레드를 넘나들면서 하나의 요청을 비동기-논블록킹으로 처리하는 코드에 대한 트랜잭션 지원 기능을 제공한다. 그러니 이런 시나리오에도 사실 트랜잭션 문제가 없다. 

 

리소스 최적화를 위한, 혹은 push 방식의 스트림을 처리하기 위한 리액티브 코드가 아니라 정말 병렬로, 혹은 백그라운드에서 비동기적으로 요청을 처리하면서 데이터를 건드리는 코드인 경우는 그러면 어쩔 것인가? 결국 커밋 테스트를 만들고 직접 데이터를 지워야 하려나?

 

이것 테스트 입장에서 생각해 볼거리가 많다. 테스트가 애플리케이션 코드를 실행했는데 중간에 스레드가 추가되어 별도의 작업이 시작된다. 테스트는 언제 그 결과를, 어떻게 검증해야 할까? 애플리케이션에서 웹 요청에 의해서 @Async 백그라운드 작업이 실행되는 경우, 보통 클라이언트에 바로 응답을 한다. 데이터를 건드리는 백그라운드 작업을 진행시키고 나서 최종 결과를 테스트에서 확인하려면?

 

어떻게든 새로 만들어지는 스레드의 정보를 받아서 해당 작업에 다 끝날 때까지 기다렸다가 최종 결과를 DB에서 확인하면 되긴하겠지. 그런데 왜 굳이 별도의 쓰레드를 돌리는 걸까? 그건 대부분 그 작업이 시간이 오래 걸리는 백그라운드에서 실행되다가 언젠가 종료되기만 하면 충분한 작업이라서겠지. 근데 테스트에서 새로 만들어진 스레드 정보를 가져와서(어떻게?) 종료될 때까지 대기했다가 결과를 테스트한다는 건, 과연 좋은 방법일까?

 

나라면 차라리 이렇게 하겠다. @Async 메서드를 mock으로 만들고, 호출되는지만 테스트한다. 그리고 @Async가 붙은 메소드를 테스트에서 직접 실행한다. 프락시가 생기지 않게 간단히 테스트 컨텍스트를 세팅하면 별도의 스레드 풀에서 동작하지 않는, 테스트와 같은 스레드에서 동작하는 코드가 될 거다. 그리고 그 작업의 결과를 롤백 테스트로 확인한다. 그러면 역시 별 문제가 없을 것 같은데.

 

그런데 이런식으로 비동기 실행한 작업의 결과를 get()으로 대기하는 코드를 누군가 만들었다면 코드 리뷰 때 세게 지적해서 그런 쓸데없는 방식을 쓰지 않게 변경할 것이고, 혹시 성능을 위한답시고 병렬로 스레드 풀을 돌려서 작업을 수행하면서 그 안에서 DB를 건드리는 코드라면, 당장 순차적인 코드로 바꾸라고 하겠다. IO 바운드된 코드를 왜 쓸데없이 쓰레드 풀을 만들어서 병렬로 돌리냐는 잔소리와 함께.

 

그래도, 어쩌다 보니 꼭 필요한 비동기 작업과 그 결과를 테스트하는 극히 드문 케이스가 나오면, 테스트 끝내고 그 안에서 일어난 DB 변경사항을 돌려놓는 코드를 넣으면 그만이고.

 

TransactionalEventListener

이것도 예상했던 내용이다. 

 

먼저 이 얘기로 시작하고 싶다. 그냥 실행하면 되는 코드를 굳이 이벤트를 던지고, 그게 꼭 트랜잭션이 완료되어야만 실행되게 만들어야 할까? 그런데 이벤트를 던지는 코드에 대해서 테스트 하면서 그 이벤트를 구독해서 동작하는 쪽의 작업까지 테스트를 해야 하는 건가?

 

간혹 스프링의 이벤트 기능을 쓰면서, 이 이벤트로 인해서 어떤 코드가 실행이 되는지 꼼꼼히 메모해 놓는, 심지어는 이벤트 클래스의 javadoc에도 넣는다는 개발자의 이야기를 들은 적이 있다. 아니 도대체 왜 그러는 걸까. 나는 이벤트의 발행, 구독 모델을 즐겨 쓴다. 이걸 통해서 이벤트를 던지는 쪽과 이를 받아 쓰는 쪽의 결합도를 낮추는 것이 목적이다. 이벤트를 발행하는 코드는 이것 때문에 어떤 일이 일어날지에 대해서, 구독을 하든, 하다 말든, 트랜잭션을 끼고 하든, 외부 이벤트 서비스로 던져서 다른 서버에서 받든, 상관없이 그저 내가 한 일에 대해서 통보하고, 그걸 사용하는 코드는 알아서 하라는, 나는 내 할 일과 책임은 여기 까지라는 깔끔한 분리 때문에 그렇게 만든다. 

 

그렇다면 이벤트를 발행하는 코드를 테스트할 때 이걸 구독하는 코드가 잘 실행되는지, 그리고 그 결과로 어떤 추가 작업이 일어나는지도 검증해야 하는 건가? 음.. 그럴 수도, 아닐 수도 있겠다. 

 

트랜잭션이 완료되는 시점에 추가 작업이 일어나야 한다, 그걸로 상태가 변하고, 심지어 DB도 변경되어야 한다, 그래서 그걸 테스트에서 검증하고 싶은데 @Transactional은 롤백을 해버리니 그 동작이 일어나지 않는다는 게 요지일 텐데. 나는 왜 이걸 테스트해야 하는지부터 진지하게 생각해 보자고 하고 싶다.

 

트랜잭션이 끝나면 실행되게 만드는 작업은 기능적으로 메인 트랜재션 흐름에서 벗어난, 느슨하게 분리하고 싶은 작업일 수 있다. 이런 경우 굳이 스프링의 @TransactionalEventListener를 썼더니 정말 트랜잭션이 끝나면 리스너 메소드가 동작하네, 이걸 확인해보고 싶은 게 아니면, 그냥 그것도 테스트에서 호출해 버리면 되지 않을까? 나는 일단 롤백 테스트를 걸고 그 방법부터 생각해 보겠다. 트랜잭션이 끝났으니 로직을 처리하는 중에 데이터를 건드려야 하는 작업을 더 하지는 않을 것이고, 서버의 메모리에 있는 오브젝트의 상태를 건드려두고 다음에 참고하는 식의  코드 따위도 만들지 않을 테니, 아마도 다음 두 가지의 경우가 많지 않을까 싶은데.

 

하나는 메인 작업이 끝나면 외부 서비스를 호출하거나 통합하는 등의 작업을 수행하는 경우. 사용자가 등록됐다고 외부에 API를 이리저리 쏴야 하는데, 이때 트랜잭션을 계속 물고 있으면 낭비가 된다. 사실 트랜잭션을 커밋하고도 커넥션을 물고 있기만 해도 낭비다. 0.1초에 메인 작업을 끝냈는데 외부 서비스에 메일을 보내고, API를 쏘는데 1초가 걸리면 쓸데없이 10배 낭비가 되겠지. 그러니 빠르게 트랜잭션을 완료시켜서 DB 리소스를 해제해 주고, 아마도 @TransactionalEventListener를 @Async까지 걸어서 현재 웹 요청은 빠르게 응답해버리고, 부가 작업은 @Async 스레드 풀의 큐에 넣어두고 차근차근 처리하게 만드는 방식이 대부분이 아닐까 싶다. 

 

다른 건, 메인 트랜잭션에 묶지 않지만 또 다른 부가적인 DB 작업을 수행하는 경우이다. 로그나 통계성 정보를 업데이트한다거나, 아무튼 뭐가 됐든, 역시 웹 요청에 대한 메인 작업이 끝났으면 빨리 응답을 해버리고, @Async 풀을 이용해서 단계적으로 다음 데이터 처리 작업을 수행한다. 이건 메인 트랜잭션과 묶이지 않았으니 부가 작업이 실패로 끝날 수도 있기도 하다. 

 

아무튼 @TransactionalEventListener가 호출되는가를 검증되는 게 과연 테스트에서 중요한가? 이게 첫 번째 질문이고.

그리고 @Async를 쓴다면 역시 테스트에 의해서 비동기 작업이 실행될 때 발생하는 위해서 언급한 비동기 관련 테스트의 고민이 시작된다가 두 번째. 

 

@Async의 스레드 풀의 코어 사이즈가 충분하지 않으면 큐에 대기를 한다. 테스트가 실행되는 조건 따라서 @Async가 붙은 @TransactionalEventListener 메소드는 테스트 메소드가 끝날 때까지 작업이 완료되지 않을 수도 있다. 이러면 어떻게 검증할 건데. 

 

향로 님의 예제에서는 매우 아쉽게도 이벤트 리스너를 mock bean으로 만들어서 얘가 호출되는가만 체크했다. 뭐 그게 중요하고 전부일 수도 있겠지. 하지만 나라면 그런 테스트를 만들지 않을 것이다. Mock bean으로 만들면 내부에서 동작하는 기능에 대한 검증도 포기하겠다는 것인데, 그러면 @TransactionalEventListener 애노테이션이 잘 붙어있는지에 대한 테스트일까. 

 

리스너 내부에서 DB와 관련된 작업을 @Async로 수행한다면, 이것 또 다른 차원의 검증을 요구하는 것일 테고. 잘 알려진 대로 트랜잭션널 이벤트 리스너에서 DB 트랜잭션을 또 시작하려면 REQUIRES_NEW 등을 활용하는 새로운 트랜잭션을 강제로 만드는 작업이 요구된다. 

 

어떻게든 테스트에서 실제 애플리케이션이 동작하는 상황과 똑같은 순서로, 똑같은 방식으로 동작하는 걸, 그 특성을 다 살려서 테스트하기란 매우 어렵다. 가능은 하지만 비싸다. 그만큼 완벽한, 이후에 테스트만 성공하면 아무 문제가 없다는 확신이 들만한 테스트 코드를 만들기 위해 어디까지 노력을 해야하는 것일까.

 

테스트에 대한 생각 정리

쓰다 보니 향로 님의 글에 대한 반대 내지는 반박 비슷하게 보이기도 할 텐데, 나는 향로 님의 의견을 존중하고 상당부분 동의한다. 혹시 내가 중간에 참여하는 개발팀이 이미 향로 님 스타일로 테스트를 만들고 있다면 계속 그렇게 개발하자고 할 수도 있다. 

 

하지만 스프링 테스트를 사용하는 개발자라면 스프링 테스트가 관리하는 트랜잭션 방식을 따라 @Transactional 테스트를 기본으로 쓰고, 주의할 상황을 잘 이해하고, 필요한 경우 데이터를 초기화하는 커밋 테스트도 만들면서, 심지어 TestTransaction을 이용해서 하나의 테스트에서 여러 개의 트랜잭션이 만들어지고 복잡하게 돌아가는 테스트도 만들 수 있어야 한다고, 그게 기본이면 좋겠다고 생각한다.

 

근본적으로 테스트는 애플리케이션 코드를 완벽하게 검증하고 모든 문제를 미리 다 파악할 수는 없지만 최선을 다해서 좋은 코드를 만드는데 도움을 주는 중요한 도구라는 생각을 한다. 

 

향로 님 방식의 테스트로 기본적인 문제를 풀고, 어떤 기술적인 부분에 대해선 깊이 이해하지 않고도 손쉽게 테스트를 하면서 개발을 할 수 있겠지만, 다양한 트랙잭션을 검증하는 테스트로는 완전하지 않으며, 몇 가지 제약 사항도 있고, 불편함도 있다고는 해야겠다.

 

테스트가 끝날 때마다 메타 데이터를 읽어서 테이블을 다 초기화시키는 것, 나중에 테이블 개수가 많아지고 테스트 중에 넣는 데이터가 많다면 이건 테스트 성능을 떨어뜨릴 수 있을 것이다.

 

테스트 수행 성능이라면 테스트용 메모리 DB를 사용하면 된다고? 나도 좋아하는 방식이긴 하지만, 이거야말로 테스트와 애플리케이션이 실행되는 시점에 차이를 가져올 수 있는 숨은 폭탄이기도 하지 않나. 데이터 테스트를 완벽하게 통과하지만 실제 사용되는 DB에서는 미묘한 문제를 일으키거나 최적화된 SQL 작업을 못하게 하거나, 그걸 억지로 구분해서 넣는다면 테스트를 실행하는 조건이 매우 복잡하게 만들어버리는.

 

또 테스트에서 만든 데이터만 삭제하는 게 아니라 전체 테이블을 날리는 방식으로 하기 위해서 FK 제약조건을 빼버린다. 그게 테스트를 위한 것이고, 모든 데이터는 JPA를 통해서만 등록된다는 전제를 깔면 된다고 하겠지만 그러기엔 너무 위험하지 않나. 테스트용 스키마만 그런다면 모르겠지만, FK 제약 조건을 무조건 다 빼는 데는 나는 아직 찬성할 수 없고, FK가 깨진 데이터가 들어가고 그걸 잘못 만져서 매우 처치 곤란한 장애를 수도 없이 만나본 경험을 떠올린다면, @Transactional을 안 쓰겠다고 FK를 뺀다는 주장은, 물론 다른 이유로 FK 제약조건을 제한하는 것에 대해서는 동의하지 못하는 건 아니지만, 받아들이기 쉽지 않네. 

 

마지막으로 테스트에서 항상 커밋하고 데이터를 다 초기화하는 방식으로 테스트를 만들면, 시드 데이터를 기준으로 테스트를 만드는 게 어려워진다. 나는 어느 정도 모델이 정리되고, 테스트를 위해 의미 있는 데이터가 준비되면, 테스트 실행 전에 기본 데이터를 로딩하고, 그 데이터에 대해서 @Transactional 테스트를 작성하는 방식을 선호한다. 매번 코드로 테스트에서 사용할 데이터를 준비하는 것은 언젠가 한계가 있다. 그런데 매번 테이블을 다 날려버리면 매번 테스트용 시드 데이터를 테스트마다 로딩하란 말인가? 롤백 테스트가 주는, 초기 DB 상태를 계속 유지하게 만드는 방식이 얼마나 편하고 빠른데.

 

향로 님의 정성스러운 글과 코드에 성의를 보이려고 오래간만에 길게 글을 적어봤다. 아무튼 피드백을 할만한 좋은 글을 남겨주셔서 다시 감사하고, 언젠가 오프라인으로 더 이어지는 얘기를 해봤으면, 혹은 내 유튜브 방송에서도 해도 되겠지. 

 

그 외에도 데이터를 사용하는 테스트에 관해서 하고 싶은 얘기가 많긴 한데, 작년에 부러진 손가락이 아직도 말썽이라 글을 길게 쓰기도 쉽지 않구나. 차차 적어보자.

 

참, 이건 이번에 알게 된 건데, 스프링 테스트에서 트랜잭션 전과 후에 검증을 위해서 사용되는 메서드에 붙이는 @BeforeTransaction, @AfterTransaction이 있는데 스프링 6.1부터는 이 두 가지 애노테이션이 붙은 메소드에 파라미터를 넣을 수 있게 되었다고 한다. 그러니까 테스트 컨텍스트의 빈을 주입받을 수 있다는 건데, 뭔가 활용해보고 싶은 아이디어들이 떠오른다. 트랜잭션 전의 데이터 상태와 후의 데이터 상태를 검증하는 용도인데, 위의 롤백 테스트임에도 데이터가 남는 경우에 이를 확인하고 처리하는 코드 같은 걸 넣을 수 있지 않을까 싶기도 하고. 

 

코드 한 줄 없이 말로만 떠들었더니.. 아쉬워서 스프링 테스트의 트랜잭션 관련 애노테이션이 나온 코드를 남겨본다. 스프링 레퍼런스에 있는 코드다.

 

 

@SpringJUnitConfig
@Transactional(transactionManager = "txMgr")
@Commit
class FictitiousTransactionalTest {

	@BeforeTransaction
	fun verifyInitialDatabaseState() {
		// logic to verify the initial state before a transaction is started
	}

	@BeforeEach
	fun setUpTestDataWithinTransaction() {
		// set up test data within the transaction
	}

	@Test
	// overrides the class-level @Commit setting
	@Rollback
	fun modifyDatabaseWithinTransaction() {
		// logic which uses the test data and modifies database state
	}

	@AfterEach
	fun tearDownWithinTransaction() {
		// run "tear down" logic within the transaction
	}

	@AfterTransaction
	fun verifyFinalDatabaseState() {
		// logic to verify the final state after transaction has rolled back
	}

}