상품 검색 기능 성능 문제 발견 및 테스트
최종프로젝트에서 상품 검색 기능을 구현하고 프로젝트를 배포했을때, 아래와 같이 조회 속도가 매우 느린 문제가 발생했다.
이를 해결하고자 먼저 포스트맨을 이용해 응답시간을 체크해보았다. 포스트맨으로 확인해보았을때 5000~6000ms의 비정상적인 응답시간을 반환하는것을 확인할 수 있었다. 클라이언트의 입장에서 2000ms가 넘어가면 서비스를 제공하기 어렵다는 점을 기준으로 봤을때 확실히 개선히 필요한 부분이었다.
nGrinder로 부하테스트를 진행했는데 TPS는 1.2, MTT는 8000ms로 매우 비정상적인 매트릭을 확인할 수 있었다. 반면, 메모리나 CPU가 매우 안정적인 모습을 보여주고 있는데 이를 확인했을때 서버 외부의 문제일 가능성이 크다고 예측했다.
상품 검색 기능 1+N 문제 해결
성능 하락의 원인으로 두 가지 이유를 도출하고 해결할 수 있었는데 첫번째는 상품 검색 과정에서 1+N 문제가 발생하고 있다는 것이었다. 상품 엔티티와 이미지 테이블이 1:N 관계를 맺고 있었고 상품을 불러오고 이미지를 매핑해 반환할때 limit만큼의 1+N 문제가 발생했다.
1:N 엔티티가 한개라 fetch join을 쓸만 하지만, JPA는 limit, offset이 적용된 쿼리의 결과를 메모리에 적재한 후 페이징 처리를 진행해서 반환하기 때문에 잘못하면 상품 테이블 레코드 수 * N 사이즈의 반환 테이블이 그대로 메모리에 올라갈 수 있어 fetch join은 사용하지 않기로 결정했다. 결과적으로 선택한 방법은 @BatchSize를 이용해 해결하는 방법이었다. 쿼리수가 늘어날 순 있지만 앞선 생각으로 안정적인 서비스를 제공하는게 더 좋을것이라 판단했다. 아래와 같이 상품이미지 컬렉션에 선언해 in 쿼리를 이용해 불러올 수 있도록 하였다.
@BatchSize를 이용해 연관 컬렉션을 in 쿼리로 불러오게되면 Hibernate의 QueryPlanCache로 인해 out-of-memory가 발생할 수 있다. QueryPlanCache에 대한 설명은 아래 글에 별도로 정리했고 프로젝트에서는 아래와 같이 in_cluase_parameter_padding 옵션을 선언해 주었다. size 속성으로 32를 사용한 것도 이를 고려해서이다.
in_clause_parameter_padding=true:defer-datasource-initialization: true
반환타입을 Page객체에서 List로 변경
1+N 문제를 해결해서 쿼리가 3개로 줄어든 것을 볼 수 있다. 그리고 여기서 성능 하락의 가장 치명적인 원인을 발견할 수 있었다. 분명, 클라이언트의 조회 요청 limit 값은 20인데 쿼리가 2개가 아니라 3개로 나가고 있었다. 2번째 줄의 count 쿼리가 추가로 발생하고 있었는데 이유는 JPA에서 제공하는 Page객체에 있었다.
Spring Data JPA의 JpaRepository를 이용할때, 페이징 처리를 위해 Pageable을 파라미터로 전달하면 Page<> 객체를 필수적으로 반환하게 되는데 이때, Page객체는 조건에 맞는 총 페이지수도 같이 반환하게 된다. 이를 위해 count 쿼리를 요청하는데 이부분이 의심스러워 직접 DB에 접속해 count 쿼리를 요청해보았다. 아니나 다를까 아래와 같이 5000ms의 응답시간이 찍히며 성능 향상에 원인을 준 주범이었다.
하지만, 아무리 데이터가 많다고 해도 count 쿼리에 5000ms의 시간이 걸리는건 매우 비정상적이다. 결국 수천만번의 삽질 끝에 AWS RDS의 스펙 문제인 것으로 판단했다. 프리티어 RDS를 사용하다보니 200만건의 데이터를 감당하지 못하고 임계점을 지나 DB 자체의 수행 능력이 떨어지게된 것이다.
문제는 발견했는데 고스펙의 RDS를 운영할 수 있는 여건이 되지 않았고 로컬에서 계속 호스팅을 할 순 없었기 때문에 현실적인 방안을 찾아보았다. 결론은 프론트엔드와 협의하에 상품 검색에 무한로딩 방식을 적용해 전체 페이지수를 사용하지 않도록 서비스하는 것이었고 이를 위해 QueryDSL을 이용해 직접 검색 쿼리를 구성하는 방식으로 변경하였다.
상품 검색 서비스 성능개선 마무리 및 스트레스 테스트
1+N 문제와 낮은 DB스펙으로 발생한 문제를 해결하고 아래와 같이 매우 빨라진 응답속도를 확인할 수 있었다. 이번 성능 개선 경험은 단순히 코드를 잘 짜면 좋은 성능을 낼 수 있다는 기존 생각을 완전히 엎었고 문제는 다양한 곳에서 발생할 수 있다는 것을 느꼈다. 문제는 생각보다 허무하게 해결할 수 있었지만 이런 문제를 찾을 수 있는지 또한 백엔드 개발자로서 매우 중요하다고 생각한다.
아래는 성능 개선 후 nGrinder로 부하테스트를 진행한 매트릭이다. TPS는 381, MTT는 200ms로 이전과 비교했을때 준수한 성능을 보여주고 있고 MEM, CPU 사용률 또한 문제없이 보여지고 있다. 테스트 시나리오와 전체적인 결과는 아래 다른 포스트를 통해 정리해놓았다.