RestAssured & @Transactional 방금 저장한 데이터를 못찾아요
1. 조회 실패
프로젝트를 진행하며 간단한 테스트코드를 작성하다가,
원하는 대로 동작하지 않는 경우가 있었다.
다음은 문제가 발생한 테스트코드이다.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class MemberTest {
@LocalServerPort
int port;
@Autowired
private MemberRepository memberRepository;
@BeforeEach
void setUp() {
RestAssured.port = port;
}
@Test
@Transactional
@DisplayName("멤버를 조회한다")
void findMemberTest() {
//given
final Member member = memberRepository.save(new Member("123", "폴로", "image"));
//when
final Response response = RestAssured.given().log().all()
.when().get("/members/{memberId}", member.getId())
.then().log().all()
.extract().response();
final String foundId = response.jsonPath().getString("id");
//then
assertThat(foundId).isEqualTo(member.getId());
}
}
간략하게 설명하면, 저장한 멤버를 정상적으로 조회 하는지 확인하는 테스트코드이다.
@Transactional
은 테스트 실행 후 롤백을 위해 붙여줬다.
given 블록에서 멤버를 하나 저장하고,
when 블록에서 RestAssured
를 이용해 API 요청을 보내면,
내부적으로 memberRepository.findById()
가 동작하여,
해당하는 멤버를 가져오는 아주 심플하고, 당연한 성공이 예측되는 테스트코드이다.
그런데 이게 웬걸,restAssured
를 통한 요청의 응답이 회원이 존재하지 않는다는 ErrorResponse
를 반환한다.
어째설까...
2. 원인
우선은 when 블록 즉, RestAssured
가 돌아가는 부분만 확인해보고 싶어서, 해당 부분의 로그를 확인해보았다.
===========REST ASSURED 시작===========
Request method: GET
Request URI: http://localhost:59068/members/12413
Proxy: <none>
Request params: <none>
Query params: <none>
Form params: <none>
Path params: <none>
Headers: Accept=*/*
Cookies: <none>
Multiparts: <none>
Body: <none>
2023-08-22 17:24:34 | http-nio-auto-1-exec-1 | [USER: ] | INFO | [/] | Initializing Spring DispatcherServlet 'dispatcherServlet'
2023-08-22 17:24:34 | http-nio-auto-1-exec-1 | [USER: ] | INFO | DispatcherServlet | Initializing Servlet 'dispatcherServlet'
2023-08-22 17:24:34 | http-nio-auto-1-exec-1 | [USER: ] | INFO | DispatcherServlet | Completed initialization in 1 ms
2023-08-22 17:24:34 | http-nio-auto-1-exec-1 | [USER: anonymous] | INFO | API Request | Request For '/members/12413'
Hibernate:
select
m1_0.id,
m1_0.image,
m1_0.name
from
member m1_0
where
m1_0.id=?
2023-08-22 17:24:34 | http-nio-auto-1-exec-1 | [USER: ] | INFO | Exception Handler | Bad Request: ErrorResponse{code='M1', message='회원이 존재하지 않습니다.'}
HTTP/1.1 400
Content-Type: application/json
Transfer-Encoding: chunked
Date: Tue, 22 Aug 2023 08:24:34 GMT
Connection: close
{
"code": "M1",
"message": "회원이 존재하지 않습니다."
}
===========REST ASSURED 종료===========
가장 먼저 눈에 들어온 부분은 SELECT
쿼리였다.
이 SELECT
쿼리를 보고 두가지 의문이 들었다.
- SELECT 쿼리를 통해 DB에서 조회를 하는데, 왜 방금 저장한 데이터를 못찾지?
- 그 전에 방금 member 데이터를 저장했는데, 그러면 이 member data 가 1차 캐시에 저장되어 있을텐데,
왜 1차 캐시에서 못 찾고 DB에 SELECT 쿼리를 날리는 거지?
2-1. DB에서 못 찾는 이유
이건 간단했다.
원인은 @Transactinal
어노테이션이었다.
@Transactional
어노테이션이 붙은 메서드는 내부의 모든 동작이 정상적으로 종료돼야,commit()
이 호출되고, commit()
이 호출 돼야 flush()
가 호출되며, DB에 쿼리를 날린다.
그런데 위의 코드를 다시 보면,
@Test
@Transactional
@DisplayName("멤버를 조회한다")
void findMemberTest() {
//given
final Member member = memberRepository.save(new Member("123", "폴로", "image"));
//when
final Response response = RestAssured.given().log().all()
.when().get("/members/{memberId}", member.getId())
.then().log().all()
.extract().response();
final String foundId = response.jsonPath().getString("id");
//then
assertThat(foundId).isEqualTo(member.getId());
}
then 블록까지 정상적으로 동작을 마쳐야 commit()
이 호출되며 쿼리가 실행 될거다.
또한, 테스트코드에서 @Transactional
을 사용했기에, 트랜잭션이 정상적으로 종료 되었다 하더라도 롤백이 되는데,
이런 경우 똑똑한 JPA가 어차피 롤백 될 데이터라 판단하여 INSERT
쿼리를 실행하지 않을것이다.
@Test
@Transactional
@Rollback(value = false) // 롤백 안되도록 변경
@DisplayName("멤버를 조회한다")
void findMemberTest() {
// 중략
}
위와 같이 롤백 옵션을 false
로 변경 후, 콘솔을 확인해보니
RestAssured
작업이 끝나니, 그제서야 INSERT
쿼리가 실행되는 것을 확인할 수 있었다.
즉, RestAssured
가 동작하는 시점에는 DB에 데이터가 입력되지 않은 시점이다.
2-2. 1차 캐시에서는 왜 못 찾음?
DB에서 데이터를 못찾는 이유는 알겠다.
근데 그렇다고 해도, 1차 캐시에는 저장되어 있는 상태일텐데,
왜 1차캐시에서 찾지 못하고 DB까지 접근을 하는걸까?
일단 1차 캐시에 저장되어 있는 데이터를 확인하기 위해 테스트코드를 다음과 같이 수정해보았다.
@Test
@Transactional
@DisplayName("멤버를 조회한다")
void findMemberTest() {
final Member member = memberRepository.save(new Member("12413", "연어", "image"));
System.out.println("===========1차 캐시 확인===========");
System.out.println("1차 캐시에 저장된 member.name : " + memberRepository.findById(member.getId()).get().getName());
//when
System.out.println("===========REST ASSURED 시작===========");
// 이하 동일
}
만약 1차 캐시에 저장되어 있지 않다면,
1차캐시 확인 블록에서 SELECT
쿼리가 실행될 것으로 예상했다.
그런데 당연하게도 SELECT
쿼리는 실행되지 않았다.
이로써 1차캐시에 정상적으로 데이터가 캐시되어 있음을 확신했다.
그렇다면 왜 restAssured에서는 1차 캐시에서 데이터를 못찾는 것일까.
이 부분의 원인을 파악하기 위해서는 다음의 내용들을 알고 있어야 한다.
- RestAssured + RandomPort(or DefinedPort)를 사용하게 되면 테스트 코드 실행과 애플리케이션 서버는 각각 다른 스레드에서 동작한다.
@Transactional
어노테이션에 의해 시작된 트랜잭션 정보는 스레드 별로 스레드로컬저장소에(ThreadLocal) 저장되어 관리된다.- EntityManger는 트랜잭션 시작 시점에 생성되고, 트랜잭션이 종료되면 폐기된다.
이 세가지의 내용을 충분히 이해 했다면, 원래의 테스트 코드를 다시 살펴보자.
@Test
@Transactional
@DisplayName("멤버를 조회한다")
void findMemberTest() {
//given
final Member member = memberRepository.save(new Member("123", "폴로", "image"));
//when
final Response response = RestAssured.given().log().all()
.when().get("/members/{memberId}", member.getId())
.then().log().all()
.extract().response();
final String foundId = response.jsonPath().getString("id");
//then
assertThat(foundId).isEqualTo(member.getId());
}
위 세가지 내용을 토대로 위 테스트 코드의 흐름을 따라가보면 다음과 같다.
- 트랜잭션 시작
- entityManager가 생성된다(em1)
- memberSave가 동작하며 em1의 1차캐시에 member 데이터가 저장된다.
- RestAssured 시작
- 새로운 스레드에서 동작한다.
- controller -> service 순으로 호출되며 동작
- service에 걸려있는 @Transactional로 인해 새로운 트랜잭션 시작(해당 스레드에는 동작 중인 트랜잭션이 없으므로 새로운 트랜잭션이 시작한다.)
- 새로운 EntityManger가 생성된다.(em2)
- em2의 1차캐시에는 member데이터가 없으므로(em1과 공유되지 않는다), DB에 SELECT 쿼리를 날린다.
이후의 과정은 2-1 에서 설명 했듯, DB에 데이터가 저장되지 않은 시점이기에 데이터를 찾는데 실패한다.
3. 해결
그래서 문제의 원인은 찾았고, 어떻게 해결 하면 되나.
원인은 어려워도 해결은 쉽다. 그냥 메서드에 붙여놓은 @Transactional
어노테이션을 제거 해주면 된다.
이렇게 되면, 바로 DB에 flush() 되기 때문에 정상적으로 동작하는 것을 확인할 수 있다.