JAVA/JAVA | Spring 학습기록

[Spring] Elasticache Redis 캐싱과 테스트 코드를 이용한 성능 개선

kth990303 2023. 5. 29. 15:45
반응형

해당 글에서는 aws Elasticache for Redis로 캐시 데이터를 이용한 성능 개선,

Spring Boot + Lettuce Redis + JUnit5 프로덕션 + 테스트 코드에 대해 다룹니다.


사이드 프로젝트 `모카콩`의 Wiki에 작성한 글에 해당된다.


 

해당 프로젝트 github: https://github.com/mocacong/Mocacong-Backend

 

GitHub - mocacong/Mocacong-Backend: 모카콩 백엔드

모카콩 백엔드. Contribute to mocacong/Mocacong-Backend development by creating an account on GitHub.

github.com


들어가며

 모카콩에는 수많은 카페 데이터들이 존재합니다. 전국의 카페 수가 적지 않은데다가, 회원들이 해당 카페에 리뷰와 코멘트를 남기게 되므로 모카콩에서 카페 데이터란 가장 중요하고도 가장 많은 데이터에 해당되게 됩니다. 그리고 그만큼, 해당 데이터를 이용하는 데에는 불편함이 없어야 합니다. 모두에게 코딩하기 좋은 카페를 추천해주는 앱인데, 정작 중요한 카페 조회 API가 동작하지 않는다면? 유저는 해당 앱을 지우는 결과를 초래하게 되겠죠.

 

 카페 미리보기 API 에서는 해당 카페의 많은 정보들 중에서 해당 카페의 평점, 스터디타입 관련 정보만 조회하는 API 입니다. 유저가 해당 카페를 클릭하기 전에, 미리 정보들을 띄워서 보여주는 데에 사용되는 API인 셈이죠. 그만큼 해당 API는 빈번하게 호출됩니다.

카페 미리보기 조회 API 응답시간 1254ms

 위 사진은 해당 API 호출 응답시간을 담고 있습니다. 빈번하게 호출되는, 그리고 매우 중요한 API의 응답시간이 무려 1초를 넘어갑니다. Cafe 엔티티 뿐만 아니라, Member, Review, Score 등 수많은 엔티티를 조회하고 DB I/O 비용도 꽤 발생하는 API이기 때문에 충분히 그럴 수 있습니다. 하지만 해당 API가 빈번하게 호출됨에도 불구하고 매번 1초 씩 기다려야된다는 사실은 유저 입장에서 여간 불편한 점이 아닐 겁니다.

 

 따라서 모카콩에서는 해당 API의 응답 결과를 Redis, 그 중에서도 aws 프리티어 범위 내에 포함되는 Elasticache for Redis를 이용하여 해당 응답 값을 캐싱해두기로 결정했습니다.


어떤 값을 캐시로 사용할 것인가?

어떠한 값을 캐시로 적용할 지는 프로젝트 상황마다 달라질 수 있지만, 모카콩에선 아래 기준을 우선으로 결정했습니다.

 

  • 조회는 빈번히 발생하지만, 수정은 거의 발생하지 않는 값
  • 조회에 오랜 시간이 발생하는 값

 

 전자에 해당되는 값에는 OAuth 공개키, 유저의 refreshToken이 해당됩니다. 현재 모카콩에는 refreshToken이 존재하지 않으므로 OAuth 공개키와 accessToken을 캐싱하기로 결정했습니다. 유저는 OAuth 로그인을 자주 하지만, OAuth 공개키는 1년에 거의 두세번 변할까 말까 한 값이기 때문입니다. 또, accessToken 유효기간이 비교적 길지만 유저의 모카콩 접속 및 이용시간이 30분 ~ 1시간일 것이라 예측하여 accessToken도 캐싱하기로 결정했습니다. 추후 refreshToken이 도입된다면 변경될 수도 있을 듯합니다.

 

 후자에 해당되는 값에는 카페 미리보기 API, 카페 조회 API가 해당됩니다. 두 API 모두 여러 엔티티와 repository의 값을 조회하는 I/O 비용이 발생하기 때문입니다. 하지만 카페 조회 API는 리뷰, 코멘트, 평점 등 다양한 상황에 의해 수정이 빈번하게 발생할 수 있습니다. 또, 카페 조회 API는 카페 미리보기 API에 비해 호출 횟수가 현저히 적을 것이라 예상했습니다. 반면, 카페 미리보기 API는 변경 횟수가 비교적 적으며 호출 횟수는 비교적 많습니다. 그렇기 때문에 카페 미리보기 API의 응답값을 redis에 캐싱하기로 결정했습니다.


