🛠️ 개발자 매뉴얼

22. 모니터링 연동 가이드 — 결제 수익 피드

👤

대상: 모니터링 서버 개발 담당 · 결제서버 API: GET /api/v1/metrics/sync(일별 집계) · /metrics/subscriptions(스냅샷) · /metrics/payments·/metrics/subscription-list(건별, 22.12) · 작성 2026-09-03 · 갱신 2026-09-09

모니터링 서버가 결제서버에서 서비스별 일일 수익을 받아 손익 대시보드를 만들기 위한 지침입니다. 호출할 API는 하나이고, 모니터링이 저장할 상태는 커서 문자열 하나입니다. 이 문서대로만 구현하면 됩니다. 결제서버 쪽 명세는 13.10 모니터링 동기화 API를 참고하세요.


22.1 먼저 알아둘 것

받는 것 일별 집계 행 — 날짜 × 서비스 × 요금제 × 결제종류(매출·환불 + 구독 흐름) · 구독 현황 스냅샷
하는 것 UPSERT 한 번 + 커서 저장
저장할 상태 커서 문자열 하나 (스냅샷은 as_of별로 append)
호출 시점 언제든 — 5분 뒤든 3일 뒤든 동일

피드는 "직전 호출 이후에 바뀐 일별 집계 행"을 돌려줍니다. 기간을 계산하거나 무엇을 받았는지 기억할 필요가 없습니다. 받은 next_cursor를 저장했다가 다음 호출에 그대로 보내면 끝입니다.

  • 건별 결제가 아니라 일별 합계가 옵니다. 매출·환불·순매출은 결제서버가 이미 계산해서 보내므로 모니터링은 더하기만 합니다.
  • 같은 행이 다시 올 수 있습니다. 과거 날짜의 결제가 뒤늦게 승인되거나 환불이 생기면 그 날짜 행이 재전송됩니다. 4개 키로 덮어쓰면(UPSERT) 됩니다. 중복 수신은 무해합니다.
  • 커서는 열어보지 마세요. 서버가 서명한 불투명 토큰입니다. 변조하면 422입니다. 결제서버가 형식을 바꿀 수 있고, 바꾸면 422로 알려줍니다(→ 커서 지우고 처음부터).

22.2 준비물

결제서버 운영자(어드민)에게 아래를 요청해 받으세요.

항목 설명 요청 방법
결제서버 주소 예: https://pay.example.com 운영자에게 확인
API 키 요청 헤더 x-service-key에 넣는 평문 키 어드민 > 서비스 관리에서 모니터링용 서비스를 등록하고 상세 화면의 "모니터링 전용 키" 카드를 ON 한 뒤 발급된 키·시크릿을 전달받음
HMAC 시크릿 요청 서명에 쓰는 비밀 문자열 (위와 동일)
허용 IP 등록 모니터링 서버의 공인 IP(아웃바운드) 운영자에게 전달해 서비스의 허용 IP 목록에 등록 요청. 필수 — 서명이 쿼리 파라미터를 보호하지 않아 IP가 실질적인 2차 방어선
💬

