웹 개발/무신사 스토어 watcher

[무신사 watcher] 캐시 서버에 장애가 생긴다면?

제리 . 2021. 1. 13. 05:41

서비스를 운영하면서 고려해야 할 중요한 문제는 장애 처리라고 생각한다. 이번 프로젝트로 장애 처리에 대한 지식을 조금이나마 얻어 갈 수 있었다. MSA구조에도 자주 사용된다는 서킷 브레이커를 적용해봤다.

 

Redis서버에 장애가 생기면?

redis서버에 장애가 생기면 위와 같이 서버에서 500 error가 발생하고 컨텐츠가 보이지 않는다. 별도의 설정을 하지 않았을 때 캐시 서버에 문제가 생겼을 때 서비스가 다운됐다.

기존 캐시 구조는 다음과 같다.

1. 클라이언트로부터 api요청이 들어온다.

2. redis에 캐시가 저장되어 있는지 확인한다. 

  2-1. 캐시가 있다면 캐시 값을 반환한다.

  2-2. 캐시가 없다면 3으로 이동한다.

3. 캐시가 없는 경우 DB에서 조회한다.

 

위와 같은 구조에서 global cache인 redis에 장애가 발생하면 캐시 값으로 null을 반환하지 않는다. connection exception등 오류를 발생시키기 때문에 DB 조회가 아닌 장애를 발생시킨다.

 

변경된 캐시 구조

변경된 캐시는 로컬 캐시를 추가했다.

 

로컬 캐시를 추가한 이유

글로벌 캐시 장애 상황에서도 db를 직접 조회하는 방안을 적용하면 문제 상황을 대처할 수 있다. 하지만 캐시 서버를 구축한 목적인 db부하 감소, 빠른 응답 측면에서 둘 다 패널티를 얻는다. 이는 db서버의 장애로 까지 전파 될 수 있다. 따라서 로컬 캐시를 추가하여 부하를 감소하고 빠른 응답을 가능하게 했다. 로컬 캐시는 redis나 memcached같이 별도의 데몬을 갖지 않고 서버와 라이프 사이클이 동일한 encache를 선택했다.

 

장애 시나리오

로컬 캐시 선 조회 vs 글로벌 캐시 선 조회

글로벌 캐시를 먼저 조회하고 장애 상황에서 로컬 캐시를 조회하는 방식과 평소에서 로컬캐시를 먼저 조회하고 저장된 캐시가 없는 경우 글로벌 캐시를 확인하는 방식이 있다. 글로벌 캐시를 선 조회 하면 로컬 캐시와 글로벌 캐시간 동기화를 신경쓸 필요가 없다. 로컬 캐시를 선조회하면 글로벌 캐시에 몰리는 부하를 각 서버로 분산시킬 수 있다. 해당 프로젝트에서 데이터는 하루에 한번 업데이트 되는 구조로 동기화에 대한 부담이 적다. 따라서 로컬 캐시를 선조회하여 글로벌 캐시의 부하를 줄여주는 방식을 적용했다.

 

로컬 캐시와 글로벌 캐시의 동기화 문제

 

동기화가 발생하는 순간은 하루에 한번 데이터가 업데이트 되는 순간이다. 이 때 로컬 캐시를 초기화해서 새로운 캐시를 업데이트하면 된다. 하지만, 로컬 캐시는 여러 서버에서 운영되고 캐시를 초기화하는 상황에서 모든 서버에 초기화되는 작업이 진행되야한다. 어떻게 해야 동기화되지 않는 시간을 최소한으로 하면서 여러 서버의 로컬 캐시를 초기화 해야할지가 고민되었다.

 

1. 로컬 캐시의 TTL을 줄이기?

로컬 캐시의 TTL을 줄일수록 동기화의 문제가 완화된다. 하지만 TTL이 작을 수록 로컬 캐시를 통해 글로벌 캐시의 부하를 분산하는 목적에 반하게 되며 장애 상황시 db를 조회하는 상황이 잦아진다.

 

2. 글로벌 캐시를 우선 조회?

이 방식을 적용하면 동기화 문제는 발생하지 않지만, 글로벌 캐시에 부하가 몰린다

 

