catsridingCATSRIDING|OCEANWAVES
Dev

Redis 캐시를 이용한 애플리케이션 성능 향상하기

jynn@catsriding.com
Feb 06, 2024
Published byJynn
999
Redis 캐시를 이용한 애플리케이션 성능 향상하기

Optimize Spring Boot Application with Redis Cache

데이터 송수신의 효율성을 높이기 위해 중요한 역할을 하는 기술 중 하나가 바로 Caching입니다. 캐시는 반복적인 데이터 요청에서 발생하는 시간 지연을 줄이고, 이를 통해 전체 시스템의 응답 시간을 단축시키는 역할을 합니다.

Redis는 데이터를 디스크가 아닌 메모리에 저장하기 때문에 빠른 읽기와 쓰기 속도를 제공하며, 네트워크 비용에 의한 지연 시간을 줄일 수 있어 고성능 및 고가용성 애플리케이션을 위한 합리적인 해결책을 제공합니다.

이번 포스트에서는 Spring Boot 애플리케이션에 Redis를 캐시 저장소로서 활용하는 방법에 대해 알아봅니다.

Prerequisite

간단한 데이터베이스 요청을 활용하여 캐싱을 적용해보고, 이를 통해 캐시가 애플리케이션 성능에 어떤 영향을 미치는지 확인해볼 예정입니다. 이 과정을 살펴보기 위해 간단한 데이터베이스 테이블을 준비하였으며, 다음과 같은 환경에서 진행됩니다:

POSTS
+-----------+--------+---+--------------+
|Column Name|Type    |Key|Extra         |
+-----------+--------+---+--------------+
|id         |bigint  |PRI|auto_increment|
|pathnam    |varchar |   |              |
|title      |varchar |   |              |
|preface    |varchar |   |              |
|createdAt  |datetime|   |              |
+-----------+--------+---+-------------+
  • Java 17
  • Spring Boot 3.2.2
  • JPA
  • Gradle 8
  • MySQL 8
  • Redis 7

Implementing Redis Cache in Application

Spring Boot 프로젝트와 Redis 서버가 모두 준비되었다면, 이제 애플리케이션에서 캐시를 활성화하고, 그 저장소로 Redis를 사용해보겠습니다. 이를 통해, 변동성이 낮은 데이터를 캐시에 저장함으로써 데이터베이스의 작업 부하를 줄이고 응답 시간을 단축시킬 수 있습니다. 결과적으로 애플리케이션의 성능 향상을 이룰 수 있게 될 것입니다.

Adding Dependencies

먼저, Redis 활용에 필요한 의존성을 프로젝트에 추가합니다.  build.gradle을 열고 Spring Data Redis 의존성을 추가합니다.

