스프링

[Spring Boot/Kotlin] Spring Cache 어노테이션 심화 가이드

Stitchhhh 2026. 2. 26. 10:02

1. 개요

Spring Cache 추상화의 가장 큰 장점은 비즈니스 로직에 캐시 코드를 섞지 않고, 어노테이션(Annotation) 만으로 선언적인 캐싱을 적용할 수 있다는 점입니다. 본 보고서는 Spring Cache의 핵심 어노테이션인 @Cacheable, @CachePut, @CacheEvict 등의 기능과 속성을 상세히 분석하고, Kotlin 환경(Data Class, Coroutines)에서의 구체적인 사용 패턴을 제시합니다.

2. 핵심 어노테이션 상세 분석

2.1 @Cacheable: 조회 및 저장의 자동화

가장 빈번하게 사용되는 어노테이션으로, 캐시 룩어사이드(Look-aside) 전략을 수행합니다. 데이터가 캐시에 있으면 반환하고, 없으면 메서드를 실행 후 결과를 캐시에 저장합니다.

2.1.1 기본 사용법 및 키 생성

@Service
class ProductService(private val repository: ProductRepository) {

    // 1. 기본: 파라미터(id)가 자동으로 키가 됨
    // 캐시 이름: "products", 키: id 값
    @Cacheable("products")
    fun getProduct(id: Long): ProductDto {
        return repository.findById(id).toDto()
    }
    
    // 2. 복합 키: SpEL을 사용하여 여러 파라미터 조합
    // 키 예시: "IT_books"
    @Cacheable(value = ["products"], key = "#category + '_' + #type")
    fun getProductsByCategory(category: String, type: String): List<ProductDto> {... }
    
    // 3. 객체 내부 프로퍼티 접근 (Kotlin Data Class)
    // 주의: 컴파일러의 -parameters 옵션이 켜져 있거나, getter 스타일 접근이 필요할 수 있음
    @Cacheable(value = ["users"], key = "#searchRequest.email")
    fun findUser(searchRequest: UserSearchRequest): UserDto {... }
}

2.1.2 조건부 캐싱 (condition vs unless)

모든 데이터를 캐싱하는 것은 비효율적일 수 있습니다. 특정 조건에서만 캐싱하거나, 특정 결과는 캐싱하지 않아야 할 때 사용합니다.

  • condition: 메서드 실행 에 평가합니다. true일 때만 캐시를 적용(조회/저장)합니다.
  • unless: 메서드 실행 에 평가합니다. true이면 캐싱을 거부합니다. (주로 null이나 에러 상황 배제에 사용)
@Cacheable(
    value = ["heavyCalculations"],
    key = "#number",
    condition = "#number > 1000", 
    unless = "#result == null || #result == 0"
)
fun heavyCalculation(number: Int): Int? {... }

2.1.3 sync 속성과 Cache Stampede 방지

sync = true로 설정하면, 멀티 스레드 환경에서 동일한 키에 대한 요청이 동시에 들어올 경우, 단 하나의 스레드만 메서드를 실행하고 나머지 스레드는 결과를 기다리게 합니다. 이는 'Cache Stampede' 현상을 방지하는 데 매우 유용합니다.

// 인기 게시글 조회 시 수천 명의 동시 요청이 DB로 튀는 것을 방지
@Cacheable(value = ["popularPosts"], key = "#postId", sync = true)
fun getPopularPost(postId: Long): PostDto {... }

2.2 @CachePut: 캐시 강제 갱신

메서드를 항상 실행하고, 그 결과값으로 캐시 내용을 갱신합니다. 주로 데이터의 생성(Create)이나 수정(Update) 시에 사용되어, 데이터베이스와 캐시 간의 정합성(Consistency)을 유지합니다.

@Service
class MemberService(private val repository: MemberRepository) {

    // DB 업데이트 후, 변경된 객체(결과값)를 캐시에 다시 덮어씀
    // 반환 타입이 @Cacheable 메서드의 반환 타입과 일치해야 함!
    @CachePut(value = ["members"], key = "#memberDto.id")
    fun updateMember(memberDto: MemberDto): MemberDto {
        val updated = repository.save(memberDto.toEntity())
        return updated.toDto()
    }
}

 

주의사항: @Cacheable과 함께 사용하면 안 됩니다. (@Cacheable은 실행을 건너뛰려 하고, @CachePut은 실행하려 하므로 충돌 발생)


2.3 @CacheEvict: 캐시 삭제

데이터가 삭제되거나, 캐시가 더 이상 유효하지 않을 때 데이터를 제거합니다.

  • allEntries = true: 키와 상관없이 해당 캐시 저장소("menu")의 모든 데이터를 비웁니다.
  • beforeInvocation = true: 메서드 실행 에 캐시를 지웁니다. (메서드 실행 중 예외가 발생해도 캐시는 확실히 지워짐)
