웹 개발/무신사 스토어 watcher

[무신사 스토어 watcher] 페이징 성능 개선기

제리 . 2021. 1. 13. 04:30

이 프로젝트를 진행하면서 사용자의 트래픽에 대해 어떻게 처리해야할지 고민했다. 그런 이유로 서버를 쉽게 확장할 수 있는 CI/CD구조도 고민하게 되었고 자연스럽게 서버 성능에 대해서도 고민하게 되었다. 프로젝트를 진행하며 부하테스트라는 것도 알게 되었는데 부하 테스트를 통해  트래픽을 어느 정도 처리할 수 있는지 정량적인 수치를 알 수 있었다. 

 

부하 테스트

랭킹 카테고리의 아이템을 페이징하여 조회하는 API를 호출한 결과이다. 40명의 가상 유저를 만들어 테스트 해봤을 때 TPS(초당 트랜잭션 수)가 13에 불과했다.

select id, brand, brand_url, category, img, modified_date, product_id, product_name, product_url, rank
from product  
where category="001" 
order by date_format(modified_date, "%Y-%m-%d") desc, rank asc limit 25

쿼리와 실행 계획은 위와 같다. category에 대해서 인덱스가 적용되어 있으며 일자별, 랭킹별 조회를 하고 있다.

 

쿼리에 문제인가 서버의 문제인가?

 

1. CPU 확인 

부하테스트 시 서버의 CPU는 안정적이었지만 DB의 CPU는 급격히 상승하는 상황을 확인했다.

 

2. 다른 쿼리에 대해서 테스트

좀 더 빠르게 쿼리가 조회되는 api를 테스트 해봤을 때, TPS는 150까지 상승했다.

 

3. 로드밸런서 적용 후 테스트

서버 증설 후 병목 현상이 나타나던 api는 TPS가 13~15수준으로 변화가 없었다. 반면, 위의 쿼리를 사용해서 테스트 해봤을 때 TPS가 서버의 수와 비례해서 증가했다. 부하테스트시 로드밸런서의 CPU는 안정적이었다.

 

4. 슬로우 쿼리 로그 확인

병목 현상이 발생하는 api의 부하테스트를 진행하면서 슬로우 쿼리 로그를 확인해봤다. 부하 테스트시 쿼리 시간이 0.5초 까지 상승했다. 가상 유저를 TPS 이하로 만들어 부하테스트를 하면 슬로우 쿼리가 쌓이지 않았다.

 

위의 테스트를 종합하여 생각해봤을 때 DB에 부하가 걸렸다고 판단했다.

 

첫 번째 개선사항 - 유저 로그 기반 쿼리 수정

먼저, 쿼리를 수정해야겠다고 생각했다.

 

1. 인덱스는 사용되고 있는가?

실행 계획을 보면 인덱스를 사용하고 있었다. 

 

2. 불필요하게 조회되는 데이터는 없는가?

api에서 제공하는 데이터에서 불필요한 정보가 포함되어 있지 않을까 생각 해봤다. 서비스는 이미 배포되서 사용자가 있던 상황이었고 내가 불필요하다고 생각한 정보를 사용자가 조회하고 있는지 확인했다. 사용자의 api 요청 로그를 확인해보니 당일 업데이트된 상품만을 조회하는 경향이 파악되었고 기존에 모든 날짜를 조회해서 페이징하는 쿼리를 수정했다. 그에 따라 인덱스에 대해 이점도 얻을 수 있었다.

order by date_format(modified_date, "%Y-%m-%d") desc, rank asc 

기존에는 날짜 데이터가 datetime형식으로 되어있어 일별로 데이터를 정렬하기위해서 컬럼을 가공하는 연산이 들어갔다. 컬럼을 가공하는 연산이 들어가면 인덱스를 적용하지 않는다. 하지만 더이상 날짜를 가공할 필요가 없었다.

 

기존 쿼리

select id, brand, brand_url, category, img, modified_date, product_id, product_name, product_url, rank
from product  
where category="001" 
order by date_format(modified_date, "%Y-%m-%d") desc, rank asc limit 25

변경 쿼리

SELECT a, b, c ... 
FROM musinsa.product 
where category=?1 and modified_date > ?2 and modified_date <?2 + interval 1 day 
order by rank;

기존에 category 컬럼에 대해 인덱스가 걸려있었지만 category + modified_date를 복합 인덱스를 태워서 스캔하는 row가 1/4로 줄어들었다.

 

