해당 글에서는 이전 글에서 정리한 이탈률 생명주기를 바탕으로, 각 이벤트가 어떤 방식으로 이탈률 계산에 반영되는지, 그리고 이탈률 임계치에 도달한 이유를 기록하기 위해 어떤 파이프라인을 구축했는지를 설명하고자 한다. 이번 글에서는 그중에서도 실제 구축 과정에서 팀원들과 함께 고민했던 지점들, 그리고 그 문제를 어떤 방식으로 해결해 나갔는지를 중심으로 기록하려 한다.
1. Event - 상담 데이터 이탈률 반영
고민들 - 상담 이탈률 구축까지의 스토리
고민 1) 어느 시점에 상담 데이터를 반영할 것 인가?
상담 데이터는 처음에는 Default 유형과 마찬가지로, 7일 단위의 배치 시스템을 통해 상담 내용을 분석한 뒤 그 결과를 이탈률에 반영하는 방식으로 결정했었다. 즉, 일정 기간 동안 누적된 상담 데이터를 모아 분석하고, 이후 해당 분석 결과를 기반으로 이탈률을 계산하는 구조였다.
하지만 이 방식에는 분명한 한계가 있었다. 예를 들어 사용자가 상담 과정에서 다음과 같은 내용을 남겼다고 가정해 보자.
- “인터넷이 너무 느려서 다른 서비스를 이용하고 싶어요.”
- “3일 전에 제 개인정보가 유출된 것 같은데, 정확한 사실 확인과 조치를 부탁드립니다.”
이러한 상담은 사용자가 서비스에 대해 강한 불만을 가지고 있거나, 이미 이탈을 고려하고 있음을 간접적으로 보여주는 신호라고 볼 수 있다. 그런데 이런 신호를 최대 7일 뒤에나 분석하고 반영한다면, 관리자가 해당 사용자의 상태를 인지했을 때는 이미 서비스 해지가 발생한 이후일 수도 있다.
이탈 가능성이 드러나는 상담은 배치로 늦게 반영하기보다, 상담이 기록되는 시점에 최대한 빠르게 분석하고 즉시 이탈률에 반영해야 의미가 있다고 판단했다. 그래야 관리자가 실시간에 가깝게 위험 사용자를 모니터링할 수 있고, 필요한 대응도 더 빠르게 할 수 있기 때문이다.
결국 상담 데이터는 7일 단위 배치 반영이 아니라, 상담이 생성되거나 분석 결과가 확정되는 시점에 이벤트성으로 이탈률에 반영하는 방향으로 설계를 변경했다.
고민 2) 어떻게 상담 데이터를 반영할 수 있는가?
이 단계에서 가장 많은 시간을 쓴 고민은, 분석된 상담 데이터를 어떤 방식으로 Admin Server에 전달할 것인가였다.
현재 상담 분석은 Python 기반의 별도 서비스인 Intelligence Server에서 수행하고 있다. 이 서버는 상담 데이터에 대해 부정/긍정 판단을 수행하고, 비즈니스 키워드를 추출하고, 최종적으로 상담 분석 결과를 생성한다. 문제는, 이렇게 분석이 끝난 결과를 어떤 방식으로 Admin Server에 전달해 이탈률 계산 파이프라인에 연결할 것인가였다.
후보는 크게 세 가지였다.
- MSK(Kafka)
- HTTP
- CDC
먼저 MSK(Kafka)는 초기에 후보로 올랐지만 비교적 빠르게 제외했다. 로그 데이터처럼 대량으로 빠르게 유입되는 이벤트라면 Kafka가 적합하지만, 상담 데이터는 그 정도의 대용량 트래픽이 발생하지 않는다. 상담은 비교적 낮은 빈도로 생성되고, 분석 결과 역시 건별로 의미가 크기 때문에 Kafka 기반의 스트리밍 구조를 도입하는 것은 현재 요구사항 대비 과하다고 판단했다.
즉, 처리량에 비해 운영 복잡도와 리소스 비용이 더 크다고 보았다.
그다음으로 검토한 방식은 HTTP였다.
이 방식은 상담이 등록되면 먼저 원본 데이터를 저장하고, 이후 상담 전문 또는 상담 ID를 Intelligence Server로 전달해 분석을 요청한 뒤, 분석 결과를 다시 Admin Server로 반영하는 흐름을 생각할 수 있었다. 하지만 이 방식은 곧바로 채택하지는 않았다.
겉보기에는 단순해 보였지만, 실제로는 “누가 분석 요청의 책임을 가지는가”, “분석 완료 시점을 어떻게 보장할 것인가”, “실패 시 재시도나 중복 반영은 어떻게 제어할 것인가” 같은 문제가 함께 따라왔기 때문이다.
특히 Admin Server가 직접 분석 흐름을 오케스트레이션하게 되면, 분석 서비스와의 결합도가 높아지고 시스템 간 책임 경계가 애매해질 수 있다는 점이 부담이었다.
고민 3) 최종적으로 왜 CDC 방식을 선택하였는가?
여러 후보를 비교한 끝에, 상담 데이터 반영 방식은 최종적으로 **CDC(Change Data Capture)** 기반으로 가져가기로 결정했다.
가장 큰 이유는 서비스 간 책임을 자연스럽게 분리할 수 있었기 때문이다.
HTTP 요청 기반으로 설계를 가져가면, 상담이 등록되는 시점에 어느 서비스가 분석 요청을 시작하고, 어느 서비스가 그 완료를 기다리며, 실패 시 재시도까지 어디서 책임질 것인지가 애매해질 수 있다.
특히 Admin Server가 Intelligence Server의 분석 흐름까지 직접 의식하게 되면, 본래 이탈률 계산과 모니터링을 담당해야 하는 서비스가 분석 파이프라인의 오케스트레이션까지 떠안게 되는 문제가 생긴다.
반면 CDC 방식은 흐름이 훨씬 자연스럽다.
- 상담 원본 데이터는 기존처럼 서비스 DB에 먼저 저장된다.
Intelligence Server는 DB 변경을 감지해 필요한 상담 데이터를 가져간다.- 자체적으로 부정/긍정 분석, 비즈니스 키워드 추출 및 매핑을 수행한다.
- 분석이 완료된 뒤, 그 결과만
Admin Server에 전달한다.
즉, 상담 데이터의 생성 책임은 원본 서비스, 상담 데이터의 분석 책임은 Intelligence Server, 분석 결과를 바탕으로 한 이탈률 반영 책임은 Admin Server가 가지는 구조로 분리된다.
이 구조는 우리가 원했던 책임 경계와도 잘 맞았다.
또 하나 중요한 이유는 실시간성 확보와 결합도 최소화를 동시에 만족할 수 있었다는 점이다.
우리는 상담이 기록된 이후 가능한 한 빠르게 이탈률을 반영하고 싶었지만, 그렇다고 해서 Admin Server가 상담 생성 트랜잭션이나 분석 요청 흐름에 직접 결합되는 구조는 피하고 싶었다.
CDC는 데이터 변경을 기반으로 분석을 시작하기 때문에, 원본 서비스의 쓰기 흐름을 막지 않으면서도 비교적 빠르게 후속 분석 파이프라인을 시작할 수 있었다.
운영 관점에서도 장점이 있었다.
- 재처리와 복구가 상대적으로 용이하다.
HTTP요청 기반에서는 특정 시점의 요청이 누락되거나 실패했을 때 이를 다시 복구하려면 별도의 재전송 로직이나 보상 로직이 필요하다. 반면CDC는 원본 데이터 변경 이력을 기준으로 다시 따라갈 수 있기 때문에, 장애 상황에서 재처리 전략을 세우기가 훨씬 수월하다. - 분석 대상의 기준점이 명확하다.
Admin Server입장에서는 “분석해 달라”는 요청을 받는 것이 아니라, “분석이 완료된 결과”를 전달받게 된다. 즉, 이탈률 반영 시점은 상담 생성 시점이 아니라 분석 결과가 확정된 시점으로 명확하게 정리된다. 이 덕분에Admin Server는 본연의 책임인 이탈률 계산과 저장에 더 집중할 수 있었다.
물론 CDC 방식도 완전히 비용이 없는 선택은 아니었다. 별도의 변경 감지 파이프라인을 운영해야 하고, 분석 완료 이후 결과 전달 경로도 추가로 설계해야 했다. 하지만 우리는 이 복잡도가 장기적으로 더 나은 구조를 만든다고 판단했다. 단순히 “빠르게 연결하는 방법”보다는, 각 서비스가 자신의 책임에 집중할 수 있고 이후 확장이나 재처리에도 유리한 구조가 더 중요하다고 보았다.
결론적으로 상담 데이터는 아래와 같은 플로우로 진행된다.

