Redis Pub/Sub을 이용한 Distributed Cache 도입기 2부

채널톡 워크플로우 캐싱, 이렇게 빨라졌습니다

Perry • BE API

  • 엔지니어링

안녕하세요, 채널톡 백엔드 엔지니어 페리입니다.

지난 1편, 'Redis Pub/Sub을 이용한 Distributed Cache 도입기 1부' 에서는 채널톡 서비스가 성장하면서 마주했던 분산 환경에서의 캐시 불일치 문제에 대해 이야기했습니다. 여러 서버 인스턴스가 각자의 로컬 캐시를 사용하면서 발생하는 데이터 부정합 문제와 그로 인한 어려움들을 공유드렸죠.

이 문제를 해결하기 위해 저희는 로컬 캐시의 빠른 속도는 유지하면서도 데이터 일관성을 확보할 수 있는 새로운 방안을 모색했습니다. 여러 대안을 검토한 끝에, 저희는 Redis의 Pub/Sub 기능을 활용하여 캐시 무효화 메시지를 전파하는 방식에 주목했고, 이를 기반으로 DistributedCachedDao 라는 새로운 캐시 관리 솔루션을 설계했습니다. DistributedCachedDao는 데이터 변경 시 관련 키(Key) 정보를 Redis 채널에 발행(Publish)하고, 이를 구독(Subscribe)하는 다른 모든 인스턴스들이 해당 키를 자신의 로컬 캐시에서 제거(무효화)하도록 하여 결과적 일관성(Eventual Consistency)을 달성하는 아이디어였습니다.

이번 2편에서는 이렇게 설계된 DistributedCachedDao실제 채널톡의 핵심 기능 중 하나인 '워크플로우' 서비스에 적용하며 겪었던 구체적인 리팩토링 과정과 그 결과를 공유하고자 합니다. 이론적인 설계를 실제 코드에 녹여내면서 마주했던 현실적인 문제들을 어떻게 해결하고, 최종적으로 얼마나 놀라운 성능 개선을 이끌어낼 수 있었는지 그 생생한 여정을 함께 따라가 보시죠.

도전 과제: 워크플로우 캐싱의 현실

1편에서 설계한 DistributedCachedDao가 실제 서비스에서 어떤 문제를 해결해 줄 수 있을지 알아보기 위해, 저희는 가장 먼저 '워크플로우' 기능의 캐싱 시스템을 개선 대상으로 삼았습니다. 워크플로우는 채널톡의 핵심 기능 중 하나로, 성능 개선의 파급 효과가 클 것으로 기대되었기 때문입니다. 하지만 그전에, 워크플로우 기능과 기존 캐싱 방식이 어떤 상태였는지 먼저 살펴볼 필요가 있습니다.

워크플로우 기능 간단히 알아보기

채널톡의 워크플로우는 반복적인 고객 응대나 내부 업무 처리 과정을 자동화해주는 강력한 기능입니다. 예를 들어, "특정 키워드가 포함된 고객 문의가 들어오면 자동으로 담당 팀에 알림을 보내고, 고객에게는 예상 답변 시간을 안내하는 메시지를 보낸다" 와 같은 시나리오를 미리 설정해둘 수 있죠.

이 워크플로우를 이해하기 위해 두 가지 핵심 데이터 모델을 알아두면 좋습니다.

  1. Workflow: 워크플로우의 전체적인 정의와 상태를 담는 객체입니다. 여기에는 워크플로우의 이름, 어떤 조건(Trigger)에서 실행될지, 현재 활성화 상태인지(active, draft 등), 그리고 현재 사용해야 할 실행 내용의 버전 ID(revisionId) 등이 포함됩니다. 관리자가 워크플로우를 수정하고 '활성화(Activate)'하면, 이 Workflow 객체의 revisionId가 새로운 버전으로 업데이트됩니다.

  2. WorkflowRevision: 활성화된 워크플로우의 구체적인 실행 내용을 담고 있는, 일종의 스냅샷(Snapshot)입니다. 여기에는 워크플로우가 어떤 단계(Section)들로 구성되어 있고, 각 단계에서 어떤 작업(Action)들을 수행할지(예: 메시지 보내기, 상담 상태 변경 등) 상세한 로직이 정의되어 있습니다. 중요한 점은 WorkflowRevision은 한번 생성되면 변경되지 않는 불변(Immutable) 데이터라는 것입니다. 워크플로우가 수정될 때마다 새로운 WorkflowRevision이 생성되고, Workflow 객체가 이 새로운 리비전을 가리키도록 revisionId만 변경되는 구조입니다.

