No Offset 쿼리로 Paging 성능 개선하기 (NGrinder로 성능 개선 확인2)

Minseok-Choi·2022년 12월 26일
4

토이프로젝트

목록 보기
4/5
post-thumbnail

이 글에 대해서

  • 이전 NGrinder로 부하 테스트를 진행해보고, 해당 API에서 paging 쿼리와 이름 검색에서의 성능 문제를 예상한 리팩토링 과정입니다.
  • 기존 Paging 쿼리를 No Offset 쿼리로 개선하고, 이름 검색을 Index를 활용해 성능의 개선을 확인합니다.
  • 프로젝트에서는 MySQL 8.0(Local docker 환경) 과 Spring Data Jpa를 사용하고 있습니다.
  • 테스트는 모두 로컬환경에서 진행되었습니다.
  • 코드는 https://github.com/squad-map/squad-map-project/tree/BE/BE 확인할 수 있습니다.

Dummy Data insert하기

  • 지도 목록을 조회하는 API로 회원(member), 지도(map), 장소(place) 테이블을 조회해야하는 테이블입니다.
  • 장소의 수를 count하는 것은 캐싱으로의 리팩토링을 통해 개선을 생각하고 있어서 place를 제외한 member와 map 데이터만을 넣고 테스트합니다.
  • member 500만건
  • map 1000만건 (member 1명당 지도를 2개씩 생성합니다.)

1. 기존 Paging (Offset)

기존 API 로직 살펴보기

  • Controller 로직입니다.
    - Pageable 객체와 검색조건을 QueryParam으로 받아서, mapService를 호출합니다.

  • MapServiceImpl 로직입니다.
    - 지도 이름 검색조건이 있을시 없을시에 따라서 Query를 다르게 호출하도록 했습니다.
    - fullDisclosure 필드의 의미는 지도 전체 공개, 비공개의 의미로 true일 경우 전체공개지도입니다.

  • MapRepository의 경우 (SpringDataJpa)JpaRepository를 통해서 NamedQuery를 활용했습니다.

기존 로직의 문제 확인

  • 기존 로직을 활용하면, Spring MVC와 Spring Data Jpa를 통해서 Pageable 객체를 아주 손쉽게 활용할 수 있습니다.
  • 또한, 데이터가 적다면 성능적인 이슈도 느끼지 못합니다.
  • 하지만 데이터가 증가함에 따라서 쿼리속도가 굉장히 느려지고, DB커넥션의 지속 시간이 길어짐에 따라서 성능적인 부하를 견딜 수 없게됩니다.

Pageable을 파라미터로 주고, Page객체를 반환타입으로 받으면?

  • 반환타입인 Page에는 TotalElement(조건에 해당되는 전체 데이터 갯수)와 TotalPages(전체 페이지 갯수) 필드가 존재합니다.
  • 전체 데이터, 전체 페이지 수를 확인하기 위해서는 요청하는 size의 데이터만을 조회하면 되는 것이 아니라, Count 쿼리를 한번 더 호출하게 됩니다.

Join과 paging 쿼리를 함께 사용할 때, count쿼리를 수정해주어야하는 이유입니다.

Offset

  • Pageable의 구현체인 PageRequest의 필드인 pageoffset(page * size)으로 작동하게 됩니다.
  • Offset은 MySQL 기준으로 쿼리 조건에 해당되는 데이터의 몇번째 row부터 반환하는 것입니다.
    • 몇번째 row인지 확인하기 위해서는 해당 offset(row)이전의 데이터까지 모두 조회해야만 합니다.
  • 그래서 요청되는 page(page number)가 작을 경우 속도가 빠르지만, page가 커질 경우 size는 같지만 조회해야할 데이터가 많아지기 때문에 속도가 느려질 수 밖에 없습니다.

실제 쿼리로 확인하기

  • Offset 크기에 따라서 쿼리의 속도가 크게 차이나는 것을 볼 수 있습니다.
  • 실제 서비스의 단순 조회 기능에 이러한 쿼리 속도라면, 서비스가 장애가 발생하는 것은 당연한 결과입니다.

NGrinder로 확인해보기

  • 지난번 사용했던 script에서 page number에 대한 조건을 1000만건으로 수정하고, size는 10개로 고정하였습니다.
  • 기본적으로 동작 확인을 위한 테스트로 Vuser를 30으로 두고 실행했습니다.
  • 테스트가 동작중 실패하였고, error가 계속 발생했습니다.

  • 문제의 원인은 로그를 통해서 확인할 수 있었습니다.

  • 커넥션 타임아웃이 지속적으로 발생하는 것을 확인할 수 있습니다.

  • 위에서 DB에 직접 쿼리를 통해서 확인했던 것과 같이 쿼리 응답시간이 너무 오래걸리게 되어서, timeout이 발생하게 됩니다.

  • 이전 map 데이터를 1000개 넣고 테스트했을 때는 정상적으로 작동했던 API가 제 기능을 하지 못하는 것을 확인할 수 있습니다.

