서비스에서 같은 데이터를 반복해서 DB에서 읽고 있다면, 캐시를 도입하는 가장 간단한 방법은 무엇일까요?

직접 RedisTemplate을 다루며 캐시 로직을 짜는 것도 방법이지만, Spring Cache 추상화를 사용하면 어노테이션 하나로 캐시를 적용할 수 있습니다. 이 글에서는 Spring Cache와 Redis를 연동하는 전체 과정을 정리합니다.

개념 정의

Spring Cache 추상화 는 캐시 저장소(Redis, Caffeine, EhCache 등)에 독립적인 어노테이션 기반 캐시 인터페이스입니다. 비즈니스 로직과 캐시 로직을 분리하여, 캐시 구현체를 바꿔도 코드를 수정할 필요가 없습니다.

Spring Cache 추상화란

Spring Cache의 핵심 구성요소는 세 가지입니다.

  • @EnableCaching — 캐시 기능을 활성화하는 스위치
  • CacheManager — 캐시 저장소를 관리하는 인터페이스
  • ** 캐시 어노테이션** — @Cacheable, @CacheEvict, @CachePut
JAVA
@Configuration
@EnableCaching  // 이걸 빼먹으면 캐시 어노테이션이 전부 무시됩니다
public class CacheConfig {
}

Spring Boot에서 spring-boot-starter-data-redis를 추가하면 자동으로 RedisCacheManager가 등록됩니다. 별도 CacheManager 빈을 선언하지 않아도 Redis가 기본 캐시 저장소로 동작합니다.

Spring Boot의 Auto-Configuration이 클래스패스에서 Redis를 감지하면 RedisCacheManager를 자동 생성합니다. Caffeine이나 EhCache도 마찬가지로 해당 라이브러리가 클래스패스에 있으면 자동 적용됩니다.

핵심 어노테이션

@Cacheable — 캐시에서 먼저 조회

가장 많이 사용하는 어노테이션입니다. 메서드 호출 전에 캐시를 확인하고, 값이 있으면 메서드를 실행하지 않고 캐시된 값을 반환합니다.

JAVA
@Cacheable(value = "users", key = "#userId")
public User getUser(Long userId) {
    // 캐시 미스일 때만 이 코드가 실행됩니다
    return userRepository.findById(userId).orElseThrow();
}
  • value (= cacheNames) — 캐시 이름. Redis에서는 키 prefix로 사용됩니다
  • key — 캐시 키를 SpEL로 지정. 생략하면 파라미터 전체가 키가 됩니다

condition과 unless

캐시 적용 조건을 세밀하게 제어할 수 있습니다.

JAVA
@Cacheable(
    value = "users",
    key = "#userId",
    condition = "#userId > 0",       // 실행 전 평가: false면 캐시 자체를 건너뜀
    unless = "#result.role == 'ADMIN'" // 실행 후 평가: true면 결과를 캐시에 저장하지 않음
)
public User getUser(Long userId) {
    return userRepository.findById(userId).orElseThrow();
}

