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

개발 회고록

[ 개발 회고록 ] Redis 와 @Cacheable 을 적용하여 응답속도 개선하기 ( + Redis keys 대신 scan 을 사용하는 이유 )

전대홍 2024. 1. 24. 16:59

 


서론

현재 배달과 관련 된 토이프로젝트를 진행하고 있다.

토이 프로젝트 링크 <<<

응답속도를 개선하기 위해 제일 좋은 방법은 캐싱을 하는 것이다.

우리가 바로 DB 에서 가져오는 것은, Disk 를 읽어오는 것이지만, 캐싱 데이터를 가져오는것은 메모리에서 가져오는 거기 때문에 속도가 당연 빠를 수 밖에 없다.

그 이유는 필자가 TIL 블로그에 정리해두었는데 ( 필자 TIL 해당 링크 ) 쉽게 설명하면, 메모리는 데이터에 직접 엑세스하고, Disk 는 헤드를 움직이면서 찾아야하기 때문이다.

그래서 우리는 메모리를 활용하는 캐싱 방법을 통해, 응답속도를 개선할 수 있다.

 

 

 


본론

1. 캐싱에 적합한 데이터

필자가 개발한 코드를 생각해보면, 매장 정보나 메뉴 정보에 적용하면 좋을 거 같다.

왜냐하면 기본적으로 캐싱하는 데이터를 선정하는 기준은, Update 가 잦지 않고, 자주 조회하는 데이터를 기준으로 하기 때문이다.

한 번 읽어서 조회 결과를 가져온 데이터를 캐시 메모리에 저장하는 개념이다.

 

 

2. 캐싱 전략

필자가 공부하며 TIL 로 정리해 놓은 내용들이 있다.

1. 레디스 캐싱 전략 ( TIL 링크 )

2. Local Cache 와 Global Cache ( TIL 링크 )

자세한 내용은 해당 TIL 블로그를 참조하면 되고, 지금은 필요한 내용만 이야기하려고 한다.

필자는 Redis 를 사용하여 캐싱하는 데이터를 저장하기로 하였고, 가져올 때는 Look Aside 패턴을 사용하기로 하였다. 쉽게 이야기해서, 데이터가 메모리에 없으면 DB에서 가져오고, 있으면 메모리에서 가져오는 방식이다.

그러면 내용이 Update 가 될 경우 어떻게 해야하는가 ?

그 점을 고려하여, 필자는 3가지 방법을 선택하였다.

하나는 Update 가 될 경우 @CacheEvict 를 활용해서 캐시 데이터를 refresh 하는 방법을 선택하였고,

두 번째는 혹시 바뀌는 경우를 고려하지 못하고 로직이 진행 될 수 있기 때문에, 예외처리를 꼼꼼하게 걸어놓았으며,

마지막으로 캐싱 데이터 유지 시간을 10분으로 지정해놓았다.

 

 

3. 캐싱 적용 과정

필자가 적용한 과정을 간략하게 소개하고자 한다.

 

먼저 Redis 를 Gradle 에 추가해준다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

RedisConfig 를 작성해준다.

필자의 코드는 Session 과 Cache 를 분리하였기 때문에, redisCacheConnectionFactory 라는 이름으로 빈을 등록하였다.

@Bean("redisCacheConnectionFactory")
public RedisConnectionFactory redisCacheConnectionFactory() {
    RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration(cacheHost, cachePort);
    return new LettuceConnectionFactory(redisConfiguration);
}

@Bean
public RedisCacheManager redisCacheManager() {
    RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration
            .defaultCacheConfig()
            .disableCachingNullValues()
            .serializeKeysWith(
                    RedisSerializationContext.SerializationPair
                            .fromSerializer(new StringRedisSerializer())
            )
            .serializeValuesWith(
                    RedisSerializationContext.SerializationPair
                            .fromSerializer(new Jackson2JsonRedisSerializer<>(Object.class))
            )
            .entryTtl(Duration.ofMinutes(10L)); // 캐시 유지시간 10분

    return RedisCacheManagerBuilder
            .fromConnectionFactory(redisCacheConnectionFactory())
            .cacheDefaults(redisCacheConfiguration)
            .build();
}

 

마지막으로 @EnableCaching 어노테이션을 main 메서드가 있는 클래스에 적용시킨다.

이는 public 메서드에서 캐싱 어노테이션이 있는지를 검사하는 후처리를 진행한다. 만약 관련 어노테이션이 있다면 return 된 값을 캐싱 처리하는 기능이 추가된 프록시 객체를 생성하고, 해당 객체로 캐싱을 처리한다.

@EnableCaching
@SpringBootApplication
public class Main {

    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }

}

 

이제 마지막으로 캐싱을 적용하고자 하는 메서드에 @Cacheable 을 적용시켜서 아래와 같이 적용시켜주면 된다.

cacheManager 는 스프링 빈으로 등록된 어떤 메서드를 사용할 것인지를 체크하는 것이고,