Slice 활용하기

  • Page객체로 반환함에 따라 나가는 Count 쿼리가 없어지면 조금은 나아질까? 하는 궁금증으로 반환타입만을 Slice로 수정하고 테스트 해보았습니다.
  • Slice 자료구조를 활용하면, 전체 page 갯수 및 데이터 갯수를 활용할 수는 없지만
    • 같은 조건으로 더 많은 데이터를 받을 수 있는지(hasNext), 이 데이터가 첫 데이터인지(first)를 확인할 수 있습니다.
  • Count 쿼리를 보내지는 않는 것만 확인되었을 뿐 결과적으로 테스트를 통과할 수는 없었습니다.

2. Paging 개선하기 (No Offset)

  • offset를 사용하게 되었을 때의 성능 문제를 no offset 쿼리를 통해서 개선합니다.

API 수정

  • 기존 PageSize를 통한 UI를 구성했다면, 무한 스크롤 개념으로의 UI로 수정되어야 합니다.
    • 페이지 사이즈를 사용하는 UI에서 수정할 수 없다면, 커버링 인덱스를 활용한 성능 개선이 필요합니다.
  • 쿼리파라미터로 page와 size를 받았던 것과 다르게 마지막으로 표시된 지도의 아이디를 넘겨받도록 수정합니다.
    • ex) /map/public?lastMapId=1000
    • size는 기존과 같이 요청받아도 무방하지만, 테스트 조건을 동일하게 하기위해서 size를 고정했습니다.

로직 수정

  • Controller 로직입니다.
  • SimpleSlice는 직접만든 클래스로, Slice에서 제가 필요한 정보만 담도록 구현했습니다.
  • LastMapId에 대한 파라미터를 따로 검증할 수도 있지만, 들어오지 않는 경우에 대해서만 defalutValue로 0을 받도록 간단하게 구현했습니다.
  • Map의 id의 경우 AUTO_INCREMENT에 의해서 1부터 등록됩니다.

  • Service 로직입니다.
    • NameContaining이 아닌 NameStartingWith로 메서드 이름이 변경된것은 이름 검색시 Index를 활용하기 위해서 수정했습니다.
      • 그 내용은 다음 글에서 확인하실 수 있습니다.
  • PageRequest를 활용하여 size값만을 10으로 고정하고 요청하도록 했습니다.

PageRequest를 사용한 이유

  1. limit의 사용
  • JPA를 통해서 쿼리를 JPQL로 짜게되면, limit를 사용할 수 없습니다.

  • SpringDataJpa를 통해서 Named query를 활용한다면 findMapsTop10과 같이 조회할 데이터의 갯수를 지정해줄 수 있지만,

  • 요청 size를 고정하지 않도록 요구사항이 변경된다면 활용할 수 없습니다.

  • 다른 방법으로는 native query를 활용해서 직접 쿼리를 구성하는 방법이 있습니다.

  1. Slice의 size
  • Slice에는 실제 담긴 data의 갯수 뿐만 아니라, 요청한 size의 갯수를 담아야 정확한 정보를 제공할 수 있습니다.
  • Slice의 추상 구현 클래스인 Chunk의 경우 Pageable의 유무에 따라서 반환되는 size의 갯수와 요청된 size의 갯수를 다르게 반환합니다.
  • 이 또한 size를 클라이언트에서 정해서 요청할 수 있도록 요구사항이 변경된다면 필요한 부분입니다.

실제 쿼리로 확인해보기

  • offset 쿼리와 같은 결과가 나오지만, 속도의 차이는 명확합니다.

NGrinder로 확인해보기

  • Agent 1 / Vuser 102 (Process 3 / Thread 34) 환경에서 5분간 테스트했습니다.
    • lastMapId는 10,000,000까지 random하게 요청했습니다.
  • MTT가 1.6초 / TPS가 63입니다.
  • 만족스러운 수치는 아니지만, 이전 offset paging시에는 Vuser 30 환경에서, Connection timeout이 발생했던것과는 다르게 정상적으로 동작하는 것을 확인할 수 있습니다.

결론

  • 정말 간단한 조회 쿼리에 대해서 offset과 no offset, JPA에서 Page, Slice 객체에 대한 간단한 활용에 대해서 알아봤습니다.
  • No offset과 관련하여 Cursor Paging, 무한스크롤 페이징과 관련하여 검색하시면 많은 내용을 참고하실 수 있습니다.
  • 지도 이름 검색의 성능 개선에 대해서 이어적도록 하겠습니다.

References

profile
차곡차곡

0개의 댓글