허용 IP가 비어 있으면 원격 호출은 403입니다. 같은 서버(루프백)에서만 통과합니다. 운영에서는 IP 없이 키를 켤 수도 없습니다. 그리고 /api/v1/metrics/*에는 분당 30회 한도가 있습니다 — 정상 사용에는 충분하고, 초과하면 429입니다.

이 키로는 결제·구독 API를 호출할 수 없습니다. 모니터링 전용 키는 /api/v1/metrics/*만 허용됩니다. 다른 API를 호출하면 403 FORBIDDEN입니다. 반대로 일반 서비스 키로는 이 피드를 호출할 수 없습니다. 의도된 분리이니 우회하지 마세요.

키·시크릿은 코드에 박지 말고 시크릿 저장소/환경변수로 관리하세요. 유출 시 운영자에게 키 재발급을 요청하면 기존 키는 즉시 무효가 됩니다.

22.3 인증 — 요청 서명

모든 요청에 헤더 4개를 붙입니다. 서명은 HMAC-SHA256, 결과는 소문자 hex입니다.

x-service-key : <API 키>
x-timestamp   : <현재 unix epoch 초, 문자열>      # 서버 시각과 ±300초 이내
x-nonce       : <요청마다 새로 만든 난수 문자열>   # 600초 안에 같은 값 재사용 불가
x-signature   : <HMAC-SHA256(secret, canonical) hex>

# canonical — 다섯 요소를 개행(\n)으로 연결. 마지막은 요청 본문의 SHA-256 hex.
# GET 이라 본문이 비어 있으므로 항상 아래 고정값이다.
GET
/api/v1/metrics/sync
<x-timestamp>
<x-nonce>
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

서명에 관해 틀리기 쉬운 네 가지

  1. 경로는 /api/v1/metrics/sync까지만. 쿼리스트링(?cursor=…)은 서명에 넣지 않습니다. 넣으면 401입니다.
  2. nonce는 요청마다 새로. 페이지를 이어받을 때도, 재시도할 때도 새 nonce·새 timestamp를 만드세요. 같은 헤더를 그대로 되쏘면 401입니다.
  3. 서버 시각과 5분 이상 어긋나면 401. 모니터링 서버의 NTP를 확인하세요.
  4. 메서드는 대문자 GET. 소문자로 서명하면 불일치입니다.

22.4 API 호출과 응답

GET /api/v1/metrics/sync?cursor=<직전 응답의 next_cursor>&limit=500
파라미터 필수 기본 설명
cursor 선택 없음 직전 응답의 next_cursor. 생략하면 처음부터(전체 히스토리)
limit 선택 500 1회 최대 행 수. 1~2000. 초과하면 422

응답 예시:

{
  "rows": [
    {
      "date": "2026-09-01",                      // KST 일자 (YYYY-MM-DD)
      "service_id": "8f2c4a1e-…",                // ★ 키
      "service_name": "chat-prod",               // 서비스구독명 (등록 단위)
      "real_service_name": "메디솔브 챗",          // 서비스명 — 손익 집계 단위
      "plan_id": "a71e9c02-…",                   // ★ 키 — 단건결제는 null
      "plan_name": "프로",
      "billing_cycle": "MONTH",                  // YEAR | MONTH | WEEK | DAY | MINUTE | null
      "mrr_factor": 1.0,                         // 가격 → 월환산 계수 (null 가능)
      "kind": "SUBSCRIPTION",                    // ★ 키 — SUBSCRIPTION | ONE_OFF
      "gross_amount": 1400000,                   // 승인 원금 합 (KRW 정수)
      "refund_amount": 29000,                    // 그날 발생한 환불 합
      "cancel_fee_amount": 0,                    // 그날 취소에서 보유한 수수료 합
      "net_amount": 1371000,                     // gross − refund (음수 가능)
      "currency": "KRW",
      "payment_count": 48,                       // 승인 건수
      "failed_count": 1,                         // 실패 건수
      "new_subscriptions": 5,                    // ── 구독 일별 흐름 (SUBSCRIPTION 행만, 단건은 0) ──
      "canceled_subscriptions": 1,               // 사용자·운영자 해지
      "suspended_subscriptions": 0,              // 결제 실패 정지 (대시보드 '구독 취소' = canceled + suspended)
      "expired_subscriptions": 0,
      "resumed_subscriptions": 0,
      "plan_changed_subscriptions": 1,
      "renewed_subscriptions": 40,               // 자동 갱신 성공
      "revision": "2026-09-02T11:20:44+09:00"    // 이 행이 마지막으로 바뀐 시각
    }
  ],
  "next_cursor": "eyJ2IjoxLCJyIjoi…",            // 그대로 저장 → 다음 호출에 전달
  "has_more": false,                             // true 면 즉시 이어서 호출
  "generated_at": "2026-09-03T18:10:04+09:00"
}

행의 키는 (date, service_id, plan_id, kind) 넷입니다. 이 넷이 같으면 같은 행이고, 나머지 필드는 전부 덮어씁니다. rows가 비어 있으면 next_cursor는 보낸 커서와 같은 값입니다 — 그대로 다시 저장해도 됩니다.

22.5 저장 — 테이블과 UPSERT

PostgreSQL 기준입니다. 다른 DB면 같은 구조로 옮기세요. 핵심은 4개 키 유니크 + 덮어쓰기이고, 단건결제의 plan_id가 NULL이라 유니크 제약에 그대로 못 넣으므로 plan_key 생성 컬럼을 둡니다.

CREATE TABLE revenue_daily (
  date              DATE        NOT NULL,
  service_id        UUID        NOT NULL,
  plan_id           UUID,                              -- 단건결제 NULL
  plan_key          UUID GENERATED ALWAYS AS
                      (COALESCE(plan_id, '00000000-0000-0000-0000-000000000000')) STORED,
  kind              TEXT        NOT NULL,              -- SUBSCRIPTION | ONE_OFF
  service_name      TEXT        NOT NULL,
  real_service_name TEXT        NOT NULL,
  plan_name         TEXT,
  billing_cycle     TEXT,
  mrr_factor        NUMERIC(12,6),
  gross_amount      BIGINT      NOT NULL,
  refund_amount     BIGINT      NOT NULL,
  cancel_fee_amount BIGINT      NOT NULL,
  net_amount        BIGINT      NOT NULL,
  payment_count     INTEGER     NOT NULL,
  failed_count      INTEGER     NOT NULL,
  new_subscriptions          INTEGER NOT NULL DEFAULT 0,   -- 구독 흐름 (SUBSCRIPTION 행만)
  canceled_subscriptions     INTEGER NOT NULL DEFAULT 0,
  suspended_subscriptions    INTEGER NOT NULL DEFAULT 0,
  expired_subscriptions      INTEGER NOT NULL DEFAULT 0,
  resumed_subscriptions      INTEGER NOT NULL DEFAULT 0,
  plan_changed_subscriptions INTEGER NOT NULL DEFAULT 0,
  renewed_subscriptions      INTEGER NOT NULL DEFAULT 0,
  revision          TIMESTAMPTZ NOT NULL,
  synced_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (date, service_id, plan_key, kind)
);
CREATE INDEX ix_revenue_daily_service_date ON revenue_daily (real_service_name, date);

-- 커서 저장 — 행 하나
CREATE TABLE sync_state (
  source     TEXT PRIMARY KEY,            -- 'payment_server'
  cursor     TEXT,                        -- NULL = 아직 한 번도 못 받음 = 처음부터
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO sync_state (source, cursor) VALUES ('payment_server', NULL);

-- 구독 현황 스냅샷 — 호출 시각(as_of)별로 쌓는다(덮어쓰지 않음)
CREATE TABLE subscription_snapshot (
  as_of             TIMESTAMPTZ NOT NULL,
  service_id        UUID        NOT NULL,
  plan_id           UUID        NOT NULL,
  service_name      TEXT NOT NULL, real_service_name TEXT NOT NULL, plan_name TEXT,
  billing_cycle     TEXT, price BIGINT NOT NULL, mrr_factor NUMERIC(12,6),
  total             INTEGER NOT NULL, open INTEGER NOT NULL,
  trial INTEGER NOT NULL, active INTEGER NOT NULL, past_due INTEGER NOT NULL,
  suspended INTEGER NOT NULL, canceled INTEGER NOT NULL, extended INTEGER NOT NULL,
  expired INTEGER NOT NULL,
  mrr_krw           BIGINT NOT NULL,
  PRIMARY KEY (as_of, service_id, plan_id)
);

UPSERT:

INSERT INTO revenue_daily
  (date, service_id, plan_id, kind, service_name, real_service_name, plan_name,
   billing_cycle, mrr_factor, gross_amount, refund_amount, cancel_fee_amount,
   net_amount, payment_count, failed_count,
   new_subscriptions, canceled_subscriptions, suspended_subscriptions, expired_subscriptions,
   resumed_subscriptions, plan_changed_subscriptions, renewed_subscriptions, revision)
VALUES (%(date)s, %(service_id)s, %(plan_id)s, %(kind)s, %(service_name)s,
        %(real_service_name)s, %(plan_name)s, %(billing_cycle)s, %(mrr_factor)s,
        %(gross_amount)s, %(refund_amount)s, %(cancel_fee_amount)s, %(net_amount)s,
        %(payment_count)s, %(failed_count)s,
        %(new_subscriptions)s, %(canceled_subscriptions)s, %(suspended_subscriptions)s,
        %(expired_subscriptions)s, %(resumed_subscriptions)s, %(plan_changed_subscriptions)s,
        %(renewed_subscriptions)s, %(revision)s)
ON CONFLICT (date, service_id, plan_key, kind) DO UPDATE SET
  service_name = EXCLUDED.service_name, real_service_name = EXCLUDED.real_service_name,
  plan_name = EXCLUDED.plan_name, billing_cycle = EXCLUDED.billing_cycle,
  mrr_factor = EXCLUDED.mrr_factor,
  gross_amount = EXCLUDED.gross_amount, refund_amount = EXCLUDED.refund_amount,
  cancel_fee_amount = EXCLUDED.cancel_fee_amount, net_amount = EXCLUDED.net_amount,
  payment_count = EXCLUDED.payment_count, failed_count = EXCLUDED.failed_count,
  new_subscriptions = EXCLUDED.new_subscriptions, canceled_subscriptions = EXCLUDED.canceled_subscriptions,
  suspended_subscriptions = EXCLUDED.suspended_subscriptions, expired_subscriptions = EXCLUDED.expired_subscriptions,
  resumed_subscriptions = EXCLUDED.resumed_subscriptions, plan_changed_subscriptions = EXCLUDED.plan_changed_subscriptions,
  renewed_subscriptions = EXCLUDED.renewed_subscriptions,
  revision = EXCLUDED.revision, synced_at = now();

응답의 필드명이 컬럼명과 같으므로 row dict를 그대로 파라미터로 넘길 수 있습니다(currency는 항상 KRW라 저장하지 않아도 됩니다).

22.6 동기화 루프 — 복사해서 쓰기

Python + requests + psycopg 기준의 완전한 예시입니다. 다른 언어여도 구조는 같습니다. 아래 세 규칙만 지키면 됩니다.

  • UPSERT와 커서 저장은 같은 트랜잭션. 커서만 먼저 저장되고 UPSERT가 실패하면 그 배치가 영영 사라집니다.
  • 페이지마다 커밋. 첫 적재 중에 죽어도 끊긴 페이지부터 재개됩니다.
  • 동시 실행 금지(락). 정시 실행과 수동 버튼이 겹쳐 커서를 동시에 갱신하면 유실됩니다.
import hashlib, hmac, os, random, time, uuid
import requests

BASE_URL = "https://pay.example.com"          # 결제서버 주소 (운영자에게 확인)
PATH     = "/api/v1/metrics/sync"
API_KEY  = os.environ["PAYMENT_API_KEY"]       # x-service-key
SECRET   = os.environ["PAYMENT_HMAC_SECRET"]
EMPTY_BODY_SHA256 = hashlib.sha256(b"").hexdigest()


class Retryable(Exception): ...            # 429 · 503 · 네트워크 오류
class Fatal(Exception): ...                # 401 · 403 — 재시도 금지, 즉시 알림
class BadCursor(Exception): ...            # 422 — 커서 폐기 후 전체 재적재


def signed_headers() -> dict:
    """요청마다 새 timestamp·nonce 로 서명. 서명 대상은 path 까지(쿼리 제외)."""
    ts, nonce = str(int(time.time())), uuid.uuid4().hex
    canonical = "\n".join(["GET", PATH, ts, nonce, EMPTY_BODY_SHA256])
    sig = hmac.new(SECRET.encode(), canonical.encode(), hashlib.sha256).hexdigest()
    return {"x-service-key": API_KEY, "x-timestamp": ts,
            "x-nonce": nonce, "x-signature": sig}


def fetch_page(cursor: str | None, limit: int = 500) -> dict:
    params = {"limit": limit}
    if cursor:
        params["cursor"] = cursor
    try:
        r = requests.get(BASE_URL + PATH, params=params,
                         headers=signed_headers(), timeout=30)
    except requests.RequestException as exc:
        raise Retryable(str(exc)) from exc
    if r.status_code in (429, 503):
        raise Retryable(r.text)
    if r.status_code in (401, 403):
        raise Fatal(r.text)
    if r.status_code == 422:
        raise BadCursor(r.text)
    r.raise_for_status()
    return r.json()


def fetch_page_with_backoff(cursor, attempts: int = 5) -> dict:
    for i in range(attempts):
        try:
            return fetch_page(cursor)          # 재시도마다 서명이 새로 만들어진다
        except Retryable:
            if i == attempts - 1:
                raise
            time.sleep(min(60, 2 ** i) + random.random())


def sync_from_payment_server(db, lock) -> int:
    """정시 실행과 수동 버튼이 공통으로 부르는 유일한 진입점. 갱신된 행 수를 반환."""
    with lock.acquire("payment_sync", timeout=0):      # ★ 이미 돌고 있으면 즉시 포기
        cursor = db.scalar("SELECT cursor FROM sync_state WHERE source='payment_server'")
        total = 0
        while True:
            try:
                page = fetch_page_with_backoff(cursor)
            except BadCursor:
                # 결제서버가 커서 형식을 바꿨거나 값이 손상됨 → 처음부터 다시
                db.execute("UPDATE sync_state SET cursor=NULL, updated_at=now() "
                           "WHERE source='payment_server'")
                db.commit()
                cursor = None
                continue

            with db.transaction():                        # ★ UPSERT + 커서 = 한 트랜잭션
                for row in page["rows"]:
                    db.execute(UPSERT_SQL, row)             # 22.5 의 UPSERT
                db.execute("UPDATE sync_state SET cursor=%s, updated_at=now() "
                           "WHERE source='payment_server'", (page["next_cursor"],))
            total += len(page["rows"])                        # ★ 페이지마다 커밋
            cursor = page["next_cursor"]
            if not page["has_more"]:
                break
        # ★ 매 실행 1회 — 구독 현황 스냅샷. 서명은 fetch_page 와 같은 방식(경로만 다름)
        snap = fetch_json("/api/v1/metrics/subscriptions")
        with db.transaction():
            for row in snap["rows"]:
                db.execute(SNAPSHOT_INSERT_SQL, {
                    "as_of": snap["as_of"],
                    **{k: v for k, v in row.items() if k != "by_status"},
                    **{status.lower(): n for status, n in row["by_status"].items()},
                })
        return total

SNAPSHOT_INSERT_SQL은 §5의 subscription_snapshot 테이블에 INSERT … ON CONFLICT (as_of, service_id, plan_id) DO NOTHING입니다. 스냅샷은 시점 값이라 덮어쓰지 않고 시각별로 쌓습니다.

💬

첫 적재: sync_state.cursor가 NULL이면 위 루프가 자동으로 처음부터 받습니다. 전체 기간을 훑으므로 결제 트래픽이 적은 새벽에 처음 돌리세요. 중간에 끊겨도 커서가 페이지마다 저장돼 있어 다시 실행하면 이어집니다. 1년치가 수천~수만 행이라 limit=500이면 수십 번의 호출로 끝납니다.

재적재(모니터링 쪽 집계 로직을 바꿨거나 데이터를 날렸을 때)도 같습니다 — 커서를 NULL로 지우고 실행하면 됩니다. UPSERT라 기존 행과 충돌하지 않습니다.

22.7 스케줄과 수동 새로고침

정시 실행과 수동 버튼은 완전히 같은 함수(sync_from_payment_server)를 부릅니다. 범위를 정할 게 없습니다.

실행 결제서버 수익 AI · 클라우드 지출 비고
00:10 KST sync_from_payment_server() 실행 지출 데이터는 프로바이더가 일 단위로 확정하므로 하루 한 번
06:10 · 12:10 · 18:10 sync_from_payment_server() —
수동 버튼 sync_from_payment_server() — 아래 요구사항

정각이 아니라 :10인 이유는 자정 직전 결제가 하루 첫 호출에 온전히 담기게 하려는 것입니다. 놓쳐도 다음 호출에 딸려오니 필수는 아닙니다.

수동 새로고침 버튼 요구사항

  • 락 — 정시 실행 중이면 버튼은 "수집 중" 상태만 보여주고 새로 시작하지 않습니다. 락 없이 겹치면 커서가 유실됩니다.
  • 쿨다운 60초 — 마지막 실행 후 60초 이내면 "방금 갱신됨 (12:14)"만 표시하고 실행하지 않습니다. 연타 방어.
  • 비동기 실행 — 버튼은 작업 ID를 즉시 받고 상태를 폴링합니다. 동기 처리하면 첫 적재처럼 여러 페이지를 돌 때 HTTP 타임아웃이 납니다.
  • 상태 표시 — 대기 / 수집 중 / 완료(갱신 시각 · 행 수) / 실패(사유). 진행 중엔 버튼 비활성.
  • 실행 이력 — 누가 언제 눌렀고 몇 행이 갱신됐는지 기록.
💬

화면에 신선도를 반드시 표기할 것. 6시간 주기면 화면 숫자는 최대 6시간 전 것입니다. 표기가 없으면 "결제가 됐는데 왜 안 보이냐"는 문의가 반복됩니다. 모든 손익 화면 상단에 마지막 갱신 시각 · 다음 갱신 예정을 고정 노출하고, 그 옆에 새로고침 버튼을 두세요.

그리고 손익 화면의 기본 기간은 "어제까지"로 잡으세요. 수익은 6시간 지연이지만 지출(AI·클라우드)은 하루 지연이라, 오늘을 포함하면 수익만 있고 지출이 0인 행이 나와 마진율이 비정상적으로 높게 보입니다.

22.8 에러 처리

오류 응답은 항상 {"error": {"code": "...", "message": "..."}} 형식입니다.

HTTP code 원인 모니터링이 할 일
401 UNAUTHORIZED 키 불일치 · 서명 오류 · 타임스탬프 ±300초 초과 · nonce 재사용 재시도 금지, 즉시 알림. 서명 규칙(22.3)·NTP·키 확인
403 FORBIDDEN 허용 IP 아님 · 모니터링 전용 키가 아님 재시도 금지, 즉시 알림. 운영자에게 IP 등록·키 스코프 확인 요청
422 VALIDATION_ERROR 커서 손상·구버전 · limit 범위 밖 커서를 NULL로 지우고 전체 재적재
429 RATE_LIMITED 분당 120회 초과 지수 백오프 후 재시도
503 SERVER_DISABLED 결제서버 킬스위치(운영자가 서버 비활성화) 지수 백오프 후 재시도. 장시간 지속되면 알림
5xx INTERNAL_ERROR 등 서버 오류 · 프록시 502/504(JSON 아닐 수 있음) 지수 백오프 후 재시도

실패하면 커서를 갱신하지 않고 다음 실행에서 그대로 재사용하면 끊긴 지점부터 다시 흐릅니다. 실패한 페이지를 따로 기억할 필요가 없습니다. 재시도할 때는 반드시 서명을 새로 만드세요(22.6의 fetch_page는 호출마다 새로 만듭니다).

22.9 데이터 해석 규칙

숫자를 화면에 올리기 전에 알아야 할 규칙입니다. 결제서버 어드민의 정산 화면과 같은 규칙이라, 두 화면의 숫자가 일치해야 정상입니다.

귀속 — 어느 날짜 행에 잡히는가

필드 날짜 기준 의미
gross_amount · payment_count 승인일 승인된 원금 합. 나중에 취소돼도 이 값은 안 줄어듦
refund_amount 취소일 그날 실제로 돌려준 금액
cancel_fee_amount 취소일 그날 취소에서 우리가 보유한 수수료(참고값 — net_amount에 이미 반영)
net_amount — gross − refund. 순매출. 이걸 수익으로 쓰세요
failed_count 요청일 실패 건수. 운영 경보용이지 금액 아님

예: 8월 15일 결제 30,000원을 9월 2일에 18,000원 환불하면 → 8/15 행 gross 30000(불변), 9/2 행 refund 18000, net −18000. 8월 순매출이 소급 변경되지 않으므로 월 마감이 가능합니다.

구독 지표 두 종류를 섞지 말 것

  • 흐름(피드 컬럼): new/canceled/suspended/expired/resumed/plan_changed/renewed_subscriptions는 "그날 몇 건 일어났나"입니다. 기간으로 합산해도 됩니다. 어드민 대시보드의 "구독 취소" = canceled + suspended.
  • 재고(스냅샷): total/open/by_status/mrr_krw는 "그 시각에 몇 개인가"입니다. 합산하면 안 되고 시점별로 보관해 추세를 봅니다. 서비스별 값은 real_service_name, 서비스구독별은 service_id로 묶습니다.

반드시 지킬 것

  • net_amount는 음수가 될 수 있습니다. 매출 없이 환불만 있는 날이 그렇습니다. 차트가 음수를 그릴 수 있어야 하고, ABS()나 0으로 자르면 안 됩니다.
  • 단건결제(kind=ONE_OFF)는 plan_id가 null입니다. 요금제별 화면에서 null 행을 버리면 매출이 새는 것처럼 보입니다. "요금제 없음(단건)" 묶음으로 표시하세요.
  • 키는 id, 이름은 표시용. 요금제명·서비스구독명이 바뀌어도 과거 행은 재전송되지 않으므로, 이름으로 GROUP BY하면 같은 요금제가 둘로 갈라집니다. plan_id·service_id로 묶고 이름은 최신 행에서 가져오세요(22.10).
  • 서비스별 손익은 real_service_name 단위. 실 서비스 하나에 서비스구독(service_id)이 여러 개일 수 있습니다. 인프라 비용이 붙는 단위가 실 서비스이므로 손익은 그 레벨에서 봅니다. "서비스구독별"은 service_id로 한 단계 더 쪼갠 것입니다.
  • 구독 결제의 요금제는 구독의 현재 요금제입니다. 요금제 변경 이력은 없습니다. 정산 화면과 같은 방식입니다.
  • 활성 구독 수·MRR은 이 피드에 없습니다. mrr_factor는 참고용 계수일 뿐이고, 구독 현황이 필요하면 별도 요청 사항입니다.
  • 금액은 전부 KRW 정수입니다. 소수점·환율 처리는 없습니다.

22.10 롤업 쿼리 예시

일별 행이 있으니 나머지는 전부 GROUP BY입니다.

-- 서비스(실 서비스)별 · 월별 순매출
SELECT date_trunc('month', date)::date AS month, real_service_name,
       SUM(net_amount) AS net_revenue, SUM(gross_amount) AS gross,
       SUM(refund_amount) AS refund, SUM(payment_count) AS payments
FROM revenue_daily
GROUP BY 1, 2 ORDER BY 1, 2;

-- 주별(월요일 시작) · 연도별은 date_trunc('week' | 'year', date)

-- 서비스구독별 (service_id) — 이름은 최신 행에서
SELECT r.service_id, latest.service_name, date_trunc('month', r.date)::date AS month,
       SUM(r.net_amount)
FROM revenue_daily r
JOIN LATERAL (
  SELECT service_name FROM revenue_daily x
  WHERE x.service_id = r.service_id ORDER BY x.revision DESC LIMIT 1
) latest ON true
GROUP BY 1, 2, 3 ORDER BY 3, 1;

-- 요금제별 (단건결제는 '요금제 없음' 으로)
SELECT COALESCE(plan_name, '요금제 없음(단건)') AS plan, kind, SUM(net_amount)
FROM revenue_daily WHERE date >= '2026-09-01' AND date < '2026-10-01'
GROUP BY plan_id, plan_name, kind;

-- 구독 vs 일반결제 비중
SELECT kind, SUM(net_amount) FROM revenue_daily
WHERE date >= date_trunc('month', current_date) GROUP BY kind;

22.11 검수와 체크리스트

첫 적재 후 검수 — 어드민 정산 화면과 대조

결제서버 어드민 > 정산에서 지난달의 서비스별 순매출을 보고, 아래 결과와 비교하세요. 귀속 규칙이 같으므로 원 단위까지 일치해야 합니다. 다르면 UPSERT 키나 롤업 쿼리를 먼저 의심하세요.

SELECT service_name, SUM(gross_amount) AS 총매출, SUM(refund_amount) AS 환불,
       SUM(net_amount) AS 순매출, SUM(payment_count) AS 건수
FROM revenue_daily
WHERE date >= date_trunc('month', current_date - interval '1 month')
  AND date <  date_trunc('month', current_date)
GROUP BY service_name ORDER BY 순매출 DESC;

구현 체크리스트

  • [ ] API 키·HMAC 시크릿을 환경변수/시크릿 저장소에 두었다 (코드에 없음)
  • [ ] 모니터링 서버 공인 IP를 운영자에게 전달해 허용 IP에 등록했다
  • [ ] 서명: 메서드 대문자 GET, 경로 /api/v1/metrics/sync까지, 빈 본문 해시 고정값
  • [ ] nonce·timestamp를 요청마다(재시도·페이지 포함) 새로 만든다
  • [ ] revenue_daily 4개 키 유니크(plan_key 생성 컬럼) + UPSERT
  • [ ] UPSERT와 커서 저장이 한 트랜잭션, 페이지마다 커밋
  • [ ] 정시 실행과 수동 버튼이 같은 함수를 부르고 락으로 동시 실행을 막는다
  • [ ] 401·403은 재시도 없이 알림, 422는 커서 폐기 후 재적재, 429·503·5xx는 백오프
  • [ ] 첫 적재를 새벽에 돌리고 어드민 정산 화면과 지난달 순매출을 대조했다
  • [ ] 화면에 마지막 갱신 시각·다음 갱신 예정을 표기하고, 손익 기본 기간은 "어제까지"
  • [ ] net_amount 음수와 plan_id null(단건)을 버리지 않는다

22.12 건별 피드 — 고객(이메일)별 통계가 필요할 때 (2026-09-09 추가)

일별 집계만으로는 "어느 병원이 어떤 구독으로 얼마를 냈는지"를 알 수 없습니다. 그래서 같은 키·같은 서명·같은 커서 규칙으로 건별 피드 두 개를 더 제공합니다. 명세는 13.10.4 건별 결제·13.10.5 건별 구독입니다.

피드 경로 행 키(UPSERT) 서명 대상 경로
건별 결제 GET /api/v1/metrics/payments 결제 1건(DONE·CANCELED·FAILED) payment_id /api/v1/metrics/payments
건별 구독 GET /api/v1/metrics/subscription-list 구독 1건(EXPIRED 포함) subscription_id /api/v1/metrics/subscription-list

22.6의 동기화 루프를 그대로 복사해 경로와 저장 테이블만 바꾸면 됩니다. 커서는 피드마다 따로 저장하세요(sync_state에 source 행을 둘 더 — 예: payment_server_payments, payment_server_subscriptions). 커서 토큰에 피드 이름이 들어 있어 서로 바꿔 넣으면 422가 납니다.

개인정보(2026-09-10 변경) — 두 피드의 external_user_id(이메일)는 기본 null입니다. 대신 서비스별 키드 해시 user_ref(24자)가 내려갑니다.

  • user_ref는 같은 고객이면 결제·구독 피드에서 같은 값이라 고객별 매출·이력·구독 상태를 묶는 키로 쓰면 됩니다. 서비스가 다르면 값이 달라 교차 추적은 안 되고, 원문은 복원할 수 없습니다.
  • 이메일 원문이 꼭 필요하면 결제서버 운영자에게 요청해 어드민의 "고객 이메일 원문 포함"을 켜야 합니다. 그때부터 external_user_id가 채워지며, 받는 쪽은 저장 시 암호화·화면 마스킹(ab***@hospital.com)·관리자 전용 내려받기를 지켜 주세요.
  • 건별 피드 호출은 결제서버 감사 로그에 남습니다(피드·행수·PII 여부·호출 IP).

대조 — 건별 결제 행을 approved_at의 KST 일자 × service_id × plan_id × kind로 더하면 일별 집계 행의 gross_amount·payment_count와, FAILED 행을 requested_at 일자로 세면 failed_count와 같아야 합니다. 동기화가 끝날 때마다 이 합계를 비교해 다르면 경고를 남기세요(환불은 취소 회차 원장 기준이라 비교 대상이 아닙니다).

수집 순서 — 한 실행 안에서 일별 집계 → 건별 결제 → 건별 구독 → 스냅샷 순으로 부르면 대조가 같은 시점 기준이 됩니다.

문의: 서명이 계속 401이면 22.3의 canonical 문자열을 로그로 찍어 결제서버 운영자에게 전달하세요(시크릿은 제외).


🔗

함께 보기: 결제서버 쪽 명세 → 13.10 모니터링 동기화 API · 서비스 등록·키 발급 → 서비스 관리