Spring Data Redis Repository

Spring Data Redis Repository란?

Spring Data Redis는 JPA처럼 Repository 인터페이스로 Redis에 객체를 저장·조회할 수 있는 추상화를 제공합니다. @RedisHashCrudRepository만으로 Redis를 마치 NoSQL DB처럼 사용할 수 있어, 세션 저장소·캐시 오브젝트·실시간 랭킹 등에 매우 유용합니다.

1. @RedisHash 엔티티 정의

Redis에 저장할 객체는 @RedisHash로 선언합니다. JPA의 @Entity와 유사하지만, 내부적으로 Hash 자료구조로 직렬화됩니다.

@RedisHash(value = "session", timeToLive = 3600)
public class UserSession {

    @Id
    private String id;

    @Indexed
    private String userId;

    @Indexed
    private String deviceType;

    private String accessToken;
    private LocalDateTime createdAt;
    private Map<String, String> metadata;
}

핵심 포인트:

  • value: Redis key prefix (session:{id} 형태로 저장)
  • timeToLive: 초 단위 TTL, 만료 시 자동 삭제
  • @Id: Hash key의 suffix (UUID 자동생성 가능)
  • @Indexed: Secondary Index 생성 → 해당 필드로 검색 가능

2. Repository 인터페이스

JPA와 동일하게 CrudRepository를 상속하면 CRUD 메서드가 자동 제공됩니다.

public interface UserSessionRepository 
        extends CrudRepository<UserSession, String> {

    // @Indexed 필드 → 자동 쿼리 메서드 지원
    List<UserSession> findByUserId(String userId);
    List<UserSession> findByDeviceType(String deviceType);
    List<UserSession> findByUserIdAndDeviceType(
        String userId, String deviceType
    );
}

주의: findBy 쿼리 메서드는 @Indexed가 선언된 필드에서만 동작합니다. 인덱스 없는 필드로 검색하면 빈 결과가 반환됩니다.

3. Secondary Index 내부 동작 원리

@Indexed가 실제로 Redis에 어떤 구조를 만드는지 이해하는 것이 핵심입니다.

# 엔티티 저장 시 생성되는 Redis 키들:

# 1) Hash 본체
HGETALL session:abc-123
→ { "userId": "user1", "deviceType": "mobile", ... }

# 2) 전체 ID 추적 Set
SMEMBERS session
→ { "abc-123", "def-456", ... }

# 3) Secondary Index (Set)
SMEMBERS session:userId:user1
→ { "abc-123", "ghi-789" }

SMEMBERS session:deviceType:mobile
→ { "abc-123" }

@Indexed 필드마다 Set 타입 인덱스가 생성됩니다. findByUserId("user1")SMEMBERS session:userId:user1로 ID 목록을 가져온 뒤, 각 ID의 Hash를 조회하는 2단계 과정입니다.

동작 Redis 명령 시간복잡도
save(entity) HMSET + SADD (인덱스별) O(1) × 필드 수
findById(id) HGETALL O(N) N=필드 수
findByIndexed(val) SMEMBERS + HGETALL × M O(M) M=결과 수
deleteById(id) DEL + SREM (인덱스별) O(1) × 인덱스 수

4. TTL 전략: 정적 vs 동적

@RedisHash(timeToLive)은 모든 인스턴스에 동일한 TTL을 적용합니다. 엔티티별 동적 TTL이 필요하면 @TimeToLive를 사용합니다.

@RedisHash("otp")
public class OtpToken {

    @Id
    private String id;

    @Indexed
    private String phone;

    private String code;

    @TimeToLive
    private Long expiration;  // 초 단위, 엔티티마다 다르게 설정 가능

    public static OtpToken create(String phone, String code) {
        OtpToken otp = new OtpToken();
        otp.id = UUID.randomUUID().toString();
        otp.phone = phone;
        otp.code = code;
        otp.expiration = 180L;  // 3분
        return otp;
    }
}

TTL과 인덱스의 함정: Hash 키가 TTL로 만료되면 본체는 삭제되지만, Secondary Index Set에는 유령 ID가 남습니다. Spring Data Redis는 RedisKeyExpiredEvent를 수신하여 인덱스를 정리하지만, 이를 위해 Keyspace Notifications가 활성화되어야 합니다.

