상품 검색 성능 문제 발견 및 테스트
최종프로젝트에서 상품 검색 기능을 구현하고 프로젝트를 배포했을때, 아래와 같이 조회 속도가 매우 느린 문제가 발생했다. 포스트맨으로 응답 시간을 확인해 보았을때, 5000~6000ms의 비정상적인 응답시간을 반환하는것을 확인할 수 있었다. 클라이언트의 입장에서 2000ms를 초과하면 서비스를 제공하기 어렵다는 점을 기준으로 봤을때 확실히 개선이 필요한 부분이었다.
또한, 정확한 지표를 통한 성능 차이를 확인하기 위해 nGrinder로 부하테스트를 진행했다. 개선 전 TPS가 1.2, MTT는 8000ms로 매우 비정상적인 매트릭을 확인할 수 있었다. 반면, 메모리와 CPU 사용률은 안정적인 모습을 보여주고 있었는데 이를 통해 서버 외부의 문제일 가능성이 크다고 예측했다.
상품 검색 기능 1+N 문제 해결
성능 하락의 원인으로 두 가지 이유를 도출하고 해결할 수 있었다. 첫번째는 상품 검색 과정에서 1+N 문제가 발생하고 있다는 것이었다. 상품 엔티티와 이미지 테이블이 1:N 관계를 맺고 있었고, 상품 데이터를 불러오고 이미지를 매핑해 반환할때 반환 테이블의 크기 만큼 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 옵션을 선언해 주었다. 아래 옵션을 사용하면 in-query를 2의 제곱 단위로 고정해 실행하기 때문에 메모리를 효율적으로 사용할 수 있다. @BatchSize 32를 사용한 것도 이를 고려해서이다.
in_clause_parameter_padding=true:defer-datasource-initialization: true
상품 테이블 파티셔닝
1+N 문제를 해결해 쿼리가 3개로 줄었다. 하지만, 응답시간은 미세하게 개선되었을 뿐 여전히 5000ms 정도의 응답시간을 유지하고 있었다. 확인차 쿼리를 다시 분석했을때, 성능 하락의 가장 치명적인 원인을 발견할 수 있었다. 클라이언트에서 상품 검색 시 요청하는 페이지 사이즈는 20이기 때문에 2개의 쿼리가 실행되어야 하는데 3개의 쿼리가 실행되고 있었다. 아래 로그를 보면 2번째 줄에 count 쿼리가 추가로 발생하고 있었는데 이유는 JPA에서 제공하는 Page객체에 있었다.
SpringDataJPA의 JpaRepository를 이용할때, 페이징 처리를 위해 Pageable을 파라미터로 전달하면 Page객체를 필수적으로 반환하게 되는데 이때, Page객체는 조건에 맞는 총 페이지수를 반환하기 위해 count쿼리를 실행한다. 해당 문제를 재현하기 위해 DB에 접속해 직접 쿼리를 요청했고, 그 결과 아래와 같이 5000ms의 응답시간이 발생하는 성능 하락의 주요 원인을 찾을 수 있었다.
데이터가 많다고 해도 count 쿼리 하나에 5000ms의 시간이 걸리는건 매우 비정상적이었다. 인덱스를 확인해봤을때 count 쿼리는 기본적으로 PK 인덱스를 타고 있었고 멘토님에게 조언을 얻어봐도 무엇이 문제인지 찾을 수 없었다. 마지막 시도로 비교적 고성능의 DB를 구성하고 데이터 마이그레이션 후 동일한 쿼리를 실행해봤을때 100~150ms의 정상적인 실행속도를 보여주는것을 확인할 수 있었다. 이를 통해 성능 하락의 원인을 저성능 RDS 사용으로 200만건의 데이터를 감당하지 못하고 DB 자체의 수행 능력이 떨어진 것으로 판단하게 되었다.
EXPLAIN
SELECT COUNT(*)
FROM product;
문제는 발견했지만 비용의 문제로 높은 스펙의 RDS를 운영할 수 없었다. 또한, 로컬에서 호스팅을 할 수도 없었기 때문에 현실적인 방안을 찾아보았고 결론적으로 테이블 파티셔닝을 기법을 이용해 성능을 향상할 수 있었다. 아래와 같은 SQL을 이용해 기존 상품 테이블에 파티셔닝을 적용하였고, 파티셔닝은 상품 테이블 PK의 해시값을 기준으로 4개의 파티션 테이블에 균등하게 저장될 수 있도록 하였다. 아래 파티션 조회 결과에서 데이터가 잘 나누어진 것을 확인할 수 있었다.
ALTER TABLE product PARTITION BY HASH (id)
PARTITIONS 4;
SELECT TABLE_NAME, PARTITION_NAME, PARTITION_ORDINAL_POSITION, PARTITION_METHOD, TABLE_ROWS
FROM INFORMATION_SCHEMA.PARTITIONS
WHERE TABLE_NAME='product'
;
상품 검색 서비스 성능개선 마무리 및 부하 테스트
1+N 문제와 파티셔닝을 통한 낮은 DB 스펙으로 발생한 문제를 해결하고 아래와 같이 매우 빨라진 응답속도를 확인할 수 있었다. 이번 성능 개선 경험은 단순히 코드를 잘 짜면 좋은 성능을 낼 수 있다는 기존 생각을 뒤엎고 문제는 다양한 곳에서 발생할 수 있다는 것을 깨달음을 주었다. 문제는 생각보다 쉽게 해결할 수 있었지만, 이러한 외부 문제를 찾고 해결하거나, 협업할 수 있는 능력 또한 개발자로서 매우 중요한 역할을 할 것이라고 생각한다.
아래는 성능 개선 후 nGrinder로 진행한 부하테스트 매트릭이다. TPS는 381, MTT는 200ms로 이전과 비교했을때 준수한 성능을 보여주고 있고 MEM, CPU 사용률 또한 문제없이 보여지고 있다. 테스트 시나리오와 전체적인 결과는 아래 다른 포스트를 통해 정리해놓았다.