요금제, 체험, 캠페인까지 고려한 SaaS 기능 오픈 정책 설계

요금제, 체험, 캠페인까지 고려한 SaaS 기능 오픈 정책 설계
Photo by Headway / Unsplash

누구에게? 어떤 기능을?

SaaS 제품이 단순한 무료/유료 구조를 넘어가면, “이 기능을 누구에게 열어줄 것인가?”라는 문제가 복잡해진다.

처음에는 요금제만 보면 충분해 보인다. 하지만 실제 운영에서는 제휴 고객, 이벤트 대상, 신기능 체험, 특정 고객 예외처럼 요금제만으로 설명되지 않는 상황이 계속 생긴다.

이 조건들을 기능마다 개별 if 문으로 처리하면 예외가 코드 곳곳에 흩어지고, 나중에는 왜 특정 고객에게 기능이 열려 있는지 추적하기 어려워진다.

그래서 필요한 것이 기능 오픈 정책이다.
기능 오픈 정책은 요금제, 체험, 캠페인, 제휴, 운영 예외를 하나의 기준과 우선순위로 정리해 기능 사용 가능 여부를 판정하는 구조다.

정책이 필요한 실제 상황

실제 서비스를 운영하다 보면 다음과 같은 요청이 자연스럽게 생긴다.

이번에 숙제 기능이 새로 출시되었습니다. 7월 말까지 자유롭게 이용 가능합니다.
마이뮤직 원장님, 도와주셔서 감사합니다. 실시간 스케줄 기능은 계속 무료로 사용할 수 있게 해드릴게요.
50인 이상 카톡방에 서비스를 소개해주시면 알림장 수신자조회 기능을 6개월간 열어드립니다.
무료 체험 기간에는 광고는 보입니다.

비즈니스적 요구사항

SaaS 기능 오픈 정책은 단순히 기능을 켜고 끄기 위한 장치가 아니다. 요금제 차등, 신기능 확산, 체험 전환, 제휴 운영, 고객별 예외 처리를 제품 안에서 일관되게 다루기 위한 구조다.

이 정책은 다음 요구사항을 만족해야 한다.

  1. 요금제에 포함된 기능은 자동으로 사용할 수 있어야 한다.
  2. 특정 고객에게 이벤트나 제휴 조건으로 기능을 열어줄 수 있어야 한다.
  3. 새 기능을 전체 고객에게 일정 기간 캠페인 형태로 공개할 수 있어야 한다.
  4. 요금제를 체험할 수 있지만 해당 전체 기능을 여는 것은 아닐 수 있다.
  5. 운영상 필요한 경우 특정 기능을 강제로 켜거나 끌 수 있어야 한다.
  6. 어떤 이유로 기능이 열렸는지 추적 가능해야 한다.

결국 목표는 기능 오픈 여부를 즉흥적으로 처리하지 않고, 비즈니스 정책과 제품 구현이 같은 기준으로 움직이게 만드는 것이다.

기능 오픈 정책 소개

기능 오픈 정책은 특정 고객이 특정 기능을 사용할 수 있는지 판단하는 규칙의 묶음이다. 여기서는 기능별로 조건을 따로 흩어놓지 않고, 하나의 우선순위에 따라 최종 사용 가능 여부를 결정한다.

기본 구조는 다음과 같다.

  1. 운영 예외 정책이 있으면 우선 적용한다. (강제 차단이 강제 오픈보다 우선)
  2. 현재 요금제에 포함된 기능이면 기능을 켠다.
  3. 고객별 이벤트나 제휴 허가 기간이 유효하면 기능을 켠다.
  4. 전사 캠페인 기간에 포함된 신기능이면 기능을 켠다.
  5. 사용자가 체험을 신청했고, 해당 요금제에서 체험 가능한 기능이면 기능을 켠다.
  6. 위 조건에 모두 해당하지 않으면 기능을 끈다.

이제 이 정책을 데이터로 어떻게 표현할지 살펴보자. 핵심은 모든 조건을 하나의 테이블에 몰아넣지 않고, 변경 주기와 성격에 따라 나누는 것이다.

DB 스키마 소개

customer_plan

고객별 현재 요금제를 관리한다.
한 고객은 한 시점에 하나의 활성 요금제만 가진다.

customer_plan {
  id: number
  customer_id: number
  plan: "free" | "plus" | "pro"

  plan_start_date: YYYYMMDD

  effective_from: datetime
  effective_until: datetime | null
  // null이면 현재 active

  deleted_at: datetime | null
}

feature_campaign

전사 기능 캠페인을 관리한다.
특정 기능을 일정 기간 동안 여러 고객에게 열어줄 때 사용한다.

feature_campaign {
  id: number

  feature_key: string 
  // homework, post_viewer

  min_plan_code: "free" | "plus" | "pro"
  // 캠페인 적용 최소 요금제

  start_date: YYYYMMDD
  end_date: YYYYMMDD

  index(feature_key, start_date, end_date, deleted_at)
  // 특정 기능이 현재 캠페인 기간에 포함되는지 조회
}