3. 캐시 초기화를 모든 서버에 요청이 도달할 때 까지 반복적으로 요청한다?

서버의 특정 주소를 지정해서 작업하기엔 서버 스케일링에 문제가 있고 로드 밸런서에게 맡기기에는 확률적인 요소가 크다. 또한, 특정 pod으로 직접 요청을 보내기는 까다롭다. 이외에도 모든 서버에게 요청이 도달했음을 파악하기 위해 관리해야하는 부담이 발생한다.  

 

@Scheduled를 사용한 주기적인 동기화 체크

spring프레임워크에서 제공하는 @Scheduled 어노테이션을 사용하면 주기적으로 동기화 여부를 확인할 수 있다. 

 

동기화 api작성

@Scheduled(fixedRate = 5000)
  public boolean doSynchronize() {
    ChainedCache cache = (ChainedCache) cacheManager.getCache(CACHE_NAME);
    if (cache.isSynchronized(DATE_KEY)) {
      return false;
    } else {
      cache.clearLocalCache();
      log.info("동기화를 위해 로컬 캐시 초기화가 되었습니다.");
      return true;
    }
  }

동기화를 담당하는 메서드는 위와 같다. 글로벌 캐시에 저장된 데이터 업데이트 날짜와 로컬 캐시에 저장된 데이터 업데이트 날짜를 비교한다.

 

1. 로컬 캐시의 TTL을 줄일 필요가 있는가?

없다. 로컬 캐시는 글로벌 캐시와 동기화 여부만 확인하면 된다. 

 

2. 지속으로, 주기적으로 health check를 위해 요청을 보낼텐데 서버 혹은 글로벌 캐시에 부담은 없는가?

10초마다 로컬 캐시, 글로벌 캐시를 조회해서 동기화를 확인한다. 운영하는 서버가 수 만대, 수 천대가 있는 것이 아니고 DB를 조회하는 과정도 없으니 health check의 부하는 크지 않다고 판단했다. 

 

3. health check 응답 시간 지연으로 pod이 재시작되거나 요청이 배제될 가능성이 없는가?

hystrix를 사용하여 응답에 대한 최대 시간을 보장할 수 있다. 따라서 이런 문제는 발생하지 않는다.

 

 

hystrix 서킷 브레이커를 사용하므로서 얻었던 이점

캐시는 빠른 응답을 보장하는 장점이 있다. 하지만 커넥션의 문제로 응답 시간이 지연되거나 timeout이 지나 예외를 던지는 경우 이런 목적을 달성할 수 없다. hystrix는 메서드가 실행되는 제한 시간을 설정할 수 있다. 별도의 스레드를 통해 작업이 실행되며 제한 시간이 초과한 경우 미리 지정한 fallback 메서드가 실행된다. 주의할 점은, fallback 메서드가 실행된다고해서 실행중인 작업이 중지되지는 않는다. 이러한 실패가 일정 통계 조건을 만족하면 서킷 브레이커가 열린다. 서킷 브레이커가 열리게 되면 기존 메서드를 실행하지 않고 바로 fallback메서드를 실행한다. 그러므로 장애 상황에 대해 불필요하게 요청을 보내지 않으며 실패에대한 빠른 처리를 할 수 있었다.

 

테스트 시나리오

2개의 cache manger(로컬, 글로벌) 사용

@Slf4j
@RequiredArgsConstructor
@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {

  private final RedisConnectionFactory connectionFactory;

  @Bean
  public EhCacheManagerFactoryBean ehCacheManagerFactoryBean() {
    EhCacheManagerFactoryBean ehCacheManagerFactoryBean = new EhCacheManagerFactoryBean();
    ehCacheManagerFactoryBean.setConfigLocation(
        new ClassPathResource("ehcache.xml"));
    ehCacheManagerFactoryBean.setShared(true);
    return ehCacheManagerFactoryBean;
  }

  @Bean
  public EhCacheCacheManager ehCacheCacheManager(EhCacheManagerFactoryBean ehCacheManagerFactoryBean) {
    EhCacheCacheManager ehCacheCacheManager = new EhCacheCacheManager();
    ehCacheCacheManager.setCacheManager(ehCacheManagerFactoryBean.getObject());
    return ehCacheCacheManager;
  }
  @Bean
  public CacheManager globalCacheManager() {
    RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig();
    RedisCacheManager redisCacheManager = RedisCacheManager.RedisCacheManagerBuilder
        .fromConnectionFactory(connectionFactory).cacheDefaults(redisCacheConfiguration).build();
    return redisCacheManager;
  }

  @Bean
  @Primary
  @Override
  public CacheManager cacheManager() {
    return new ChainedCacheManager(ehCacheCacheManager(ehCacheManagerFactoryBean()), globalCacheManager());
  }
}

