Spring Data Redis Repository란?
Spring Data Redis는 JPA처럼 Repository 인터페이스로 Redis에 객체를 저장·조회할 수 있는 추상화를 제공합니다. @RedisHash와 CrudRepository만으로 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을 적절히 조합하는 것이 실전 운영의 핵심입니다.