Series/실전!

업무에 적절한 pagination 선택하기

Hyunec 2021. 11. 7. 03:31

pagination은 백엔드 구현에서 가장 많이 고려해야 되는 기술 중 하나입니다.

일반적으로 알려진 pagination 기술에는 page, slice 가 있고 특수한 경우에 사용되는 noOffset과 Covering Index 기술이 있는데요.

이번 글에서는 실무에서 만난 특이한 pagination 사례에 대해 각색하여 기록해봅니다.


업무 요건 

  1. 전체고 조회가 필요합니다.
  2. 상품군이 신설되었습니다.
  3. 기존에는 회사와 상품이 존재했으며 M:N 관계입니다.
  4. 상품군과 상품은 1:N 관계입니다.
  5. 상품군과 회사의 관계는 M:N 관계입니다.
  6. 서로 다른 재고처에 있는 재고를 합쳐야 합니다.
  7. 각 상품군별, 회사별로 소계를 보여주어야 합니다.
  8. 페이지당 300개 안팎은 허용 범위입니다.

기술적 배경

  1. 각 도메인별 조인이 많이 일어나 성능 이슈가 있습니다.
    1. 상품군과 상품, 회사는 서로 다른 테이블에서 관리되고 있습니다.
    2. 재고의 총수량은 결과 모든 재고처의 합으로 실시간 계산됩니다.
  2. 레거시 로직의 개선은 힘듭니다.

전체고 조회는 신설된 요건이지만 기존에는 비슷한 로직을 '한방 쿼리'라는 거대한 SQL와 slice 방식으로 처리하고 있었습니다.

slice 방식은 cursor와 size를 통해 다음 데이터를 가져오는데, 소계가 생기면서 그 처리가 힘들어졌습니다.

 

업무 요건의 엑셀을 예로 들어

  • size = 5 일 때
    • 0 페이지(1 ~ 5): A 마켓의 식용유 상품군이 소계까지 정상적으로 출력됩니다. 
    • 1 페이지(6 ~ 10): B 마켓의 식용유 상품군은 소계까지 보이지만 A 마켓의 커피 상품군은 첫 줄만 보이고 잘려버립니다.
    • 2 페이지(11 ~ 15): A 마켓의 커피 상품군이 나오지만 limit 가 소계까지 가지 못합니다.

프론트의 관점에서는 스크롤하면 다음 페이지가 나오기에 큰 문제가 없어 보입니다.

하지만 백엔드의 관점에서는 전체 데이터를 생성한 후에야 cursor와 limit를 활용할 수 있습니다.

상품 종류 수를 통해 rowCount를 계산하여한다고 해도 테이블에 존재하지 않는 소계를 염두에 두고 로직을 구현해야 되기에 복잡도가 상승합니다.

그리고 상품과 회사가 늘어나고, 특히 재고 처가 늘어날수록 쿼리 성능이 저하될 것임이 확실해 보입니다.

 

그래서 조금 다른 접근법을 생각해봤습니다.


기술적 배경 확인

  1. 특정 회사와 특정 상품군에 M:N으로 존재하는 상품은 50개 이하입니다.
  2. 상품군 - goods, 회사 - company, 상품 - product

그리고 화면에서 호출할 API를 설계해봤습니다.

// A. 전체 회사 목록
GET /companies

// B. 전체 상품군 목록
GET /goods

// C. 대상 회사들의 모든 상품 개수 (총 row 수)
GET /products/count?companyId=10,20

// D. 특정 회사 & 특정 상품군에 해당하는 상품 목록
GET /products?goodsId=10&companyId=10
GET /products?goodsId=10&companyId=20
GET /products?goodsId=20&companyId=10
GET /products?goodsId=20&companyId=20

// E. 특정 회사 & 특정 상품군에 해당하는 상품 소계
GET /products/sum?goodsId=10&companyId=20

// F. 대상 회사들의 모든 상품 총계
GET /products/sum?companyId=10,20

// G. 앞의 API 를 조합하여 모든 자료를 한번에 반환
GET /products/total?companyId=10,20
  1. 해당 페이지에 접근하면 A, B API를 통해 전체 회사와 상품군 목록을 가져오고 List로 관리합니다.
  2. C API 를 통해 전체 개수를 보여줄 수 있습니다.
  3. 프론트에서는 그리드 화면의 최하단에 위치할 때마다 1번에서 생성한 List를 통해 D, E API를 동시에 호출합니다.
    1. 특정 회사와 특정 상품군에 속하는 상품은 50개 미만이고, 페이지 단위는 300 임으로 6쌍씩 호출해도 됩니다. 
    2. D API 호출은 매번 같은 row 수가 반환되지는 않기에 페이지 단위는 유동적일 수 있습니다.
  4. 1번의 모든 List 소비가 끝나면 F API를 통해 총계를 가져옵니다.
    1. 또는 최초에 호출해 항상 보여주는 방법도 있습니다.

기존의 한방 쿼리에 비해 API 가 많아졌기에 프론트의 로직이 복잡해지긴 했지만, 로직의 분리로 각각의 부담이 줄고 부분적인 리팩터링이 가능한 구조입니다. 무엇보다도 사용자의 입장에서는 이전과 차이가 없거나, 좀 더 빠른 속도를 제공할 수 있습니다.

그리고 기능이 부분적으로 구현되었기에 사용자에 맞춘 커스텀이 쉬워집니다. (ex. 소계 노출 여부)

하지만 여전히 문제는 남아 있습니다.

  1. 상품의 상태를 실시간으로 계산하기에 스크롤 중 상품에 변화가 있으면 총계와 맞지 않을 위험이 있습니다.
    1. 업무 성격상 전체고 조회를 할 때 상품에 변화가 없을 것으로 기대됩니다.
    2. 업무 성격상 최종본은 엑셀이나 출력자료를 사용할 텐데 이때는 속도가 느리더라도 G API를 사용할 수 있습니다.

기존의 로직을 유지하면서 성능을 가져갈 수 있는 방법도 있습니다.

 

  1. repository 레이어의 결과를 캐싱합니다.
    1. REPEATABLE READ에서 착안한 방법으로 해당 페이지를 이탈할 때 캐시를 지워주는 로직이 필요합니다.
    2. 로직이 분리되지 않았으므로 유지보수의 어려움은 존재합니다.
    3. JPA를 사용하지는 않음으로 영속성 콘텍스트가 아닌 수동 캐싱을 구현해야 합니다.

업무적으로 아직 결론을 내지 못한 주제이지만 전체 List를 반환하는 것 밖에 몰랐던 처음에서 업무 요건에 맞는 pagination 기술을 선택하고, 이제는 커스텀한 pagination 기술을 고민해야 되는 시기가 되었습니다.

좋은 기술에는 끝이 없고 필요에 의한 공부를 하는 것이 재미있다고 느꼈습니다.