서비스를 고도화하는 작업을 진행하면 할수록 초기에 잘 동작하는 것처럼 보였던 설계가 발목을 잡는 순간이 온다. 최근 우리 서비스의 결제 및 가격 구조를 개편하면서 이 사실을 다시 한번 뼈저리게 느꼈다.
기존에는 각 콘텐츠마다 가격을 개별적으로 설정할 수 있도록 설계되어 있었다. 하지만 본격적으로 서비스 출시를 앞두고, 프로모션을 기획해야 하는 시점이 오자 이 구조는 기술 부채로 다가왔다. 오늘은 파편화된 데이터 구조의 한계를 SSoT(Single Source of Truth, 단일 진실 공급원) 원칙을 통해 어떻게 극복했는지, 그리고 이를 통해 어떻게 프로모션 시스템의 토대를 마련했는지 그 고민의 과정을 기록해 본다.
AS-IS: SSoT의 부재와 파편화된 데이터
처음 시스템을 설계할 때는 직관적이었다. 'A 스토리의 가격은 10코인, B 스토리의 가격은 20코인.' 데이터베이스의 스토리 문서(Document) 안에 가격 정보를 직접 박아 넣었다.
| Story Document |
하지만 이 AS-IS 구조는 치명적인 비효율성을 내포하고 있었다.
데이터 정합성 문제: 특정 카테고리의 스토리 가격을 일괄 인상해야 한다면? 지금은 콘텐츠가 많이 없어서 괜찮지만... 추후엔 수백, 수천 개의 스토리 문서를 일일이 찾아 업데이트해야 한다. 이 과정에서 단 하나의 문서라도 누락되면 사용자에게 잘못된 가격이 노출된다.
프로모션 기획의 한계: "론칭 첫 달 동안 특정 장르의 스토리를 30% 할인한다"는 이벤트를 진행하려면, 이벤트 시작 시점에 맞춰 각 스토리마다 일일이 DB를 수정하고, 이벤트가 끝나면 다시 롤백해야 한다. 이는 데이터베이스에 과도한 부하를 줄 뿐만 아니라 장애 발생 확률을 기하급수적으로 높인다.
결론적으로 진실의 원천(Source of Truth)이 수많은 스토리 데이터에 파편화되어 있었기 때문에 발생한 문제였다(열심히 Architect Agent와 씨름을 하다보니 알게 된 사실). 시스템이 '어떤 것이 진짜 가격인가?'를 판단할 중앙 통제소가 없었던 것이다.
TO-BE: '스토리'와 '가격 정책'의 분리
이 문제를 해결하기 위해 데이터의 책임을 분리하기로 했다. 스토리는 스토리의 본질(내용, 메타데이터)만 가지고, 가격은 '옵션(Option)'이라는 새로운 계층으로 분리하여 중앙에서 관리하는 TO-BE 구조를 설계했다.
핵심은 SSoT 원칙의 확립이었다.
1. 스토리 옵션 카탈로그 (Story Option Catalog) 도입
이제 개별 스토리는 가격을 직접 가지지 않는다. 대신 optionKey (예: option_A, option_B)만을 참조한다. 실제 가격 정보는 시스템 설정(System Configs)의 카탈로그 문서에서 중앙 집중식으로 관리된다.
| Story Document |
작성한 createCatalogCacheService 코드를 보면, DB에서 story_options 데이터를 읽어와 구조화하는 로직을 확인할 수 있다.
function normalizeStoryOption(optionKey, rawOption) {
// ... 유효성 검사 로직 생략 ...
return {
optionKey,
purchaseCost,
adRemovalCost,
reportSubmitLimit,
isActive,
// ...
};
}
이제 가격을 변경하고 싶다면, 수천 개의 스토리를 건드릴 필요 없이 카탈로그의 optionKey 데이터 단 한 곳만 수정하면 된다. 진정한 의미의 SSoT가 구축된 것이다. 그리고 혹시나 새로운 가격 정책이 필요하다면 새로운 옵션을 만들고, 스토리에 해당 옵션을 지정하기만 하면 끝이었다!
| Option Field |
2. 프로모션 시스템의 기반 마련
가격을 중앙에서 통제할 수 있게 되자, 자연스럽게 프로모션 로직을 구현할 수 있는 길이 열렸다. storyPromotionCampaigns 상태를 추가하여, 특정 조건이 만족할 때 기존 카탈로그의 가격을 '덮어쓰기(Overwrite)' 하는 구조를 만들었다.
function normalizeStoryPromotionCampaign(rawCampaign) {
// 특정 옵션키나 스토리 코드를 타겟팅
const targetOptionKeys = hasTargetOptionKeys ? targetOptionKeysRaw : [];
const targetStoryCodes = hasTargetStoryCodes ? targetStoryCodesRaw : [];
// 프로모션 기간 설정
const startsAtMs = toEpochMs(startsAt);
const endsAtMs = endsAt === null ? null : toEpochMs(endsAt);
// ...
이제 특정 기간(startsAtMs ~ endsAtMs) 동안 특정 타겟(targetOptionKeys)에 대해 할인가(purchaseCost, adRemovalCost)를 적용하는 캠페인을 데이터베이스에 하나만 등록해 두면 된다. 서버는 실시간으로 현재 시간이 프로모션 기간에 해당하는지, 사용자가 요청한 스토리가 타겟에 포함되는지 평가하여 동적으로 가격을 계산한다.
| Promotion Field |
3. 인메모리 캐싱(In-memory Caching)을 통한 병목 해결
SSoT를 구축하면서 마지막으로 신경을 썼던 부분은 결제 및 조회 요청이 카탈로그 DB로 몰리지 않도록 하는 것이었다. 카탈로그 데이터는 모든 사용자가 빈번하게 조회하지만, 변경은 어드민에 의해 어쩌다 한 번 일어난다. (이러한 특성은 이전에 모델에 캐릭터의 페르소나를 입혀 서빙하는 서버를 구축했을 때도 나타났었다. 이번에도 같은 방법, "캐싱"을 활용했다)
따라서 매번 DB를 조회하는 대신, 메모리 상에 데이터를 올려두고 TTL(Time-To-Live) 기반으로 갱신하는 캐시 서비스(CatalogCacheService)를 구현했다. refreshIfNeeded 함수를 통해 캐시가 만료되었을 때만 백그라운드에서 데이터를 동기화하도록 처리하여, 데이터의 일관성과 응답 성능이라는 두 마리 토끼를 모두 잡을 수 있었다.
마치며
이번 리팩토링은 단순히 코드를 깔끔하게 정리하는 수준을 넘어, 비즈니스의 요구사항(프로모션, 동적 가격 정책)을 기술적으로 소화할 수 있는 기반을 다지는 과정이었다.
처음부터 완벽한 설계는 없다는 것을 안다. 초기에는 빠르게 개발하여 시장의 반응을 보는 것이 중요했기에 이전의 구조가 틀렸다고 생각하지는 않는다. 하지만 비즈니스가 다음 단계로 넘어가야 할 때, 기존 시스템의 근본적인 한계(단일 진실 공급원의 부재)를 파악하고 이를 과감하게 재설계하는 결단력이 필요하다는 점을 깊이 깨달았다. 빠른 프로토타이핑과 robust한 시스템 사이... 사람들의 반응을 보고 검증하는 건 끊임없이 해왔다면 이제 시스템을 점검할 필요가 있었다.
이렇게 탄탄하게 구축된 카탈로그와 프로모션 캐시 레이어 위에서, 앞으로 기획팀이 어떤 이벤트를 가져오더라도 가볍게 대응할 수 있을 것 같아 든든하다.
추천글
[스타트업/기술] Node.js & Google Play 인앱결제 | 결제 시스템 아키텍처 설계 (Part 1)
[스타트업/기술] Node.js & App Store 인앱결제 | 결제 시스템 아키텍처 (Part 2)
[스타트업/기술] FastAPI와 클라우드 네이티브 | Cloud Run 도입과 백엔드 전환기 (Part 2)