게시판 구현에서 가장 기본적이면서 빠지면 안되는것이 바로 페이징 기능 이다. 페이징을 할 수 없으면 우리는 모든 데이터들을 한 페이지에 전부 불러와야하는데 데이터가 1000만건이라면?? 그렇기 때문에 페이징은 필수적이다. 하지만 이 페이징을 구현하면서 이슈가 하나 발생하게 되는데 바로 컬렉션 조회 를 하는 경우다.
우선 요구사항은 다음과 같다.
전체 주문을 조회하는데 이떄 회원 정보, 주문 내의 모든 개별 주문들, 상품들, 선택한 상품 옵션들도 전부 출력이 되어야한다.
먼저 테이블간 연관관계를 ERD를 통해 파악하고 이를 활용해 SQL을 작성해보자.
간단히 말해 그냥 주문에 대한 모든 정보를 싹 긁어오고 싶다는 것이다. 복잡해 보이지만 그냥 join 몇번 해주면 될것같다. 바로 SQL 을 작성해보자.
SELECT o.*
FROM orders o
INNER JOIN member m ON m.member_id = o.member_id
INNER JOIN order_detail od ON od.order_id = o.order_id
INNER JOIN product p ON p.product_id = od.product_id
INNER JOIN prod_option po ON po.product_id = p.product_id
INNER JOIN options op ON op.option_id = po.option_id
WHERE o.member_id = 2;
우리는 JPA를 사용할것이기 때문에 이걸 JPQL로 변환 해보자.
SELECT o FROM Order o
JOIN FETCH o.member m
JOIN FETCH o.orderDetails od
JOIN FETCH od.product p
JOIN FETCH p.prodOptions po
JOIN FETCH po.option
WHERE m.id = :memberId;
그런데 막상 JPQL을 작성해보니 문제가 발생했다. JPQL의 경우 두 개 이상의 컬렉션 조회를 지원하지 않는다는것 이다. 왜냐하면 컬렉션 조회를 한다는것은 1:N 관계에서 '1' 하나로 '1' 에 연관된 모든 'N' 을 조회해오겠다는 뜻인데 이 경우 데이터가 뻥튀기, 즉 중복되는 데이터가 생기게 되버린다.
현재 멤버2가 주문한 주문수는 총 10개이다. 하지만 위에 작성한 join 쿼리로 DB를 조회해보면
10개를 아득히 초과하며 총 40개의 주문이 출력이 된다. DB의 경우 설령 order_id 는 같더라도 한 order에 여러개의 order_detail이 존재하면 그대로 row를 2개 생성해버린다. 그런데 만일 order_detail이 또 여러개의 product, product가 여러개의 option을 가지게 되면 row는 계속해서 늘어나게 된다.
DB의 경우 그냥 row의 개수를 계속해서 부풀려서 출력해주면 되지만, 이걸 객체지향 세계에선 1:N fetch 조인(=컬렉션 조회) 이 여러번 하게 되면 풀어낼 방법이 없기 때문에 아예 JPA가 2개이상의 컬렉션 조회를 금지 시킨것이다.
그럼 컬렉션 조회를 한 번만 했을 경우엔 괜찮은가?
그렇게 하면 예외가 터지진 않겠지만 어쨌거나 데이터 중복은 여전히 발생하므로 페이징을 구현할 수는 없다.
예를들어, 총 주문내역 100개를 10개씩 끊어서 총 10 페이지로 페이징을 구현하려한다. 이때, 컬렉션 조회를 해버리면 데이터가 중복되어 주문이 100개가 아닌 100개 이상이 될 것이고 페이지수가 10 페이지보다 늘어나게된다. 결국 주문내역 100+a 개는 뻥튀기된(중복된) 데이터이므로 페이징이 의미가 없어지게 되버린다.
그럼 이걸 어떻게 해결 해야 할 까?
우선 N:1 관계인 경우에는 데이터 중복이 전혀 발생하지 않기 때문에 N:1 인 엔티티들만 먼저 fetch join 을 한다.
SELECT o FROM Order o
JOIN FETCH o.member m
WHERE m.id = :memberId;
그런 다음에 나머지 OrderDetail Product ProdOption Option 들은 어떻게 가져올까? 이 객체들은 현재 fetch join을 하지 않았기 때문에 모든Order 의 List<OrderDetail> orderDetails 에는 빈 리스트 객체만이 차지하고 있을것이며 나머지들 또한 빈 리스트 혹은 프록시 객체가 대신 채워져있을것이다.
그럼 어떻게 채우느냐?
우리는 이 녀석들을 "서비스 레이어" 에서 채워 넣어줄것이다.
원리는 다음과 같다.
프록시 객체들의 경우 호출되기 전까지 계속 프록시가 들어있지만 호출 되는 순간 JPA가 이를 감지해서 진짜 객체를 로딩해준다. (이게 지연로딩의 원리이기도하다) 즉, 프록시 객체들을 우리가 일일이 호출해주면 되는것이다.
그런데 그렇게 하면 반드시 따라오는 문제, N+1 문제 를 피할 수 없게된다. 예를들어 현재 Order 와 OrderDetail 은 1:N 관계이다. 즉, Order 하나에 여러개의 OrderDetail 들이 존재하는데 현재는 지연로딩이 적용되었기 때문에 빈 리스트만이 들어있는 상태이다. 총 10개의 Order 가 있고 Order 1개당 3개의 OrderDetail 들이 들어있다면 Order 하나를 조회하는데 3개의 OrderDetail 조회 쿼리가 날아갈것이고 X10 이므로 총 30개의 조회 쿼리가 날아가게 되는것 이다. 이는 성능에 엄청난 악영향을 끼치게 될 것이다.
그렇다면 이 N+1 문제는 어떻게 해결할까?
JPA에서는 BatchSize 를 통해 N+1 문제를 해결 할 수 있도록 도와준다. BatchSize 를 사진과 같이 100개로 지정을 해놓는다면 JPA는 for문이 돌아가면서 모든 (정확히는 BatchSize 만큼) Order 의 PK들을 기억해놓았다가 OrderDetail 들을 조회하려 할 때 in 쿼리로 한방에 날리게 된다.
보다시피 order_detail 을 조회할때 N개의 쿼리를 날리는게 아니라 in 쿼리로 한번에 가져오게된다.
즉, N+1 이 아닌 1+1 개의 쿼리가 발생 한다. (Product, ProdOption, Option 도 동일한 원리로 in 쿼리를 날리므로 엄밀히 따지면 1+4 개의 쿼리가 발생한다.)
정리하자면 다음과 같다.