[Shopping Mall] 무한 스크롤

이정민·2023년 12월 7일
0

쇼핑몰 프로젝트

목록 보기
3/5
post-custom-banner

프로젝트의 상품 도메인의 데이터는 약 1000개로 매우 적은 양이지만 데이터의 확장성과 서비스 성능을 위해 offset 조회가 아닌 범위 탐색을 통한 cursor 기반 조회 방식으로 구현했습니다.

패키지 구조

  • 프로젝트의 요구사항에 따라 탭별로 무한스크롤 Service class 구현했습니다.

Cursor-Based

offset query를 사용하지 않는 조회

기본 코드

ProductRepository.java
  • @Query 어노테이션을 사용해 nativeQuery 사용했습니다.
  • Key가 존재할 때와, 존재하지 않을 때로 구분했습니다.
    @Query(value = "select * from product where id < :id order by id desc limit :size", nativeQuery = true)
    List<Product> findAllOrderByIdDescHasKey(@Param("id") Long id, @Param("size") int size);
    
    @Query(value = "select * from product order by id desc limit :size", nativeQuery = true)
    List<Product> findAllOrderByIdDescNoKey(@Param("size") int size);

CursorRequest.java
  • key가 존재하는지 검사하는 hasKey() 메소드입니다.
  • 다음 key 값을 통해 CursorRequest 객체를 만드는 next() 메소드입니다.
@AllArgsConstructor
@Getter
@Builder
public class CursorRequest {
    public static final Long NONE_KEY_LONG = -1L;

    private Number key;
    private int size;

    public boolean hasKey(){
        return key != null;
    }

    public CursorRequest next(Number key){
        return new CursorRequest(key, size);
    }
}

PageCursor.java
  • CursorRequest로 현재 위치를 반환합니다.
  • 실제 조회된 내용이 담길 body입니다.
@Getter
@Builder
public class PageCursor<T> {
    private CursorRequest cursorRequest;
    private List<T> body;

    public PageCursor(CursorRequest cursorRequest, List<T> body) {
        this.cursorRequest = cursorRequest;
        this.body = body;
    }

}

ProductCursorUtilService.java
@RequiredArgsConstructor
@Service
public class ProductCursorUtilService {

    private final ProductReadService productReadService;

    public PageCursor<ProductResponse> getProductResponsePageCursor(CursorRequest cursorRequest, List<Product> products) {
        var productDtoList = products.stream()
                .map(productReadService::toProductResponse)
                .collect(Collectors.toList());

        var nextKey = getNextKey(products);
        return new PageCursor<>(cursorRequest.next(nextKey), productDtoList);
    }

    public Long getNextKey(List<Product> products){
        return products.stream()
                .mapToLong(Product::getId)
                .min()
                .orElse(CursorRequest.NONE_KEY_LONG);
    }

문제 상황

  • 기본 무한 스크롤 방식의 구현은 어렵지 않았습니다. 하지만 프로젝트의 요구사항은 상품을 무한 스크롤 방식을 유지하며 최신순, 인기순, 낮은 가격순, 높은 가격순으로 정렬할 수 있어야 했고 이는 기존의 key로는 구현이 불가능했습니다.

해결 방법

  • key가 될 데이터의 타입에 따라 메소드를 추가하는 방식으로 해결했습니다.

개선된 Cursor

ProductRepository.java
  • 요구사항에 따라 Key 값을 구분하여 처리합니다.
	// 최신순
	@Query(value = "select * from product where id < :id order by id desc limit :size", nativeQuery = true)
    List<Product> findAllOrderByIdDescHasKey(@Param("id") Long id, @Param("size") int size);
    
    @Query(value = "select * from product order by id desc limit :size", nativeQuery = true)
    List<Product> findAllOrderByIdDescNoKey(@Param("size") int size);

