티스토리 뷰

Spring

[Spring] 스프링이 제공하는 Cache와 CacheManager 쉽고 완벽하게 이해하기

망나니개발자 2024. 6. 18. 10:00
반응형

 

 



# 1. 스프링이 제공하는 Cache 관련 기능 파헤치기


[ Cache Abstraction(캐시 추상화) ]

스프링이 가장 잘하는 것 중 하나를 뽑으라면 추상화(Abstraction)를 빼놓을 수 없다. 대표적으로 스프링은 트랜잭션 관련 공통 사항을 담은 트랜잭션 추상화 기술을 제공하고 있다. 이를 통해 각 애플리케이션에서는 데이터베이스 접근 기술(JDBC, JPA 등)에 종속적이지 않고 일관되게 트랜잭션을 처리할 수 있도록 도와준다. 데이터베이스 접근 기술을 JDBC에서 JPA로 변경해도 우리의 코드는 안전한 것이다.

캐시 역시 마찬가지다. 로컬 메모리에서 동작하는 caffeine 캐시, 분산 저장소를 활용하는 Redis 캐시 등 다양한 캐시 구현체가 존재한다. 해당 기술을 직접 의존하면 캐시 구현체가 변경될 때 우리의 코드도 영향을 받으므로, 스프링은 이러한 문제를 방지하기 위해 캐시를 추상화하였다.

 

 

 

[ Cache 인터페이스 ]

Cache 인터페이스는 공통된 캐시 기능을 스프링이 추상화 한 것으로, 이를 통해 모든 캐시를 하나의 일관된 인터페이스로 접근 할 수 있다. Cache 인터페이스의 핵심 메서드를 살펴보면 다음과 같다.

public interface Cache {

	String getName();

	@Nullable
	ValueWrapper get(Object key);
	
	void put(Object key, @Nullable Object value);

	@Nullable
	default ValueWrapper putIfAbsent(Object key, @Nullable Object value) {
		ValueWrapper existingValue = get(key);
		if (existingValue == null) {
			put(key, value);
		}
		return existingValue;
	}

	void evict(Object key);

	default boolean evictIfPresent(Object key) {
		evict(key);
		return false;
	}

	void clear();

	@FunctionalInterface
	interface ValueWrapper {

		@Nullable
		Object get();
	}

  ...

}

 

 

 

기본적으로 캐시 구현체는 1개의 캐시 이름(Cache Name)을 가지며, 캐시의 종류에 따라서 구현이 달라진다. 대표적으로 로컬 캐시인 Caffein Cache와 Redis Cache 등이 존재하는데, 먼저 Caffeine Cache에 대해 살펴보도록 하자.

Caffeine Cache는 로컬 메모리에 값을 저장하는 인메모리 캐시로, ConcurrentHashMap을 사용하여 구현되어 있다. 예를 들어 우리가 사용자의 이메일을 바탕으로 닉네임 정보를 캐싱하는 UserName 캐시가 존재한다고 하자. 이를 도식화하면 다음과 같다.

 

 

외부 저장소에 실제 값을 저장하는 Redis Cache와 같은 캐시들은 내부 구현이 다르다. Redis Cache도 동일하게 Cache Name을 namespace로 갖지만 실제 데이터는 외부 저장소에 존재하므로, 레디스 설정 시에 등록해준 CacheWriter를 통해 캐시 관련 연산을 수행한다.

@Bean
fun redisCacheManager(redisSerializer: RedisSerializer<Any>): CacheManager {
    val redisCacheConfigurationMap = CardCacheName.entries
        .filter { it.cacheType == CardCacheType.GLOBAL || it.cacheType == CardCacheType.COMPOSITE }
        .associate {
            it.cacheName to RedisCacheConfiguration.defaultCacheConfig()
                .prefixKeysWith(it.cacheName + "-")
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
                .entryTtl(it.expiredAfterWrite)
		    }

    return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory)
        .cacheWriter(RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory))
        .withInitialCacheConfigurations(redisCacheConfigurationMap)
        .enableStatistics()
        .build()
}

 

 

이러한 Redis Cache의 동작을 간략하게 도식화하면 다음과 같다.

 

 