Elasticache 연동

Elasticache Redis 클러스터 생성 방법은 생략합니다.

주의할 점은, 기본 옵션으로 바로 생성해버리면 r6g.xlarge 인스턴스로 생성돼 엄청난 과금 폭탄을 맞을 수 있습니다. 반드시 데모 옵션 또는 그 이하에 해당되는 t2.micro 인스턴스를 선택하시고, 가동 중단 없이 클러스터 크기를 동적으로 조정하는 클러스터 모드는 비활성화됨을 선택해주세요. Primary/Standby 기능을 사용하지 않도록 복제본 개수도 0으로 설정해주세요.

 

또, Elasticache는 local에서 엔드포인트 연동이 불가능합니다. 따라서 local 환경에서는 별도로 redis, redis-cli를 설치하셔야 합니다. Docker를 이용한다면 간편하게 로컬에서 테스트가 가능할 수 있겠죠.

 

바로 스프링부트와 연동하는 코드를 살펴보겠습니다.

build.gradle

1
2
3
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
cs

 

application.yml

1
2
3
4
spring:
  redis:
    host: ${REDIS_HOST}
    port: ${REDIS_PORT}
cs

Redis의 Host에는 Elasticache의 엔드포인트를, Port에는 Elasticache의 port를 설정해줍시다.

기본적으로 Redis의 default port는 6379로 설정돼있습니다. 별도 설정을 건드리지 않았다면 port는 6379를 적어주면 됩니다.

Elasticache Redis 클러스터 세부정보에서 엔드포인트를 조회할 수 있습니다. 그대로 복사하지 마시고, 포트 번호는 제외해서 Host에 기록합니다.

참고로 로컬에서는 Elasticache 연동이 불가능하므로 로컬 환경에 설치된 redis의 엔드포인트를 기록해줍시다.

host에 localhost라고 설정해주시면 되며, 127.0.0.1 을 적으셔도 무방합니다.

 

RedisConfig.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Configuration
public class RedisConfig {
 
    @Value("${spring.redis.host}")
    private String redisHost;
 
    @Value("${spring.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;
    }
}
 
cs

Java의 Redis Client에는 Jedis, Lettuce가 있습니다만, 스프링부트는 default로 lettuce를 이용합니다. 성능이나 자원 사용률 측면에서도 jedis보다 lettuce가 현재 더 좋은 상황이므로 LettuceConnectionFactory를 빈으로 등록하도록 하겠습니다.

Jedis vs Lettuce 자세한 성능 비교는 조졸두님 블로그에서 확인할 수 있습니다.

https://jojoldu.tistory.com/418

 

Jedis 보다 Lettuce 를 쓰자

Java의 Redis Client는 크게 2가지가 있습니다. Jedis Lettuce 둘 모두 몇천개의 Star를 가질만큼 유명한 오픈소스입니다. 이번 시간에는 둘 중 어떤것을 사용해야할지에 대해 성능 테스트 결과를 공유하

jojoldu.tistory.com

 

`redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());` 코드를 추가하여 기본적으로 Java 직렬화가 아닌 JSON 직렬화가 되도록 했습니다. 이 코드는 없어도 무방합니다만, 추후 가독성을 위해 추가해주었습니다.


TTL 설정