변경된 쿼리에 대해 부하 테스트 결과 13 TPS에서 47 TPS로 증가했다.

 

두 번째 개선 사항 - 캐시를 활용 하자

캐시를 사용하게 되면 db부하를 줄이고 응답 속도를 높일 수 있다. 

 

어떤 쿼리에 캐시 전략을 도입할 것인가?
실시간으로 쿼리의 결과가 바뀌지 않고 같은 결과를 보장하는 것들, 시간이 오래 걸리는 쿼리등

 

그런 쿼리가 무엇인가?
데이터가 하루에 한 번씩 업데이트되기 때문에 사실상 모든 쿼리가 캐시의 대상이 될 수 있다. 하지만 모든 쿼리에 대해서 캐싱하는 것보다는 조회가 빈번하거나 시간이 오래 걸리는 쿼리를 캐싱했다.

 

어떤 캐시를 적용할 것인가?

초기에 서버를 한 대만 운영할 때는 spring boot에 ehcache를 로컬 캐시를 적용했다. 하지만 서버가 분산되면서 여러 서버에서 캐시를 공유할 필요가 생겼고 redis를 사용해서 캐싱했다.

 

캐시 적용 x/ 가상 유저 50명 / TPS 47

캐시 적용 x + 1cpu 1g mem 서버2대 로드밸런싱 / 가상 유저 50명 / TPS 51

캐시 적용 / 가상 유저 296명 / TPS 357

캐시 적용 + 1cpu 1g mem 서버2대 로드밸런싱 / 가상 유저 296명 / TPS 836

 

부하 테스트 결과 47 TPS에서 357 TPS로 증가했다.

 

 

하루에 한번 업데이트되는 데이터 및 캐시

데이터를 수집하는 구조는 위와 같다. 데이터를 크롤링해서 저장한다. 데이터가 업데이트된 순간이 저장된 캐시를 초기화해야하는 순간이다. redis에 저장된 캐시를 초기화하고 크롤링 결과를 알림을 주는 방식으로 데이터 수집이 진행된다.

 

세번째 개선사항 - 페이징 API 개선하기

페이징 API = 조회 쿼리 + count쿼리

JPA의 페이징 API는 위처럼 실행된다. count쿼리는 조회 쿼리 못지 않게 시간이 걸린다. 데이터가 자주 갱신되는 구조가 아니라면 count쿼리를 매번 조회할 필요가 없었고 저장해서 사용하면 성능상에 이점을 기대할 수 있다. 데이터가 하루 한 번 업데이트되는 내 프로젝트에 적합한 방식이다.

일반적으로 paging에 fetchResults()메서드를 많이 사용한다. 나도 paging에 아래와 같이 fetchResults()를 사용했었다.

//카테고리별 랭킹 조회 메서드
public Page<ProductResponseDto> findByCategory(String category, LocalDate updateDate,
      Pageable pageable) {
    QueryResults<Product> results = queryFactory.selectFrom(QProduct.product)
            .where(QProduct.product.category.eq(category),
                QProduct.product.modifiedDate.after(updateDate.atStartOfDay()),
                QProduct.product.modifiedDate.before(updateDate.plusDays(1).atStartOfDay()))
            .orderBy(QProduct.product.rank.asc())
            .limit(pageable.getPageSize())
            .offset(pageable.getOffset())
            .fetchResults(); //fetchResults()메서드에 주목
    return new PageImpl<>(results.getResults()
        .stream()
        .map(ProductResponseDto::new)
        .collect(Collectors.toList()), pageable, results.getTotal());
  }
 

fetchResults() 코드를 확인해보면 카운트 쿼리가 실행된다.

@Override
    public QueryResults<T> fetchResults() {
        try {
            Query countQuery = createQuery(null, true);
            long total = (Long) countQuery.getSingleResult();
            if (total > 0) {
                QueryModifiers modifiers = getMetadata().getModifiers();
                Query query = createQuery(modifiers, false);
                @SuppressWarnings("unchecked")
                List<T> list = (List<T>) getResultList(query);
                return new QueryResults<T>(list, modifiers, total);
            } else {
                return QueryResults.emptyResults();
            }
        } finally {
            reset();
        }

    }

기존 fetchResults() = fetch() + count method로 분리하려고한다. 

