본문 바로가기

백엔드 개발일지

[Spring] Redis 캐시를 통해 조회 성능 개선하기

디프만 동아리에서 진행 중인 프로젝트 Bibbi(https://github.com/depromeet/14th-team5-BE)에서는 조회 기능이 많이 사용됩니다. 특히 사용자마다 여러 번 조회하는 API들이 존재하는데 아직은 크게 문제가 없지만 사용자 수가 늘어나 매번 DB까지 도달해 읽어오게 되면 성능적인 이슈가 발생할 수 밖에 없습니다. 저는 이런 기능의 성능을 개선하기 위해 Redis 캐싱 기능을 이용하기로 했습니다.

Bibbi에서는 조회는 빈번히 발생하지만 수정은 거의 발생하지 않는 값을 캐시로 적용했습니다.

 

 

 

1. 프로젝트 내에서 Redis 설정하기

본 내용은 Redis가 구축되어 있다는 전제 하에 진행됩니다. (Redis 구축하기)

 

1) RedisConfig

build.gradle

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

 

 

RedisConfig

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String redisHost;

    @Value("${spring.data.redis.port}")
    private int redisPort;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(new RedisStandaloneConfiguration(redisHost, redisPort));
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());

        /* Java 기본 직렬화가 아닌 JSON 직렬화 설정 */
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());

        return redisTemplate;
    }
}

 

Java의 Redis Client에는 Jedis와 Lettuce가 있지만, 스프링부트는 디폴트로 Lettuce를 사용합니다. 해당 프로젝트에서도 LettuceConnectionFactory를 빈으로 등록했습니다.

 

 

 

2) RedisCacheConfig

@EnableCaching
@Configuration
public class RedisCacheConfig {

    @Bean
    @Primary
    public CacheManager monthlyCalendarCacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisCacheConfiguration redisCacheConfiguration = generateCacheConfiguration()
                .entryTtl(Duration.ofHours(5L));

        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(redisCacheConfiguration)
                .build();
    }

    private RedisCacheConfiguration generateCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(
                                new StringRedisSerializer()))
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(
                                new GenericJackson2JsonRedisSerializer()));
    }
}

 

RedisCacheConfig를 설정하여 각 기능에 필요한 TTL을 설정합니다.

해당 포스트에서는 캘린더 조회 API 응답을 캐싱할 것이므로 이에 맞는 메서드를 하나 만들고 TTL은 5시간으로 설정했습니다.

참고로 CacheManager를 빈으로 등록하는 메서드가 여러 개라면, 빈 초기화 과정에서 에러가 발생하므로 @Primary로 등록을 해줍니다.

@Primary: 여러 빈이 있을 때 기본적으로 선택될 빈에 @Primary 어노테이션을 붙여주면 자동적으로 해당 빈이 선택됨

 

 

 

 

2. 캐시 적용하기

1) @Cacheable로 캐시 저장하기

처음에 말했듯이 Bibbi에서는 조회는 빈번히 발생되지만 수정은 적은 데이터를 캐싱하기로 했습니다. 마침 가족 캘린더 조회 기능이 이 조건에 부합하여 해당 포스트에서는 가족 캘린더 조회 기능 역할을 수행하는 getMonthlyCalendar의 응답 값을 캐싱했습니다.

 

@Override
@Cacheable(value = "calendarCache", key = "#familyId.concat(':').concat(#yearMonth)", cacheManager = "monthlyCalendarCacheManager")
public ArrayResponse<CalendarResponse> getMonthlyCalendar(String yearMonth, String familyId) {
    ...

    List<CalendarResponse> calendarResponses = getCalendarResponses(familyIds, startDate, endDate);
    return new ArrayResponse<>(calendarResponses);
}

 

@Cacheable캐시 생성 및 전달을 담당하는 어노테이션입니다. 캐시에 데이터가 없을 경우에는 기존의 로직을 실행 후 캐시에 데이터를 추가하고, 캐시에 데이터가 있으면 캐시의 데이터를 반환합니다.

  • 캐시를 저장할 때, 같은 가족 멤버들이 같은 캘린더 응답 데이터를 조회할 때 하나의 캐시에서 조회할 수 있도록 키의 값을 {familyId}:{yearMonth}로 지정했습니다.
  • getMonthlyCalendar는 사용자가 속한 가족 구성원들의 대표 피드 썸네일 이미지와 가족 구성원 모두가 피드를 업로드 했는지 여부에 대한 한달 치의 응답을 반환하는 로직입니다. 이제 해당 응답을 캐시 값으로 저장하게 됩니다.

 

 

