Redis를 캐시로만 쓰고 있다면, Spring Data Redis가 제공하는 다양한 기능을 놓치고 있는 것일 수 있습니다. RedisTemplate의 직렬화 전략부터 Pub/Sub, Lua Script까지 어디까지 활용할 수 있을까요?

Spring Data Redis란

Spring Data Redis는 Redis를 스프링 애플리케이션에서 쉽게 사용할 수 있도록 추상화한 모듈입니다. 크게 두 가지 접근 방식을 제공합니다.

  • RedisTemplate: 저수준 API로 Redis의 모든 자료구조(String, Hash, List, Set, ZSet)를 직접 다룹니다.
  • Repository: @RedisHashCrudRepository를 결합해 도메인 객체 중심으로 CRUD를 수행합니다.

의존성과 기본 설정

JAVA
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
YAML
# application.yml
spring:
  data:
    redis:
      host: localhost
      port: 6379
      password: ""
      lettuce:
        pool:
          max-active: 8
          max-idle: 8
          min-idle: 2

Lettuce가 기본 클라이언트입니다. 커넥션 풀을 사용하려면 commons-pool2 의존성을 추가해야 합니다.

Lettuce는 Netty 기반 비동기 클라이언트로, 하나의 커넥션을 여러 스레드가 공유하는 모델입니다. 대부분의 경우 커넥션 풀 없이도 충분하지만, MULTI/EXEC 트랜잭션이나 블로킹 명령(BLPOP 등)을 사용할 때는 전용 커넥션이 필요하므로 풀 설정이 필요합니다. Lettuce, Jedis, Redisson의 상세 비교는 Redis 클라이언트 비교 — Lettuce, Jedis, Redisson의 선택 기준 글을 참고하세요.

직렬화 전략

RedisTemplate의 기본 직렬화는 JdkSerializationRedisSerializer입니다. 이 방식은 바이너리 데이터를 저장하기 때문에 redis-cli에서 값을 확인할 수 없고, Java 클래스 변경에 취약합니다.

JAVA
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(
            RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        // 키는 문자열로
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());

값 직렬화는 GenericJackson2JsonRedisSerializer를 사용하여 JSON 형태로 저장하면 redis-cli에서도 가독성이 좋습니다.

JAVA
        // 값은 JSON으로
        ObjectMapper mapper = new ObjectMapper();
        mapper.activateDefaultTyping(
            mapper.getPolymorphicTypeValidator(),
            ObjectMapper.DefaultTyping.NON_FINAL
        );
        GenericJackson2JsonRedisSerializer jsonSerializer =
            new GenericJackson2JsonRedisSerializer(mapper);

        template.setValueSerializer(jsonSerializer);
        template.setHashValueSerializer(jsonSerializer);

        return template;
    }
}
Serializer특징용도
StringRedisSerializer문자열 그대로 저장키, 단순 값
Jackson2JsonRedisSerializer특정 타입 고정 JSON타입이 확정된 값
GenericJackson2JsonRedisSerializer타입 정보 포함 JSON다양한 타입 저장
JdkSerializationRedisSerializer바이너리(기본값)권장하지 않음

RedisTemplate으로 자료구조 다루기

String

JAVA
@Service
@RequiredArgsConstructor
public class TokenService {
    private final StringRedisTemplate redisTemplate;

    public void saveRefreshToken(String userId, String token) {
        redisTemplate.opsForValue().set(
            "refresh:" + userId,
            token,
            Duration.ofDays(7)  // TTL 설정
        );
    }

    public String getRefreshToken(String userId) {
        return redisTemplate.opsForValue().get("refresh:" + userId);
    }
}

Hash

JAVA
// 사용자 세션 정보를 Hash로 관리
HashOperations<String, String, String> hashOps = redisTemplate.opsForHash();
hashOps.put("session:abc123", "userId", "42");
hashOps.put("session:abc123", "role", "ADMIN");

Map<String, String> session = hashOps.entries("session:abc123");

Sorted Set (ZSet)