customer_feature_grant

특정 고객에게 특정 기능을 개별적으로 허가한다.
이벤트, 제휴, 초기 고객 혜택처럼 고객별로 기능을 열어주는 경우에 사용한다.

customer_feature_grant {
  id: number

  customer_id: number
  feature_key: string
  // 예: advanced_report

  grant_kind: "partner" | "event"
  // 제휴 허가인지, 이벤트 허가인지 구분

  start_date: YYYYMMDD
  end_date: YYYYMMDD | null
  // null이면 무기한 허가

  evidence: string
  // 증빙 링크 + 증빙 설명

  index(customer_id, feature_key, start_date, end_date, deleted_at)
  // 특정 고객의 특정 기능 허가 여부 조회

  index(grant_kind, start_date, end_date)
  // 이벤트/제휴 유형별 기간 조회
}

customer_plan_trial

고객별 요금제 체험 이력을 관리한다.
체험은 실제 요금제 변경과 분리해서 기록한다.

customer_plan_trial {
  id: number

  customer_id: number
  plan: "plus" | "pro"
  // 체험 중인 요금제

  start_date: YYYYMMDD
  end_date: YYYYMMDD

  is_active: boolean
  // true  = 체험 적용 중
  // false = 중단

  index(customer_id, plan, start_date, end_date, is_active, deleted_at)
  // 특정 고객의 현재 체험 여부 조회

  index(plan, start_date, end_date, is_active, deleted_at)
  // 플랜별 체험 현황 조회
}

customer_feature_state

앱에서 실제로 읽는 최종 기능 상태를 저장한다.
정책의 원천 데이터라기보다, 계산된 결과를 빠르게 읽기 위한 테이블이다.

customer_feature_state {
  customer_id: number

  plan: "free" | "plus" | "pro"

  feature_ad_free: boolean
  feature_ad_free_meta: string

  feature_homework: boolean
  feature_homework_meta: string

  feature_comment: boolean
  feature_comment_upload: boolean

  feature_realtime_schedule: boolean
  feature_realtime_schedule_meta: string

  ...

  primary_key(customer_id)
}

업데이트 시점

기능 오픈 정책은 매번 화면에 접근할 때마다 모든 조건을 실시간으로 계산할 수도 있지만, 이 방식은 DB 조회가 많아지고 응답 경로가 복잡해진다.

이번 구조에서는 정책의 원천 데이터와 최종 기능 상태를 분리한다.

정책 원천 데이터
→ plan, grant, campaign, trial, override

최종 기능 상태
→ customer_feature_state

즉, 기능이 열리는 이유는 여러 테이블에 저장하고, 앱에서 빠르게 읽어야 하는 최종 on/off 상태는 별도 상태 테이블에 반영한다.

기능 상태 갱신은 다음 시점에 수행한다.

1. 요금제 체험 시작 직후 (사용자별)
2. 유료 요금제 시작 직후 (사용자별)
3. 유료 요금제 해지 직후 (사용자별)
4. 기능 캠페인 생성 또는 변경 후 (전체)
5. 고객별 기능 grant 생성 또는 변경 후 (사용자별)
6. 매일 정기 배치 (전체)
7. 운영자가 수동 동기화하는 경우 (전체 또는 사용자별)

이 중 가장 중요한 것은 요금제 변경 직후와 정기 배치다.

요금제 체험을 시작하거나 유료 요금제를 시작하면, 먼저 고객의 plan/quota를 갱신하고 그 다음 기능 상태를 재계산한다.

await updateCustomerPlanAndQuota(customerId, plan)
await updateCustomerFeatureSetting(customerId)

반대로 정기 배치는 날짜 기반 정책을 반영하기 위해 필요하다.
예를 들어 캠페인 종료일이 지났거나, 고객별 기능 허가 기간이 만료되었거나, 요금제 체험 기간이 끝났다면 별도 사용자 행동이 없어도 기능이 꺼져야 한다.

따라서 이 구조에서는 이벤트 기반 갱신과 시간 기반 배치를 함께 사용한다.

이벤트 기반 갱신
→ 요금제 신청, 해지, grant 생성 등 즉시 반영이 필요한 경우

시간 기반 배치
→ campaign, grant, trial의 시작일/종료일 변화 반영

배치 갱신 구조

전체 고객의 기능 상태를 재계산할 때는 모든 고객을 한 번에 처리하지 않는다.
일정 개수씩 나누어 순차적으로 처리한다.

const FEATURE_UPDATE_BATCH_SIZE = 100

for each customerIdBatch of customerIdList.chunk(100) {
  await updateCustomerFeatureSettingList(customerIdBatch)
}

이렇게 하면 한 번에 과도한 update가 발생하는 것을 막고, 배치 처리 중 장애가 발생했을 때 영향 범위도 줄일 수 있다.

배치 결과는 다음 정보를 남긴다.

customerCount
updatedCount
batchSize
batchCount
onToOffCount

특히 on → off 변경은 별도로 수집한다.

기존에는 켜져 있었는데,
정책 재계산 결과 꺼진 기능