로컬 캐시로 ehcache, 글로벌 캐시로 redis를 사용한다. cache manager가 여러개 있는 경우 primary를 사용해서 어떤 캐시 매니저를 사용할지 지정해야 한다.

@SpringBootTest
@RunWith(SpringRunner.class)
public class ChainedCacheMangerTest {

  @Autowired
  private CacheManager cacheManager;

  @Test
  @DisplayName("지정한 2차 cache manager를 사용한다")
  public void getGlobalCache() {
    assertEquals("com.musinsa.watcher.config.cache.ChainedCacheManager",
        cacheManager.getClass().getName());
  }

}

적용된 CacheManager는 두 개의 캐시를 사용하기위해 새로 정의한 ChainedCacheManger가 사용된다.

public class ChainedCache implements Cache {

  private final Cache localCache;
  private final Cache globalCache;

  public ChainedCache(List<Cache> caches) {
    this.localCache = caches.get(0);
    this.globalCache = caches.get(1);
  }

  @Override
  public ValueWrapper get(Object key) {
    ValueWrapper valueWrapper = localCache.get(key);
    log.info("로컬 캐시 : " + valueWrapper);
    if (valueWrapper != null && valueWrapper.get() != null) {
      log.info("로컬 캐시 조회");
      return valueWrapper;
    } else {
      valueWrapper = new HystrixGetCommand(globalCache, key).execute();
      log.info("글로벌 캐시 : " + valueWrapper);
      if(valueWrapper != null){
        localCache.put(key, valueWrapper.get());
      }
      return valueWrapper;
    }
  }
  
  ...
  
 }
 
 
 @Slf4j
public class HystrixGetCommand extends HystrixCommand<ValueWrapper> {
  private final Cache globalCache;
  private final Object key;

  public HystrixGetCommand(Cache globalCache, Object key) {
    super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("cacheGroupKey"))
        .andCommandKey(HystrixCommandKey.Factory.asKey("cache-get"))
        .andCommandPropertiesDefaults(
            HystrixCommandProperties.defaultSetter()
                .withExecutionTimeoutInMilliseconds(1000)
                .withCircuitBreakerErrorThresholdPercentage(50)
                .withCircuitBreakerRequestVolumeThreshold(10)
                .withCircuitBreakerSleepWindowInMilliseconds(30000)
                .withMetricsRollingStatisticalWindowInMilliseconds(10000)));
    this.globalCache = globalCache;
    this.key = key;
  }

  @Override
  protected ValueWrapper run() {
    log.info("글로벌 get");
    return globalCache.get(key);
  }

  @Override
  protected ValueWrapper getFallback() {
    log.warn("get fallback called, circuit is {}", super.circuitBreaker.isOpen());
    return null;
  }
}

새롭게 구현한 cache중 get은 아래와 같은 로직으로 실행된다.

1. 로컬 캐시를 조회한다.

  1-1. 로컬 캐시가 존재하면 값을 반환한다.

  1-2. 로컬 캐시가 없다면 2로 이동한다.

2. 글로벌 캐시를 조회한다.

  2-1 글로벌 캐시의 값이 없다면 null을 반환한다. 이 경우 DB에서 직접 조회가 이뤄진다.

  2-2 글로벌 캐시의 값이 있다면 로컬 캐시에 값을 저장한다.

 

HysrixCommand는 다음과 같이 실행된다.

