siino's 개발톡

Redis 캐싱 전략 개선 및 스케줄링 본문

프로젝트

Redis 캐싱 전략 개선 및 스케줄링

siino 2024. 2. 20. 16:08

이전 글에서 응답속도를 확인하면서 Redis를 도입한 근거를 함께 알아보았습니다.

 

배경

이번 글에서는 도입한 redis를 사용하면서 고려했던 캐싱전략에 대해 글을 써보려합니다.

제가 구현했던 기능 중 하나는 '검색어 자동완성 기능'에 더불어 사용자가 검색어를 입력했을 때 인기검색어 등에 활용할 수 있도록 해당 검색어의 score를 1씩 늘려갔습니다.

 

문제

redis의 zset자료구조를 사용하면 score에 대한 처리를 손쉽게 할 수 있습니다. redis에서 관련 기능을 지원해줍니다.

이 기능에서 고민했던 점은, redis는 결국 in-memory 저장소이므로 이 score 점수를 DB에도 반영을 해야하는데, 이 시점을 언제로 할 지에 대한 고민이었습니다.

 

고민점과 전략

현재 redis를 사용하는 방식과 구조는

1. 사용자가 검색어 입력시 자동완성 기능을 통해 검색어 입력을 도와줌. (Look aside)

2. 사용자 검색어 입력 ➡️ 해당 검색어의 score 1 증가 (redis)

3. 인기검색어 조회➡️redis내에서 score의 값이 큰 순서대로 반환 (Look aside)

 

결국 2번을 제대로 구현하기 위해서는 캐시 쓰기 전략에 대해서 고려를 해야했습니다.

redis에 갱신된 score의 값을 언제/어떻게 DB에 반영해야할지에 대한 고민이 많았습니다.

단순히 '사용자가 입력했을 때 redis와 DB에 모두 증가시키면 되는 것이 아니냐?'라고 물으실 수 있겠지만 (처음에 이렇게 구현했답니다) 이제부터 그 과정과 시행착오에 대해서 말씀드리겠습니다.

 

이 'score값 증가'라는 로직은 사용자가 검색어를 입력할 때마다 일어나게 되므로, read/write 모두 빈번하게 일어남을 알 수 있습니다. 이 때마다 redis뿐만 아니라 db에도 score값의 update를 하게 되면 disk I/O의 작업이 빈번하게 일어나게 될 것이라고 생각했고, redis가 고속으로 쓰기 작업을 처리하는 이점을 제대로 활용하지 못한다는 생각을 하게 되었습니다.

 

정리하면, 프로젝트에서 아래 2가지 기준으로 쓰기 전략을 고려했습니다.

1. 인기검색어의 경우 주로 redis(cache)에서 조회가 발생하기 때문에 db의 실시간성이 크게 중요하지 않다.

2. score update는 빈번하게 발생하고 이 때마다 db에도 함께 반영한다면 redis의 고속 쓰기의 이점을 제대로 활용하지 못한다.

 

cache 쓰기 전략

1. write through - 쓰기(수정) 작업 시 redis와 db에 함께 반영

2. write around - 쓰기(수정) 작업 시 db에만 반영, 이후 cache miss 시 db 접근

3. write back - 쓰기(수정) 작업 시 redis에만 반영, 이후 스케쥴링을 통해 db 동기화

 

따라서 cache의 쓰기 전략에 대해서 고민하던 중, 기존의 write through 전략이 아닌 write back(write behind) 전략에 대해 알아보고 적용했습니다.

Write back 쓰기 전략은 Write through전략에 비해 쓰기 성능을 크게 향상시킬 수 있는 반면에  DB와 캐시간의 동기화가 이루어지기 전에 서버에 문제가 발생한다면 캐시에만 존재하는 데이터가 손실될 수 있는 문제가 발생할 수 있습니다.

 

본 프로젝트의 경우, 인기 검색어 score 데이터가 일부 누락되는 것에 대해 상대적으로 낙관적이라 판단했고, 응답성이 더 중요한 영역이라고 생각하여 해당 전략을 사용하기로 결정했습니다.

 

아래는 Write-Through전략과 수정된 Write-Back 방식의 로직입니다. (성능을 Locust를 통해 직접 비교해보았습니다.)

 

개선 사항 (코드)

기존의 로직 - Write Through

redisTemplate의 sortedSet 자료구조를 활용한 incrementScore함수를 통해 score를 실시간으로 1 올려줍니다.

increaseScore함수를 통해 DB에도 함께 score를 update합니다.

	public class FoodService {
        /**
         * write through 전략으로 캐시와 DB에 증가값 저장
         * */
        public FoodSearchResponse getFoodByName(String name) {
            redisTemplate.opsForZSet().incrementScore(SCORE_KEYWORD, name, 1); //redis에 score 증가분 반영
            
            FoodSearch findFood = findFoodSearchByName(name);
            findFood.increaseScore(); //write through 방식으로 db에 바로 반영
            
            return new FoodSearchResponse(findFood);
        }
    }
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class FoodSearch{
	//다른 필드 생략
    
    //검색된 횟수
    private double score;
    
    //score 증가 메서드
    public void increaseScore() {
        score++;
    }
    
}

 

