[SpringBoot] 인기도 순 Redis sorted set 적용하기

이의찬·2023년 7월 28일
0

Springboot

목록 보기
11/12

Github
🐱 https://github.com/BidderOwn/BidderOwn_BE

이번엔 BidderOwn의 인기도를 Redis의 sorted set을 이용하여서 개선해보자.

문제 상황

Querydsl로 구현된 쿼리는 다음과 같다.

// 아이템의 id를 먼저 찾아온다.
public List<Long> findItemIdsSortByPopularity(Pageable pageable, String searchText) {
    return queryFactory
            .select(item.id)
            .from(item)
            .leftJoin(item.bids, bid)
            .where(
                    eqToSearchText(searchText),
                    eqNotDeleted()
            )
            .groupBy(item.id)
            .orderBy(bid.count().desc())
            .limit(pageable.getPageSize())
            .offset(pageable.getOffset())
            .fetch();
}

// 찾아온 id를 where in으로 가져온다.
public List<ItemsResponse> findItemsInIdsSortByPopularity(List<Long> ids) {
    return queryFactory
            .select(
                    Projections.constructor(
                            ItemsResponse.class,
                            item.id,
                            item.title,
                            item.minimumPrice,
                            item.bids.size(),
                            item.comments.size(),
                            item.hearts.size(),
                            item.thumbnailImageFileName,
                            item.itemStatus,
                            item.expireAt
                    )
            )
            .from(item)
            .where(item.id.in(ids))
            .fetch();
}

위와 같이 쿼리를 분리한 이유는 id를 찾는 쿼리에 복잡한 쿼리를 넣어서 가져온 이후 WHERE id IN조건으로 원하는 데이터 블록만 접근하기 위해서다.
(실제로 ngrinder로 테스트를 하였을 때 4배정도 빠르게 측정된다.)

문제는 매 요청마다 입찰 개수를 계산하고 정렬을 해야하기 때문에 비효율적이다.

bid 테이블의 count를 정렬하는 것이기 때문에 Using filesort가 포함된다. Using filesort는 ORDER BY를 처리하기 위해 인덱스를 이용하지 못하고 MYSQL의 sort 버퍼에 레코드를 복사해서 정렬하기 때문에 비효율적인 작업이다.

이를 해결하기 위해 세 가지 방법을 고민하였다.

  1. 정규화를 포기하고 입찰 개수를 컬럼에 포함시킨다.
  2. 상품과 개수 정보를 포함한 읽기 전용 테이블을 생성한다.
  3. redis의 sorted set을 사용하여서 경매가 진행 중인 상품을 정렬해둔다.

1번 방식은 동시성 문제가 발생할 가능성이 있고 확장성이 좋지 않다.
2번 방식은 이전에 cqrs 패턴을 흉내내어 구현한 경험이 있지만 테이블 동기화를 완벽하게 제어할 필요가 있을 것 같다.

일종의 랭킹 기능과 비슷하다고 판단하여서 3번 방식으로 구현하게 되었다.

구현

sorted set

Redis의 sorted set에 들어갈 데이터의 형태는 다음과 같다.

"bid-ranking": [
  	//value: score
	"14": 1,
  	"3": 2,
  	"5": 3,
  	...
]

Redis sorted set
Sorted Sets는 key 하나에 여러개의 score와 value로 구성된다.
value는 중복되지 않으며 score를 기준으로 정렬된다.
*Sorted sets에서는 집합이라는 의미에서 value를 member라 부른다.
http://redisgate.kr/redis/command/zsets.php

이 프로젝트에서 사용할 명령어는 ZREVRANGE, 을 통해서 score가 높은 순으로 9개씩 가져올 계획이다.

ZREVRANGE는 redis 6.2.0 버전부터 deprecated 되었다.
이전 버전을 사용하거나 ZRANGE의 'REV'를 넘겨서 사용하면 된다. 이 프로젝트에서는 직접적으로 명령어를 사용하지 않고 RedisTemplate를 사용한다.

ItemRedisRepository

@RequiredArgsConstructor
@Repository
public class ItemRedisRepository {

    private final RedisTemplate<String, String> redisTemplate;

    private ZSetOperations<String, String> zSetOperations;

    @Value("${custom.redis.item.bidding.ranking-key}")
    private String bidRankingKey;

    @PostConstruct
    private void init() {
        zSetOperations = redisTemplate.opsForZSet();
    }

    public void save(Long itemId, int day) {
        zSetOperations.add(bidRankingKey, itemId.toString(), 0);
    }
    