# redis.conf 또는 런타임 설정
CONFIG SET notify-keyspace-events Ex

# Spring Boot 설정으로도 가능
@Configuration
@EnableRedisRepositories(
    enableKeyspaceEvents = EnableKeyspaceEvents.ON_STARTUP
)
public class RedisConfig { }

5. 복합 조회와 한계: @Query 없음

Spring Data Redis Repository는 JPA와 달리 @Query 어노테이션을 지원하지 않습니다. 복잡한 조회가 필요하면 RedisTemplate과 조합하는 패턴을 사용합니다.

@Service
@RequiredArgsConstructor
public class SessionQueryService {

    private final UserSessionRepository repository;
    private final StringRedisTemplate redisTemplate;

    // Repository로 기본 조회
    public List<UserSession> getActiveSessions(String userId) {
        return repository.findByUserId(userId);
    }

    // RedisTemplate으로 교집합 조회 (AND 조건)
    public Set<String> findMobileUserSessions(String userId) {
        return redisTemplate.opsForSet().intersect(
            "session:userId:" + userId,
            "session:deviceType:mobile"
        );
    }

    // Sorted Set 기반 랭킹 (Repository로 불가능한 영역)
    public void recordScore(String gameId, String playerId, double score) {
        redisTemplate.opsForZSet().add(
            "leaderboard:" + gameId, playerId, score
        );
    }

    public Set<ZSetOperations.TypedTuple<String>> getTopPlayers(
            String gameId, int count) {
        return redisTemplate.opsForZSet()
            .reverseRangeWithScores("leaderboard:" + gameId, 0, count - 1);
    }
}

6. 참조 관계 매핑: @Reference

Redis Hash 간 참조 관계를 @Reference로 표현할 수 있습니다. 단, JPA의 Lazy Loading과 달리 항상 Eager로 로드됩니다.

@RedisHash("order")
public class Order {

    @Id
    private String id;

    @Reference
    private Product product;  // product:{product.id}를 별도 Hash로 저장

    @Indexed
    private String customerId;
    private int quantity;
}

@RedisHash("product")
public class Product {
    @Id
    private String id;
    private String name;
    private BigDecimal price;
}

주의사항: @Reference는 참조된 객체의 ID만 저장하고, 조회 시 별도 HGETALL을 발생시킵니다. N+1과 유사한 문제가 생길 수 있으므로 단순 참조에만 사용하고, 복잡한 관계는 비정규화(embedded)를 권장합니다.

7. Partial Update 패턴

PartialUpdate API를 사용하면 전체 객체를 다시 쓰지 않고 특정 필드만 갱신할 수 있습니다.

@Service
@RequiredArgsConstructor
public class SessionUpdateService {

    private final RedisKeyValueTemplate template;

    public void refreshToken(String sessionId, String newToken) {
        PartialUpdate<UserSession> update = 
            new PartialUpdate<>(sessionId, UserSession.class)
                .set("accessToken", newToken)
                .set("createdAt", LocalDateTime.now());

        template.update(update);
    }

    public void addMetadata(String sessionId, String key, String value) {
        PartialUpdate<UserSession> update =
            new PartialUpdate<>(sessionId, UserSession.class)
                .set("metadata." + key, value);

        template.update(update);
    }

    // TTL 갱신도 가능
    public void extendSession(String sessionId, long additionalSeconds) {
        PartialUpdate<UserSession> update =
            new PartialUpdate<>(sessionId, UserSession.class)
                .refreshTtl(true);

        template.update(update);
    }
}

8. RedisTemplate 직렬화 설정

기본 JdkSerializationRedisSerializer는 바이너리로 저장되어 redis-cli에서 읽을 수 없습니다. JSON 직렬화로 변경하는 것이 운영에 유리합니다.