기존 캐싱 방식(AS-IS)의 문제점 파헤치기

워크플로우 기능은 고객 문의 처리 자동화 등에 널리 쓰이기 때문에, 빠르고 안정적인 동작이 매우 중요했습니다. 이를 위해 저희는 당연히 캐싱을 적용하고 있었죠. 기존에는CachedActiveWorkflowWithRevisionDao라는 이름의 캐시 DAO를 사용했습니다. 이름에서 짐작할 수 있듯이, 이 DAO는 활성 상태(active)인 Workflow 정보와 그에 해당하는 WorkflowRevision 정보를 하나로 묶어서 통째로 로컬 캐시에 저장하는 방식이었습니다.

위 그림처럼, 하나의 캐시 DAO가 Workflow 정보와 Revision 정보를 함께 묶어 캐싱하고 있었습니다.

언뜻 보기에는 필요한 정보를 한 번에 가져올 수 있어 효율적으로 보일 수 있습니다. 하지만 이 구조에는 치명적인 문제가 숨어 있었습니다. 바로 캐시의 수명(TTL, Time-To-Live) 설정 때문이었습니다.

워크플로우는 관리자가 언제든지 수정하고 다시 활성화할 수 있습니다. 워크플로우가 수정되어 새로운 WorkflowRevision이 생성되고 WorkflowrevisionId가 업데이트되면, 이 변경 사항이 사용자 경험에 최대한 빨리 반영되어야 합니다. 하지만 캐시에 이전 버전의 WorkflowRevision 정보가 남아있다면, 사용자는 수정 전의 워크플로우를 경험하게 될 것입니다.

이 문제를 해결하기 위해, 기존 CachedActiveWorkflowWithRevisionDao캐시 수명을 단 1분으로 매우 짧게 설정하는, 일종의 '울며 겨자 먹기' 식의 전략을 사용했습니다. 캐시를 자주 만료시켜서, 변경 사항이 생기더라도 최대 1분 안에 새로운 정보가 로드되도록 유도한 것이죠.

하지만 이 방식은 다음과 같은 심각한 문제들을 야기했습니다.

  1. 형편없는 캐시 효율: 캐시 수명이 1분이니, 캐시에 저장된 데이터는 금방 만료되어 버렸습니다. 워크플로우 데이터는 한번 로드되면 자주 조회되는 읽기 중심(Read-Heavy) 패턴을 보임에도 불구하고 (실제 Read 요청은 Write 요청의 약 700배, 일 평균 약 80만 건의 Read 발생), 캐시가 제 역할을 거의 하지 못했습니다. 실제 캐시 히트율(Hit Rate)은 평균 31.6% 에 불과했습니다. 캐시가 존재하지만, 실제로는 요청의 약 70%가 캐시를 지나쳐 DB까지 도달하고 있었던 셈입니다.

시간이 지남에 따라 낮은 수준(평균 31.6%)에서 유지되는 기존 워크플로우 캐시의 히트율을 보여주는 그래프

  1. 과도한 데이터베이스 부하: 낮은 캐시 히트율은 곧바로 데이터베이스 부하 증가로 이어졌습니다. 특히 워크플로우 정보를 조회하는 특정 DB 쿼리는 하루에도 수백만 건씩 실행되며 시스템에 큰 부담을 주었습니다. 캐싱의 주된 목적 중 하나가 DB 부하 감소임을 생각하면, 기존 캐싱 전략은 완전히 실패하고 있었던 것입니다.

개선 전 높은 수준을 유지하는 workflows 테이블 및 workflow_revisions 테이블 관련 DB 쿼리 수를 보여주는 그래프

  1. 느린 변경 반영 속도: 캐시 수명을 1분으로 설정했음에도 불구하고, 이는 여전히 사용자가 워크플로우 변경 사항을 확인하기까지 최대 1분의 지연이 발생할 수 있음을 의미했습니다. 실시간 반영이 중요한 시나리오에서는 답답함을 유발할 수 있는 시간이었습니다.

  2. 비효율적인 데이터 관리: WorkflowWorkflowRevision을 함께 캐싱하면서, 실제로는 WorkflowrevisionId 필드만 변경되었을 뿐인데도 전체 WorkflowRevision 데이터까지 캐시에서 제거되고 다시 로드되는 비효율이 발생했습니다. 불변 데이터인 WorkflowRevision은 훨씬 더 오래 캐싱될 수 있었음에도 불구하고, Workflow의 변경 주기에 묶여 불필요하게 자주 캐시에서 사라졌습니다.

