2022. 11. 30. 01:20ㆍ경험
프로젝트 소개
- 영화의 리뷰를 조회하고, 예매가 가능한 씨네마 천국
도구
- Java 11, SpringBoot v2.7.6,
- JPA Hibernate
- MySQL 8
- React, node v16.13.0
요구사항
- 영화를 예매할 수 있다.
- 이메일을 이용해 예매한 영화를 조회할 수 있다.
- 예매한 영화를 취소할 수 있다.
- 영화에 간단한 후기를 남길 수 있다.
- 영화의 후기를 조회할 수 있다.
ERD
API
아쉬운 점
- 시간이 부족해 테스트 코드를 소홀히 작성했다. 필자는 일주일 동안 여행을 다녀오기로 했다..
- 같은 이유로 리팩토링도 충분히 진행하지 못했다.
- 리액트에 대한 이해 부족으로 몇몇 기능은 API 개발에 그쳐 구현하지 못했다.
- "잔여석을 초과하는 수량을 예매할 수 없다" 처럼 비즈니스 로직(?)을 좀 넣고 검증까지 했더라면 어땠을까 싶다..
배움
- 그동안 타임리프만 사용해봤지 API를 이용한 개발은 이번이 처음이다. 나 혼자 프론트, 백을 둘 다 진행해서 주먹구구식으로 끝낼 수 있었지만, 실제 협업이라면 프론트 팀원과 상당한 티키타카를 해야 할 것 같다.
- 앞으로 설계를 할 때, API를 사용할 프론트엔드의 입장도 한 번 생각해보게 되었다.
- Java, Spring을 공부하느라 JPA를 거의 다 까먹었는데 이번 프로젝트를 하면서 한 번 상기하는 시간을 가졌다.
그리고 이번 데브코스에서 JPA를 제대로 학습하기로 맘먹었다.. - 서버 사이드 렌더링, 클라이언트 사이드 렌더링에 대해 조금이나마 알게 된 것 같다.
- CSS는 내 얘기를 들어주지 않는다.
Troubleshoot
1. N+1
API 개발을 마친 뒤부터는 프론트 엔드 코드가 있는 IntelliJ만 띄워놓고 작업을 했었는데, 이번 주 팀미팅 시간에서 멘토님이 JPA를 사용한다면 ORM이 생성한 쿼리를 눈으로 확인해야 한다고 말씀하셨다.
따라서 화면을 띄워놓고 시연을 하면서, 날아가는 쿼리를 살펴보았는데 N+1의 문제가 발생하고 있었다. 총 두 곳에서 N+1의 문제가 발생했는데,
1) 특정 영화의 리뷰를 조회할 때 2) 다건 예매한 내역을 조회할 때 그러했다.
1번 사례를 바탕으로 기술하자면,
리뷰를 조회하는 순간 select 쿼리가 리뷰 조회 1회, 영화 조회 1회, 리뷰를 작성한 Customer 수만큼 조회 N회 발생했다.
현재 Review 엔티티는 Movie와 Customer 의 초기화에 있어서 지연 방식을 사용하고 있다(fetch = LAZY)
지연 방식을 사용하므로 DB에서 Review를 조회하는 쿼리를 날릴 때, Movie와 Customer를 대상으로 하는 쿼리는 보류하고 있다가, Review의 movie, customer의 필드 값을 사용할 때 비로소 DB로 movie, customer를 조회하는 쿼리를 날리는 방식으로 동작할 것이다.
서비스 계층에 만들어놓은 entityToDto 메소드를 살펴보면 지연 로딩으로 보류해놓은 customer, movie를 조회하는 쿼리가 언제 날아가는지 예상할 수 있다.
당장 Review 객체는 Customer와 Movie를 프록시 객체로 들고 있으므로, review.getCustomer().getId()와 review.getMovie().getId() 메소드를 맞닥뜨리는 순간 DB로 각각 Customer, Movie 조회 쿼리를 날리게 될 것이다.
그럼 특정 영화의 리뷰를 클릭했을 때 review 조회 쿼리 1, movie 조회 쿼리 1 & customer 조회 쿼리 1 ⇒ 총 3번의 쿼리가 날아간다고 생각할 수 있는데 그것은 영화에 리뷰가 1개일 때의 상황이다.
ReviewDto가 생성되는 횟수만큼 Customer를 조회하는 쿼리가 발생한다.
즉, 영화에 리뷰를 작성한 사람이 300명이라면 customer를 select하는 쿼리가 300번 발생하는 것이다.
Movie는 그래도 조회하는 쿼리를 한 번만 날리는데 아마 DB에서 조회한 뒤 Id가 1차 캐시에 저장되기 때문일 것이다.
말이 길었는데, join -> fetch join으로 JPQL을 변경했다. 변경한 쿼리와 결과는 아래에서 확인할 수 있다.
fetch join은 지연 로딩이어도 select 절에 movie, customer의 정보를 다 포함해서 가져온다.
따라서 DB에 계속해서 쿼리를 날리는 N+1의 문제를 해결할 수 있는 듯 하다.
@Query("select r from Review r " +
"join r.movie m " +
"join r.customer c " +
"where m.id = :movieId")
List<Review> findByMovie(int movieId);
@Query("select r from Review r " +
"join fetch r.movie m " +
"join fetch r.customer c " +
"where m.id = :movieId")
List<Review> findByMovie(int movieId);
페치 조인 실행 쿼리
2. [Error] ids for this class must be manually assigned before calling save()
- 저장하기 전에 해당 엔티티에 키 값을 부여해야 하는데, @GeneratedValue 어노테이션을 누락했다.
- 모든 엔티티의 PK를 자동증가로 관리하기로 해서, GenerationType.*IDENTITY* 옵션을 주어 해결했다.
3. Infinite recursion (StackOverflowError) (through reference chain …)
- Movie 엔티티는 Review 엔티티를 가지고 있으며(OneToMany), Review 엔티티도 역시 Movie를 가지고 있는(ManyToOne) 양방향 참조 상태다.
- 컨트롤러에서 DTO가 아닌 엔티티를 반환했는데, 양방향 참조의 경우 Json으로 직렬화 하는 과정에서 순환 참조가 발생한다.
- movie → reviews → movie → reviews가 계속해서 참조하다가 스택 오버 플로우가 터져버린 것이다.
- 컨트롤러에서 엔티티가 아닌 DTO를 반환함으로써 해결했다.
https://thalals.tistory.com/303
https://imbf.github.io/spring/2020/07/20/Jackson-Infinite-Recursion-Issue-With-JPA-Entity.html
4. object references an unsaved transient instance - save the transient instance before flushing
- FK로 사용하는 컬럼의 값이 없는 상태에서 데이터를 넣으려고 하는 경우 발생한다.
- 예약 N : 1 회원의 구조인데, 회원 컬럼이 저장되지 않은 상태에서 예약을 save() 하면 위 에러가 발생한다.
- cascade 옵션을 주어 해결할 수도 있을 것이다. 하지만 ReservationService에서 회원을 먼저 영속 상태로 만들고, 그 다음으로 예약을 영속상태로 만들어서 해결했다.
5. detached entity passed to persist
- 예약 엔티티를 save하는 과정에서 영화 엔티티는 이미 DB상에 존재하기 때문에 발생한 오류같다.
- 예약 엔티티는 영화 엔티티와 N:1 관계이며 고객 엔티티와도 N:1 관계이다.
- 예약을 save할 때 영화, 고객도 같이 저장되도록 CASCADE 옵션을 주었던 것이 에러의 원인이 되었다.
- 영화 엔티티는 이미 DB에 저장되어 있기 때문이다.
- CASCADE 옵션을 제거함으로써 해결하였다.
'경험' 카테고리의 다른 글
프로그래머스 데브코스 백엔드 3기 후기(2) (4) | 2023.04.15 |
---|---|
프로그래머스 데브코스 백엔드 3기 후기(1) (16) | 2023.04.01 |
코드로 배우는 스프링부트 웹 프로젝트 후기 (0) | 2022.10.20 |
2022 Woori 코딩 페스티벌 본선 후기(feat. 스타벅스) (1) | 2022.09.18 |
[삼성 SDS] 2022 하계 알고리즘 특강 후기 (2) | 2022.08.01 |