티스토리 뷰
발단 — "인기순으로 보여주세요"
상품 목록에 정렬 옵션을 추가했다. 최신순, 가격순, 그리고 좋아요순.
enum class ProductSort(val sort: Sort) {
LATEST(Sort.by(Sort.Direction.DESC, "createdAt")),
PRICE_ASC(Sort.by(Sort.Direction.ASC, "price")),
LIKES_DESC(Sort.by(Sort.Direction.DESC, "likeCount")),
}
최신순·가격순은 멀쩡했는데, 좋아요순만 유독 느렸다.
데이터가 적을 땐 몰랐다가, 상품을 10만 건으로 늘리고 나서야 체감이 됐다.
여기서 "처음엔 데이터가 적어서 못 느꼈다"는 점이 핵심이다. 성능 문제는 모수가 커져야 드러난다.
왜 느릴까 — 두 가지 비용
좋아요는 원래 별도 테이블이다. (user_id, product_id) 조합으로 쌓인다. 그래서 "좋아요 순 정렬"을 순진하게 구현하면 이렇게 된다.
SELECT p.*, COUNT(l.id) AS like_count
FROM product p
LEFT JOIN likes l ON p.id = l.product_id
GROUP BY p.id
ORDER BY like_count DESC
LIMIT 20;
여기엔 비용이 두 개나 겹친다.
집계 비용 — JOIN + GROUP BY로 매번 좋아요 수를 센다.
정렬 비용 — 센 값을 다시 정렬한다.
집계는 비정규화로 없앨 수 있다. 상품 테이블에 like_count 컬럼을 두고, 좋아요 등록/취소 때 원자적으로 갱신하면 된다.
// 좋아요 수를 셀 필요 없이, 컬럼 하나로 정렬
@Column(name = "like_count", nullable = false)
var likeCount: Long = 0L
protected set
-- 동시성 안전하게 증감 (음수 방지 가드 포함)
UPDATE products SET like_count = like_count + 1 WHERE id = :id AND deleted_at IS NULL;
UPDATE products SET like_count = like_count - 1 WHERE id = :id AND deleted_at IS NULL AND like_count > 0;
집계는 사라졌다. 그런데도 정렬은 여전히 남는다. 이번 글의 진짜 주인공은 이 정렬 비용이다.
측정 환경
추측 대신 숫자로 보기로 했다. 앱 스키마와 분리한 벤치 전용 테이블에 10만 건을 넣었다. 컬럼 값은 카디널리티가 다양하도록 분포시켰다.
- brand_id : 1~500 (카디널리티 높음)
- status : ON_SALE 90% / SOLD_OUT 10% (카디널리티 낮음)
- like_count : 0~9999 (다양)
검증 쿼리는 실제 목록 조회와 같은 모양이다.
SELECT * FROM products
WHERE deleted_at IS NULL AND status = 'ON_SALE' AND brand_id = 1
ORDER BY like_count DESC
LIMIT 20;
1차 시도 — 복합 인덱스 (brand_id, like_count)
브랜드로 거르고 좋아요로 정렬하니, 두 컬럼을 묶은 복합 인덱스가 자연스러웠다.
@Table(
name = "products",
indexes = [
Index(name = "idx_products_brand_like", columnList = "brand_id, like_count"),
],
)
AS-IS (인덱스 없음)

10만 행을 전부 읽고(Table scan), 거기서 정렬(filesort)한다. LIMIT 20이 무색하다.
TO-BE (복합 인덱스 적용)

filesort가 사라졌다. 인덱스가 이미 like_count 순으로 정렬돼 있으니, 거꾸로(Backward index scan) 20개만 읽으면 끝이다. 26.3ms → 0.09ms.

여기까지는 "인덱스 걸었더니 빨라졌다"는 흔한 결말이다. 문제는 그 다음이었다.
반전 — 전체 인기상품은 왜 아직도 느린가
홈 화면엔 브랜드 구분 없는 "전체 인기 상품"도 있다. 즉 brand_id 조건이 빠진다.
SELECT * FROM products
WHERE deleted_at IS NULL AND status = 'ON_SALE'
ORDER BY like_count DESC
LIMIT 20;
복합 인덱스가 있으니 당연히 빠를 줄 알았다. 그런데...

