Link Search Menu Expand Document

2023-02-05

[etc] 실무에서 사용하는 캐시 고도화

언젠가 캐시에 대해 써봐야겠다는 마음만 있었는데 최근에 생각이 조금 정리가 되어서 써봤어.
소프트웨어 설계 관련된 책에서 캐시 설계 부분을 보면 지나치게 추상적이라 실제로 적용할 때는 많은 난관에 부딪히곤 해.
실무에서 일어나는 수많은 트레이드 오프들이 거기선 드러나지 않거든.
그래서 여기서는 간단히 캐시에 대해 설명하고 이후에 예시를 통해 고도화를 해보고 그에 따라 발생할 수 있는 문제 및 보완책을 살펴볼게.


- 캐시의 정의와 목적
AWS 캐싱 페이지에 정의된 문구보다 적절한 표현을 찾기 힘드네. (https://aws.amazon.com/ko/caching/)
"캐시의 주요 목적은 더 느린 기본 스토리지 계층에 액세스해야 하는 필요를 줄임으로써 데이터 검색 성능을 높이는 것이다."
더 느린 스토리지라고 하면 보통 DB, file, network 접근을 말하고, 이 스토리지 접근 비용을 줄이기 위해 redis나 in-memory에 데이터를 저장해서 사용하는걸 캐싱이라 할 수 있어.


- 예시 시나리오
내가 만약 홈쇼핑 서비스를 개발중인데 사업팀에서 12월 25일에 의류 카테고리에 40% 할인 이벤트를 걸어놨어.
이 때, 할인 이벤트 중에서 가장 높은 할인율이 적용되는 룰이라고 해보자.

이 이벤트 데이터는 아래처럼 생겼고 eventDB에 들어있어.
{
  id: "20221225001",
  type: "sale",
  category: "cloth",
  value: 0.4,
  startat: "2022-12-25T00:00:00",
  endat: "2022-12-25T23:59:59"
}

자. 이제 의류 카테고리의 상품을 볼 때마다 유저에게 결제 예상 금액을 알려줘야 하는데 그 때마다 가격 계산을 위해 eventDB에서 적용 가능한 이벤트를 찾는다고 생각해 봐.
유저 접속이 100명이면 부하가 견딜만 할 거야. 하지만 천명이면? 만명이면? 페이지가 갑자기 핫딜로 공유되기라도 한다면?
eventDB에는 엄청난 개수의 요청이 들어올거고 문제가 발생할 확률이 높아.
아래에서는 이런 악조건을 추가하면서 고도화를 적용해볼게.


- 고도화
1) 초기화 시점에 in-memory에 보관
서비스가 시작하는 초기화 시점에 미리 데이터를 받아와서 EventDataManager 같은 싱글턴 객체에서 관리하여 서비스 어느 곳에서든 접근하도록 만들어봤어.
getSalePrice는 대충 item, events 가져와서 조건에 맞게 필터링해서 가장 높은 salePer를 계산하는 간단한 구조야.

init() {
  EventDataManager.init();
}
getSalePrice(long itemid) {
  let item = getItem(itemid);
  let salePer = 0;
  const events = EventDataManager.getEventsByTime(Date.Now());
  for (let ev of events) {
    if (ev.category === item.category && ev.type === "sale") {
      salePer = Math.max(salePer, ev.value);
    }
  }
  const retPrice = item.price * salePer;
  return retPrice;
}
EventDataManager::init() {
  this.events = eventDB.findAll();
}
EventDataManager::getEventsByTime(time) {
  return this.events.filter(ev => {
    return time >= ev.startat && time <= ev.endat;
  });
}

이러면 서비스를 처음 시작할 때만 부하가 생기고 이후에는 괜찮을거야. 일반적으로 나쁘지 않은 방법이지.
그런데 등록된 이벤트 양이 늘어나서 한번에 가져오는게 5mb라고 생각해 봐.
이 상황에서 서비스 수백개를 동시에 띄우면 DB 응답 지연을 피하기 힘들거야.


2) 지연 로딩
서비스 초기화 시점에 몰리지 않도록 구성하면 자연스레 로드밸런싱이 될거야.
몇 가지 방법이 있을텐데 시작할 때 랜덤한 시간을 지연했다가 로드를 요청하거나 처음 사용하는 시점에 불러오는 방법이 있어.
랜덤한 시간을 주는 방법은 그 시간동안 초기화가 끝나지 않아서 서비스 가동이 늦어지는 단점이 있지만 트레이드 오프라고 생각해.

EventDataManager::init() {
  const randSec = Math.floor(Math.rand() * 30 + 1);
  setTimeout(() => {
    this.events = eventDB.findAll();
  }, randSec * 1000);
}

사용 시점에 불러오는 방식은 호출 시 확인해야 하는데 이건 뒷맛이 좀 나쁘긴 해. 왜냐하면,
1. 단 한번만 실행되는 분기를 매번 확인해야 함
2. 최초 api의 지연 시간이 지나치게 길어질 수 있음
3. get역할의 함수에서 멱등성이 보장되지 않는 찜찜함

