개발 이야기/데이터베이스

[DB] 캐시를 설정하는 기준

제이온 (Jayon) 2024. 1. 7.

안녕하세요? 제이온입니다.

오늘은 캐시를 설정하는 기준을 설명드리려 합니다. 개인적으로 실무를 겪으면서 작성하는 포스팅이므로, 어디까지나 참고로 봐주시면 좋을 것 같습니다 ㅎㅎ

 

Cache란?

캐시는 나중에 요청할 결과를 미리 저장해 둔 후 빠르게 서비스해 주는 것을 의미합니다. 즉, 미리 결과를 저장하고 나중에 요청이 오면 그 요청에 대해서 DB 또는 API를 참조하지 않고 캐시에 접근하여 요청을 처리하는 기법입니다. 이러한 캐시가 나온 배경에는 파레토 법칙이 있습니다.

 

파레토 법칙은 80%의 결과는 20%의 원인으로 인해 발생한다는 뜻으로, 아래 사진을 참고해 주시면 좋을 것 같습니다!

 

 

즉, 캐시는 모든 결과를 캐싱할 필요가 없으며 서비스를 할 때 많이 사용되는 20%만 캐싱함으로써 전체적으로 효율을 끌어올릴 수 있습니다.

 

어떤 데이터를 캐싱해야 할까?

파레토 법칙에 의해 아무 데이터를 캐싱하면 안 되고, 꼭 필요한 데이터만 캐싱해야 합니다. 그렇다면, 어떠한 데이터를 캐싱해야 할까요?

 

자주 읽어야 하지만 쓰기가 거의 일어나지 않는 데이터

바로, “자주 읽어야 하지만 쓰기가 거의 일어나지 않는 데이터에 대해서 캐싱해야 한다.” 라고 이론적으로 많이 이야기하는데 “자주 읽음”의 기준과 “쓰기가 거의 일어나지 않음”의 기준이 상당히 모호했습니다.

 

그래서 저는 다음과 같은 스텝으로 캐싱할 데이터를 조사합니다.

 

  • 데이터 독과 같은 APM을 통해 RDB의 쿼리 호출 내역 TOP 5를 확인합니다.
  • 그 중, 조회 쿼리를 찾고 어떤 테이블로부터 조회를 해 오는지 확인합니다.
  • 해당 테이블의 업데이트 쿼리가 얼마나 불리는지 확인합니다.

 

위와 같은 프로세스를 거치면서 조회가 많지만 업데이트 쿼리가 적게 발생하는지 보는 것입니다. 제가 실무에서 확인한 테이블은 조회 쿼리가 하루에 174만 번 발생했는데, 업데이트 쿼리는 많아야 500번 정도 발생했습니다. 이정도면 누가봐도 캐시에 적합한 조건이라 봐도 되겠습니다 ㅎㅎ

 

최신화에 민감한 데이터

최신화에 민감한 데이터는 RDB와 캐시 사이에 불일치가 짧아야 한다는 뜻입니다. 가령, 결제와 관련된 정보의 경우 최신화에 매우 민감하기 때문에 위의 캐시 조건에 맞더라도 적용을 고민해 봐야 합니다.

 

저는 위의 2가지 특성에 맞는 결제 관련 테이블에 대해 캐싱을 해야 했습니다. 그래서 해당 결제 관련 테이블을 사용하는 모든 로직에서 캐시를 적용하지는 않았고, 실질적으로 결제가 일어나지 않는 비교적 안전한 로직에 부분적으로 캐싱하기로 결정했습니다.

 

로컬 캐싱 vs 글로벌 캐싱

이제, 캐싱할 데이터와 캐싱할 범위는 어느 정도 정했습니다. 그렇다면, “어디”에 캐싱 데이터를 저장할지 고민해야 합니다. 일반적으로 로컬 메모리에 저장하거나, Redis와 같은 별도 서버에 저장할 수 있습니다.

 

로컬 캐싱

로컬 캐싱은 애플리케이션 서버의 메모리에 캐싱할 데이터를 저장하는 방법이며, 일반적으로 Guava cache나 Caffeine cache를 많이 사용합니다.

 

장점

  • 애플리케이션 로직을 수행하다 바로 같은 서버 내의 메모리에서 캐시를 조회하므로 속도가 빠릅니다.
  • 구현하기 쉽습니다.

 

단점

  • 인스턴스가 여러 개일 경우 여러 문제점이 생깁니다.
    • 한 인스턴스에서 변경한 캐시를 다른 인스턴스에 전파할 수 없습니다. 단, 변경을 다른 인스턴스에 전파하는 로컬 캐싱 라이브러리도 있습니다.
    • 각 인스턴스마다 캐시가 저장되므로 새로운 인스턴스가 뜨면 캐시를 새로 넣어야 합니다. 이로 인해 캐시 미스가 많이 발생하여 트래픽을 견디지 못해서 인스턴스가 죽을 수 있습니다.

 

글로벌 캐싱

글로벌 캐싱은 Redis와 같은 별도로 캐시 데이터를 저장하는 서버를 두는 방식입니다.

 

