Spring Cache에 대해 알아보자

라모스·2023년 5월 14일
1
post-thumbnail

애플리케이션을 개발하며 쓰기 동작보다 읽기 동작이 많은 데이터가 있다면 캐시 도입을 고민할 수 있다.

예를 들어, 상품의 카테고리 목록은 자주 바뀌지 않는 데이터들인데 쇼핑몰 내에서 페이지를 이동 할 때마다 전체 카테고리들을 DB에서 매번 Query해서 모두 불러오는 것은 비효율적이기에 이런 경우에도 캐시를 적용하면 좋은 케이스다.

일반적으로 캐시는 메모리에 데이터를 미리 적재하고 이를 빠르게 읽어 응답하는 구조다. 그래서 읽기 동작이 많은 서비스에 캐시를 사용하면 서비스 응답 속도를 향상할 수 있고, 시스템 리소스도 효율적으로 사용할 수 있다.

Spring은 다양한 저장소에 데이터를 캐시할 수 있는 기능을 제공한다. 또한 저장소에 독립적이고 추상화된 캐시 메커니즘을 제공한다. AOP 기반의 애너테이션을 제공하여 간편하게 캐시 기능을 운영 중인 애플리케이션에 도입할 수 있다.

레디스와 Spring Cache의 사용법을 간단하게 살펴보자.

Redis

레디스의 특징을 나열하자면 크게 다음과 같다.

  • 메모리 기반의 데이터 저장소다.
  • Key-Value 데이터 구조에 기반한 다양한 형태의 자료 구조를 제공하며, 데이터들을 저장할 수 있는 데이터 저장소다.
  • Pub/Sub 형태의 기능을 제공하여 메시지를 전달할 수 있다.
  • 디스크에 데이터를 저장하는 데이터 저장소보다 저장 공간에 제약이 있다.
  • 레디스 클러스터 기능을 제공하여 저장 공간을 확장할 수 있다.
  • 데이터를 영구적으로 디스크에 저장할 수 있는 백업 기능을 제공하므로 애플리케이션의 주 저장소로도 사용 가능하다.
  • 빠른 처리 속도가 장점
  • 레디스 내부에서 명령어를 처리하는 부분은 싱글 스레드 아키텍처로 구현되어 있다.

메모리 특성상 저장된 데이터는 사라질 가능성이 있다. 이를 보완하고자 레디스는 관리하고 있는 데이터에 영속성을 제공한다. 즉, 메모리에 있는 데이터를 디스크에 백업하는 기능을 제공하며 그 방법들은 다음과 같다.

RDB 방식과 AOF 방식

이 두 가지 기능은 각각 사용해도 되지만, 함께 설정하여 상호 보완 기능으로 사용해도 된다.

  • RDB(Redis DataBase): 메모리에 있는 데이터 전체에서 스냅샷을 작성하고 이를 디스크로 저장하는 방식
    • 스냅샷을 생성하는 기능이므로 데이터를 백업하거나 복원하기가 매우 간단하다.
    • 스냅샷 이후에 변경된 데이터는 복구할 수 없다는 단점이 있다.
  • AOF(Append Only File): 레디스에 데이터가 변경되는 이벤트가 발생하면 이를 모두 로그에 저장하는 방식
    • 데이터를 생성/수정/삭제하는 이벤트들을 초 단위로 취합하여 로그 파일에 작성
    • 최신의 데이터 정보 백업 가능 → 레디스 서버 복구 시 RDB 방식에 비해 데이터 유실량이 적다.
    • 로딩 속도와 파일 크기에 대한 단점이 있다.

레디스는 사용자들이 실행한 명령어들을 이벤트 루프 방식으로 처리한다.
이벤트 루프 방식은 클라이언트가 실행한 명령어들을 이벤트 큐에 적재하고 싱글 스레드로 하나씩 처리한다. 멀티 스레드 환경에서 발생할 수 있는 컨텍스트 스위칭이 없으므로 효율적으로 시스템 리소스를 사용할 수 있는 장점이 있다.

  • 데드 락 같은 현상이 발생하지 않는다.
  • 싱글 스레드이므로 이 명령어를 처리하는 동안 다른 명령어를 처리할 수 없다.
  • 레디스 서버를 구축할 때 단독 서버로 아키텍처를 구성하면 장애에 적절히 대응할 수 없다.

레디스 사용 목적

  • 주 데이터 저장소
  • 데이터 캐시: 캐시된 데이터는 한곳에 저장되는 중앙 집중형 구조로 구성된다. MSA 환경의 수평 확장되는 모든 애플리케이션이 레디스 한곳만 바라보므로 데이터 일관성을 유지할 수 있는 장점이 있다.
  • 분산 락: 분산 환경에서 여러 시스템이 동시에 데이터를 처리할 때는 특정 공유 자원의 사용 여부를 검증하여 데드 락을 방지할 필요가 있는데, 이 때 레디스를 분산 락으로 사용할 수 있다.
  • 순위 계산: ZRANGE, ZREVRANGE, ZRANGEBYSCORE ZREVRANGEBYSCORE는 ZSet(Sorted Set) 자료 구조를 사용한다. 이런 자료구조로 쉽고 빠르게 순위를 계산할 수 있다.

