진짜 개발자
본문 바로가기

FrameWork/Spring JPA

Spring JPA - JPA를 이용해 Commerce App 만들기 - 8 (무한스크롤, 페이지네이션, 컬렉션 조회 최적화, N+1 문제해결)

728x90
제목 없음


안녕하세요, 이번 포스팅에서는 나의 주문 목록 페이지에 무한스크롤 기능을 추가해보도록 하겠습니다. 또한 JPQL을 이용해 컬렉션을 조회할때, 최적화를 하는 방법과, Pagination을 할 수 있도록 하는 방법도 알아보겠습니다.

 

 

 

1. 필요한 것

우선 무한 스크롤을 만들기 위해 필요한것들을 생각해보도록 하겠습니다.

 

1.1 페이지네이션

가장 먼저 떠오르는 것은 페이지네이션 입니다. 페이지네이션이란, 간단히 말씀드려 게시판에서 페이지를 나누는것을 말합니다. 한번에 수많은 데이터를 요청하기보다는, 사용자가 볼 만큼만 부분 부분 서버에 요청하도록 하여, 서버에 부하를 줄일 수 있도록 하는 기술입니다.

 

무한 스크롤구현을 위해서도 프론트에서 데이터를 페이지별로 받을 수 있도록, 서버단 페이지네이션 기능 구현이 필요합니다. (너무 당연한가요?..

 

 

 

1.2 Rest API

지금까지 우리가 구현한 handler는 ModelAndView를 반환하도록 구현이 되어있었습니다. 즉 Serverside Rendering을 통해 View가 만들어지고 있었습니다. 하지만, Web페이지를 이미 로딩한 상태에서, 새로고침 없이 동적으로 DOM에 태그를 추가하기 위해서는, 데이터를 받아와 그것을 Javascript로 element로 바꿔주어 추가를 해주어야 합니다. 즉, Handler에서 View를 완성해 반환하는것이 아닌, 데이터를 반환해주어야 합니다. 1.1 페이지네이션과 합쳐 생각을 해본다면, 그때 그때 필요한 만큼의 데이터를 요청할 수 있는 API가 필요할 것입니다.

 

 

1.3 Ajax와 같은 비동기 Javascript 라이브러리

HTML과 같은 페이지는 새로고침이 일어나면, 새로히 다시 서버에게 정보를 요청하고 페이지를 렌더링하게 됩니다. 우리가 구현할 기능은, 무한 스크롤 기능인데, 다음 페이지의 데이터가 뿌려질때마다, 새로고침이 발생한다면, 다시 맨처음부터 페이지를 보게되고, 다시 요청하고... 무한반복을 하게 되고 절대로 다음페이지는 볼 수 없게 될 것입니다.

따라서 Ajax와 같이 비동기적으로 Server에 데이터를 요청하는 방법이 필요합니다. 이 덕분에, 데이터를 서버에 요청한 뒤, Javascript나 Jquery를 이용해, 새로고침 없이 DOM에 태그를 추가할 수 있습니다.

 

 

 

1.4 장애대응

만약 API가 별도로 분리 되어있거나, 외부 모듈을 사용하는 경우라면, 항상 장애대책을 마련해야 합니다. 왜냐하면, 우리 기능이 외부 모듈에 의존하게 되기 때문에, 응답시간이 느려질 수도 있고, 외부모듈에 장애가 발생한 경우에는 저희 서버의 페이지에도 장애가 같이 발생하기 때문입니다. 저희는 같은 서버내에서 API를 구현할 것이므로 이부분은 넘어가도록 하겠습니다.

더 많은 정보를 원하신다면, 서킷브레이커 패턴을 검색해보세요!

 

 

 

 

2. 구현

2.1 내 주문목록 페이지 반환 Controller

우선 데이터를 반환하는 Rest Handler를 만들기 전, 내 주문목록 페이지를 보여주기 위한 핸들러를 만들어야 합니다.

 

이 핸들러에서는 myOrderList.html 을 반환하는 일 만을 처리합니다. 주문 목록은 위에서 설명드렸다시피, 프론트측에서 구현할 것 입니다.

 

 

 

2.2 내 주문목록 데이터 반환 RestController(JPQL 컬렉션 최적화, N+1 문제 해결)

MyOrderRestController.class

이 핸들러는 지금 까지 저희가 구현했던 Handler와는 다르게 @RestController어노테이션이 부여된것을 볼 수 있는데요, ModelAndView를 반환하지 않고, HTTP의 바디에 바로 데이터를 넣어주게됩니다.

MyOrderService를 이용해 주문내역 데이터를 Pagination하여 필요한 만큼을 가져와 반환하게 됩니다.

 

 

MyOrrderService.class

이 서비스는, MyOrderDao를 통해 Pagination된 OrderEntity를 가져와 이를 사용자에게 전달할 데이터 형태(MyOrderSummaryDto)로 가공해 반환을 하게 됩니다.

 

 

JpaMyOrderDao.class

MyOrderDao는 Interface이며, 이를 구현한 JpaMyOrderDao를 살펴보도록 하겠습니다. 이번에는 직접 JPQL을 이용하는 것이 아니라, 가독성과 유지보수성을 위해 QueryDsl을 사용했습니다.

Query의 조건들은 간단합니다.

  • 성능향상을 위해, 주문 목록에서 필요한 데이터들을 모두 Fetch Join합니다.
  • 특정 Member의 주문목록을 가져오기 위해서, where절에 memberId 조건을 추가합니다.
  • 삭제 되지 않은 주문내역만을 가져오기 위해서 where절에 removed.eq(false) 조건을 추가합니다.
  • 페이지네이션된 데이터를 가져오기 위해 limit, offset(Mysql) 을 추가합니다.

 

  

OneToMany 관계 JPQL 최적화 (N+1 문제 해결)

위에서 이상한 점이 있습니다. 바로 제일 필요한 OrderItemEntity와, ItemEntity와의 연관관계를 맺지 않았다는 점입니다. 이렇게 하지 않으면 N+1 문제가 발생할 것이며, 결과적으로 성능 저하의 문제를 가져올 것입니다.

 

테스트를 위해 3개의 주문을 추가하고, 주문목록 RestController에 요청을 보내보겠습니다.

 

위 query는 주문목록 조회시 발생한 Query중 일부만을 발췌해서 보여드리기 편한 형태로 조금 수정한 것입니다. 보시다 시피, JPA 성능 문제를 야기하는 문제중 하나인 N+1문제가 발생하고 있습니다. OrderItem과 Item 역시 주문 목록에서 결국 필요한 데이터지만, 처음 Query시에 한번에 FetchJoin을 하지 않아, 영속성 컨텍스트에 데이터가 존재하지 않으므로 결국 Query를 별도로 요청하기 때문에 발생하는 문제입니다.

 

 

OrderItem, Item도 FetchJoin하면 되지 않나요 ?

OrderItem, Item도 같이 FetchJoin을 하면 물론 N+1문제는 해결할 수 있습니다. 하지만, 컬렉션을 FetchJoin하는경우, Pagination을 처리할 수가 없게됩니다.

 

왜냐하면, 데이터베이스에서, 1:N 관계를 조인하게 되면, 1쪽에 해당하는 데이터가 N수만큼 복사되어 출력이 되기 때문입니다. 이 때문에, Pagination이 N에 맞추어 되기 때문에 원하는 Pagination 결과를 받을 수 없습니다.

 

위 그림은 order와 order_item을 join한 결과입니다. order_item에는 2개의 상품이 들어있습니다. 이때 두 데이터를 Join하면 Order가 2개로 출력이 됩니다.

 

 

 

default_batch_fetch_size

그럼 어떻게 JPQL의 조회성능을 향상시키며, 페이지네이션을 할 수 있을까요?

 

바로 default_batch_fetch_size를 이용하는 것입니다. 위와 같이, application.yml 또는 application.properties에 옵션을 추가하면, 지정된 수 만큼 미리 fetch를 해오기 때문에, N+1문제가 발생하지 않습니다. 또한 pagination이 가능해집니다.

 

 

결과를 보면 한눈에 보아도, N+1문제가 발생하지 않았음을 볼 수 있습니다. query를 확인하면, where조건에 in절을 이용하여 필요한 데이터를 한번에 조회한 것을 볼 수 있습니다.

 

 

 

2.3 myOrderList 무한 스크롤 페이지 구현

전체 코드 : https://gitlab.com/galid1/jpa-commerce/-/blob/master/src/main/resources/templates/orders/myOrderList.html

 

view를 구현하는 것은 독자분들의 마음입니다. 여기서는 무한 스크롤을 만들기 위해 필요한 부분만을 볼 것입니다.

 

 

과정

무한 스크롤을 위해서는 다음 과정을 거치게 됩니다.

  1. 스크롤이 특정 위치에 닿는다.
  2. 서버에 데이터를 요청한다.
  3. 요청한 데이터를 element로 변환한다.
  4. DOM에 추가한다.

 

 

스크롤이 특정 위치에 닿을때 발생하는 이벤트 구현

위의 코드에대한 설명은 주석으로 적어놓았습니다. 이해를 위해서는 아래의 개념이 도움이 될것 같습니다.

  • window height: 화면의 높이
  • document height: 문서 전체의 높이
  • scroll top: 스크롤의 top이 위치하고있는 높이

 

 

데이터 요청 함수 구현

await $.get(...)

새로운 데이터를 요청하는 함수는 위와 같습니다. ajax를 이용해 앞서 생성한 Rest Controller에 요청을 하는데요. Pagination을 위한 정보를 query parameter로 전달합니다.

page는 말그대로 현재 받아올 데이터의 페이지를 말합니다. 전체 데이터의 개수를 한페이지에서 볼 size로 나누어 구할 수 있습니다. size는 한 페이지에서 볼 데이터의 양을 의미합니다.

$("tbody").append(toTrList(data.myOrderList));

데이터를 받아왔다면, data에 이를 셋팅하고, toTrList()함수를 이용해 원하는 형태의 element로 변환합니다. 마지막으로 DOM에 추가하고 싶은 위치에 append를 합니다.

if (data.total < size) {...}

만약 받아온 data의 total이 한 페이지의 size보다 작다면, 데이터가 더이상 서버에 존재하지 않는 것이므로, 스크롤 이벤트를 unbind() 합니다.