본문 바로가기

우테코

[기술 토론] @Transactional 롤백 테스트 vs 클린업 후 커밋 테스트

안녕하세요 브로콜리입니다.

 

오늘은 매쉬업 10분 테크 세미나에서 발표할 @Transactional 롤백 테스트에 대해 이야기를 나눠보겠습니다.

 

먼저 스프링 테스트 프레임워크에서 @Transactional을 사용하면

테스트 레이어에서 생성된 트랜잭션이 프로덕션 레이어에 전파되어 테스트가 자동 롤백됩니다.

참고) 어떤 내부원리로 롤백이 가능한 걸까?

 

본 글에서는

1) 롤백 테스트의 합리성에 대한 개발계의 주요 논의를 정리하고,

2) 제 경험을 기준으로 판단한 롤백테스트에 대한 기준들을 정리해보고자 합니다.


 

1. 논의의 시작 : 토비님의 QA

 

개발계에서 본 논의의 시발점은 다름 아닌 토비님의 유투브 Q&A로 파악됩니다.

당시 토비님은 "몇 가지 상황만 주의하면 되는데 아예 @Transactional 테스트의 단점을 포기하는 건 옳지 않다"라는 입장을 표합니다.

 

그 이유를 요약하면 다음과 같습니다.

- 도메인이 많아지면 외래키 제약 조건을 고려해 롤백코드를 작성해야 한다.
- 각 테스트의 특성을 고려해 클린업 코드를 직접 작성하는 과정은 리소스이다.
- 전체 DB를 롤백하는 것은 트랜잭션 롤백보다 훨씬 더 많은 오버헤드가 발생한다.

 

이후, 토비님은 재미니의 개발실무를 운영중인 재민님을 태그하여 의견을 물으셨는데요. 여기서부터 다양한 의견 전개가 이루어집니다.

 

2. 논의의 전개

2-1) 재민님의 의견

해당 영상에서 재민님의 의견은 "@Transactional을 지양하자"의 측면이었습니다.

 3줄 요약
1. 테스트에서는 @Transactional은 실제 환경과 다른 동작을 보일 때가 있다.
2. 롤백이 필요하다면, H2, 인메모리, DB제너레이터 같은 형태로 사용
3. 명시적 롤백은 득보다 실이 더 크다

 

그렇다면 여기서 "실제 환경과 다른 동작"은 무엇을 의미할까요?

 

테스트 클래스 혹은 메서드 단에 트랜잭션을 붙이게 되면, 테스트 스레드에서 트랜잭션이 열리고 이 트랜잭션이 전파됩니다.

 

따라서, 2가지 차이가 발생할 수 있습니다.

 

첫째는 트랜잭션 자체의 유무입니다.

테스트에서 트랜잭션을 열기 때문에 실제 운영환경에서는 트랜잭션이 붙지 않은 경우에도 트랜잭션이 열린 것처럼 동작할 수 있습니다.

 

실제로 트랜잭션이 없는 메서드를 하나 만들고 트랜잭션 활성화 여부를 로깅해보겠습니다.

 

상위에 트랜잭션이 붙은 테스트에선 트랜잭션이 활성화된 모습을 볼 수 있습니다.

 

 

이에 반해 트랜잭션을 붙이지 않은 테스트에서는 실제 운영환경과 동일하게 비활성화된 모습을 볼 수 있습니다.

 

 

두번째로 실제 운영환경에 트랜잭션이 붙은 경우의 생명주기 확장 입니다.

 

테스트에서 열린 트랜잭션이 전파되므로, 각 메서드를 호출한 이후에 바로바로 커밋되는 것이 아니라 테스트 메서드 전체가 실행된 이후 트랜잭션이 롤백되게 됩니다. 따라서 트랜잭션의 생명주기 자체가 늘어나게 됩니다.

 

가령 메서드 A와 B에 모두 트랜잭션이 붙어있는 상황을 가정하겠습니다.

 

