Spring

Redis, 그리고 Spring 에서 Redis 활용

recent0 2024. 9. 7. 17:43

서론

Redis가 제공하는 기능 중 알고 있는 내용은 간단하게 정리하고, 직접 구현해보지 않았거나 두리뭉실하게 알고 있는 내용들에 대해 예제 코드를 작성하여, Redis에서 제공하는 기능들을 더 적극적으로 활용하고자 글을 쓰게 되었습니다.

Redis에 대해


Redis는 무엇인가?

Redis는 인메모리 기반의 key-value DB입니다. MySQL과 같은 관계형 데이터베이스와 비교했을 때, 관계가 존재하지 않고 단순히 key 하나에 value 하나를 저장할 수 있습니다.

 

또한 인메모리 기반이기 때문에 디스크에 쓰고 읽는 것이 아닌, 메모리에서 데이터를 처리하게 됩니다. 메모리에서 데이터를 처리함으로써 빠른 연산을 통해 Redis를 캐시 서버로 사용할 수 있습니다.

Redis 사용 시 주의점

1.  메모리 관리

Redis를 잘 사용하기 위한 1순위는 바로 메모리 관리입니다. 메모리 관리가 필요한 이유는 다음과 같습니다

  • MaxMemory 제한을 사용하더라도, 실제 크기보다 더 사용하는 경우가 존재
  • 메모리를 오버해서 사용하면 메모리 스왑이 발생할 수 있기 때문

2. 키 관리

Redis 잘 사용하기 위해서는 Key의 이름이나 길이 또한 중요한 역할을 합니다. Redis 공식 문서에서는 KeySpace 관련하여 키를 다음과 같이 관리하기를 권장하고 있습니다.

  • 1024 바이트 이상의 길이를 가진 키는 저장면에서나, 조회 시 비교 측면에서도 좋지 않기 때문에 권장하지 않습니다. 만약 비교가 필요하다면 해싱해서 사용하는 것을 권장합니다.
  • 반대로 너무 짧은 키보단 적절한 단어를 사용하는 게 가독성이나 비교측면에서 좋습니다. e.g) user:1000:follower" vs "u1000fw"

3. 시간복잡도 O(n) 명령어 

Redis는 싱글 스레도로 동작하고 있습니다. 그렇기 때문에 keys, flushAll과 같은 redis 전체를 scan하는 명령어를 사용하게 되면 성능이 제대로 나오지 않아 명령어 사용에 주의해야 합니다.

Spring에서 Redis 데이터를 다루는  방법


Redis의 키를 어떻게 다루는지 알았다면 Spring을 활용해 Redis 데이터를 다룰 수 있는 방법에 대해 소개해 드리겠습니다.

1. RedisTemplate

  • 소개할 방법 중 가장 Low Level에서 Redis 데이터에 접근하는 기본적인 방법입니다.
  • 해당 클래스를 통해 객체와 Redis 저장소의 데이터 직렬화/역직렬화를 수행합니다. 

RedisTemplate을 활용해 저장

  • 아래와 같이 Redis 환경 설정 후, key Type이 Sorted Set인 value에 데이터를 넣는 코드를 작성했습니다.
@Configuration
@EnableRedisRepositories(
        enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP
)
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;
    @Value("${spring.data.redis.port}")
    private int port;
    @Value("${spring.data.redis.database}")
    private int databaseNumber;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(host, port);
        redisStandaloneConfiguration.setDatabase(databaseNumber);
        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory());
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));
        return template;
    }
}

@Service
@RequiredArgsConstructor
public class RedisService {

    private final RedisTemplate<String, String> redisTemplate;
    private static final String RANKED_KEY = "rank";

    public void saveByRedisTemplate(ComplicatedDto dto) {
        redisTemplate.opsForZSet().add(RANKED_KEY, dto.getUsername(), dto.getAge());
    }
}

 

RedisTemplate을 활용해 저장한 결과 확인

  • 두 번 데이터를 저장해, 아래와 같이 정렬되어 있는 데이터 결과를 확인할 수 있습니다.

Sorted Set 저장된 데이터 확인

2. CrudRepository

  • redisKeyValueTemplate을 기반으로 RedisTemplate에 비해 고수준으로 데이터를 다룰 수 있습니다.
  • Hash 데이터 타입은 편리하게 사용할 수 있으나, Hash 데이터 타입 이외의 타입을 다루기엔 커스텀 설정이 필요합니다.

아래는 @RedisHash 어노테이션을 사용하여 CrudRepository 인터페이스로 Repository를 다루는 예시입니다.

CrudRepository를 활용한 저장, 조회

