💧 N+1 문제 발생
현재 진행 중인 프로젝트가 JPA로 제작이 되고 있는 상황에서 N+1의 문제는 피할 수 없다. 물론 현재 나의 프로젝트에서도 문제가 어김없이 발생했다.
N+1 이란?
조회를 할때 많이 발생하는 과정이다. 애초에 1개의 쿼리를 통해 전체 데이터를 조회하고자 하지만 막상 쿼리문이 발생하는것을 보면 N개가 추가로 발생하는 것이다.
💧 왜 발생했는가?
현재 나의 상황은 orderProduct라는 테이블을 중간테이블로 사용하고 있는 상황이다. 사용의 이유는 간단하다 주문은 다양한 상품을 가지게 되는데 한 개의 오더 마다 order테이블에 중복적인 행이 발생하는 것을 막기위해 즉, 정규화를 지키기 위해 제작해두었다.
이 이외에는 전부 MSA의 서비스 상태라서 N+1 의 상황이 발생하지 않고 있다. 어쨋든 이로 인해 JPA의 로직은
이런식으로 나타난다. 기본적으로 ManyToOne은 EAGER, OneToMany는 LAZY를 default로 잡고 있는데 굳이 표시한 이유는 추후에 누군가 나의 코드를 보기에 더욱 편리하게 하기 위함이다
♬ 문제가 발생하는 코드
내가 했던 주문들을 모두 모아 보는 상황이다. 정확히는 여기서 문제가 발생하는 것이다. findByMemberUUID를 통해 사용자의 UUID로 주문한 내용을 가져오는데 이때 해당 주문의 내용은 굉장히 많을 것이다. 그래서 List형식으로 데이터를 가져오게 되고 그것을 foreach문으로 가져오게 되는데 여기서 각각의 order를 가져올때 쿼리가 날아가는 것이다.
1번에서는 내가 원하는 select 문이 날아간다. 그러면 memberUUID에 맞는 주문들을 가져오게 되는 것이다. 근데 어차피 사용할 것이라 그냥 join을 사용해서 가져오면 되는 건데 그렇지 않고 lazy답게 실제로 불러야 가져오게 되는 것이다.
하지만 우리는 inner join을 select 한번 할 때 그냥 가져왔으면 하는 것이다.
💧 해결 시도
1. Fetch 전략을 EAGER(즉시 로딩)으로 변환한다
- findAll()을 하면 select ? from ? 이라는 JPQL이 생성되고 해당 구문을 분석해서 실제 SQL이 생성된다
- DB의 결과를 받아 해당 엔티티의 인스턴스들을 생성한다
- order 와 연관된 orderProduct또한 로딩을 한다
- 영속성 컨텍스트에서 연관된 orderProduct가 있는지 확인한다
- 결국 영속성에 없다면 해당 데이터를 가져오기 위해 쿼리문을 날리게 되면서 또 다시 N + 1이 발생한다.
2. LAZY(지연 로딩)이 왜 안되는거지?
- 위에서는 이미 Lazy 방식을 사용하고 있고 왜 안되는지 확인할 수 있다.
왜 두개를 모두 다뤘냐면 쿼리를 불러오는 방식은 결국 똑같다는 것이다. 차이점은 바로
영속성 컨텍스트에서 가져오는 타이밍이다.
- 즉시로딩은 영속성에 있을때 바로 가져오는것이고
- 지연로딩은 영속성에 있어도 불러야 가져온다는 것이다.
두개의 공통점은 영속성 컨텍스트의 데이터 유무이다. 없다면 결국 불러오는 쿼리가 날아간다
여기서 쿼리는 해당 Entity에 대한 내용을 프록시로써 연결해두고 실제 데이터를 가져오는 쿼리를 말한다.
💧 해결
♬ Fetch Join 사용하기
결국 해결은 내가 직접 Join을 걸어서 내보내는 것이다. 어떻게 내가 직접 쿼리에 내가 Join을 걸어서 내보낼 수 있을까?
바로 실제로 DB의 쿼리문이 생성되고 날아가는 Repository에서 JPQL을 직접 작성해주면 원하는 것이 가능하다.
위에 설명과 덫붙이자면 원래는 proxy객체로 가져와서 가져왔던 데이터를 진짜 데이터로 변경하여 가져온다는 것이다.
원래는 @Query가 없는 상황에서 해당 메소드를 사용했지만 이번에는 직접 쿼리문을 작성해서 해당 메소드가 불러진다면 원하는 쿼리문이 적히게 된다. 그럼 결과로 어떤 쿼리문이 나가는 것일까?
보면 원하는 대로 Join 문이 한 번에 나가는 것을 확인 할 수 있다. 똑같이 불러와도 추가적인 쿼리문이 발생하지 않는 것을 확인 할 수 있다.
💧 결론
이제 fetch join 을 직접 사용하고 JPA에서 N+1이 왜 발생하는지도 확실히 알게되었다. 하지만 그래서? 왜 하는건데?? 라는 얘기가 없다. 우리가 쿼리문을 줄이고자 하는 것은 결국 성능을 위해서이다. 쿼리가 줄어드는것은 결국 DB에 부하를 덜어주고 이는 분명 성능과 관련이 있다 실제로 성능이 눈에 띄게 좋아지는 환경을 볼 수 있다
우선 한 사람이 5개의 주문을 했다고 가정하고 성능을 비교해보았다
- @Query를 작성하지 않고 그저 FetchType.LAZY만 설정했을때 응답 시간
- @Query 문을 추가적으로 작성하고 난 후의 응답 시간
어렴풋이 봐도 무려 50% 정도의 성능의 개선이 된 것을 확인 할 수 있다.
중요한 것은 겨우 5개의 데이터 만으로 해당 결과가 나타난 것이다. 쇼핑몰 서비스를 다루게 되면 앞으로 굉장히 많은 주문서를 다루고 그에 따라 데이터가 수만, 수십만건이 발생하게 되는데 이때 50%는 정말 무시 할 수 없는 수치가 된다
심지어 50%인 이유도 5번 불러올 시간에 의해 50%이니까 데이터가 커질수록 그만큼의 데이터를 불러올 시간이 단축될테니 데이터가 커지면 커질수록 더욱 눈에 띄게 성능이 올라갈 수 있다.
또한 너무 거대한 데이터가 N+1로 인해 계속해서 쿼리문이 쌓이다 보면 DB가 죽어버리는 경우까지 발생할 수 있다.
나아가 이것까지도 방지할 수 있게 된다. 굉장히 흘려 보낼수 있는 문제이지만 절대 흘려보내선 안되는 문제이기도 하다.
💧 참고
'프로젝트 > 항해99 개인 프로젝트' 카테고리의 다른 글
🚢 쿠폰 발급 시 사용자에게 전달하는 비동기통신 Kafka (0) | 2024.05.15 |
---|---|
🚢 Redis를 사용해서 선착순으로 쿠폰 발급하기 (0) | 2024.05.13 |
🚢 MSA의 동시성 제어를 위한 Lock 사용(feat. Redis의 분산락) (1) | 2024.04.30 |
🚢 Feign Client 와 RestTemplate (0) | 2024.04.30 |
🚢 Open Feign을 사용하며 발생하는 서비스 간의 장애 처리 (0) | 2024.04.30 |