장점

  • 인스턴스간 캐시를 공유하므로 한 인스턴스에서 캐시를 수정하더라도 모든 인스턴스가 동일한 캐시 값을 얻을 수 있습니다.
  • 새로운 인스턴스가 뜨더라도 이미 있는 캐시 저장소를 바라보면 되므로, 캐시를 채워 넣는 동작을 할 필요가 없습니다.

 

단점

  • 네트워크 트래픽을 거쳐야 하므로 속도가 로컬 캐싱에 비해 느립니다.
  • 별도의 캐시 서버를 두어야 하므로 인프라 관리 비용이 생깁니다.
    • 인프라 관리 비용? → 서버 요금, 인프라 세팅 및 유지 보수하는 데 걸리는 시간, 장애 대응 대책 구상 등

 

필자는 어떤 것을 선택했는가?

현재 회사의 애플리케이션 서버는 여러 인스턴스를 띄우는 구조이지만, 로컬 캐시를 선택했습니다.

크게 이유는 3가지가 있습니다.

 

  • RDB에 저장된 캐싱할 데이터가 4만개가 좀 되지 않아, 이를 모두 메모리에 올려도 4MB 이하입니다.
  • 결제 관련된 데이터에 대해 조회 성능이 좋아야 했습니다.
  • Redis가 기존에 있기는 하나, 새로운 캐시를 Redis에 저장하는 것이 인프라 비용이 생깁니다.

 

캐시를 어떻게 최신화할 것인가?

애플리케이션 서버가 여러개 있고, 여기에 로컬 캐싱을 적용하였다면 각 애플리케이션 서버 별로 저장된 캐시 값이 다를 수 있습니다. 가령 A 서버의 저장된 캐시 데이터는 “1”이지만, B 서버의 저장된 캐시 데이터는 B 서버에서 변경하여 “2”가 될 수 있습니다. 이 상황에서 사용자가 로드밸런서에 요청을 보내면 A 서버와 B 서버에서 상이한 값을 받게 됩니다.

 

따라서 각 인스턴스 별로 캐시를 자동으로 제거하여 RDB로부터 조회하도록 구성해야 하는데, 이때 TTL을 주로 사용합니다.

 

TTL은 얼마로 설정해야 하는가?

TTL은 Time To Live의 약어로, 특정 시간이 지나면 캐시를 지우는 설정입니다. 가령, TTL을 5초로 설정하였다면 캐시 데이터는 5초 뒤에 자동으로 지워집니다. 그 후, 캐시 미스가 발생하면 RDB로부터 데이터를 조회해 와서 저장합니다.

 

그렇다면, TTL은 얼마로 설정해야 할까요?

 

read/write가 하나의 캐시 서버에서 발생

read/write가 Redis와 같은 글로벌 캐싱 서버 하나에서 발생하거나, 로컬 캐싱이 적용된 하나의 애플리케이션 서버에서 발생한다면, TTL의 값은 시간 단위 이상으로 올려도 무방합니다. 어차피 write할 때 기존 캐시를 수정할 것이고, 해당 캐시에서 데이터를 가져오는 서버는 항상 최신화된 데이터를 보게 됩니다.

 

이 경우 TTL을 설정하지 않고, 캐시 서버가 꽉차면 LRU 알고리즘을 통해 자동으로 캐시를 조금씩 비우는 방식으로 구성할 수도 있습니다.

 

read/write가 여러 캐시 서버에서 발생

read/write가 다중화된 글로벌 캐싱 서버에서 발생하거나, 로컬 캐싱이 적용된 여러 개의 애플리케이션 서버에서 발생한다면, TTL은 초 ~ 분 단위로 가져가는 것이 좋습니다. 왜냐하면 수정된 데이터를 아직 반영하지 않는 캐시 서버의 오래된 데이터를 읽을 가능성이 있기 때문입니다.

 

이때 TTL은 다양한 맥락에서 결정되는데, 최신화가 중요하고 값이 변경될 확률이 높을수록 TTL을 짧게 가져 가야 하고, 최신화가 덜 중요하고 값이 변경될 확률이 낮을수록 TTL을 조금 더 길게 가져가도 됩니다.

 

필자는 TTL을 어떻게 설정하였는가?

제가 캐싱할 데이터는 결제와 관련한 데이터고, 실질적으로 결제가 발생하는 엄밀한 로직에는 캐싱을 적용하지 않더라도 어쨌든 결제 특성상 최신화가 중요합니다. 다만, 업데이트 가능성은 낮기 때문에 TTL은 5초 정도로 안전하게 가져갔습니다.

 

결론

정리해 보면, 제가 선택한 캐싱 방식을 다음과 같습니다.

 

  • 결제 관련 데이터
  • 조회가 매우 빈번히 일어나지만, 수정은 거의 일어나지 않음.
  • 실질적으로 결제가 일어나지 않지만, 조회가 발생하는 로직에 한해 캐싱을 적용함.
  • 로컬 캐싱을 적용하였으며, TTL은 5초로 설정함.

 

앞으로 할 일은 실제로 적용한 캐싱 방식에 한해 성능 테스트를 진행하는 것입니다. 아직 구체적으로 어떻게 성능 테스트를 진행할지는 고민 중이어서 이후 포스팅에서 작성해보겠습니다!

댓글

추천 글