결국, 워크플로우 캐싱 시스템은 낮은 성능, 높은 데이터베이스 부하, 느린 변경 반영이라는 총체적인 난국에 빠져 있었습니다. 캐시가 오히려 시스템의 발목을 잡고 있는 상황이었죠. 성능 저하와 높은 DB 부하를 더 이상 외면할 수 없었습니다. 워크플로우 캐싱 전략의 근본적인 변화가 절실히 필요했습니다.

리팩토링 설계: 데이터 특성에 맞는 캐시 전략

기존 워크플로우 캐싱 방식의 문제점을 명확히 파악한 저희는 다음과 같은 개선 목표를 설정했습니다.

  1. 캐시 히트율 극대화: 캐시가 제 역할을 다하여 불필요한 DB 조회를 최소화한다.

  2. DB 부하 감소: 캐시 효율성을 높여 DB 의존도를 낮추고 시스템 안정성을 확보한다.

  3. 변경 반영 속도 개선: 워크플로우 수정 사항이 지연 없이 거의 실시간으로 반영되도록 한다.

이 목표들을 달성하기 위한 핵심 전략은 바로 데이터의 특성에 맞게 캐싱 방식을 분리하는 것이었습니다. 기존 방식의 가장 큰 문제가 성격이 다른 두 데이터(WorkflowWorkflowRevision)를 하나의 캐시에 묶어 관리하면서 발생했기 때문입니다.

저희는 두 데이터의 변경 빈도와 특성을 다시 한번 분석했습니다.

  • WorkflowRevision (실행 내용): 워크플로우가 활성화될 때 생성되는 스냅샷으로, 일단 생성되면 내용이 절대 변하지 않는 불변(Immutable) 데이터입니다. 즉, 한번 캐시에 저장되면 매우 오랫동안 유효하게 사용할 수 있습니다.

  • Workflow (활성 정보): 워크플로우의 상태(active, draft 등)나 현재 사용 중인 revisionId 와 같은 메타 정보가 포함됩니다. 워크플로우가 수정되고 재활성화될 때마다 revisionId 등이 변경될 수 있습니다. 이 데이터는 변경이 발생하면 다른 모든 서버 인스턴스에도 빠르게 전파되어야 합니다.

이러한 데이터 특성 차이에 착안하여, 저희는 다음과 같이 캐싱 전략을 완전히 분리하기로 결정했습니다.

  1. WorkflowRevision 캐싱: 내용이 변하지 않는 불변 데이터이므로, 기존의 일반 로컬 캐시(CachedDao) 를 사용하되 캐시 수명을 1일로 대폭 늘립니다. 이렇게 하면 한번 캐싱된 Revision 정보는 거의 항상 로컬 메모리에서 즉시 찾을 수 있어(높은 히트율 기대), DB 조회 없이 매우 빠르게 접근할 수 있습니다.

  2. Workflow 캐싱: revisionId 등 변경될 수 있는 정보를 포함하고 있으므로, 1편에서 설계한 DistributedCachedDao를 적용합니다. 캐시 수명은 Revision과 마찬가지로 1일로 길게 설정하되, 데이터 변경 시 Redis Pub/Sub을 통해 모든 인스턴스에 실시간으로 무효화 메시지를 전파합니다. 이를 통해 긴 캐시 수명을 유지하면서도 데이터 일관성을 효과적으로 확보할 수 있습니다.

새로운 워크플로우 캐싱 아키텍처 (TO-BE)

이 새로운 전략에 따라 워크플로우 캐싱 관련 DAO 구조도 다음과 같이 변경되었습니다.

  • CachedWorkflowRevisionDao (신규 도입): revisionId를 키(Key)로 사용하여 WorkflowRevision 객체를 캐싱하는 일반 로컬 캐시 DAO입니다. 매우 긴 캐시 수명(1일)을 가집니다.

  • DistributedCachedActiveWorkflowDao (신규 도입): channelIdtriggerType을 조합한 키로, 현재 활성 상태(active)인 Workflow 객체 목록을 캐싱하는 분산 캐시 DAO입니다. 1편에서 만든 DistributedCachedDao를 상속받아 구현되었으며, 긴 캐시 수명(1일)과 실시간 무효화 기능을 제공합니다.

위 그림은 두 개의 분리된 캐시 DAO (DistributedCachedActiveWorkflowDaoCachedWorkflowRevisionDao)를 보여줍니다. 워크플로우 정보는 분산 캐시 DAO를 통해, 리비전 정보는 로컬 캐시 DAO를 통해 조회되며, Redis Pub/Sub이 분산 캐시의 동기화를 담당하는 구조입니다.

