팀 프로젝트/market.normalization.project

조회 성능 향상시키기(3) Pagination Count Query 최적화

pon9 2025. 3. 23. 21:46

문제 상황

현재 '전체 경매장 조회'는 커버링 인덱스로 개선하였음에도 불구하고 702ms로 1초 가까이 소모된다.

로컬에서 네트워크 지연시간이 0ms인 걸 감안하면, 실제 서버에선 1초가 넘는다는 소리다

 

수많은 개선방법이 있겠지만, 나는 count query 최적화를 우선 진행하기로 했다.

'전체 거래소 조회' 에도 같은 로직을 적용할 수 있기 때문이다.

 

count query 최적화는 꽤 많은 방법을 적용해볼 수 있을 거 같다. 하나씩 적용해보고 장단점을 비교해보자.

 

 

count query 개선 방법

우선 대상 count query들은 이미 index를 꽤 잘 타고 있기 때문에 DB레벨에서의 최적화는 더이상 힘든 상황이다.

 

1. 마지막 페이지 근처에 도달하기 전까지는 count query 생략하기

데이터가 100만 건 이상이고, 한 페이지당 10건 정도를 보여준다고 했을 때, 사용자가 1페이지나 2페이지를 볼 때 굳이 전체 페이지 수(=count)를 알 필요는 없다.

실제로 대부분의 사용자는 끝까지 스크롤을 내리는 것보다, 정렬 기능을 활용해서 원하는 결과를 앞쪽에서 찾는다.

이런 경우, 초기 페이지에서는 count쿼리를 아예 생략하면 응답속도를 크게 줄일 수 있다.

 

2. count결과를 캐싱하기

count결과를 Redis나 로컬 캐시 등에 저장해두고, 스케줄러 등을 이용해 주기적으로 갱신하는 방식이다.

1번 방식에 비해 UI의 표현력이 더 다양해질 수 있다는 장점이 있지만, 단점은 주기적인 DB 접근이 필요하다는 점이다.
물론 실제 사용자 트래픽에 따라 count쿼리가 계속 발생하는 것보다는 부담이 적다.

단, 캐시 설계 및 무효화 전략을 함께 고민해야 한다.

 

3. count쿼리 병렬실행

별도로 UI나 API 구조를 바꿀 필요 없이, 기존 쿼리를 병렬로 실행만 하면 되기 때문에 도입이 가장 간단한 방법이기도 하다.

하지만 스레드를 두개 사용하니 트래픽이 몰리면 그만큼 부담이 된다.

 

적다 보니 모든 방법을 합쳐서 사용할 수 있을 거 같다.

캐시에 count값을 저장해두고, 이후 요청에서 이를 선택적으로 조회하는 방식이다. 캐시에 값이 없거나 오래된 값이면 병렬 실행을 통해 count값을 구해온다. 

이 방법은 분명 응답 속도를 빠르게 해줄 수 있지만, 보다 보니 이 방식이 과연 비용 효율적인지에 대해 의문이 들었다.

우선, 캐시를 사용한다는 건 결국 메모리를 추가로 소모하는 것이고, 그에 비해 count값이라는 건 사용자에게 '필수 정보'는 아닐 수 있다.

또한 캐시의 한계는 항상 최신 정보를 제공할 수는 없다는 점이다.
만약 캐시된 count값이 실제 데이터와 차이가 발생한다면, 특히 마지막 페이지 근처에서 정합성이 중요한 상황에선 UX에 오히려 혼란을 줄 수도 있다.

 

그래서 우선은 병렬 실행 방식부터 구현하고, 사용자가 마지막 페이지 근처에 도달했을 때만 count쿼리를 병렬로 실행하도록 조건을 추가해 점진적으로 개선해 나가도록 해보자.

이렇게 하면 불필요한 캐시 낭비를 막고, 정확도가 중요한 시점에만 비용을 지불하는 형태가 되므로 성능과 정합성, 비용 사이의 균형을 맞출 수 있는 방향이라고 생각한다.

이런느낌?

이제 구현해보장

 

 

count query 병렬실행