2. RealTime - 사용자 로그 데이터 이탈률 반영
고민들 - 실시간 로그 데이터 반영까지의 스토리
고민 1) Customer Server로부터 로그를 받을때 MSK(Kafka)를 사용할 것 인가?
상담 데이터와 별개로, 사용자 행동 로그도 이탈률을 빠르게 반영할 수 있는 중요한 신호라고 봤다.
특히 우리가 주목한 로그는 요금제 탐색, 요금제 비교, 요금제 변경 시도, 위약금 조회처럼 사용자의 이탈 가능성을 비교적 직접적으로 보여주는 행동들이었다. 상담 데이터가 사용자의 불만을 텍스트로 드러내는 신호라면, 행동 로그는 사용자의 의도를 실제 행동으로 보여주는 신호에 가깝다.
문제는 이 로그를 어떤 방식으로 Admin Server에 전달할 것이냐였다. 가장 먼저 떠오른 선택지는 기존에도 사용 중이던 MSK(Kafka)였다. Kafka는 대량 이벤트를 비동기적으로 안정적으로 처리할 수 있어 충분히 매력적인 후보였다.
하지만 다시 생각해 보니, 정말 필요한 것은 모든 사용자 로그가 아니라, 이탈 가능성을 보여주는 일부 로그만 빠르게 전달하는 구조였다. 이 지점에서 팀은 “이 정도 로그량에 정말 Kafka가 필요할까?”를 먼저 검토했다.
대상 로그는 모든 클릭이나 페이지 진입처럼 지속적으로 쌓이는 이벤트가 아니라, 특정 상황에서만 발생하는 고의도 행동이었다. DAU 3만 명 수준을 가정하더라도 모든 사용자가 동시에 요금제를 비교하거나 위약금을 조회하는 것은 아니었고, 짧은 시간 안에 대량으로 반복되는 성격도 아니었다.
그래서 우리는 이 정도 규모라면 HTTP 기반으로도 충분히 안정적으로 처리할 수 있다고 판단했다.
Customer Server에서 이탈률과 관련된 로그만 필터링해 Admin Server로 직접 전달하는 방식이 더 단순하고 목적에도 잘 맞았다.
물론 Kafka를 도입하면 버퍼링, 재처리, 소비자 확장 같은 장점이 있다. 하지만 이번 요구사항에서는 토픽 관리, 컨슈머 운영, 장애 추적 포인트 증가 같은 복잡도가 더 크게 느껴졌다. 결과적으로 Kafka는 기술적으로는 더 확장성 있는 선택이지만, 현재 문제를 해결하기에는 다소 무거운 구조였다. 전체 로그 저장과 장기 분석에는 Kafka가 여전히 유효하지만, 이탈률 반영용 실시간 로그는 적은 양의 고의도 이벤트만 다루기 때문에 HTTP로도 충분하다고 판단했다.
고민 2) 로그를 왜 Customer Server에서 받아서 적재하지 않는가?
로그 데이터를 실시간으로 이탈률에 반영하기로 결정한 이후, 다음으로 고민한 것은 어느 서비스가 이탈 관련 로그를 선별할 것인가였다.
처음에는 두 가지 방향을 생각할 수 있었다.
Customer Server는 발생한 로그를 그대로 전달하고,Admin Server가 그중 이탈률 계산에 필요한 로그만 다시 필터링하는 방식Customer Server에서부터 이탈률 반영 대상 로그만 선별해서 Admin Server로 전달하는 방식
Admin Server의 본래 역할은 로그 수집 서비스가 아니라, 전달받은 신호를 기반으로 이탈률을 계산하고, 그 이유를 기록하고, 관리자 화면에서 이를 조회할 수 있게 만드는 것이다. 그런데 모든 로그를 그대로 받아버리면 Admin Server는 결국 “어떤 로그가 의미 있는가”를 계속 판단하는 책임을 가지고 시간이 지날수록 행동 로그 해석 규칙이 이탈률 계산 로직과 섞이게 된다.
우리가 다루는 사용자 로그는 Customer Server가 가장 먼저 의미를 해석할 수 있는 데이터다. 예를 들어 사용자가 요금제를 비교하거나 위약금을 조회하는 행동은 어떤 API에서 발생했는지, 단순 조회인지 실제 이탈 가능성과 연결되는 행동인지를 Customer Server가 가장 잘 알고 있다.
반면 Admin Server는 이러한 사용자 인터랙션의 세부 맥락까지 모두 알 필요는 없다. Admin Server는 이미 가공된 “이탈 관련 행동 신호”를 전달받고, 이를 기반으로 feature 계산, 점수 산정, 위험 사유 기록, snapshot 저장에 집중하는 책임만을 가지면 된다.
즉, 우리는 로그 필터링을 단순한 성능 최적화가 아니라 서비스 책임 분리의 문제로 보았다.
이탈률 반영 대상 로그를 Customer Server에서 먼저 필터링하면 아래와 같은 장점이 있었다.
- Admin Server가 불필요한 원본 로그 맥락까지 알지 않아도 된다.
- 전체 로그를 모두 전달하지 않아도 되므로 전송량과 내부 호출 비용을 줄일 수 있다.
- 로그 해석 규칙을 사용자 행동에 가장 가까운 Customer Server에 둘 수 있다.
- Admin Server는 이탈률 계산과 저장이라는 책임에 더 집중할 수 있다.
물론 어떤 로그를 이탈 관련 이벤트로 볼 것인지에 대한 규칙이 Customer Server에 들어가기 때문에 관리 포인트가 늘어날 수 있다는 점은 단점이었다. 하지만 현재 이탈률에 반영하기로 한 로그 종류가 명확했고, 전체 로그를 모두 넘긴 뒤 Admin Server에서 다시 해석하는 구조보다 훨씬 단순하다고 판단했다.

