Skip to content

[metrics-core] calculateUserCC가 임의 lookback 기간의 NRR을 항상 30일로 환산해 수용 한계를 왜곡한다 #3218

Description

@kang-heewon

우선순위

P2 — 로컬 공개 API의 기능 정확성 문제

문제

calculateUserCC는 lookbackDays 길이로 retention을 조회하지만 dailyChurnRate 계산에서는 30을 고정 사용합니다. 같은 일별 유입과 같은 일별 감소율의 정상 시계열을 30일과 60일로 조회하면 capacity가 10000과 5000으로 달라집니다.

기준 및 근거

실제 재현

clean checkout의 저장소 루트에서 실행합니다. AGENTS.md/CONTRIBUTING.md의 환경 준비 후 frozen dependency를 설치합니다. 외부 서비스, DB, 인증, 실사용자 데이터는 필요하지 않습니다.

pnpm install --frozen-lockfile --ignore-scripts
mkdir -p audit-data

다음을 audit-data/repro.vitest.config.ts에 저장합니다.

import { defineConfig } from 'vitest/config';
import { resolve } from 'node:path';

export default defineConfig({
  resolve: {
    alias: {
      '@croco/problems-core': resolve('packages/problems-core/src/index.ts'),
      '@croco/framework-context': resolve('packages/framework-context/src/index.ts'),
      '@croco/telemetry-api': resolve('packages/telemetry-api/src/index.ts'),
    },
  },
  test: { include: ['audit-data/D*.spec.ts'], maxWorkers: 1, testTimeout: 10000 },
});

다음을 audit-data/D05.spec.ts에 저장합니다.

import { it, expect } from 'vitest';
import { CarryingCapacityCalculator } from '../packages/metrics-core/src/index';
import type { MetricsRepository } from '../packages/metrics-core/src/index';

const providers = (inflow: number, retention: MetricsRepository['getRetentionMetrics']) => ({
 users: { getDailyActiveUsers: async () => 100, getNewUsersCount: async () => inflow, getChurnedUsersCount: async () => 0 },
 repo: { recordMRRMovement: async () => {}, recordSnapshot: async () => {}, getSnapshot: async () => null, getMRRHistory: async () => [], getRetentionMetrics: retention } satisfies MetricsRepository
});
it('D05 derives daily churn from the actual lookback period', async () => {
  const calls: number[] = [];
  const {users, repo} = providers(100, async (_tenant, period) => {
   const days = (period.to.getTime() - period.from.getTime()) / 86400000;
   calls.push(days);
   return { logoChurn: 0, revenueChurn: 0, grr: 100 * Math.exp(-0.01 * days), nrr: 100 * Math.exp(-0.01 * days) };
  });
  const calculator = new CarryingCapacityCalculator(users, repo);
  const days30 = await calculator.calculateUserCC({ tenantId: 'sample-tenant', lookbackDays: 30 });
  const days60 = await calculator.calculateUserCC({ tenantId: 'sample-tenant', lookbackDays: 60 });
  console.log('D05', JSON.stringify({ calls, capacity30: days30?.capacity, capacity60: days60?.capacity, churn30: days30?.dailyChurnRate, churn60: days60?.dailyChurnRate }));
  expect(days30?.capacity).toBeCloseTo(10000);
  expect(days60?.capacity).toBeCloseTo(10000);
 });
TZ=UTC pnpm exec vitest run --config audit-data/repro.vitest.config.ts audit-data/D05.spec.ts

현재 baseline에서는 의도한 회귀 assertion이 실패하고 exit code 1입니다. 실행 출력의 핵심은 다음과 같습니다.

D05 {"calls":[30,60],"capacity30":10000,"capacity60":5000.000000000001,"churn30":0.01,"churn60":0.019999999999999997}

기대 동작 / 실제 동작

  • 기대: 일 유입 100, 연속 감소율 0.01/day로 동일한 시계열이라면 관측 기간을 30일에서 60일로 늘려도 dailyChurnRate=0.01, capacity=10000이어야 합니다.
  • 실제: 위 출력처럼 현행 구현은 기대 동작과 다른 결과를 냅니다.

영향

조회 범위 선택만 바꿔도 사용자 수용 한계와 여유 용량이 비례적으로 바뀌며, 시계열 상태가 악화된 것처럼 보고됩니다.

제한된 수정 경로

  1. retention.nrr이 실제 어떤 기간을 나타내는지에 맞춰 로그 환산 분모를 결정합니다. 현재 repository 계약을 유지한다면 조회한 기간의 일수로 환산해야 합니다.
  2. calendar-day/elapsed-day 정책을 기존 API와 일관되게 명시하고 테스트합니다. 월간 NRR로 가정하려면 별도로 월간 값을 조회해야 하므로 현재 기간 전체 값을 30일 값으로 취급하지 않습니다.
  3. 30/60일과 짧은 기간의 일별 감소율이 일정한 fixture로 계산 결과의 불변성을 검증합니다.

완료 기준

  • 같은 일 유입·일 감소율의 30일과 60일 표본에서 같은 dailyChurnRate와 capacity가 나옵니다.
  • 기존 30일 결과는 유지됩니다.
  • MetricsRepository의 period 전체 계산 계약과 일치하며 유효하지 않은 lookback을 거부하는 테스트도 유지됩니다.

검증

pnpm --filter @croco/metrics-core test
pnpm --filter @croco/metrics-core typecheck
TZ=UTC pnpm exec vitest run --config audit-data/repro.vitest.config.ts audit-data/D05.spec.ts

수정 후 위 회귀 테스트가 통과해야 합니다. 관련 기존 테스트는 통과했으며, 이 재현에서는 의존성 로딩 오류가 아닌 결과 assertion 실패를 확인했습니다. 전체 monorepo 검증은 별도입니다.

중복 검사

닫힌 #2263은 유효하지 않은 lookback 설정 거부이고 공식 변경을 명시적으로 범위 밖으로 둡니다. #2308은 로그 입력의 유효범위 검사입니다. 이번은 유효한 30/60 설정에서 기간 환산이 달라지는 독립적인 계산 오류입니다.

범위 제외

NRR을 사용자 이탈률 proxy로 사용하는 도메인 모델 자체, RevenueCC 공식, provider DB 쿼리와 DST 정책의 전면 변경은 제외합니다. 조회 기간과 환산 단위 불일치만 수정합니다.

Activity

  1. added
    bugSomething isn't working
    P2Priority 2 issue
    on Sep 30, 2026
  2. self-assigned this
    on Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P2Priority 2 issuebugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions