Application, Redis, MySQL

캐시 문제 해결 가이드 - DB 과부하 방지 실전 팁

profile-img
김신토스코어 서버 개발자
2024. 2. 15

데이터베이스는 시스템을 확장하기 어렵다. 주로 샤딩과 복제를 통해 어렵게 확장해야 하는데다가, 이 과정에서 일관성, 가용성, 분할 내성 셋을 모두 만족시킬 수 없다는 점이 널리 알려져 있다.(CAP 이론) 이러한 이유로 데이터베이스를 확장할 때는 신중한 고민이 필요하다.

가급적 데이터베이스의 부하를 최소화하여 확장 필요성을 줄이는 것이 바람직하다. 이를 위한 기본적인 접근법은 데이터베이스 조회 이전에 캐시를 먼저 확인하는 것이다. 높은 캐시 히트율을 유지하면 데이터베이스 확장 없이도 상당한 트래픽을 처리할 수 있다.

인메모리 저장소인 레디스(Redis)멤캐시드(Memcached)를 사용해서 캐시 시스템을 구축하는 곳이 많다. 이들 저장소는 사용하기 쉬운 데다가 응답 속도가 빠르기 때문이다. 특히 레디스를 쓰는 곳이 많은데 레디스는 다양한 활용 사례를 쉽게 찾을 수 있어서 안정적인 운영이 가능하다.

하지만 대용량 트래픽 환경에서 캐시를 사용할 때는 몇가지 주의해야 할 상황들이 있다. 이 글에서는 캐시를 사용함에도 불구하고 데이터베이스 부하로 인해 서비스 장애가 발생할 수 있는 위험 상황들을 설명하고 이를 예방하는 방법을 소개한다.

1. 캐시 쇄도 (Cache Stampede)

캐시 미스가 동시에 많이 발생할 경우 데이터베이스에 부담이 가중된다. '캐시 쇄도'라고 부르는 상황인데, 캐시가 전부 정확히 같은 시간에 만료되도록 구현하면 자주 발생한다.

예를 들어 매일 자정에 캐시를 갱신하는 경우를 생각해 보자. 갱신 시간에 맞춰 캐시를 일제히 만료되도록 설계하는 것은 구현하기 쉬운 데다가 최신 정보를 바로 제공할 수 있다는 이점이 있다. 하지만 이런 캐시 만료 전략은 캐시가 만료되는 자정마다 데이터베이스로 트래픽이 집중되어 서비스 장애가 발생할 위험이 있다.

해결안: 지터 (Jitter)

캐시 만료 시간을 무작위로 약간 지연시키면, 캐시 쇄도 상황에서도 데이터베이스의 부하를 균등하게 분산시킬 수 있다.

전자공학에서 사용 되는 '지터' 개념을 활용하는 것이 그 방법이다. 지터는 전자 신호를 읽는 과정에서 발생하는 짧은 지연 시간을 의미한다. 우리는 지터처럼 짧은 시간을 캐시 만료 시간에 더해서 부하를 분산시킬 수 있다. 예를 들어 0~10초 사이의 무작위 지연 시간을 추가하면, 데이터베이스의 부담이 10초에 걸쳐 분산된다.

서비스마다 허용할 수 있는 지연 시간은 다르기 때문에, 서비스에 맞게 최대 지터 시간을 설정해야 한다. 지터가 길어질수록 사용자는 더 오래된 정보를 볼 수 있으므로, 지터가 과도하게 추가되지 않도록 주의해야 한다.

2. 캐시 관통 (Cache Penetration)

보통 캐시에서 null 값이 반환되면 자연스럽게 데이터베이스를 조회해서 캐시를 채운다. 그런데 데이터베이스에도 해당 값이 없어서 null을 반환받았을 때는 캐시를 채우지 않도록 구현하는 경우가 흔하다. 데이터베이스로 부터 반환받은 '값이 없다'는 정보를 캐싱하지 않으면 어떤 위험이 있을까?

데이터베이스에서 읽었는데도 캐싱 되지 않는 상황을 '캐시 관통'이라고 부른다. 캐시 관통이 빈번하다면, 데이터베이스에 불필요한 조회 요청이 자주 발생한다. 따라서 데이터가 없다는 사실도 캐싱해야 불필요한 데이터베이스 부하를 줄일 수 있다.

해결안: 널 오브젝트 패턴 (Null Object Pattern)

‘값이 없음’을 캐싱함으로써 데이터베이스의 트래픽을 줄이기 위해서 블룸 필터를 사용하는 것도 좋은 방법이다. 블룸 필터를 사용하면 확률적으로 캐시 관통을 방지한다. 하지만 블룸 필터의 정합성이 깨진다면, 블룸 필터를 복구하기 위해 모든 캐시를 읽어야 해서 운영에 어려움이 따른다.

