Application, Redis, MySQL

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

profile-img
김신토스코어 Server Developer
2024. 2. 15

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

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

RedisMemcached와 같은 인메모리 저장소로 캐시 시스템을 많이 구축합니다. 사용하기 쉬운 데다가 응답 속도가 빠르기 때문인데요. 특히 Redis는 온라인에서 다양한 활용 사례를 쉽게 찾을 수 있어서 안정적인 운영이 가능하기도 하고요.

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

1. 캐시 쇄도(Cache Stampede)

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

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

해결안: 지터(Jitter)

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

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

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

2. 캐시 관통(Cache Penetration)

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

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

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

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

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

3. 캐시 시스템 장애

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

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

해결안: 대체 작동(Failover)

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

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

4. 핫(Hotkey) 만료

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

해결안: 분산 락(Distributed Lock)

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

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

정리

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

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

재미있게 읽으셨나요?

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

😍
🤔
website-code-blue

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

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