기능이 없으면 재사용할 수 없지만, 속도가 느리면 아무도 사용하지 않는다.
Make it work, make it right, make it fast. - 켄트 벡 (Kent Beck)
서비스를 운영하다 보면 결국 가장 중요한 것은 응답 속도다.
처음부터 의도적으로 느린 시스템을 만들지는 않겠지만, 데이터가 쌓일수록 조회 성능은 점점 떨어지게 된다.
그렇다면 현재 내가 설계한 상품 조회는 어느 정도의 성능을 보여줄까?
여기서 보여줘야 할 데이터는 상품, 상품이름, 가격, 상품 좋아요 개수를 페이징하여 보여준다.
상품조회 테이블 구조

상품과 좋아요는 1:N 관계를 가지고 있으며, 좋아요 개수는 Wishlist 테이블을 집계하여 계산한다.
테스트 환경
테스트 환경은 다음과 같이 구성했다.
- 상품 100,000건
- 좋아요 3,000,000건
- 좋아요는 상품에 균등 분포
- K6 동시 요청 10회의 평균 iteration_duration 값을 기준 측정
- 최초 요청은 워밍업으로 제외
정렬 조건은 최신순(LATEST), 가격 낮은 순(PRICE_ASC), 좋아요(LIKES_DESC) 많은 순 이렇게 3가지이고 검색조건은 브랜드 필터 유무로 테스트를 진행한다.
처음에 작성한 쿼리는 최신순과 가격 순은 정렬 조회 후 좋아요 수를 가져오고, 좋아요 순은 Left Join을 사용해서 그룹으로 묶어 정렬 후에 가져온다.
1. 최신순
SELECT * FROM products WHERE status = 'ACTIVE' AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 20 OFFSET 0;
2. 가격 순
SELECT * FROM products WHERE p.status = 'ACTIVE'
ORDER BY min_price ASC LIMIT 20 OFFSET 0;
3. 좋아요 순
SELECT p.id FROM products p LEFT JOIN wishlists w ON w.product_id = p.id
WHERE p.status = 'ACTIVE' AND p.deleted_at IS NULL
GROUP BY p.id ORDER BY COUNT(w.id) DESC LIMIT 20 OFFSET 0;
-- 상품을 가져온 후 좋아요 수를 가져오는 쿼리
SELECT product_id, COUNT(id) FROM wishlists
WHERE product_id IN (:productIds) GROUP BY product_id;
| 1. 기존 쿼리 실행 결과 | ||
| 정렬 종류/브랜드 유무 | 브랜드 필터 미 존재 | 브랜드 필터 존재 |
| 최신순 (LATEST) | 515.13ms | 351.43ms |
| 가격 낮은 순 (PRICE_ASC) | 434.86ms | 380.5ms |
| 좋아요 많은 순 (LIKES_DESC) | 1.52s | 1.01s |
브랜드 필터를 사용하는 쿼리는 FK 인덱스를 효율적으로 활용할 수 있어, 필터가 없는 쿼리보다 더 빠른 응답 속도를 제공했다.
좋아요 정렬은 좋아요 테이블과의 LEFT JOIN 후 GROUP BY를 통해 집계 과정을 수행해야 한다. 이러한 추가 연산 비용으로 인해 다른 조회 쿼리 대비 성능 저하가 발생하는 것을 확인할 수 있었다.
기존 쿼리에 인덱스를 추가하면 얼마나 빨라질까?
좋아요 외에 다른 인덱스를 걸어보고 테스트를 진행해 본다.
@Index(name = "idx_product_status_deleted_created", columnList = "status, deleted_at, created_at"),
@Index(name = "idx_product_status_deleted_price", columnList = "status, deleted_at, min_price"),
@Index(name = "idx_product_brand_status_deleted_created", columnList = "brand_id, status, deleted_at, created_at"),
@Index(name = "idx_product_brand_status_deleted_price", columnList = "brand_id, status, deleted_at, min_price"),
| 2. 인덱스 추가 후 실행 결과 | ||
| 정렬 종류/브랜드 유무 | 브랜드 필터 미 존재 | 브랜드 필터 존재 |
| 최신순 (LATEST) | 406.11ms | 346ms |
| 가격 낮은 순 (PRICE_ASC) | 442.51ms | 360.62ms |
| 좋아요 많은 순 (LIKES_DESC) | 1.57s | 1.25s |
인덱스를 건 최신순, 가격 낮은 순은 소폭 향상 되었다.
그러면 여기에서 개선 포인트는 무엇일까?
유니크 인덱스 순서를 바꿨더니 빨라졌다?
기존 Wishlist 테이블은 다음과 같은 유니크 제약을 가지고 있었고 다음과 같이 변경했다.
기존
@Table(name = "wishlists", uniqueConstraints = {
@UniqueConstraint(name = "uq_wishlist_user_product", columnNames = {"user_id", "product_id"})
})
변경
@Table(name = "wishlists", uniqueConstraints = {
@UniqueConstraint(name = "uq_wishlist_user_product", columnNames = {"product_id", "user_id"})
})
유니크 제약 조건은 내부적으로 중복 여부를 빠르게 검증하기 위한 유니크 인덱스를 생성한다. 따라서 유니크 제약 조건의 컬럼 순서를 변경하면 인덱스 구조 역시 함께 변경된다.
유니크 인덱스와 복합 인덱스는 선두 컬럼 규칙(Leftmost Prefix Rule)을 따르기 때문에 컬럼 순서가 성능에 큰 영향을 미친다.
기존 인덱스 (user_id, product_id) 구조에서는 상품별 좋아요 집계를 수행할 때 product_id를 기준으로 인덱스를 효율적으로 활용하기 어려웠다. 반면 (product_id, user_id)로 변경하면 집계 대상인 product_id가 선두 컬럼이 되어 상품 기준 조회, 집계, 조인 과정에서 인덱스를 적극적으로 활용할 수 있다.
| 3. Wishlist 유니크 인덱스 순서 변경 후 | ||
| 정렬 종류/브랜드 유무 | 브랜드 필터 미 존재 | 브랜드 필터 존재 |
| 최신순 (LATEST) | 102.47ms | 33.47ms |
| 가격 낮은 순 (PRICE_ASC) | 113.18ms | 39.75ms |
| 좋아요 많은 순 (LIKES_DESC) | 717.33ms | 49.61ms |
실제 테스트 결과에서도 단순히 유니크 인덱스의 컬럼 순서를 변경했을 뿐인데 조회 성능이 크게 향상되었으며, 특히 브랜드 필터가 함께 적용되는 경우 더욱 큰 개선 효과를 확인할 수 있었다.
다만 이러한 변경이 항상 좋은 선택인 것은 아니다.
유니크 인덱스는 데이터 저장 시 중복 여부를 검사해야 하므로 INSERT 또는 UPDATE 과정에서 추가적인 비용이 발생한다.
또한 인덱스 선두 컬럼을 product_id로 변경하면서 상품 기준 조회 성능은 향상되었지만, 반대로 사용자 기준(user_id) 조회가 많은 환경에서는 기존 구조가 더 유리할 수도 있다.
하지만 유니크 인덱스를 활용할 수 있으면 활용하는 것이 좋은 선택이지만 유니크 인덱스는 유니크 인덱스의 역할도 있기 때문에 join 집계에 영향을 주는 product_id를 단독으로 거는 인덱스만 추가하면 조회 성능을 향상할 수 있다.
@Table(name = "wishlists", uniqueConstraints = {
@UniqueConstraint(name = "uq_wishlist_user_product", columnNames = {"user_id", "product_id"})
}, indexes = {
@Index(name = "idx_wishlist_product_id", columnList = "product_id")
})
그럼에도 불구하고 좋아요 정렬은 여전히 다른 정렬 방식보다 느린 모습을 보였다.
좋아요 수 계산을 위해 LEFT JOIN과 GROUP BY가 수행되기 때문에 추가적인 집계 비용이 발생하며, 인덱스 최적화 이후에도 이러한 연산 자체의 비용은 남아 있었기 때문이다.
좋아요 정렬을 위한 개선
인덱스를 아무리 잘 구성하더라도 다음 작업 자체는 여전히 필요하다.
- Wishlist 집계
- 상품별 좋아요 수 계산
- 계산 결과 정렬
대부분의 서비스는 이런 문제를 반정규화로 해결한다.
반정규화란 데이터 중복을 허용하는 대신 조회 성능을 높이는 방법이다.
예를 들어 Product 테이블에 좋아요 수를 직접 저장하여 관리할 수 있다.