이 새로운 아키텍처 하에서 워크플로우 데이터를 조회하는 흐름은 다음과 같습니다.

  1. 특정 채널과 트리거 타입에 해당하는 활성 Workflow 목록을 DistributedCachedActiveWorkflowDao에서 조회합니다. (Pub/Sub 기반 동기화 덕분에 늘어난 TTL로 인해 높은 히트율 기대)

  2. 조회된 각 Workflow 객체에서 필요한 revisionId를 확인합니다.

  3. 해당 revisionId를 사용하여 CachedWorkflowRevisionDao에서 WorkflowRevision 데이터를 조회합니다. (불변 데이터 + 긴 캐시 수명 덕분에 매우 높은 로컬 캐시 히트율 기대)

  4. 최종적으로 애플리케이션 로직에서는 이렇게 얻은 Workflow 정보와 WorkflowRevision 정보를 조합하여 사용합니다.

이렇게 데이터 특성에 맞게 캐싱 전략을 분리하고 새로운 아키텍처를 설계함으로써, 저희는 기존 워크플로우 캐싱 시스템의 문제점들을 해결하고 설정했던 개선 목표들을 달성할 수 있을 것으로 기대했습니다.

결과 분석: 숫자가 보여주는 변화

이론적인 설계와 개선 목표를 바탕으로, 저희는 워크플로우 캐싱 시스템 리팩토링을 진행했습니다. 데이터 특성에 맞게 캐싱 전략을 분리하고, 새로 구현한 DistributedCachedDao와 기존 CachedDao를 적재적소에 배치했죠. 과연 이 변화는 실제로 어떤 결과를 가져왔을까요? 숫자가 모든 것을 말해줍니다.

DistributedCachedActiveWorkflowDao (Workflow 캐시) 개선 효과

먼저, 활성 Workflow 정보를 캐싱하는 DistributedCachedActiveWorkflowDao의 변화입니다. 이 DAO는 변경 가능한 데이터를 다루면서도 실시간 일관성을 확보하기 위해 DistributedCachedDao를 적용한 핵심 개선 대상이었습니다.

  • 캐시 히트율: 31.6% → 93.4%

    가장 극적인 변화는 캐시 히트율이었습니다. 기존 31.6%에 불과했던 히트율이 무려 93.4%까지 치솟았습니다! 이는 캐시 수명을 1일로 대폭 늘리면서도, Redis Pub/Sub을 통한 실시간 무효화 덕분에 데이터 일관성을 해치지 않은 결과입니다. 이제 대부분의 워크플로우 조회 요청은 로컬 캐시에서 매우 빠르게 처리됩니다. 캐시가 드디어 제 역할을 하기 시작한 것입니다!

개선 전(31.6%)과 비교하여 개선 후(93.4%) 현저하게 높아진 워크플로우 캐시 히트율을 보여주는 그래프

  • DB 쿼리 수 (workflows 테이블): 75% 감소 (8.16M → 2.02M)

    캐시 히트율의 극적인 상승은 곧바로 DB 부하 감소로 이어졌습니다. workflows 테이블에 대한 주간 DB 쿼리 수가 기존 816만 건에서 202만 건으로 약 75%나 감소했습니다. 캐시가 DB로 가는 요청 대부분을 막아주면서, DB 서버의 부담이 크게 줄어들었습니다. 이는 시스템 전체의 안정성 향상에 직접적으로 기여합니다.

개선 전(8.16M)과 비교하여 개선 후(2.02M) 크게 감소한 workflows 테이블 관련 DB 쿼리 수를 보여주는 그래프

CachedWorkflowRevisionDao (Revision 캐시) 개선 효과

다음은 불변 데이터인 WorkflowRevision을 캐싱하는 CachedWorkflowRevisionDao의 결과입니다. 이 DAO는 내용이 변하지 않는 데이터의 특성을 활용하여 로컬 캐시의 효율을 극대화하는 것을 목표로 했습니다.

  • 캐시 히트율: 28.5% → 97.3%

    WorkflowRevision 캐시의 히트율 역시 놀랍게 상승했습니다. 기존 28.5%에서 97.3% 라는 거의 완벽에 가까운 수치를 기록했습니다. 캐시 수명을 1일로 늘리고 캐시 크기를 적절히 확보한 덕분에, 한번 로드된 Revision 데이터는 거의 항상 로컬 캐시에서 찾을 수 있게 된 것입니다. 이는 불변 데이터 캐싱 전략이 매우 효과적이었음을 보여줍니다.

개선 전(28.5%)과 비교하여 개선 후(97.3%) 매우 높아진 리비전 캐시 히트율을 보여주는 그래프

