# 개요
Postgres를 쓰는 프로젝트에서 ./gradlew test를 돌렸더니 특정 테스트들만 무더기로 실패했다. 원인을 좁혀보니 서로 다른 두 문제가 겹쳐 있었다.
@DataJpaTest가 설정을 무시하고 임베디드 H2를 띄우는 문제- 프로덕션 예외 핸들러가 바뀌었는데 테스트 픽스처가 그대로 남아 있던 문제
# 증상
실패한 테스트는 두 부류였다.
@DataJpaTest계열 (FeedServiceTest,TrackRepositoryTest,UserHypeRepositoryTest) →org.h2.jdbc.JdbcSQLSyntaxErrorException@WebMvcTest계열 (GlobalExceptionHandlerTest) → 단순AssertionError
반면 @SpringBootTest 계열(UserServiceTest, AuthServiceIntegrationTest 등)은 전부 통과했다. 같은 DB 설정을 공유하는데 왜 일부만 H2로 터질까?
# 문제 1: @DataJpaTest는 데이터소스를 H2로 바꿔치기한다
test/resources/application.properties에는 분명히 Postgres를 쓰라고 적어놨다.
spring.datasource.url=jdbc:postgresql://localhost:5432/dignify
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=create-drop
그런데 에러는 Postgres가 아니라 H2에서 났다. 범인은 @DataJpaTest의 기본 동작이다.
@DataJpaTest의 숨은 기본값
@DataJpaTest는 내부적으로 @AutoConfigureTestDatabase를 포함하는데, 이 기본값이 Replace.ANY다. 즉 설정에 뭐라고 적혀 있든 클래스패스에 임베디드 DB(H2)가 있으면 데이터소스를 통째로 H2로 갈아끼운다. testImplementation 'com.h2database:h2'가 들어 있으면 그걸 자동으로 물어온다.
@SpringBootTest는 이 자동 교체를 하지 않고 실제 설정된 데이터소스를 쓴다. 그래서 @SpringBootTest 테스트들은 Postgres로 붙어 통과했고, @DataJpaTest 3개만 H2로 떨어진 것이다.
# H2가 실제로 터진 지점
H2로 붙자마자 data.sql을 실행하다가 문법 에러가 났다. data.sql이 Postgres 전용 문법을 쓰기 때문이다.
INSERT INTO cron_state (job_name, last_processed_id, created_at, updated_at)
VALUES ('track_collect', NULL, NOW(), NOW())
ON CONFLICT (job_name) DO NOTHING; -- ← H2가 파싱 못 함
ON CONFLICT는 Postgres의 upsert 문법이다. H2는 이걸 모르니 JdbcSQLSyntaxErrorException을 던지고, 그 여파로 스프링 컨텍스트 로딩 자체가 실패해 해당 클래스의 모든 테스트가 무더기로 죽는다.
# 해결: 데이터소스 교체를 끈다
@AutoConfigureTestDatabase(replace = NONE)를 붙여 "임베디드로 바꾸지 말고 설정된 DB 그대로 써라"라고 명시한다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class TrackRepositoryTest {
// ...
}
이제 세 클래스 모두 실제 Postgres에 붙어 ON CONFLICT가 정상 동작한다.
왜 굳이 테스트도 Postgres로?
H2는 cascade 동작, flush 순서, 제약 위반 시점 등이 실제 Postgres와 미묘하게 다르다. 테스트가 초록불인데 운영에서 터지는 함정을 만들 수 있다. 방언 차이(ON CONFLICT 등)까지 실제로 검증하려면 로컬 docker-compose Postgres에 붙이는 편이 안전하다.
# 문제 2: 프로덕션 코드가 바뀌었는데 픽스처가 낡음
GlobalExceptionHandlerTest는 DB와 무관한 @WebMvcTest라 원인이 달랐다. 어느 시점부터 예외 핸들러 로직이 바뀌어 있었다.
@ExceptionHandler
public ResponseEntity<ErrorResponse> handleDataIntegrityViolationException(DataIntegrityViolationException e) {
Throwable cause = e.getCause();
while (cause != null) {
// SQLState 23505(unique 위반)일 때만 409로 매핑
if (cause instanceof java.sql.SQLException sqlEx && "23505".equals(sqlEx.getSQLState())) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ErrorResponse.from(ErrorCode.DATA_INTEGRITY_VIOLATION));
}
cause = cause.getCause();
}
// 그 외의 무결성 위반은 500
return ResponseEntity.internalServerError()
.body(ErrorResponse.from(ErrorCode.INTERNAL_SERVER_ERROR));
}
핸들러는 이제 cause 체인을 타고 내려가 SQLState 23505를 찾았을 때만 409를 준다. 유니크 위반이 아닌 무결성 예외는 500으로 떨어뜨리기 위한 방어 로직이다.
그런데 테스트용 컨트롤러는 예전 그대로, cause 없는 맨 예외를 던지고 있었다.
// TestController — 낡은 픽스처
@GetMapping("/test/data-integrity")
void throwDataIntegrity() {
throw new DataIntegrityViolationException("test"); // cause 없음 → 500으로 떨어짐
}
cause가 없으니 while 루프를 그냥 통과해 500을 반환했고, 409를 기대하던 테스트가 깨졌다. 프로덕션 코드는 진화했는데 테스트 픽스처가 그 변화를 따라가지 못한 전형적인 케이스다.
# 해결: 픽스처를 핸들러의 새 계약에 맞춘다
핸들러가 요구하는 대로 SQLState 23505를 담은 cause를 붙여 던진다.
@GetMapping("/test/data-integrity")
void throwDataIntegrity() {
// 핸들러는 SQLState 23505(unique 위반) cause가 있을 때만 409로 매핑한다
throw new DataIntegrityViolationException("test",
new java.sql.SQLException("duplicate key", "23505"));
}
SQLException(String reason, String SQLState) 생성자로 두 번째 인자에 SQLState를 넣을 수 있다. 이제 핸들러가 cause를 찾아 409를 돌려주고 테스트가 통과한다.
# 정리
@DataJpaTest는 기본적으로 데이터소스를 임베디드 H2로 바꿔치기한다. 실제 DB로 테스트하려면@AutoConfigureTestDatabase(replace = NONE)을 붙인다.@SpringBootTest는 이 교체를 하지 않는다.- H2와 Postgres는 방언이 달라,
ON CONFLICT같은 DB 전용 문법이data.sql에 있으면 H2에서 컨텍스트 로딩째로 실패한다. - 예외 핸들러가 "SQLState 23505일 때만 409" 처럼 정교해지면, 테스트 픽스처도 그 계약(cause에 SQLState를 담는 것)을 똑같이 맞춰줘야 한다. 프로덕션 로직 변경과 테스트 픽스처는 함께 움직여야 한다.