트랜잭션이 없는 테스트의 경우,

테스트 시작 > A 호출 > 트랜잭션 열기 > A수행 > 트랜잭션 커밋 > B호출 > 트랜잭션 열기 > B 수행 > 트랜잭션 커밋 > 테스트 종료

의 순서로 메서드 수행범위가 트랜잭션 범위와 동일하지만

 

트랜잭션이 붙은 테스트의 경우,

테스트 시작 > 트랜잭션 열기 >. A 호출 > A수행 > B호출 > B 수행 > 트랜잭션 커밋 > 테스트 종료

의 순서로 테스트 전체가 하나의 트랜잭션 단위가 되어 생명주기가 증가하게 됩니다.


실제 코드를 통해 해당 차이를 확인해보겠습니다.

 

특정 메서드에서 UserEvent를 발행하고, TransactionEventListener가 Transaction commit 시 시행되도록 합니다.

public class UserService {

    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void publishEvent() {
        eventPublisher.publishEvent(new UserEvent(this));
    }
}


@Component
public class TransactionEventListener {
    
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onTransactionEvent(UserEvent event) {
    }
}

 

이제 트랜잭션이 붙은 테스트와 붙지 않은 테스트에서 TransactionEventListener가 잘 작동하는지 확인해보겠습니다.

 

트랜젹션이 붙은 테스트의 경우, 생명주기가 확장되어 커밋 이벤트 리스닝이 되지 않는 모습을 볼 수 있었습니다.

실제로 모킹한 이벤트 리스너의 호출횟수는 0회였습니다.

 

그러나, 트랜잭션이 붙지 않은 테스트의 경우, 운영환경과 동일한 트랜잭션 생명주기를 가져 이벤트 리스닝이 정상적으로 되었습니다.

 

 

정리하자면, 트랜잭션을 테스트에 붙이는 것은 2가지 차이를 만들어냅니다.

1) 트랜잭션의 유무

2) 트랜잭션의 생명주기

 

그럼 다시 본론으로 돌아가 "실제 운영과 다른 동작"은 그 중 1번의 차이

즉, 트랜잭션이 없음에도 트랜잭션이 있는 것처럼 행동하는 것을 의미합니다.

 

실제에서는 이러한 차이는 다음과 같은 실제 운영환경과 테스트 환경의 차이를 만들 수 있습니다.

 

예를 들어, 실제 운영환경에서 트랜잭션을 안붙여 Dirty checking이 안되는데 테스트가 통과하는 상황이 벌어질 수 있습니다.

 

코드를 통해 예시를 보겠습니다.

유저의 이름을 바꾸는 로직인데 JPA 변경감지를 활용하고자 하였으나 실수로 트랜잭션을 열지 않았습니다.

 

그러나, 트랜잭션이 붙은 테스트의 경우 상위 테스트 스레드에서 트랜잭션이 열리므로 이름 변경에 대한 테스트를 통과해버립니다.

 

그러나, 실제 운영환경에선 어떨까요? 영속성 컨텍스트는 트랜잭션을 단위로 만들어지므로 당연히 update 문이 나가지 않습니다.

트랜잭션이 없는 테스트의 경우 이러한 운영환경의 문제를 테스트가 잘 잡아줍니다.

 

그러나, 테스트와 실제 프로덕션 코드의 환경이 다르기에 테스트가 높은 신뢰성을 보장하지 못한다는 문제를 지니게 됩니다.


 

그렇기 때문에 재민님은 트랜잭션을 붙이지 않는다고 주장하셨습니다. 그러나, 동시에 명시적 롤백 또한 다양한 문제를 수반하기 때문에 데이터 충돌이 나지 않게 테스트 자체를 설계하는 방법과 인메모리 DB를 사용하는 전략을 제안하셨습니다.

 

주로 데이터 충돌이 나는 경우는 데이터 키가 충돌하는 경우인데, 타겟 파라미터를 통해 서로 키 충돌이 나지 않고 데이터가 겹치지 않도록 테스트를 구성하는 전략입니다.