conditionunless의 평가 시점이 다르다는 게 핵심입니다. condition은 메서드 실행 전에 평가되어 캐시 동작 자체를 건너뛸지 결정하고, unless는 메서드 실행 후에 결과(#result)를 보고 캐시에 저장할지 결정합니다.

@CacheEvict — 캐시 무효화

데이터가 변경되었을 때 오래된 캐시를 삭제합니다.

JAVA
// 특정 키의 캐시 삭제
@CacheEvict(value = "users", key = "#userId")
public void updateUser(Long userId, UserUpdateDto dto) {
    userRepository.update(userId, dto);
}

// 해당 캐시의 모든 엔트리 삭제
@CacheEvict(value = "users", allEntries = true)
public void clearAllUserCache() {
    // 메서드 본문은 비어 있어도 됩니다
}
  • allEntries = true — 해당 캐시 이름 아래의 모든 키를 삭제합니다
  • beforeInvocation = true — 메서드 실행 전에 캐시를 삭제합니다. 메서드가 예외를 던져도 캐시는 이미 삭제된 상태입니다

@CachePut — 항상 실행하고 캐시 갱신

@Cacheable과 달리 캐시 존재 여부와 무관하게 항상 메서드를 실행하고, 그 결과를 캐시에 저장합니다.

JAVA
@CachePut(value = "users", key = "#userId")
public User updateAndReturn(Long userId, UserUpdateDto dto) {
    User user = userRepository.findById(userId).orElseThrow();
    user.update(dto);
    return userRepository.save(user);  // 반환값이 캐시에 저장됩니다
}

데이터를 갱신하면서 동시에 캐시도 최신 상태로 유지하고 싶을 때 @CachePut을 사용합니다. 하지만 동시 요청 환경에서는 @CacheEvict로 무효화하는 편이 더 안전한 경우가 많습니다.

@Caching — 여러 어노테이션 조합

한 메서드에 여러 캐시 동작을 동시에 적용해야 할 때 사용합니다.

JAVA
@Caching(
    evict = {
        @CacheEvict(value = "users", key = "#userId"),
        @CacheEvict(value = "userList", allEntries = true)
    }
)
public void deleteUser(Long userId) {
    userRepository.deleteById(userId);
}

사용자를 삭제할 때 개별 사용자 캐시와 목록 캐시를 한꺼번에 무효화하는 패턴입니다.

@CacheConfig — 클래스 레벨 기본값

같은 클래스의 모든 캐시 메서드에 공통 설정을 적용합니다.

JAVA
@Service
@CacheConfig(cacheNames = "users")  // 이 클래스의 모든 캐시 어노테이션에 적용
public class UserService {

    @Cacheable(key = "#userId")  // value 생략 가능
    public User getUser(Long userId) {
        return userRepository.findById(userId).orElseThrow();
    }

    @CacheEvict(key = "#userId")  // value 생략 가능
    public void updateUser(Long userId, UserUpdateDto dto) {
        userRepository.update(userId, dto);
    }
}

Redis 연동 설정

의존성 추가

GROOVY
// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-cache'
}

application.yml 설정

YAML
spring:
  data:
    redis:
      host: localhost
      port: 6379
      # password: your-password  # 필요 시
  cache:
    type: redis                  # 캐시 타입 명시 (자동 감지되지만 명시하면 확실)
    redis:
      time-to-live: 600000       # 기본 TTL 10분 (밀리초)
      cache-null-values: false   # null 캐싱 비활성화
      key-prefix: "myapp::"     # 키 접두사
      use-key-prefix: true

RedisCacheConfiguration 커스터마이징

application.yml로 안 되는 세밀한 설정은 Java Config로 합니다.

JAVA
@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // 기본 설정
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .disableCachingNullValues()
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        // 캐시별 개별 TTL 설정
        Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
            "users", defaultConfig.entryTtl(Duration.ofMinutes(30)),
            "products", defaultConfig.entryTtl(Duration.ofHours(1)),
            "sessions", defaultConfig.entryTtl(Duration.ofMinutes(5))
        );

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigs)
            .build();
    }
}

직렬화 설정

Spring Cache + Redis의 기본 직렬화는 JdkSerializationRedisSerializer입니다. 이게 문제가 되는 이유가 있습니다.

  • Redis CLI에서 값을 읽으면 바이너리라 사람이 확인할 수 없습니다
  • 클래스 패키지 경로나 필드가 바뀌면 역직렬화가 실패합니다
  • 다른 언어의 애플리케이션과 캐시를 공유할 수 없습니다

JSON 직렬화로 변경

JAVA
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.registerModule(new JavaTimeModule());       // LocalDateTime 등 지원
    objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    objectMapper.activateDefaultTyping(
        objectMapper.getPolymorphicTypeValidator(),
        ObjectMapper.DefaultTyping.NON_FINAL,
        JsonTypeInfo.As.PROPERTY
    );

    GenericJackson2JsonRedisSerializer serializer =
        new GenericJackson2JsonRedisSerializer(objectMapper);

    return RedisCacheConfiguration.defaultCacheConfig()
        .serializeKeysWith(
            RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
        .serializeValuesWith(
            RedisSerializationContext.SerializationPair
                .fromSerializer(serializer));
}

activateDefaultTyping을 설정하면 JSON에 @class 필드가 포함되어 역직렬화 시 정확한 타입을 복원할 수 있습니다. 다만 보안 취약점이 될 수 있으므로 신뢰할 수 있는 내부 시스템에서만 사용하는 것이 좋습니다.

캐시 키 전략

SpEL 기반 키 생성

JAVA
// 단일 파라미터
@Cacheable(value = "users", key = "#userId")
public User getUser(Long userId) { ... }

// 복합 키
@Cacheable(value = "products", key = "#category + ':' + #page")
public List<Product> getProducts(String category, int page) { ... }

// 객체의 필드
@Cacheable(value = "orders", key = "#request.userId + ':' + #request.status")
public List<Order> getOrders(OrderSearchRequest request) { ... }