3. Default - 사용자 기본 정보 주기적 배치 반영
고민들 - 사용자 기본 정보 배치 반영까지의 스토리
고민 1) 어떤 값들을 갱신해야 하는가?
이벤트성 데이터인 상담 내용이나 실시간성 사용자 행동 로그와 달리, 사용자 기본 정보는 실시간으로 크게 변하지 않는다. 하지만 그렇다고 해서 이탈률 계산에서 중요하지 않은 건 아니다 오히려 계약 상태, 이용 기간, 사용량 대비 요금 수준처럼 사용자의 현재 상태를 보여주는 정보는 이탈률을 해석하는 기본 축에 가까웠다.
문제는 여기서 어떤 값을 이탈률 계산에 포함할 것인가였다. 기본 정보라고 해서 회원 테이블의 모든 컬럼이나 구독 관련 데이터를 전부 가져오는 것은 오히려 불필요한 복잡도만 키운다. 중요한 것은 많이 가져오는 것이 아니라, 실제로 의미 있는 값만 선별하는 것이었다.
우리가 가장 먼저 세운 기준은 단순했다. 이 값이 사용자의 이탈 가능성을 설명할 수 있는가였다. 즉, 단순히 조회 가능한 정보가 아니라, 왜 이 사용자가 이탈 위험군으로 분류되었는지 설명할 수 있는 값이어야 했다. 기본 정보 영역에서는 크게 두 종류의 값이 중요하다고 판단했다. 하나는 계약 기반 정보, 다른 하나는 사용량 기반 정보였다.
계약 기반 정보에서는 약정 상태나 계약 잔여 기간처럼 서비스 유지 여부에 직접 영향을 줄 수 있는 값을 중요하게 봤다. 예를 들어 약정 종료 시점이 가까워질수록 사용자는 다른 서비스나 요금제를 더 적극적으로 비교할 가능성이 높다. 반대로 가입한 지 얼마 되지 않은 사용자는 당장 이탈할 가능성이 상대적으로 낮을 수 있다. 이런 계약 관련 정보는 사용자의 현재 상태를 해석하는 기본 맥락이 된다.
사용량 기반 정보에서는 현재 이용 중인 상품이나 요금제에 비해 실제 사용량이 적절한지를 살폈다. 예를 들어 높은 요금을 내고 있지만 실제 사용량이 낮다면, 사용자는 현재 요금제가 과하다고 느낄 수 있다. 반대로 사용량이 이전보다 눈에 띄게 줄어드는 경우 역시 서비스 이용 가치가 낮아졌다는 신호로 해석할 수 있었다.
상담 데이터나 행동 로그처럼 즉시 반응이 필요한 신호는 이벤트 기반이 적합하다. 반면 계약 정보나 사용량 정보는 변화 주기가 비교적 길기 때문에, 실시간 파이프라인보다 주기적 배치 방식으로 반영하는 편이 더 안정적이라고 판단했다.
결국 기본 정보 반영에서 중요한 것은 “얼마나 많이 가져오느냐”가 아니라 “얼마나 의미 있는 값을 고르느냐”였다.
아래는 배치 시스템을 통해 갱신되는 컬럼을 표시했다.


