기록하며 성장한다 - 개발, 회고

개발 회고록

[ 개발 회고록 ] 조회 성능을 높이기 위해선 어떻게 해야 할까

전대홍 2024. 1. 31. 19:44

 

 

서론

필자가 만들던 Food-Delivery 프로젝트가 마무리되었고, 나의 코드를 돌아보다보니 문득 조회 성능에 대한 생각이 들었다. 
한 번에 여러 스레드의 접근을 통한 조회 성능은 테스트 해보았지만, 데이터가 수백 수천만개가 있을 경우 조회성능이 어떨까를 고민하게 되었다.

특히, 필자는 Redis 로 캐싱을 하는 방식으로 조회 성능을 올리는 방법을 택하였는데, 페이징 처리를 해줘야하는 서비스라면 이걸 또 어떻게 하는 것이 좋을까를 고민하게 되었다.

이를 해결하기 위해서는 큰 수정이 필요하고, 이미 프로젝트가 마무리 되었기 때문에, 우선 개별적으로 조회 성능에 대하여 코드를 만들어봐야겠다는 생각이 들었다. ( 물론 추후 프로젝트 전체를 한 번 고쳐 볼 예정이다. )

필자가 기존에 만들었던 프로젝트 : https://github.com/JeonDaehong/daehong-food-delivery

조회 성능 개선 공부를 위해 별도로 만든 프로젝트 : https://github.com/JeonDaehong/study-big-data-list-view

 


본론

대용량 데이터를 처리하다보면, 조회 성능이 저하되는 문제와 마주하게 된다.

필자는 500만개의 데이터를 하나의 테이블에 넣어서 조회 성능을 테스트해보고, 그걸 개선할 수 있는 방법엔 어떠한 것이 있는지를 공부하고 적용해보기로 하였다.

 

1단계 :  일반적인 구현 

일반적인 구현 및 적용이다.

@Getter
@Builder
@ToString
public class ProductQueryDto {

    private final Long code;
    private final String message;
    private final Long nextToken;
    private List<ProductDataDto> dataList;

    public static ProductQueryDto toProductResponse(Long code, String message, Long nextToken, List<ProductDataDto> dataList) {
        return ProductQueryDto.builder()
                .code(code)
                .message(message)
                .nextToken(nextToken)
                .dataList(dataList)
                .build();
    }

}
@Transactional(readOnly = true)
public ProductResponse getProductList(Integer nextPage, Integer maxPerPage, String productName) {
    Pageable pageable = (Pageable) PageRequest.of(nextPage, maxPerPage);
    List<ProductDataResponse> dataList = new ArrayList<>();
    List<Product> productList = productRepository.findProducts(productName, pageable).orElseThrow();
    for ( Product product : productList ) {
        Category category = categoryRepository.findCategoryById(product.getCategoryId()).orElseThrow();
        Brand brand = brandRepository.findBrandById(product.getBrandId()).orElseThrow();
        ProductDataResponse data = ProductDataResponse.toProductDataResponse(product, brand, category);
        dataList.add(data);
    }
    return ProductResponse.toProductResponse(200L, "Success", (nextPage + 1), dataList);
}

Product 라는 테이블에서,  제품 정보를 가져오고, 그 속에 있는 컬럼값에서 CategoryId 와 BrandId 를 가지고 그 정보를 가지고와, ProductQueryDto 에 담아서 return 을 해준다.

이 때 설계를 할 때, 쿠팡에서 페이지별로 제품을 가져오는 걸 모티브로 하였기 때문에 가져오는 정보는 페이징처리를 해서 가져와주어야 한다.
일반적으로는 LIMIT 와 OFFSET 을 사용하는데, 필자는 JPA 를 사용했기 때문에 Pageable 을 활용하였다.

SELECT p FROM Product p
WHERE p.name LIKE %:productName%
ORDER BY p.id DESC

그래서 가져올 때 쿼리문은 이러하지만, 실제로는 Pageable 을 통해 내부적으로 Count 계산과, LIMIT 그리고 OFFSET 이 적용되는 셈이다.


이렇게 해서 테스트를 진행해보았다.
테스트는 두 가지를 가지고 테스트를 진행하였다.

이건 500만개의 데이터 중, 첫 페이지에서 10개의 데이터를 가져오는 테스트이며,

이건 500만개의 데이터 중, 100번째 페이지에서 10개씩 가져오는 테스트이다.

둘 다 3초가량이 걸렸다.

