org.springframework.orm.jpa.JpaSystemException: identifier of an instance of com.example.scenchive.domain.filter.repository.PerfumeTag was altered from com.example.scenchive.domain.filter.repository.PerfumeTag$PerfumeTagId@776a779d to com.example.scenchive.domain.filter.repository.PerfumeTag$PerfumeTagId@4b8b343
키워드 필터링 검색 결과 총 개수를 반환하는 메소드를 추가한 후 GET 요청을 보냈을 때 발생한 오류다. 검색 결과를 반환하는 메소드의 일부를 그대로 사용한 것이기 때문에 오류의 원인을 파악하기 더욱 어려웠다.
향수 리스트를 조회하는 메소드이다.
// PerfumeService.java
// 키워드 필터링 결과로 나온 향수 리스트 조회
public List<PerfumeDto> getPerfumesByKeyword(List<PTag> keywordIds, Pageable pageable) {
// 주어진 키워드 id들로 PerfumeTag 리스트 조회
List<PerfumeTag> perfumeTags = perfumeTagRepository.findByPtagIn(keywordIds);
// perfume_id 오름차순으로 조회
Set<Perfume> uniquePerfumes = new TreeSet<>((p1, p2) -> p1.getId().compareTo(p2.getId())); // Set: 다중 키워드로 인해 중복된 향수가 있는 경우 제거
for (PerfumeTag perfumeTag : perfumeTags) {
Perfume perfume = perfumeTag.getPerfume();
uniquePerfumes.add(perfume);
}
List<PerfumeDto> perfumes = new ArrayList<>();
int startIndex = (int) pageable.getOffset();
int endIndex = Math.min(startIndex + pageable.getPageSize(), uniquePerfumes.size());
List<Perfume> paginatedPerfumes = new ArrayList<>(uniquePerfumes).subList(startIndex, endIndex);
for (Perfume perfume : paginatedPerfumes) {
List<Long> perfumeKeywordIds = new ArrayList<>();
for (PerfumeTag perfumeTag : perfumeTags) {
if (perfumeTag.getPerfume().equals(perfume)) {
perfumeKeywordIds.add(perfumeTag.getPtag().getId());
}
}
Brand brand = brandRepository.findById(perfume.getBrandId()).orElse(null);
String brandName = (brand != null) ? brand.getBrandName() : null;
PerfumeDto perfumeDto = new PerfumeDto(perfume.getId(), perfume.getPerfumeName(), brandName, perfumeKeywordIds);
perfumes.add(perfumeDto);
}
// 향수 DTO 리스트 반환
return perfumes;
}
향수 리스트 조회 결과 총 개수를 구하는 메소드이다.
// PerfumeService.java
// 키워드 필터링 결과로 나온 전체 향수 개수 구하기
public long getTotalPerfumeCount(List<PTag> keywordIds) {
List<PerfumeTag> perfumeTags = perfumeTagRepository.findByPtagIn(keywordIds);
Set<Perfume> uniquePerfumes = new HashSet<>();
for (PerfumeTag perfumeTag : perfumeTags) {
Perfume perfume = perfumeTag.getPerfume();
uniquePerfumes.add(perfume);
}
return uniquePerfumes.size();
}
getPerfumesByKeyword 메소드에서 달라진 점이라고는 TreeSet을 HashSet으로 변경했다는 것 하나였다. 배열 정렬 단계를 제외했을 뿐이기 때문에 이건 아닐 거라고 생각했다.
관련 문서들에 의하면 hibernate가 기존 엔티티의 ID 변경을 제한하기 때문에 발생하는 오류라고 했다. 오류가 발생한 클래스는 다음과 같다.
// PerfumeTag.java
@Getter
@Setter
@NoArgsConstructor
@Entity
@IdClass(PerfumeTag.PerfumeTagId.class)
@Table(name = "perfumetag")
public class PerfumeTag {
@Id
@ManyToOne
@JoinColumn(name = "perfume_id")
private Perfume perfume;
@Id
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ptag_id")
private PTag ptag;
public static class PerfumeTagId implements Serializable {
private Perfume perfume;
private PTag ptag;
}
public Long getPerfumeId() {
return perfume != null ? perfume.getId() : null;
}
public void setPerfumeId(Long perfumeId) {
this.perfume = new Perfume();
this.perfume.setId(perfumeId);
}
public Long getPtagId() {
return ptag != null ? ptag.getId() : null;
}
public void setPtagId(Long ptagId) {
this.ptag = new PTag();
this.ptag.setId(ptagId);
}
}
perfumetag 테이블에는 perfume과 ptag가 복합 기본키로 묶여 있다.
테이블에 접근하면서 둘 중 한 컬럼의 ID 값이 변경되는 건가 싶어 auto-increment가 되는 id 컬럼을 추가한 뒤 단일 기본키로 설정하고, 기존 두 컬럼의 PK 설정을 해제하였다.
이쯤에서 해결되기를 바랐으나 여전히 동일한 오류가 발생했다.
getTotalPerfumeCount 메소드 자체를 잘못 구현해서 size 값이 제대로 안 나오는 건가 싶어서 하나하나 콘솔에 찍어가며 값을 확인해보았다. 디버깅 하는 게 정석일 텐데 아직 디버깅 방법을 모른다.. (삽질 오브 삽질)
모두 잘 구해진다. uniquePerfumes는 HashSet이라 반복문으로 출력해야 하기 때문에 안 찍히는 게 맞고, size는 잘 나오니 문제 없다고 판단했다.
@GetMapping("/perfumes/recommend")
public PerfumeResponseDto recommendPerfumes(@RequestParam("keywordId") List<PTag> keywordIds,
@PageableDefault(size = 10) Pageable pageable) { // 향수 10개씩 반환
// 유저가 선택한 키워드를 받아와 해당 키워드에 대한 향수 목록 조회
List<PerfumeDto> recommendedPerfumes = perfumeService.getPerfumesByKeyword(keywordIds, pageable);
long totalPerfumeCount = perfumeService.getTotalPerfumeCount(keywordIds);
PerfumeResponseDto responseDto = new PerfumeResponseDto(recommendedPerfumes, totalPerfumeCount);
return responseDto;
}
컨트롤러 클래스에는 문제가 될 만한 여지가 없었다. 혹시 자료형 문제인가 싶어 long
으로 설정된 totalPerfumeCount의 자료형을 int
로도, Long
으로도 바꿔보았지만 해결되지 않았다.
최후의 수단으로 함께 프로젝트를 진행 중인 백엔드 팀원에게 도움을 요청했다.
줌 화면 공유를 통해 함께 코드를 처음부터 뜯어보았다.
머리 두 개가 모였으나 안타깝게도 원인을 알 수 없었고 결국 깃헙에 푸시하면 팀원분이 풀 받아서 천천히 다시 확인해보기로 했다.
팀원분이 해결해주셨다.......그저 빛..
과정을 설명해보자면 다음과 같다.
// PerfumeTagId.java
@AllArgsConstructor
@NoArgsContructor
@EqualsAndHashCode
public class PerfumeTagId implements Serializable {
private Perfume perfume;
private PTag ptag;
}
PerfumeTag 엔티티에 있던 PerfumeTagId 클래스를 따로 만들어보았으나 미해결.
'그러면 Service 클래스의 메소드가 데이터를 변경하나?' 싶어서 getPerfumesByKeyword 메소드와 getTotalPerfumeCount 메소드에 @Transactional(readOnly=true)
어노테이션을 달고 재실행
📌 코드 수정하면서 내가 다시 확인했을 때는 getTotalPerfumeCount 메소드에만 추가해줘도 정상 작동했다.
HttpMediaTypeNotAcceptableException
에러 발생 (<- new!)
PerfumeResponseDto를 확인해보니 게터가 없어 응답 내용이 DTO에 포함되지 않기 때문에 발생하는 문제였고, @Getter
어노테이션을 추가해주자 해결됨!
예쁘게 잘 나온다.👏🏻
스프링을 단계별로 공부하지 않고 냅다 실전 프로젝트부터 진행하니 여러모로 막히는 순간이 많다. 이번 에러 역시 @Transactional 어노테이션에 대해 알고 있었다면 스스로 해결할 수도 있었을 거라는 생각이 든다.
프로젝트를 진행하는 동안 이런 오류가 발생하면 이틀은 붙들고 끙끙대다가 도저히 풀 수 없을 것 같을 때 팀원분에게 도움을 요청했다. Spring 커리를 제대로 한 번 타신 분이라서 그런지, 한 두 시간 만에 문제를 해결하시는 걸 보면서 현타를 느끼기도 한다.
그렇지만 노력 없이 얻을 수 있는 건 없기 때문에 당연한 일이다!
이번 방학에는 스프링부트를 차근차근 공부해보려 한다.
일단은 @Transactional에 대해 알아봐야겠다. 데이터 조회 메소드에는 전부 이 어노테이션을 추가하는 게 맞는 것인지, 그리고 getPerfumesByKeyword 메소드만 있을 때는 readOnly 설정을 하지 않아도 정상 작동했는데 어째서 getTotalPerfumeCount 메소드를 추가했더니 이런 오류가 생기는 것인지도 궁금하다.
오케이 공부 킵고잉!!!
참고 자료