    public List<Long> getBidRankingRange(Pageable pageable) {
        long start = pageable.getOffset();
        long end = pageable.getOffset() + pageable.getPageSize() - 1;

        Set<String> ids = zSetOperations.reverseRange(bidRankingKey, start, end);

        if (CollectionUtils.isEmpty(ids)) {
            return new ArrayList<>();
        }

        return ids.stream().map(Long::parseLong).collect(Collectors.toList());
    }

    public void increaseScore(Long itemId, int delta) {
        zSetOperations.incrementScore(bidRankingKey, itemId.toString(), delta);
    }

    public void decreaseScore(Long itemId, int delta) {
        if (delta > 0) throw new RuntimeException("decreaseScore invalid delta");
        zSetOperations.incrementScore(bidRankingKey, itemId.toString(), delta);
    }
    }

    public void removeBidRankingKey(Long itemId) {
        zSetOperations.remove(bidRankingKey, itemId.toString());
    }
}

1. 저장 save()

public class ItemEntityListener {

    @PostPersist
    public void postPersist(Item item) {
        ItemRedisService itemRedisService = BeanUtils.getBean(ItemRedisService.class);
        itemRedisService.createWithExpire(item, genExpireDay(item));
    }
    ...생략
}

EntityListener를 이용하여서 item 엔티티가 생성될 때 redis에 score를 0으로 넣는다.

2. 조회 getBidRankingRange()

paging 방식으로 조회되기 때문에 Pageable의 offset을 그대로 사용하였다.
redis는 0~9로 데이터를 조회하게 되면 9번도 포함되기 때문에 end - 1로 조회해야 정상적으로 데이터를 가져올 수 있다.

3. 증감 increaseScore(), decreaseScore()

zSetOperations에서 감소 메서드는 지원하지 않고 incrementScore()는 음수를 받을 수 있기 때문에 이 메서드를 이용하여서 구현하였다.

EntityListener를 이용하여서 Bid 엔티티가 생성될 때 증감이 된다.

public class BidEntityListener {

    @PostPersist
    public void postPersist(Bid bid) {
        ItemRedisService itemRedisService = BeanUtils.getBean(ItemRedisService.class);
        itemRedisService.increaseBidScore(bid.getItem().getId());
    }

    @PostRemove
    public void postRemove(Bid bid) {
        ItemRedisService itemRedisService = BeanUtils.getBean(ItemRedisService.class);
        itemRedisService.decreaseBidScore(bid.getItem().getId());
    }
}

Redis increase 동시성 문제
redis는 싱글 스레드이기 때문에 값을 증가할 때 동시성 문제가 발생하지 않는다.
실제로 curl로 테스트해본 결과 문제가 없었다.
curl -X GET "http://localhost:8080/redis/increase" & curl -X GET "http://localhost:8080/redis/increase"

4. 삭제 removeBidRankingKey()

삭제도 마찬가지로 EntityListener를 이용하여서 구현하였다.
이 프로젝트에서는 Item 엔티티를 삭제할 때 소프트 딜리트로 구현되어 있기 때문에 @PostUpdate 어노테이션으로 이벤트를 리스닝하였다.

@PostUpdate
public void postUpdate(Item item) {
    if (!item.getItemStatus().equals(ItemStatus.BIDDING)) {
        ItemRedisService itemRedisService = BeanUtils.getBean(ItemRedisService.class);
        itemRedisService.removeBidRankingKey(item.getId());
    }
}

ItemRedisService

따로 비즈니스 로직이 추가로 들어가지 않는다.

@RequiredArgsConstructor
@Service
public class ItemRedisService {

    private final ItemRedisRepository itemRedisRepository;

    public void createWithExpire(Item item, int expire) {
        itemRedisRepository.save(item.getId(), expire);
    }

    public List<Long> getItemIdsByRanking(Pageable pageable) {
        return itemRedisRepository.getBidRankingRange(pageable);
    }

    public void removeBidRankingKey(Long itemId) {
        itemRedisRepository.removeBidRankingKey(itemId);
    }

    public void increaseBidScore(Long itemId) {
        itemRedisRepository.increaseScore(itemId, 1);
    }

    public void decreaseBidScore(Long itemId) {
        itemRedisRepository.decreaseScore(itemId, -1);
    }
}

Redis와 관련된 코드는 이걸로 끝이다. 이제 상품 조회를 알아보자.

ItemService

/**
 * 인기순 정렬
 * @description Redis ranking 사용, 검색어 혹은 레디스에 데이터가 없을 때 기존 방식 사용 findItemLegacy()
 */