TTL이란 Redis에 캐싱된 값의 유효기간을 의미합니다. 해당 기간이 지나면 캐시된 값은 삭제됩니다. TTL을 설정할 때는 아래 사항들을 고려합니다.

 

  • 수정 빈도를 고려하여, 캐시된 값을 그대로 반환해도 유효한 데이터가 되도록 TTL 설정
  • 인메모리 Redis DB에 캐시 데이터가 가득 차서 OOM이 발생하지 않도록 TTL 설정

 

 OAuth Public Key는 수정 빈도가 정말 낮기 때문에 TTL을 길게 잡아도 무방합니다. 하지만, TTL을 지나치게 길게 잡으면 문제 상황이 발생합니다. 해당 TTL 기간동안 캐시된 데이터를 반환하는데, 만약 중간에 OAuth 공개키가 변경된다면? 해당 TTL 기간동안은 잘못된 캐시 데이터로 로그인을 시도할테니 로그인에 실패하게 됩니다. 따라서 OAuth 공개키 캐시 데이터의 TTL은 3일로 잡았습니다.

 

 카페 캐시 데이터와 accessToken 캐시 데이터는 OAuth 공개키에 비해 훨씬 생성 빈도가 잦을 것이라 생각했습니다. 모카콩의 Elasticache는 프리티어 범위의 인스턴스를 사용하므로 캐시 데이터가 조금만 많아도 OOM이 발생하리라 판단했습니다. 따라서 OAuth 공개키보다 짧게 TTL을 가져가도록 하였습니다. 다만 모카콩의 현재 접속자 수가 많지 않은 점을 고려하면, 지나치게 짧은 TTL은 cache hit ratio(캐시 적정률)를 낮추는 결과만 초래하리라 판단했습니다. 따라서 적절하게 TTL을 하루 이내로 잡아주었습니다.

 

RedisCacheConfig.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@EnableCaching
@Configuration
public class RedisCacheConfig {
 
    private static final long DELTA_TO_AVOID_CONCURRENCY_TIME = 30 * 60 * 1000L;
 
    @Value("${security.jwt.token.expire-length}")
    private long accessTokenValidityInMilliseconds;
 
    @Bean
    @Primary
    public CacheManager cafeCacheManager(RedisConnectionFactory redisConnectionFactory) {
        /*
         * 카페 관련 캐시는 충분히 많이 쌓일 수 있으므로 OOM 방지 차 ttl 12시간으로 설정
         */
        RedisCacheConfiguration redisCacheConfiguration = generateCacheConfiguration()
                .entryTtl(Duration.ofHours(12L));
 
        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(redisCacheConfiguration)
                .build();
    }
 
    @Bean
    public CacheManager oauthPublicKeyCacheManager(RedisConnectionFactory redisConnectionFactory) {
        /*
         * public key 갱신은 1년에 몇 번 안되므로 ttl 3일로 설정
         * 유저가 하루 1번 로그인한다고 가정, 최소 1일은 넘기는 것이 좋다고 판단
         */
        RedisCacheConfiguration redisCacheConfiguration = generateCacheConfiguration()
                .entryTtl(Duration.ofDays(3L));
        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(redisCacheConfiguration)
                .build();
    }
 
    @Bean
    public CacheManager accessTokenCacheManager(RedisConnectionFactory redisConnectionFactory) {
        /*
         * accessToken 시간만큼 ttl 설정하되,
         * 만료 직전 캐시 조회하여 로그인 안되는 동시성 이슈 방지를 위해 accessToken ttl 보다 30분 일찍 만료
         */
        RedisCacheConfiguration redisCacheConfiguration = generateCacheConfiguration()
                .entryTtl(Duration.ofMillis(accessTokenValidityInMilliseconds - DELTA_TO_AVOID_CONCURRENCY_TIME));
 
        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()));
    }
}
cs

RedisCacheConfiguration을 설정하고, 각 상황에 맞게 TTL을 설정하는 모습입니다.

참고로 CacheManager를 빈으로 등록하는 메서드가 여러 개일 경우, 빈 초기화 과정에서 에러가 발생합니다. 따라서 특정 메서드를 @Primary로 등록해주도록 합시다. @Primary로 등록되지 않은 메서드라고 해서, 수행이 안되거나 하지 않으므로 걱정하지 않으셔도 됩니다.


@Cacheable, @CacheEvict를 설정할 메서드

@Cacheable 어노테이션으로 해당 메서드의 반환 값을 캐싱할 수 있으며, @CacheEvict 어노테이션으로 해당 작업이 수행되면 캐시된 값을 삭제시키게 할 수 있습니다.

 

카페 미리보기 조회 API의 비즈니스 로직에 해당되는 previewCafeByMapId에 아래와 같이 @Cacheable 어노테이션을 설정해주었습니다.

1
2
3
4
5
@Cacheable(key = "#mapId", value = "cafePreviewCache", cacheManager = "cafeCacheManager")
@Transactional(readOnly = true)
public PreviewCafeResponse previewCafeByMapId(String email, String mapId) {
    // ...
}
cs