// CrudRepository 에서 다룰 데이터 정의
@Getter
@NoArgsConstructor
@AllArgsConstructor
@RedisHash(value = "refreshToken", timeToLive = 1000)
public class RefreshToken {

    @Id
    private Long id;

    @Indexed
    private String email;

}

// CrudRepository 인터페이스
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByEmail(String email);   
}

// RefreshToken 저장, 조회 서비스
@Service
@RequiredArgsConstructor
public class RedisService {

    private final RefreshTokenRepository refreshTokenRepository;

    private static final String FIXED_EMAIL = "test@email.com";
    private static final String RANKED_KEY = "rank";

    public void saveToken() {
        RefreshToken refreshToken = new RefreshToken(1L, FIXED_EMAIL);
        refreshTokenRepository.save(refreshToken);
    }

    public RefreshToken getToken() {
        return refreshTokenRepository.findByEmail(FIXED_EMAIL)
                .orElseThrow(IllegalStateException::new);
    }
}

CrudRepository을 활용해 저장한 결과 확인

  • Hash 데이터는 물론, 이후에 설명드릴 Secondary Index와 Key Evenet 관련 데이터가 같이 저장되어 있음을 확인할 수 있습니다. 

Hash 데이터 및 Secondary Index, Key Event 데이터

3. CacheManager, Cacheable(2차 캐시)

CacheManger란?

  • CacheManager는 Spring에서 Cache를 관리하기 위한 인터페이스입니다.
  • Redis를 통해 캐시를 이용하기 위해서는 CacheManager의 구현체인 RedisCacheManager를 활용합니다.

@Cacheable 역할?

Cacheable API를 확인해 보면 다음과 같이 설명하고 있습니다.

  • 메서드를 실행한 결과가 캐싱될 수 있음을 알려줍니다.
  • AOP를 기반으로 동작하기 때문에 @Cacheable이 적용된 메서드를 실행하기 전 캐싱된 데이터가 있는지 확인합니다.

Redis를 기반으로 하는 Cacheable 어노테이션을 설정했을 때, CacheInterceptor와 CacheAspectSupport를 통해 동작하는 것을 확인할 수 있었습니다.

CacheAspectSupport execute 메서드 내부 CachedValue 찾는 로직

CacheManager 활용한 예제 코드

@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .entryTtl(Duration.ofMinutes(10));

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

//Service
@Service
@RequiredArgsConstructor
public class RedisService { 

    private final ProductRepository productRepository;

    @Cacheable(value = "product", key = "#productId", cacheManager = "cacheManager")
    public Product getByCacheable(Long productId) {
        return productRepository.findById(productId)
                .orElse(null);
    }
}

//Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
}

//Entity 
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "product_id")
    private Long id;

    @Column
    private String name;
}

 

Cacheable를 활용한 조회 및 저장 결과

  • 아래와 같이 Redis에 데이터가 존재하지 않으면 Redis에 데이터를 저장하고 반환하는 것을 확인할 수 있었습니다

Redis의 데이터가 없는 경우 command
Cacheable 데이터 저장 예시

Redis 어떻게 사용할 수 있을까?


1. 다양한 자료구조 저장

상대적으로 다양한 자료구조

Redis와 같은 인메모리 DB인 Memcached와 비교했을 때 자주 나오는 차이점으로, Memcached 같은 경우는 String만 지원하는 것에 비해 Redis는 String, Set, Sorted Set, List, Hash 등 다양한 데이터 타입을 지원하고 있습니다.

2. Pub/Sub 기반 이벤트 처리

Pub/Sub 특징

Redis에는 Topic이라는 이름으로 메시지를 처리할 수 있습니다. Publisher는 Topic을 기반으로 이벤트를 발행하고 Channel에 게시했다면, Channel을 구독하고 있는 Subscriber는 이벤트를 받아 동작합니다.

https://medium.com/@saurabh.singh0829/redis-pub-sub-implementation-f3208e4625c7

Spring Boot Pub/Sub 예제 코드

1. Publisher, Subscriber 설정

  • 아래와 같이 Publisher는 String 타입으로 넘어오는 파라미터를 바탕으로 publish 하도록 구현했고, Subscriber는 받은 이벤트를 handleMessage라는 메서드를 통해 로그를 찍도록 구현했습니다.
@Service
@RequiredArgsConstructor
public class RedisMessagePublisher {

    private final RedisTemplate<String, String> redisTemplate;
    private final ChannelTopic channelTopic;

    public void publish(String message) {
        redisTemplate.convertAndSend(channelTopic.getTopic(), message);
    }
}


@Slf4j
@Service
@RequiredArgsConstructor
public class RedisMessageSubscriber {