@Override
public Page<AuctionListResponseDto> findAllAuctionItems(LocalDateTime startDate, String searchKeyword, String sortBy, String sortDirection, Pageable pageable) {

    BooleanBuilder builder = new BooleanBuilder();

    //count query
    CompletableFuture<Long> countFuture = CompletableFuture.supplyAsync(() ->
           queryFactory
                    .select(auction.countDistinct())
                    .from(auction)
                    .where(builder)
                    .fetchOne()
    );
        
    //covering index
    List<Long> auctionIds = queryFactory
            .fetch();

    if (auctionIds.isEmpty()) return Page.empty(pageable);
        
    //실제 조회
    List<AuctionListResponseDto> results = queryFactory
            .select(new QAuctionListResponseDto(
            .fetch();

    Long count;
    try {
        count = countFuture.get();
        if (count == null) count = 0L;
    } catch (Exception e) {
        count = 0L;
    }
    return new PageImpl<>(results, pageable, count);
}

count query를 병렬실행 하기 위해, java의 CompletableFuture를 이용하였다.

CompletableFuture.supplyAsync로 count쿼리를 백그라운드에서 실행시키고,

마지막에 병렬실행했던 결과값을 countFuture.get()으로 받고,

null이거나 예외가 발생했을 경우 0으로 fallback해서 쿼리 실패 시에도 서비스엔 영향이 없도록 했다

로그를 찍어보면 스레드를 실제로 2개 사용하는 것을 볼 수 있다

main작업은 WAS 톰캣의 메인 http 요청 처리 스레드에서 진행되었고,

sub작업(count쿼리)는 자바의 기본 비동기 스레드풀에서 진행됐다. 나중에 작업량이 과해지면 스레드풀을 따로 미리 만들어놔야할거다

그 결과 702ms -> 500ms 후반대로 개선되었다. 큰 수준의 개선은 이루어지지 않았다 ㅠ-ㅠ

병렬처리는 오버엔지니어링인가? 스레드 관리도 해야 하고.. 장점에 비해 단점이 많은 것 같다.

 

 

선택적 count query

이제 마지막 페이지의 근사치에 도달했을 때만 count쿼리를 실행하도록 해보자

일단은 이렇게.. 하드코딩이 썩 맘에 들진 않지만.. 어쩔수없다. 어쩌면 캐싱해둬야 하는 값은 이 값이 아닐까? 대강 하루에 한번 업데이트 되도록, 근사치를 캐싱해두는거다.

여튼 실행해보면,

145001페이지 입력 시 totalElements가 총 개수로 나타나지만, 1페이지로 입력하면 10으로만 나온다.

프론트에서는 그냥 뒷 페이지가 있다고 믿고 ui를 구성해주면 된다.

그런데 캐시 없이 쿼리를 첫 실행 했을 때, count쿼리를 생략하니 결과가 500후반대에서 100ms 정도로 눈에띄게 개선되었다. 뭐지?

병렬 실행 했을 때도 결과가 비슷해야 하는 거 아닌가?

 

 

원인은 count query 그 자체에..

count쿼리가 병목의 원인이었다.
인덱스를 잘 타도 실행 시간만 무려 577ms, 전체 쿼리 중 가장 느렸다.

병렬 실행 덕분에 (그나마) 메인 쿼리와 count쿼리를 동시에 돌릴 수 있었고, (그나마) 결과적으로 전체 응답 속도를 count쿼리 단독 실행 시간만큼으로 만들 수 있었다.

메인쿼리 최적화가 그만큼 잘 되었다는 뜻이니 좋아해야하나..

 

 

캐싱

데이터 실시간성이 좀 떨어지더라도, 그냥 캐싱이 맞다고 판단했다.

메인 페이지 외에 검색 기능까지 고려하려니, 선택적 count query 조건을 구성하는 로직이 복잡해지는 문제가 생겼다.

검색어가 들어가면 count결과는 매번 달라지고, 이를 병렬실행 하는 것이 과연 캐시에 비해 비용 효율적인가를 고민하게 된다.

 

보통 메인 application보다 redis 등의 인메모리저장소가 qps에 더 강하니, 대량의 읽기 요청에는 훨씬 적합한 구조라고 판단했다.
spring scheduler로 count쿼리를 주기적으로 실행하여 캐시해두자.

@Service
@RequiredArgsConstructor
public class CountCacheScheduler {

    private final RedisTemplate<String, Long> redisTemplate;
    private final JPAQueryFactory queryFactory;

    private static final String ITEM_COUNT_KEY = "count:item";
    private static final String AUCTION_COUNT_KEY = "count:auction";

    public Long getCachedMarketCount() {
        return redisTemplate.opsForValue().get(ITEM_COUNT_KEY);
    }

    public Long getCachedAuctionCount() {
        return redisTemplate.opsForValue().get(AUCTION_COUNT_KEY);
    }

    @Scheduled(fixedRate = 300000)
    public void updateItemCount() {
        Long count = queryFactory
                .select(QItem.item.countDistinct())
                .from(QItem.item)
                .fetchOne();

        redisTemplate.opsForValue().set(ITEM_COUNT_KEY, count != null ? count : 0L);
    }

    @Scheduled(fixedRate = 300000)
    public void updateAuctionCount() {
        Long count = queryFactory
                .select(QAuction.auction.countDistinct())
                .from(QAuction.auction)
                .where(QAuction.auction.status.eq(Status.ON_SALE),
                        QAuction.auction.createdAt.goe(getStartDate()))
                .fetchOne();

        redisTemplate.opsForValue().set(AUCTION_COUNT_KEY, count != null ? count : 0L);
    }

    private LocalDateTime getStartDate() {
        return LocalDateTime.now().minusDays(30);
    }
}

이미 redis를 다른 부분에서 사용중이기도 하고, 분산 환경에서도 문제없이 접근할 수 있도록 redis를 사용했다.

우선은 검색어는 고려하지 않고 캐싱해뒀다. 결과는?

거래소 151ms, 경매장 112ms

결국 캐시에 굴복하고 말았다

최종uml

 

 

회고

현재 조회서비스에는 과업이 하나 남아있다. '검색 기능 최적화' ..

지금까지는 count쿼리를 캐싱함으로써 일반 조회기능의 성능을 개선할 수 있었지만, 사용자가 검색어를 입력하기 시작하면 캐시된 count의 의미가 사라진다.

현재 검색에서 사용중인 %like%연산은 인덱스를 타지 못하는 구조기 때문에, 데이터 양이 많아질수록 성능 저하가 발생한다.

이를 해결하기 위해 full text index를 도입할 예정이다

 

그리고 검색 쿼리의 count는 검색어에 따라 결과가 매번 달라질 수 있기 때문에, 기존 count캐싱과는 다르게 접근할 수 있다.

검색어를 redis key로 포함시키고, ttl은 15분정도로 제한해 캐시의 정합성과 메모리 사용량을 적절히 조절할 수 있다.

검색어가 없는 1페이지(메인페이지)의 경우에는 redis조회 없이 처리해서, redis 사용률을 줄일 수 있다.

 

redis 사용량이 많아짐에 따라 redis connection pool도 함께 고려해야한다.

 

하 그나저나 지금 코드 진짜 마음에 안든다.. 빨리 다 뜯어고치고 싶다.. 리팩토링이 시급해

 

 

1. 인기 거래소 조회: 8.51s -> 621ms

1. PagableExecutionUtils + fetchCount로 조회되던 쿼리를, PageImpl + 고정값으로 변경: 8.51s -> 4.46s

2. Covering index 적용(Serve Query 분리): 4.46s -> 2.25s

3. Serve Query를 한번 더 분리하여 join 최적화: 2.25s -> 621ms

 

2. 전체 거래소 조회: 5.79s -> 151ms

1. PageableExecutionUtils + fetchCount로 조회되던 쿼리를, PageImpl + countQuery로 변경: 5.79s -> 4.98s

2. TotalCount에 들어가는 countQuery를 최적화: 4.98s -> 3.03s

3. Covering index 단순 적용: 3.03s -> 1.20s

4. Covering index용 Serve Query와 Main Query 분리: 1.20s -> 334ms

5. Count Query에 캐싱 적용: 334ms -> 151ms

 

3. 인기 경매장 조회: 22.28s -> 322ms

1. PagableExecutionUtils + fetchCount로 조회되던 쿼리를, PageImpl + 고정값으로 변경: 22.28s -> 9.23s

2. Covering index 적용(Serve Query 분리): 9.23s -> 322ms

 

4. 전체 경매장 조회: 24.48s -> 112ms

1. PageableExecutionUtils + fetchCount로 조회되던 쿼리를, PageImpl + countQuery로 변경: 24.48s -> 10.47s

2. Covering index 적용(Serve Query 분리): 10.47s -> 702ms

3. Count Query를 병렬실행: 702ms -> 582ms

4. Count Query에 캐싱 적용: 582ms -> 112ms