기록 이유
현재 Issue Controller 조회 API 중, 모든 Issue 를 조회하는 기본적인 Read API 가 존재하는데, 평소보다 많은 목 데이터를 서버, 로컬 DB 에 넣어두고 조회하니 굉장히 느리게 조회되는 것을 알게됐다. 평소 개발 시 목 데이터를 꽤 넣고 조회하는 편이 아니라 빨리 알아채지 못했고 수정하면서 다시 상기하기 위해 기록하게 됐다 🙂 기록 시작 ~
어떤 문제가 발생했나 ?
쉽게 말하자면, JPA 를 활용하는 개발자들이 한 번씩은 꼭 겪는 현상인 N + 1 문제가 발생했다.
아래 사진과 같이 현재 프로젝트에서 가장 중요한 Issue Entity 는 여러 Entity 와 @ManyToOne, @OneToMany 관계를 맺고있는 것을 확인할 수 있다. Issue Controller, Service, Repository 에서 모든 Issue 를 조회하는 로직을 살펴보자.
Issue Controller 의 read()
Issue Service 의 findAll()
호출 후 모든 Issue List 를 가져오고 ResponseReadAllIssueDto 클래스로 변환해 Controller 로 반환한다.
Entity 에서 DTO 로 변환하는 부분이 중요한데, 아래에서 좀 더 자세히 알아보자.
Issue Repository 의 JPQL 활용 findAllIssue()
Issue Entity 에서 Member 와 Milestone 을 Fetch Join 한 뒤 모든 Issue 를 찾아와 Service 로 반환한다.
Entity 에서 DTO 로 변환하는 부분 좀 더 자세히
앞서 말했듯 findAllIssue() 메서드를 통해 모든 Issue List 로 반환받아 DTO 로 변환한 뒤 Contorller 로 반환하는 로직이다.
DTO 로 반환하는 역할을 가진 getResponseReadAllIssueDto(List<Issue>) 메서드를 확인해보자.
사실 ResponseReadAllIssueDto 는 API 명세를 지키기 위한 껍데기 DTO 이다. Issue Entity 를 DTO 로 변환시켜주는 진짜 DTO 클래스는 바로 ResponseIssueDto 클래스인데, responseIssueDtos(List<Issue>) 메서드를 통해 만들어지는 DTO 클래스이다.
responseIssueDtos(List<Issue>) 또한 확인해보자.
여기서 stream.map(), 정적 팩토리 메서드 of() 을 통해 ResponseIssueDto DTO 를 하나씩 만들어내는데, from() 를 살펴보면 ?
드디어 문제의 로직에 도달했다. Issue Entity 와 연관관계가 있는 모든 Entity 를 지연 로딩해놓은 사실을 다시 상기시켜보자.
Repository JPQL 에서 @ManyToOne 관계인 Entity 는 Fetch Join 으로 한번에 가져오도록 설정했지만 @OneToMany 관계의 Entity 를 모두 지연로딩으로 가져오기 때문에 굉장히 많은 수의 쿼리가 발생하는 성능상 문제가 생겨버렸다.
(Stream 을 돌면서 그 만큼 계 ~ 속 쿼리가 발생한다 .. 😫)
Collection 타입에 Fetch Join 을 사용할 순 있지만.. 그렇게 처리하면 N(다) 의 데이터 만큼 1(일) 이 중복되서 조회되는 또다른 문제가 생겨버린다. 왜냐면 RDB 는 Collection 타입을 처리할 수 없으니까 Colleciton 만큼 Row 가 증가되서 나오기 때문 ..ㅎㅎ
쿼리와 실행 시간을 본다면 ?
무려 Select 쿼리가 1,500 개 이상이 나가버린다.
실행시간도 3 ~ 4 초..
그럼 Fetch Join 을 사용해서 Collection 을 한 번에 조회하도록 하고, JPQL의 distinct 을 사용하면 중복이 제거되지 않나? 라고 생각할 수 있다. 물론 그렇게해서 중복을 제거한 Issue 를 조회할 수는 있다. 페이징이 필요없거나 Collection 을 하나만 사용하는 API 에서는 이렇게 해결할 수도 있긴하지만, 페이징이 필요하다면 절대 Collection 타입 Fetch Join 자체를 하지 말도록 하자.
앞서 언급했듯 Collection 을 한 번에 조회하면서 엄청난 데이터를 조회하게 되고, 그 양만큼 중복 Row 가 생겨난다. 이 때 DB 에서 페이징 처리를 할 수 없기 때문에(중복 때문에) 경고 로그가 생기며 모든 데이터를 DB 에서 쭉 읽어오고 애플리케이션 메모리에서 페이징 처리를 해버린다. 메모리가 누워버릴 수도 있는 위험한 방법이라 절대절대 이렇게 처리하면 안된다고 한다.
이 부분에서는 영한님 강의가 엄청난 도움을 줬다. 궁금하신 분들은 실전! 스프링 부트와 JPA 활용2 를 꼭꼭 챙겨보시길 .. 정말 아름다운 강의 ㅠㅠ
해결은 ?
해결방법은 의외로 심플하다.
@ManyToOne, @OneToOne 등의 @xToOne 관계를 한꺼번에 가져올 수 있도록 모두 Fetch Join 한다. 얘들은 Colletion 타입이 아니기 때문에 Fetch Join 해도 문제가 없다. 그리고 Collection 타입은 그대로 지연로딩 상태로 둔 뒤 최적화 쿼리를 위해 옵션을 추가하자.
@BatchSize 어노테이션을 사용할 수도 있지만 나는 글로벌한 설정을 위해 .yml 파일에 원하는 배치 사이즈를 설정했다.
(참고로 100 ~ 1000 까지 설정할 수 있음)
이제 쿼리가 어떻게 나가는지 확인해보자.
Select 쿼리 갯수를 찾아보면 19개로 확연히 줄었고, Collection 타입을 in 절을 통해 한꺼번에 조회하는 것을 확인할 수 있다 !
조회에 3 ~ 4 초 걸리던 시간도 0.3 초 정도로 단축 !
배운 점
로컬 개발 시 항상 데이터를 조금만 넣어서 돌려보곤 했었는데, 목 데이터를 잔뜩 넣어두고 테스트해보는 습관을 길러야 할 것 같다.
JPA 는 아직 어렵고 겪을 삽질이 많지만, 이렇게 시행착오를 겪고 들었던 강의를 상기하여 해결하는 기분은 여전히 좋다 ㅎ.ㅎ
얼른 책도 사서 백과사전 처럼 써봐야겠다.
'프로젝트 기록 > Issue Tracker 삽질 기록' 카테고리의 다른 글
[Issue Tracker Project] Switch - Case Refactoring (0) | 2022.11.06 |
---|---|
[Issue Tracker Project] 로그인 시 환경변수 이슈 발생 (0) | 2022.09.07 |
[Issue Tracker Project] Object Mapper 에서 @Getter 와 기본 생성자의 역할 ? (0) | 2022.08.01 |
[Issue Tracker Project] JPA 연관관계 매핑 관련 이모저모 (0) | 2022.07.15 |