물론 아주 느린건 아니지만, 데이터가 추후 더 늘어난다는 가정하에는 점점 느려지게 될 것이다.

이렇게 느린이유는 LIMIT 와 OFFSET 사용 때문인데, OFFSET 은 특성상 Full Scan 을 한 후, count 처리를 하여 페이지를 나눈다. 결국 Full Scan 을 하기 때문에, 데이터 양이 많을수록 성능이 낮아질 수밖에 없는 것이다.

또한, Order By Desc 를 사용하고 있는데, 이는 정렬을 시도하는 과정에서 데이터양이 많을수록 성능저하를 가지고 온다.

이를 지금부터 개선해보고자 한다.

 

2단계 :  Index

검색 성능을 높이기 위해 일반적으로 DB에서 많이 적용하는 것이 인덱스이다.

필자는 여기서 Order By 에 적용되는 Id 컬럼을 가지고 인덱스 테이블을 만들었다.

ProductName 을 가지고 인덱스 테이블을 만들어도 되지만, LIKE 를 사용하고 앞 뒤에 전부 % % 가 붙어있는 경우에는 인덱스 테이블을 읽지 않는 문제가 있다. 물론 이를 해결하기 위해서는 Full Text Search 라는걸 적용한 인덱스 테이블을 만들면 되지만, 테스트 데이터를 만들 때 제품명이 거의 다 비슷하게 만들어져있으므로 ( 점수는 각기 거의 다 다르다. ), 큰 성능 개선이 어려울 것 같아 우선 Id에만 적용을 하였다.

그리고 애플리케이션단에서도 성능을 개선하기 위해 무엇을 할 수 있을까를 고민하다가, DB에 여러번 접근하던 로직을 JOIN 을 활용하여 수정을 해보기로 하였다. ( 아래 테스트는 JOIN 으로 한 상태에서의 테스트이다. 이후 여러 로직으로 개선을 시도하다가 현재 깃허브에 저장된 코드에는 원래 코드로 저장이 되어있다. )

결과는 아래와 같았다.

첫 페이지에서 10개를 조회할 경우 성능이 0.6초까지 줄어들었다. 거의 5배 이상 빨라진 것이다.

어라?
그런데, 100 페이지에서 가져올 경우에는 오히려 속도가 느려졌다.
이게 어떻게 된 일일까?

바로 OFFSET 때문이다. 위에서 이야기하였듯 OFFSET 은 처음부터 모든 행을 읽도록 하기 때문에, OFFSET 값이 커지다 못해 전체 테이블 레코드의 20~25% 이상을 넘어서게되면 인덱스를 이용하지 않게 되는 문제가 발생한다.

또한 인덱스 테이블도 읽고, 원래 테이블도 읽게되는 문제때문에 성능이 갈수록 느려질 수 밖에 없다.

 

3단계 :  No Offset

검색 성능을 높이기 위해서 없애야 하는 것이 Offset 인걸 알아냈다.

이제 Pageable 을 사용하지 않도록 코드를 수정하였다.

@Transactional(readOnly = true)
public ProductQueryDto getProductList(Integer lastProductId, Integer maxPerPage, String productName) {
    List<ProductDataDto> dataList = new ArrayList<>();
    List<Product> productList = productRepository.findProducts(productName, lastProductId, maxPerPage).orElseThrow();
    Long lastId = 0L;
    for ( Product product : productList ) {
        int i = 0;
        Category category = categoryRepository.findCategoryById(product.getCategoryId()).orElseThrow();
        Brand brand = brandRepository.findBrandById(product.getBrandId()).orElseThrow();
        ProductDataDto data = ProductDataDto.toProductDataResponse(product, brand, category);
        dataList.add(data);
        if ( i == productList.size() - 1) lastId = product.getId();
    }
    return ProductQueryDto.toProductResponse(200L, "Success", (lastId + 1), dataList);
}
SELECT * FROM PRODUCT
WHERE PRODUCT_NM LIKE %:???%
AND PRODUCT_ID < ???
ORDER BY PRODUCT_ID DESC
LIMIT 10

방식은 간단하다.

ID 순으로 정렬이되니, 가장 마지막으로 가져온 ID 값을 가지고오고, 그 ID값을 가지고 Search 하는 것이다.
이렇게되면 전체적으로 데이터를 읽을 필요도 없고, 원하는 갯수만큼 읽어드리는 쿼리를 수행한다.
즉, Full Scan 을 하지 않기 때문에 성능이 개선될 수 있다.