출처 : 재미니의 개발실무 : 테스트에서 @Transactional을 꼭 사용해야 할까?

가령, ASC_TARGET_KEY와 DESC_TARGET_KEY를 만들어 각 데이터 생성 로직에 키를 넘겨주어,  테스트간 데이터 충돌을 막도록 하는 것입니다. 

 

또한, 테스트 실행 시 클린업이 필요하다면 매번 새롭게 만들어지는 내장 DB를 사용하면 그리 리소스가 크지 않음을 이야기했습니다.

 


논의의 전개2. 토비님의 반박

 

그러나, 이에 대해 토비님은 페이스북 글을 통해 다음과 같은 반박을 하십니다.

 

1) 운영환경과 다르게 동작하는 문제?

->  변경 발생 시, 강제로 flush 하는 테스트 코드 룰만으로 환경의 차이를 극복하기 충분하다

-> E2E, 통합 테스트를 통해 실제 환경과 유사한 환경을 구성하고 진행하는 테스트를 추가로 진행하면 보완 가능하다

 

2) 매번 새롭게 만들어지는 내장 DB 활용?

-> 테스트 상황과 운영환경의 이질감을 높인다 ex) natvie query, 미묘한 DB간 차이를 인지하지 못함

-> 테스트 환경에선 통과하지만 운영환경에선 오류가 나는 상황 발생가능하다

 

3) 키에 따른 테스트 데이터 충돌 방지 설계?

-> 모든 개발자가 키의 규칙, 데이터 충돌, 테스트 순서를 의식하여야 한다.

 


논의의 전개3. 향로님의 참전

 

이에 다음으로 향로님이 해당 이슈에 대한 생각을 정리하여 블로그에 올려주십니다.

 

향로님의 결론은 "@Transactional 롤백 테스트를 반대한다" 입니다.

 

그 이유로 4가지 근거를 듭니다.

1) 의도치 않은 트랜잭션 적용 
2) 트랜잭션 전파 속성을 조절한 테스트 롤백 실패
3) 비동기 메서드 테스트 롤백 실패
4) TransactionEventListner 동작 실패

 

그 중, 1과 4는 위의 예시에서 충분히 설명하였으니 2-3번을 중점으로 살펴보겠습니다.

 

@Transactional 롤백 테스트는 테스트 스레드에서 열린 트랜잭션을 롤백하여 테스트 데이터 정합성을 유지합니다.

 

여기서 주목해야 하는 2가지 키워드는 이것입니다.

1) 테스트 스레드에서 열린 트랜잭션와
2) 테스트 스레드

 

그럼 다음의 질문을 던질 수 있습니다.

1) 테스트 스레드에서 열린 트랜잭션 -> 테스트 트랜잭션과 다른 트랜잭션에서 데이터를 삽입하면?

2) 테스트 스레드 -> 만약 테스트 로직에서 새로운 스레드를 데이터를 삽입하면?

 

두 경우 모두 롤백 대상에 포함되지 않습니다.

 

그리고 이렇게 데이터가 롤백되지 않는 각각의 반례가

2번(트랜잭션 전파 속성 조정 시, 테스트 롤백에 실패한다)와

3번(비동기 메서드 테스트 롤백에 실패한다)

입니다.

 

그럼 2번의 경우부터 예시를 보겠습니다.

 

반례1) 테스트 트랜잭션과 다른 트랜잭션에서 데이터를 삽입하는 경우 => 전파속성

 

전파속성이 REQUIRES_NEW로 새로운 트랜잭션을 만들어 피망을 저장하는 메서드가 있다고 가정해보겠습니다.

 

 

그럼 트랜잭션 롤백 테스트에서 피망을 저장하고 롤백이 되었는지 검증해보겠습니다.

 

트랜잭션 롤백 테스트에서는 롤백이 되지 않은 모습을 볼 수 있습니다.