    // 인기순
    //    상품 점수 필드 생성(하루마다 업데이트)
    @Query(value = "select * from product as p left join best_product as bp on p.id = bp.product_id " +
            "where bp.score < :score order by bp.score desc limit :size", nativeQuery = true)
    List<Product> findAllOrderByScoreHasKey(@Param("score") Double score, @Param("size") int size);
   
    @Query(value = "select * from product as p left join best_product as bp on p.id = bp.product_id " +
            "order by bp.score desc limit :size", nativeQuery = true)
    List<Product> findAllOrderByScoreNoKey(@Param("size") int size);

    // 낮은 가격순
    @Query(value = "select * from product where price > :price order by price asc limit :size", nativeQuery = true)
    List<Product> findAllOrderByPriceAscHasKey(@Param("price") Integer price, @Param("size") int size);
    
    @Query(value = "select * from product order by price asc limit :size", nativeQuery = true)
    List<Product> findAllOrderByPriceAscNoKey(@Param("size") int size);

    // 높은 가격순
    @Query(value = "select * from product where price < :price order by price desc limit :size", nativeQuery = true)
    List<Product> findAllOrderByPriceDescHasKey(@Param("price") Integer price, @Param("size") int size);
    
    @Query(value = "select * from product order by price desc limit :size", nativeQuery = true)
    List<Product> findAllOrderByPriceDescNoKey(@Param("size") int size);

CursorRequest.java
  • Double, Integer 타입의 NON_KEY 값 저장합니다.
@AllArgsConstructor
@Getter
@Builder
public class CursorRequest {
    public static final Long NONE_KEY_LONG = -1L;
    public static final Double NONE_KEY_DOUBLE = -1.;
    public static final Integer NONE_KEY_INTEGER = -1;

    private Number key;
    private int size;

    public boolean hasKey(){
        return key != null;
    }

    public CursorRequest next(Number key){
        return new CursorRequest(key, size);
    }
}

PageCursor.java
@Getter
@Builder
public class PageCursor<T> {
    private CursorRequest cursorRequest;
    private List<T> body;

    public PageCursor(CursorRequest cursorRequest, List<T> body) {
        this.cursorRequest = cursorRequest;
        this.body = body;
    }

}

ProductCursorUtilService.java
  • sortId로 정렬 방식을 구분합니다.
  • key 타입에 따라 NextKey 메소드를 구분합니다.
@RequiredArgsConstructor
@Service
public class ProductCursorUtilService {

    private final ProductReadService productReadService;

    public PageCursor<ProductResponse> getProductResponsePageCursor(CursorRequest cursorRequest, List<Product> products, Long sortId) throws Exception {
        var productDtoList = products.stream()
                .map(productReadService::toProductResponse)
                .collect(Collectors.toList());

        if (sortId == 0L) {
            var nextKey = getNextKey(products);
            return new PageCursor<>(cursorRequest.next(nextKey), productDtoList);
        } else if (sortId == 1L) {
            var nextKey = getScoreNextKey(productDtoList);
            return new PageCursor<>(cursorRequest.next(nextKey), productDtoList);
        } else if (sortId == 2L) {
            var nextKey = getPriceAscNextKey(products);
            return new PageCursor<>(cursorRequest.next(nextKey), productDtoList);
        } else if (sortId == 3L) {
            var nextKey = getPriceDescNextKey(products);
            return new PageCursor<>(cursorRequest.next(nextKey), productDtoList);
        } else {
            throw new Exception("Wrong SortId!!");
        }
    }