그렇게 No Offset 방식을 적용해보니, 성능이 눈에 띄게 개선이 되었다.
참고로 페이지 기준으로 100페이지에서 가져오던 데이터를 가져오게끔 쿼리문을 작성한 것이기 때문에, 실질적으로 가져오는 데이터는 100페이지에서 10개를 가져왔던 데이터와 동일하다.

여기서 성능을 더 개선해보자.

 

4단계 :  CQRS 방식 적용과 DB 분할

CQRS란 Command and Query Responsibility Segregation 의 약자로, 데이터 저장소로부터의 읽기와 업데이트 작업을 분리하는 패턴을 말한다.

필자가 지금 급하게 만든 코드는 단순 조회와 관련된 코드지만 실제 프로젝트는 조회만 있는 것이 아니라, 여러가지 로직이 들어가며 시스템과 DB에 부하를 주고 성능에 문제가 발생한다. 또한 읽기와 쓰기에서 사용되는 데이터 표현들이 일치하지 않는 경우로 인해 일부 작업에서는 필요하지 않은 추가적인 컬럼이나 속성 업데이트가 이루어져야 하는 불필요한 작업의 문제도 발생한다.

CQRS 패턴는 읽기와 쓰기를 분리하여 이러한 성능 문제를 해결하는 패턴이며, MSA 설계 방식에서 많이 사용하는 방식이다.

CQRS 패턴에는 세 가지가 있다.

하나는 Simple CQRS Architecture 로, 그냥 단순히 Query 서비스와, Command 서비스를 분리하는 방법이다.

https://kellis.tistory.com/49

필자는 여기까지 적용해보았다.
이후 아래에서 추가로 공부하며 작성한 방법과 내용들은 필자가 더 공부를 한 후, 신규 프로젝트를 만들어서 해볼 예정이다.

아래부터는 아직 적용하지 않고, 성능 개선을 위해 어떻게 할 수 있을 지 고민하면서 공부하고 정리한 내용이다.

위에서 정리한 Simple 패턴은 그저 CQRS 패턴대로 분리만 한 것일 뿐, 실질적인 DB 사용에 대한 성능 개선은 이루어지지 않는다.

실질적인 성능 개선을 하기 위해서는 

https://kellis.tistory.com/49

이렇게 DB도 분할하고, Event 스트림 통해 별도의 데이터베이스에 저장하는 방식을 사용하는 것이 좋다.

이벤트 스트림을 저장하는 DB에는 오직 데이터 추가만 가능하고, 계속 쌓인 데이터를 구체화시키는 시점까지 구축된 데이터를 바탕으로 조회 대상 데이터를 작성한다. 즉, 데이터가 변경되었을 때 데이터의 현재 상태만 도메인에 저장하던 방식을, 추가로 전용 저장소를 두어 해당 데이터에 수행된 전체 작업을 기록하는 방식으로 바꾼 것이라 생각하면 된다.

또한, DB는 Wrtie 용 DB는 RDBMS 를, Read 용 DB는 NoSQL 을 사용하여 조회 성능을 향상 시킬 수도 있다. NoSQL 은 유연성과 확장성이 좋아서, 대량의 데이터를 처리하고 읽어내는데에는 RDBMS 보다 좋은 성능을 갖기 때문이다.

이후 MSA 구조로 설계하고, Kafka 를 사용하는 등 성능을 높일 수 있는 방법은 여러가지가 있었다.
그리고 페이징 처리가 되더라도, Redis 의 캐싱 기능을 이용할 수 있는 방법도 있었다.

 


결론

조회 성능을 높일 수 있는 방법은 무척이나 많았다.

이 부분은 추후 공부를 진행한 후에 별도의 프로젝트를 만들어서, MSA 구조와 CQRS 적용, 그리고 RDBMS 와 NoSQL 을 나누는 과정까지 해봐야 겠다.

 

공부 및 참고

https://medium.com/coupang-engineering/%EB%8C%80%EC%9A%A9%EB%9F%89-%ED%8A%B8%EB%9E%98%ED%94%BD-%EC%B2%98%EB%A6%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-%EC%BF%A0%ED%8C%A1%EC%9D%98-%EB%B0%B1%EC%97%94%EB%93%9C-%EC%A0%84%EB%9E%B5-184f7fdb1367

 

대용량 트래픽 처리를 위한 쿠팡의 백엔드 전략

마이크로서비스로 고객에게 데이터 서빙하기: 고가용성, 고처리량, 그리고 지연시간 최소화 — Part 1

medium.com