왓섭
경력근거: 사용자가 놓치고 있던 구독과 고정지출을 자동으로 발견하고, 자신의 소비를 정확히 이해하며
왓섭은 마이데이터로 카드 결제 내역을 받아 사용자가 놓치고 있던 구독과 고정지출을 발견하는 회사입니다. 2026년 6월 정부 생활밀착 서비스 개선방안에서 구독 내역 통합 조회가 왓섭으로 구현될 예정이라고 소개됐고, 누적 35만 명이 쓴 구독 등록 기록과 카드 결제 명세가 한자리에 모이는 곳이 왓섭입니다. 결제 명세만 보면 가맹점 표기가 무엇의 결제인지 말해주지 않고, 구독 등록만 보면 해지 뒤에 결제가 실제로 멈췄는지 알 수 없습니다. 두 쪽이 같이 있어야 해지 뒤 결제와 미등록 정기결제가 갈리므로 이 도구는 왓섭의 데이터 자리에서만 성립합니다. 마이데이터 사업자 인허가 실무와 금융사 API 연동 경험은 주장하지 않습니다.
누적 35만 명이 쓴 서비스가 구독과 고정지출을 자동으로 발견하는 일을 내세웁니다. 결제 내역과 구독 등록을 견주는 계산이 곧 제품의 첫 화면입니다.
Python 과 FastAPI 로 금융·결제 데이터를 처리하면서 웹과 앱, 데이터 영역의 기능까지 직접 구현하는 자리입니다. 규칙과 API 와 화면을 한 사람이 붙이는 산출물이 이 공고에 맞습니다.
LLM 으로 금융 데이터를 풍성하게 만들고 분류와 검증을 자동화하는 일을 주요업무로 적었습니다. 가맹점 표기를 서비스 이름으로 잇는 자리가 그 일과 바로 겹칩니다.
왓섭 운영팀이 마이데이터로 들어온 카드 결제 내역과 앱에 등록된 구독을 견줘, 해지한 뒤에도 계속 나가는 결제와 등록되지 않은 정기결제를 사유별로 보여주는 관리자 페이지입니다. 결제 명세에 찍힌 가맹점 표기를 정리해 어느 구독의 결제인지 잇고, 결제 사이 간격과 금액 변화를 내어 다섯 가지로 나눠 보여줍니다.
왓섭 운영팀에서 구독 카탈로그를 관리하는 담당자입니다. 사용자가 해지했다고 표시한 서비스인데 결제가 계속 잡히면 문의가 들어오고, 그때마다 결제 명세의 가맹점명 문자열을 눈으로 맞춰 어느 서비스인지 짚는 자리에 있습니다.
결제가 구독 등록과 어긋나는 자리는 다섯입니다. 등록대로 빠져나가거나, 해지한 뒤에도 계속 나가거나, 등록되지 않은 서비스가 매달 나가거나, 같은 서비스인데 금액이 올랐거나, 한 번만 결제돼 주기가 서지 않습니다. 다섯 모두 결제 내역과 구독 등록을 사용자와 가맹점 표기로 묶어 날짜와 금액을 견주면 규칙으로 갈립니다. 사용자가 이 다섯을 스스로 가르지 못하는 이유는 결제 명세에 찍히는 가맹점 표기가 서비스 이름과 달라서입니다.
카드 결제 내역과 구독 등록 JSON 두 장을 올리면 사용자와 가맹점으로 묶어 다섯 가지 판정으로 나눠 보여주는 화면이 돕니다. 각 행에 결제 금액과 해지일, 결제 사이 간격이 함께 뜹니다. 가맹점 표기 정리는 규칙과 별칭 사전으로 시작하고 모델은 여기서 쓰지 않습니다.
1주차 판정에 해지 뒤 결제 건수와 그 합계 금액을 얹어 사용자별로 보여줍니다. 어느 구독에도 잇지 못한 가맹점 표기는 클로드 Sonnet 이 서비스 후보를 내고 담당자가 승인한 것만 별칭 사전에 들어갑니다. 2주차부터 대조 API 를 AWS Lambda 와 API Gateway 로 배포해 운영합니다.
2주차 결과를 EventBridge 예약으로 매일 다시 계산하고, 지난 회차에 없던 판정만 골라 담당자에게 알립니다. CloudWatch 지표로 사유별 건수가 갑자기 늘어난 날을 잡습니다.
운영 담당자가 카드 결제 내역과 구독 등록 두 장을 올리면 해지 뒤에도 나가는 결제가 사유별로 뜹니다.
다섯 판정이 각각 최소 한 건씩 나오는 더미 200묶음에서 판정이 갈리고, 각 행에 결제 금액과 해지일, 결제 사이 간격이 함께 뜹니다.
일부러 만든 결측과 중복, 경계값, 표기흔들림, 미매핑 입력에서도 판정 결과 형식이 안 깨지고 그 행이 사유로 돌아갑니다.
숫자 앞에 조건을 하나 답니다. 200묶음은 전부 합성 결제 내역이고 채점자도 저라서, 해지 뒤 결제를 실제로 얼마나 잡아내는지는 왓섭의 마이데이터와 사용자 문의가 붙어야 나옵니다. 이 포트폴리오가 맡는 몫은 그 측정을 바로 시작할 수 있는 표기 정리 사전과 채점 러너를 준비해 두는 것까지입니다.
이 수치가 성능을 말해 주지는 않습니다. 결제 내역도 정답도 제가 지어낸 것이라 자가채점에 지나지 않으며, 면접에서는 어디까지가 돌아가는 데모이고 어디부터가 실데이터 검증인지를 먼저 밝힙니다.
결제 건마다 정답을 손으로 달아 둔 더미라 판정이 들어맞는 것은 예정된 결과입니다. 금액이 비어 있는 승인 건과 같은 결제 번호가 두 번 들어온 행, 반복으로 보는 최소 기준인 세 번에 딱 걸리는 경계, 별표 위치가 다른 표기흔들림 쌍, 어느 구독 표기에도 걸리지 않는 미매핑까지 넣어 둔 이유가 거기 있습니다.
가맹점 표기가 비슷하다고 같은 서비스라고 단정하지 못합니다. 결제대행사 표기인 PADDLE.NET 은 여러 서비스가 같이 쓰므로 도구는 후보를 보여주고 최종 연결은 담당자가 정합니다.
금액이 올랐다고 요금 인상이라고 볼 수 없습니다. 이용권을 바꾸거나 가족 요금제로 옮긴 경우도 같은 모양이라 도구는 직전 결제와 이번 결제 금액을 같은 행에 보여주고 이유는 담당자가 확인합니다.
실제 도입에서는 마이데이터로 들어오는 결제 내역이 카드사마다 표기 규칙이 다르고 승인 취소와 부분 환불 흐름이 붙습니다. 그때 판정 enum 과 표기 정리 규칙을 다시 잡아야 합니다.
① 인라인 샘플 · ② seed.json 다운로드 · ③ 증강 프롬프트. 세 층 모두 동일한 schema 에서 파생.
마이데이터로 들어온 카드 결제 내역입니다. 누가 어느 가맹점 표기로 얼마를 언제 냈는지가 한 행입니다. 결제 사이 간격과 금액 변화를 여기서 냅니다. 가맹점 표기는 공백과 마침표·별표 같은 기호, ㈜·(주)·주식회사 같은 법인격 표기만 걷어내고 한글과 영문, 숫자는 그대로 남긴 키로 견줍니다. 영문만 대문자로 올리므로 한글 상호도 키가 비지 않습니다. 금액 미확정과 같은 결제 번호 중복, 무료 체험 0원 결제, 같은 서비스의 다른 가맹점 표기, 법인격 표기가 붙은 한글 상호와 지점명이 붙은 한글 표기를 일부러 섞었습니다.
| tx_no | user_id | merchant_raw | amount | paid_at | card_alias |
|---|---|---|---|---|---|
| TX-20260617-0001 | U001 | NETFLIX.COM | 13,500 | 2026-06-17T02:11:00+09:00 | 신한 체크 |
| TX-20260717-0002 | U001 | NETFLIX.COM | 13,500 | 2026-07-17T02:09:00+09:00 | 신한 체크 |
| TX-20260817-0003 | U001 | NETFLIX.COM | 13,500 | 2026-08-17T02:14:00+09:00 | 신한 체크 |
| TX-20260720-0004 | U001 | WATCHA-PLAY | 7,900 | 2026-07-20T04:02:00+09:00 | 신한 체크 |
| TX-20260819-0005 | U001 | WATCHA-PLAY | 7,900 | 2026-08-19T04:05:00+09:00 | 신한 체크 |
다음 JSON Schema 에 맞는 카드 결제 내역 행을 더 만들어 주세요. {"type":"object","properties":{"tx_no":{"type":"string"},"user_id":{"type":"string"},"merchant_raw":{"type":"string"},"amount":{"type":["integer","null"],"minimum":0},"paid_at":{"type":"string"},"card_alias":{"type":["string","null"]}},"required":["tx_no","user_id","merchant_raw","paid_at"]} 쉬운 데이터만 만들지 말 것. 다음 하드 케이스를 의도적으로 섞어라: (1) amount 가 null 인 결측 행, 해외 가맹점 승인만 잡히고 원화 금액이 확정되지 않은 경우입니다. (2) 같은 tx_no 가 두 번 오는 중복 행, 카드사 원장이 재전송된 경우입니다. (3) amount 가 최소값 0 인 무료 체험 경계 행과, 결제가 반복으로 보는 최소 기준인 세 번에 딱 걸리는 경계 행. (4) 같은 서비스인데 공백과 별표 위치가 다른 표기흔들림 행 쌍. (5) 어떤 merchant_pattern 에도 걸리지 않는 미매핑 가맹점 표기 행. 그리고 형식 깨뜨리기 시도 3건, paid_at 을 날짜만 쓰기, amount 를 문자열로 넣기, tx_no 를 숫자로 넣기를 따로 표시해 주세요. 가맹점 표기는 국내 카드 명세에 실제로 찍히는 세 형태를 반드시 함께 넣어라: ㈜·주식회사 같은 법인격 표기가 붙은 한글 상호, 지점명이 붙은 한글 표기, 결제대행사 접두어가 붙은 영문 표기. 세 형태 모두 공백과 기호, 법인격 표기만 걷어내고 한글·영문·숫자를 남긴 키(영문은 대문자)로 바꿨을 때 키가 비지 않아야 한다.{"type":"object","properties":{"tx_no":{"type":"string"},"user_id":{"type":"string"},"merchant_raw":{"type":"string"},"amount":{"type":["integer","null"],"minimum":0},"paid_at":{"type":"string"},"card_alias":{"type":["string","null"]}},"required":["tx_no","user_id","merchant_raw","paid_at"]}사용자가 왓섭 앱에 등록한 구독입니다. 어느 서비스가 어떤 가맹점 표기로 찍히고 지금 이용 중인지 해지했는지, 해지 시각과 결제 주기가 한 행입니다. 결제 내역과 견주는 상대 표이자 판정 기준이 되는 표입니다. merchant_pattern 도 결제 내역과 같은 규칙으로 키를 만들어 견주므로 한글로 적힌 표기를 그대로 써도 됩니다. 가맹점 표기를 아직 연결하지 않은 미매핑 행을 일부러 섞었습니다.
| sub_no | user_id | service_name | merchant_pattern | status | cancelled_at | billing_cycle_days |
|---|---|---|---|---|---|---|
| SUB-U001-001 | U001 | 넷플릭스 | NETFLIX | active | — | 30 |
| SUB-U001-002 | U001 | 왓챠 | WATCHA | cancelled | 2026-06-30T03:40:00+09:00 | 30 |
| SUB-U001-003 | U001 | 스포티파이 | SPOTIFY | active | — | 30 |
| SUB-U001-004 | U001 | 유튜브 프리미엄 | GOOGLE*YOUTUBE | active | — | 30 |
| SUB-U001-005 | U001 | 밀리의 서재 | 밀리의서재 | cancelled | 2026-05-31T10:02:00+09:00 | 30 |
다음 JSON Schema 에 맞는 구독 등록 행을 더 만들어 주세요. {"type":"object","properties":{"sub_no":{"type":"string"},"user_id":{"type":"string"},"service_name":{"type":"string"},"merchant_pattern":{"type":["string","null"]},"status":{"type":"string","enum":["active","cancelled"]},"cancelled_at":{"type":["string","null"]},"billing_cycle_days":{"type":["integer","null"]}},"required":["sub_no","user_id","service_name","status"]} 쉬운 데이터만 만들지 말 것. 다음 하드 케이스를 의도적으로 섞어라: (1) merchant_pattern 이 null 인 미매핑 행, 사용자가 서비스만 고르고 결제 명세 표기를 아직 연결하지 않은 경우입니다. (2) billing_cycle_days 가 null 인 결측 행. (3) billing_cycle_days 가 365 인 연간 결제 경계 행. (4) 해지했지만 그 뒤 결제가 한 건도 없는 정상 해지 행. (5) 등록만 있고 결제 내역이 한 건도 없는 행. 그리고 형식 깨뜨리기 시도 3건, status 에 없는 값 넣기, cancelled_at 을 빈 문자열로 넣기, billing_cycle_days 를 문자열로 넣기를 따로 표시해 주세요.{"type":"object","properties":{"sub_no":{"type":"string"},"user_id":{"type":"string"},"service_name":{"type":"string"},"merchant_pattern":{"type":["string","null"]},"status":{"type":"string","enum":["active","cancelled"]},"cancelled_at":{"type":["string","null"]},"billing_cycle_days":{"type":["integer","null"]}},"required":["sub_no","user_id","service_name","status"]}| Python | 가맹점 원문 표기를 키로 정리하고 결제 사이 간격과 금액 변화를 내어 다섯 판정으로 가르는 대조 규칙을 Python 으로 씁니다. |
| FastAPI | 대조 규칙을 REST 로 열어 화면이 사용자와 기간을 바꿔 가며 부르게 합니다. |
| PostgreSQL | 결제 내역과 구독 등록을 사용자와 가맹점 키로 색인해 기간 단위 집계를 한 번에 읽습니다. |
| DynamoDB | 가맹점 원문 표기를 서비스로 잇는 별칭 사전을 키·값으로 두고 대조할 때마다 조회합니다. |
| AWS Lambda | 대조 규칙을 함수로 올려 새 결제가 들어온 사용자만 다시 계산합니다. |
| API Gateway | 화면과 Lambda 사이의 입구를 두고 사용자별 조회 범위를 여기서 끊습니다. |
| EventBridge | 매일 새벽 전체 사용자 재대조를 예약으로 돌립니다. |
| CloudWatch | 판정 건수와 판정 실패를 지표로 남겨 사유별 급증을 알림으로 받습니다. |
| TypeScript | 운영 담당자가 보는 대조 화면을 만들고 판정마다 근거 두 값을 같은 자리에 붙입니다. |
| GitHub | 대조 규칙 테스트를 커밋마다 돌려 다섯 판정이 바뀌지 않았는지 확인합니다. |
| Claude Code | 더미 결제 내역 증강 스크립트와 판정 경계 테스트를 만들 때 씁니다. |
✓ pass → 여기서 멈춰도 완결입니다. 더 키우려면 해지 뒤 더 나간 금액 합산과 가맹점 표기 후보 제안을 얹습니다.
✓ pass → 여기서 멈춰도 완결입니다. 더 키우려면 매일 재대조 예약과 새로 생긴 판정 알림을 만듭니다.
위 예측 UI 는 3주차까지 완주했을 때의 화면입니다. 1주차에 실제로 만드는 것은 아래 표시된 화면 하나입니다.
verdict사유 라벨 · charge_key + 한 줄 요약(근거 수치 포함)화면·코드·면접에서 이 단어를 그대로 씁니다. 키는 출력 스키마의 enum 과 같습니다.
active_as_registered등록대로 결제cancelled_but_charged해지 뒤 결제unregistered_recurring미등록 정기결제price_increased요금 인상one_off_not_recurring일회성 결제한 사용자의 한 가맹점 키로 묶은 결제 묶음의 판정입니다. 어느 사유로 갈렸는지와 그 판단의 근거가 된 값을 함께 싣습니다.
{"type":"object","properties":{"charge_key":{"type":"string","description":"user_id 와 정규화한 가맹점 키를 세로줄로 이은 값입니다."},"user_id":{"type":"string"},"merchant_raw":{"type":"string"},"service_name":{"type":["string","null"]},"verdict":{"type":"string","enum":["active_as_registered","cancelled_but_charged","unregistered_recurring","price_increased","one_off_not_recurring"]},"charge_count":{"type":"integer","description":"같은 tx_no 중복을 한 건으로 센 결제 건수입니다."},"interval_days":{"type":["integer","null"],"description":"가장 최근 결제의 paid_at 에서 직전 결제의 paid_at 을 뺀 일수입니다. 결제가 한 건뿐이면 null 입니다."},"days_after_cancel":{"type":["integer","null"],"description":"가장 최근 결제의 paid_at 에서 subscriptions.cancelled_at 을 뺀 일수입니다. 이어진 구독이 없거나 해지가 아니면 null 입니다."},"amount_gap":{"type":["integer","null"],"description":"가장 최근 결제의 amount 에서 직전 결제의 amount 를 뺀 값입니다. 결제가 한 건뿐이거나 어느 한쪽 amount 가 null 이면 null 입니다."},"evidence":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"agg":{"type":"string","enum":["count","sum","avg","min","max"]},"value":{}},"required":["field","value"]}},"reason":{"type":"string"}},"required":["charge_key","user_id","merchant_raw","verdict","charge_count","evidence","reason"]}[
{
"charge_key": "U001|NETFLIXCOM",
"user_id": "U001",
"merchant_raw": "NETFLIX.COM",
"service_name": "넷플릭스",
"verdict": "active_as_registered",
"charge_count": 3,
"interval_days": 31,
"days_after_cancel": null,
"amount_gap": 0,
"evidence": [
{
"field": "tx_no",
"value": "TX-20260817-0003"
},
{
"field": "amount",
"value": 13500
},
{
"field": "amount",
"value": 13500
},
{
"field": "paid_at",
"value": "2026-08-17T02:14:00+09:00"
},
{
"field": "paid_at",
"value": "2026-07-17T02:09:00+09:00"
},
{
"field": "billing_cycle_days",
"value": 30
}
],
"reason": "이번 결제와 직전 결제가 모두 13,500원이고 간격 31일이 등록된 주기 30일과 맞습니다. 넷플릭스 구독이 이용 중이라 등록대로 나간 결제입니다."
},
{
"charge_key": "U001|WATCHAPLAY",
"user_id": "U001",
"merchant_raw": "WATCHA-PLAY",
"service_name": "왓챠",
"verdict": "cancelled_but_charged",
"charge_count": 2,
"interval_days": 30,
"days_after_cancel": 50,
"amount_gap": 0,
"evidence": [
{
"field": "tx_no",
"value": "TX-20260819-0005"
},
{
"field": "paid_at",
"value": "2026-08-19T04:05:00+09:00"
},
{
"field": "cancelled_at",
"value": "2026-06-30T03:40:00+09:00"
},
{
"field": "status",
"value": "cancelled"
},
{
"field": "amount",
"value": 7900
},
{
"field": "amount",
"value": 7900
},
{
"field": "tx_no",
"agg": "count",
"value": 2
}
],
"reason": "왓챠 구독이 6월 30일에 해지됐는데 그 뒤로 7,900원이 두 번 더 나갔습니다. 가장 최근 결제는 해지로부터 50일 뒤입니다."
},
{
"charge_key": "U001|PADDLENETRAYCAST",
"user_id": "U001",
"merchant_raw": "PADDLE.NET*RAYCAST",
"service_name": null,
"verdict": "one_off_not_recurring",
"charge_count": 1,
"interval_days": null,
"days_after_cancel": null,
"amount_gap": null,
"evidence": [
{
"field": "tx_no",
"value": "TX-20260812-0014"
},
{
"field": "amount",
"value": null
},
{
"field": "paid_at",
"value": "2026-08-12T11:05:00+09:00"
}
],
"reason": "이 가맹점 표기로 잡힌 결제가 한 건뿐이라 결제 사이 간격이 서지 않습니다. 해외 승인만 잡히고 원화 금액이 확정되지 않아 금액 비교도 비웠습니다."
},
{
"charge_key": "U001|SPOTIFYKR",
"user_id": "U001",
"merchant_raw": "SPOTIFY KR",
"service_name": "스포티파이",
"verdict": "price_increased",
"charge_count": 2,
"interval_days": 31,
"days_after_cancel": null,
"amount_gap": 3000,
"evidence": [
{
"field": "tx_no",
"value": "TX-20260822-0010"
},
{
"field": "amount",
"value": 13900
},
{
"field": "amount",
"value": 10900
},
{
"field": "tx_no",
"agg": "count",
"value": 2
},
{
"field": "merchant_pattern",
"value": "SPOTIFY"
}
],
"reason": "직전 결제 10,900원에서 이번 결제 13,900원으로 3,000원 올랐습니다. 같은 결제 번호가 두 번 들어와 한 건으로 세었습니다."
},
{
"charge_key": "U001|PADDLENETNOTION",
"user_id": "U001",
"merchant_raw": "PADDLE.NET*NOTION",
"service_name": null,
"verdict": "unregistered_recurring",
"charge_count": 3,
"interval_days": 31,
"days_after_cancel": null,
"amount_gap": 0,
"evidence": [
{
"field": "tx_no",
"value": "TX-20260820-0008"
},
{
"field": "tx_no",
"agg": "count",
"value": 3
},
{
"field": "amount",
"value": 11000
},
{
"field": "amount",
"value": 11000
},
{
"field": "paid_at",
"value": "2026-08-20T09:33:00+09:00"
},
{
"field": "paid_at",
"value": "2026-07-20T09:31:00+09:00"
}
],
"reason": "31일 간격으로 세 번 나갔는데 이 가맹점 표기에 걸리는 구독 등록이 없습니다. 반복으로 보는 최소 기준인 세 번에 딱 걸린 경계 사례입니다."
},
{
"charge_key": "U001|GOOGLEYOUTUBEPREMIUM",
"user_id": "U001",
"merchant_raw": "GOOGLE*YOUTUBE PREMIUM",
"service_name": "유튜브 프리미엄",
"verdict": "active_as_registered",
"charge_count": 2,
"interval_days": 31,
"days_after_cancel": null,
"amount_gap": 0,
"evidence": [
{
"field": "tx_no",
"value": "TX-20260814-0012"
},
{
"field": "merchant_raw",
"value": "GOOGLE *YOUTUBEPREMIUM"
},
{
"field": "merchant_pattern",
"value": "GOOGLE*YOUTUBE"
},
{
"field": "amount",
"value": 14900
},
{
"field": "amount",
"value": 14900
},
{
"field": "billing_cycle_days",
"value": 30
}
],
"reason": "두 결제의 가맹점 표기가 서로 달랐지만 공백과 별표를 걷어내면 같은 키라 등록된 유튜브 프리미엄 구독에 이어집니다. 금액도 14,900원 그대로입니다."
},
{
"charge_key": "U002|WAVVE",
"user_id": "U002",
"merchant_raw": "WAVVE",
"service_name": null,
"verdict": "unregistered_recurring",
"charge_count": 4,
"interval_days": 31,
"days_after_cancel": null,
"amount_gap": 0,
"evidence": [
{
"field": "tx_no",
"value": "TX-20260803-0018"
},
{
"field": "tx_no",
"agg": "count",
"value": 4
},
{
"field": "merchant_pattern",
"value": null
},
{
"field": "service_name",
"value": "웨이브"
},
{
"field": "amount",
"value": 13900
},
{
"field": "amount",
"value": 13900
}
],
"reason": "13,900원이 31일 간격으로 네 번 나갔습니다. 웨이브 구독이 등록돼 있지만 가맹점 표기가 비어 있어 이 결제와 이어지지 않았습니다. 표기를 채우면 등록대로 결제로 바뀝니다."
},
{
"charge_key": "U001|BAEMINCLUB",
"user_id": "U001",
"merchant_raw": "BAEMIN CLUB",
"service_name": "배달의민족 배민클럽",
"verdict": "one_off_not_recurring",
"charge_count": 1,
"interval_days": null,
"days_after_cancel": null,
"amount_gap": null,
"evidence": [
{
"field": "tx_no",
"value": "TX-20260810-0019"
},
{
"field": "amount",
"value": 0
},
{
"field": "billing_cycle_days",
"value": 30
},
{
"field": "paid_at",
"value": "2026-08-10T12:00:00+09:00"
}
],
"reason": "무료 체험으로 0원이 한 번 청구됐습니다. 배민클럽 구독이 등록돼 있지만 결제가 한 건뿐이라 등록된 주기 30일과 견줄 간격이 서지 않습니다."
},
{
"charge_key": "U001|밀리의서재",
"user_id": "U001",
"merchant_raw": "밀리의 서재",
"service_name": "밀리의 서재",
"verdict": "cancelled_but_charged",
"charge_count": 2,
"interval_days": 31,
"days_after_cancel": 62,
"amount_gap": 0,
"evidence": [
{
"field": "tx_no",
"value": "TX-20260801-0021"
},
{
"field": "merchant_raw",
"value": "㈜밀리의서재"
},
{
"field": "merchant_pattern",
"value": "밀리의서재"
},
{
"field": "cancelled_at",
"value": "2026-05-31T10:02:00+09:00"
},
{
"field": "status",
"value": "cancelled"
},
{
"field": "amount",
"value": 9900
},
{
"field": "amount",
"value": 9900
},
{
"field": "tx_no",
"agg": "count",
"value": 2
}
],
"reason": "㈜밀리의서재 와 밀리의 서재 는 공백과 법인격 표기를 걷어내면 같은 한글 키라 한 구독으로 묶입니다. 5월 31일에 해지된 뒤로 9,900원이 두 번 더 나갔고 가장 최근 결제는 해지로부터 62일 뒤입니다."
}
][자동 검증 · 1주차] 1. verdict 값이 다섯 enum 안에 있는지 확인합니다. 2. charge_key 와 user_id, merchant_raw, verdict, charge_count, evidence, reason 이 모두 있는지 확인합니다. 3. charge_count 는 1 이상, interval_days 는 0 이상, amount 는 최소값 0 이상인지 확인합니다. 4. amount_gap 은 가장 최근 결제의 amount 에서 직전 결제의 amount 를 뺀 값과 같은지 다시 계산합니다. charge_count 가 1 이거나 어느 한쪽 amount 가 null 이면 amount_gap 도 null 인지 확인합니다. 5. interval_days 는 가장 최근 결제의 paid_at 에서 직전 결제의 paid_at 을 뺀 일수와 같은지 다시 계산합니다. charge_count 가 1 이면 interval_days 는 null 입니다. 6. days_after_cancel 은 가장 최근 결제의 paid_at 에서 subscriptions.cancelled_at 을 뺀 일수와 같은지 다시 계산합니다. verdict 가 cancelled_but_charged 이면 days_after_cancel 은 1 이상입니다. 7. verdict 가 price_increased 이면 amount_gap 은 1 이상입니다. 8. verdict 가 unregistered_recurring 이면 charge_count 는 3 이상입니다. 9. evidence 의 field 가 결제 내역과 구독 등록 두 표의 컬럼에 실재하는지 확인합니다. 10. agg 가 없는 evidence 항목의 value 가 수록된 행의 그 컬럼 값과 같은지 확인합니다. 11. charge_key 의 가맹점 키가 merchant_raw 에서 공백과 기호, ㈜·(주)·주식회사 같은 법인격 표기만 걷어내고 한글·영문·숫자를 남긴 값(영문은 대문자)과 같은지 다시 계산합니다. 한글 상호만으로 된 표기도 키가 비지 않아야 합니다. 12. 모든 reason 이 존댓말로 끝나는지 확인합니다. [수동 검증] 1. 가맹점 표기가 달랐던 두 결제를 같은 구독으로 묶은 판단이 맞는지 사람이 봅니다. 2. 결제대행사 표기로 잡힌 미등록 정기결제가 실제로 어느 서비스인지 담당자가 확인합니다. 3. 금액이 오른 건이 요금 인상인지 이용권 변경인지 담당자가 확인합니다. [평가 러너 · 2주차] 1. 정답을 붙인 더미 200묶음으로 다섯 판정의 정밀도와 재현율을 냅니다. 2. 표기 정리의 적중률을 별칭 사전 유무로 나눠 견줍니다. 편의점 결제인 GS25 강남점 행은 결제가 한 건뿐이라 일회성 결제로 갈리고 어긋난 것이 없어 별도 사유로 올라가지 않습니다. 판정 순서는 결제 한 건이면 one_off_not_recurring, 이어진 구독이 없고 세 번 이상 반복이면 unregistered_recurring, 이어진 구독이 해지 상태이고 결제가 더 뒤이면 cancelled_but_charged, 금액이 올랐으면 price_increased, 그 외 이어진 구독이 있으면 active_as_registered 입니다.