1. 실행되면 글로벌 캐시를 조회한다. 이때, 1초안에 처리되지않는다면  getFallbacke()메서드가 호출된다.

2. fallback에서는 DB에서 직접 값을 조회하기 위해 null을 반환한다.

 

 

로컬 캐시 도입에 따른 6가지 테스트 시나리오

1. 로컬 캐시가 없어 글로벌 캐시를 조회하는 상황

2. 로컬 캐시와 글로벌 캐시가 없는 상황

3. 로컬 캐시가 있어 로컬 캐시를 반환하는 상황

4. 글로벌 캐시에 오류가 생겨 fallback을 반환하는 상황

5. 글로벌 캐시가 있어 로컬 캐시에 값을 저장하는 상황

6. 데이터 업데이트시 로컬, 글로벌 캐시를 초기화 하는 상황

@Slf4j
@RunWith(SpringRunner.class)
public class ChainedCacheTest {

  @Mock
  private Cache localCache;

  @Mock
  private Cache globalCache;

  @Test
  @DisplayName("local cache가 없다면 global cache 사용한다")
  public void useGlobalCache() {
    //given
    String key = "key1";
    String value = "value1";
    ValueWrapper valueWrapper = mock(ValueWrapper.class);
    List<Cache> caches = mock(List.class);
    when(caches.get(eq(0))).thenReturn(localCache);
    when(caches.get(eq(1))).thenReturn(globalCache);
    ChainedCache cache = new ChainedCache(caches);
    when(localCache.get(eq(key))).thenReturn(null);
    when(globalCache.get(eq(key))).thenReturn(valueWrapper);
    when(valueWrapper.get()).thenReturn(value);

    //when
    ValueWrapper result = cache.get(key);

    //then
    assertEquals(result, valueWrapper);
  }

  @Test
  @DisplayName("local cache에 value가 없다면 global cache를 사용한다")
  public void useGlobalCache2() {
    //given
    String key = "key1";
    String value = "value1";
    ValueWrapper lovalValueWrapper = mock(ValueWrapper.class);
    ValueWrapper globalValueWrapper = mock(ValueWrapper.class);
    List<Cache> caches = mock(List.class);
    when(caches.get(eq(0))).thenReturn(localCache);
    when(caches.get(eq(1))).thenReturn(globalCache);
    ChainedCache cache = new ChainedCache(caches);
    when(localCache.get(eq(key))).thenReturn(lovalValueWrapper);
    when(globalCache.get(eq(key))).thenReturn(globalValueWrapper);
    when(globalValueWrapper.get()).thenReturn(value);
    when(lovalValueWrapper.get()).thenReturn(null);

    //when
    ValueWrapper result = cache.get(key);

    //then
    assertEquals(result, globalValueWrapper);
  }

  @Test
  @DisplayName("둘 다 cache가 없다면 null을 반환한다.")
  public void nullCache() {
    //given
    String key = "key1";
    String value = "value1";
    ValueWrapper valueWrapper = mock(ValueWrapper.class);
    List<Cache> caches = mock(List.class);
    when(caches.get(eq(0))).thenReturn(localCache);
    when(caches.get(eq(1))).thenReturn(globalCache);
    ChainedCache cache = new ChainedCache(caches);
    when(localCache.get(eq(key))).thenReturn(null);
    when(globalCache.get(eq(key))).thenReturn(null);
    when(valueWrapper.get()).thenReturn(value);

    //when
    ValueWrapper result = cache.get(key);

    //then
    assertEquals(result, null);
  }

  @Test
  @DisplayName("local cache가 있다면 local cache를 사용한다")
  public void useLocalCache() {
    //given
    String key = "key1";
    String value = "value1";
    ValueWrapper valueWrapper = mock(ValueWrapper.class);
    List<Cache> caches = mock(List.class);
    when(caches.get(eq(0))).thenReturn(localCache);
    when(caches.get(eq(1))).thenReturn(globalCache);
    ChainedCache cache = new ChainedCache(caches);
    when(localCache.get(eq(key))).thenReturn(valueWrapper);
    when(valueWrapper.get()).thenReturn(value);

    //when
    ValueWrapper result = cache.get(key);

    //then
    assertEquals(result, valueWrapper);
  }