JAVA
// 실시간 랭킹 보드
ZSetOperations<String, String> zSetOps = redisTemplate.opsForZSet();
zSetOps.add("ranking:daily", "player1", 1500);
zSetOps.add("ranking:daily", "player2", 2300);

// 상위 10명 조회 (점수 내림차순)
Set<String> top10 = zSetOps.reverseRange("ranking:daily", 0, 9);

@RedisHash와 Repository 방식

도메인 객체를 Redis Hash에 매핑하면 JPA처럼 Repository 패턴을 사용할 수 있습니다.

JAVA
@RedisHash(value = "cart", timeToLive = 3600)  // 1시간 TTL
@Getter
public class Cart {

    @Id
    private String id;           // cart:{id} 형태로 키 생성

    @Indexed                     // 보조 인덱스 생성
    private String userId;

    private List<CartItem> items;
    private LocalDateTime createdAt;
}

public interface CartRepository extends CrudRepository<Cart, String> {
    // @Indexed 필드에 대한 쿼리 메서드
    List<Cart> findByUserId(String userId);
}
JAVA
@Service
@RequiredArgsConstructor
public class CartService {
    private final CartRepository cartRepository;

    public Cart createCart(String userId, List<CartItem> items) {
        Cart cart = new Cart(UUID.randomUUID().toString(), userId, items);
        return cartRepository.save(cart);
    }

    public Optional<Cart> findCart(String cartId) {
        return cartRepository.findById(cartId);
    }
}

RedisTemplate vs Repository

기준RedisTemplateRepository
유연성모든 Redis 명령 사용 가능CRUD 중심
자료구조String, Hash, List, Set, ZSet 모두Hash만
쿼리직접 키 패턴 관리@Indexed 기반
적합한 경우캐시, 랭킹, 세션도메인 객체 저장

Pub/Sub — 메시지 발행과 구독

Redis의 Pub/Sub을 스프링에서 사용하는 방법입니다.

JAVA
// 메시지 발행
@Service
@RequiredArgsConstructor
public class NotificationPublisher {
    private final StringRedisTemplate redisTemplate;

    public void publish(String channel, String message) {
        redisTemplate.convertAndSend(channel, message);
    }
}
JAVA
// 메시지 수신
@Component
public class NotificationSubscriber implements MessageListener {

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String channel = new String(message.getChannel());
        String body = new String(message.getBody());
        log.info("채널 [{}]에서 메시지 수신: {}", channel, body);
    }
}
JAVA
@Configuration
public class RedisPubSubConfig {

    @Bean
    public RedisMessageListenerContainer container(
            RedisConnectionFactory connectionFactory,
            NotificationSubscriber subscriber) {
        RedisMessageListenerContainer container =
            new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(
            subscriber,
            new PatternTopic("notification:*")  // 패턴 구독
        );
        return container;
    }
}

주의할 점은 Redis Pub/Sub은 메시지를 영속화하지 않습니다. 구독자가 없으면 메시지는 유실됩니다. 메시지 보장이 필요하다면 Redis Streams나 Kafka를 고려해야 합니다.

Lua Script — 원자적 연산

여러 Redis 명령을 하나의 원자적 연산으로 실행해야 할 때 Lua Script를 사용합니다. 대표적인 예가 Rate Limiting입니다.

JAVA
@Component
public class RateLimiter {

    // Lua 스크립트: 요청 횟수 증가 + TTL 설정을 원자적으로 수행
    private static final String SCRIPT = """
        local current = redis.call('INCR', KEYS[1])
        if current == 1 then
            redis.call('EXPIRE', KEYS[1], ARGV[1])
        end
        return current
        """;

    private final StringRedisTemplate redisTemplate;
    private final RedisScript<Long> rateLimitScript;

생성자에서 스크립트를 초기화하고, execute()로 원자적으로 실행하여 요청 횟수를 판단합니다.

JAVA
    public RateLimiter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.rateLimitScript = RedisScript.of(SCRIPT, Long.class);
    }

    public boolean isAllowed(String clientId, int maxRequests, int windowSeconds) {
        Long count = redisTemplate.execute(
            rateLimitScript,
            List.of("ratelimit:" + clientId),    // KEYS
            String.valueOf(windowSeconds)         // ARGV
        );
        return count != null && count <= maxRequests;
    }
}

