JPA 통합 테스트 삽질기 (1) - ID는 어디로 사라졌는가?
통합 테스트는 애플리케이션의 여러 구성 요소가 올바르게 상호작용하는지 검증하는 필수적인 과정이다. 하지만 때로는 단순해 보이는 설정 문제 하나가 모든 테스트를 붉은색으로 물들이기도 한다. 최근 콘서트 예약 시스템의 통합 테스트를 작성하며 겪었던, 연관관계 설정과 관련된 두 가지 문제를 기록하고자 한다.
첫 번째 실패: NULL not allowed for column "CONCERT_ID"
테스트를 실행하자마자 모든 테스트 케이스가 @BeforeEach 설정 단계에서부터 실패했다. 로그는 명확했다. concert_dates 테이블에 데이터를 삽입하려 할 때, 외래 키인 concert_id가 NULL이라서 발생하는 제약 조건 위반 오류였다.
1
2
3
4
5
6
7
8
9
10
// 문제가 발생한 테스트 데이터 설정 코드
// ...
testConcert = Concert.create(/* ... */);
concertRepository.save(testConcert); // 1. Concert 저장
testConcertDate = ConcertDate.create(
testConcert.getId(), // 2. 여기서 testConcert의 ID가 null
/* ... */
);
concertDateRepository.save(testConcertDate); // 3. 결국 예외 발생
원인은 JPA의 save 메서드 동작 방식에 대한 기본적인 오해에서 비롯되었다. concertRepository.save(testConcert)를 호출하면, 영속성 컨텍스트에 저장된 testConcert 인스턴스에 ID가 할당될 것이라고 기대했다. 하지만 Spring Data JPA의 save 메서드는 ID가 생성된 영속 상태의 엔티티를 반환한다. 기존 변수에 이 반환값을 다시 할당하지 않으면, 변수는 여전히 ID가 없는 상태의 객체를 참조하게 된다.
해결책은 간단했다. save 메서드의 반환값을 다시 변수에 할당하는 것이었다.
1
2
3
4
5
6
7
8
9
// 수정된 코드
testConcert = Concert.create(/* ... */);
testConcert = concertRepository.save(testConcert); // 반환된 객체를 다시 할당
testConcertDate = ConcertDate.create(
testConcert.getId(), // 이제 ID가 보장된다.
/* ... */
);
testConcertDate = concertDateRepository.save(testConcertDate);
두 번째 실패: IllegalArgumentException: 좌석을 찾을 수 없습니다
첫 번째 문제를 해결하자, 다음 단계에서 새로운 예외가 발생했다. 이번에는 @BeforeEach에서 좌석(SeatReservation) 데이터를 정상적으로 저장했음에도 불구하고, 정작 테스트 본문에서 해당 좌석을 조회하지 못하는 문제였다.
이 문제의 원인은 조금 더 깊은 곳에 있었다. 우리는 클린 아키텍처를 위해 도메인 객체와 JPA 엔티티를 분리하여 사용하고 있었는데, Repository 구현체의 변환 로직(Mapper)에 빈틈이 있었다.
1
2
3
4
5
6
7
8
9
// SeatReservationRepositoryImpl.java의 변환 메서드 (문제 버전)
private SeatReservationEntity toEntity(SeatReservation domain) {
// 도메인 객체의 concertId를 사용하지 않고 엔티티를 생성
return new SeatReservationEntity(
null, // ConcertEntity와의 연관관계 설정 누락
domain.getSeatNumber(),
// ...
);
}
ConcertDate 때와 마찬가지로, SeatReservation 도메인 객체가 가진 concertId를 이용해 ConcertEntity를 조회하고, SeatReservationEntity에 연관관계를 설정해주는 코드가 누락되었던 것이다. setupTestData에서는 좌석 데이터가 저장된 것처럼 보였지만, 실제 DB에는 concert_id가 NULL인 데이터가 들어갔고, 서비스에서는 concert_id를 조건으로 좌석을 조회했으니 데이터를 찾지 못하는 것이 당연했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// SeatReservationRepositoryImpl.java의 toEntity (수정 후)
private SeatReservationEntity toEntity(SeatReservation domain) {
SeatReservationEntity entity = new SeatReservationEntity(/* ... */);
// 도메인 객체의 ID를 이용해 부모 엔티티를 조회하고 관계를 설정
if (domain.getConcertId() != null) {
ConcertEntity concertEntity = concertJpaRepository.findById(domain.getConcertId())
.orElseThrow(() -> new IllegalArgumentException("Concert not found"));
entity.setConcert(concertEntity);
}
return entity;
}
교훈
두 번의 실패를 통해 얻은 교훈은 명확하다.
- Spring Data JPA의
save를 호출한 후에는 항상 반환된 인스턴스를 사용할 것. - 도메인과 엔티티를 분리하는 구조에서는, Mapper가 두 객체 간의 모든 상태(특히 ID와 연관관계)를 정확하게 전달하는지 반드시 검증할 것.
작은 실수였지만, 이로 인해 모든 테스트가 실패하는 경험은 JPA의 영속성 관리와 객체 매핑의 중요성을 다시 한번 일깨워 주었다.