EventDataManager::getEventsByTime(time) {
  if (!this.events) {
    EventDataManager.init();
  }
  return this.events.filter(ev => {
    return time >= ev.startat && time <= ev.endat;
  });
}

뭐 어쨌든 대충 취사선택해서 이걸로 동시접속자 1만명을 버텨내고 있는 상황에서 사업팀에게 요구사항이 오게 돼.
'이벤트를 실시간으로 변경하고 싶어요. 점검없이.'


3) 캐시 갱신 이벤트 전파
이제 구조를 확장할 때가 왔어.
할인 이벤트를 변경하는 api가 날아오면 모든 서비스는 그 시점에 알 수 있어야 해.
polling은 잘 안쓰니 여기선 event로 받는다고 생각해볼게.
event시스템으로는 보통 mq, kafka, redis(pubsub)를 사용하게 될텐데 뭐든 상관없어.

캐시 만료 메시지의 보내는 주체는 이벤트 변경 api를 받는 서비스가 보낼거고 (보통 운영툴?), 이 서비스가 event를 생성해서 mq로 전달하면 mq와 연결된 모든 서비스는 캐시 만료 메시지를 받게 돼.
이 때, 변경된 사항만 받을건지 전체를 갱신할건지 선택의 기로에 놓이는데 메시지가 작다면 diff만, 정합성이 깨지기 쉬운 데이터면 전체를 받는게 안전해.
다만 각자의 장점은 각자의 단점이니 정답은 없고 실제 비즈니스 데이터의 빈도와 양에 따라 선택하면 돼.

혹시 나중에 바뀔지라도 초기 요구사항을 명확히 해야하는 이유가 이런 설계에 영향을 많이 미치기 때문인데 빈도가 거의 없을줄 알고 전체 갱신하도록 구현했는데 실제로는 짧은 시간에 여러번 바꾼다던가 하면 갱신할 때마다 부하가 엄청날거고 정합성을 맞추기도 어려워 져.
아래는 변경 사항만 받아서 업데이트 시키는 로직인데 new/delete/edit 상황을 처리하도록 했어.

changeEventData(id, eventData) {
  let changeType = '';
  for (let i = 0; i < this.events.length; i++) {
    if (this.events[i].id === id) {
      if (eventData) {
        changeType = 'edit';
        this.events[i] = eventData;
      } else {
        changeType = 'delete';
        this.events[i].splice(i, 1);
      }
      break;
    }
  }
  if (!changeType) {
    changeType = 'new';
    this.events.push(eventData);
  }
  console.log(`changedEventData: ${changeType}/${id}/${eventData}`);
}

근데 이것만으로는 많이 부족해. 왜냐하면 여러 서비스 간의 공통 캐시는 동시성이 깨질 경우가 생각보다 많아서 그래.
이벤트 갱신 중에 다시 이벤트 갱신 메시지가 오거나, 이벤트 순서 정합성을 보장하지 못한다던가 등등..
전체가 잘못된 캐시를 가지고 있는 것도 문제지만, 일부 인스턴스만 잘못된 캐시를 가지고 있는게 사실 더 큰 문제야. 이런건 찾아내기도 힘들고 로컬에서 재현하기가 쉽지 않거든.
다음은 이런 동시성 문제를 일으키는 여러가지 시나리오와 보완책 등을 알아볼게.


4) 캐시 만료 동시성 유지
diff만 보낼 때 가장 신경써야 할 건 이벤트 순서 정합성이야.
new, delete 순서로 생성된 갱신 메시지가 도착했을 때에 delete, new 순서로 처리한다면 잘못된 데이터셋을 가지게 되니까.
이를 해결하는 방법은 다양하지만 그 중에 몇 가지만 상상해볼게

기본 아이디어는 이벤트 변경 api를 받으면 변경 메시지를 별도 db에 기록하고 이 때 순차적으로 증가하는 고유 id를 받아서 각 서비스에 전달하는거야.
변경 메시지 db에는 이런 식으로 데이터가 쌓일거야.

{no:14, eventData:~~}, {no:15, eventData:~~}...

개별 서비스는 no가 포함된 통지 메시지를 받으면 저 db에서 변경된 사항을 가져와서 적용시키는거지.

그럼 순서가 뒤집히면 어떻게 하냐고? 이전에 가장 마지막으로 설정된 no값을 가지고 있다가 그 사이에 있는 값을 가져오도록 하면 돼.
만약 서비스에서 마지막으로 업데이트 한 no가 13번이고 그 다음에 온 no가 15번이면 14,15를 가져와서 갱신하는거야.
이후에 no 14번 메시지가 와서 갱신을 요청해도 이 서비스에는 이미 15번까지 적용되었기 때문에 무시하면 ok.
서비스에서는 최초 초기화 시점에 가장 마지막 no를 가지고 있어야 해. 그래야 어느 시점의 데이터부터 적용하면 되는지 알 수 있어.

