HJW's IT Blog

Spring 테스트 격리와 트랜잭션: @Sql 삽입 데이터가 사라지는 이유 본문

SPRING

Spring 테스트 격리와 트랜잭션: @Sql 삽입 데이터가 사라지는 이유

kiki1875 2025. 4. 1. 13:52

들어가며

어플리케이션의 통합 테스트 코드를 작성하다 이해하기 힘든 상황을 마주쳤다.

INSERT INTO users (id, created_at, updated_at, username, email, password, profile_id)  
VALUES ('00000000-0000-0000-0000-000000000001',  
        NOW(),  
        NOW(),  
        'user1',  
        'user1@example.com',  
        'encrypted-password',  
        null);  

INSERT INTO users (id, created_at, updated_at, username, email, password, profile_id)  
VALUES ('00000000-0000-0000-0000-000000000002',  
        NOW(),  
        NOW(),  
        'user2',  
        'user2@example.com',  
        'encrypted-password',  
        null);

 

위와 같은 sql 을 통해 테스트용 in-memory H2 database 에 데이터를 추가하고 이 user 들로 channel 컬럼을 생성하는 상황이었다.

 

이때, 위 Id 들을 통해 userRepository.findAllByIdIn(...) 을 조회하면, 빈 리스트가 반환되는 문제가 발생했다.

분명 sql 스크립트로 데이터를 넣은 것 같은데 왜 이런 일이 발생한 것일까?

 

테스트 코드 짚고 넘어가기

@Test  
@Sql("/insert_user.sql")  
@DisplayName("비공개 채널을 생성할 수 있다")  
void createPrivateChannel_success() throws Exception {  
  // given  
  String userId1 = "00000000-0000-0000-0000-000000000001"; 
  String userId2 = "00000000-0000-0000-0000-000000000002";  

  List<String> participantIds = List.of(userId1, userId2);  
  CreatePrivateChannelDto createDto = new CreatePrivateChannelDto(participantIds);  
  HttpHeaders headers = new HttpHeaders();  
  headers.setContentType(MediaType.APPLICATION_JSON);  
  HttpEntity<String> request = new HttpEntity<>(  
      objectMapper.writeValueAsString(createDto),  
      headers  
  );  

  // when  
  ResponseEntity<ChannelResponseDto> response = restTemplate.postForEntity(  
      getBaseUrl() + "/private",  
      request,  
      ChannelResponseDto.class  
  );  

  // then  
  assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();  
  assertThat(response.getBody().participants()).hasSize(2);  
  assertThat(response.getBody().name()).isEmpty();  

  List<String> userIds = response.getBody().participants().stream().map(u -> u.id().toString())  
      .collect(  
          Collectors.toList());  

  assertThat(userIds).containsExactlyInAnyOrder(userId1, userId2);  
}
  1. sql 을 통해 생성된 user 들의 id를 가지고 CreatePrivateChannelDto 를 생성한다
  2. headerapplication/json 으로 설정하고, RestTemplate 을 통해 요청을 보낸다.
  3. 정상적인 동작을 수행한다면, 전달된 userId 들로 private channel 을 생성하고, 참여자 id 목록을 포함한 반환값이 돌아와야 한다.
  • 하지만, assertThat(response.getBody().participants()).hasSize(2); 에서 테스트가 실패하였다.

문제 파악하기

문제점이 어디서 발생하는지 확인하기 위해, 우선 혹시 Datasource 나 repository 가 다른 인스턴스인지 확인하기 위해 출력을 해 보았다.

try {  
  log.info(dataSource.getConnection().getMetaData().getURL());  
} catch (SQLException e) {  
  throw new RuntimeException(e);  
}  
log.info("HASH: {}" , userRepository.hashCode());  
log.info(userRepository.getClass());

 

당연하게도 둘의 datasource 및 인스턴스는 완전히 동일하게 출력되었다.

 

jdbc:h2:mem:testdb
HASH:-506480451
class jdk.proxy3.$Proxy206

 

그렇다면 test 코드 내에서도 조회가 되지 않을까?

 

test 코드에서 직접 UserRepository 를 주입받아 findAllByIdIn 으로 조회해 보았다.

 

놀랍게도 test 코드에서 정확하게 동일한 메서드로 쿼리를 날리면 의도한 대로 2개의 user 객체가 조회 되었다.

어플리케이션 코드상 findAllByIdIn 의 결과
테스트 코드상의 findAllByIdIn 의 결과

Transactional 의 격리 수준

Spring Boot 에서 테스트에 @Transactional 이 선언되면, 해당 테스트는 하나의 Transaction 안에서 실행하게 되며, 테스트가 완료될 시, rollback 을 수행한다.

 

이로 인해, 개발자는 서로 상호 독립적인 테스트 코드를 짤 수 있으며, 테스트의 멱등성을 유지할 수 있다.

 

문제는 여기서 발생한다. 하나의 Test 는 하나의 Transaction 내에서 실행되게 되는데, 이는 @Sql 로 삽입한 script 에도 동일하게 적용 된다.

  • 그렇다면 생각해보자, RestTemplate을 통해 어플리케이션에 요청을 보내 실행하게 되는 하나의 Transaction 은 과연 이 테스트의 Transaction 과 같은 Transaction 일까?

정답은 아니다. 어플리케이션의 API 요청은, 같은 프로젝트 내의 테스트라 할지라도, DispatcherServlet -> Controller -> Service -> Repository 순의 흐름을 타게 되므로 다른 트렌젝션이다.

 

Spring 의 Transaction 은 기본적으로 READ COMMITED 의 격리 수준을 가진다. 즉, 트렌젝션은 COMMIT 된 트렌젝션의 데이터만 읽을 수 있다. 여기서 생각해 볼 수 있는점은 다음과 같다.

  • @Sql 로 삽입한 데이터는 Commit 된 상태인가?

그렇다, 해당 데이터들은 commit 되지 않았기 때문에 다른 별개의 Transaction 에서 조회하게 될 경우, 해당 데이터들을 조회할 수 없다.

격리 수준을 낮춰 보자

위의 내용을 증명하기 위해, 어플리케이션의 서비스 코드 최상위 계층에 @Transactional(isolation = Isolation.READ_UNCOMMITTED) 을 달고 테스트를 한번 더 진행해 보았다.

 

 

 

이전과 다르게 participants 에 의도한대로 2개의 user 객체가 들어가 있음을 볼 수 있다.

 

해결책 - SQL 을 독립적인 트랜잭션으로 실행하기

 

하지만, 테스트 때문에 트렌젝션 격리 수준을 READ_UNCOMMITTED 로 두기엔 상당히 위험하다. READ UNCOMMITTED는 일반적으로 일관성, 무결성을 해칠 수 있기 때문에 프로덕션 환경에서는 사용해서는 안 된다.

 

그렇기 때문에, INSERT sql 자체를 독립적인 transaction 에서 실행하여 commit 된 상태로 만들고, 이후 테스트가 끝나면 cleanup sql 을 실행하도록 만들었다.

  • cleanup 단계는 독립적인 테스트를 작성함에 있어 필수적이다. 만약 삽입한 데이터를 지우는 과정이 생략된다면, 이는 다른 테스트에 영향을 미칠수 있다.

 

 

테스트가 성공적으로 수행되었다.