    public void handleMessage(String message) {
        log.info("message => {}", message);
    }
}

2. Redis Config

  • 채널명은 String message 그리고 Message를 수행할 메서드명은 handleMessage로 지정해 Pub/Sub 설정을 해주었습니다.
@Configuration
@EnableRedisRepositories(
        enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP
)
public class RedisConfig {

    // 아래는 Message 처리를 위한 코드만 작성됨
    private static final String CHANNEL_NAME = "String message";

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer() {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(redisConnectionFactory());
        container.addMessageListener(listenerAdapter(), channelTopic());
        return container;
    }

    @Bean
    public MessageListenerAdapter listenerAdapter() {
        return new MessageListenerAdapter(new RedisMessageSubscriber(), "handleMessage");
    }

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(host, port);
        redisStandaloneConfiguration.setDatabase(databaseNumber);
        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }

    @Bean
    public ChannelTopic channelTopic() {
        return new ChannelTopic(CHANNEL_NAME);
    }
}

3. 결과 

  • 다음과 같이 Redis Pub/Sub을 이용해서 로그를 출력한 것을 확인할 수 있습니다

3. Redis Transaction

Redis Transaction 특징

  • Redis의 여러 명령을 하나의 그룹으로 그룹화를 시켜줍니다. 
  • 다만 롤백 기능은 성능에 큰 영향을 끼치기 때문에 제공되고 있지 않습니다.

Redis Transaction 기본 동작

Redis에서 트랜잭션을 아래와 같이 동작합니다.

  • MULTI 명령을 사용하여 트랜잭션을 시작합니다. 그리고 이후에 오는 모든 명령을 큐에 담습니다.
  • SET, GET, INCR 등과 같은 Redis에 실행하는 모든 명령이 큐에 담깁니다
  • 담긴 명령어를 실행하기 위해 EXEC 명령을 사용합니다. 이 명령은 다중 명령으로 그룹화된 모든 명령을 단일 연산으로 실행합니다.

Redis Transaction 사용 시 주의점

  • Redis 트랜잭션은 SQL의 트랜잭션처럼 롤백 기능을 지원하지 않습니다. 즉, EXEC가 호출된 이후에 발생한 에러는 트랜잭션을 중단시키지 않고, 나머지 명령어들은 그대로 실행됩니다.
  • Redis의 트랜잭션은 일반적인 데이터베이스의 트랜잭션과 달리 모든 명령이 성공적으로 완료되지 않으면 원상태로 되돌리는 기능은 없습니다. 단, WATCH를 이용해 조건을 걸어 중간에 변경된 값이 있는지 확인해 트랜잭션을 실패시킬 수 있습니다. (Optimistic Lock과 유사)

Redis Secondary Index, KeySapceNotifications


1. Secondary Index

Secondary Index란?

  • 기존에 key로만 찾던 데이터를, value에 저장되어 있는 값을 활용해 원본 데이터를 찾을 수 있도록 도와주는 기능입니다.
  • Redis에서 자체적으로 지원하는 기능은 아니지만, 인덱싱 할 데이터를 추가로 만들어 저장 후, 이를 활용해 효율적으로 데이터를 검색할 수 있습니다.

Secondary Index 예시 코드

저장할 데이터 및 서비스 로직

  • 편의성 때문에 @RedisHash와 CrudRepository를 활용하여 Secondary Index 생성 및 조회를 아래와 같이 구현했습니다.
  • 물론 RedisTemplate을 활용해 수동으로 Secondary Index를 구현할 수 있습니다. 
@Getter
@NoArgsConstructor
@AllArgsConstructor
@RedisHash(value = "refreshToken", timeToLive = 12)
public class RefreshToken {

    @Id
    private Long id;

    @Indexed
    private String email;

}

public interface RefreshTokenRepository extends CrudRepository<RefreshToken, Long> {

    Optional<RefreshToken> findByEmail(String email);

}

public class RedisService {
	
    public void saveToken() {
        RefreshToken refreshToken = new RefreshToken(1L, FIXED_EMAIL);
        refreshTokenRepository.save(refreshToken);
    }

    public RefreshToken getToken() {
        return refreshTokenRepository.findByEmail(FIXED_EMAIL)
                .orElseThrow(IllegalStateException::new);
    }
}

저장된 데이터 확인

  • Secondary Index와 관련된 데이터는 refreshToken:1:index, refreshToken:email:test@gmail.com를 키로 가지는 데이터입니다.

저장된 데이터들

로그 확인 및 조회 플로우

1. SINTER command를 통해서 Set에 대한 데이터를 조회합니다. 이때 데이터는 ID를 가지고 있습니다.

