고성능 웹 서비스를 구축할 때, DB 조회 부하를 줄이기 위한 효과적인 방법 중 하나가 바로 캐싱(Caching) 입니다.
특히 Redis는 빠른 속도, 유연한 데이터 구조 덕분에 가장 많이 선택되는 캐시 저장소입니다.
Redis를 사용할 때는 어떤 방식으로 데이터를 저장하고 관리할지를 전략적으로 고민하고, 목적에 맞는 캐싱 기법을 정확히 이해하고 사용하는 것이 중요합니다.
이번 글에서는 다음 3가지 Redis 캐싱 기법을 다루고, 각 기법의 사용 방법, 장단점을 비교해보겠습니다.
- RedisTemplate 직접 캐싱
- @RedisHash 엔티티 기반 캐싱
- @Cacheable 어노테이션 기반 캐싱
1. RedisTemplate 직접 캐싱
RedisTemplate을 사용하면 Redis Key-Value 저장을 직접 제어할 수 있습니다. TTL, 어떤 타입의 데이터를 저장할지, 어떤 Serializer 를 사용할지 모두 개발자가 원하는 대로 설정할 수 있어 가장 유연한 방식입니다.
RedisConfig.java
@Bean
RedisTemplate<String, Object> objectRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfSubType(Object.class)
.build();
var objectMapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.registerModule(new JavaTimeModule())
.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL)
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
var template = new RedisTemplate<String, Object>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper));
return template;
}
- StringRedisSerializer를 key 직렬화에 사용하였습니다.
- GenericJackson2JsonRedisSerializer를 value 직렬화에 사용하여 모든 객체를 JSON으로 저장합니다.
- activateDefaultTyping을 통해 다양한 타입을 저장할 수 있도록 허용하지만, 반드시 PolymorphicTypeValidator를 적용해서 보안 리스크(JSON 변조 공격 등)를 방지합니다.
- JavaTimeModule을 등록하여 LocalDateTime 같은 날짜 타입도 문제없이 처리합니다.
UserService.java
public User getUser(final Long id) {
var key = "users:%d".formatted(id);
var cachedUser = objectRedisTemplate.opsForValue().get(key);
if (cachedUser != null) {
return (User) cachedUser;
}
User user = userRepository.findById(id).orElseThrow();
objectRedisTemplate.opsForValue().set(key, user, Duration.ofSeconds(30));
return user;
}
Redis에 먼저 조회하여 캐시 히트 시 빠른 반환을 하고 캐시 미스일 경우 DB 조회 후 Redis에 저장한 코드입니다. TTL은 30초로 설정되어 있습니다.
특징
- Redis key-value를 직접 다루는 로우 레벨 접근
- TTL, 직렬화 포맷, 키 포맷 모두 개발자가 자유롭게 제어할 수 있음.
- 복합 키/다중 TTL/락(분산락) 처리가 가능
✅ 복잡한 비즈니스 로직이 있고, 캐시를 세밀하게 제어하고 싶을 때 RedisTemplate를 사용합니다. 특히 대형 트래픽, 다중 서버 환경에서는 RedisTemplate을 쓰는 경우가 많습니다.
2. @RedisHash 엔티티 기반 캐싱
@RedisHash는 Spring Data Redis가 제공하는 기능으로 마치 JPA 엔티티처럼 Redis에 저장할 수 있게 해줍니다.
객체를 Redis의 Hash 구조에 저장할 수 있고, Spring Data Redis가 CrudRepository 스타일로 조회/저장을 지원해줍니다.
RedisHashUser.java
@RedisHash(value = "redishash-user", timeToLive = 30L)
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class RedisHashUser {
@Id
private Long id;
@Indexed
private String email;
private String name;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
- @RedisHash에 timeToLive를 설정하여 저장 시점부터 자동으로 TTL이 적용됩니다.
- @Id 필드가 Redis Hash의 고유 키가 됩니다.
- @Indexed를 사용하면 특정 필드로 검색 최적화가 가능합니다.
UserService.java
public RedisHashUser getUser(final Long id) {
return redisHashUserRepository.findById(id).orElseGet(() -> {
User user = userRepository.findById(id).orElseThrow();
return redisHashUserRepository.save(RedisHashUser.builder()
.id(user.getId())
.name(user.getName())
.email(user.getEmail())
.createdAt(user.getCreatedAt())
.updatedAt(user.getUpdatedAt())
.build());
});
}
ID 기준으로 Redis에서 조회하고 없는 경우 DB 조회 후 Redis에 저장합니다.
특징
- JPA 엔티티처럼 Redis에 저장/조회할 수 있게 지원
- @Id, @Indexed, @RedisHash(timeToLive=xxx) 등을 이용해 손쉬운 관리
- Hash 자료구조 기반이라 필드 단위 수정도 가능
- 키 설계나 복합 TTL 같은 고급 기능은 제약이 있음
- 다대다 관계, 깊은 구조 경우 힘듦이 있음
✅ 단순한 객체를 TTL과 함께 빠르게 Redis에 저장하고 싶을 때 사용합니다. 복잡한 구조가 아니고 단건 조회 중심이라면 @RedisHash가 효율적입니다.
3. @Cacheable 어노테이션 기반 캐싱
@Cacheable은 메서드 호출 결과를 자동으로 캐시하고, 같은 파라미터로 호출할 때 캐시된 결과를 반환하는 기능입니다. Spring Cache 추상화 위에서 동작하므로, 코드 변경 없이 다양한 캐시 저장소로 교체가 가능합니다.
(현재 코드에서는 Spring Cache 추상화를 사용하기 위해 CacheManager를 Redis 기반으로 설정합니다.)
CacheConfig.java
@EnableCaching
@Configuration
public class CacheConfig {
public static final String CACHE1 = "cache1";
@Bean
public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfSubType(Object.class)
.build();
var objectMapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.registerModule(new JavaTimeModule())
.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL)
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
List<CacheProperty> properties = List.of(
new CacheProperty(CACHE1, 30)
);
return (builder -> {
properties.forEach(i -> {
builder.withCacheConfiguration(
i.getName(),
RedisCacheConfiguration.defaultCacheConfig()
.disableCachingNullValues()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)))
.entryTtl(Duration.ofSeconds(i.getTtl()))
);
});
});
}
}
- Null 값은 캐시하지 않도록 설정해 불필요한 메모리 낭비 방지합니다.
- 캐시 이름별로 다른 TTL을 설정해 다양한 데이터 특성에 대응할 수 있습니다.
- 운영 중 캐시 전략을 변경할 때 코드 수정 없이 설정만 변경하면 되도록 유연성을 확보합니다.
UserService.java
@Cacheable(cacheNames = CACHE2, key = "'users:' + #id")
public User getUser3(final Long id) {
return userRepository.findById(id).orElseThrow();
}
메서드 호출 시 자동으로 캐시 저장합니다. 같은 파라미터로 호출하면 캐시에서 바로 응답합니다.
특징
- 어노테이션 하나로 캐시 기능을 바로 적용할 수 있습니다. 비즈니스 로직을 건드리지 않아 코드 변경이 최소화되고, TTL 변경과 Cache 이름 변경이 설정만으로 가능하여 운영 전략 수정이 용이합니다.
- 조건부 캐싱, 복합 키 구성이 불편
- @CacheEvict 관리 필요(캐시 무효화 주의)
✅ 읽기 성능을 빠르게 높이고 싶을 때 가장 먼저 적용하는 전략입니다. 개발 리소스를 최소로 쓰고, 성능 효과를 빠르게 얻을 수 있습니다.