시행착오 끝에 찾은 구독 설정(BillSetting) 캐싱 문제 해답
Jayon • Jinyoung Park, Backend Enginner
안녕하세요, 채널톡 백엔드 엔지니어 제이온입니다.
지난 2편, ’Distributed Cache 도입기 (2): 워크플로우 캐시 리팩토링’에서는 페리께서 워크플로우 캐싱 시스템에 분산 캐시를 성공적으로 도입하여 캐시 히트율을 90% 이상 끌어올리고, DB 부하를 60~70% 감소시키는 성과를 공유해주셨습니다.
사실 저에게는 페리의 이 성공이 의미가 있었습니다. 저 역시 비슷한 문제로 고민하다가 실패를 경험했기 때문입니다.
구독 설정(BillSetting)이 바로 그 주인공이었습니다.
당시 BillSetting 캐싱 상황은 심각했습니다. 기존 Caffeine 기반 로컬 캐시만으로는 1500 TPS가 넘는 RDB 조회가 지속적으로 발생하고 있었습니다. 또한 다중 인스턴스 환경에서 캐시 히트율도 기대에 미치지 못하는 상황이었습니다. 이 문제를 해결하고자 저는 Redis 글로벌 캐시 방식을 시도했지만, 예상치 못한 병목 현상으로 인해 롤백해야 했습니다. 이후 이 문제를 어떻게 해결할지 고민하게 되었습니다.
이때 페리의 워크플로우 분산 캐시 성공 사례를 보면서 깨달았습니다.
“아, 이 방법이면 BillSetting 캐싱 문제도 해결할 수 있겠구나!”
페리가 보여준 접근법을 BillSetting에 적용해본 결과, 이번에는 성공적인 결과를 얻을 수 있었습니다.
이 글에서는 왜 Caffeine 기반 로컬 캐시 및 Redis 글로벌 캐시 시도가 실패했는지, 그리고 분산 캐시 적용으로 어떻게 문제를 해결할 수 있었는지 그 과정을 공유하고자 합니다.
BillSetting은 각 채널(고객사)의 구독 플랜, 결제 정보, 사용 한도 등을 관리하는 핵심 데이터입니다.
문제는 BillSetting 조회가 채널톡의 매니저가 사용하는 대부분의 API 요청에서 발생한다는 점이었습니다. 매니저가 사용하는 API에는 권한을 확인하는 기능이 있는데, 이 권한 확인 과정에서 BillSetting이 필요했습니다.
그 결과 BillSetting 테이블에는 하루 6000만 건 이상의 조회가 몰리고 있었고, RDB로 향하는 쿼리만 1500 TPS를 넘나들고 있었습니다.
Caffeine은 Java에서 가장 널리 사용되는 고성능 인메모리 캐시 라이브러리입니다. 애플리케이션 내부(로컬)에서 동작하며, 자주 사용되는 데이터를 메모리에 저장해 두었다가 같은 요청이 들어오면 RDB에 가지 않고 메모리에서 바로 반환합니다.
BillSetting 조회 부하를 줄이기 위해 다음과 같이 Caffeine 로컬 캐시를 설정했습니다.
TTL을 5초로 설정한 이유는 다중 인스턴스 환경의 특성 때문이었습니다. 채널톡은 수십 개의 인스턴스가 동시에 실행되고 있고, 각 인스턴스마다 독립적인 로컬 캐시를 가지고 있습니다.
→ 고객이 “Enterprise 플랜”으로 업그레이드
→ 인스턴스 A, B는 여전히 “Growth 플랜” 반환 (구버전 데이터)
→ 인스턴스 C만 최신 “Enterprise 플랜” 반환
이런 데이터 불일치 문제를 최소화하기 위해 TTL을 5초로 짧게 설정했습니다. 결제나 플랜 변경 같은 중요한 작업에서는 캐시를 거치지 않고 직접 DB를 조회하도록 했지만, 사용자에게 보여지는 구독 정보는 가능한 최신이어야 한다고 판단했습니다.
로컬 캐시 도입 후 어느 정도 개선 효과는 있었지만, 근본적인 한계가 명확했습니다.
1. 인스턴스별 캐시 히트율 편차
로드밸런서가 요청을 각 인스턴스에 분산시키다 보니 캐시 히트율에 편차가 발생했습니다. 어떤 인스턴스는 인기 있는 BillSetting의 요청을 많이 받아 캐시 히트율이 높았습니다. 반면 어떤 인스턴스는 다양한 BillSetting의 요청을 받아 캐시 히트율이 낮았습니다.
2. Cold Start 문제
새로운 인스턴스가 배포되거나 재시작될 때마다 캐시가 비어있는 상태에서 시작하므로, 초기에는 모든 요청이 RDB로 향했습니다.
3. TTL로 인한 주기적 캐시 만료
5초마다 캐시가 만료되어 주기적으로 DB 부하가 발생했습니다. 특히 트래픽이 많은 시간대에는 문제가 더 심각했습니다. 여러 인스턴스에서 동시에 캐시가 만료되어 순간적으로 높은 DB 부하가 발생하기도 했습니다.
로컬 캐시의 한계를 해결하기 위해 Redis 글로벌 캐시 방식을 시도해보기로 했습니다. 아이디어는 단순했습니다.
(AS-IS): 각 인스턴스 → 로컬 캐시 → RDB
(TO-BE): 각 인스턴스 → Redis (글로벌 캐시) → RDB
수십 개 인스턴스가 각자 개별적으로 캐시를 관리하는 대신, 하나의 Redis에서 통합 관리하는 방식입니다.
기대하는 장점은 다음과 같았습니다.
모든 인스턴스가 같은 캐시 공유
한 번 캐시되면 모든 인스턴스에서 사용 가능
데이터 일관성 문제 해결
하지만 실제로 적용해보니 예상치 못한 문제가 발생했습니다.
수십 개의 인스턴스에서 처리하던 캐시 조회 부하가 Redis 싱글 노드로 집중되면서, Redis 서버의 CPU 사용률이 70%까지 치솟았습니다.
문제의 핵심은 하나의 Redis로는 모든 요청을 감당할 수 없다는 점이었습니다. 기존에는 각 인스턴스가 자신의 메모리에서 캐시를 처리했기 때문에 부하가 분산되었습니다. 하지만 Redis 글로벌 캐시에서는 수십 개 인스턴스의 모든 캐시 요청이 Redis 한 대에 몰리게 되었습니다.
구체적으로 Redis의 CPU 사용률이 치솟은 원인을 분석해보니, BillSetting 관련 초당 캐시 히트 횟수 자체가 무려 80K에 육박한다는 것이었습니다. 더욱 놀라운 것은 이 수치가 채널톡의 트래픽이 상대적으로 적은 야간 시간대 기준이라는 점이었습니다. 주간 피크 시간대라면 약 1.5배 증가한 120K 정도까지 치솟을 것으로 예상되었습니다.
아무리 캐시 히트율이 높아도, Redis 자체가 병목이 되어버린 상황이었습니다. 80,000 TPS의 요청을 Redis 한 대가 모두 처리하려니 CPU 사용률이 급격히 올라갔고, 이는 곧 Redis의 성능 및 안정성 저하로 이어졌습니다.
물론 Redis 다중화를 통해 이 문제를 해결할 수도 있었습니다. 하지만 당시 상황에서는 여러 현실적 제약이 있었습니다.
인프라 비용: Redis 클러스터 구성에 따른 추가 서버 비용
구현 복잡도: 클러스터 관리, Failover 로직, 데이터 샤딩 등
운영 비용: 24시간 모니터링, 장애 대응 등 운영 부담 증가
CPU 사용률이 70%까지 치솟는 상황에서 사태의 심각성을 느끼고 롤백을 결정했습니다. 이 경험을 통해 중요한 교훈을 얻었습니다. "직관적으로 좋아 보였던 솔루션"과 "현재 환경에서 실용적인 솔루션"은 다를 수 있다는 것이었습니다.
Redis 글로벌 캐시 실패 이후 다른 해결책을 찾지 못하고 있던 중, 페리의 워크플로우 분산 캐시 성공 사례를 보고 영감을 받았습니다.
핵심 아이디어는 다음과 같았습니다.
“각 인스턴스는 로컬 캐시의 빠른 속도를 유지하되, Redis Pub/Sub을 통해 캐시 무효화 메시지만 동기화하자”
[데이터 조회 흐름 - 로컬 캐시 방식과 동일함]
API 요청 → BillSetting 조회 필요
로컬 캐시 확인
캐시 히트: 즉시 반환
캐시 미스: RDB 조회 → 로컬 캐시 저장 → 반환
[데이터 변경 시 동기화 흐름]
BillSetting 업데이트 발생
RDB 업데이트 완료
Redis Pub/Sub으로 cache invalidate 메시지 발행
모든 인스턴스가 메시지 수신
각 인스턴스에서 해당 키 로컬 캐시 삭제
다음 요청 시 최신 데이터로 캐시 재구성
이 방식의 핵심 장점은 부하 분산에 있었습니다.
캐시 조회: 각 인스턴스의 로컬 메모리에서 처리 (Redis 부하 없음)
캐시 동기화: Redis에는 가벼운 Pub/Sub 메시지만 전송
분산 캐시 도입으로 TTL을 5초에서 24시간으로 크게 늘릴 수 있었습니다.
이는 Redis Pub/Sub을 통한 실시간 동기화가 이루어지므로, 긴 TTL을 설정해도 데이터 일관성에 문제가 없었습니다.
하지만 완전히 TTL을 제거하지는 않았습니다. 두 가지 안전 장치 역할을 하기 때문입니다.
1. Redis Pub/Sub 동기화 실패 대비
Redis 자체에 문제가 발생하거나 Pub/Sub 메시지 전달에 장애가 발생하는 드문 상황에서도, 최대 24시간 후에는 반드시 최신 데이터를 보장할 수 있습니다.
2. 수동 DB 조작 대비
운영 중 RDB에 직접 DML을 수행하는 경우가 가끔 있습니다. 물론 캐시 무효화 admin API가 준비되어 있지만, 사람이 놓칠 수 있는 휴먼 에러에 대한 안전장치입니다.
캐시 히트율
RDB 부하
결과는 예상했던 성능 개선 효과를 얻었습니다. 캐시 히트율은 거의 100%에 가까워졌고, RDB로 향하는 쿼리는 1500 TPS에서 100 TPS 이하로 93% 감소했습니다.
이는 두 가지 시너지 효과 때문이었습니다.
1. 긴 TTL: 24시간 TTL로 캐시 만료로 인한 미스가 거의 없어짐
2. 실시간 동기화: 데이터 변경 시 즉시 모든 인스턴스 캐시 무효화
분산 캐시를 도입했지만, 여전히 몇 가지 상황에서는 순간적인 RDB 부하가 발생합니다.
캐시 스탬피드 현상은 캐시된 데이터가 만료되는 순간 동일한 데이터를 갱신하기 위한 DB 조회 요청이 동시에 RDB로 향하면서 발생하는 문제입니다.
우리 시스템에서는 다음과 같은 상황에서 발생합니다.
TTL 만료: 24시간 TTL이 만료되면서 여러 인스턴스에서 동시에 캐시 미스 발생
쓰기 무효화: 쓰기 요청으로 인한 Redis Pub/Sub 무효화 시점에 동일한 BillSetting을 조회하는 요청들이 몰리는 경우
특히 인기 있는 BillSetting의 경우, 만료 순간에 동일한 SQL 조회 쿼리가 여러 번 실행되어 순간적으로 RDB 부하가 증가할 수 있습니다.
따라서 아래 해결 방법을 고려할 수 있습니다.
분산 락: Redis 기반 분산 락으로 캐시 만료 순간에 하나의 요청만 DB 조회
Probabilistic Early Expiration: TTL 만료 전 확률적으로 캐시를 미리 갱신하여 만료 방지
위에서 설명했듯이, 오토스케일링이나 배포로 새로운 인스턴스가 생성되면 해당 인스턴스의 로컬 캐시는 비어 있게 됩니다.
우리는 k8s를 사용하고 있으므로 Readiness Probe를 활용하여 인스턴스가 트래픽을 받기 전 startup 단계에서 BillSetting을 미리 캐시에 로딩하는 해결 방법을 고려할 수 있습니다.
이 두 현상이 복합적으로 작용하면서 평소보다 RDB 조회량이 "삐죽" 올라가는 순간들이 있습니다.
하지만 기존 1500 TPS에서 대부분 100 TPS 이하로 유지되고, 위 예외 상황에서도 최대 300 TPS 정도로 85% 이상 감소한 상황에서, 이 정도 순간적 부하는 시스템 안정성에 큰 위협이 되지 않다고 판단했습니다.
이번 BillSetting 캐싱 개선 과정을 통해 얻은 가장 큰 교훈은 현실적 제약을 고려한 기술 선택의 중요성이었습니다. 처음에는 간단해 보였던 글로벌 캐시보다는 현재 인프라 환경에서 실용적으로 동작하는 분산 캐시가 더 나은 선택이었습니다.
또한 동료의 성공 사례에서 배우는 것의 가치를 다시 한번 느꼈습니다. 페리의 워크플로우 경험이 없었다면 이런 해결책을 찾기 어려웠을 것입니다.
채널톡에서는 이런 기술적 도전과 경험 공유를 통해 함께 성장하는 동료들을 찾고 있습니다. 복잡한 성능 문제를 데이터 기반으로 분석하고, 현실적인 해결책을 만들어가는 과정에 동참하고 싶으시다면 언제든 채널톡 엔지니어링 팀에 합류하세요!
We Make a Future Classic Product