// 특정 ID의 메뉴만 캐시에서 제거
@CacheEvict(value = ["menu"], key = "#menuId")
fun deleteMenu(menuId: Long) {
    repository.deleteById(menuId)
}

// 메뉴 전체 목록 캐시를 통째로 날림 (대량 업데이트 시 유용)
@CacheEvict(value = ["menuList"], allEntries = true)
fun refreshAllMenus() {
    // 로직 없음, 단순히 캐시 초기화 트리거로 사용 가능
}

2.4 @Caching: 여러 작업 묶기

하나의 메서드 실행으로 여러 종류의 캐시를 동시에 조작해야 할 때 사용합니다. 예를 들어 주소 정보를 수정하면 '회원 정보 캐시'와 '배송 정보 캐시'를 모두 날려야 할 수 있습니다.

@Caching(
    evict = [
        CacheEvict(value = ["users"], key = "#userId"),
        CacheEvict(value = ["shippingInfo"], key = "#userId")
    ],
    put = [
        CachePut(value = ["auditLog"], key = "#userId")
    ]
)
fun updateUserStatus(userId: Long, status: String): User {... }

2.5 @CacheConfig: 클래스 레벨 공통 설정

클래스 내의 모든 메서드가 동일한 캐시 이름(cacheNames)이나 CacheManager를 공유할 때, 이를 클래스 레벨로 올려 중복을 제거합니다.

@Service
@CacheConfig(cacheNames = ["users"], cacheManager = "redisCacheManager")
class UserService {

    @Cacheable(key = "#id") // value = ["users"] 생략 가능
    fun findById(id: Long) {... }

    @CacheEvict(key = "#id") // value = ["users"] 생략 가능
    fun delete(id: Long) {... }
}

3. Kotlin 특화 이슈와 해결책

3.1 코루틴(Coroutines)과 Suspend 함수 지원 (Spring 6.1+)

과거에는 suspend 함수에 @Cacheable을 붙이면 Continuation 파라미터 때문에 키 생성에 문제가 생기거나, 비동기 처리가 제대로 되지 않았습니다. 하지만 Spring Framework 6.1부터는 suspend 함수를 공식적으로 지원합니다.

// Spring 6.1 이상: 별도의 설정 없이 suspend 함수 캐싱 가능
@Service
class WeatherService {

    @Cacheable("weather")
    suspend fun getWeather(city: String): WeatherData {
        // WebClient 등을 이용한 비동기 논블로킹 호출
        return weatherClient.fetch(city).awaitSingle()
    }
}

 

참고: sync = true 옵션 역시 Spring 6.1부터 CompletableFuture 및 리액티브 타입과 함께 지원되므로 코루틴 환경에서도 안전하게 Cache Stampede를 방지할 수 있습니다.

 

3.2 [참고] SpEL 파라미터 이름 인식 에러 대응

만약 @Cacheable(key = "#id") 사용 시 SpelEvaluationException이 발생한다면, Kotlin 컴파일러가 바이트코드에서 파라미터 이름을 제거했기 때문입니다. 최근 Spring Boot 환경에서는 자동 설정되지만, 문제가 발생할 경우 아래 설정을 추가합니다.

  • 해결책: build.gradle.kts에 -java-parameters 옵션 명시
  •  
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
    compilerOptions {
        freeCompilerArgs.add("-java-parameters")
    }
}

4. 실전 시나리오 예제

시나리오 A: 검색 결과 캐싱과 DTO 반환

검색 조건(SearchCondition 객체)에 따라 결과를 캐싱하되, 결과가 없으면 캐싱하지 않습니다. Data Class를 키로 사용할 때는 hashCode()가 올바르게 구현되어 있으므로 객체 자체를 키로 써도 무방합니다.

data class SearchCondition(val keyword: String, val page: Int)

@Service
class SearchService {

    @Cacheable(
        value =,
        key = "#condition", // SearchCondition 객체의 hashCode를 키로 사용
        unless = "#result.isEmpty()" // 빈 리스트는 캐싱 안 함
    )
    fun search(condition: SearchCondition): List<Item> {
        return repository.search(condition)
    }
}

시나리오 B: 예외 발생 시 캐싱 방지

@Cacheable은 메서드 실행 중 예외(Exception)가 발생하면 자동으로 캐싱을 수행하지 않습니다. 따라서 별도의 설정 없이도 에러 응답이 캐시되는 것을 막을 수 있습니다.

시나리오 C: TTL(만료 시간) 개별 설정

어노테이션 자체에는 TTL 속성이 없습니다. 각 캐시 이름(cacheNames)별로 TTL을 다르게 가져가려면 CacheManager 설정에서 정의해야 합니다.

// 어노테이션에서는 이름만 지정
@Cacheable("shortLivedData") // 1분 만료
fun getDataA() {... }

@Cacheable("longLivedData") // 1시간 만료
fun getDataB() {... }