반응형
Spring Boot 프로젝트에서 Redis를 쓰다 보면, 단순히 라이브러리를 추가하는 것만으로는 부족하다는 걸 느낄 때가 많습니다. 캐시, 세션, 데이터 공유 같은 실무 요구사항이 다양하다 보니, 중복되는 설정이나 문제를 매번 반복하지 않고 공통으로 관리할 필요가 있습니다.
이 글에서는 실제 현업에서 경험한 Redis 설정과 서비스를 기준으로,
클러스터/싱글 모드 지원, 동기/비동기 클라이언트, TTL 적용, LocalDateTime 직렬화, SCAN 안전 조회까지 포함한 실무용 구성과 방법을 공유합니다.
실무 활용에서 공통으로 관리해야 하는 부분
제가 현업에서 Redis를 다루면서 공통으로 관리해야 한다고 느낀 부분은 다음과 같습니다.
- 객체 직렬화
- LocalDateTime 등 복잡한 객체를 Redis에 안전하게 저장하기 위해 Jackson과 JavaTimeModule 사용
- RedisTemplate 기본 직렬화만 사용할 경우 조회 시 LinkedHashMap으로 변환되거나 에러가 발생할 수 있기 때문에, 직렬화/역직렬화를 공통 처리하는 것이 실무에서는 매우 중요합니다.
- TTL 적용
- 캐시 데이터를 key 단위로 만료시키지 않으면 메모리 부담이 커집니다.
- 메서드 단위로 TTL을 적용하면 key별로 유연하게 캐시 정책을 설정할 수 있습니다.
- 싱글톤 관리
- Redis 클라이언트를 매번 생성하지 않고 Spring Bean으로 관리하면 연결을 재사용할 수 있어 안정적입니다.
- 클러스터/싱글 모드 대응
- 운영 환경에서는 단일 Redis 서버를 쓰는 경우도 있고, 클러스터 Redis를 쓰는 경우도 있습니다.
- 공통 Config를 만들면 환경이 바뀌더라도 코드 변경 없이 재사용할 수 있습니다.
요약하면, Redis Config 자체가 실무에서 반복되는 문제를 공통으로 관리하는 역할을 합니다.
그래서 단순한 설정 파일이 아니라, 재사용 가능하고 안정적인 Redis 접근 계층을 만드는 기반이 됩니다.
Spring Boot Redis Config 예제
@Configuration
public class RedisConfig {
@Value("${spring.redis.mode:single}")
private String redisMode;
@Value("${spring.redis.host:localhost}")
private String redisHost;
@Value("${spring.redis.port:6379}")
private int redisPort;
@Value("${spring.redis.password:}")
private String redisPassword;
@Value("${spring.redis.cluster.nodes:}")
private String clusterNodes;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
if ("cluster".equalsIgnoreCase(redisMode)) {
RedisClusterConfiguration clusterConfig = new RedisClusterConfiguration(
Arrays.asList(clusterNodes.split(",")));
if (!redisPassword.isEmpty()) clusterConfig.setPassword(redisPassword);
return new LettuceConnectionFactory(clusterConfig);
} else {
RedisStandaloneConfiguration standaloneConfig = new RedisStandaloneConfiguration(redisHost, redisPort);
if (!redisPassword.isEmpty()) standaloneConfig.setPassword(redisPassword);
return new LettuceConnectionFactory(standaloneConfig);
}
}
@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());
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(mapper);
template.setValueSerializer(serializer);
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
@Bean
public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(
ReactiveRedisConnectionFactory connectionFactory) {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper);
RedisSerializationContext<String, Object> context = RedisSerializationContext
.<String, Object>newSerializationContext(new StringRedisSerializer())
.value(serializer)
.hashValue(serializer)
.build();
return new ReactiveRedisTemplate<>(connectionFactory, context);
}
}
RedisService (withTTL + SCAN + 동기/비동기)
@Service
@RequiredArgsConstructor
public class RedisService {
private static final long DEFAULT_TTL_SECONDS = 3600; // 기본 TTL 1시간
private final RedisTemplate<String, Object> redisTemplate;
private final ReactiveRedisTemplate<String, Object> reactiveRedisTemplate;
// ---------------- Key-Value 동기 ----------------
public void setWithTTL(String key, Object value) {
setWithTTL(key, value, DEFAULT_TTL_SECONDS);
}
public void setWithTTL(String key, Object value, long ttlSeconds) {
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(ttlSeconds));
}
public <T> T get(String key, Class<T> clazz) {
Object value = redisTemplate.opsForValue().get(key);
return value != null ? clazz.cast(value) : null;
}
public void delete(String key) {
redisTemplate.delete(key);
}
public boolean exists(String key) {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
public void expire(String key, long ttlSeconds) {
redisTemplate.expire(key, Duration.ofSeconds(ttlSeconds));
}
// ---------------- Hash 동기 ----------------
public void hSetWithTTL(String key, String hashKey, Object value) {
hSetWithTTL(key, hashKey, value, DEFAULT_TTL_SECONDS);
}
public void hSetWithTTL(String key, String hashKey, Object value, long ttlSeconds) {
redisTemplate.opsForHash().put(key, hashKey, value);
redisTemplate.expire(key, Duration.ofSeconds(ttlSeconds));
}
public <T> T hGet(String key, String hashKey, Class<T> clazz) {
Object value = redisTemplate.opsForHash().get(key, hashKey);
return value != null ? clazz.cast(value) : null;
}
public Map<Object, Object> hGetAll(String key) {
return redisTemplate.opsForHash().entries(key);
}
// ---------------- SCAN 안전 조회 ----------------
public List<String> scanKeys(String pattern, int countPerChunk) {
List<String> keys = new ArrayList<>();
ScanOptions options = ScanOptions.scanOptions().match(pattern).count(countPerChunk).build();
try (Cursor<byte[]> cursor = redisTemplate.getConnectionFactory().getConnection().scan(options)) {
cursor.forEachRemaining(b -> keys.add(new String(b)));
} catch (IOException e) {
throw new RuntimeException("Redis SCAN 실패", e);
}
return keys;
}
// ---------------- 동기/비동기 Reactive ----------------
public Mono<Boolean> setWithTTLAsync(String key, Object value) {
return setWithTTLAsync(key, value, DEFAULT_TTL_SECONDS);
}
public Mono<Boolean> setWithTTLAsync(String key, Object value, long ttlSeconds) {
return reactiveRedisTemplate.opsForValue()
.set(key, value, Duration.ofSeconds(ttlSeconds));
}
public <T> Mono<T> getAsync(String key, Class<T> clazz) {
return reactiveRedisTemplate.opsForValue()
.get(key)
.map(clazz::cast);
}
public Mono<Boolean> hSetWithTTLAsync(String key, String hashKey, Object value) {
return hSetWithTTLAsync(key, hashKey, value, DEFAULT_TTL_SECONDS);
}
public Mono<Boolean> hSetWithTTLAsync(String key, String hashKey, Object value, long ttlSeconds) {
return reactiveRedisTemplate.opsForHash()
.put(key, hashKey, value)
.flatMap(result -> reactiveRedisTemplate.expire(key, Duration.ofSeconds(ttlSeconds)));
}
public <T> Mono<T> hGetAsync(String key, String hashKey, Class<T> clazz) {
return reactiveRedisTemplate.opsForHash()
.get(key, hashKey)
.map(clazz::cast);
}
}
LocalDateTime 포함 객체 예제
@Data
public class Event {
private String name;
private LocalDateTime eventTime;
}
// 동기 저장
redisService.setWithTTL("event:1", new Event("Redis 테스트", LocalDateTime.now()), 3600);
// 동기 조회
Event savedEvent = redisService.get("event:1", Event.class);
System.out.println(savedEvent.getEventTime());
// 비동기 저장
redisService.setWithTTLAsync("event:2", new Event("Reactive 테스트", LocalDateTime.now()), 3600)
.subscribe(result -> System.out.println("비동기 저장 완료: " + result));
// 비동기 조회
redisService.getAsync("event:2", Event.class)
.subscribe(event -> System.out.println("비동기 조회: " + event.getEventTime()));
실무 적용 팁
- 메서드별 TTL 적용 → Key별 캐시 정책 유연하게 설정 가능
- SCAN 사용 시 countPerChunk 지정 → 대량 Key 조회 안전
- Key 네이밍 규칙: "user:123", "session:abcd" → 관리 용이
- Spring Bean 싱글톤 관리 → 연결 재사용 가능
- 클러스터/싱글 모드 모두 동일 코드 처리 가능
- 동기/비동기 선택: CRUD 단순 처리 → 동기, 대량 데이터/스트리밍 → 비동기
결론
실무에서 Redis를 안정적으로 활용하려면, Config와 RedisService가 반복되는 문제를 공통으로 관리해야 합니다.
withTTL 오버로드 패턴, SCAN 안전 조회, 동기/비동기 모두 포함하면 캐시, 세션, 데이터 공유 요구사항을 안정적으로 처리할 수 있습니다.
그리고 LocalDateTime 등 복잡한 객체도 Jackson과 JavaTimeModule로 안전하게 직렬화/역직렬화할 수 있습니다.
반응형
'백엔드 개발' 카테고리의 다른 글
| java.lang.NullPointerException 원인 정리 (0) | 2026.03.31 |
|---|---|
| Spring Boot + SAP JCo 연동 SDK 만들기 (Builder 패턴 적용) (0) | 2026.03.27 |
| Builder 패턴으로 SDK 모듈 래핑하기 (실무 적용 사례) (0) | 2026.02.09 |
| AWS SQS SDK 기반 Consumer 실무 적용기 (0) | 2026.02.06 |
| JPA vs MyBatis, 실무에서 왜 이렇게 고민하게 될까? (0) | 2026.02.05 |