System Design · Live BM
특정 조건을 충족한 유저에게만, 정해진 시간 동안 노출되는 CRM 팝업 시스템
상시 상점에 상품을 늘리는 방식은 노출이 분산되고, 정작 그 상품이 필요한 유저에게 닿지 못합니다. 조건부 팝업은 "유저의 상태"를 조건으로 삼아, 아이템이 필요한 시점에 맞는 상품을 그 유저에게만 제안하는 시스템입니다. 조건 · 상품 · 팝업을 3개 데이터 시트로 분리해, 기획자가 코드 수정 없이 시트 값만으로 새로운 조건부 팝업을 운영할 수 있도록 설계했습니다.
본 문서는 재직 중 직접 작성한 사내 기획서를 포트폴리오용으로 재구성한 것입니다. 회사 로고 · 대외비 표기는 제거했고, 실제 인앱 상품 코드(SKU) · 로케일 코드는 예시 값으로 대체했으며, 테이블 · 컬럼명은 실제 스키마와 다르게 표기했습니다(구조 · 의미는 동일). 데이터 테이블은 핵심 연산 로직 설명을 위해 축약되었으며, 실제 스키마에는 노출 조건 · 예외 처리 등 추가 속성이 존재합니다.
"상품을 더 만드는 것"이 아니라 "필요한 유저에게 닿게 하는 것"이 목표였습니다.
상시 상점은 모든 유저에게 같은 상품을 노출합니다. 신규 상품을 계속 추가하면 상점이 길어지고, 정작 그 상품이 필요한 유저(재화가 부족하거나, 특정 카드가 없는 유저)의 눈에는 잘 띄지 않습니다.
조건부 팝업은 유저의 현재 상태를 조건으로 삼습니다. 조건을 충족한 유저에게만, 정해진 시간 동안 팝업을 띄워 "지금 당신에게 필요한 상품"을 제안합니다.
설계 의도
구매 전환이 1차 목표지만, 구조는 상품에 한정하지 않았습니다. "조건을 만족한 유저에게 시간제한 콘텐츠를 노출한다"는 뼈대로 설계해서, 미션 유도 · 이벤트 참여 유도 같은 비-BM 용도로도 재사용할 수 있습니다.
그래서 팝업 타입을 상품 구매형 하나로 못 박지 않고, URL 이동형을 함께 두어 "팝업 = 특정 페이지로 보내는 트리거"라는 더 넓은 정의를 유지했습니다.
조건부 팝업은 세 가지 축으로 정의됩니다 — ① 팝업 타입 · ② 활성화 조건(트리거) · ③ 운영 기간.
① 조건부 팝업 타입 — 동작에 따라 2종
② 활성화 조건 (트리거)
③ 운영 기간 — 3개 시간 개념
운영 기간
이 팝업이 노출될 수 있는 전체 기간. 종료되면 어떤 조건에서도 다시 노출 안 됨
활성화 시간
운영 기간 안에서, 유저에게 팝업이 실제로 보이는 시간 (활성화된 시점부터 N시간, hour 단위)
쿨타임
한 번 노출된 뒤 다음 노출까지 대기하는 시간. 이 동안은 조건을 다시 충족해도 재할당되지 않음
Design Note — 왜 활성화 시간과 쿨타임을 나눴나
조건을 만족하는 순간 계속 팝업이 뜨면 유저 피로도가 급격히 올라갑니다. "할당되면 N시간만 보이고(활성화 시간), 사라진 뒤 M시간은 다시 안 뜬다(쿨타임)"로 노출 빈도를 데이터 값만으로 조절할 수 있게 했습니다. 유저가 활성화 조건을 벗어나도 활성화 시간 동안은 팝업이 유지되도록 해, 결제 도중 재화가 바뀌어 팝업이 사라지는 문제를 막았습니다.
최초 개발 범위의 조건 타입은 ItemAmount 하나입니다 — 현재 유저의 특정 아이템 보유량을 목표 값(Value)과 비교합니다. 조건의 상세는 matchScript 한 컬럼에 JSON으로 담아, 조건 종류가 늘어도 컬럼 구조를 바꾸지 않도록 했습니다.
matchScript = { category, targetCode, operator }
ConditionData
조건부 팝업에서 사용할 "조건"을 정의하는 시트
| 컬럼 | 자료형 | 설명 |
|---|---|---|
ruleId | Integer |
|
ruleType | Integer |
|
matchScript | String |
|
| 설명 | ruleId | ruleType | matchScript |
|---|---|---|---|
보유 다이아 수량 = N | 1002 | 1 | {"category":0,"targetCode":2,"operator":1} |
보유 다이아 수량 <= N | 1003 | 1 | {"category":0,"targetCode":2,"operator":101} |
보유 다이아 수량 > N | 1006 | 1 | {"category":0,"targetCode":2,"operator":202} |
비교할 목표 값(N)은 조건 시트가 아니라 팝업 시트의 conditionTarget에 둔다 → 같은 조건(예: "다이아 이하")을 여러 팝업이 서로 다른 임계값으로 재사용.
조건부 팝업 생명주기
① 할당
운영 기간 + 활성화 조건을 충족하면 해당 팝업이 유저에게 할당됨 (동일 팝업 중복 할당 방지)
② 활성화
할당 이후 로비 진입 시점부터 활성화 시간 동안 노출
③ 종료
활성화 시간 종료 / 운영 기간 경과 / 상품 구매 횟수 소진 / 테이블에서 데이터 삭제 시
④ 재노출
쿨타임 경과 후 조건 · 운영 기간을 재검증하여 충족 시 재노출 (구매 가능 횟수는 초기화 안 됨)
팝업 강제 호출 & 우선순위
Design Note — "오늘 하루 안보기"를 통합으로 묶은 이유
팝업마다 개별로 "안보기"를 처리하면, 유저가 A 팝업을 껐는데 접속할 때마다 B, C 팝업이 순서대로 강제 호출됩니다. 유저 입장에서는 "껐는데도 계속 뜬다"로 느껴집니다. 조건부 팝업 전체를 하나의 스위치로 묶어, "오늘은 그만"이 실제로 오늘 하루 유효하도록 했습니다.
화면 영역 정의 — 정보 / 리스트 / 상세 3개 영역
결제 도중 활성화 시간이 끝나도, 이미 결제가 진행 중이면 이어서 완료 가능. 결제 취소 시 상점과 동일한 "구매를 취소하였습니다" 안내 팝업을 호출합니다.
조건 · 상품 · 팝업을 3개 시트로 분리 — 기획자가 코드 수정 없이 시트 값만 채워 새 조건부 팝업을 운영합니다.
시트 관계
ConditionData
조건 정의 (category/targetCode/operator)
PopupData
팝업 = 조건 + 상품 + 기간을 묶는 중심 시트
ProductData
상품 정의 (구성품/가격/SKU/청약철회)
PopupData
조건부 팝업 및 구성 요소를 정의하는 중심 시트
| 컬럼 | 자료형 | 설명 |
|---|---|---|
campaignId | Integer |
|
popupType | Integer |
|
productRef | String |
|
conditionRef | Integer |
|
conditionTarget | Integer |
|
beginAt / expireAt | Timestamp |
|
activeTime | Integer |
|
cooldownHours | Integer |
|
titleTextId / descTextId | Integer |
|
badgeImage / bannerImage | String |
|
| campaignId | popupType | productRef | conditionRef | conditionTarget | activeTime | cooldownHours |
|---|---|---|---|---|---|---|
1 | 1 | 300002 | 1002 | 50 | 24 | 24 |
2 | 1 | 300002 | 1002 | 100 | 72 | 72 |
3 | 1 | 300001 | 1006 | 15000 | 48 | 48 |
ConditionData
조건 정의 시트 (3번 항목에서 상술)
| 컬럼 | 자료형 | 설명 |
|---|---|---|
ruleId | Integer | 조건 고유 값 (여러 팝업이 공유) |
ruleType | Integer | 조건 타입 (1:ItemAmount) |
matchScript | String | { category, targetCode, operator } 스크립트 |
ProductData
조건부 팝업에서 판매할 상품을 정의하는 시트
| 컬럼 | 자료형 | 설명 |
|---|---|---|
itemCode | Integer | 상품 코드 값 |
rewardItems | String |
|
purchaseLimit | Integer |
|
currencyType | Integer |
|
priceValue | Double |
|
aosSku / iosSku | String |
|
etcSku | String |
|
canRefund | Boolean |
|
| itemCode | rewardItems | purchaseLimit | currencyType | priceValue | aosSku | canRefund |
|---|---|---|---|---|---|---|
300001 | [{"p":40105,"q":1}] | 1 | 1 | 4.99 | [SKU_예시_01] | TRUE |
300002 | [{"p":40106,"q":1}] | 5 | 1 | 4.99 | [SKU_예시_02] | FALSE |
300003 | [{"p":40107,"q":1}] | 0 | 1 | 4.99 | [SKU_예시_03] | TRUE |
실제 인앱 SKU · 로케일 코드는 예시 값으로 대체했습니다.
회고
"상품을 늘리는" 요청을 "조건을 만족한 유저에게 시간제한 콘텐츠를 보내는 트리거"라는 더 일반적인 문제로 바꿔 정의한 것이 핵심이었습니다. 덕분에 첫 버전은 BM으로 썼지만, 같은 시스템을 미션 · 이벤트 유도에도 쓸 수 있는 여지를 남겼습니다.