여기서 value 값은 필수적입니다. 만약 value 값이 중복될 경우를 대비하여 key 값을 추가로 설정해주었습니다. 위처럼 설정할 경우 redis-cli에서 해당 키값은 cafePreviewCache::${mapId에 해당되는 값} 로 설정됩니다. 만약 mapId가 1234라면 레디스에 저장되는 키는 cafePreviewCache::1234 가 됩니다.

 

 이제 카페 미리보기 조회 API를 호출하면 먼저 Elasticache Redis에서 O(1) 시간복잡도로 조회합니다. 인메모리 DB이므로 I/O 비용이 매우 적게 발생합니다. 만약 Elasticache Redis에서 캐시 데이터를 발견하지 못하면 실제 DB에 I/O 작업을 통해 데이터를 가져오고, 해당 데이터를 캐싱하여 Elasticache Redis에 TTL 기간만큼 캐시로 저장합니다.

 

 

그런데 카페 미리보기 조회 API에서 반환되는 데이터는, 해당 카페에 리뷰 또는 즐겨찾기가 등록되면 반환 데이터가 변경돼야 합니다. 따라서 특정 트리거가 발생할 때에는 캐시 데이터가 삭제되도록 @CacheEvict 어노테이션을 설정했습니다.

1
2
3
4
5
@CacheEvict(key = "#mapId", value = "cafePreviewCache")
@Transactional
public CafeReviewResponse saveCafeReview(String email, String mapId, CafeReviewRequest request) {
    // ...
}
cs

saveCafeReview 메서드를 통해 해당 카페에 리뷰가 작성되면 cafePreviewCache::#mapId에 해당되는 캐시 데이터를 삭제하도록 설정했습니다.


embedded-redis를 이용한 테스트 코드

위 설정을 마치고 난 뒤에, 프로덕션 코드를 수행해보면 캐싱된 값이 반환되는 것을 확인할 수 있습니다. 하지만, 테스트 코드를 수행해보면 Failed to application context 에러가 우리를 반겨줍니다. 이는 테스트 환경에서는 Redis 설정이 없기 때문입니다. 그렇다고 테스트 코드를 돌릴 때마다 실제 Redis DB를 사용할 수도 없는 노릇. 어떻게 하면 좋을까요?

 

다행히 테스트 코드 환경에서 사용할 수 있도록 ozimov:embedded-redis 라이브러리가 존재합니다. 이 라이브러리를 이용하여 테스트 환경에 redis 설정을 도입해보겠습니다.

 

build.gradle

1
2
3
dependencies {
    testImplementation 'it.ozimov:embedded-redis:0.7.2'
}
cs

테스트 환경에서는 실제 redis를 쓰는 것을 지양해야될 뿐더러, elasticache를 사용할 수도 없습니다. 그렇기 때문에 테스트용 embedded-redis 를 추가했습니다.

 

최신 버전 0.7.3은 Slf4j를 사용하여 logback과 충돌 이슈가 발생합니다. 모카콩에서는 로깅 라이브러리로 Slf4j가 아닌 logback을 사용하기 때문에 0.7.3 버전을 사용할 수 없습니다. 따라서 버전을 0.7.2로 설정했습니다. 해당 이슈는 ozimov/embedded-redis#18 (comment)에서도 확인 가능합니다.

 

Minor: move slf4j dependency to test dependencies (unused in library). by doumdoum · Pull Request #18 · ozimov/embedded-redis

Hi, My very little contrib to your great work. Regards, /DV

github.com

 

TestRedisConfig.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@TestConfiguration
public class TestRedisConfig {
 
    @Value("${spring.redis.port}")
    private int port;
 
    private RedisServer redisServer;
 
    @PostConstruct
    public void startRedis() {
        try {
            redisServer = RedisServer.builder()
                    .port(port)
                    .setting("maxmemory 256M")  // 메모리 할당 직접 해주어야 함
                    .build();
            redisServer.start();
        } catch (Exception e) {
            e.printStackTrace();  // 테스트 레디스가 종료되지 않도록
        }
    }
 
    @PreDestroy
    public void stopRedis() {
        redisServer.stop();
    }
}
 
cs

테스트 환경에서 사용할 설정 파일이기 때문에 @Configuration 대신 @TestConfiguration 을 사용했습니다.

 

ozimov:embedded-redis메모리 할당이 자동으로 되지 않습니다. 이러한 문제 때문에, setting("maxmemory") 를 해주지 않으면 전체 테스트 수행 시 중간 부분쯤부터 에러가 발생합니다. 힙 메모리 플래그가 설정되지 않았기 때문입니다.