    public Long getNextKey(List<Product> products){
        return products.stream()
                .mapToLong(Product::getId)
                .min()
                .orElse(CursorRequest.NONE_KEY_LONG);
    }
    public Double getScoreNextKey(List<ProductResponse> productResponses){
        return productResponses.stream()
                .mapToDouble(ProductResponse::getScore)
                .min()
                .orElse(CursorRequest.NONE_KEY_DOUBLE);
    }
    public Integer getPriceAscNextKey(List<Product> products){
        return products.stream()
                .mapToInt(Product::getPrice)
                .max()
                .orElse(CursorRequest.NONE_KEY_INTEGER);
    }
    public Integer getPriceDescNextKey(List<Product> products){
        return products.stream()
                .mapToInt(Product::getPrice)
                .min()
                .orElse(CursorRequest.NONE_KEY_INTEGER);
    }

}

ProductAllCursorReadService.java
@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class ProductAllCursorReadService {
    private final ProductRepository productRepository;

    private final ProductCursorUtilService productCursorUtilService;

    public PageCursor<ProductResponse> getProductsByCursor(Number key, int size, Long sortId) throws Exception {
        CursorRequest cursorRequest = new CursorRequest(key, size);

        var products = findAll(cursorRequest, sortId);
        return productCursorUtilService.getProductResponsePageCursor(cursorRequest, products, sortId);
    }

    private List<Product> findAll(CursorRequest cursorRequest, Long sortId) throws Exception {
        if (sortId == 0L) {
            if (cursorRequest.hasKey()) {
                return productRepository.findAllOrderByIdDescHasKey(cursorRequest.getKey().longValue(), cursorRequest.getSize());
            } else {
                return productRepository.findAllOrderByIdDescNoKey(cursorRequest.getSize());
            }
        } else if (sortId == 1L) {
            if (cursorRequest.hasKey()) {
                return productRepository.findAllOrderByScoreHasKey(cursorRequest.getKey().doubleValue(), cursorRequest.getSize());
            } else {
                return productRepository.findAllOrderByScoreNoKey(cursorRequest.getSize());
            }
        } else if (sortId == 2L) {
            if (cursorRequest.hasKey()) {
                return productRepository.findAllOrderByPriceAscHasKey(cursorRequest.getKey().intValue(), cursorRequest.getSize());
            } else {
                return productRepository.findAllOrderByPriceAscNoKey(cursorRequest.getSize());
            }
        } else if (sortId == 3L){
            if (cursorRequest.hasKey()) {
                return productRepository.findAllOrderByPriceDescHasKey(cursorRequest.getKey().intValue(), cursorRequest.getSize());
            } else {
                return productRepository.findAllOrderByPriceDescNoKey(cursorRequest.getSize());
            }
        } else {
            throw new InvalidValueException("Wrong SortId", ErrorCode.INVALID_INPUT_VALUE);
        }
    }
}

실행 예시

Talend API를 통해 진행했습니다.

  • sortId에 따라 key 값이 달라지는 것을 볼 수 있습니다.

sortId = 0)


sortId = 1)


문제 상황 2

  • Brand 도메인에서 좋아요 순 정렬이 필요했습니다. 하지만 좋아요 개수는 고유값이 아니기 때문에 size 값에 따라 데이터의 key 값이 무한 반복되거나 데이터가 유실되는 상황이 생겼습니다.

해결 방법

  • 다음과 같이 review_score 필드를 추가해 review_score = 좋아요 개수 + id 값 * 0.00001 을 계산해 id값을 사용하여 유니크한 데이터를 삽입했습니다.

후기

작업 기간이 가장 길었던 부분입니다. 첫 번째 문제는 key 타입을 다르게 줘야하는 것이었고 두 번째는 특정 상황에서의 데이터 유실과 무한 루프 문제였습니다. 이 외에도 서비스 구조를 어떻게 해야할지도 고민이 많았습니다. 처음에는 한 클래스에 모두 모았다가 코드를 알아보기 힘들어 무한 스크롤 부분만 따로 쪼개어도 봤지만 현재처럼 탭별로 구분하고 공통으로 사용되는 cursor 기능은 util서비스로 따로 분리했습니다.
Repository를 보면 모두 nativeQuery를 사용해 코드가 굉장히 길어졌는데 차라리 QueryDSL을 사용했으면 어땠을까하는 생각도 들었습니다.

전체 코드

https://github.com/SudalKing/Shopping_mall/tree/main

post-custom-banner

0개의 댓글