다시 풀스캔에 filesort다. 이유는 복합 인덱스의 leftmost 원칙이다.
(brand_id, like_count) 인덱스는 전화번호부가 "성 → 이름" 순으로 정렬된 것과 같다. 성(brand_id)을 알면 이름(like_count) 순으로 바로 찾을 수 있지만, 성을 모른 채 이름만으로 정렬하려 하면 전화번호부의 정렬은 아무 쓸모가 없다.
brand_id가 조건에 없으면, 그 뒤에 붙은 like_count의 정렬성은 살릴 수 없다. 복합 인덱스는 선두 컬럼이 조건에 잡혀야만 뒤 컬럼이 의미를 갖는다.
2차 — 단일 인덱스 (like_count)
전체 정렬을 위해선 like_count 자체가 선두인 인덱스가 필요했다.
indexes = [
Index(name = "idx_products_brand_like", columnList = "brand_id, like_count"), // 브랜드별 정렬
Index(name = "idx_products_brand_price", columnList = "brand_id, price"), // 브랜드별 가격순
Index(name = "idx_products_like_count", columnList = "like_count"), // 전체 인기순
]
TO-BE (단일 like_count 인덱스)

41ms → 0.09ms. 이번에도 인덱스가 정렬 자체를 대체했다. status/deleted_at 필터는 인덱스를 거꾸로 훑으며 행마다 걸러지는데, ON_SALE이 90%라 20건을 채우기까지 읽는 행이 거의 없다.
정리 — 무엇을 배웠나
- 인덱스는 "컬럼"이 아니라 "쿼리 패턴"에 건다. 같은 like_count 정렬이라도 브랜드 필터가 있느냐 없느냐에 따라 필요한 인덱스가 달랐다. 복합 인덱스 하나로 다 커버될 거란 가정이 틀렸다.
- EXPLAIN의 Using filesort는 정렬이 인덱스를 못 탔다는 신호다. 이게 보이면 ORDER BY 컬럼이 인덱스 순서와 맞는지 의심한다.
- 공짜는 아니다. 인덱스를 3개 두면 그만큼 쓰기(INSERT/UPDATE)가 느려지고 저장 공간을 쓴다. like_count는 좋아요마다 갱신되는 컬럼이라 더 그렇다. 그래서 "전체 인기순"이 정말 자주 쓰이는 화면인지 확인한 뒤에 단일 인덱스를 추가했다 — 안 쓰이는 정렬을 위해 인덱스를 늘리는 건 손해다.
결국 성능 최적화는 "빠르게 만드는 법"이 아니라 "어디까지 빠르게 할 가치가 있는가"를 정하는 일에 가까웠다.
'Coding > Dev Study' 카테고리의 다른 글
| [LOOp:pak vol.4] 거부하고, 줄 세우고, 붙잡기 - 주문 대기열을 3층으로 방어하기 (0) | 2026.07.10 |
|---|---|
| [LOOp:pak vol.4] 100장 한정 쿠폰에 1만 명이 동시에 요청하면? (0) | 2026.07.03 |
| [LOOp:pak vol.4] 스투시 반팔티를 10명이 동시에 주문하면 생기는 일 (0) | 2026.06.07 |
| [LOOp:pak vol.4] 이 규칙은 도메인일까, 유스케이스일까 — Service / Facade 의 진짜 분기 기준 (0) | 2026.05.29 |
| [LOOp:pak vol.4] 좋아요, 그 단순해 보이는 기능에서 결정해야 했던 것들 (0) | 2026.05.22 |
- Total
- Today
- Yesterday
- 프로그래머스
- 백준
- 제로베이스 백엔드 스쿨
- 제로베이스 백준 장학금
- 코테 준비
- 코딩테스트공부
- 프로그래머스 자바
- 개발자 면접 준비
- 기술 면접 준비
- 자바공부
- 알고리즘
- 알고리즘공부
- 코딩테스트 공부
- 백엔드 개발자 기술 면접 준비
- 개발자 취업 준비
- 코딩테스트 준비
- 백엔드 개발자
- 취업준비
- 자바
- java
- 백엔드 개발자 취업 준비
- 개발자 취준
- 취준
- 알고리즘 공부
- 코딩테스트
- 코테준비
- 코테공부
- 취업 준비
- 주니어 개발자 취업 준비
- 프로그래머스 카카오
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |