👀 들어가기 전에
그동안 주문 생성부터 결제 완료까지 서비스의 핵심 트랜잭션 경로에 대한 성능 최적화 과정을 주로 다뤄왔다.
이번 글에서는 잠시 그 이야기를 멈추고 상대적으로 중요도가 낮다고 생각해 (트래픽이 몰리지 않을 것이라 판단해) 미뤄두었던 주문 조회 페이지의 성능 문제를 다시 꺼내보려 한다.
주문 조회는 결제만큼 복잡한 비즈니스 로직이 들어가지는 않지만, DAU가 증가할수록 가장 많이 호출되는 조회 API 중 하나가 되며 구조에 따라서는 예상보다 빠르게 병목이 발생할 수 있는 영역이기도 하다.
👀 본론
주문 조회 페이지의 요구사항
주문 조회 페이지는 단순히 "주문 목록"을 보여주는 화면이 아니다.
아래는 내가 레퍼런스로 삼았던 쿠팡과 무신사의 주문내역을 보여준다.
쿠팡의 주문보기
무신사 주문 내역
래퍼런스로 살펴본 쿠팡, 무신사 모두 다음과 같은 특징을 가지고 있었다.
- 날짜 단위로 주문이 묶여 표시된다.
- 하나의 주문 안에 여러 상품이 포함된다.
- 상품 단위로 교환/환불/취소가 가능해야한다.
- 상품 옵션, 가격, 상태, 대표 이미지까지 노출해야한다.
즉 화면을 구성하기 위해 필요한 데이터는 단일 테이블로 끝나지 않았다.
필요한 데이터 정리
orderProduct 테이블
주문 테이블
상태 테이블
위의 ERD 기준으로 확인했을 때,
- Order : 주문 번호, 주문명, 총 금액, 주문 시각
- OrderProduct : 상품별 주문 가격, 옵션(사이즈/색상), 취소 수량, 대표 이미지
- Prdouct : 상품명, 상품 고유 정보
- Status : 주문 상태 / 주문 상품 상태
결과적으로 3~4개 이상의 테이블을 조인해야만 화면 하나를 구성할 수 있는 구조였다.
☝🏻첫 번째 시도 : 단순 페이징 + Lazy Loading
처음에는 별다른 고민 없이 다음과 같은 접근을 했다.
- 주문 테이블 기준으로 페이징
- 연관 엔티티는 Lazy 로딩
결과는 예상대로였다.
쿼리 폭증의 문제
- 주문 목록 조회 한 번에 300ms 이상
- 주문 → 주문 상품 → 상품 → 상태 접근 과정에서 N+1 쿼리 폭증
단순 페이징 + Lazy 로딩
단일 사용자의 주문 내역 조회가 300ms 이상 (당시 주문 내역도 5건 내외였다.) 걸린다는 점은 트래픽이 증가했을 때 충분히 문제가 될 수 있는 수치였다.
✌🏻 Fetch Join의 유혹과 페이징의 딜레마
그 다음으로 떠올린 선택지는 Fetch Join이었다.
하지만 여기에는 잘 알려진 문제가 있다. (이제는 너무 유명해서 누구나 다 아는...)
컬렉션 Fetch Join + 페이징은 안전하지 않다.
Fetch Join을 적용하면 쿼리 결과는 Order x OrderProduct 형태로 row가 뻥튀기된다.
이 상태에서 lilmit 20을 걸게 되면 주문 20개가 아니라 조인결과 row 20개가 나오게 된다.
즉,
- 주문 개수가 보장되지 않거나
- Hibernate가 메모리에서 페이징을 수행하며
- 성능과 메모리 사용량이 급격히 악화된다.
예를 들면 아래 그림과 같다.
Join 결과 (Order x OrderProduct)
LIMIT 이내와 이후
Join된 Row 결과로 뽑아내기 때문에 20개가 주문 20개로 나오는 것이 아니라
불완전하게 주문번호 #20241213-b2c4f6d1에 대해서는 나머지 아이템이 잘려 보이게 되는 것이다.
3️⃣ 선택한 결과 : ID 기반 페이징 + Fetch Join
결과적으로 선택한 방식은 다음과 같다.
1) 주문 ID만 페이징으로 조회한다.
Order 테이블 기준
- userId 조건
- 정렬 + offset/limit
- 결과는 ID만 조회
이 단계에서는 row가 폭발할 일도 없고 인덱스를 효율적으로 사용할 수 있게 된다.
2) 페이징된 ID 기준으로 연관 데이터 Fetch Join
where order.id in (:ids)
- 필요한 주문만 대상으로
- Order / OrderProduct / Product/ Status를 Fetch Join
- 화면 렌더링 시 추가 쿼리 발생이 안생긴다.
이 방식으로 N+1 문제를 해결하였으며 페이징 안정성을 확보했고 조인 비용을 통제할 수 있었다.
ID 페이징 + Fetch Join
부하테스트로 검증하기
구조를 변경한 뒤, 실제로 어느 정도까지 버틸 수 있는지 확인해보기로 했다.
테스트 시나리오는 다음과 같다.
- DAU : 100,000
- 주문 조회 클릭률 : 30%
- 하루 요청 수 : 약 30,000
- 30초 구간으로 환산 시 Peak VUser는 약 50
또한 테스트의 신뢰도를 높이기 위해
- 사용자 1명당 주문 데이터 약 10만건
- 총 4명의 사용자 데이터를 적재한 상태에서 테스트를 진행해보았다.
결과는 아래와 같다.
결과 RPS
VUser 50기준으로 주문 조회 API Max Response Time은 약 3초 정도이며 평균 응답 시간은 1.4초로 훨씬 안정적인 수준이었다.
조회 API 특성상 모든 요청이 동시에 몰리는 경우는 드물고 캐시/스케일 아웃 여지도 충분하다는 점을 고려하면 단일 서버 환경에서 충분히 감당 가능한 수준이라고 판단했다.
이 지점 이후의 개선은 인프라 확장으로 판단했다. (더이상 쿼리 구조를 개선할 부분이 없다고 판단했다.)
👀 마치며
이번 글에서는 메인 트랜잭션 흐름에 가려 잠시 잊고 있었던 주문 조회 성능 최적화 과정을 다시 정리해보았다.
조회 성능 문제는 눈에 잘 띄지 않지만 DAU가 증가할수록 서비스가 커질 수록 가장 먼저 병목으로 드러나는 곳이기도 하다.
쿼리만 줄일게 아니라
을 함께 고려한 구조를 선택하는 것이 중요하다는 것을 다시금 깨닳게 되었다.
다음 포스팅에서는 저번 글에서 작성하려 했던 기업이 성장함에 따라 늘어나는 트래픽이 발생할텐데
이때, 트래픽이 증가함에 따라 구조를 어떻게 바꿔나가면 좋을지,
어떤 식으로 인프라를 바꿨을 때 늘어나는 트래픽을 방어할 수 있을지에 대해 고민했던 부분에 대해 글로 남겨보려한다.