@Cacheable로 캐시를 자동 관리하는 것만으로는 부족한 순간이 온다. 좋아요 수를 원자적으로 증가시키거나, 특정 키에 원하는 TTL을 개별 지정하거나, Redis의 자료구조를 직접 활용해야 할 때다. RedisTemplate은 Redis를 직접 제어하는 도구다.
RedisTemplate이 뭔가
자판기와 주방의 차이
음식을 얻는 방법이 두 가지 있다.
하나는 자판기다. 버튼을 누르면 음식이 나온다. 내부에서 어떤 음식이 재고에 있는지, 언제 보충하는지 신경 쓸 필요 없다. 자판기가 알아서 관리한다. 메뉴가 정해져 있고, 내가 개입할 여지가 없다.
다른 하나는 주방이다. 냉장고에서 재료를 직접 꺼내고, 원하는 방식으로 조리하고, 먹고 남은 건 직접 치운다. 자유도가 높지만 전부 내 책임이다.
이 자판기가 Spring에서는 @Cacheable이다. 메서드에 어노테이션만 붙이면 캐시 저장·조회·삭제를 프록시가 알아서 해준다. 주방이 RedisTemplate이다. Redis에 데이터를 직접 넣고, 직접 꺼내고, 직접 지운다.
왜 주방이 필요한가
자판기로 해결 안 되는 상황이 있다.
- 원자적 증감 : 좋아요 수를
increment로 1 올리고 싶은데,@Cacheable은 "메서드 반환값을 통째로 저장"하는 구조다. 값을 1만 올리는 연산을 지원하지 않는다. - 키별 개별 TTL :
@Cacheable은 캐시 이름(region) 단위로 TTL을 설정한다. "이 키는 5분, 저 키는 1시간"처럼 개별 지정이 불가능하다. - 다양한 자료구조 : Redis의 Hash, List, Set, Sorted Set을 활용하고 싶은데,
@Cacheable은 String(단일 값) 자료구조만 사용한다. - 벌크 조회 :
multiGet으로 여러 키를 한 번에 가져오고 싶은데,@Cacheable은 키 하나에 메서드 호출 하나가 묶이는 구조다.
이 중 하나라도 해당되면 RedisTemplate이 필요하다.
RedisTemplate 설정
의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
이 의존성 하나로 세 가지가 자동 설정된다.
LettuceConnectionFactory: Redis 서버와의 연결을 관리하는 팩토리RedisTemplate<Object, Object>: 기본 RedisTemplate 빈StringRedisTemplate:<String, String>타입의 편의 빈
Spring Boot 2.0부터 기본 Redis 클라이언트가 Jedis에서 Lettuce로 바뀌었다. Lettuce는 Netty 기반 비동기 클라이언트로, 단일 커넥션을 여러 스레드가 공유할 수 있다. Jedis는 스레드마다 커넥션이 필요해서 커넥션 풀 관리가 필수였다. 별도 설정 없이 의존성만 추가하면 Lettuce가 사용된다.
application.yml
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
host
Redis 서버 주소다. ${REDIS_HOST:localhost}는 환경 변수 REDIS_HOST가 있으면 그 값을 쓰고, 없으면 localhost를 쓴다는 뜻이다.
왜 환경 변수로 주입하는가? 로컬 개발에서는 localhost지만, 운영 환경에서는 별도 Redis 서버 주소를 써야 한다. 코드 변경 없이 환경 변수만 바꿔서 배포할 수 있도록 하기 위해서다.
port
Redis 기본 포트는 6379다. 변경할 일이 거의 없지만, 한 머신에 Redis 인스턴스를 여러 개 띄우거나 보안 정책으로 포트를 바꿔야 할 때를 대비해 환경 변수로 뺀다.
RedisTemplate 빈 등록
Spring Boot가 자동 등록하는 RedisTemplate<Object, Object>는 기본 직렬화가 JdkSerializationRedisSerializer다. 이게 문제를 일으킨다. 커스텀 빈을 등록해야 하는 이유를 먼저 이해하자.
택배를 보내는 상황을 떠올려 보자.
물건을 택배로 보내려면 상자에 포장해야 한다. 받는 사람이 상자를 열면 원래 물건이 나와야 한다. 포장 방식이 두 가지 있다. 하나는 물건을 압축 팩에 넣는 것이다. 공간은 절약되지만, 받는 사람이 뭐가 들었는지 겉에서 볼 수 없다. 같은 압축기가 없으면 열 수도 없다. 다른 하나는 투명 상자에 넣는 것이다. 겉에서 내용물이 보이고, 누구나 열 수 있다.
압축 팩이 JdkSerializationRedisSerializer다. 자바 바이너리 형식으로 직렬화하므로, Redis CLI에서 값을 읽을 수 없고, 자바가 아닌 다른 언어에서도 읽을 수 없다. 투명 상자가 StringRedisSerializer나 Jackson2JsonRedisSerializer다. 사람이 읽을 수 있는 형태(문자열, JSON)로 저장한다.
이제 실제 빈 설정 코드를 보자.
@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.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
}
RedisTemplate<String, Object>
키는 항상 문자열이다. Redis 키는 결국 바이트 배열이고, 사람이 읽을 수 있으려면 문자열이어야 한다. 값은 Object로 열어둔다.
왜 <String, String>이 아닌가? 문자열만 저장할 거면 StringRedisTemplate을 쓰면 된다. <String, Object>로 하면 DTO, 숫자, 컬렉션 등 다양한 타입을 하나의 RedisTemplate으로 다룰 수 있다.
토큰 저장소처럼 값이 전부 문자열인 경우, StringRedisTemplate을 주입받으면 된다. 별도 빈 등록 없이 Spring Boot가 자동으로 제공한다. opsForValue().set("key", "value")처럼 사용하면 키와 값 모두 StringRedisSerializer가 적용된다.
setConnectionFactory
Redis 서버 연결 정보를 주입한다. application.yml의 host, port를 읽어서 Spring Boot가 만든 LettuceConnectionFactory가 여기에 들어온다.
왜 직접 new LettuceConnectionFactory(...)를 하지 않는가? 커넥션 팩토리는 Spring Boot가 관리하는 빈이다. 직접 만들면 커넥션 풀 설정, 타임아웃, 재연결 정책 등 Boot가 자동으로 잡아주는 설정이 적용되지 않는다.
setKeySerializer
키를 StringRedisSerializer로 직렬화한다. 이걸 빠뜨리면 기본값인 JdkSerializationRedisSerializer가 적용되어 키 앞에 바이너리 프리픽스가 붙는다.
# StringRedisSerializer 적용 시
feed:like:abc-123
# JdkSerializationRedisSerializer 적용 시 (기본값)
\xac\xed\x00\x05t\x00\x12feed:like:abc-123
바이너리 프리픽스가 붙으면 Redis CLI에서 GET feed:like:abc-123으로 조회해도 키를 찾을 수 없다. 실제 키 이름이 다르기 때문이다. 디버깅이 불가능해진다.
setValueSerializer
값을 GenericJackson2JsonRedisSerializer로 직렬화한다. JSON 형태로 저장되므로 Redis CLI에서 값을 읽을 수 있다.
왜 Jackson2JsonRedisSerializer가 아닌가? Jackson2JsonRedisSerializer는 특정 타입을 지정해야 한다. GenericJackson2JsonRedisSerializer는 타입 정보를 JSON에 포함시켜서, 어떤 타입이든 직렬화·역직렬화할 수 있다. <String, Object>와 궁합이 맞다.
안 쓰면 어떻게 되는가? 기본값인 JdkSerializationRedisSerializer가 적용된다. Redis CLI에서 값이 바이너리로 보여서 디버깅이 불가능하고, 자바 외 다른 언어에서 값을 읽을 수 없다.
setHashKeySerializer, setHashValueSerializer
Redis의 Hash 자료구조를 쓸 때 적용되는 직렬화 설정이다. opsForHash()를 통해 Hash 필드를 다룰 때 여기서 설정한 직렬화가 적용된다.
왜 따로 설정하는가? setKeySerializer는 Redis 키(최상위 키)의 직렬화, setHashKeySerializer는 Hash 내부 필드 이름의 직렬화다. 둘은 별개다. Hash를 쓸 계획이 없더라도 미리 설정해두면 나중에 빠뜨릴 일이 없다.
opsForValue 기본 사용법
RedisTemplate을 주입받으면, 실제 데이터 조작은 opsFor___() 메서드를 통해 한다. 각 메서드가 Redis의 자료구조에 대응한다.
| 메서드 | Redis 자료구조 | 용도 |
|---|---|---|
opsForValue() | String | 단일 값 저장·조회 |
opsForHash() | Hash | 필드-값 쌍 (객체 속성) |
opsForList() | List | 순서 있는 목록 |
opsForSet() | Set | 중복 없는 집합 |
opsForZSet() | Sorted Set | 점수 기반 정렬 집합 |
이번 편에서는 opsForValue()만 다룬다. 나머지는 다음 편에서 다룬다.
set — 값 저장
// 기본 저장
redisTemplate.opsForValue().set("feed:like:abc-123", 42);
// TTL과 함께 저장
redisTemplate.opsForValue().set("feed:like:abc-123", 42, Duration.ofMinutes(5));
첫 번째 인자 — 키
Redis에서 이 데이터를 찾을 주소다. feed:like:abc-123처럼 도메인:용도:식별자 패턴을 따르면 나중에 KEYS feed:like:*로 좋아요 관련 키만 골라서 조회할 수 있다.
왜 계층적으로 짓는가? Redis는 테이블이나 스키마 같은 구분이 없다. 하나의 평탄한 키-값 공간이다. 키 이름 자체에 구조를 넣어야 관리가 된다.
두 번째 인자 — 값
저장할 데이터다. <String, Object>로 설정했으므로 문자열, 숫자, DTO 등 어떤 타입이든 넣을 수 있다. GenericJackson2JsonRedisSerializer가 JSON으로 변환해서 저장한다.
세 번째 인자 — TTL
이 키가 자동 삭제되는 시간이다. Duration.ofMinutes(5)는 5분 후 자동 삭제다.
왜 항상 TTL을 붙여야 하는가? TTL이 없으면 키가 영원히 Redis에 남는다. 좋아요 수 캐시가 만료되지 않으면, 피드가 삭제된 후에도 좋아요 데이터가 남아 있게 된다. 시간이 지날수록 Redis 메모리가 무한히 증가한다.
안 붙이면 어떻게 되는가? set("key", value)만 호출하면 TTL 명령 결과가 -1(만료 없음)로 나온다. 의도적으로 영구 저장이 필요한 경우가 아니라면, 반드시 TTL을 명시한다.
get — 값 조회
Object value = redisTemplate.opsForValue().get("feed:like:abc-123");
키가 존재하면 저장된 값을 반환하고, 존재하지 않으면 null을 반환한다.
RedisTemplate<String, Object>이므로 get()의 반환 타입은 Object다. 실제 사용할 때는 캐스팅이 필요하다.
```java
Integer count = (Integer) redisTemplate.opsForValue().get("feed:like:abc-123");
```
GenericJackson2JsonRedisSerializer가 숫자를 Integer나 Long으로 역직렬화한다. 작은 숫자는 Integer, 큰 숫자는 Long이 되므로, Long으로 통일해서 받는 게 안전하다. Number로 받아서 .longValue()를 호출하는 방법도 있다.
delete — 값 삭제
Boolean deleted = redisTemplate.delete("feed:like:abc-123");
키가 존재해서 삭제했으면 true, 존재하지 않았으면 false를 반환한다.
왜 opsForValue().delete()가 아닌가? delete()는 자료구조와 무관하게 키 자체를 삭제하는 연산이다. String이든 Hash든 List든, 키를 지우면 값 전체가 사라진다. 그래서 opsForValue() 안이 아니라 RedisTemplate에 직접 있다.
increment — 원자적 증감
// 1 증가
Long newCount = redisTemplate.opsForValue().increment("feed:like:abc-123");
// 지정한 값만큼 증가
Long newCount = redisTemplate.opsForValue().increment("feed:like:abc-123", 5);
// 감소 (음수를 더함)
Long newCount = redisTemplate.opsForValue().increment("feed:like:abc-123", -1);
increment가 @Cacheable로는 불가능한 핵심 기능이다.
왜 "원자적"인가
동시에 100명이 좋아요를 누르는 상황을 보자.
분홍 영역은 get으로 현재 값을 읽고, 1을 더해서 set으로 다시 쓰는 방식이다. 두 요청이 동시에 10을 읽으면 둘 다 11로 쓴다. 좋아요가 하나 증발한다.
초록 영역은 INCR 명령(Java에서는 increment())을 쓰는 방식이다. Redis가 읽기-증가-쓰기를 하나의 연산으로 처리하므로, 동시 요청이 몇 개든 정확한 결과가 나온다.
키가 없을 때
키가 존재하지 않는 상태에서 increment()를 호출하면 Redis가 자동으로 키를 생성하고 값을 0에서 시작해서 증가시킨다. 미리 set()으로 초기화할 필요가 없다.
안 쓰면 어떻게 되는가? get → +1 → set 패턴을 직접 구현해야 하고, 동시성 문제를 막으려면 분산 락이 필요해진다. increment 하나로 끝나는 걸 불필요하게 복잡하게 만드는 셈이다.
setIfAbsent — 존재하지 않을 때만 저장
Boolean success = redisTemplate.opsForValue()
.setIfAbsent("lock:feed:abc-123", "locked", Duration.ofSeconds(10));
키가 없을 때만 값을 저장하고 true를 반환한다. 이미 키가 있으면 아무것도 하지 않고 false를 반환한다. Redis의 SET NX 명령에 대응한다.
왜 필요한가? 분산 락의 기본 구현이다. 여러 서버에서 동시에 같은 작업을 실행하면 안 될 때, setIfAbsent로 락을 잡는다. 먼저 성공한 서버만 작업을 수행하고, 나머지는 false를 받아서 대기하거나 포기한다.
전체 흐름 정리
Java 코드에서 Redis 서버까지 데이터가 어떻게 이동하는지 전체 흐름을 보자.
1~3번에서 RedisTemplate이 키와 값을 각각 설정된 Serializer로 직렬화한다. 4~6번에서 LettuceConnection이 Redis 프로토콜로 변환해서 서버에 전송한다. 7~8번에서 응답이 돌아온다.
개발자가 신경 쓸 부분은 1번뿐이다. 나머지는 RedisTemplate과 Spring이 처리한다.
자주 하는 실수
@Autowired RedisTemplate<Object, Object>로 Spring Boot 자동 설정 빈을 그대로 쓰면, 기본 직렬화인 JdkSerializationRedisSerializer가 적용된다. 키와 값 앞에 바이너리 프리픽스가 붙어서 Redis CLI에서 읽을 수 없고, 같은 문자열 키라도 직렬화 방식이 다르면 서로 다른 키로 취급된다.
반드시 위에서 설명한 것처럼 커스텀 빈을 등록하거나, 문자열만 다룬다면 StringRedisTemplate을 사용한다.
[!DANGER] TTL을 빠뜨려서 데이터가 영원히 남는 실수
opsForValue().set("key", value)만 호출하면 TTL이 설정되지 않는다. 캐시 용도의 데이터가 만료되지 않으면, 삭제된 피드의 좋아요 수가 영원히 남고, Redis 메모리가 끝없이 증가한다.
@Cacheable은 CacheConfig에서 기본 TTL을 잡아주지만, RedisTemplate은 호출할 때마다 직접 TTL을 지정해야 한다. set() 호출 시 항상 Duration을 세 번째 인자로 넘기는 습관을 들인다.
[!DANGER] increment 대신 get → set으로 카운터를 구현하는 실수
좋아요 수를 get으로 읽고, Java에서 1을 더하고, set으로 다시 쓰는 패턴은 동시성 문제를 일으킨다. 동시에 두 요청이 같은 값을 읽으면 하나가 덮어써진다.
increment()는 Redis 서버 내부에서 원자적으로 읽기-증가-쓰기를 수행한다. 카운터 연산에는 반드시 increment()를 사용한다.
[!DANGER] get() 반환 타입을 확인하지 않는 실수
GenericJackson2JsonRedisSerializer는 숫자를 역직렬화할 때, 값의 크기에 따라 Integer 또는 Long을 반환한다. (Long) redisTemplate.opsForValue().get("key")로 캐스팅하면 작은 숫자에서 ClassCastException이 터진다.
```java
// 위험
Long count = (Long) redisTemplate.opsForValue().get("key");
// 안전
Number count = (Number) redisTemplate.opsForValue().get("key");
long value = count != null ? count.longValue() : 0L;
```