Spring Cache 이해하기

김태현·2023년 9월 28일

Caching

캐시 정의

Caching(캐싱)은 데이터나 계산 결과를 임시로 저장하는 기술 또는 메커니즘을 가리킵니다. 주로 데이터에 접근할 때 발생하는 비용을 줄이고 성능을 향상시키는 목적으로 사용됩니다.
즉 데이터를 조회해오는데 비용이 큰 데이터를 한 번 조회한 뒤, 속도가 빠른 임시 공간에 저장해둠으로써 애플리케이션 처리속도를 높이는 방식입니다.

캐시 동작 과정

캐시는 다음과 같은 방식으로 동작합니다.
1. 클라이언트(예: 웹 브라우저, 애플리케이션)가 데이터나 결과를 필요로 하는 요청을 보냅니다. 이 요청은 서버 또는 어떤 데이터 소스(예: 데이터베이스, 외부 API)에 대한 작업을 의미합니다.
2. 서버(또는 캐시 서버)는 요청을 받으면 먼저 캐시에 해당 데이터나 결과가 저장되어 있는지 확인합니다. 이를 "Cache Check"라고 합니다.
3. 캐시에서 요청한 데이터나 결과를 찾지 못하면 이를 "캐시 미스"라고 합니다. 이 경우 원본 데이터 소스(예: 데이터베이스, 외부 서비스)로부터 데이터를 가져와서 캐시에 저장합니다.
4. 캐시 히트: 캐시에서 요청한 데이터나 결과를 찾으면 이를 "캐시 히트"라고 합니다. 이 경우 원본 데이터 소스에 접근하지 않고 캐시에서 데이터를 반환합니다.

캐시 히트율을 높이는 방법

캐시미스가 발생하는 경우 실제 데이터베이스에서 데이터를 가져와야 하기 때문에 비용이 발생합니다. 캐시의 활용도를 높이기 위해서는 캐시 히트율을 높이는 것이 중요합니다.

캐시 히트율을 높이기 위해서, 자주 참조되고 수정이 잘 발생하지 않는 데이터들로 캐시를 구성해야합니다. 데이터의 수정이 자주 발생하는 경우 데이터베이스 접근 및 캐시 데이터 일관성 처리 과정이 필요합니다.

Local Caching vs Global Caching

Local Cache

로컬캐시는 로컬 서버 내부 저장소에 데이터를 보관합니다. 서버에서 데이터에 바로 접근할 수 있기 때문에 속도가 빠르다는 장점이 있지만, 다중 서버 환경에서는 각 서버에 중복된 데이터를 보관해야 하며 서버간 데이터 일관성이 깨질수 있습니다.

Global Cache

글로벌캐시는 서버와 분리된 별도의 저장소에 데이터를 보관하는 것입니다. 네트워크를 타기 때문에 로컬캐시에 비해 속도가 느리지만 중복 데이터 및 데이터 일관성 문제가 없다는 장점이 있습니다.

스프링 캐시 추상화

캐시 추상화(Cache Abstraction)는 다양한 캐시 프로바이더(예: Redis, Ehcache, Caffeine 등)와 상호작용하는 코드를 작성하는 프로세스를 단순화하고 표준화하기 위한 개념입니다. 캐시 추상화는 캐시를 사용하는 애플리케이션 코드를 캐시 프로바이더에 종속되지 않도록 해줍니다.

즉 캐시 추상화를 사용하면 개발자는 캐시 프로바이더의 구체적인 구현에 대해 신경쓰지 않고 캐시를 사용할 수 있으며 필요에 따라 다양한 캐시 프로바이더로 전환할 수 있습니다.

캐시 매니저(CacheManager)

캐시매니저는 캐시 인스턴스를 관리하고 캐시에 접근할 수 있는 방법을 제공합니다. Spring은 CacheManager 인터페이스를 제공하며, 이 인터페이스를 구현한 다양한 캐시매니저가 존재합니다. 각 캐시 프로바이더에 대한 캐시매니저 구현체가 제공되므로 개발자는 선택한 캐시 프로바이더에 맞게 캐시 매니저를 구성할 수 있습니다.

