서론
Spring은 캐시 추상화를 통해서 쉽게 캐시를 사용할 수 있게 해준다. CacheManager
를 잘 구현한다면 @Cacheable
, @CachePut
, @CacheEvict
등을 통해서 얼마든지 커스터마이징된 캐시를 사용할 수 있다. 하지만 의외로 장애에 대응하는 방안에 대해서는 Reference 상에서도 구체적인 설명을 해주지 않고 있다. 덕분에 다시 글 쓸 거리가 생긴 것 같다.
사용하는 캐시 시스템에 문제가 생겼을 때 어떻게 대처해야하는지는 어떤 정보를 캐시하느냐에 따라 다르다. 경우의 수 자체가 너무 많다. 또한 캐시 자체가 여러 용도로 쓰일 수 있다. HTTP 응답을 캐시하든지, Repository의 결과를 캐시하든지 혹은 Service의 결과를 캐시할 수도 있다. 본문에서는 아래 두 경우로 간단하게 나누고 이에 대한 해결법을 궁리해보고자 한다.
- 부하가 많이 걸린다면? 2차 캐시 구성을 고려하자.(Local Cache, Global Cache 구성)
- Global Cache에서 장애가 발생할 경우를 대비, Hystrix를 고려하자
서비스 입장에서 본 구조
MVC의 Controller에서 로직을 실행하기 위해 Service를 호출할 때 캐시를 사용하려 한다.
기본 예제
우선 Spring에서 캐시를 사용하는 기본 예제를 간단히 작성하자. 여기서는 Redis를 캐시로 사용한다.
의존성 설정
Spring Boot
는 2.0.1.RELEASE
를 사용했다.
2.0.x부터 redis를 사용시 lettuce
라이브러리가 기본으로 설정된다. 하지만 예전부터 Spring을 사용하는 경우라면 jedis에 익숙한 개발자가 많을 것으로 예상된다. 어차피 본문에서는 라이브러리에 따라 크게 달라지는 설정이 없으므로 익숙한 jedis를 사용하려고 한다.
1 | dependencies { |
도메인
Person
1 |
|
PersonRepository
본문에서는 Repository의 내용이 중요하진 않으므로 Method Signature만 적고 넘어가려고 한다.
1 |
|
응용 계층
PersonService
1 |
|
Controller
1 |
|
캐시 설정
1 | true) (proxyTargetClass = |
기본 데이터 인입
1 |
|
테스트
/people/1를 호출하면 get(Long) called
가 로그에 한 번 남고, 이후에는 20초 동안 남지 않는다. 그리고 redis-cli
로 키를 직접 조회해보면 아래와 같이 정보가 잘 인입되었음을 확인할 수 있다.
1 | 127.0.0.1:6379> get person::1 |
소스코드 : https://github.com/supawer0728/simple-cache/tree/simple-cache
2차 캐시 구성
캐시에 많은 부하가 몰릴 수 있는 시스템의 경우 캐시를 수직적으로 나누어 1차, 2차 캐시를 사용하는 것이 좋을 때가 있다. 서비스 입장에서 구성을 나타내면 아래와 같다.
이러한 구성을 할 때 주의할 점이 있다. local cache와 global cache의 정보가 맞지 않는 기간이 발생할 수 있다는 것이다. 당연한 이야기지만 local cache의 만료 시간이 길면 길수록 그 기간은 길어진다. 때문에 일관된 데이터가 필요한 만큼 local cache의 만료 시간을 짧게 가져가야한다.
아쉽게도 이러한 n차 캐시 구성은 Spring에서 지원해주지 않는다. CompositeCacheManager
라는 클래스가 있는데, 이 클래스는 등록된 여러 CacheManager 중에서 캐시명으로 Cache를 조회해서 하나만 내어준다.
CompositeCacheManager
1 |
|
우리가 필요한 것은 CacheManager의 목록을 연결(Chained) 구성하는 것이다. 다행히 핵심 로직은 이미 라이브러리들이 구현해두었다. 우리는 CacheManager와 Cache만 연결 구현하면 된다.
예제
ChainedCacheManager
1 | public class ChainedCacheManager implements CacheManager { |
실제 로직을 담고 있는 cacheManagers
를 두고 위임자 패턴으로 구현하였다. getCache(String)
호출이 있으면, cacheManagers
의 순서로 해당 CacheManager
의 Cache
를 불러와 ChainedCache
를 새로 생성한다.
ChainedCache
1 | public class ChainedCache implements Cache { |
CacheManager
에서 실제 구현을 담고 있는 Cache
를 저장한다. 우리가 구현할 내용은 순서를 지정해주고 순서에 따라 값을 넣는 것이다. 실제 동작은 caches
에 위임하자.
CacheConfig
1 | true) (proxyTargetClass = |
RedisCacheManager
와 JCacheCacheManager(ehCache)
를 각각 원격, 로컬 캐시로 잡았다. LoggingCacheManager
는 필자가 로그를 남기기 위해 작성한 것이다.
테스트
1 | # 최초 호출시 service의 get(Long)까지 부른다 |
원하는 대로 정상적으로 동작하고 있다.
소스코드 : https://github.com/supawer0728/simple-cache/tree/double-cache
Hystrix 구성
전역 캐시가 부하를 받아 Timeout이 발생하거나 특정 이유로 접근이 되지 않는 등의 장애가 발생한 경우, 일전에 공유했던 Hystrix를 고려해볼 수 있다. 전역 캐시에 장애를 감지한 시점부터는 요청을 보내면 안된다. 물론 이 방법 또한 만병 통치약인 것은 아니다. 아래의 경우를 생각해보자.
- 단순 네트워크 단절 : 다행이다. 1차 캐시와 repository로 2차 캐시를 복구할 때까지 운용할 수 있다.
- 트래픽 부하로 인한 장애 : 전역 캐시에 장애를 일으켰던 트래픽을 고스란히 1차 캐시와 repository로 견뎌내어야 한다. 2차 캐시를 복구하기 위한 잠깐의 시간 벌기는 가능할 것이다.
위와 같은 한계가 있음을 숙지하고, 이제 Hystirx를 어떻게 구성할 수 있을지 예제로 작성하려한다.
HystirxCacheManager
사실상 Spring에서 캐시 추상화(CacheManager
, Cache
)를 제공하니 우리는 앞에서 한 작업의 반복을 할 뿐이다. 실제 구현체를 delegate
로 잡아두고 위임자 패턴을 사용하여 구현하자.
1 | public class HystrixCacheManager implements CacheManager { |
HystrixCache
1 | 4j |
아쉽게도 spring-netflix-starter-hystrix
에서 지원해주는 애노테이션들을 이용한 설정은 어렵다. 애노테이션을 사용해서 Hystirx설정을 하기 위해서는 설정할 인스턴스가 ApplicationContext
에 Bean으로 등록되어야 한다. 때문에 여기서는 Spring의 도움 없이 직접 Hystrix API를 사용하였다. execute()
는 HystrixCommand를 동기로 실행한다.
HystrixCacheGetCommand
앞에서 get, push, evict에 대해서 모두 HystirxCacheXXXCommand
로 작성하였으나 본문에서는 HystrixCacheGetCommand
만 살펴보려고 한다. 나머지든 대동소이하다.
1 | 4j |
20초간 API의 성공/실패 여부를 측정하며, 5번 이상 실행되고 50% 이상 실패했을 경우 회로가 열린다. Timeout은 500ms로 설정했다. execute()
를 실행하면 run()
이 실행되며, 실패한 경우 getFallback()
이 실행된다.
CacheConfig
1 | true) (proxyTargetClass = |
앞서 만들었던 부분을 그대로 생성자로 주입하자.
테스트
서버를 실행 후 Redis 서버를 Down 시킨 후 /people/1
을 호출해보았다.
1 | 2018-04-18 22:55:09.653 INFO LoggingCache : Local-Cache.get(Object) called |
소스코드 : https://github.com/supawer0728/simple-cache/tree/hystrix-cache
마무리
Spring의 캐시 추상화를 사용하며, 부하 분산을 통해 장애에 대응할 수 있는 방안에 대해 다뤄보았다.
우선 n차 캐시 구성을 통해서 Heap을 사용하여 외부 시스템의 호출을 줄여서 전역 리소스(전역 캐시, repository)의 부하를 줄였다. n차 캐시 구성을 사용하는 경우, 리소스 간의 일관성이 무너질 수 있다. 일관성이 중요할수록 로컬 캐시의 수명을 짧게해서 사용해야 한다.
원격 캐시의 경우 파티션(장애)이 발생할 수 있다. 이에 대응하기 위해 Hystrix를 활용할 수 있다. 회로를 열어 원격에 요청을 보내지 않고, 빠른 실패처리를 할 수 있다. 하지만 여기서도 유의해야할 점이 있는데. 트래픽이 몰리는 상황에서 부하를 견디지 못해 장애가 발생한 경우, 이를 1차 캐시와 repository가 받아내게 된다. 본문에서는 fallback에서 null을 던져 repository를 실행시켰다. 원격 캐시에서 장애가 발생했을 때, 사용자 정의 Exception을 던져 캐시 오류인 경우의 응답을 따로 내려줘서 repository를 지켜내는 것도 방법이 될 수 있을 것 같다.