이제 Wishlist를 집계하지 않아도 조회 시에는 Product 테이블만 읽으면 된다.
대신 좋아요 추가/취소 시 like_count를 함께 관리해야 한다는 단점이 존재하는데 과연 성능에 얼마나 영향을 줄 수 있을지 테스트해보자
테스트 방식은 10명의 사용자가 상품 하나를 동시에 좋아요를 누를 때 테스트를 진행한다.
like_count를 원자성 연산을 하는 코드가 추가되었을 때 성능을 확인해 보자
| 좋아요 수 구조 변경에 따른 원자성 쓰기비용 - 로직 추가 | ||
| 행위/로직 | 변경 전 | 변경 후 |
| 원자성 연산 추가 | 119.41ms | 208.45ms |
기존 성능 보다 약 2배 정도 느려졌지만 조회성능 향상과 비교했을 때 like_count를 따로 관리하는 편이 좋다.
추가로 이런 쓰기 성능이 누적해서 나빠진다면 레디스 카운트 집계나 이벤트 방식으로 정합성을 일부 포기하는 대신 쓰기 성능을 높일 수도 있을 것 같다.
| 4. 좋아요 반 정규화 적용 | ||
| 정렬 종류/브랜드 유무 | 브랜드 필터 미 존재 | 브랜드 필터 존재 |
| 최신순 (LATEST) | 119.02ms | 43.57ms |
| 가격 낮은 순 (PRICE_ASC) | 121.21ms | 43.37ms |
| 좋아요 많은 순 (LIKES_DESC) | 117.37ms | 46.11ms |
좋아요 정렬이 717.33ms에서 117.37ms로 감소했고 약 84% 이상의 응답 시간이 줄어들었다.
집계와 정렬 비용을 반 정규화로 제거하면서 다른 정렬과 비슷한 수준의 성능을 확보할 수 있었다.
결론
인덱스를 어떻게 잡을지도 중요하지만, 새로운 인덱스를 추가하기 전에 이미 존재하는 유니크 인덱스와 복합 인덱스의 컬럼 순서부터 확인하는 것이 중요하고 집계 비용 자체가 병목이라면, 결국 읽기 성능을 위한 반정규화를 고려하는 것이 가장 현실적인 해결책이 될 수 있다.
'루퍼스 4기' 카테고리의 다른 글
| 주문 도메인에서 아웃박스 패턴과 polling 스케줄러 도입기 (1) | 2026.07.02 |
|---|---|
| 주문 상태는 Pending 인데, 사용자는 결제 안내를 받았다. (0) | 2026.06.26 |
| 루퍼스 4주차 WIL (동시성 문제 해결) (0) | 2026.06.14 |
| 쇼핑몰에서 안전하게 재고 차감하기 (0) | 2026.06.12 |
| 주문 생성 흐름에서 책임을 어디까지 나눌 것인가 (0) | 2026.05.29 |