한줄요약 :
데이터가 증가함에 따라 JOIN과 GROUP BY를 사용하던 '좋아요 순 정렬' API의 성능이 급격히 저하되었다.
비정규화로 JOIN을 제거하고, 조회 패턴에 맞는 복합 인덱스와 단일 인덱스를 전략적으로 설계하여
DB 정렬 부하(filesort)를 없애고 쿼리 성능을 대폭 개선했다.
문제의 시작: "좋아요 순으로 정렬해주세요"
기존에는 '좋아요' 정보를 가진 likes 테이블과 product 테이블을 JOIN하고, COUNT와 GROUP BY를 사용해
'좋아요'가 많은 순서대로 상품을 정렬하는 쿼리를 사용했다.
-- 가격순 정렬
EXPLAIN SELECT * FROM product
WHERE brand_id = 10 AND status = 'ACTIVE'
ORDER BY price ASC
LIMIT 20;
-- 좋아요 순 정렬
EXPLAIN SELECT
p.id, p.name, p.price, p.stock, p.brand_id,
COUNT(l.id) AS like_count
FROM product p
LEFT JOIN likes l ON p.id = l.target_id AND l.type = 'PRODUCT'
WHERE p.status = 'ACTIVE'
GROUP BY p.id
ORDER BY like_count desc
LIMIT 20;
| table | type | rows | Extra |
| p | ALL | 898,436 | Using temporary; Using filesort |
| i | index |
EXPLAIN 실행 계획을 봤을 때
type: ALL (테이블 전체 스캔)
Using temporary (임시 테이블 생성)
Using filesort (별도 정렬 작업)
라는 결과가 나오고 있어 비효율적으로 보인다.
만약 상품이 10만개 등록되어 있다고 가정하고 진행했을 때
이 방식은 데이터가 증가함에 따라 성능이 급격히 저하 될 수 있다고 생각했다.
해결을 위한 고민 : 인덱스
1. 비정규화
AsIs
// AS-IS
@Getter
@Entity
@Table(name = "product")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product extends BaseEntity {
@Column(name = "brand_id", nullable = false)
@NotNull
private Long brandId;
@NotNull
private String name;
private String description;
@NotNull
private long price;
@NotNull
private int stock;
private int maxOrderQuantity;
@NotNull
@Enumerated(EnumType.STRING)
private ProductStatus status;
...
}
@Service
@RequiredArgsConstructor
public class ProductApplicationService {
@Transactional(readOnly = true)
public Page<ProductResponse> searchProducts(Long brandId, String sort, int page, int size) {
...
// 1. '좋아요' 많은 순으로 정렬된 상품 ID 목록을 페이지 단위로 가져옵니다.
Pageable idPageable = PageRequest.of(page, size);
Page<Long> topLikedProductIdsPage = likeRepository.findProductIdsOrderByLikesDesc(brandId, idPageable);
// 2. 해당 ID 목록으로 Product 리스트를 조회합니다.
List<Long> productIds = topLikedProductIdsPage.getContent();
List<Product> products = productRepository.findAllById(productIds);
// 3. ID 순서에 맞게 Product 리스트를 재정렬하고 Page 객체로 만듭니다.
productPage = reorderProductsAndCreatePage(products, productIds, topLikedProductIdsPage);
...
}
}
기존 로직은 먼저 like에서 좋아요 순으로 정렬 후 그안의 productId를 가지고 상품을 조회하여 매핑하는 형식이였다.
이 문제를 해결하기 위해 먼저 JOIN 자체를 없애는 비정규화(likeCount 컬럼 추가)를 진행했고,
그 후 본격적인 인덱스 최적화에 돌입했다.
ToBe
@Getter
@Entity
@Table(name = "product")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product extends BaseEntity {
...
@NotNull
@Enumerated(EnumType.STRING)
private ProductStatus status;
@NotNull
private long likeCount = 0L; // 기본값을 0으로 설정
@Version
private Long version;
...
}
@Service
@RequiredArgsConstructor
public class ProductApplicationService {
@Transactional(readOnly = true)
public Page<ProductResponse> searchProducts(Long brandId, String sort, int page, int size) {
...
Sort sortCondition = switch (sort) {
case "price_asc" -> Sort.by(Sort.Direction.ASC, "price");
case "likes_desc" -> Sort.by(Sort.Direction.DESC, "likeCount");
default -> Sort.by(Sort.Direction.DESC, "createdAt");
};
Pageable pageable = PageRequest.of(page, size, sortCondition);
Specification<Product> spec = Specification.where(ProductSpecs.isActive());
if (brandId != null) {
spec = spec.and(ProductSpecs.isBrand(brandId));
}
Page<Product> productPage = productRepository.productList(spec, pageable);
...
}
}
비정규화로 product 엔티티에 likeCount가 추가되므로써 join 쿼리를 사용하지 않을 수 있게 됐다.
2. 인덱스
@Getter
@Entity
@Table(
name = "product",
indexes = {
@Index(name = "idx_product_brand_status_price", columnList = "brandId, status, price"),
@Index(name = "idx_product_like_count", columnList = "likeCount DESC")
}
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product extends BaseEntity {
...
}
주로 사용되는 조건을 생각하여
1. 브랜드, 가격 조건이 올때의 복합인덱스
2. 좋아요 수 조건 에 대한 단일 인덱스
로 나누어 인덱스 설정을 하였다.
아래의 EXPAIN으로 인덱스 적용 전과 후의 성능 비교를 테스트 해보았다.
-- 1. 가격순 정렬 EXPLAIN 측정
EXPLAIN SELECT * FROM product
WHERE brand_id = 10 AND status = 'ACTIVE'
ORDER BY price ASC
LIMIT 20;
-- 2. 좋아요순 정렬 EXPLAIN 측정
EXPLAIN SELECT * FROM product
WHERE status = 'ACTIVE'
ORDER BY like_count DESC
LIMIT 20;
| 브랜드Id + 가격순 정렬 | type | rows | Extra |
| As-Is | ALL | 898,436 | Using where; Using filesort |
| To-Be | ref | 14,148 | Using index condition |
| 좋아요순 정렬 | type | rows | Extra |
| As-Is | ALL | 898,436 | Using where; Using filesort |
| To-Be | index | 40 | Using where |
인덱스를 적용한 후 DB로 조회하는 데이터의 양(row)가 획기적으로 줄은 것을 볼 수 있다.
2. 캐시(Redis)
인덱스 설정으로 DB 쿼리 자체는 매우 빨라졌지만, 10만개라는 데이터 앞에서 모든 요청이 DB까지 도달한다는 사실은
아무리 빠른 쿼리라도 수천, 수만 명의 사용자가 동시에 요청하면 DB에 무리가 갈 수 있다고 생각한다.
이 문제를 해결하기 위해 Redis를 활용한 캐시를 적용해보기로 했다.
적용대상
1. 상품 목록 조회 (searchProducts)
이커머스 특성상 사용자가 많이 이용하는 API여서 선택했다.
하지만, brandId, sort, page 등 조회 조건이 다양해 캐시 키 설계와 무효화 전략에 대한 깊은 고민이 필요할것 같다.
2. 상품 상세 조회 (getProductDetail)
사용자들이 반복적으로 조회할 확률이 매우 높고, 데이터 변경이 잦지 않아
캐싱 효과를 극대화할 수 있다고 생각
* 응답시간 테스트에는 k6를 사용했다.
AS-IS(캐시 적용 전)

ToBe(캐시 적용 후)

- 캐시 적용 전 응답시간 p(95) : 360.05ms
- 캐시 적용 후 응답시간 p(95) : 116.49ms
캐시가 있다면 캐시에서 데이터를 받아오고 없다면 DB 조회 후 캐시에 등록하는 Cache-Aside 패턴을 적용하였다
적용 후 응답속도가 줄어든 것을 보니 확실히 성능 개선에 도움이 된 것 같다.
배우고 느낀 점
사실 인덱스와 캐시사용은 이번이 처음이라 모르는것 투성이어서 걱정했는데 눈에 보이는 변화가 있으니 더 재밌었던것 같다.
인덱스 적용 시 실행 계획(EXPLAIN)을 분석하고, 데이터의 분포(카디널리티)을 통해 설계해야 한다는것을 알았지만
아직 헷갈리는 부분이 많아 공부가 더 필요하다고 느끼고 조금씩 더 개선해나가야 겠다.
'Loopers > 테크니컬 라이팅' 카테고리의 다른 글
| Kafka 찍먹해보기! : Kafka통한 서비스 경계를 넘는 이벤트 파이프라인을 구축기 (0) | 2025.09.05 |
|---|---|
| Spring 이벤트를 처음 써보며 깨달은 것들 (1) | 2025.08.28 |
| 동시성 테스트(Flaky Test 삽질기) (4) | 2025.08.09 |
| 다들 이해하지?! 설계의 청사진 시퀀스 다이어그램 (0) | 2025.07.25 |
| TDD, 실패하는 테스트가 알려준 것들: 아래에서 내려다보는 TDD (0) | 2025.07.18 |