같은 데이터를 조회하는 요청이 초당 수천 번 들어온다면, 매번 DB를 조회하는 게 맞을까요?

캐싱은 반복적인 데이터 접근을 최적화하는 가장 효과적인 방법 중 하나입니다. Spring은 캐시 추상화를 통해 구현체에 독립적인 캐싱 기능을 제공합니다. @Cacheable 하나면 메서드 결과를 자동으로 캐시할 수 있습니다.

캐시 추상화란

Spring의 캐시 추상화는 어떤 캐시 구현체를 사용하든 동일한 어노테이션으로 캐싱 할 수 있게 해주는 계층입니다. ConcurrentHashMap이든 Redis든 Caffeine이든, 코드 변경 없이 구현체만 교체할 수 있습니다.

핵심 구성 요소는 이렇습니다.

  • Cache: 실제 캐시 저장소 인터페이스
  • CacheManager: Cache 인스턴스를 관리하는 팩토리
  • ** 캐시 어노테이션 **: @Cacheable, @CachePut, @CacheEvict, @Caching

@Cacheable — 캐시 조회 + 저장

가장 기본적인 캐시 어노테이션입니다. 캐시에 값이 있으면 메서드를 실행하지 않고 캐시된 값을 반환합니다.

JAVA
@Service
public class ProductService {

    @Cacheable("products")
    public Product findById(Long id) {
        // 캐시 미스일 때만 실행됨
        log.info("DB에서 상품 조회: {}", id);
        return productRepository.findById(id)
                .orElseThrow(() -> new NotFoundException("상품을 찾을 수 없습니다"));
    }
}

동작 흐름은 이렇습니다.

  1. 메서드 호출 시 캐시 키를 생성 (기본값: 메서드 파라미터)
  2. "products" 캐시에서 해당 키로 조회
  3. ** 캐시 히트 **: 캐시된 값 반환 (메서드 실행 안 함)
  4. ** 캐시 미스 **: 메서드 실행 → 결과를 캐시에 저장 → 결과 반환

캐시 키 지정 — key 속성

기본적으로 메서드 파라미터가 캐시 키가 됩니다. SpEL(Spring Expression Language)로 키를 커스텀할 수 있습니다.

JAVA
// 파라미터의 특정 필드를 키로 사용
@Cacheable(value = "users", key = "#request.email")
public User findByEmail(UserSearchRequest request) { ... }

// 여러 파라미터 조합
@Cacheable(value = "products", key = "#category + ':' + #page")
public List<Product> findByCategory(String category, int page) { ... }

// 메서드 이름 포함
@Cacheable(value = "reports", key = "#root.methodName + ':' + #date")
public Report getReport(LocalDate date) { ... }

자주 쓰는 SpEL 표현식을 정리하면 이렇습니다.

표현식설명
#파라미터명메서드 파라미터 참조
#p0, #a0첫 번째 파라미터 (인덱스)
#result메서드 반환값 (unless에서 사용)
#root.methodName메서드 이름
#root.targetClass대상 클래스

condition과 unless — 조건부 캐싱

JAVA
@Cacheable(
    value = "products",
    condition = "#id > 0",           // 실행 전: id가 양수일 때만 캐시 사용
    unless = "#result.price == 0"    // 실행 후: 가격이 0이면 캐시하지 않음
)
public Product findById(Long id) { ... }

condition vs unless 차이:

속성평가 시점true일 때
condition메서드 실행 전캐시 사용 (조회/저장)
unless메서드 실행 후캐시에 저장하지 않음

unless에서는 #result로 반환값을 참조할 수 있지만, condition에서는 반환값을 사용할 수 없습니다.

JAVA
// null 결과는 캐시하지 않기
@Cacheable(value = "users", unless = "#result == null")
public User findByEmail(String email) { ... }

// 특정 조건에서만 캐시 조회 (조건 불충족 시 항상 메서드 실행)
@Cacheable(value = "config", condition = "#env == 'production'")
public Config getConfig(String env) { ... }

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

@CachePut은 메서드를 ** 항상 실행 **하고, 결과를 캐시에 저장합니다. 데이터를 갱신할 때 캐시도 함께 갱신하는 용도입니다.

JAVA
@CachePut(value = "products", key = "#product.id")
public Product update(Product product) {
    // 항상 실행됨
    return productRepository.save(product);
}

@Cacheable과의 차이를 명확히 이해해야 합니다.

  • @Cacheable: 캐시 히트 시 메서드 실행 안 함
  • @CachePut: 항상 메서드 실행, 결과를 캐시에 저장

@CacheEvict — 캐시 삭제

캐시된 데이터를 무효화할 때 사용합니다.

JAVA
// 특정 키의 캐시 삭제
@CacheEvict(value = "products", key = "#id")
public void delete(Long id) {
    productRepository.deleteById(id);
}

