JPA 통합 테스트 삽질기 (2) - 왜 UPDATE가 아닌 INSERT가 실행될까?
이전 글에서 ID와 연관관계 매핑 문제를 해결하고 통합 테스트의 setUp 단계를 통과시켰다. 하지만 기쁨도 잠시, 테스트의 본문 로직이 실행되자 이번에는 전혀 다른 종류의 예외가 발생하기 시작했다.
세 번째 실패: IncorrectResultSizeDataAccessException: 2 results were returned
새로운 에러는 “결과가 유니크하지 않다”는 메시지를 담고 있었다. concert_id와 seat_number로 좌석을 조회했는데, 결과가 2건 반환되었다는 것이다. setupTestData에서 AVAILABLE 상태의 1번 좌석을 하나만 만들었음에도 불구하고, 어떻게 중복 데이터가 생긴 걸까?
로그를 따라가 보니 원인은 SeatReservationService의 reserveSeatTemporarily 메서드에 있었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// SeatReservationService.java (문제 버전)
public SeatReservation reserveSeatTemporarily(Long concertId, Integer seatNumber, Long userId) {
Optional<SeatReservation> existingSeat = seatReservationRepository
.findByConcertIdAndSeatNumberForUpdate(concertId, seatNumber);
if (existingSeat.isPresent()) {
SeatReservation seat = existingSeat.get();
// ...
// ‼️ 기존 좌석의 상태를 바꾸는 대신, 새로운 예약 객체를 생성하고 있다.
SeatReservation reservedSeat = SeatReservation.createTemporaryReservation(
concertId, seatNumber, userId, seat.getPrice()
);
reservedSeat.assignId(seat.getId()); // ID를 할당해도 소용없었다.
return seatReservationRepository.save(reservedSeat);
}
// ...
}
로직의 의도는 명확했다. AVAILABLE 상태의 좌석을 찾아 RESERVED 상태로 바꾸는 것. 하지만 코드는 기존 객체의 상태를 변경(UPDATE)하는 대신, createTemporaryReservation 팩토리 메서드로 새로운 객체를 생성(INSERT)하고 있었다.
JPA 엔티티의 ID를 할당해주면 UPDATE가 될 것이라 기대했지만, Repository 구현체의 toEntity 메서드가 이 ID를 제대로 처리하지 못하면서 결국 INSERT 쿼리가 실행되었고, 이것이 데이터 중복의 원인이었다.
해결 과정: 도메인 객체에 책임 위임하기
이 문제를 해결하기 위해 서비스 로직을 리팩토링했다. 서비스가 직접 객체의 상태를 세세하게 제어하는 대신, 도메인 객체 스스로 자신의 상태를 변경하도록 책임을 위임했다.
1. 도메인 모델에 비즈니스 메서드 추가
SeatReservation 클래스에 상태를 변경하는 명시적인 메서드를 추가했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// SeatReservation.java (도메인 모델)
public class SeatReservation {
// ...
public void reserveTemporarily(Long userId) {
if (this.status != SeatStatus.AVAILABLE) {
throw new IllegalStateException("예약 가능한 상태의 좌석이 아닙니다.");
}
this.status = SeatStatus.RESERVED;
this.userId = userId;
this.reservedAt = LocalDateTime.now();
this.expiresAt = this.reservedAt.plusMinutes(5);
}
// ...
}
2. 서비스 로직 수정
서비스는 이제 조회한 도메인 객체의 메서드를 호출하기만 하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
// SeatReservationService.java (수정 후)
public SeatReservation reserveSeatTemporarily(Long concertId, Integer seatNumber, Long userId) {
SeatReservation seat = seatReservationRepository
.findByConcertIdAndSeatNumberForUpdate(concertId, seatNumber)
.orElseThrow(/* ... */);
// 도메인 객체에 상태 변경을 위임
seat.reserveTemporarily(userId);
// 상태가 변경된 객체를 그대로 저장
return seatReservationRepository.save(seat);
}
마지막 관문: Mapper의 ID 처리
서비스 로직을 수정했지만 여전히 문제는 해결되지 않았다. 마지막 퍼즐 조각은 SeatReservationRepositoryImpl의 toEntity 메서드에 있었다. 도메인 객체의 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를 가지고 있다면(기존 데이터), 엔티티에도 ID를 설정
if (domain.getId() != null) {
entity.setId(domain.getId()); // 이 한 줄이 UPDATE와 INSERT를 결정한다.
}
// ... concertEntity 설정 로직은 그대로 ...
return entity;
}
이 코드를 추가하자, JPA는 save 요청을 UPDATE로 올바르게 해석했고 드디어 모든 테스트가 통과했다.
결론
이번 디버깅 과정은 JPA를 사용할 때 객체의 ‘상태’를 다루는 것이 얼마나 중요한지 보여주었다.
- 새로운 데이터를 만드는 것(
new,INSERT)과 기존 데이터의 상태를 바꾸는 것(setter,UPDATE)을 명확히 구분해야 한다. - 도메인 객체가 스스로의 상태 변경을 책임지도록 설계하면, 서비스 레이어의 코드가 더 명확해지고 실수를 줄일 수 있다.
- 엔티티와 도메인을 변환하는 Mapper는 ID를 포함한 모든 상태를 누락 없이 전달해야 한다.
결국 테스트 코드는 우리에게 애플리케이션의 동작 방식뿐만 아니라, 설계의 빈틈까지도 알려주는 훌륭한 스승임을 다시 한번 깨닫게 되었다.