2. SET에서 조회한 ID 데이터를 토대로 Hash 데이터 타입의 데이터를 조회합니다. 

조회 Command

Seconday Index 사용 시 주의점

CrudRepository는 Secondary Index 데이터에 대한 TTL을 설정하고 있지 않습니다. 그렇기 때문에 다음과 같은 방법들을 활용하여 데이터를 관리할 수 있습니다.

  • Low Level에서 Secondary Index를 구현하여 Secondary Index 데이터에 대해 TTL을 설정
  • Redis의 KeySpaceNotifications 기능을 활용하여 만료된 데이터와 관련된 데이터 삭제

2. Pub/Sub 기반 기능 KeySpaceNotifiactions

앞서 Secondary Index를 사용한 경우, 원본 데이터를 찾기 위한 데이터에 대해 TTL이 따로 설정되어 있지 않았습니다. 이때 KeySpaceNotifications를 사용한다면 수월하게 개선할 수 있습니다.

KeySpaceNotifications란?

  • 실시간으로 Redis에 저장된 key, value의 변화를 감지할 수 있는 기능입니다.
  • Redis는 Pub/Sub을 통해 데이터에 대한 변화를 감지하고 있습니다.

이벤트 감지할 수 있는 몇 가지 예시는 다음과 같습니다.

  • 주어진 키에 대한 수행되는 모든 명령어
  • LPUSH 기능을 수행한 모든 키
  • 0번 데이터베이스에 존재하는 키가 만료된 경우

KeySpaceNotifications 동작 원리

Spring Data Redis에서는 KeyExpiration에 대한 이벤트를 감지하기 위해 아래와 같이 동작합니다.

 

일단 저장된 RefreshToken 데이터가 만료되었다고 가정하겠습니다.

RedisKeyValueAdapter onMessage 메서드 phantomKey 제거

  1. KeyExpirationMessage인지 먼저 검증합니다.
  2. 만료된 키에 Phantom KeySpace를 추가합니다.
  3. phantomKey를 토대로 phantomValue를 가져와 모두 삭제합니다.

RedisKeyValueAdapter onMessage 메서드 Index 관련 데이터 삭제

4. sRem(), removeKeyFromIndexes() 메서드를 통해 지워지지 않고 남아있는 secondary index 및 set 데이터를 삭제합니다.

KeySpaceNotifications로 개선

RedisConfig 설정 

  • KeySpaceNotification 기능을 사용하기 위해서 다음과 같이 Redis를 설정했습니다.
@Configuration
@EnableRedisRepositories(
        enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP,
        keyspaceNotificationsConfigParameter = "Ex"
)
public class RedisConfig {
	// 하단은 Redis 설정 필요
}

 

EnableRedisRepositories 어노테이션 내부의 속성들은 아래와 같은 의미를 가집니다.

EnableKeySpaceEvents

  • ON_STARTUP : 애플리케이션이 시작하자마자 Redis의 키 만료 이벤트를 수신하고 처리할 수 있도록 초기화합니다.
  • ON_DEMAND : 만료기간이 설정된 첫 번째 키가 Redis에 삽입될 때 초기화합니다
  • OFF : KeyExpirationEventMessageListener를 사용하지 않습니다.

KeyExpirationEventMessageListener 초기화 시점 Enum

 

KeySpaceNotificationConfigParameter

keySpaceNotificationsConfigParameter에서 문자들은 다음의 의미를 가집니다.

  • K: Keyspace notifications를 활성화합니다.
  • E: Keyevent notifications를 활성화합니다.
  • g: 일반적인 명령에 대한 알림을 활성화합니다. (예: set, del, expire 등)
  • $: 모든 키에서 발생하는 알림을 활성화합니다. (예: expired 이벤트)
  • x: 만료된 키에 대한 알림을 활성화합니다.
  • A: 모든 이벤트 알림을 활성화합니다. (전체 키스페이스와 키이벤트 알림을 모두 활성화)

기본적으로 Ex 파라미터를 default로 가지고, 빈 문자열로 주면 서버 설정을 따라갑니다.

keyspaceNotificationsConfigParameter 속성

결과 확인

  • 데이터가 만료되면 자연스럽게 다음과 같은 Command가 실행되어 데이터가 삭제되는 것을 확인할 수 있습니다.

phantom, index 데이터 삭제

 

 

이렇게 Redis에 대해 알아보고 Spring에서 Redis를 어떻게 사용할 수 있는지에 대해서 알아보았습니다. 틀린 부분이 있다면 댓글 남겨주시는대로 찾아보고 수정하겠습니다. 읽어주셔서 감사합니다.