ConcurrentMapCacheManager: ConcureentHashMap에 저장한다.
EhCacheCacheManager: EhCache를 지원한다.
RedisCacheManager: Redis를 지원한다.
그밖에 다양한 캐시 매니저가 존재하며 캐싱 전략에 따라 적절한 캐시 매니저를 활용할 수 있다.

선언적 어노테이션 기반 캐싱

Spring은 @Cacheable, @CachePut, @CacheEvict 등의 캐시 관련 어노테이션을 제공하여 메서드에 캐싱 로직을 쉽게 적용할 수 있도록 해줍니다. 이런 어노테이션을 사용하면 개발자는 명시적으로 캐싱 로직을 작성할 필요 없이 캐시를 쉽게 활용할 수 있습니다.

@Cacheable

메서드 결과를 캐싱하고 동일한 메서드가 동일한 인자로 호출될 때 캐시된 결과를 반환한다. 메서드가 호출되면 먼저 캐시에서 결과를 찾고, 반환해야할 데이터가 캐시에 없는 경우 메서드 로직을 타고 해당 결과를 캐시에 저장한 뒤 반환합니다.

@CachePut

메서드의 실행 결과를 캐시에 강제로 저장합니다. 주로 메서드의 실행 결과를 캐시에 업데이트 할 때 사용합니다. 이 어노테이션을 사용하면 메서드는 항상 실행되고 결과를 캐시에 저장합니다.

@CacheEvict

캐시에서 데이터를 제거하는데 사용합니다. 특정 메서드가 실행될 때 캐시에서 데이터를 제거하기 위해 사용합니다. 제거할 대상 데이터를 정의하기 위해 keycondition 등의 속성을 사용할 수 있습니다.

@Caching

여러개의 캐싱 어노테이션을 조합하여 사용할 때 유용합니다. 예를들어 한 메서드에서 여러개의 캐시 조작을 수행해야 할 경우 이 어노테이션을 사용하여 여러 어노테이션을 조합할 수 있습니다.

어노테이션 사용 예제

@Cacheable

이 어노테이션을 메서드에 적용하면 메서드의 결과가 캐시에 저정되고, 이후 해당 메서드가 동일한 인자로 호출될 때 메서드를 실행하지 않고 캐시에서 결과를 반환합니다.
@Cacheable 어노테이션의 주요 속성과 사용법은 다음과 같습니다.

1. value(필수)

캐시의 이름을 지정합니다. 이 이름은 캐시 관리자 또는 설정에서 실제 캐시 구현체에 대한 설정과 연결됩니다.

@Cacheable(value = "book")
public Book findBook(String isbn) {...}

2. key

캐시 키를 생성하기 위한 SpEL 표현식을 지정합니다. 기본적으로 메서드의 모든 파라미터를 조합하여 캐시키를 생성합니다.

@Cacheable(value = "book", key = "#isbn")
public Book findBook(String isbn) {...}

커스텀키 생성

커스텀 키 생성은 @Cacheable 어노테이션을 사용할 때, 캐시의 키를 자동으로 생성하는 대신 개발자가 직접 지정하고자 할 때 사용하는 기능입니다. 이를 통해 캐시 키를 더 세밀하게 제어하고 원하는 방식으로 구성할 수 있습니다.

커스텀 캐시 키 생성이 필요한 경우는 다음과 같습니다.

A. 다중 인자 메서드의 특정 인자를 사용해서 캐시를 구분하고 싶을때

온라인 서점의 상품 조회 메서드가 있다고 가정해보겠습니다. 이 메서드는 상품 ID와 언어 설정을 인자로 받습니다. 상품 조회 쿼리는 언어 설정에 따라 다른 결과를 반환하기 때문에, 캐시를 언어 설정에 따라 구분하고 싶습니다.