커스텀 KeyGenerator

SpEL이 복잡해지면 별도 KeyGenerator를 만드는 것이 깔끔합니다.

JAVA
@Component("customKeyGenerator")
public class CustomKeyGenerator implements KeyGenerator {

    @Override
    public Object generate(Object target, Method method, Object... params) {
        // 클래스명::메서드명::파라미터들
        return target.getClass().getSimpleName()
            + "::" + method.getName()
            + "::" + Arrays.stream(params)
                .map(String::valueOf)
                .collect(Collectors.joining(","));
    }
}

// 사용
@Cacheable(value = "reports", keyGenerator = "customKeyGenerator")
public Report generateReport(String type, LocalDate from, LocalDate to) { ... }

키 네이밍 컨벤션

Redis에서 캐시 키를 확인할 때 구조가 보이도록 prefix를 잘 설계하는 게 중요합니다.

PLAINTEXT
# 권장 형식
myapp::users::42
myapp::products::electronics::1

# Spring Cache 기본 형식 (prefix 설정 시)
myapp::users::42

실무에서 자주 겪는 문제

1. 같은 클래스 내부 호출은 캐시가 동작하지 않음

Spring Cache는 AOP 프록시 기반으로 동작합니다. 같은 클래스의 메서드를 this로 호출하면 프록시를 거치지 않아 캐시가 적용되지 않습니다.

JAVA
@Service
public class UserService {

    @Cacheable(value = "users", key = "#userId")
    public User getUser(Long userId) {
        return userRepository.findById(userId).orElseThrow();
    }

    public UserDto getUserDto(Long userId) {
        // 이 호출은 캐시가 동작하지 않습니다 (this를 통한 내부 호출)
        User user = this.getUser(userId);
        return UserDto.from(user);
    }
}

해결 방법은 크게 두 가지입니다.

  • ** 별도 클래스로 분리** — 캐시 메서드를 다른 서비스로 추출합니다 (가장 권장)
  • self 주입 — 자기 자신을 주입받아 프록시를 통해 호출합니다
JAVA
@Service
public class UserService {

    private final UserCacheService userCacheService;  // 캐시 메서드를 분리

    public UserDto getUserDto(Long userId) {
        User user = userCacheService.getUser(userId);  // 프록시를 통한 호출
        return UserDto.from(user);
    }
}

2. null 캐싱 문제

기본적으로 Spring Cache는 null 반환값도 캐시합니다. 존재하지 않는 데이터를 반복 조회하면 null이 캐시되어 DB를 보호하는 효과가 있지만, 데이터가 나중에 생성되었을 때 계속 null을 반환하는 문제가 됩니다.

JAVA
// 방법 1: unless로 null 캐싱 방지
@Cacheable(value = "users", key = "#userId", unless = "#result == null")
public User getUser(Long userId) {
    return userRepository.findById(userId).orElse(null);
}

// 방법 2: 글로벌 설정으로 비활성화
RedisCacheConfiguration.defaultCacheConfig()
    .disableCachingNullValues();

disableCachingNullValues()를 설정한 상태에서 메서드가 null을 반환하면 IllegalArgumentException이 발생합니다. null을 반환할 가능성이 있는 메서드에는 unless = "#result == null"을 사용하는 것이 안전합니다.

3. TTL 전략

캐시에 TTL을 설정하지 않으면 데이터가 영원히 남아 메모리 문제가 생깁니다. 반대로 TTL이 너무 짧으면 캐시 히트율이 떨어집니다.

데이터 특성권장 TTL예시
거의 변하지 않는 데이터1~24시간공지사항, 약관
주기적으로 변하는 데이터5~30분상품 목록, 랭킹
실시간성이 중요한 데이터1~5분 또는 캐시 미적용재고, 잔액

정리

어노테이션동작메서드 실행
@Cacheable캐시 조회 → 미스 시 실행 후 저장캐시 미스 시에만
@CacheEvict캐시 삭제항상 실행
@CachePut항상 실행 후 결과를 캐시에 저장항상 실행
@Caching위 어노테이션들의 조합조합에 따라 다름
@CacheConfig클래스 레벨 공통 설정-

Spring Cache 추상화를 사용하면 캐시 로직을 비즈니스 코드에서 분리할 수 있고, Redis 외에 다른 캐시 저장소로 교체할 때도 어노테이션은 그대로 유지됩니다. 다만 프록시 기반의 한계(내부 호출 문제)와 직렬화 설정은 반드시 짚고 넘어가야 합니다.

댓글 로딩 중...