  @Test
  @DisplayName("global cache에 오류가 있다면 fallback이 발동된다.")
  public void useFallback() {
    //given
    String key = "key1";
    String value = "value1";
    ValueWrapper valueWrapper = mock(ValueWrapper.class);
    List<Cache> caches = mock(List.class);
    when(caches.get(eq(0))).thenReturn(localCache);
    when(caches.get(eq(1))).thenReturn(globalCache);
    ChainedCache cache = new ChainedCache(caches);
    when(localCache.get(eq(key))).thenReturn(null);
    when(globalCache.get(eq(key))).thenThrow(new RuntimeException());
    when(valueWrapper.get()).thenReturn(value);

    //when
    ValueWrapper result = cache.get(key);

    //then
    assertEquals(result, null);
  }

  @Test
  @DisplayName("global cache가 존재하면 local cache에 저장한다.")
  public void putCache() {
    //given
    String key = "key1";
    String value = "value1";
    ValueWrapper valueWrapper = mock(ValueWrapper.class);
    List<Cache> caches = mock(List.class);
    when(caches.get(eq(0))).thenReturn(localCache);
    when(caches.get(eq(1))).thenReturn(globalCache);
    ChainedCache cache = new ChainedCache(caches);
    when(localCache.get(eq(key))).thenReturn(null);
    when(globalCache.get(eq(key))).thenReturn(valueWrapper);
    when(valueWrapper.get()).thenReturn(value);

    //when
    ValueWrapper result = cache.get(key);

    //then
    assertEquals(result, valueWrapper);
    verify(localCache, times(1)).put(eq(key), eq(value));
  }

  @Test
  @DisplayName("cache를 초기화하면 local, global cache 둘 다 초기화된다.")
  public void clearCache() {
    //given
    List<Cache> caches = mock(List.class);
    when(caches.get(eq(0))).thenReturn(localCache);
    when(caches.get(eq(1))).thenReturn(globalCache);
    ChainedCache cache = new ChainedCache(caches);

    //when
    cache.clear();

    //then
    verify(localCache, times(1)).clear();
    verify(globalCache, times(1)).clear();

  }

}

서킷 브레이커 발동

서킷 브레이커가 발동하지 않은 상황

서킷 브레이커가 발동하지 않은 상황에서는

1. 로컬 캐시에 값이 있나 확인

2. 없다면 글로벌 캐시 조회

3. 장애 발생시 fallback호출 및 null리턴

 

서킷 브레이커가 발동한 상황

서킷 브레이커가 발동한 상황에서는

1. 로컬 캐시에 값이 있나 확인

2. fallback호출 및 null리턴

 

서킷 브레이커는 글로벌 get과정. 즉, 글로벌 캐시에서 조회하는 과정이 생략된다. 빠른 실패가 보장된다. 

 

동기화에 대한 6가지 테스트 시나리오

세세한 분기를 나눈 것을 제외하고 큰 로직은 다음과 같다.

 

1. 로컬 캐시에 값이 없을 때 동기화 x

2. 로컬 캐시에 값이 있고 글로벌 캐시에 값이 없으면 동기화

3. 로컬 캐시와 글로벌 캐시와 값이 같으면 동기화 x

4. 로컬 캐시와 글로벌 캐시 값이 다르면 동기화