build.gradle
dependencies {
    // Spring Data Redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

Setting Up Redis Client

다음은, Redis 서버를 애플리케이션에 연결하는 과정을 진행합니다. 아래와 같이  application.yml 파일을 열어 Redis 관련 속성들을 추가합니다:

application.yml
spring:
  data:
    redis:
      host: localhost
      port: 6379
      password: redis1234
      timeout: 3000
      connect-timeout: 3000
      lettuce:
        shutdown-timeout: 10000
      repositories:
        enabled: false
  • redis.host: Redis 서버의 호스트를 설정합니다. 여기서는 로컬 환경에서 실행되는 Redis 서버를 가리키기 위해 localhost를 설정하였습니다.
  • redis.port: Redis 서버의 연결 포트를 설정합니다. 일반적으로 Redis는 6379 포트를 사용합니다.
  • redis.password: Redis 서버에 접속하기 위한 패스워드를 입력합니다.
  • redis.timeout: Redis 클라이언트와 서버 간의 통신에 걸리는 최대 시간을 밀리초(ms) 단위로 설정합니다.
  • redis.connect-timeout: Redis 서버에 초기 연결 시도에 대한 타임아웃을 밀리초(ms) 단위로 설정합니다.
  • lettuce.shutdown-timeout: Lettuce 클라이언트를 종료할 때까지 최대 대기 시간을 밀리초(ms) 단위로 설정합니다.
  • repositories.enabled: Spring Data Redis repository 지원을 활성화할지 여부를 설정합니다. 여기에서는 false로 설정하여 비활성화하였습니다.

위 속성을 토대로 Redis 사용을 위한 Redis 클라이언트를 구성합니다. Java의 Redis 클라이언트는 Lettuce와 Redis에서 공식적으로 지원하는 Jedis가 있습니다. Spring Data Redis는 Lettuce를 기본 연결 팩토리로 사용하므로, 특별한 설정을 하지 않아도 Lettuce 기반의 Redis 연결이 가능합니다.

Lettuce를 사용하는 이유는 이 라이브러리가 비동기적이면서도 Thread-Safe한 클라이언트이기 때문입니다. 이는 Redis 연결을 관리하는데 있어 높은 효율성을 제공하며, 최신 Redis 기능에 대한 지원도 포괄적으로 갖추고 있습니다.

RedisConfig.java
@Slf4j
@Configuration
@RequiredArgsConstructor
public class RedisConfig {

    private final RedisProperties redisProperties;

    @Bean
    public LettuceConnectionFactory redisConnectionFactory(LettuceClientConfiguration lettuceClientConfiguration) {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(redisProperties.getHost());
        redisStandaloneConfiguration.setPort(redisProperties.getPort());
        redisStandaloneConfiguration.setPassword(redisProperties.getPassword());

        return new LettuceConnectionFactory(redisStandaloneConfiguration, lettuceClientConfiguration);
    }

    @Bean
    public LettuceClientConfiguration lettuceClientConfiguration() {
        return LettuceClientConfiguration.builder()
                .commandTimeout(redisProperties.getTimeout())
                .shutdownTimeout(redisProperties.getLettuce().getShutdownTimeout())
                .build();
    }
}
  • RedisProperties: Redis 설정을 담당하는 프로퍼티들을 모아놓은 클래스입니다. application.yml에 정의된 값들을 담아서 프로그램에서 사용합니다. 여기에는 레디스 서버의 호스트, 포트, 타임아웃 등 필요한 설정들이 포함됩니다.
  • redisConnectionFactory(): Lettuce는 Redis를 위한 고성능 클라이언트입니다. 이를 활용하기 위해 LettuceConnectionFactory를 스프링 빈으로 등록합니다. RedisStandaloneConfigurationLettuceClientConfiguration을 파라미터로 받아 레디스 연결을 위한 설정이 완료되며, 이를 통해서 실제 레디스 서버와의 연결을 생성합니다.
  • RedisStandaloneConfiguration: Redis를 단일 서버 모드로 운영할 때 필요한 기본 설정을 담당하는 객체합니다.
    • setHostName(): Redis 서버의 호스트명을 설정합니다.
    • setPort(): Redis 서버가 작동하는 포트를 설정합니다.
    • setPassword(): Redis 서버에 연결할 때 필요한 암호를 설정합니다.
  • LettuceClientConfiguration: Lettuce 클라이언트의 동작을 설정하는 구성 객체입니다.
    • commandTimeout(): Redis 서버와 통신에서 각 명령이 완료될 때까지 대기하는 최대 시간을 설정합니다. 이 시간 이후에도 응답이 오지 않을 경우, 클라이언트는 timeout 예외를 발생시킵니다.
    • shutdownTimeout(): Redis 클라이언트가 종료되는 데 필요한 최대 시간을 설정합니다. 클라이언트 종료 요청 후 이 시간이 초과되면 클라이언트는 강제로 종료됩니다.

Configuring CacheManager

Spring Boot 애플리케이션에서 캐시 메커니즘을 활용하려면, 캐시 관련 처리를 담당하는 CacheManager를 설정해야 합니다.

RedisCacheConfig.java
@Slf4j
@Configuration
@EnableCaching
@RequiredArgsConstructor
public class RedisCacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisSerializer<String> redisStringSerializer = new StringRedisSerializer();
        GenericJackson2JsonRedisSerializer arraysSerializer = createValueSerializer();

        RedisCacheConfiguration postsCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .prefixCacheNameWith("caching-")
                .serializeKeysWith(SerializationPair.fromSerializer(redisStringSerializer))
                .serializeValuesWith(SerializationPair.fromSerializer(arraysSerializer))
                .entryTtl(Duration.ofMinutes(60));

        return RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .withCacheConfiguration("posts", postsCacheConfiguration)
                .transactionAware()
                .build();
    }

    private static GenericJackson2JsonRedisSerializer createValueSerializer() {
        BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
                .allowIfSubType(Object.class)
                .build();
        ObjectMapper objectMapper = new ObjectMapper()
                .activateDefaultTyping(ptv, DefaultTyping.NON_FINAL, As.WRAPPER_ARRAY)
                .registerModule(new JavaTimeModule());
        return new GenericJackson2JsonRedisSerializer(objectMapper);
    }
}
  • @EnableCaching: Spring Framework의 캐시 지원을 활성화합니다. 이를 통해 스프링은 캐싱 관련 작업을 단순화하는 캐싱 추상화를 제공합니다.
  • cacheManager(): Redis 캐시 서버가 적용된 CacheManager를 Spring의 Bean으로 등록합니다.
    • RedisCacheConfiguration: 캐시의 동작 방식을 정의합니다.
      • prefixCacheNameWith(): 생성된 모든 캐시 이름을 지정된 접두사로 시작하게 할 수 있습니다. 여기서는 각 캐시 이름이 caching-으로 시작하도록 설정했습니다.
      • serializeKeysWith(): 캐시 키(Key)의 직렬화 방식을 설정합니다.
      • serializeValuesWith(): 캐시 값(Value)의 직렬화 방식을 설정합니다.
      • entryTtl(): Time-to-Live(TTL)을 설정합니다. 설정된 시간이 지나면, 해당 캐시 항목은 자동으로 만료(삭제) 됩니다. 여기서는 각 캐시 항목이 60분 동안 유지되게 설정하였습니다.
      • fromConnectionFactory(): RedisConnectionFactory 객체를 매개변수로 받아, 이를 이용하여 RedisCacheManagerBuilder의 인스턴스를 초기화하는 역할을 합니다. 이는 RedisCacheManager가 Redis와 소통하기 위한 통신 채널을 설정한 것이라고 이해하면 좋습니다.
    • RedisCacheManagerBuilder: RedisCacheManager의 인스턴스를 효과적으로 구성 및 생성하는 데 필요한 메서드를 제공하는 빌더 클래스입니다.
      • withCacheConfiguration(): 캐시 이름과 그에 해당하는 RedisCacheConfiguration을 취합하여 특정 캐시에 대한 개별적인 설정을 적용합니다.
      • transactionAware(): 이 메소드는 생성될 RedisCacheManager가 트랜잭션을 인식하도록 설정합니다. 만일 트랜잭션 안에서 캐시 변화가 일어날 경우, 해당 변화는 트랜잭션의 커밋이 이루어질 때까지 실제 캐시에 반영되지 않습니다. 이는 트랜잭션의 원자성을 보장하기 위한 메소드입니다.
  • createValueSerializer(): 캐시 값(Value)을 직렬화 하기 위한 RedisSerializer 객체를 구현합니다. 캐시 마다 직렬화 구현체가 다를 수 있기 때문에 사용자 정의 함수로 분리하였습니다. 직렬화 구현체에 대해서는 Spring 캐시 Redis 직렬화 이해하기에서 다루고 있습니다.
    • BasicPolymorphicTypeValidator: 지정한 객체의 서브 클래스에 대한 직렬화 허용 여부를 결정합니다. 여기는 모든 Object 서브 클래스를 허용하였습니다.
    • ObjectMapper: Java 객체와 JSON 간의 직렬화 & 역직렬화를 담당하는 핵심 클래스입니다.
      • activateDefaultTyping(): 실행 시점에 각 객체의 실제 타입 정보를 포함하는 기능을 활성화합니다. 이를 통해 역직렬화 시 원래의 객체 타입을 복원할 수 있습니다.
      • registerModule(): Java 8의 날짜 및 시간 API를 제대로 처리하기 위한 JavaTimeModule 모듈을 ObjectMapper에 등록하였습니다.