condition 은 조건을 걸어주는 것이다. 해당 코드에서는 member 가 null 이 아닐경우에만 적용하도록 하였다.

@Cacheable(key = "'member:' + #member?.id + '-category:' + #categoryId", value = STORE_LIST, cacheManager = "redisCacheManager", condition = "#member != null")
@Transactional(readOnly = true)
public List<StoreResponse> getStoreListByCategory(Member member, Long categoryId) {

    List<Store> stores;

    if (member == null) {
        stores = storeRepository.findTop30ByCategoryIdOrderByName(categoryId).orElse(List.of());
    } else {
        stores = memberAddressRepository
                .findTopByStatusAndMemberIdOrderByPriorityAsc(MemberAddress.Status.DEFAULT, member.getId())
                .map(memberAddress ->
                        storeRepository.findAllWithInDistanceInCategoryIdOrderByDistance(
                                        memberAddress.getLatitude(), memberAddress.getLongitude(), categoryId)
                                .orElse(List.of())
                )
                .orElseGet(() ->
                        storeRepository.findTop30ByCategoryIdOrderByName(categoryId).orElse(List.of())
                );
    }

    return stores.stream()
            .map(store -> {
                String awsImagePathURL = fileService.getFilePath(store.getImageFileName());
                return StoreResponse.toStoreResponse(store, awsImagePathURL);
            })
            .toList();
}

 

아래와 같이 Redis 에 캐싱된 내용이 저장된다.

 

 

4. [참고] key 값을 조회할 때 Keys 말고 Scan 을 사용하는 이유

필자는 로그아웃을 할 경우, 해당 memberId 로 만들어진 모든 캐싱 데이터를 삭제하는 로직을 만들었다.

이 때 Key 값들을 가져와야 되는데,

처음에는

아래와 같은 코드를 만들었다.

public void evictCachesByMember(Long memberId) {
    Set<String> keys = redisTemplate.keys("*member:" + memberId + "*");
    if (keys != null) {
        redisTemplate.delete(keys);
    }
}

그러나 이렇게 만들면 문제가 있다는 걸 알게되었다.

레디스의 명령어중 keys를 이용하면 모든 키값들을 가져올 수 있지만 이 키값들을 가져오는동안 다른 명령어를 수행하지 못한다. 따라서 성능에 영향을 줄 수 있어 레디스에서는 scan,hscan을 권장한다.

Redis의 keys 명령어는 시간 복잡도가 O(N)이며, 싱글스레드로 동작하기 때문에 이처럼 어떤 명령어를 O(n)시간 동안 수행하면서 lock이 걸린다면 그시간동안 keys 명령어를 수행하기 위해 멈춰버리기 때문이다. 

Redis에서 제공하는 Scan명령어는 Keys처럼 한번에 모든 레디스 키를 읽어오는 것이아니라, count 값을 정하여 그 count값만큼 여러번 레디스의 모든 키를 읽어오는 것이다. 

따라서 count의 개수를 낮게잡으면 count만큼 키를 읽어오는 시간은 적게걸리고 모든 데이터를 읽어오는데 시간이 오래걸리지만 그 사이사이 시간에 다른 요청들을 레디스에서 처리해줄 수 있게 된다. 반대로 count의 개수를 높게 잡으면  count의 개수만큼 읽어오는데 시간이 오래걸리고 모든데이터를 읽는데는 시간이 짧게 걸리지만 그 사이사이에 다른 요청을 받는 횟수가 줄어들어 레디스가 다른 요청을 처리하는데 병목이 생길 수 있다.

필자는 기본 count 값인 10으로 설정해 두었다.

public void evictCachesByMember(Long memberId) {

    List<String> keyList = new ArrayList<>();

    redisTemplate.execute((RedisCallback<List<String>>) redisConnection -> {
        ScanOptions scanOptions = ScanOptions.scanOptions().match("*member:" + memberId + "*").count(10).build();
        // Redis 서버에서 모든 키를 스캔합니다.
        Cursor<byte[]> cursor = Objects.requireNonNull(redisTemplate.getConnectionFactory()).getConnection().scan(scanOptions);
        while (cursor.hasNext()) {
            byte[] keyBytes = cursor.next();
            String key = new String(keyBytes, StandardCharsets.UTF_8);
            keyList.add(key);
        }
        cursor.close();
        return keyList;
    });

    for ( String key : keyList ) {
        redisTemplate.delete(key);
    }

}

 

 

 


결론

캐싱처리를 통하여, 응답 속도를 개선해보았다.

실제로 서버에 적용을 하였을 때, 응답 속도가 얼마나 개선되었는지 테스트는 아직 하지 못하였지만, 레디스에 데이터가 들어가고, 꺼내지는 것을 보아 읽기 성능에 대한 큰 개선이 있을 것이란 생각이 든다.

다음에는 JMeter 등으로 테스트를 진행 할 때, 캐싱처리가 된 경우와 그렇지 않은 경우를 비교하며 속도를 확인해보아야 겠다.