실제 스프링이 제공하는 구현체를 보면 로컬 캐시를 위한 CaffeineCache, 레디스를 위한 RedisCache 외에도다양한 캐시가 존재한다. 그리고 캐싱된 값을 래핑하는 공통 기능을 담당하는 추상 클래스도 일부 부모로 구현하고 있음을 확인할 수 있다.

 

 

참고로 스프링의 경우 일반적인 캐시 사용을 위해 Cache 인터페이스는 null 값 캐싱을 허용한다. 즉, @Cacheable과 같은 스프링 기반의 캐싱 기능을 활용하는 경우, 값이 null 일 때에도 캐싱이 적용된다는 것이다. 따라서 null일 경우에는 캐싱에서 제외되도록 표현식을 적용할 수도 있다.

 

 

 

[ CacheManager ]

애플리케이션을 개발하다보면 다양한 캐시가 필요할 수 있다. 위에서 살펴본 UserName 캐시 외에도 UserPhone 캐시 등 다양한 캐시 객체가 필요할 수 있다. 스프링은 이러한 캐시 객체들을 관리하는 CacheManager라는 인터페이스를 제공한다. 자세한 구조를 표현하면 다음과 같다.

 

 

Cache 인터페이스와 유사하게, CacheManager 역시 캐시 목록을 관리하는 기능을 추상화된 인터페이스이다. CacheManager 인터페이스를 살펴보면 다음과 같다.

public interface CacheManager {

	/**
	 * Get the cache associated with the given name.
	 * <p>Note that the cache may be lazily created at runtime if the
	 * native provider supports it.
	 * @param name the cache identifier (must not be {@code null})
	 * @return the associated cache, or {@code null} if such a cache
	 * does not exist or could be not created
	 */
	@Nullable
	Cache getCache(String name);

	Collection<String> getCacheNames();
}

 

 

 

CacheManager는 여러 개의 캐시를 관리하므로, 캐시 이름(Cache Name)으로 캐시를 조회하는 기능이 존재한다. 따라서 캐시 매니저에게 특정한 캐시를 가져오도록 우리가 @Cacheble을 사용할 때 cacheNames를 지정해주는 것이다.

@Cacheable(cacheNames = [CacheKey.HOLIDAYS], cacheManager = "localCacheManager", key = "#year", )
override fun getHolidays(year: Int): List<LocalDate> {
    return fetchDataApiPort.getHolidays(year)
}

 

 

그 외에도 전체 캐시 이름 목록을 조회하는 메서드도 갖고 있다. 여기서 주의할 부분은 getCache 메서드 부분인데, 설명을 보면 구현체에 따라 캐시 매니저의 캐시가 lazy하게 생성될 수 있다는 부분이다. 이를 보다 자세히 이해하기 위해 CacheManager 구현을 살펴보도록 하자.

CacheManager 계층 구조를 살펴보면 다양한 구현체들이 존재함을 확인할 수 있다. 그 중에서 로컬 캐시를 위해 사용되는 SimpleCacheManager를 먼저 살펴보도록 하자.

 

 

SimpleCacheManager는 내부적으로 ConcurrentHashMap을 사용하여 단일 인스턴스 내부에서 캐싱하기 위한 용도로 활용되는 CacheManager이다. SimpleCacheManager의 계층 구조를 자세히 보면, 실질적인 기능들은 공통 기능을 추상화한 AbstractCacheManager에 존재함을 확인할 수 있다.

 

 

AbstractCacheManager 클래스의 getCache 메서드를 살펴보면 CacheManager에서 캐시를 꺼내고 존재하지 않을 경우에는 put하는 것을 확인할 수 있다. 우리가 흔히 사용하는 SimpleCacheManager와 RedisCacheManager 모두 해당 클래스를 상속받으므로, 캐시가 없다면 예외가 발생하지 않고 새로운 캐시가 등록된다.

