1. 개요
@DataJpaTest를 통해 Repository 계층에 대한 슬라이스 테스트 작성하고 있었다.
그 과정에서 메서드 레벨에서 개별적으로 테스트를 실행했을 때는 정상적으로 테스트가 통과되었지만, 클래스 레벨 혹은 전체 테스트를 실행했을 때 테스트가 실패하는 경우가 발생했다. 코드와 로그를 통해 문제의 원인을 파악해 보자.
2. 문제 코드
문제 코드
- data.sql 파일은 테스트용 데이터를 삽입하기 위한 sql 구문이 들어 있는 파일이다.
- @Sql을 통해 테스트 데이터 삽입을 위해 data.sql을 실행하고 있다.
@DisplayName("Repository - 회원")
@Sql(scripts = "/sql/data.sql")
@Import(TestJpaConfig.class)
@DataJpaTest
class UserAccountRepositoryTest {
@Autowired
private UserAccountRepository sut;
@DisplayName("회원 ID를 전달하면, 해당되는 회원 엔티티를 조회할 수 있다")
@Test
void givenUserId_whenFindById_thenReturnsMatchingUser() {
// Given
String userId = "user1";
// When
Optional<UserAccount> result = sut.findById(userId);
// Then
assertThat(result).isPresent();
assertThat(result.get().getUserId()).isEqualTo(userId);
}
@DisplayName("회원엔티티를 전달하면, DB에 저장한다.")
@Test
void givenUser_whenSave_thenUserIsSaved() {
// Given
String userId = "test-user1";
UserAccount userAccount = UserAccountMockDataFactory.createDBUserAccountFromUserId(userId);
// When
UserAccount result = sut.save(userAccount);
// Then
assertThat(result).isNotNull();
assertThat(result).isEqualTo(userAccount);
assertThat(sut.findById(result.getUserId())).contains(result);
}
}
data.sql
- 각 데이터의 PK 는 INDETITY 전략을 사용하고 있어 생략했다.
--- user_account
insert into user_account (user_id, user_password, email, nickname, memo, social_provider, social_id, role, created_at, created_by, modified_at, modified_by)
values ('admin1', '{noop}qwer1234', 'admin1@naver.com', '별빛1', null, 'kakao', 'qdwd12ddd31dwd', 'ROLE_ADMIN',
'2015-03-17 23:20:26', 'admin1', '2010-03-05 08:28:54', 'admin1');
...
--- hashtag
insert into hashtag (hashtag_name, created_at, created_by, modified_at, modified_by)
values ('java', '2002-03-31', 'admin1', '2015-03-19', 'admin1');
...
--- article_hashtag
insert into article_hashtag (article_id, hashtag_id, created_at, created_by, modified_at, modified_by)
values (1, 1, '2002-03-31', 'admin1', '2015-03-19', 'admin1');
...
--- comment
insert into comment (article_id, user_id, content, created_at, created_by, modified_at, modified_by)
values (1, 'user1', '야구 시즌 시작야구 시즌 시작야구 시즌 시작123', '2002-03-31', 'user1', '2015-03-19', 'user1');
...
실패 로그
- 로그를 보면 SQL 외래키 제약조건으로 인해서 DBException이 발생한 것으로 보인다.

3. 원인 파악
왜 제약 조건에 문제가 발생한 것일까?
insert into article (title, ...) values ("제목", ...);
insert into article_hashtag (article_id, ...) values (1, ...);
article_hashtag insert 쿼리는 article_id를 참조한다.
그런데, 테스트 여러 개 동시에 실행 하면 article_id를 찾을 수 없다고 알려주고 있다.
현재 article는 PK를 IDENTITY 전략을 사용하고 있다. DB에서 자동으로 증가된 ID 값을 생성해준다.
그런데 article_id = 1인 데이터를 찾을 수 없다는 것, 해당 PK 값이 정상적으로 생성되지 않다는 의미이다.
이 상황이 왜 발생했는지 알아보자..
@Sql의 exectionPhase 속성
@Sql 어노테이션은 테스트 전 후에 sql 스크립트를 실행하도록 지원해준다.
exectionPhase 속성을 통해 스크립트 실행 시점을 제어할 수 있다.
- BEFORE_TEST_CLASS(기본값) : 테스트 클래스 시작 전
- AFTER_TEST_CLASS : 테스트 클래스 종료 후
- BEFORE_TEST_METHOD: 각 테스트 메서드 실행 전
- AFTER_TEST_METHOD: 각 테스트 메서드 실행
@Sql에 지정된 스크립트는 SqlScriptsTestExectionListener를 통해 실행된다.
SqlScriptsTestExectuionListener 내부 로직
실제 스크립트를 실행하는 메서드를 살펴보자.
Sql 스크립트 실행 준비
- @Sql에 있는 sql 파일 경로, stateMents를 통해 실제 SQL 리소스를 읽어온다.
- SQL 리소스를 ResourceDatabasePopulator에 등록하여 sql를 실행할 준비를 한다.

Sql 스크립트 실행
- 테스트 컨텍스트에서 트랜잭션과 데이터 소스를 불러온다.
- 트랜잭션 매니저가 없으면 트랜잭션 없이 그냥 실행
- 트랜잭션이 존재하면 트랜잭션 내에서 실행

트랜잭션이 뭐가 중요한데?
@DataJpaTest의 경우 @Transactional을 포함하고 있다.