@Cacheable(value = "products", key = "#productId" + '-' + #language")
public Product findProduct(String productId, String language) {...}

위의 예시에서는 #productId와 #language를 조합하여 캐시키를 생성합니다. 만약 findProduct(”123”, “ko”)가 호출되었다면 캐시키는 “123-ko”가 되어 이 설정으로 캐시된 결과를 반환합니다.

B. 동적인 키 생성이 필요한 경우

만약 로그인 사용자의 데이터를 캐싱하고자 할 때, 사용자마다 다른 데이터를 제공해야 하며 로그인 사용자의 고유한 식별자를 기반으로 캐시키를 생성할 수 있습니다.

@Cacheable(value = "userData", key = "#userId")
public UserData getUserData(long userId) {...}

이렇게하면 각 사용자의 userId를 기반으로 캐시키를 생성하고 다른 사용자의 데이터를 구분하여 캐시할 수 있습니다.

C. 다양한 캐시 영역을 사용할 때

쇼핑 애플리케이션에서 상품 정보와 리뷰 정보를 별도의 캐시영역에 저장하고 싶다면 각 캐시 영역에 대해 다른 키를 사용하여 구분할 수 있습니다.

@Cacheable(value = "productInfo", key = "#productId")
public Product getProductInfo(Stirng productId) {...}

@Cacheable(value = "productReviews", key = "#productId")
public List<Review> getProductReviews(String productId) {...}

이러한 방식으로 각각 다른 캐시 영역에 동일한 #productId를 기반으로 서로 다른 캐시키를 생성하고 사용할 수 있습니다.

커스텀 캐시 키 생성은 이렇게 다양한 상황에서 유용하게 활용될 수 있으며 캐싱 로직을 더 세밀하게 제어할 수 있습니다.

3. condition

캐시를 적용할지 여부를 결정하기 위해 SpEL 표현식을 지정합니다. 이 표현식이 true 인 경우에만 캐시가 적용됩니다.

@Cachable(value = "products", condition = "#quantity > 0")
public Product findProduct(String productId, int quantity) {...}

condition에 지정된 조건이 만족되면 (quantity가 0보다 큰 경우) 결과를 캐시에 적용하고 같은 인자로 호출될 때는 캐시에서 결과를 반환합니다.

4. unless

캐시를 적용하지 않는 여부를 결정하기 위해 SpEL 표현식을 지정합니다. 이 표현식이 true 인 경우에는 캐시가 적용되지 않습니다.

@Cacheable(value = "users", unless = "#result.isAdmin")
public User findUser(String username) {...}

사용자가 관리자 권한을 가진경우(isAdmin이 true인 경우) unless 조건에 해당하여 결과를 캐시에 저장하지 않습니다. 즉 다음번 같은 인자로 메서드가 호출되어도 캐시를 반환하지 않고 메서드를 실행하여 최신 결과를 가져오게 됩니다.

반대로 사용자가 관리자 권한을 가지지 않는 경우(isAdmin이 false인 경우) 결과를 캐시에 저장하고, 같은 인자로 메서드가 호출 되면 캐시에서 결과를 반환하게 됩니다.

@CachePut

@CachePut 어노테이션을 사용하면 메서드의 결과를 항상 캐시에 저장합니다. 주로 데이터를 업데이트하거나 추가할 때 캐시를 갱싱하는데 사용됩니다. 즉 이 어노테이션을 사용하면 메서드가 호출될 때마다 항상 캐시를 갱싱할 수 있습니다.

@CachePut(value = "users", key = "#userId")
public User updateUser(String userId, User updatedUser) {
		User foundUser = userRepository.findById(userId);
		
		if (foundUser != null) {
				existingUser.setName(updatedUser.getName());
        existingUser.setEmail(updatedUser.getEmail());
		}
		return foundUser;
}

위 코드에서 @CachePut 어노테이션은 updateUser 메서드를 캐시 대상으로 지정하고, value 속성에 users라는 캐시이름을 지정했습니다. 또한 key 속성을 사용하여 #userId로 지정하여 userId 값을 기반으로 캐시키를 생성합니다.

이렇게하면 updateUser 메서드가 호출될 때마다 사용자 정보를 업데이트하고 업데이트된 정보를 캐시에 갱신합니다. 따라서 캐시는 항상 최신 정보를 유지하게 됩니다.

@CachePut 어노테이션은 주로 데이터 갱신 또는 추가 작업을 수행할 때 사용되며, 메서드의 결과를 캐시에 저장하여 성능을 향상시킬 수 있습니다.

@CacheEvict

@CacheEvict 어노테이션은 캐시에서 데이터를 삭제하고자 할 때 사용됩니다. 이 어노테이션을 사용하면 메서드가 호출될 때 지정된 캐시에서 데이터를 삭제할 수 있습니다.

@CacheEvict(value = "users", key = "#userId")
public void deleteUser(String userId) {
    userRepository.delete(userId);
}

위 코드에서 @CacheEvict 어노테이션은 deleteUser 메서드를 캐시 대상으로 지정하고 value 속성에 users 라는 캐시 이름을 지정했습니다. 또한 key 속성을 사용해서 userId 값을 기반으로 캐시에서 해당 사용자 정보를 삭제합니다.

이렇게하면 delteUser 메서드가 호출될 때마다 사용자 정보를 삭제하고 캐시에서도 해당 사용자 정보를 제거할 수 있습니다.

@CacheEvict 어노테이션은 캐시에서 데이터를 명시적으로 삭제할 때 사용되며, 데이터를 업데이트하거나 삭제하는 작업에서 주로 사용됩니다.

캐시 어노테이션 활성화

캐시 어노테이션을 선언하는 것만으로는 자동으로 캐시 기능이 동작하지 않습니다. 즉 선언적으로 캐시 기능을 확성화 해야합니다. 이는 캐시에 문제가 있다고 의심되면 코드에 적용된 모든 캐시 어노테이션을 지우는 대신 설정에서 딱 한줄만 지워서 캐시를 비활성화 할 수 있다는 의미입니다.

  1. @EnableCaching 어노테이션을 사용하여 캐싱을 활성화합니다. 이 어노테이션을 사용하면 스프링 캐시 관련 기능이 활성화되어, @Cacheable, @CachePut, @CacheEvict 등의 캐시 어노테이션을 사용할 수 있게 됩니다.
  2. 캐시 관련 설정을 추가합니다. Spring의 캐시 설정은 다양한 캐시 프로바이더(예: EhCache, Caffeine, Redis)에 따라 드릅니다. 설정 파일 또는 Java 구성 클래스를 사용하여 어떤 캐시 프로바이더를 사용할 것인지 설정해야합니다.
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableCaching // 캐시 활성화
public class CacheConfig {
}

@EnableCaching 어노테이션을 추가하고 해당 클래스를 구성 클래스로 지정하면 캐시가 활성화됩니다. 그리고 application.properties 또는 application.yml 파일에서 캐시 관련 설정을 지정할 수 있습니다.

Redis

spring:
  cache:
    type: redis
  redis:
    host: localhost
    port: 6379

spring.cache.typeredis로 지정하여 Redis를 캐시 프로바이더로 사용하고, spring.redis.hostspring.redis.port를 사용하여 Redis 서버 호스트 및 포트를 구성합니다.

EhCache

spring:
  cache:
    type: ehcache
  ehcache:
    config: classpath:ehcache.xml

spring.cache.typeehcache로 지정하여 EhCache를 캐시 프로바이더로 사용하고, ehcache.config를 사용하여 EhCache 설정 파일 경로를 지정합니다.

Caffeine

spring:
  cache:
    type: caffeine
  caffeine:
    spec: maximumSize=100,expireAfterAccess=5m

spring.cache.typecaffeine으로 지정하여 Caffeine을 캐시 프로바이더로 사용하고, caffeine.spec를 사용하여 Caffeine 캐시의 구성을 지정합니다. 위의 예제에서는 최대 크기를 100개로, 접근 후 5분 동안 캐시를 유지하도록 설정하였습니다.

각각의 설정은 해당 캐시 프로바이더에 대한 기본적인 구성입니다. 실제로 사용할 때에는 애플리케이션의 요구 사항에 따라 캐시 설정을 더 세밀하게 조정해야 합니다. 또한 Redis, EhCache, Caffeine 외에도 다른 캐시 프로바이더를 사용하려면 해당 프로바이더에 대한 의존성을 추가하고 적절한 설정을 수행해야 합니다.

참고문헌

https://www.baeldung.com/spring-cache-tutorial

profile
안녕하세요. Java&Spring 기반 백엔드 개발자 김태현입니다.

0개의 댓글