Lua Script가 실행되는 동안 Redis는 다른 명령을 처리하지 않으므로, INCREXPIRE사이에 경쟁 조건이 발생하지 않습니다.

실무에서 주의할 점

1. 키 네이밍 컨벤션

PLAINTEXT
서비스명:도메인:식별자
예: myapp:session:abc123, myapp:cache:user:42

콜론(:)으로 계층을 구분하면 redis-cli에서 관리하기 편합니다.

2. TTL 설정

모든 키에 TTL을 설정하는 것을 습관화해야 합니다. TTL 없는 키가 쌓이면 메모리가 고갈됩니다.

3. 대량 조회 시 SCAN 사용

KEYS * 명령은 Redis를 블로킹합니다. 대량 키 조회에는 반드시 SCAN을 사용합니다.

JAVA
// SCAN으로 키 조회
ScanOptions options = ScanOptions.scanOptions()
    .match("cache:user:*")
    .count(100)
    .build();

try (Cursor<byte[]> cursor =
        redisTemplate.getConnectionFactory()
            .getConnection().scan(options)) {
    while (cursor.hasNext()) {
        String key = new String(cursor.next());
        // 키 처리
    }
}

4. Pipeline으로 성능 향상

여러 명령을 한 번에 보내서 네트워크 왕복을 줄입니다.

JAVA
List<Object> results = redisTemplate.executePipelined(
    (RedisCallback<Object>) connection -> {
        for (int i = 0; i < 1000; i++) {
            connection.stringCommands().set(
                ("key:" + i).getBytes(),
                ("value:" + i).getBytes()
            );
        }
        return null;
    }
);

주의할 점

1. 기본 JdkSerializationRedisSerializer를 운영 환경에서 사용하면 클래스 변경 시 역직렬화가 실패한다

RedisTemplate의 기본 직렬화는 Java 직렬화를 사용합니다. 클래스에 필드를 추가하거나 삭제하면 이미 저장된 데이터를 읽을 수 없어 역직렬화 에러가 발생합니다. 반드시 GenericJackson2JsonRedisSerializerStringRedisSerializer로 변경하세요.

2. KEYS * 명령은 운영 Redis를 멈출 수 있다

KEYS 명령은 모든 키를 순회하기 때문에 키가 수십만 개 이상이면 Redis 서버가 수 초간 블로킹됩니다. 이 동안 모든 클라이언트의 요청이 대기하게 되어 서비스 장애로 이어집니다. 운영 환경에서는 반드시 SCAN 명령을 사용하세요.

3. TTL을 설정하지 않은 키가 쌓이면 Redis 메모리가 고갈된다

Redis는 인메모리 저장소이므로 TTL 없는 키가 계속 늘어나면 maxmemory에 도달합니다. maxmemory-policynoeviction이면 새로운 쓰기가 거부되고, allkeys-lru이면 중요한 데이터까지 임의로 삭제될 수 있습니다. 모든 키에 적절한 TTL을 설정하는 것을 습관화하세요.

정리

  • RedisTemplate 은 Redis의 모든 자료구조를 다룰 수 있는 저수준 API입니다. 직렬화 설정은 반드시 변경하세요.
  • @RedisHash + Repository 는 도메인 객체 중심의 CRUD에 적합합니다. @Indexed로 검색도 가능합니다.
  • Pub/Sub 은 간단한 실시간 메시지에 적합하지만, 메시지 유실 가능성이 있습니다.
  • Lua Script 는 여러 명령을 원자적으로 실행할 때 사용합니다. Rate Limiting, 재고 차감 등에 활용됩니다.
  • 실무에서는 키 네이밍, TTL 관리, SCAN 사용, Pipeline 최적화를 항상 신경 써야 합니다.
댓글 로딩 중...