일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
- java
- 일급 객체
- factory
- Dependency Injection
- lombok
- 일급 컬렉션
- builder
- OAuth 2.0
- nestjs
- middleware
- spring security
- Volatile
- Google OAuth
- Spring
- synchronized
- Today
- Total
HJW's IT Blog
Spring 테스트 격리와 트랜잭션: @Sql 삽입 데이터가 사라지는 이유 본문
들어가며
어플리케이션의 통합 테스트 코드를 작성하다 이해하기 힘든 상황을 마주쳤다.
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);
}
- sql 을 통해 생성된 user 들의 id를 가지고
CreatePrivateChannelDto
를 생성한다 header
를application/json
으로 설정하고,RestTemplate
을 통해 요청을 보낸다.- 정상적인 동작을 수행한다면, 전달된 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
객체가 조회 되었다.
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 단계는 독립적인 테스트를 작성함에 있어 필수적이다. 만약 삽입한 데이터를 지우는 과정이 생략된다면, 이는 다른 테스트에 영향을 미칠수 있다.
테스트가 성공적으로 수행되었다.
'SPRING' 카테고리의 다른 글
Spring Batch 시스템에서 JdbcTemplate와 중복 데이터 처리 효율화 (0) | 2025.04.22 |
---|---|
[Spring Security] OAuth2.0 + JWT 로그인 구조에 Form Login 통합하기 (0) | 2025.03.26 |
Spring Security 기반 OAuth 2.0 & JWT 구현하기 (0) | 2025.03.25 |
JPA 다건 조회 시 프록시 객체가 포함되는 원인 분석 - feat. Persistence Context 출력하기 (0) | 2025.03.12 |
Batch Fetching + Pagination으로 N + 1 해결하기 (0) | 2025.03.04 |