DB 조회 및 정렬 성능 개선하기(비정규화, 인덱스, 캐시(Redis))

2025. 8. 15. 17:04·Loopers/테크니컬 라이팅
더보기

한줄요약 :
데이터가 증가함에 따라 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
'Loopers/테크니컬 라이팅' 카테고리의 다른 글
  • Kafka 찍먹해보기! : Kafka통한 서비스 경계를 넘는 이벤트 파이프라인을 구축기
  • Spring 이벤트를 처음 써보며 깨달은 것들
  • 동시성 테스트(Flaky Test 삽질기)
  • 다들 이해하지?! 설계의 청사진 시퀀스 다이어그램
KBroJ9210
KBroJ9210
  • KBroJ9210
    개발일기
    KBroJ9210
  • 전체
    오늘
    어제
    • 분류 전체보기 (25)
      • 토스 러너스하이 2기 (11)
        • 회고 (1)
        • 기술 (10)
      • Loopers (9)
        • 테크니컬 라이팅 (6)
        • WIL(What I Learned) (3)
      • 두리두리넋두리 (5)
        • 개발일기 (5)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
KBroJ9210
DB 조회 및 정렬 성능 개선하기(비정규화, 인덱스, 캐시(Redis))
상단으로
목차

    티스토리툴바