롤백 대상인 테스트 스레드의 트랜잭션이 아닌 새로운 트랜잭션에서 피망을 저장했기 때문입니다.

 

그에 반해 클린업으로 데이터를 지운 테스트는 당연히 트랜잭션과 무관하게 전체 데이터를 삭제하기 때문에 데이터 정합성이 유지됩니다.

 

 

반례2) 테스트 트랜잭션과 다른 스레드에서 데이터를 삽입하는 경우 => 비동기

 

다음으로 테스트 트랜잭션과 다른 스레드에서 데이터를 삽입하는 경우도 봅시다.

 

CompletableFuture를 통해 당근이라는 유저를 비동기로 저장하는 메서드를 만들었습니다. 해당 메서드는 새로 지정된 Executors.newSignleThreadExecutor에서 생성된 스레드에서 user를 저장하게 됩니다.

 

 

트랜잭션 롤백 테스트의 경우, 롤백이 되지 않습니다.

이유는 테스트 스레드가 아닌 다른 스레드에서 해당 user가 삽입되었기 때문에 롤백 대상이 아니기 때문입니다.

 

실제로 당근유저를 삽입한 스레드와 테스트 스레드가 다른 스레드에서 실행되었고, 스레드 간 트랜잭션은 영향을 주고 받지 못하기에 다음 과 같은 문제가 발생합니다. 

 

이와 반대로 클린업 테스트의 경우는 스레드와 무관하게 데이터를 전체 삭제하므로 이러한 정합성 문제가 발생하지 않았습니다.


논의의 전개4. 토비님의 2차 반박

 

그러나, 토비님은 이러한 향로님의 의견에 대해서도 따로 의견을 표하는 글을 남기십니다.

 

그 내용을 요약하면 이렇습니다.

 

- 스프링 트랜잭션 테스트에는 3가지를 유의하여야 하고 이는 스프링을 사용하는 개발자라면 상식에 속한다

유의점1 : 전파 속성이 REQUIRED와 SUPPORT 이외인 경우는 당연히 롤백 대상에 포함되지 않는다

유의점2 : ThreadLocal에 의해서 트랜잭션이 바인딩되기 떄문에 다른 스레드 작업 내용은 롤백 대상에 포함되지 않는다.

유의점3 : 트랜잭션 경계가 테스트 실행 전후로 확장된다.

 

- 그러나 3가지를 유의하면 트랜잭션을 롤백하는 테스트는 단순, 편리, 신속하게 테스트를 작성하고 수행하게 해준다.

 

REQUIRES_NEW?

- 개발자가 테스트를 작성하는 과정에서 화이트박스로 자신의 코드가 REQUIRES_NEW를 인지하여야 한다.

- REQUIRES_NEW 배치나 분리할 필요가 있는 멀티 트랜잭션 전략이 필요한 경우에 가끔 사용한다

-REQUIRES_NEW에서 데이터 정합 문제가 발생가능하니 롤백 테스트를 쓰지 말자는 너무 성급하다

 

비동기 메서드?

- 비동기 도입의 이유는 주로 오래 걸리는 작업에 대한 분리일 것이다.

- 그렇다면 새로 만들어진 스레드에 대한 정보를 가져와서 종료될때까지 대기했다가 결과를 테스트하는게 좋은 방법일까?

- 비동기 호출 대상을 mocking 하여 호출 검증만 하고 + Async 메서드는 개별 단위 테스트로 가져가는게 낫지 않을까?

 

TransactionalEventListener?

- 이벤트 발행 구조의 목적 자체가 발행자와 수신자에 대한 결합도 분리의 이유가 크다.

- 굳이 발행자 차원에서 수신자의 로직을 고려해야 하는가?

- @TransactionalEventListener가 호출되는 걸 검증하는 것이 과연 중요한가?


3. 논의의 정리

전반적인 논의의 내용을 정리해보면 다음과 같습니다.