//카운트 쿼리
@Cacheable(value = "productCache", key = "'category count'+#category")
  public long countFindByCategory(String category, LocalDate updateDate) {
    return queryFactory.from(QProduct.product)
        .where(QProduct.product.category.eq(category),
            QProduct.product.modifiedDate.after(updateDate.atStartOfDay()),
            QProduct.product.modifiedDate.before(updateDate.plusDays(1).atStartOfDay()))
        .fetchCount();
  }

카운트 쿼리 실행하는 메서드를 만들고 캐시를 적용했다. 카운트 쿼리를 최적화해서 뽑아낼 수 있다면 분리해서 적용하는 것이 효과적이라고 생각된다.

public Page<ProductResponseDto> findByCategory(String category, LocalDate updateDate,
      Pageable pageable) {
    List<Product> results = queryFactory.selectFrom(QProduct.product)
            .where(QProduct.product.category.eq(category),
                QProduct.product.modifiedDate.after(updateDate.atStartOfDay()),
                QProduct.product.modifiedDate.before(updateDate.plusDays(1).atStartOfDay()))
            .orderBy(QProduct.product.rank.asc())
            .limit(pageable.getPageSize())
            .offset(pageable.getOffset())
            .fetch();  //fetchResults()대신 fetch가 사용됨
    return new PageImpl<>(results
        .stream()
        .map(ProductResponseDto::new)
        .collect(Collectors.toList()), pageable, countFindByCategory(category, updateDate)); //캐시된 카운트 쿼리 메서드 호출
  }

수정된 조회 메서드는 fetchResults()대신 fetch()를 사용했다. fetch메서드를 사용하면 List형태로 쿼리 결과를 반환한다. return 값에서는 Page객체를 만들어주는데 이때 total element를 넣는 파라미터에서 캐시를 적용한 메서드를 호출하게 하면 된다.

이제 정량적인 결과를 비교해보자.

 

카운트 쿼리 캐시 적용 x/ 가상 유저 50명 / TPS 47

카운트 쿼리 캐시 적용 / 가상 유저 50명 / TPS 61

부하 테스트 결과 47 TPS에서 62 TPS로 증가했다.

 

네번째 개선사항 - DB서버 증설

캐시를 적용한다고해도 db에서 조회가 이뤄지는 쿼리는 존재한다. 조회 작업의 부하를 분산할 수 있다면 좀 더 많은 트래픽에 대응 할 수 있을 것이다. mysql에서는 master와 slave구조로 replication하는 방법이 있었다. 웹 서버 증설과 다르게 DB서버는 이미 작업된 내용이 존재한다. 그렇기 때문에 새로 만드는 DB서버에서도 이 내용이 반영되어야하고 동기화역시 이뤄져야한다. 

 

master서버에서 DDL, DML등의 이벤트는 바이너리 로그파일에 기록된다. slave서버(새로 증설할 서버)는 master서버(기존에 서버)로 부터 바이너리 로그를 전달 받아 db에 반영하므로써 동기화를 진행한다. replication은 단방향으로 이뤄지기 때문에 master는 slave의 내용을 동기화 하지 않는다. 미리 쌓여있던 데이터는 mysqldump를 사용하여 .sql파일로 만들고 새로운 서버에 반영시켜주면 된다.

 

새로운 서버에서 이뤄지는 전체적인 과정은 아래와 같다. 매번 db에 접속해서 수동으로 slave관계를 만들어주는건 힘들어서 스크립트를 통해 자동화했다.

#!/usr/bin/env bash

mysqldump -h $MASTER_HOST -u$MASTER_USER -p$MASTER_PASSWARD musinsa > musinsa.sql

mysql -uroot -p$SLAVE_PASSWARD  -e "create database musinsa"
mysql -uroot -p$SLAVE_PASSWARD musinsa < musinsa.sql

master_log_file=`mysql -h $MASTER_HOST -u$MASTER_USER -p$MASTER_PASSWARD -e "show master status\G" | grep mysql-bin`

re="[a-z]*-bin-[a-z]*.[0-9]*"

if [[ ${master_log_file} =~ $re ]];
then
    master_log_file=${BASH_REMATCH[0]}
fi

master_log_pos=`mysql -h $MASTER_HOST -u$MASTER_USER -p$MASTER_PASSWARD -e "show master status\G" | grep Position`

re="[0-9]+"

if [[ ${master_log_pos} =~ $re ]];
then
     master_log_pos=${BASH_REMATCH[0]}
 fi