private List<ItemsResponse> getItemsSortByPopularity(ItemsRequest itemsRequest, Pageable pageable) {
    List<Long> ids = itemRedisService.getItemIdsByRanking(pageable);

    if (!StringUtils.isEmpty(itemsRequest.getQ()) || ids.size() == 0) {
        ids = itemCustomRepository.findItemIdsSortByPopularity(
                pageable,
                itemsRequest.getQ()
        );
    }

    List<ItemsResponse> items = itemCustomRepository.findItemsInIdsSortByPopularity(ids);
    sortByBidCountAndIdDesc(items);

    return items;
}

/**
 * 이미 가져온 상품을 입찰 개수 기준으로 정렬한다.
 */
private void sortByBidCountAndIdDesc(List<ItemsResponse> items) {
    items.sort((i1, i2) -> {
        if (!i1.getBidCount().equals(i2.getBidCount())) {
            return i2.getBidCount().compareTo(i1.getBidCount());
        }
        return i2.getId().compareTo(i1.getId());
    });
}

먼저 getItemsSortByPopularity()의 로직은 다음과 같다.

  1. 레디스에 인기도 순으로 item id 조회
    (레디스에 데이터가 없거나 검색어가 포함된 경우 기존 방식으로 조회)
  2. findItemsInIdsSortByPopularity()WHERE id IN ()으로 필요한 데이터 조회

sortByBidCountAndIdDesc()
ORDER BY가 아닌 정렬 메서드를 따로 구현하였다.
page size만큼 입찰 순으로 정렬된 id를 먼저 구하고, WHERE id IN 가져오게 된다.
이때 다시 id 순으로 정렬되어 가져오기 때문에 다시 ORDER BY를 해야한다.
그럼 또 count를 구해야하기 때문에 collection sort를 이용하였다.

Service 테스트

테스트 코드에 테스트용 Item 엔티티를 10개 생성하는 코드가 포함되어 있습니다.

1. Redis 테스트

@DisplayName("상품 정렬 테스트 - 인기순 sortCode 2, 레디스")
@Test
void test003() {
    //given
    int sortCode = 2;
    PageRequest pageRequest = PageRequest.of(0, PAGE_SIZE);
    ItemsRequest itemsRequest = ItemsRequest.builder().s(sortCode).build();

    //when
    List<ItemsResponse> items = itemService.getItems(itemsRequest, pageRequest);

    //then
    assertThat(items.size()).isEqualTo(PAGE_SIZE);
    assertThat(items).isSortedAccordingTo(
            Comparator.comparing(ItemsResponse::getBidCount, Comparator.reverseOrder())
    );
}

정렬 기준을 인기도이다.
검색어를 포함하지 않아서 redis에서 조회할 것으로 예상된다.

결과로 PAGE_SIZE만큼 조회되고, bidCount(입찰 개수)대로 조회될 것이라고 예상된다.

정상적으로 실행되었다.

2. 검색어 포함 테스트

검색어가 포함될 경우 레디스에서 조회가 아닌 기존 방식으로 데이터베이스에서 조회한다.

@DisplayName("상품 정렬 테스트 - 인기순 sortCode 2, 검색어 포함")
@Test
void test004() {
    //given
    int sortCode = 2;
    String searchText = "test";
    PageRequest pageRequest = PageRequest.of(0, PAGE_SIZE);
    ItemsRequest itemsRequest = ItemsRequest.builder()
            .s(sortCode)
            .q(searchText)
            .build();

    //when
    List<ItemsResponse> items = itemService.getItems(itemsRequest, pageRequest);

    //then
    assertThat(items.size()).isEqualTo(PAGE_SIZE);
    assertThat(items).isSortedAccordingTo(
            Comparator.comparing(ItemsResponse::getBidCount, Comparator.reverseOrder())
    );
}

마찬가지로 잘 실행된다.

회고

Redis의 sorted set을 활용하여서 인기순을 구현하였다. 이미 redis에 잘 구현되어 있는 자료구조를 사용하여서 구현은 쉬웠던 것 같다.

조금 복잡한 쿼리이기 때문에 더 좋은 쿼리를 작성해보기 위해 explain을 해도 Using filesort를 피할 수는 없었다.
결국 검색어가 포함되었을 때와 redis 서버 장애가 발생했을 때는 RDB를 접근해야하기 때문에 기본적인 성능을 개선해야할 것 같다.

지금까지 최신순, 인기순, 경매마감순 정렬기준을 개선해보며 데이터베이스 인덱스, redis, no offset, cqrs 패턴 등 여러가지를 공부하며 적용할 수 있었다.

다음으로는 검색 기능을 개선해볼 계획이다.

1개의 댓글

comment-user-thumbnail
2023년 7월 28일

많은 도움이 되었습니다, 감사합니다.

답글 달기