1) 트랜잭션 롤백 테스트의 경우, 신속-편리한 테스트 작성 방법을 제공해준다
2) 그러나, 운영환경과의 차이를 바라보는 시각의 차이가 있다
    2-1) 그 차이가 코드 작성에 치명적인 영향을 준다는 시각 -> 클린업 코드가 더욱 운영환경을 잘 반영한다
    2-2) 그 차이가 미미하다는 시각 -> 몇가지 주의사항만 인지하면 되는데 이걸 안써?

 


4. 나의 생각

 

첫째로, 클린업 테스트의 오버헤드가 크다는 점은 개인적으로 체감하지 못했다.

개인적으로 실무 경험이 없다보니 정말 복잡한 도메인이나 많은 테이블을 다루어보지 않았다보니 클린업 테스트의 오버헤드가 트랜잭션 롤백 테스트보다 크다는 점은 큰 설득력이 없었습니다. 또한, 실무의 다른 개발자분들 또한 클린업 테스트를 지향한다는 점에서 오버헤드가 주요 이유가 될 순 없을 것 같습니다.

 

둘째로, 강제로 flush하는 컨벤션은 화이트박스 테스트를 크게 강조한다

토비님은 트랜잭션의 경계가 연장됨에 따라 update나 insert가 필요할 경우, 강제 flush라는 컨벤션 룰로 충분히 예방이 가능하다고 하셨습니다. 그러나, 이는 도메인이 복잡할 수록 코드 작성의 복잡함을 더할 수 있는 요소가 될 수 있으리라 생각합니다. 개발자가 코드를 작성하기 전에 로직 내에 변경 내역을 인지하여야 합니다. 이는 TDD와 같은 시그니처 중심의 메시지 전달과 블랙박스적 사고를 요하는 테스트 작성 패러다임과는 상성이 충돌될 수 있다고 생각했습니다. 

 

셋째로, 통합테스트를 통해 트랜잭션 환경차이를 극복가능하다는 것은 논의의 본질에서 벗어낫다는 느낌.

토비님은 통합테스트나 인수테스트를 통해 트랜잭션으로 인한 환경 차이를 극복가능하다고 주장하였습니다. 그러나, 통합테스트의 전제가 깔리지 않더라도 서비스 테스트 자체의 완전성이 논의의 핵심이 되어야 한다고 생각합니다. 그런 경우, 만약 LazyInitialization이나 더티 체킹과 같이 트랜잭션 유무에 따른 차이가 테스트 환경에서 잡히지 않는다면 런타임에서 해당 문제가 발생할 수 있다고 생각됩니다.

 

넷째로, 내장 DB와 운영환경의 차이는 mode로 어느정도 극복가능하다.

내장 DB 중 H2에는 다른 DB의 문법을 사용하겠다는 모드 설정이 가능하다. 모든 설정적인 부분의 차이를 극복하진 못하겠지만 문법적인 부분(ex. native query)으로 인해 해당 방법이 오히려 운영환경과 이질감을 더하는 요소라는 점에는 한계가 있다.

 

다섯째로, 그럼에도 롤백 테스트의  간단, 신속함을 느껴보고 싶다는 욕구가 든다.

사실 그동안 클린업 코드를 지향해왔기에 일종의 방어기제적인 의견일 수 있다. 특히, 클린업시 모든 외래키 제약조건을 제거한다는 점에서 토비님이 이야기하신 것처럼 스키마 간 데이터가 깨지는 가능성을 배제하지 못한다. 무엇보다 상대적으로 무겁고 느린 방식임은 자명하다. 트랜잭션 롤백 테스트를 제대로 활용해보지 않고, 이것이 옳지 않다고 주장할 권리가 있을까? 토비님이 이야기하신 3가지 유의사항을 인지한 채로 트랜잭션 테스트를 한번 사용해보고 싶다는 생각은 강하게 들었다.

 

 

모든 실습 코드는 다음 링크에서 확인 및 돌려보실 수 있습니다.

https://github.com/coli-geonwoo/blog/tree/transaction-test