만약 familyId가 1인 멤버가 2024년 1월에 대한 캘린더 정보를 조회했다면 아래와 같이 캐시가 저장되며 해당 캐시에는 응답 데이터가 보관됩니다.

 

 

 

2) @CacheEvict로 특정 캐시 삭제하기

CacheManager를 통해 5시간의 ttl은 지정해두었지만 데이터 정합성을 위해 캐시된 데이터의 내용이 바뀌는 경우가 생기면 기존 캐시는 삭제해야 한다고 판단했습니다.

getMonthlyCalendar의 응답은 피드 대표 썸네일 이미지와 가족 구성원 모두가 피드를 작성했는지 여부를 반환합니다. 그러므로 응답이 수정되는 경우는 가족 구성원이 피드를 작성할 때 입니다.

 

@Transactional
@Override
@CacheEvict(value = "calendarCache",
        key = "#familyId.concat(':').concat(T(java.time.format.DateTimeFormatter).ofPattern('yyyy-MM').format(#request.uploadTime()))")
public PostResponse createPost(CreatePostRequest request, String familyId) {
    ...

    return PostResponse.from(savedPost);
}

 

@CachEvict캐시 삭제를 담당합니다.

  • getMonthlyCalendar에서 지정했던 키 이름과 동일하게 여기에서도 키를 설정해줍니다.
  • 같은 familyId를 가진 멤버가 피드를 작성한다면 createPost가 실행되고 Redis에 {familyId}:{yearMonth}와 동일한 키를 가진 캐시가 있는지 확인합니다. 이때 존재한다면 해당 캐시를 삭제해주어 데이터 정합성에 문제가 없도록 합니다.

 

 

 

3. API 응답시간 비교 및 Cache hits

1) API 응답시간 비교

성능 측정 테스트 도구 JMeter를 이용해 다수의 동시 사용자가 해당 API를 호출한다고 가정하고 API 응답시간을 비교했습니다. 

 

100명의 동시 사용자가 API를 호출한다고 가정

 

841ms가 소요되던 캘린더 조회 API가 9ms로 개선되어 약 92.44배의 성능 개선을 할 수 있었습니다.

 

 

1000명의 동시 사용자가 API를 호출한다고 가정

만약 1000명의 동시 사용자가 API를 호출한다고 가정하면 응답 속도에서도 많은 개선이 보이지만 Error와 Throughput에서도 주목할 필요가 있습니다.

캐시를 미적용했을 때는 데이터베이스 조회를 동시 다발적으로 수행하기 때문에 에러가 발생하는데 캐시를 적용한 후에는 에러율이 0%임을 확인할 수 있습니다. 

처리량에 대해서도 캐시를 미적용했을 때와 비교하면 꽤나 많은 차이가 보입니다.

 

 

 

2) Cache Hit ratio

마지막으로 NCP Cloud DB for Redis에서 제공하는 모니터링을 통해 캐시 히트율을 확인합니다.

 

캐시 히트율이란, 캐시된 데이터를 요청할 때 해당 키 값이 메모리에 존재하여 얼마만큼의 비율로 잘 찾았는지에 대한 여부를 말합니다. 캐시 데이터를 잘 찾았으면 Cache Hits, 캐시가 존재하지 않아 찾지 못했다면 Cache Misses라 합니다. 

 

아래는 Bibbi Redis 서버 모니터링 현황입니다.

캐시 히트율 = Keyspace-hits / (Keyspace-hits + Keyspace-misses)

 

아직은 테스트 서버에만 캐싱 기능이 적용이 되어 Cache hits가 적은 편입니다. 그래도 사진처럼 Cache Misses는 0~1이라 공식을 통해 구한 캐시 히트율이 100에 가깝습니다. 그렇다면 데이터베이스에서 읽어오는 비율보다 메모리에 캐시된 데이터를 읽어오는 비율이 훨씬 높다는 의미이므로 효율적으로 캐시를 사용하고 있다는 반증이 됩니다!

 

Bibbi 운영 서버에서도 얼른 적용되어 높은 Cache Hits을 봤으면 좋겠네요. ☺️

 

 

 

+) 운영서버로 도입한 후에도 Cache Hits가 꽤 많이 발생하고 있네요!