@Override
@Nullable
public Cache getCache(String name) {
		// Quick check for existing cache...
		Cache cache = this.cacheMap.get(name);
		if (cache != null) {
			return cache;
		}

		// The provider may support on-demand cache creation...
		Cache missingCache = getMissingCache(name);
		if (missingCache != null) {
			// Fully synchronize now for missing cache registration
			synchronized (this.cacheMap) {
				cache = this.cacheMap.get(name);
				if (cache == null) {
					cache = decorateCache(missingCache);
					this.cacheMap.put(name, cache);
					updateCacheNames(name);
				}
			}
		}
		
		return cache;
}

 

 

하지만 실제 서비스를 운영할 때는 로컬 캐시 외에도 레디스와 같은 분산 저장소의 캐시 모두 필요하다. 따라서 스프링은 로컬 캐시를 위해서는 SimpleCacheManager, 레디스를 위해서는 RedisCacheManager를 제공하며, 복수의 CacheManager 들을 관리하기 위한 CompositeCacheManager 역시 제공한다.

 

 

CompositeCacheManager는 이름 그대로 디자인 패턴 중 컴포지트 패턴을 적용한 것으로, 내부 구현을 보면 복수의 CacheManager들을 순차 접근하여 처리하는 방식으로 동작함을 확인할 수 있다.

@Override
@Nullable
public Cache getCache(String name) {
	for (CacheManager cacheManager : this.cacheManagers) {
		Cache cache = cacheManager.getCache(name);
		if (cache != null) {
			return cache;
		}
	}
	return null;
}

 

 

따라서 우리가 CompositeCacheManager를 기반으로 @Cacheable을 사용할 때 cacheNames와 더불어 cacheManager를 지정해주어야 하는 이유가 바로 어떠한 캐시 매니저에서 어떠한 캐시 이름에 접근할 것인지 정보가 필요하기 때문이다.

@Cacheable(cacheNames = [CacheKey.HOLIDAYS], cacheManager = "localCacheManager", key = "#year", )
override fun getHolidays(year: Int): List<LocalDate> {
    return fetchDataApiPort.getHolidays(year)
}

 

 


[ CacheAspectSupport와 CacheInterceptor ]

CacheAspectSupport는 AOP 기반의 선언형 캐시(declarative cache) 처리 로직을 담고 있는 클래스이다. 따라서 CacheAspectSupport에서 @Cacheable이나 @CacheEvict 등과 같은 애노테이션을 파싱하고, 요청을 처리하는 실질적인 기능을 수행한다.

CacheInterceptor는 CacheAspectSupport의 자식 클래스인데, 많은 캐시 처리 부분 중에서 캐시 호출의 올바른 제어를 위해 추가 기능을 갖고 있다. CacheInterceptor의 계층 구조는 다음과 같은데, 그 중에서 부모 클래스인 AbstractCacheInvoker가 실제로 get, put, evict 등의 호출을 담당한다.

 

 

실제 get, put, evict 메서드에 대한 호출 로직은 CacheAspectSupport에 존재하는데 실제 구현체는 CacheInterceptor이므로, 우리가 해당 클래스를 상속받아 get, put, evict와 같은 기능을 오버라이딩하면 get, put, evict 요청 시에 필요에 따라 개입할 수 있다.

 

 

 

 

 

[ 스프링의 캐시 동작 방식 ]

그러면 위의 내용들을 조합하여 @Cacheable과 같은 선언형 캐시 방식이 어떻게 동작하는지 살펴보도록 하자. 참고로 스프링의 동작 방식은 상당히 복잡하므로, 다음은 스프링의 세부 동작을 매우 추상화한 것임을 참고하도록 하자.

  1. @Cacheable과 같은 캐시 애노테이션이 붙은 메서드가 호출됨
  2. 캐시 처리를 위한 CacheInterceptor이자 CacheAspectSupport에 의해 캐시 관련 처리가 시작됨
  3. CacheAspectSupport 내부에서 CacheManager를 통해 CacheName으로 캐시를 찾음
  4. 캐시가 존재하는 경우 key로 요청된 연산(get, put, evict)를 처리함

 

스프링 캐시는 상당히 많고 복잡한 기능을 제공한다. 따라서 적당한 수준에서 확장 가능한 요소들만 파악해두도록 하자.

 

 

 

참고 자료

 

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
글 보관함