Utilizing Caching in Application

이제까지 구성한 Redis 캐시를 애플리케이션에 적용해봅니다.

PostService.java
@Cacheable(value = "posts", cacheManager = "cacheManager", key = "#cond.toString()")
public PostItems process(PostItemsCond cond) {
    List<PostResult> postResults = postReader.read(cond);
    boolean hasNext = postReader.hasNext(cond, postResults);
    return PostItems.bindResult(postResults, hasNext);
}

@Cacheable 어노테이션을 메서드에 적용하면 메서드의 실행 결과가 캐싱되며, 클래스에 적용하게 되면 해당 클래스의 모든 public 메서드가 캐싱의 대상이 됩니다.

Spring의 캐싱 전략은 Look-Aside 방식입니다. 이 전략은 먼저 캐시를 확인하고, 특정 키에 해당하는 값이 존재하는 경우 로직 실행 없이 캐시된 값을 바로 반환합니다. 만약 캐시에 해당 키와 연관된 값이 없다면, 메서드를 실행한 다음 그 결과를 캐시에 저장하고 결과를 반환합니다.

아래는 @Cacheable 어노테이션의 주요 인자들입니다:

  • value: 캐시의 이름을 명시합니다. @Cacheable 어노테이션이 적용된 메서드의 결과값이 저장될 캐시를 가리키며, RedisCacheConfiguration을 구성하는 과정에서 설정하였습니다.
  • cacheManager : 사용할 CacheManager의 이름을 지정합니다. 여기서는 각각의 캐시 마다 특정 RedisCacheConfiguration을 두고, 이를 하나의 CacheManager 내부에서 관리하는 단일 Bean 형태로 구성하였지만, CacheManager를 여러개 두는 방식에서는 이 인자를 통해 적용할 CacheManager를 지정할 수 있습니다.
  • key: 캐시 키(Key)값의 패턴을 정의합니다. 이를 기반으로 캐시 데이터를 식별합니다.