그런데 거의 일어나지 않는 데이터 순서 정합성때문에 db에 따로 쓰면서까지 diff를 유지하는게 좀 귀찮긴 해. 
처음처럼 변경 데이터를 다른 곳에 기록하지 않고 메시지로 받아서 비슷하게 하는 방법은 없을까?

그렇게 하려면 일단 순차적으로 증가하는 공용 no 하나를 어디에 저장해놓고 (만만한 redis?) 서비스에서 받은 변경 메시지 no가 이전 것과 비교해서 순차적이지 않으면 전체 캐시를 갱신시키는 다소 게으른 방법도 괜찮아.
이 방법은 변경점이 포함된 메시지를 받는 방식이라 제어하기 편하고 거의 발생하지 않을거라 예상되는 이벤트 순서 정합성 문제를 해결할 수 있어.
ex) 서비스에 저장된 마지막 no가 13번인데 다음에 오는 메시지가 15번이면 전체 갱신, +1 늘어난 no(여기서는 14)면 변경 데이터만 갱신

{no:15, id:event.id, eventData:~~}


여기까지는 만료 시점을 기준으로만 생각해봤는데 이제 갱신 시점에서도 살펴볼거야.

이번엔 만료를 받으면 전체 갱신을 한다고 생각해볼게.
만료 메시지를 받는 즉시 갱신을 하게 되면 같은 서비스인데 인스턴스 간에 서로 다른 캐시를 가지고 있을 수 있잖아?
10개 인스턴스 중에서 9개는 완료되었는데 나머지 1개는 아직 갱신이 되지 않았다면 유저 입장에서는 9번은 변경된 값으로 받고 1번은 예전 값으로 받을거야.
물론 정상적이라면 이렇게 정합성 깨진 상태가 오래 가진 않겠지만 캐시 갱신이 1분 이상 오래 걸린다면 어떨까?


5) 캐시 갱신 동시성 유지
이 해결을 위해서는 당장 주어진 요구사항 만으로는 해결이 불가능하고, 이를 만든 부서와 협의가 필요해.
갱신 시점을 모두 동일하게 하려면 이벤트 변경 시 즉시 적용되지 않고 5분 정도 후에 적용된다고 통보 겸 협의가 필요한거지. 어쨌든 운영 중에 사용자는 그쪽이니까.
이렇게 협의(=통보)가 끝나면 이제 자세한 설계를 해볼 수 있어.

변경 시점이 동일하기 위해서는 각 서버의 시간이 동일하다는 가정 하에 시간 기준으로 하는 방법이 가장 편해.
변경에 대한 메시지를 받으면 변경부터 5분 후에 모든 인스턴스가 갱신을 보장하는 시스템이라고 생각해 봐.

{type: 'event_refresh', changed: '2023-02-02T12:37:23'}

모든 인스턴스가 이 메시지를 받는다면 갱신은 42분 23초에 시키면 된다고 알게 될거야.
그럼 5분 후에 동시에 가져오면 될까? 그럼 다시 동일한 문제가 생길 확률이 커.
여기서는 만료 메시지가 오면 미리 가져와서 저장했다가 포인터만 바꾸는 방식으로 해볼게. (더블버퍼링처럼)
가져올 때도 일시적으로 몰리지 않도록 랜덤한 시간을 추가했어.

let new_events;
refreshCache(changed) {
  // get new events within 30 sec
  const randSec = Math.floor(Math.rand() * 30 + 1);
  setTimeout(() => {
    new_events = eventDB.findAll();
  }, randSec * 1000);

  // change event to new
  const five_min = 5 * 60 * 1000;
  const refreshTime = changed.getTime() + five_min;
  const waitTime = refreshTime - Date.now();
  setTimeout(() => {
    this.events = new_events;
  }, waitTime);
}

이 예제에서는 요구사항을 추가하면서 간단히 의사 코드로 짜봤는데 실제 서비스는 이보다 훨씬 복잡할 수 있어.
아무리 정교하게 짜놨다 하더라도 캐시 정합성이 깨지는 건 한순간인데 발견이 쉽지 않거든.
그래서 캐시 만료와 별개로 5분마다 최신 캐시인지 확인하기도 하는데 매번 전체 데이터를 가져오긴 힘드니 hash값으로 확인한다던가 하는 2중, 3중 보완책을 만들어 놓기도 해.


결론)
1) 캐시는 느린 스토리지 접근 비용을 줄이고 데이터 검색 성능을 높이는 목적으로 사용한다
2) 구현할 기능의 요구사항을 명확히 파악하고 이에 대한 협의를 통해야 효율적인 캐시 설계가 가능하다
3) 캐시 만료 시점과 갱신 시점을 정확히 알고 있어야 한다
4) 오버엔지니어링이 되지 않도록 서비스 특성에 맞는 캐시 시스템을 설계해야 한다


덧글마다 답해주긴 힘들지만 매번 소중한 의견 고마워.
새해가 지난지 한참이지만 모두 소망을 이루는 한해가 되길 바랄게.

#frogred8 #cache #architecture