mysql -uroot -pgurwns1346dl -e "change master to master_host='$MASTER_HOST',
master_user='$MASTER_USER',
master_password='$MASTER_PASSWARD',
master_log_file='$master_log_file',
master_log_pos=$master_log_pos"

mysql -uroot -p$SLAVE_PASSWARD -e "start slave"

slave를 설정하기 전에 slave서버 id도 지정해줘야한다.

 

부하 테스트 결과 55 TPS에서 100 TPS로 증가했다.

 

다섯 번째 개선사항 - 배치 작업

위의 작업들을 진행하면서 페이징 관련 성능을 많이 개선할 수 있었다. 하지만 문제는 여전히 남아있었다.

 

데이터가 쌓일 수록 느려지는 쿼리

해당 서비스에서 제공하는 대표적인 기능은 오늘 역대 최저가, 오늘 깜짝 할인이있다. 이 두 가지 쿼리에는 통계 정보가 사용된다. 오늘 역대 최저가 상품을 조회하기 위해서는 그동안 저장된 모든 가격 데이터의 통계 정보를 확인해야했다. 데이터가 적던 초반에는 1~2초 정도의 느린 쿼리를 만들어냈지만, 시간이 지나 데이터가 약 1200만건 쌓인 지금 기준으로 30초 이상 걸리는 상황이 많아졌다. 

 

어떻게 해결해야할까?

사용자는 어떤 쿼리가 느리건 관심이 없다. 관심은 페이지의 성능이므로 느린 페이징 성능을 체감해서는 안된다. 캐시되지 않은 페이지를 조회할 때도 빨라야한다. 따라서 처음 조회에도 캐시를 조회하는 것 처럼 빠르게 하기위해서는 미리 결과를 계산해두는 방법을 생각했다. 사용자에게 업데이트된 데이터를 보여주기전에 미리 값을 계산해서 저장하고 저장한 결과를 활용한다. 

 

 

기존 크롤링 구조

기존 크롤링과정은 데이터를 수집하고 캐시를 초기화하여 업데이트된 데이터를 사용자에게 보여준다. 

 

변경된 크롤링 구조

 

변경된 크롤링 구조는 캐시를 초기화하기 전에 미리 수집된 상품에 대해 최저가, 평균가, 할인가등을 계산해서 테이블에 따로 저장한다. 요청이 들어오면 집계 쿼리를 별도로 날리지 않고 테이블에 저장된 값으로 결과를 반환한다. 

 

날짜별 가격 데이터가 담긴 price테이블은 더이상 페이징에서 집계되지 않고 배치 작업으로 집계 결과를 저장할 때만 사용된다.

 

미리 저장한 결과를 사용해서 30초 이상 소요되던 쿼리가 0.05초 이내에 조회가 가능해졌다. 

 

좀 더 개선한다면?

이 방식으로 구조를 잡으면서 아쉬웠던점은 문제 상황을 빠르게 해결하기위해 단순히 집계 쿼리를 실행해서 저장하도록 했다. 하지만 이런 경우 집계 과정 실패시 처음부터 재시작 문제, 작업 내용 중복 등 작업 내용에 대한 정보를 저장해서 활용 할 수 없다. 여유가 있다면 spring batch를 공부해서 좀 더 개선해보고 싶다.

마치며

성능을 개선하기 위해 적용한 것을 정리해보면서 mysql에 대한 공부가 좀 된 것 같다. 어떤 경우에 인덱스가 적용이 안 되는지, 커버링 인덱스가 무엇인지, 실행 계획은 어떻게 보는지 등을 배울 수 있었다. 사실 개인 프로젝트를 하면서 비어있는 db에서 시작해 쿼리 조회에 대한 성능을 크게 고민한 적이 없었던 것 같다. 해당 프로젝트는 매일 크롤링으로 자료를 수집하여 실제 데이터를 사용하고 있고 적다면 적지만 하루에 수 만개의 데이터가 쌓인다. 고민할 수 있는 영역이 넓어졌고 새로운 지식도 쌓을 수 있어서 좋았다. 또한, 부하 테스트라는 것을 알게 되어 트래픽에 대한 정량적인 수치를 확인할 수 있었다. 아직 더 개선할 수 있는 부분이 많을 것 같다. 여유가 되면 어떻게 더 성능을 높일지 더 고민해보고 싶다.