4. 이탈률 데이터 적재 및 계산 오케스트레이션 엔진 구축
먼저 해당 이탈률 계산에 대해서 계산 엔진을 설계할 때 2가지 고려 사항을 우선시하여 설계를 진행했다.
- 우리 서비스에 새로운 Feature가 추가되더라도 전체 계산 흐름을 다시 뜯어고치지 않을 것
- 룰 기반 시스템의 특성상 가중치와 임계치가 언제든 바뀔 수 있다는 점을 구조적으로 수용할 것
이 두 가지를 만족시키기 위해 우리는 입력 채널별 처리와 최종 이탈률 집계를 분리한 오케스트레이션 구조를 설계했다.
상담, 로그, 배치 데이터는 모두 서로 다른 형태로 들어오지만, 최종적으로는 feature -> feature snapshot -> churn snapshot이라는 동일한 흐름으로 수렴하도록 만들었다.

입력 이벤트가 최종 이탈률을 덮여쓰지 않는다. 각 입력 파이프라인은 먼저 자신이 담당하는 Feature만을 계산하고, feature_snapshot_store를 갱신한다. 그 다음 공통 오케스트레이션 계층이 최신 Feature 점수를 다시 읽어 최종 이탈률과 위험 사유를 저장한다.
1) Feature 확장성을 어떻게 확보했는가?
가장 중요한 확장 포인트는 ChurnFeatureType, ChurnFeatureScorer, ChurnFeatureScorerFactory였다. 새로운 Feature가 추가되면 새로운 feature 모델과 scorer를 추가하고, 입력 계층에서 해당 feature snapshot만 동기화하면 된다. 공통 계산 정책인 ChurnScorePolicy는 ChurnFeatureSet에 담긴 feature들을 순회하면서 scorer를 찾아 점수를 계산하므로, 계산 흐름 자체는 바뀌지 않는다.
새 Feature 요구사항ChurnFeatureType 추가Feature 모델 추가ChurnFeatureScorer 구현ChurnFeatureScorerFactory등록입력 UseCase에서ChurnFeatureSet 조립ChurnScorePolicy 공통 계산feature snapshot 동기화ChurnSnapshotStoreService최종 집계
이 방식의 장점은 새 Feature가 들어와도 controller, use case, policy, 저장 계층을 전부 수정하는 것이 아니라, 주로 아래와 같은 고정된 지점만 건드리면 된다는 점이다.
- feature 유형 정의
- 해당 feature의 점수 계산기 구현
- 해당 feature의 snapshot 동기화 로직
- 필요 시 최종 feature score 저장 컬럼
즉, 확장 포인트가 명확하기 때문에 기능이 늘어나더라도 전체 코드가 무너지는 구조를 피할 수 있었다.
2)
3) 상담/로그/배치가 어떤 순서로 같은 엔진으로 합류하는가