5. 글로벌 캐시에 장애가 발생시 동기화 x

  @Test
  @DisplayName("로컬 캐시에 값이 없으면 동기화가 필요 없다")
  public void cacheSynchronized1(){
    ///given
    String key = "key";
    String value = "value";
    List<Cache> caches = mock(List.class);
    when(caches.get(eq(0))).thenReturn(localCache);
    when(caches.get(eq(1))).thenReturn(globalCache);
    ChainedCache cache = new ChainedCache(caches);
    when(localCache.get(eq(key))).thenReturn(null);

    //when
    boolean result = cache.isSynchronized(key);
    //then
    assertTrue(result);
  }

  @Test
  @DisplayName("로컬 캐시가 있고 글로벌 캐시가 없으면 동기화가 필요하다")
  public void cacheSynchronized3(){
    ///given
    String key = "key";
    String value = "value";
    List<Cache> caches = mock(List.class);
    ValueWrapper localValue = mock(ValueWrapper.class);
    ValueWrapper globalValue = mock(ValueWrapper.class);

    when(caches.get(eq(0))).thenReturn(localCache);
    when(caches.get(eq(1))).thenReturn(globalCache);
    ChainedCache cache = new ChainedCache(caches);
    when(localCache.get(eq(key))).thenReturn(localValue);
    when(localValue.get()).thenReturn(value);
    when(globalCache.get(eq(key))).thenReturn(globalValue);
    when(globalValue.get()).thenReturn(null);

    //when
    boolean result = cache.isSynchronized(key);
    //then
    assertFalse(result);
  }

  @Test
  @DisplayName("로컬 캐시와 글로벌 캐시와 값이 같으면 동기화가 필요 없다.")
  public void cacheSynchronized5(){
    ///given
    String key = "key";
    String value = "value";
    List<Cache> caches = mock(List.class);
    ValueWrapper valueWrapper = mock(ValueWrapper.class);
    when(caches.get(eq(0))).thenReturn(localCache);
    when(caches.get(eq(1))).thenReturn(globalCache);
    ChainedCache cache = new ChainedCache(caches);
    when(localCache.get(eq(key))).thenReturn(valueWrapper);
    when(globalCache.get(eq(key))).thenReturn(valueWrapper);
    when(valueWrapper.get()).thenReturn(value);

    //when
    boolean result = cache.isSynchronized(key);
    //then
    assertTrue(result);
  }

  @Test
  @DisplayName("로컬 캐시와 글로벌 캐시의 값이 다르면 동기화가 필요하다.")
  public void cacheSynchronized6(){
    ///given
    String key = "key";
    String value = "value";
    List<Cache> caches = mock(List.class);
    ValueWrapper valueWrapper = mock(ValueWrapper.class);
    when(caches.get(eq(0))).thenReturn(localCache);
    when(caches.get(eq(1))).thenReturn(globalCache);
    ChainedCache cache = new ChainedCache(caches);
    when(localCache.get(eq(key))).thenReturn(valueWrapper);
    when(valueWrapper.get()).thenReturn(value);
    when(globalCache.get(eq(key))).thenReturn(null);
    //when
    boolean result = cache.isSynchronized(key);
    //then
    assertFalse(result);
  }

  @Test
  @DisplayName("글로벌 캐시에 장애가 발생하면 동기화가 필요없다.")
  public void cacheSynchronized7(){
    ///given
    String key = "key";
    String value = "value";
    List<Cache> caches = mock(List.class);
    ValueWrapper valueWrapper = mock(ValueWrapper.class);
    when(caches.get(eq(0))).thenReturn(localCache);
    when(caches.get(eq(1))).thenReturn(globalCache);
    ChainedCache cache = new ChainedCache(caches);
    when(localCache.get(eq(key))).thenReturn(valueWrapper);
    when(valueWrapper.get()).thenReturn(value);
    when(globalCache.get(eq(key))).thenThrow(new RuntimeException());
    //when
    boolean result = cache.isSynchronized(key);
    //then
    assertTrue(result);
  }


  
  

이외에 서비스 단위, 통합 테스트를 진행해서 동기화 여부를 점검했다.

 

마치며

서킷 브레이커, fallback 개념을 처음 알게 되었다. 외부 의존성을 가진 어플리케이션에서 안정성, 지연 시간에 대한 대응을 위해 유용했다. Hystrix에 대한 설정이 아주 많은데 아직 어떻게 설정해야 할지 감이 오지 않아 default값을 최대한 활용했다. 서킷 브레이커의 발동 조건이나 다른 설정들은 운영 및 공부해 나가면서 개선해나가면 될 것 같다. 장애 처리에 대해 생각해보면서 이런 설계를 구상하고 구현하는 데 정말 재밌었다. 다른 개발자님들이 블로그나 여타 자료로 남겨주신 게 도움이 많이 됐다.