인기순 정렬이 신상품을 밀어내고 있었다 — PLP 정렬 로직 개선기
중고 전자기기 커머스 제품 리스트(PLP)의 단순 판매량 정렬을 4단계 복합 정렬과 신·구 인터리빙으로 바꾸고, 매 요청마다 재실행되던 상관 서브쿼리 집계를 CTE 1회 구체화로 바꾼 기록.
중고 전자기기 커머스의 제품 리스트(PLP) 기본 정렬을 단순 판매량순에서 4단계 복합 정렬로 바꾸고, 매 요청마다 주문을 통째로 집계하던 쿼리 구조를 CTE 1회 구체화로 바꾼 기록.
중고 스마트폰을 파는 커머스에서, 카테고리 제품 리스트(PLP)는 사용자가 가장 먼저 마주치는 화면이다. 여기서 뭐가 위에 뜨느냐가 곧 뭐가 팔리느냐였다. 그런데 이 리스트를 열어보면 늘 같은 얼굴이 상단을 차지하고 있었다 — 아이폰13 미니 같은, 이미 오래 팔려서 판매량이 쌓인 구형 인기 모델.
문제 — 인기순이 만든 세 가지 고인 물
기본 정렬은 단순 판매량순이었다. 직관적이지만, 운영하며 보니 세 가지가 걸렸다.
첫째, 구형이 상단을 영원히 차지했다. 판매량은 시간이 지날수록 누적된다. 그러니 오래 팔린 모델일수록 위로 가고, 갓 들어온 신상품·고가 기기는 판매량이 쌓일 새도 없이 아래로 밀렸다. 문제는 이게 회사 수익 구조와 어긋난다는 점이었다 — 21세기전파상은 판매가에 붙는 수수료가 수익이라, 고가·신형이 위에 떠서 팔릴수록 이득인데 정렬은 정반대로 움직이고 있었다.
둘째, 같은 모델이 색깔별로 리스트를 도배했다. 아이폰13 미니가 블랙·화이트·핑크로 각각 노출되니, 사용자는 스크롤을 내려도 계속 같은 모델만 봤다. 탐색 피로가 쌓였다.
셋째, 정렬을 바꾸려면 매번 무거운 집계를 돌아야 했다. 판매량 자체가 주문 데이터를 대량으로 집계하고 여러 테이블을 조인해서 나오는 값이라, 제품 리스트 조회가 눈에 띄게 느렸다. 정렬 정책을 손보고 싶어도 느려서 못 건드리는 상태였다.
검토 — 재고 우선 노출은 왜 접었나
가장 먼저 나온 아이디어는 회사 보유 재고를 위로 올리자였다. 우리 재고가 먼저 팔리면 좋으니까. 그런데 실제 재고를 들여다보니 접었다 — 보유 물량 대부분이 낮은 등급이었고, 여기에 1주 무료 환불 정책이 붙으면서 환불률이 높았다. 재고를 억지로 위에 올리면 환불이 늘고, 그 뒤처리는 고스란히 운영팀 몫이 된다. 팔리는 것과 팔고 나서 문제 없는 것은 다른 얘기였다. 그래서 재고 우선은 기준에서 뺐다.
해결 — 4단계 복합 정렬 + 신·구 인터리빙
정렬 기준을 하나에서 네 단계로 늘렸다.
- 최근 3개월 판매량 — 누적이 아니라 최근 흐름. 오래된 판매량이 영원히 상단을 잡는 걸 끊었다.
- 출시일
- 출시가
- 현재가
그런데 판매량을 여전히 1순위에 두니, 이번엔 신상품이 또 밀리는 문제가 남았다. 신상품은 최근 판매량도 아직 적으니까. 그래서 판매량 정렬 위에 한 겹을 더 얹었다 — 신·구 인터리빙.
국내 스마트폰 평균 사용 기간이 대략 2년 9개월이라는 자료를 참고해, 출시 2년 6개월을 경계로 신형/구형 그룹을 나눴다. 그리고 각 그룹을 4단계 기준으로 정렬한 뒤 1:1로 번갈아 배치했다. 구형 인기 모델이 상단을 독점하지 않으면서, 신형도 일정한 노출 슬롯을 확보하게 됐다.
색상 중복은 모델 × 색상으로 묶고, 그 모델의 최저가 대표 상품 1건만 리스트에 띄우는 것으로 정리했다.
성능 — 코드보다 쿼리 구조가 먼저였다
느린 쿼리를 처음엔 애플리케이션 코드에서 어떻게든 줄여보려 했는데, 방향이 틀렸다. EXPLAIN을 떠 보니 최근 3개월 판매량 집계가 SELECT 절 안의 상관 서브쿼리로 들어가 있었다(DEPENDENT SUBQUERY). 후보 상품 하나하나마다 3개월치 주문 집계가 재실행되고 있었던 것이다.
병목이 큰 스캔 한 번이 아니라 작은 스캔의 수천 번 반복이라는 게 핵심이었다. 이러면 스캔 한 번을 싸게 만드는 접근(인덱스)으로는 반복 횟수가 줄지 않는다.
그래서 판매량 집계를 상관 서브쿼리에서 빼내 CTE로 한 번만 구체화한 뒤, 메인 조회에서 LEFT JOIN으로 참조하도록 바꿨다. 조인·정렬에 쓰이는 컬럼의 복합 인덱스도 다시 잡았고, 집계 결과는 캐시로 재사용하게 했다.
체감상 리스트가 즉시 뜨는 수준으로 바뀌었다. 당시 프로덕션 응답 시간은 측정 조건을 기록해두지 않아 숫자로 남기지 않는다.
대신 같은 결과를 내는 두 쿼리를 나중에 동등 조건에서 재현해 비교했다. Docker MySQL 8.0.46에 합성 데이터(상품 옵션 7,449건, 주문 12만 건 중 3개월 이내 60,125건)를 넣고 잰 값이다.
| 방식 | 실행 시간 |
|---|---|
| 상관 서브쿼리 | 204.0초 |
| 상관 서브쿼리 + 인덱스 | 202초 |
| CTE 1회 구체화 | 0.085초 (best of 5) |
EXPLAIN 기준으로 상관 서브쿼리 방식은 후보 옵션 7,363행마다 약 12만 행을 스캔해 약 8.8억 행을 검사한다. CTE 방식은 1회 구체화한 뒤 조인한다. 인덱스를 붙여도 204초가 202초로밖에 줄지 않은 게 이 글에서 제일 하고 싶은 말이다 — 레버는 인덱스가 아니라 반복 실행을 없앤 쿼리 구조였다.
다만 이 재현은 3개월 내 주문이 절반을 차지하는 합성 분포라 인덱스 선택도가 낮다. 프로덕션에서는 인덱스가 더 기여했을 가능성까지 부정하지는 않는다.
결과
- 구형 인기 모델이 상단을 독점하던 구조가 깨지고, 신형이 리스트 상단에 고정 슬롯을 확보했다.
- 색상별로 갈라져 있던 노출이 모델당 최저가 1건으로 수렴했다.
- 고가 기기가 더 팔렸고, 배포 전월과 후월의 전체 매출을 DB에서 직접 집계해 비교하니 약 20% 높았다.
마지막 항목은 조건을 분명히 해두는 게 맞겠다. A/B 테스트가 아니라 전후 비교다. 같은 기간의 재고 구성 변화와 프로모션, 계절성을 분리하지 않았으니 20%를 전부 정렬 변경의 몫으로 볼 수는 없다. 정렬을 바꾸고 나서 매출이 그만큼 움직였다는 사실까지가 이 숫자가 말할 수 있는 전부다.
남는 이야기
이 작업에서 가장 크게 남은 건 두 가지다.
하나는, 정렬은 UI 옵션이 아니라 비즈니스 로직이라는 것. 뭐가 위에 뜨느냐는 전환율, 탐색 경험, 재고 운영, 심지어 수수료 수익 구조까지 건드리는 결정이었다. "인기순"이라는 한 단어 뒤에 이렇게 많은 판단이 필요할 줄은 몰랐다.
다른 하나는, 성능 문제에서 먼저 봐야 할 건 코드가 아니라 쿼리가 몇 번 도는가라는 것. 실행 계획을 안 떠 봤으면 인덱스만 만지다 202초에서 멈췄을 것이다.
댓글 0
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!