DB 쿼리 수 (workflow_revisions 테이블): 64% 감소 (5.04M → 1.82M)

  • Revision 캐시의 효율 증가는 workflow_revisions 테이블에 대한 DB 쿼리 수 감소로 나타났습니다. 주간 쿼리 수가 기존 504만 건에서 182만 건으로 약 64% 감소했습니다. Workflow 캐시와 Revision 캐시 모두에서 DB 부하가 크게 줄어든 것입니다. (참고: 일부 레거시 코드에서 여전히 이전 DAO를 사용하는 부분이 남아있어, 해당 부분까지 리팩토링하면 추가적인 감소가 예상됩니다.)

개선 전(5.04M)과 비교하여 개선 후(1.82M) 크게 감소한 workflow_revisions 테이블 관련 DB 쿼리 수를 보여주는 그래프

종합 평가: 목표 달성, 그리고 그 이상

이번 리팩토링을 통해 저희는 당초 목표했던 바를 성공적으로 달성했습니다.

  • 캐시 효율성 극대화: 두 종류의 캐시 모두 90% 이상의 높은 히트율을 달성하며 캐시 본연의 역할을 충실히 수행하게 되었습니다.

  • DB 의존도 대폭 감소: 관련 테이블들의 DB 쿼리 수가 60~70% 이상 감소하여 시스템 부하를 크게 줄이고 안정성을 높였습니다.

  • 변경 사항 즉시 반영: DistributedCachedDao 도입으로 워크플로우 수정 시 발생했던 최대 1분의 반영 지연 문제가 해소되어, 변경 사항이 거의 실시간으로 모든 인스턴스에 적용됩니다.

결과적으로 DistributedCachedDao 도입과 데이터 특성에 맞는 캐싱 전략 분리는 워크플로우 기능의 성능과 안정성을 크게 향상시키는 성공적인 결정이었습니다.

마치며: 또 다른 문제 해결을 향해

이번 2편에서는 1편에서 설계한 DistributedCachedDao를 채널톡의 실제 서비스인 워크플로우 기능에 적용하는 여정을 상세히 살펴보았습니다. 기존 캐싱 방식의 문제점을 데이터 기반으로 진단하고, 데이터의 특성(변경 빈도)에 맞게 캐싱 전략을 분리하여 적용한 결과, 캐시 히트율을 90% 이상으로 끌어올리고 DB 부하를 60~70% 이상 대폭 감소시키는 등 괄목할 만한 성능 개선을 이룰 수 있었습니다. 더불어 워크플로우 변경 사항이 거의 실시간으로 반영되면서 사용자 경험까지 향상시킬 수 있었죠.

이번 리팩토링 여정은 몇 가지 중요한 교훈을 다시 한번 되새기게 해주었습니다. 첫째, 문제 해결의 시작은 현상 너머의 근본 원인을 정확히 파악하는 것에서 출발한다는 점입니다. 둘째, 데이터의 특성을 깊이 이해하는 것이 최적의 기술 전략을 선택하는 데 얼마나 중요한지 깨달았습니다. 마지막으로, 상황에 맞는 적절한 기술(여기서는 DistributedCachedDaoCachedDao)을 선택하고 조합하는 것이 효과적인 문제 해결의 핵심임을 확인했습니다.

DistributedCachedDao의 성공적인 첫 적용 사례를 만들었지만, 저희의 도전은 여기서 멈추지 않습니다. 최근 저희 팀에서는 이 DistributedCachedDao를 또 다른 주요 기능인 '빌링 설정(Bill Setting)' 캐싱 시스템에도 적용하는 리팩토링을 진행했습니다. 저희 팀의 제이온이 이 과정에서 얻게 된 새로운 경험과 결과 역시 다음 기술 블로그를 통해 공유해 드릴 예정이니 많은 기대 부탁드립니다. 또한, 앞으로 DistributedCachedDao를 더 많은 서비스에 확대 적용하고, 관련 모니터링 시스템을 고도화하는 과제도 남아있습니다.


채널톡에서는 이처럼 서비스가 성장하며 마주하는 복잡하고 도전적인 기술 문제들을 깊이 분석하고, 데이터에 기반한 최적의 해결책을 찾아나가는 과정을 중요하게 생각합니다. 기술적 한계를 넘어서기 위한 고민과 동료들과의 건강한 토론 문화를 바탕으로 더 나은 서비스를 만들기 위해 끊임없이 노력하고 있습니다. 저희와 함께 이러한 문제들을 해결하며 성장하고 싶으시다면, 채널톡의 문은 언제나 열려있습니다. 지금 바로 채널톡 엔지니어링 팀에 합류하세요!

We Make a Future Classic Product