Spring Cache

Spring은 일부 데이터를 미리 메모리 저장소에 저장하고 저장된 데이터를 다시 읽어 사용하는 캐시 기능을 제공한다. 트랜잭션과 마찬가지로 AOP를 사용하여 캐시 기능을 구현하였고, 캐시 애너테이션을 사용하면 쉽게 구현할 수 있다. Spring에서 캐시 데이터를 관리하는 기능은 별도의 캐시 프레임워크에 위임한다.

Java의 인터페이스 기능은 Spring Framework 가 제공하고, 구현체 기능은 별도의 캐시 프레임워크에 위임하는 것에 비유할 수 있다.

캐시 저장소를 구성하는 방식은 두 가지로 구분된다.

  1. Java 애플리케이션에 embedded하는 방식(로컬 캐시)
  2. 애플리케이션 외부의 독립 메모리 저장소를 별도로 구축하여 모든 인스턴스가 네트워크를 사용하여 데이터를 캐시하는 방식(원격 캐시)

로컬 캐시 방식으로 아키텍처를 설계하면 애플리케이션은 각각의 캐시 시스템을 가지며, 1:1 방식으로 사용한다. 그러므로 로컬 캐시들은 데이터를 서로 공유할 수 없다. 같은 이름의 데이터라도 각 서버마다 관리하고 있는 캐시 데이터는 다르다.

원격 캐시 아키텍처는 외부에 독립적인 데이터 저장소를 사용한다. 따라서 데이터를 캐시하거나 사용하면 I/O가 발생한다. 로컬 캐시 방식보다 I/O 시간만큼 서버 리소스와 시간이 더 소요된다. 네트워크를 사용하므로 외부 환경으로 캐시 성능이 영향을 받는다. 하지만 어떤 서버라도 모두 같은 데이터를 사용할 수 있고 일관된 방식으로 데이터를 읽고 쓸 수 있는 장점이 있다.

Cache Manager

캐시 추상화에서는 캐시 기술을 지원하는 캐시 매니저를 Bean으로 등록해야 한다.

  • ConcurrentMapCacheManager: JRE에서 제공하는 ConcurrentHashMap을 캐시 저장소로 사용할 수 있는 구현체다. 캐시 정보를 Map 타입으로 메모리에 저장해두기 때문에 빠르고 별다른 설정이 필요 없다는 장점이 있지만, 실제 서비스에서 사용하기엔 기능이 빈약하다.
  • SimpleCacheManager: 기본적으로 제공하는 캐시가 없다. 사용할 캐시를 직접 등록하여 사용하기 위한 캐시 매니저 구현체다.
  • EhCacheCacheManager: Java에서 유명한 캐시 프레임워크 중 하나인 EhCache를 지원하는 캐시 매니저 구현체다.
  • CaffeineCacheManager: Java 8로 Guava 캐시를 재작성한 Caffeine 캐시 저장소를 사용할 수 있는 구현체다. EhCache와 함께 인기 있는 매니저인데, 이보다 좋은 성능을 갖는다고 한다.
  • JCacheCacheManager: JSR-107 표준을 따르는 JCache 캐시 저장소를 사용할 수 있는 구현체다.
  • RedisCacheManager: Redis를 캐시 저장소로 사용할 수 있는 구현체다.
  • CompositeCacheManager: 한 개 이상의 캐시 매니저를 사용할 수 있는 혼합 캐시 매니저다.

의존성 추가

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
</dependencies>

@EnableCaching

@Cacheable과 같은 애노테이션 기반의 캐시 기능을 사용하기 위해서는 먼저 별도의 선언이 필요하다.

@EnableCaching
@Configuration
public class CacheConfig {
    
    @Bean
    public RedisConnectionFactory basicCacheRedisConnectionFactory() {
    	// ...
    }
    
    @Bean
    public CacheManager cacheManager() {
    	RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofHours(1))
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new Jackson2JsonRedisSerializer(Object.class)));

        Map<String, RedisCacheConfiguration> configurations = new HashMap<>();
        configurations.put("hotelCache", defaultConfig.entryTtl(Duration.ofMinutes(30)));
        configurations.put("hotelAddressCache", defaultConfig.entryTtl(Duration.ofDays(1)));

        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(basicCacheRedisConnectionFactory())
                .cacheDefaults(defaultConfig)
                .withInitialCacheConfigurations(configurations)
                .build();
    }
}

@Cacheable

캐시 저장소에 캐시 데이터를 저장하거나 조회하는 기능을 사용할 수 있다. 애노테이션이 정의된 메서드를 실행하면 데이터 저장소에 캐시 데이터 유무를 확인한다. 적용된 메서드의 리턴 값을 기준으로 캐시에 값을 저장한다. 캐시에 데이터가 있다면 메서드를 실행하지 않고 바로 데이터를 리턴한다. 만약 예외가 발생하면 캐시 데이터는 저장하지 않는다.