메서드 파라미터 인자로 넘어온 객체를 캐시의 키로 활용하는 경우에는, toString()을 직접 구현하는 것이 편리합니다.

PostItemsCond.java
public class PostItemsCond {

    private Long postId;
    private Integer pageSize;

    @Override
    public String toString() {
        return "postId=" + postId + "&pageSize=" + pageSize;
    }
}

이렇게 toString()을 오버라이드하면, 동일한 상태를 가진 객체는 동일한 문자열을 반환하기 때문에 캐시의 키 역할을 수행할 수 있습니다.

Handling Exceptions in Spring Cache

지금까지 Redis를 캐시 저장소로 사용하고, 이를 애플리케이션에 적용하는 방법에 대해 살펴보았습니다. 그러나 외부 서비스나 시스템에 의지하는 서비스에서는 연결 실패에 대비한 Fallback 메커니즘을 구현하는 것을 반드시 고려해야 합니다.

Redis 서버와의 연결이 끊긴 경우, RedisConnectionException이 발생하고 예외 계층으로 바로 넘어가 버립니다. 이런 상황에서는 캐시 서버를 활용하지 않고 기존 로직이 실행될 수 있도록 예외를 처리해야 합니다.

이를 위해, 캐시 작업 도중에 발생할 수 있는 다양한 예외들을 처리하는 CacheErrorHandler 인터페이스를 구현합니다.

CacheErrorHandlerImpl.java
@Slf4j
@Configuration
@RequiredArgsConstructor
public class CacheErrorHandlerImpl implements CacheErrorHandler {

    @Override
    public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
        log.info("Ignoring cache exception: " + exception.getMessage());
    }

    ...
}
  • handleCacheGetError(): 캐시에서 값을 가져오는 도중 예외가 발생했을 때 처리해주는 로직을 정의합니다. 여기서는 예외 로그만 기록하고 별도의 처리를 하지 않음으로써 기존 로직이 실행되도록 하였습니다.