널 오브젝트 패턴을 사용해서 ‘값이 없음’을 캐싱하는 편이 조금 더 운영하기 쉽다. 객체 타입은 부재를 뜻하는 객체를 선언하여 사용하면 되지만, 원시 타입의 경우에는 이 객체를 대체할 특정 값을 지정해야 한다. 예를 들어, 양수만 존재하는 정수 타입의 데이터를 캐시할 때는 음수인 정수의 최솟값으로 '값이 없음'을 나타내기로 애플리케이션에서 약속할 수 있다.

3. 캐시 시스템 장애

평이한 트래픽 상황에서는 캐시 시스템에 장애가 발생하더라도 데이터베이스로 트래픽을 보내면 서비스를 정상 운영할 수 있다. 하지만 트래픽이 큰 상황이라면 캐시 시스템이 복구될 때까지 데이터베이스에 과부하가 걸릴 위험이 있다.

데이터베이스가 모든 트래픽을 감당할 수 있다고 낙관하는 것은 위험한 생각이다. 데이터베이스가 한계를 넘는 트래픽을 받으면, 캐시와 무관한 기능조차 정상적으로 작동하지 못할 수 있다. 따라서 데이터베이스가 감당할 수 있는 범위의 트래픽을 유지하도록 계획을 세워야 한다.

해결안: 대체 작동 (Failover)

캐시 시스템 결함에 의한 장애를 최소화하기 위해서는 핵심 기능을 정의할 필요가 있다. 캐시 시스템이 망가졌다면 반드시 동작해야 할 핵심 기능을 제외하고, 편의를 위한 부가 기능은 일시적으로 운영을 중단하는 게 나은 판단이다. 캐시 시스템이 복구되는 동안 데이터베이스가 핵심 기능에 의한 트래픽을 처리할 수 있도록 하며, 부가 기능에 대해서는 사용자에게 대체 UI를 제공하거나 양해를 구하는 것이 현실적인 대응 방법이다.

캐시 코드를 공통화 하다보면 기능의 중요도를 따지지 않고, 데이터베이스로 fallback하는 코드를 작성하기 쉽다. 데이터베이스 부하를 감안하더라도 꼭 동작해야할 기능인지 개발자가 미리 고민할 필요가 있다.

4. 핫키 (hotkey) 만료

많은 요청이 집중되는 키를 '핫키'라고 부른다. 핫키가 만료되는 순간, 여러 요청이 동시에 데이터베이스를 불필요하게 반복해서 조회할 수 있다. 가능하다면 캐시의 만료 기한을 없애거나, 백그라운드에서 주기적으로 새 값을 적용해서 캐시가 만료되지 않게 하는 것이 좋다. 하지만 핫키가 때에 따라 바뀌는 환경에서는 더 이상 핫키가 아닌 데이터로 인해 캐시 저장소 공간이 낭비될 수 있다.

해결안: 분산 락 (Distributed Lock)

분산 락을 사용하면 공간 낭비 없이 불필요한 데이터베이스 중복 조회를 방지할 수 있다. 멀티 스레드 프로그래밍에서 공유 자원 다룰 때 락을 사용하는 것과 비슷한 원리다. 캐시를 애플리케이션 서버 간의 공유 자원으로 볼 수 있다. 캐시 미스가 발생했을 때 락을 설정하고 캐싱한 후에 락을 해제함으로써, 단 한 번의 쓰기 작업만 허용할 수 있다.

레디스를 사용하고 있다면 분산 락을 적용하기 굉장히 쉽다. 레디스의 싱글 스레드 특징을 활용한 레드락 알고리즘 덕분이다. 다양한 프로그래밍 언어를 지원하는 레드락 구현 라이브러리들이 존재한다. 이 라이브러리들을 사용하면 분산 환경에서도 공유 자원을 효과적으로 관리할 수 있다. 레디스 없이도 분산 락을 구현할 방법은 다양하므로, 핫키 만료 상황에서 분산 락을 이용하면 캐시 히트율을 유지할 수 있다.

정리

이 글에서는 데이터베이스 부하 방지를 위한 캐시 시스템의 도전과제들을 다뤘으며, 이에 대한 몇 가지 해결책을 모색해 봤다. 모든 상황에 완벽하게 적용될 수는 없겠지만, 적어도 일부 문제를 해결하거나 완화하는 데는 도움이 될 수 있다.

데이터베이스와 캐시 시스템의 상호작용은 예측하기 어렵다. 그래서 제시된 아이디어를 실제로 적용할 때는 더 다양한 요소들을 고려해야 한다. 모든 환경에서 효과적이지는 않지만 이런 아이디어들이 캐시로 인한 문제들을 인식하고, 해결 방안을 고려하는 데 시작점이 되기를 바란다.

재미있게 읽으셨나요?

좋았는지, 아쉬웠는지, 아래 이모지를 눌러 의견을 들려주세요.

😍
🤔
website-code-blue

토스팀이 만드는 수많은 혁신의 순간들

당신과 함께 만들고 싶습니다.
지금, 토스팀에 합류하세요.
채용 중인 공고 보기