이 구조를 설계할 때 가장 중요하게 본 것은, 상담, 로그, 배치 데이터가 모두 서로 다른 방식으로 들어오더라도 마지막에는 같은 방식으로 이탈률을 계산해야 한다는 점이었다. 왜냐하면 입력 방식이 다르다고 해서 최종 점수 계산 로직까지 제각각이 되면, 나중에 기능이 늘어날수록 코드가 빠르게 복잡해지기 때문이다. 그래서 입력은 각자 다르게 처리하되, 계산은 하나의 엔진으로 모으는 구조를 만들었다.
즉, 상담은 상담대로 처리하고, 로그는 로그대로 처리하고, 배치는 배치대로 처리한다. 하지만 이 셋은 모두 먼저 자신이 담당하는 Feature 를 만든 뒤, 마지막에는 동일한 오케스트레이션 엔진으로 들어가 최종 이탈률을 계산한다.
@Service
@Profile("admin")
@RequiredArgsConstructor
public class HandleAnalysisConsultationUseCase {
private final ConsultationAnalysisPersistenceService persistenceService;
private final MemberDissatisfactionAssembler assembler;
private final CalculateChurnScoreService calculateChurnScoreService;
@Transactional
public ChurnEvaluationResult execute(AnalysisResponseCommand command) {
// 입력 검증
validate(command);
// 분석 결과 저장
persistenceService.save(command);
// 상담 feature 생성
MemberDissatisfactionFeature dissatisfactionFeature = assembler.assemble(command);
// 이탈률 계산
return calculateChurnScoreService.calculateAndStore(
command,
resolveBaseDate(command),
dissatisfactionFeature
);
}
private void validate(AnalysisResponseCommand command) {
// 회원 확인
if (command.memberId() == null) {
throw new IllegalArgumentException("memberId는 필수입니다.");
}
// 상태 확인
if (!CounselAnalysisStatus.COMPLETED.name().equals(command.status())) {
throw new IllegalArgumentException("완료된 상담 분석 결과만 처리할 수 있습니다. status=" + command.status());
}
}
private LocalDate resolveBaseDate(AnalysisResponseCommand command) {
// 기준일 추출
if (command.producedAt() != null) {
return command.producedAt()
.atZone(ZoneId.of("Asia/Seoul"))
.toLocalDate();
}
// 현재 일자
return LocalDate.now(ZoneId.of("Asia/Seoul"));
}
}
Intelligence Server로부터 분석이 완료된 상담 결과는 먼저 HandleAnalysisConsultationUseCase 클래스로 들어온다. 여기서 입력값을 검증하고 분석 결과를 저장한 뒤, 상담 내용을 바탕으로 불만 Feature를 만든다.
/**
* 스냅샷 계산.
*/
public ChurnEvaluationResult calculateAndStore(
AnalysisResponseCommand command,
LocalDate baseDate,
MemberDissatisfactionFeature dissatisfactionFeature
) {
// feature 조립
ChurnFeatureSet featureSet = new ChurnFeatureSet(Map.of(
ChurnFeatureType.MEMBER_DISSATISFACTION,
dissatisfactionFeature
));
// 점수 계산
ChurnScoreCalculationResult scoreResult = churnScorePolicy.calculateDetails(featureSet);
// feature 스냅샷 저장
memberDissatisfactionFeatureSnapshotService.sync(
command.memberId(),
command,
dissatisfactionFeature,
scoreResult
);
// 위험 사유 조립
List<ChurnRiskReason> riskReasons = buildCounselRiskReasons(command, scoreResult);
// 스냅샷 저장
return churnSnapshotStoreService.store(
command.memberId(),
baseDate,
ChurnRiskReason.Feature.COUNSEL,
riskReasons
);
}
##