속성설명Type
cacheName캐시 이름(설정 메서드 리턴값이 저장되는)String[]
valuecacheName의 aliasString[]
key동적인 키 값을 사용하는 SpEL 표현식. 동일한 cache name을 사용하지만 구분될 필요가 있을 때 사용되는 값.String
conditionSpEL 표현식이 참일 경우에만 캐싱 적용. or, and 등 조건식 및 논리연산 가능String
unless캐싱을 막기 위해 사용되는 SpEL 표현식. condition과 반대로 참일 경우에만 캐싱이 적용되지 않음String
cacheManager사용 할 CacheManager 지정String
sync여러 스레드가 동일한 키에 대한 값을 로드하려고 할 경우, 기본 메서드의 호출을 동기화함. 캐시 구현체가 Thread safe 하지 않는 경우, 캐시에 동기화를 걸 수 있는 속성boolean
@Cacheable(value="hotelCache")
public HotelResponse getHotelById(Long hotelId) {
    // ...
}

Long hotelId 메서드 인자의 toString() 메서드를 사용하여 캐시 키를 설정한다. 인자가 여러 개일 경우 모두 조합하여 캐시 키를 생성한다.

CacheManager 스프링 빈에 설정된 StringRedisSerializerJackson2JsonRedisSerializer로 캐시 키는 문자열로 변경되어 저장되고, 캐시 데이터는 JSON 형식으로 변경되어 저장된다.

@Cacheable 애노테이션을 사용할 때는 메서드의 인자와 리턴 타입 변경에 유의해야 한다. 운영 중인 시스템의 인자를 추가한다면 캐시 키 값이 변경될 수 있다. 그러면 데이터 저장소에 저장된 데이터를 활용할 수 없게 된다. 메서드의 리턴 타입을 다른 클래스로 변경한다면, 데이터 저장소에 저장된 데이터가 언마셜링되는 과정 중 에러가 발생할 수 있다.

@CacheEvict

캐시 데이터를 캐시에서 제거하는 목적으로 사용된다. 원본 데이터를 변경하거나 삭제하는 메서드에 해당 애노테이션을 적용하면 된다. 원본 데이터가 변경되면 캐시에서 삭제하고 @Cacheable 애노테이션이 적용된 메서드가 실행되면 다시 변경된 데이터가 저장되기 때문이다.

속성설명Type
cacheName제거할 캐시 이름String[]
valuecacheName의 aliasString[]
key동적인 키 값을 사용하는 SpEL 표현식. 동일한 cache name을 사용하지만 구분될 필요가 있을 때 사용되는 값.String
allEntries캐시 내의 모든 리소스를 삭제할 지의 여부boolean
conditionSpEL 표현식이 참일 경우에만 캐싱 적용. or, and 등 조건식 및 논리연산 가능String
cacheManager사용 할 CacheManager 지정String
beforeInvocationtrue 일 경우 메서드 수행 이전 캐시 리소스 삭제, false 일 경우 메서드 수행 후 캐시 리소스 삭제boolean
@CacheEvict(key = "testKey", condition="#caching")
public Object removeSome(boolean caching) {
	// ...
}

@CachePut

캐시를 생성하는 기능만 제공하는 애노테이션이다.

@Cachable과 유사하게 실행 결과를 캐시에 저장하지만, 조회 시에 저장된 캐시의 내용을 사용하지는 않고 항상 메소드의 로직을 실행한다.

속성설명Type
cacheName캐시 이름(설정 메서드 리턴값이 저장되는)String[]
valuecacheName의 aliasString[]
key동적인 키 값을 사용하는 SpEL 표현식. 동일한 cache name을 사용하지만 구분될 필요가 있을 때 사용되는 값.String
conditionSpEL 표현식이 참일 경우에만 캐싱 적용. or, and 등 조건식 및 논리연산 가능String
unless캐싱을 막기 위해 사용되는 SpEL 표현식. condition과 반대로 참일 경우에만 캐싱이 적용되지 않음String
@CachePut(key = "testKey", condition="#caching")
public Object modifySome(boolean caching) {
	// ...
}

@Caching

두 개 이상의 캐시 애노테이션을 조합하여 사용한다.

속성설명Type
cacheable적용 될 @Cacheable array를 등록Cacheable[]
evict적용 될 @CacheEvict array를 등록CacheEvict[]
put적용 될 @Cacheput array를 등록CachePut[]
@Caching(cacheable = {
		@Cacheable(value="primaryHotelCache", keyGenerator="hotelKeyGenerator"),
        @Cacheable(value="secondaryHotelCache", keyGenerator="hotelKeyGenerator")
})
public HotelResponse getHotel(HotelRequest hotelRequest) {
	// ...
}

@CacheConfig

클래스 단위로 캐시 설정을 동일하게 하고 싶을 때 사용한다.

속성설명Type
cacheNames해당 클래스 내 정의된 캐시 작업에서의 default 캐시 이름String[]
cacheManager사용 할 CacheManager 지정String
@CacheConfig(cacheNames={"addresses"})
public class CustomerDataService {
	// ...
    
    @Cacheable
    public String getAddress(Customer customer) {
    	// ...
    }
}

References

profile
Step by step goes a long way.

0개의 댓글