이 정보는 운영상 중요하다.
기능이 꺼지는 변화는 고객 경험에 직접 영향을 주기 때문이다. 캠페인 종료, 체험 종료, grant 만료로 기능이 꺼졌다면 로그나 알림으로 추적할 수 있어야 한다.

구현 예시: 판정 컨텍스트 만들기

기능 판정은 고객 한 명, 기능 하나마다 DB를 직접 조회하는 방식으로 만들면 안 된다.

나쁜 구조는 다음과 같다.

for each customer {
  for each feature {
    query current plan
    query grant
    query campaign
    query trial
    evaluate feature
  }
}

이 방식은 고객 수와 기능 수가 늘어날수록 DB 조회가 폭발한다.

대신 배치 단위로 필요한 데이터를 한 번에 조회한 뒤, 메모리에서 feature별 판정을 수행한다.

// 1. 배치 단위별로

// 2. 조건별 데이터 일괄 조회
currentPlanMap 준비 (고객별)
grantListMap 준비 (고객별)
activeCampaignList 준비 (전사)
trialMap 준비 (고객별)

// 3. 고객별 컨텍스트 구성 후 feature 판정
for customerId in customerIdBatch {
  context 만들기

  for feature in featureList {
    availability = feature 판정
    결과 모으기
  }
}

// 4. 계산 결과 저장
customer_feature_state 업데이트

전사 캠페인은 특정 고객에게 종속된 데이터가 아니라 기능 전체에 적용되는 공통 데이터다. 따라서 고객마다 반복 조회할 필요가 없다.

고객별 데이터
→ current plan
→ grant
→ trial

전사 공통 데이터
→ campaign

배치에서 필요한 데이터를 모두 조회한 뒤에는 고객별로 판정 컨텍스트를 만든다.

const context = {
  currentPlan,
  forceOffFeatureSet,
  forceOnFeatureSet,
  featureGrantMap,
  featureCampaignMap,
  activePlanTrial,
}

구현 예시: 고객별 기능 판정

모든 feature에 대해 동일한 정책 순서로 최종 사용 가능 여부를 계산한다.

function getFeatureAvailability(feature, context) {
  if (context.forceOffFeatureSet.has(feature)) {
    return off()
  }

  if (context.forceOnFeatureSet.has(feature)) {
    return on()
  }

  if (isEnabledByPlan(feature, context.currentPlan)) {
    return on()
  }

  if (context.featureGrantMap.has(feature)) {
    return on({ reason: "grant" })
  }

  if (context.featureCampaignMap.has(feature)) {
    return on({ reason: "campaign" })
  }

  if (isEnabledByTrial(feature, context.activePlanTrial)) {
    return on({ reason: "trial" })
  }

  return off()
}

핵심은 모든 기능이 같은 함수를 통과한다는 점이다.

기능마다 별도의 if 문을 흩어놓지 않고, 기능 목록을 순회하면서 같은 정책을 적용한다. 이렇게 하면 새 기능이 추가되어도 구조가 크게 바뀌지 않는다.

업데이트 결과 반영

최종적으로 고객별 기능 상태 맵을 만든 뒤, 이를 상태 테이블에 반영한다.

customerFeatureAvailabilityMap {
  customerId -> {
    featureA -> on/off
    featureB -> on/off
    featureC -> on/off
  }
}

이 결과를 기반으로 customer_feature_state를 업데이트한다.

이때 기존 상태와 새 상태를 비교해 on → off 변화를 별도로 기록한다.

if (previousEnabled && !nextEnabled) {
  onToOffChangeList.push({
    customerId,
    feature,
  })
}

기능이 새로 켜지는 것은 대체로 긍정적 변화지만, 기능이 꺼지는 것은 고객 문의나 운영 이슈로 이어질 수 있다.
따라서 on → off 변경은 배치 로그, 운영 알림, 관리자 화면에서 확인할 수 있게 두는 것이 좋다.

비즈니스 정책을 일관된 판정 흐름으로 만들고, 그 결과를 운영 가능한 형태로 동기화하는 구조다.

마무리

기능 오픈 정책의 핵심은 단순히 기능을 켜고 끄는 것이 아니다.

요금제, 체험, 캠페인, 제휴, 운영 예외처럼 서로 다른 비즈니스 조건을 하나의 판정 흐름으로 정리하고, 그 결과를 일관되게 서비스에 반영하는 것이다.

이 구조를 두면 기능이 늘어나도 조건문이 코드 곳곳에 흩어지지 않는다.
새 기능은 정책 상수와 필요 데이터만 추가하면 같은 흐름 안에서 판정할 수 있다.

또한 정책의 원천 데이터와 최종 기능 상태를 분리하면, 앱에서는 빠르게 기능 상태를 읽으면서도 “왜 이 기능이 열렸는지”를 추적할 수 있다.

결국 좋은 기능 오픈 정책은 개발 편의만을 위한 구조가 아니라, 제품 운영과 비즈니스 전략을 안정적으로 연결하는 장치다.