트랜잭션으로 인해 테스트 메서드 레벨로 실행하면 sql 스크립트는 트랜잭션 안에서 실행되지만,
클래스 레벨로 실행하면 sql 스크립튼 트랜잭션과 별도로 동작한다.
테스트 환경에서는 트랜잭션이 걸려있으면, 테스트 메서드 종료 시점에 롤백한다.
롤백 된다면서 왜 외래키 제약조건에 문제가 발생한거야?
Spring 테스트는 컨텍스트를 캐싱한다.
테스트 실행 시 컨텍스트는 한번만 로딩되고, 여러 테스트 메서드에서 재사용한다.
이 말의 의미는 테스트에 사용하는 H2 DB는 컨텍스트가 살아있는 동안 유지된다. => 공유
즉, 데이터는 롤백되는 상황이지만, Auto Increment 값은 롤백되지 않는다.
예시를 보자.
테스트 A 실행 시 data.sql 스크립트 실행 시 article_id = 1로 할당되지만, DB에 저장되지 않고 롤백된다.
테스트 B 실행 시 data.sql 스크립트 실행 시 article_id = 2로 할당된다.
하지만, article_hashtag에서는 여전히 article_id = 1 을 참조하고 있다.
그로 인해, 외래키 제약 조건이 깨지게 된 것이다.
지금까지의 내용을 참고해서 현 상황에 대해서 정리해보자
- UserAccountRepositoryTest 실행
- 테스트 컨텍스트 생성
- A 메서드 실행
- data.sql 파일 실행 (article_id = 1) 성공
- 코드 실행
- 롤백
- 메서드 종료
- B 메서드 실행
- 4-1. data.sql 실행 (article_id = 2) 외래키 제약 조건으로 인해 예외 발생
4. 해결 방법
현재의 구조에서는 data.sql파일을 변경하지 않는다면, 각 테스트 메서드마다 컨텍스트를 다시 띄워야 한다.
@DirtiesContext를 사용하여 테스트마다 컨텍스트를 분리하여 사용할 수 있다.
@DirtiesContext를 활용하여 컨텍스트 재사용 방지
@DirtiesContext는 테스트 실행 전 후 컨텍스트를 더 이상 재사용하지 않도록 "dirty" 상태로 표시해, Spring이 해당 컨텍스트를 폐기하고 새로운 컨텍스트를 만들도록 유도한다.
DirtiesContext는 메서드/클래스 레벨에 적용할 수 있다.
메서드 모드
- BEFORE_METHOD: 테스트 메서드 실행 전 context를 dirty 처리
- AFTER_METHOD(기본): 테스트 메서드 실행 후 context를 dirty 처리
클래스 모드
- BEFORE_CLASS: 클래스 전체 테스트 시작 전에 dirty 처리
- AFTER_CLASS(기본): 클래스 전체 테스트 끝난 후 dirty 처리
- BEFORE_EACH_TEST_METHOD: 각 테스트 메서드 시작 전에 dirty 처리
- AFTER_EACH_TEST_METHOD: 각 테스트 메서드 끝난 후 dirty 처리
HierarchyMode라는 것도 존재하는 데 컨텍스트 계층 내에 어떤 범위까지 캐시를 비우 것인지 결정하는 것이라고 한다.
이 부분은 추후 사용할 경우가 생기면 정리하도록 하겠다.
@DirtiesContext를 통해 컨텍스트를 클래스 레벨, 메서드 레벨에서 폐기할 수 있다고 하였다.
지금 이 상황에서 어떤식으로 적용할 수 있을까?
첫번째 방법. 각 테스트 메서드마다 컨텍스트를 제거하고, sql 스크립트를 실행한다.
- 이 방법은 매 테스트 메서드마다 컨텍스트를 지우고 새로 생성하기 때문에 성능 부담이 있다.
@DisplayName("Repository - 회원")
@Sql(scripts = "/sql/data.sql", executionPhase = BEFORE_TEST_METHOD)
@Import(TestJpaConfig.class)
@DirtiesContext(classMode = BEFORE_EACH_TEST_METHOD)
@DataJpaTest
class UserAccountRepositoryTest {
// ...
}
두번째 방법. 클래스 레벨에서 컨텍스트를 제거하고, sql 스크립트를 실행한다.
- 이 방법은 첫번째 방법에 비해 컨텍스트를 클래스 레벨에서 공유하기 때문에 성능 부담이 적다.
@DisplayName("Repository - 회원")
@Sql(scripts = "/sql/data.sql", executionPhase = BEFORE_TEST_CLASS)
@Import(TestJpaConfig.class)
@DirtiesContext(classMode = BEFORE_CLASS)
@DataJpaTest
class UserAccountRepositoryTest {
// ...
}
'Programming > Spring' 카테고리의 다른 글
| [Spring Security] SecurityFilterChain 동작 분석 - Logout 요청 시 AuthenticationEntryPoint 미호출 문제 (3) | 2025.08.13 |
|---|---|
| [Spring 테스트] Mockito ArguementCaptor란? (1) | 2025.08.10 |
| [JPA] 실전! 스프링 부트와 JPA 활용2 강의 핵심 내용 정리 (0) | 2025.07.01 |
| [JPA] 실전! 스프링 부트와 JPA 활용1 강의 핵심 내용 정리 (1) | 2025.06.06 |
| [SpringBoot] Spring Boot 404 응답 커스터마이징 (0) | 2025.04.03 |