다음은 이 예외 핸들러를 Spring 캐시 시스템에 등록하기 위해 CachingConfigurer 인터페이스를 구현합니다.

CacheConfigurerImpl.java
@Slf4j
@Configuration
@RequiredArgsConstructor
public class CacheConfigurerImpl implements CachingConfigurer {

    private final CacheErrorHandler cacheErrorHandler;

    @Override
    public CacheErrorHandler errorHandler() {
        return cacheErrorHandler;
    }

    ...

}
  • errorHandler(): 활성화된 캐싱 시스템에 사용할 CacheErrorHandler를 반환하는 메서드입니다.

마지막으로 Redis 클라이언트 설정을 업데이트하여 연결이 끊긴 경우에 어떻게 대응할지 결정합니다. 이때 몇 가지 옵션을 설정함으로써 Redis 서버와의 연결이 유실되었을 때의 동작을 조절할 수 있습니다.

RedisConfig.java
@Bean
public LettuceClientConfiguration lettuceClientConfiguration() {
    return LettuceClientConfiguration.builder()
            .commandTimeout(redisProperties.getTimeout())
            .shutdownTimeout(redisProperties.getLettuce().getShutdownTimeout())
+           .clientOptions(clientOptions())
            .build();
}

@Bean
public ClientOptions clientOptions() {
    return ClientOptions.builder()
            .disconnectedBehavior(DisconnectedBehavior.REJECT_COMMANDS)
            .autoReconnect(true)
            .socketOptions(SocketOptions.builder().connectTimeout(Duration.ofSeconds(3)).build())
            .build();
}

ClientOptions 클래스는 Lettuce 라이브러리를 사용한 Redis 클라이언트의 동작을 설정하는 데 사용됩니다. 이 클래스의 인스턴스는 다양한 옵션들을 설정하는 메서드를 제공하므로, 필요에 따라 사용자 정의 동작을 설정할 수 있습니다.

  • disconnectedBehavior() : Redis 서버와의 연결이 끊어진 경우, 클라이언트가 새로운 명령을 어떻게 처리할지 설정합니다. 여기서는 REJECT_COMMANDS 옵션을 사용하여 연결이 끊어진 상태에서 수신되는 명령을 거부하도록 설정해주었습니다.
  • autoReconnect() : Redis 클라이언트가 Redis 서버와의 연결이 끊겼을 때 자동으로 연결을 복구하려고 시도하게 합니다. 이는 일반적으로 비동기적으로 백그라운드에서 수행되며, 일정 간격으로 연결 복구를 시도합니다.

이제 Redis 서버와의 연결 문제로 인한 중단 없이 안정적으로 동작하게 됩니다. 이처럼 캐싱 서비스는 사용자 경험을 크게 향상시키지만, 반면에 연결 문제 등으로 인한 영향을 최소화하려면 적절한 예외 처리 방법이 반드시 필요합니다.

Playgrounds

Spring Boot 애플리케이션을 구동하여 @Cacheable 어노테이션이 추가된 메서드를 실행해서 Redis 캐시 서버가 원활하게 동작하는지 확인합니다. 해당 메소드가 처음 실행될 때, 먼저 데이터베이스에서 레코드를 조회하고 이를 반환하기 전에 캐시 서버에 해당 결과를 저장합니다.

Redis에서 제공하는 GUI 툴인 RedisInsight를 사용하면 메모리에 올라간 데이터를 시각적으로 확인하고 모니터링할 수 있습니다.

optimize-spring-boot-application-with-redis-cache_00.png

동일한 매개변수로 메서드를 다시 호출해보면 이번에는 데이터베이스 쿼리를 실행하지 않고, 즉시 Redis 캐시로부터 직접 응답을 받게 됩니다. 복잡한 쿼리 실행이나 대규모 데이터 처리같은 시간이 많이 걸리는 작업에서, 이런 캐시의 효과는 더욱 두드러질 것입니다.

  • Spring
  • Architecture
  • Redis