-
@Transactional rollback과 테스트 의문점Spring Framework 2022. 1. 24. 00:52반응형
1) 서론
업무 중 특정한 경우에 count +1 후 exception을 발생시켜야 하는 로직이 필요했었습니다. 하지만 해당 메서드는 @Transaction이 반드시 필요했고, exception이 발생하면 rollback 되는 현상이 있었습니다.
이를 해결하기 위해서 테스트 코드를 나름대로 작성했는데요. 이 과정에서 의문점이 생긴 이야기입니다.
2) 전체 흐름
실제 업무에서 사용한 내용이 아닌, 기록을 위해 작성한 코드입니다.
상황 설정이 매우 억지스러운 것은 이해 부탁드립니다.
private final PostRepository postRepository; private static final String unAuthorizedTitle = "어드민"; @Transactional public void exceptionTest(int id) { // 게시물 조회 Post post = postRepository.findById(id).orElseThrow(); // 제목 "어드민" 여부 확인 if (unAuthorizedTitle.equals(post.getTitle())) { // 클릭 했으니 조회 수 증가 post.setCount(post.getCount() + 1); // 권한 없음에 대한 에러 발생 throw new UnAuthorizedException("접근 권한 없음!"); } }
- 사용자가 게시물을 클릭합니다.
- 만약 제목이 어드민이라면, 읽을 권한이 없다는 에러 발생합니다.
- 읽지도 않았는데, 조회수를 증가시키는 이상한 로직입니다.
2. 1) getById
findById가 아닌 getById를 사용했습니다.
이유는 아래와 같습니다.
- 실제 값을 조회하기 전까지는 프록시 객체만 조회해서 성능상 이점을 위함입니다.
- 조회한 프록시 객체의 실제 데이터를 조회할 때 없다면 EntityNotFoundException이 나옵니다.
- findById의 경우 조회 시점에 없다면, 바로 NPE 발생합니다.
2. 1. 2) 조회 시점의 차이
findById
- findById의 경우 post 객체를 조회하는 시점에 모든 값을 조회합니다.
getById
- getById의 경우 Post의 proxy 객체를 가지고 옵니다.
- 즉 껍데기만 가지고 와서, 실제 값들은 모두 null인 상태입니다.
- 당연히 select 쿼리도 발생하지 않습니다.
- 그래서 Post 객체의 실제 값이 필요하지 않고, 그대로 다른 객체에 넘길 때는 성능상 유리합니다.
2. 2) UnAuthorizedException
만약 EntityNotFoundException이 발생했다면, 아래와 같은 메시지가 나올 것입니다.
// 출처: Stackoverflow javax.persistence.EntityNotFoundException: Unable to find com.indianretailshop.domain.User with id 5
위와 같은 에러가 그대로 응답된다면, 사용자는 알 수 없는 외계어를 만난 것과 같을 것입니다. 개발자 입장에서도 도메인이나 상황에 대한 설명 없는 에러를 만나면 난감합니다.
대표적으로 스프링 프레임워크에서 에러를 다루는 것은 아래와 같을 것입니다.
- @ExceptionHandler 사용으로 CustomException throw
- CustomException을 예외 발생 가능성 지점에 직접 throw
아마도 이 외에도 제가 알지 못하는 다양한 에러를 다루는 방식이 존재할 것이라고 생각합니다.
하지만 공통적인 것은 한 집단에서 공통적으로 동의하는 내용의 custom exception과 메시지가 존재한다면, 발생 상황과 의미를 쉽게 유추할 수 있을 것입니다.
이번 글에서는 UnAuthorizedException을 사용해서, 직접 throw 합니다.
3) rollback!
하나의 row를 미리 만듭니다.
실행합니다.
- UnAuthorizedException 발생합니다.
- @Transactional 때문에 exception 발생 후 rollback 됩니다.
- 즉 트랜잭션이 끝난 후 DB에 commit 하는 JPA 특성상 insert 쿼리는 발생하지 않았습니다.
3. 1) rollback의 이유
스프링 API 문서를 참고합니다.
If no custom rollback rules apply, the transaction will roll back on {@link RuntimeException} and {@link Error} but not on checked exceptions.
RuntimeException 혹은 Error의 경우에는 rollback 하지만, checked exception은 하지 않는다고 합니다.
위에서 직접 만든 UnAuthorizedException은 RuntimeException을 상속받았습니다. 그래서 rollback이 되는데요.
상황과 전혀 맞지 않지만, CheckedException인 FileNotFoundException을 임의로 발생시켜, 정말로 그런 건지 검증해보겠습니다.
- update 쿼리 발생합니다.
- CheckedException인 FileNotFoundException 발생합니다.
- rollback 되지 않고, 정상적으로 DB commit 됩니다.
3. 1. 1) RuntimeException이란?
Java API 문서에 의하면 JVM의 정상적인 동작 중에 발생하는 모든 일반적인 exception의 superclass라고 합니다.
컴파일, 런타임 시점의 차이를 생각하면 간단한 것 같습니다.
컴파일 시점에 JVM은 syntax, lexical, sementic 등을 분석을 합니다. 하지만 컴파일 시점에는 표면적인 자바의 문법만을 검증할 뿐입니다. 비즈니스 로직상 발생할 수밖에 없는 다양한 exception들은 분석할 수 없는데요.
이렇게 런타임 시점에 발생하는 것을 runtime exception이라고 합니다. 그리고 Java API 문서에서는 unchecked exception이라고 표현하고 있는데요. 아마도 컴파일 시점에 check하지 못 하기 때문에 이러한 이름을 지은 것 같습니다.
대표적으로 NPE, ArrayIndexOutOfBound 같은 것들이 있습니다.
3. 1. 2) Error
Error가 별도로 존재한다는 것을 몰랐는데요.
런타임 시점에 JVM에서 발생하는 비정상적인 오류를 error라고 표현한다고 합니다.
예시로는 ThreadDeath가 있는데요. Thread.stop()을 통해서 강제로 쓰레드를 종료시키는 경우에 발생합니다. Java API에서는 현재의 쓰레드가 특정 쓰레드를 다른 쓰레드라고 인식한 경우에 SecurityException을 발생시킨다고 하는데요. 이때 알 수 없는 특정 쓰레드를 stop 시킨다고 합니다.
사실 이것은 예외라는 표현 보다는, 프로그래머가 어쩔 수 없는 오류라고 표현하는 게 맞을 것 같습니다. 쓰레드가 멈췄는데, 이것을 다른 방법으로 처리를 해줄 수 없으니까요.
현재 stop() 메서드는 deprecated 됐고, interrupt()가 대신하고 있습니다.
3. 1. 3) checked exception
checked exception은 위에서 언급한 대로 컴파일 시점에 발견할 수 있는 exception입니다.
4) runtime exception과 함께 rollback 하지 않아야 한다면?
단순히 rollback을 피하기 위해서, 상황에 맞지도 않는 에러를 발생시키는 것은 말도 안 되는데요.
그래서 스프링에서는 @Transactional(noRollbackFor=exception 이름)의 기능을 제공하고 있습니다. 특정 에러가 발생했을 때는 rollback 시키지 않는 것인데요.
사실 가장 편한 것은 Exception.class를 조건으로 넣어서, 모든 예외일 때 rollback하지 않으면 됩니다. 하지만 왠지 모르게 이러한 방법은 좋은 프로그래밍은 아닌 것 같다는 생각이 듭니다.
왜냐하면 자신이 작성한 코드가 발생 가능한 예외를 전혀 모른다는 의미와 같기 때문입니다. 사실 자신이 작성했다고 해서 모든 예외를 예측할 수는 없습니다. 그럼에도 불구하고 예외 발생 가능성을 염두하며 작성한다면, 더 좋은 프로그래밍이 되지 않을까 생각합니다.
- exception이 발생했지만, rollback 되지 않고 DB반영됩니다.
5) 테스트 코드의 어려움
TDD를 연습해보기 위해 비즈니스 로직을 전혀 작성하지 않은 상태에서, 테스트 코드만을 먼저 작성해봤는데요. @Transactional(noRollbackFor)을 테스트하는 것에 어려움이 있었습니다.
실제 비즈니스 로직과 같이 count를 1 증가시키고, exception을 발생시키려고 합니다.
- noRollbackFor 속성을 추가했지만, exception 후 rollback 발생합니다.
- spring test에서 @Transactional은 무조건 rollback 시킵니다.
- 일반적인 비즈니스 로직에서 사용되는 @Transactional과는 조금 다릅니다.
위의 문제점을 해결하기 위해 spring test는 @Rollback을 별도 제공하고 있습니다.
- @Transcationl이 있지만, @Rollback(value = false) 덕분에 rollback 되지 않습니다.
5. 1) 테스트 코드의 의문
@Rollback 애노테이션을 사용하면서 의문이 생깁니다.
- 왜 @Transactional은 spring test에서는 무조건 rollback 하는 걸까요?
- 그렇다면 @Transactional(noRollbackFor)은 어떻게 테스트해야 할까요?
@Transactional을 일반적인 사용과 다르게 무조건 rollback을 강제시키면서까지, @Rollback이 추가로 존재하는 이유는 의문입니다. 일반적으로 사용되는 것처럼, 데이터 접근을 하나의 쓰레드로 묶는 것으로만 사용할 수는 없었을까라는 의문이 생깁니다.
특정한 exception의 경우에만 rollback하지 않는, 즉 UnAthorizedException이 왔을 때는 DB에 commit 하는 것은 어떻게 테스트를 해야 할까요?
@Rollback이 있지만, 이는 어떠한 exception의 경우에도 무조건 rollback하지 않기 때문에, 정말로 모든 비즈니스 로직을 테스트하는 것이 가능할까라는 의문도 생깁니다.
혹은 테스트 코드이기 때문에 UnAuthrizedException이 터졌고, 이때는 rollback하지 않는다는 것을 가정하는 것을 의도하는 걸까요? Mock이 존재하는 것과 비슷한 개념으로 보면 되는지 궁금합니다. 하지만 이것도 받았음을 가정하는 거지, 실제로 받았을 때 rollback 되는 것을 볼 수 있는 것이 아닙니다.
아마도 제가 테스트 코드 작성에 많이 서툴러서 그런 것 같지만...
여하튼 테스트 코드 작성 경험이 많지 않은 사람의 의문점이었습니다.
반응형'Spring Framework' 카테고리의 다른 글
처음으로 해본 리팩토링 후기 (0) 2022.03.24 JPA cascade 멈춰! (2) 2022.02.02 JPA 지연 로딩, 결국 Proxy (0) 2021.11.21 JPA N+1 문제를 해결해보자 (0) 2021.08.20 [JPA, Redis]페이지별 결과 캐싱 (0) 2021.08.11