따라서 .setting("maxmemory 256M") 로 힙 메모리를 할당해주었습니다. 모카콩에서는 256M로 설정해주었지만, 128M, 10M로 설정해도 문제는 발생하지 않을 것이라 생각됩니다.

 

스프링 컨텍스트가 재수행될 때, embedded-redis 또한 자동으로 종료 후 재생성하는 작업을 거칩니다. 하지만, 테스트 임베디드 redis 재생성 작업을 스프링 환경에서는 설정하는 것이 (제가 알기론) 불가능합니다. 따라서 try-catch로 스프링 컨텍스트가 재생성되고 수행될 때, embedded-redis 재생성이 불가능하다면 기존 embedded-redis 서버에 자동으로 연동되도록 하였습니다. e.printStackTrace()는 없어도 무방하며, try-catch만 존재하면 됩니다.

 

테스트 클래스마다 추가돼야 하는 부분

1
2
3
4
5
6
7
8
9
10
11
@Autowired
private RedisTemplate<String, Object> redisTemplate;
 
@BeforeEach
void setUp() {
    /* Redis Cache 제거 */
    Objects.requireNonNull(redisTemplate.getConnectionFactory())
            .getConnection()
            .flushAll();
}
 
cs

테스트 간에 캐시된 데이터로 인해 격리가 되지 않을 수 있습니다. 따라서, 매 테스트 클래스마다 @BeforeEach 를 이용하여 캐시된 데이터를 삭제하는 작업을 수행해주었습니다.


EC2 접속하여 Redis 캐시 값 조회 및 API 응답시간

ssh 명령어로 EC2에 접속한 후, 아래 명령어를 입력해줍시다. 참고로 EC2에 redis와 redis-cli가 설치돼있어야 합니다. 만약 설치돼있지 않다면, 아래 명령어를 입력하면 설치 명령어를 안내해줄 것입니다. 그 명령어로 설치하면 됩니다.

redis-cli -h ${REDIS_HOST} -p ${REDIS_PORT}
 

명령어를 입력하고 위 사진처럼 엔드포인트가 뜨면서 커서가 깜빡거리면 접속에 성공한 것입니다.

이제 아래 명령어로 레디스에 저장된 데이터 키 값들을 조회해봅시다.

scan 0

keys * 명령어를 수행해도 되지만, 비교적 더 빠른 scan 0 명령어로 전체 키 목록을 조회하겠습니다. scan 0 명령어가 keys * 명령어보다 빠른 이유는 https://www.youtube.com/watch?v=92NizoBL4uA 링크에 해당되는 유튜브 영상에서 볼 수 있습니다. (워낙 유명한 유튜브 영상이니 이미 보신 분들도 꽤 많을 듯합니다.)

 

 

현재 존재하는 캐시 데이터

현재는 redis cache된 값이 별로 없습니다.

 

이제 로그인하여 accessToken을 발급받고, 특정 카페에 미리보기 API를 호출해보도록 하겠습니다.

캐시 데이터 2건 증가

redis-cli로 데이터를 조회해보니 제 계정에 해당되는 kth990303@naver.com의 accessTokenCache, 그리고 1317526944번에 해당되는 카페의 cafePreviewCache 데이터가 성공적으로 생성됨을 확인할 수 있습니다!

 

해당 키의 ttl을 확인하고 싶다면 ttl ${키 이름} 명령어를 입력해주면 됩니다.

단위는 초 단위입니다. 해당 카페의 미리보기 조회 API 반환값 캐시데이터는 42619초의 ttl을 가지고 있는 것을 확인할 수 있습니다.

 

 

최종적으로는 1초 넘게 소요되던 카페 미리보기 API를 약 0.05초 내로 수행되게 개선할 수 있었습니다!

카페 미리보기 조회 API 응답

1254ms -> 58ms로 API 성능이 훨씬 개선됐음을 확인할 수 있습니다.

 

캐시 적중률

또, Cache Hit Ratio도 대체로 100%를 유지하는 좋은 성과를 낼 수 있었습니다. 앞으로 데이터가 더욱 많아져서, TTL을 조금 짧게 가져가더라도 충분한 hit ratio가 나올 수 있기를 기원합니다.

 

반응형