@Configuration
public class RedisSerializerConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(
            RedisConnectionFactory factory) {

        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        // Key: String
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());

        // Value: JSON
        ObjectMapper mapper = new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

        GenericJackson2JsonRedisSerializer jsonSerializer =
            new GenericJackson2JsonRedisSerializer(mapper);

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

        return template;
    }

    // Repository용 커스텀 매핑 (선택)
    @Bean
    public RedisCustomConversions redisCustomConversions() {
        return new RedisCustomConversions(List.of(
            new LocalDateTimeToBytesConverter(),
            new BytesToLocalDateTimeConverter()
        ));
    }
}

9. 실전 패턴: 세션 스토어 구현

Redis Repository의 가장 대표적인 사용처인 분산 세션 스토어를 NestJS의 Guard와 유사한 인증 흐름으로 구현합니다.

@Service
@RequiredArgsConstructor
public class RedisSessionStore {

    private final UserSessionRepository sessionRepo;
    private final RedisKeyValueTemplate kvTemplate;

    public UserSession createSession(String userId, String device) {
        // 기존 동일 디바이스 세션 무효화
        sessionRepo.findByUserIdAndDeviceType(userId, device)
            .forEach(old -> sessionRepo.deleteById(old.getId()));

        UserSession session = new UserSession();
        session.setId(UUID.randomUUID().toString());
        session.setUserId(userId);
        session.setDeviceType(device);
        session.setAccessToken(generateToken());
        session.setCreatedAt(LocalDateTime.now());

        return sessionRepo.save(session);
    }

    public Optional<UserSession> validateAndRefresh(String sessionId) {
        return sessionRepo.findById(sessionId)
            .map(session -> {
                // TTL 갱신 (슬라이딩 윈도우)
                PartialUpdate<UserSession> refresh =
                    new PartialUpdate<>(sessionId, UserSession.class)
                        .refreshTtl(true);
                kvTemplate.update(refresh);
                return session;
            });
    }

    public long countActiveSessions(String userId) {
        return sessionRepo.findByUserId(userId).size();
    }

    public void revokeAllSessions(String userId) {
        sessionRepo.findByUserId(userId)
            .forEach(s -> sessionRepo.deleteById(s.getId()));
    }
}

10. 성능 최적화 체크리스트

Redis Repository를 프로덕션에서 사용할 때 반드시 확인해야 할 항목들입니다.

항목 권장 설정 이유
Keyspace Notifications notify-keyspace-events Ex TTL 만료 시 인덱스 정리 필수
@Indexed 남용 금지 검색 필요한 필드만 인덱스마다 Set 키 추가 → 메모리 증가
Connection Pool Lettuce + pool 설정 동시 요청 시 커넥션 부족 방지
직렬화 JSON > JDK 디버깅 가능 + 언어 중립
대량 삭제 SCAN 기반 batch KEYS * 금지 (O(N) 블로킹)
# application.yml 권장 설정
spring:
  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: 6379
      lettuce:
        pool:
          max-active: 16
          max-idle: 8
          min-idle: 4
          max-wait: 2000ms
      timeout: 3000ms

11. Repository vs RedisTemplate 선택 기준

모든 상황에 Repository가 적합한 것은 아닙니다. 아래 기준으로 판단하세요.

상황 추천 이유
CRUD 중심 + 필드 검색 Repository 코드 간결, 인덱스 자동 관리
랭킹/카운터/큐 RedisTemplate ZSET, INCR, LPUSH 등 전용 명령 필요
Pub/Sub 메시징 RedisTemplate Repository 범위 밖
대량 쓰기 (10만+/초) RedisTemplate + Pipeline Repository는 건별 처리
복합 조건 조회 혼합 사용 인덱스 조합은 Template의 Set 연산 활용

마무리

Spring Data Redis Repository는 Redis를 객체 저장소처럼 사용할 수 있게 해주는 강력한 추상화입니다. @RedisHash, @Indexed, @TimeToLive 세 가지 어노테이션만으로 세션·OTP·캐시 객체 관리가 가능하고, PartialUpdate로 효율적인 부분 갱신까지 지원합니다. 다만 Secondary Index의 TTL 정리 문제와 @Query 미지원이라는 한계를 인지하고, Redis Pipeline 배치 최적화Redis Lua Script 심화와 함께 RedisTemplate을 적절히 조합하는 것이 실전 운영의 핵심입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux