본문 바로가기
우테코/요즘카페

RestAssured & @Transactional 방금 저장한 데이터를 못찾아요

by dev_kong 2023. 8. 22.
728x90
728x90

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 쿼리를 보고 두가지 의문이 들었다.

  1. SELECT 쿼리를 통해 DB에서 조회를 하는데, 왜 방금 저장한 데이터를 못찾지?
  2. 그 전에 방금 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차 캐시에서 데이터를 못찾는 것일까.

이 부분의 원인을 파악하기 위해서는 다음의 내용들을 알고 있어야 한다.

 

  1. RestAssured + RandomPort(or DefinedPort)를 사용하게 되면 테스트 코드 실행과 애플리케이션 서버는 각각 다른 스레드에서 동작한다.
  2. @Transactional 어노테이션에 의해 시작된 트랜잭션 정보는 스레드 별로 스레드로컬저장소에(ThreadLocal) 저장되어 관리된다.
  3. 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());  
}

위 세가지 내용을 토대로 위 테스트 코드의 흐름을 따라가보면 다음과 같다.

  1. 트랜잭션 시작
    1. entityManager가 생성된다(em1)
    2. memberSave가 동작하며 em1의 1차캐시에 member 데이터가 저장된다.
  2. RestAssured 시작
    1. 새로운 스레드에서 동작한다.
    2. controller -> service 순으로 호출되며 동작
    3. service에 걸려있는 @Transactional로 인해 새로운 트랜잭션 시작(해당 스레드에는 동작 중인 트랜잭션이 없으므로 새로운 트랜잭션이 시작한다.)
    4. 새로운 EntityManger가 생성된다.(em2)
    5. em2의 1차캐시에는 member데이터가 없으므로(em1과 공유되지 않는다), DB에 SELECT 쿼리를 날린다.

이후의 과정은 2-1 에서 설명 했듯, DB에 데이터가 저장되지 않은 시점이기에 데이터를 찾는데 실패한다.

3. 해결

그래서 문제의 원인은 찾았고, 어떻게 해결 하면 되나.
원인은 어려워도 해결은 쉽다. 그냥 메서드에 붙여놓은 @Transactional 어노테이션을 제거 해주면 된다.
이렇게 되면, 바로 DB에 flush() 되기 때문에 정상적으로 동작하는 것을 확인할 수 있다.

728x90
728x90

댓글