수정 후 로직 - Write Back

score가 변경되면 변경된 score의 음식 이름과 검색된 횟수를 추후 처리하기 위해 redis의 zSet자료구조에 따로 저장해 둡니다.

스케줄러가 동작할 때 이 list에 담긴 음식 이름에 대해서 일괄 처리를 수행합니다.

public class FoodService {
	/**
     * write back 전략
     * */
    public FoodSearchResponse getFoodByName(String name) {
    	//조회용 redis에는 score 증가 반영
        redisTemplate.opsForZSet().incrementScore(SCORE_KEYWORD, name, 1); 
        //write back 방식으로 이후 스케쥴링을 통해 작업할 name과 score를 Zset에 저장, db에 추후 반영
        redisTemplate.opsForZSet().incrementScore(KEY_UPDATE, name, 1);  
        return new FoodSearchResponse(findFoodSearchByName(name));
    }

    @Scheduled(fixedDelay = 3000000L) //50분
    public void redisToDB(){
    	//업데이트 할 tuple들 가져오기
        Set<TypedTuple<String>> foodsToUpdate = redisTemplate.opsForZSet()
                                                           .rangeWithScores(KEY_UPDATE, 0, -1);
        redisTemplate.delete(KEY_UPDATE);
        try {
            foodsToUpdate.forEach(
                food -> findByName(food.getValue()).increaseScore(food.getScore()));
        } catch (Exception e) {
            //예외 발생시 rollback 처리 위함
            redisTemplate.opsForZSet().add(KEY_UPDATE, foodsToUpdate);
        }
    }
}
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class FoodSearch{
	//다른 필드 생략
    
    //검색된 횟수
    private double score;
    
    //score 증가 메서드
    public void increaseScore(double value) {
        score += value;
    }
}

**추가

1. 해당 방식으로 개선하면서 List가 아닌 ZSet에 저장한 이유?

-> list에 이름들만 저장해 둘 수 있지만 같은 음식을 계속 검색했을 때, 음식 이름이 중복으로 저장되는 것이 비효율적이라고 판단. 따라서 zset 자료구조를 선택!

 

2. Redis의 pub/sub를 활용하지 않은 이유?

-> 해당 기능을 구현할 때, 즉 redis에서 db로의 동기화 코드를 개발할 때 redis의 pub/sub기능을 충분히 활용할 수 있다고 생각했습니다. 

검색된 검색어의 총 score가 특정 임계치(개발자가 설정)를 넘었을 때, DB로의 동기화 메시지를 발행 후 해당 메시지를 구독하는 DB동기화 메서드가 비동기적으로 실행될 수 있도록 만들 수 있겠다는 생각을 했습니다.

 

하지만, 해당 기능을 사용하는 유저의 사용 패턴에 근거하여 스케쥴러를 사용하기로 결정했습니다.

-> 해당 서비스는 식단을 기록할 때 내가 먹은 식단을 검색하기 위해서 사용됩니다.

따라서 주된 이용 시간이 아침/점심/저녁 시간대이며 이 시간에 해당 기능에 사람들이 몰릴 것이라고 생각했습니다.

스케쥴러는 해당 기능을 이용하는 peak시간대를 피해서 작동하게 할 수 있지만, message기반의 비동기 처리는 오히려 해당 peak시간대에 더 자주 실행되어 시스템에 부하를 줄 수 있습니다.

 


 

//현재 redis에는 3가지 key를 사용하고 있습니다.

자동완성검색을 위한 search:keywords, db반영을 위한 update:keywords, 인기검색어 조회를 위한 score:keywords를 사용하고 있습니다.

 

search:keywords : 검색어 자동완성 기능, rangeByLex (사전순 정렬을 위한 key)

score:keywords : 인기검색어 조회, rangeByScore (점수순 정렬을 위한 key)

update:keywords:  DB동기화 반영을 위한 key

 

성능측정(전/후 비교)

마지막으로 수정 전 후로 얼마나 개선되었는지 확인하겠습니다.

아래는 수정 전 후 성능 측정 비교입니다. (Locust를 활용했습니다)

최대 User 200명, 20씩 늘려나간 성능 측정 그래프

왼쪽이 write through 전략,  오른쪽이 write back 전략

 

User수 200명을 기준으로 write through전략은 평균 응답속도 210ms, write back전략은 120ms정도로 측정되었습니다.

성능이 약 42%향상된 것을 확인할 수 있었습니다.

또한, 전체적으로 처리할 수 있는 request의 성능도 write through전략은 user수 약 100명부터 한계인 반면에, write back전략은 점진적인 우상향 그래프를 그리며 전반적으로 좋아진 모습을 확인할 수 있습니다.

 


 

마무리.

초기 캐싱 전략과 비교해서 캐싱 전략을 개선하는 과정을 통해 사용자의 응답 속도를 개선시킬 수 있는 방법에 대해 고민할 수 있었습니다.

항상 공부하면서 느끼는 거지만 개발에 100% 정답은 없고, 요구사항과 적용할 기술에 대한 trade off를 생각하며 최선의 방법이 무엇인지 고민하는 과정의 연속이라 더 흥미가 생기는 것 같습니다 :)

꾸준히 노력하겠습니다!