// 해당 캐시의 모든 엔트리 삭제
@CacheEvict(value = "products", allEntries = true)
public void bulkUpdate(List<Product> products) {
    productRepository.saveAll(products);
}

// 메서드 실행 전에 캐시 삭제
@CacheEvict(value = "products", beforeInvocation = true)
public void riskyDelete(Long id) {
    // 메서드 실행 전에 캐시가 삭제됨
    // 메서드가 실패해도 캐시는 이미 삭제된 상태
    productRepository.deleteById(id);
}

beforeInvocation 기본값은 false입니다. true로 설정하면 메서드 실행 전에 캐시를 삭제하므로, 메서드 실패 시에도 캐시가 이미 삭제된 상태입니다.

@Caching — 여러 캐시 연산 조합

하나의 메서드에 여러 캐시 연산을 적용할 때 사용합니다.

JAVA
@Caching(
    put = {
        @CachePut(value = "productsById", key = "#result.id"),
        @CachePut(value = "productsByCode", key = "#result.code")
    },
    evict = {
        @CacheEvict(value = "productList", allEntries = true)
    }
)
public Product create(ProductCreateRequest request) {
    return productRepository.save(request.toEntity());
}

활성화 설정

캐시를 사용하려면 @EnableCaching을 활성화해야 합니다.

JAVA
@Configuration
@EnableCaching
public class CacheConfig {

}

Spring Boot에서는 spring.cache.type으로 캐시 구현체를 지정합니다.

YAML
spring:
  cache:
    type: caffeine  # none, simple, caffeine, redis 등
    caffeine:
      spec: maximumSize=1000,expireAfterWrite=10m

프록시 기반 동작의 주의점

Spring의 캐시 추상화는 AOP 프록시로 동작합니다. 이로 인한 주의점이 있습니다.

JAVA
@Service
public class ProductService {

    @Cacheable("products")
    public Product findById(Long id) { ... }

    public Product findWithDetails(Long id) {
        // 같은 클래스 내부 호출 → 프록시를 거치지 않아 캐시가 동작하지 않음!
        Product product = findById(id);
        // ...
        return product;
    }
}

** 해결 방법:**

  • 캐시 메서드를 별도 빈(서비스)으로 분리
  • self-injection 패턴 사용
  • AspectJ 위빙 사용 (컴파일 타임/로드 타임)
JAVA
// 해결: 별도 빈으로 분리
@Service
public class ProductCacheService {
    @Cacheable("products")
    public Product findById(Long id) { ... }
}

@Service
public class ProductService {
    private final ProductCacheService cacheService;

    public Product findWithDetails(Long id) {
        Product product = cacheService.findById(id);  // 프록시를 통해 호출
        // ...
        return product;
    }
}

주의할 점

1. @Cacheable@CachePut을 같은 메서드에 함께 사용하면 예측 불가능한 동작이 발생한다

@Cacheable은 캐시 히트 시 메서드를 실행하지 않지만, @CachePut은 항상 메서드를 실행합니다. 둘을 동시에 붙이면 실행 여부가 모호해져 의도한 대로 동작하지 않습니다. 조회는 @Cacheable, 갱신은 @CachePut으로 메서드를 분리하세요.

2. SpEL 키 표현식에서 null 파라미터를 처리하지 않으면 NullPointerException이 발생한다

@Cacheable(key = "#request.email") 같은 SpEL 표현식에서 request가 null이거나 email 필드가 null이면 런타임에 예외가 발생합니다. 캐시 키에 사용하는 파라미터가 null이 될 수 있는지 반드시 확인하고, 필요하다면 condition으로 null 케이스를 제외해야 합니다.

3. @CacheEvict(allEntries = true)는 운영 환경에서 성능 이슈를 일으킬 수 있다

Redis 같은 분산 캐시에서 allEntries = true는 해당 캐시 이름의 모든 키를 순회하며 삭제합니다. 캐시 엔트리가 수만 건 이상이면 삭제 자체가 Redis를 블로킹하여 다른 요청에 영향을 줄 수 있습니다. 대량 데이터를 다루는 캐시에서는 개별 키 삭제나 TTL 기반 만료를 우선 고려하세요.

정리

  • @Cacheable은 캐시 히트 시 메서드를 실행하지 않고, @CachePut은 항상 실행 후 캐시를 갱신합니다
  • condition은 실행 전, unless는 실행 후 평가되며, unless에서만 #result를 사용할 수 있습니다
  • @CacheEvictallEntriesbeforeInvocation 옵션을 상황에 맞게 사용하세요
  • ** 같은 클래스 내부 호출 **에서는 프록시를 거치지 않아 캐시가 동작하지 않으므로 빈을 분리해야 합니다
댓글 로딩 중...