- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- 인보이스의 구성요소
- 청구 유형
- 세금·부가세(VAT)·전자세금계산서
- 인보이스 상태 흐름
- 데이터 모델
- 결제 연동·수납 대사·멱등성
- 다국가·다통화
- 감사 추적 (audit trail)
- 실무 함정
- 마치며
- 참고 자료
들어가며
청구 시스템은 회사의 매출이 실제로 현금으로 전환되는 마지막 관문입니다. 아무리 좋은 제품을 팔아도 인보이스가 잘못 발행되거나, 결제가 중복 청구되거나, 세금 계산이 틀리면 그 손해는 고스란히 회사와 고객이 함께 떠안습니다.
특히 청구 시스템은 한 번 프로덕션에 올라가면 되돌리기가 매우 어렵습니다. 이미 발행한 인보이스는 법적·회계적 근거가 되고, 고객이 이미 결제한 금액은 함부로 수정할 수 없습니다. 그래서 청구 시스템은 처음부터 "돈이 새지 않게" 설계하는 것이 핵심입니다.
이 글에서는 인보이스의 구성요소부터 청구 유형, 세금, 상태 흐름, 데이터 모델, 결제 연동, 수납 대사, 멱등성, 다국가·다통화, 감사 추적까지 청구 시스템 전반을 설계 관점에서 정리합니다. 실무에서 흔히 만나는 함정과 그 대응법도 함께 다룹니다.
이 글이 다루는 범위를 먼저 정리하면 다음과 같습니다.
- 인보이스가 무엇으로 구성되는가 (발행자, 수취인, 품목 라인, 세금, 통화, 결제 조건)
- 어떤 청구 유형이 있는가 (정액, 사용량 기반, 구독)
- 세금과 부가세(VAT), 전자세금계산서를 어떻게 다루는가
- 인보이스가 어떤 상태를 거치는가 (초안, 발행, 결제, 연체, 무효, 환불)
- 데이터 모델을 어떻게 잡을 것인가
- 결제 대행사(PG/PSP)와 어떻게 연동하고 수납 대사를 어떻게 하는가
- 중복 청구를 어떻게 막는가 (멱등성)
- 다국가·다통화를 어떻게 처리하는가
- 감사 추적을 어떻게 남기는가
- 실무 함정과 대응
인보이스의 구성요소
인보이스(invoice)는 판매자가 구매자에게 "이만큼 지불해 주세요"라고 청구하는 공식 문서입니다. 겉으로는 단순해 보이지만, 법적 효력과 회계 근거를 동시에 갖추어야 하기 때문에 반드시 포함해야 하는 필수 요소들이 있습니다.
필수 구성요소
| 구성요소 | 설명 | 예시 |
|---|---|---|
| 인보이스 번호 | 고유하고 순차적인 식별자 | INV-2026-000123 |
| 발행일 | 인보이스를 발행한 날짜 | 2026-07-01 |
| 발행자 정보 | 판매자 상호, 주소, 사업자번호 | Acme Corp, 사업자번호 123-45-67890 |
| 수취인 정보 | 구매자 상호, 주소, 사업자번호 | Beta Inc, 주소, 세금 식별번호 |
| 품목 라인 | 판매한 상품 또는 서비스 목록 | Pro 플랜 1개월, 수량 10 |
| 소계 | 세금 전 합계 | KRW 100,000 |
| 세금 | 부가세 등 적용 세액 | VAT 10 퍼센트, KRW 10,000 |
| 총액 | 최종 청구 금액 | KRW 110,000 |
| 통화 | 청구 통화 (ISO 4217) | KRW, USD, JPY |
| 결제 조건 | 지불 기한과 방법 | Net 30, 계좌이체 |
| 결제 기한 | 최종 지불 마감일 | 2026-07-31 |
인보이스 번호는 특히 중요합니다. 세무·회계상 인보이스 번호는 누락이나 중복이 없어야 하며, 많은 국가에서 순차적이고 연속적일 것을 요구합니다. 번호에 구멍이 생기면 세무 감사에서 "발행 후 삭제한 인보이스가 있는 것 아니냐"는 의심을 받을 수 있습니다.
품목 라인 (line item)
인보이스의 핵심은 품목 라인입니다. 각 라인은 무엇을, 얼마나, 얼마에 팔았는지를 표현합니다.
품목 라인은 보통 다음 값들로 구성됩니다.
- 설명 (description)
- 수량 (quantity)
- 단가 (unit price)
- 라인 소계 (수량 곱하기 단가)
- 세율 (tax rate)
- 할인 (discount)
여기서 중요한 원칙은, 라인별 소계를 먼저 계산한 뒤 반올림하고, 그 결과를 합산해야 한다는 것입니다. 합산 후에 한 번에 반올림하면 라인별 금액의 합과 총액이 맞지 않는 문제가 생깁니다. 이 반올림 문제는 뒤에서 함정 절에서 자세히 다룹니다.
결제 조건 (payment terms)
결제 조건은 "언제까지, 어떻게 지불해야 하는가"를 정의합니다. 대표적인 표현은 다음과 같습니다.
| 조건 | 의미 |
|---|---|
| Due on receipt | 수령 즉시 지불 |
| Net 15 | 발행일로부터 15일 이내 지불 |
| Net 30 | 발행일로부터 30일 이내 지불 |
| Net 60 | 발행일로부터 60일 이내 지불 |
| EOM | 해당 월 말일까지 지불 |
기업 간 거래(B2B)에서는 Net 30이나 Net 60이 흔하고, 개인 대상(B2C) 구독에서는 즉시 결제가 일반적입니다.
청구 유형
청구 시스템을 설계할 때 가장 먼저 정해야 할 것은 "어떤 방식으로 돈을 받을 것인가"입니다. 크게 세 가지 유형이 있습니다.
정액 청구 (flat / fixed)
가장 단순한 형태입니다. 정해진 금액을 정해진 주기에 청구합니다.
- 예: 월 KRW 29,000 요금제, 연 1회 라이선스 비용
- 장점: 계산이 단순하고 예측 가능
- 단점: 사용량과 무관하게 고정되어 유연성이 낮음
사용량 기반 청구 (usage-based / metered)
고객이 실제로 사용한 만큼 청구합니다. 클라우드 인프라, API 호출, 문자 발송 등에서 흔합니다.
- 예: API 호출 1,000건당 KRW 100, 저장 용량 GB당 월 KRW 25
- 장점: 사용량에 비례해 공정하게 과금
- 단점: 사용량 집계(metering)의 정확성과 실시간성이 어려움
사용량 기반 청구의 핵심 난제는 "사용량 이벤트를 어떻게 정확하게, 중복 없이 집계하는가"입니다. 이벤트가 중복 집계되면 과다 청구가, 누락되면 과소 청구가 발생합니다. 그래서 사용량 이벤트에도 멱등 키가 필요합니다.
구독 청구 (subscription / recurring)
정해진 주기마다 자동으로 반복 청구합니다. SaaS 비즈니스의 표준 모델입니다.
- 예: 월간 구독, 연간 구독
- 장점: 예측 가능한 반복 매출(MRR/ARR)
- 단점: 주기 중간의 요금제 변경(proration), 갱신 실패, 카드 만료 등 처리할 예외가 많음
실무에서는 이 세 가지를 조합하는 경우가 많습니다. 예를 들어 "월 기본료 KRW 50,000 정액에 더해, 초과 API 호출은 사용량 기반"이라는 하이브리드 요금제가 대표적입니다.
청구 유형 비교
| 항목 | 정액 | 사용량 기반 | 구독 |
|---|---|---|---|
| 예측 가능성 | 높음 | 낮음 | 높음 |
| 계산 복잡도 | 낮음 | 높음 | 중간 |
| 집계 필요성 | 없음 | 필수 | 없음 또는 부분 |
| 주요 난제 | 없음 | 정확한 metering | proration, 갱신 실패 |
| 대표 사례 | 라이선스 | 클라우드, API | SaaS |
세금·부가세(VAT)·전자세금계산서
세금은 청구 시스템에서 가장 실수가 잦은 영역입니다. 나라마다 규칙이 다르고, 같은 나라 안에서도 상품 종류나 고객 유형에 따라 세율이 달라집니다.
부가가치세(VAT)의 기본
부가가치세(VAT, Value Added Tax)는 상품·서비스의 부가가치에 부과되는 소비세입니다. 한국에서는 대부분의 재화·용역에 10 퍼센트가 적용됩니다.
세금 계산에서 반드시 구분해야 할 두 가지 개념이 있습니다.
- 세금 별도 (tax-exclusive): 표시 가격에 세금이 포함되지 않아, 세금을 따로 더합니다. B2B에서 흔합니다.
- 세금 포함 (tax-inclusive): 표시 가격에 이미 세금이 포함되어 있습니다. B2C 소비자 대상에서 흔합니다.
세금 포함 가격에서 세액을 역산할 때는 다음과 같이 계산합니다.
세금 포함 가격 = 110,000원 (VAT 10 퍼센트 포함일 때)
소계(net) = 110,000 / 1.10 = 100,000원
세액(VAT) = 110,000 - 100,000 = 10,000원
국경 간 거래와 리버스 차지
EU 등에서는 국경 간 B2B 거래 시 "리버스 차지(reverse charge)" 규칙이 적용됩니다. 판매자가 세금을 부과하지 않고, 구매자가 자기 나라에서 세금을 신고·납부하는 방식입니다. 이 경우 인보이스에는 세율 0 퍼센트가 아니라 "reverse charge 적용" 문구를 명시해야 합니다.
또한 EU에서는 구매자의 VAT 번호(VAT ID)가 유효한지 VIES 시스템으로 검증해야 리버스 차지를 적용할 수 있습니다. 검증에 실패하면 판매자가 세금을 부과해야 합니다.
전자세금계산서
한국에서는 일정 규모 이상의 사업자에게 전자세금계산서 발행이 의무입니다. 국세청 홈택스나 연동 서비스를 통해 발행하며, 발행 즉시 국세청에 전송됩니다.
청구 시스템 관점에서 전자세금계산서는 다음을 의미합니다.
- 인보이스 발행과 세금계산서 발행은 별개의 이벤트일 수 있습니다.
- 세금계산서는 한번 발행되면 수정이 어렵고, 수정세금계산서라는 별도 절차가 필요합니다.
- 국세청 전송 상태(성공, 실패, 재전송)를 시스템에서 추적해야 합니다.
세금 처리 설계 원칙
- 세율은 하드코딩하지 말고, 시간·지역·상품 유형별로 조회 가능한 세율 테이블로 관리합니다.
- 인보이스에는 발행 시점에 적용한 세율을 스냅샷으로 저장합니다. 나중에 세율이 바뀌어도 과거 인보이스는 그대로 유지되어야 합니다.
- 세액 계산 로직은 반드시 단위 테스트로 경계값을 검증합니다.
인보이스 상태 흐름
인보이스는 생성부터 종료까지 여러 상태를 거칩니다. 이 상태 전이를 명확한 상태 기계(state machine)로 설계하는 것이 중요합니다. 허용되지 않은 전이를 막아야 데이터 무결성이 지켜집니다.
주요 상태
| 상태 | 설명 | 다음 가능 상태 |
|---|---|---|
| draft (초안) | 아직 확정되지 않음, 수정 가능 | issued, void |
| issued (발행) | 고객에게 청구됨, 금액 확정 | paid, overdue, void |
| paid (결제완료) | 전액 결제됨 | refunded |
| overdue (연체) | 기한 초과, 미결제 | paid, void |
| void (무효) | 취소됨, 회계상 무효 처리 | 없음 (종료) |
| refunded (환불) | 결제 후 환불됨 | 없음 (종료) |
여기서 중요한 규칙이 하나 있습니다. 이미 발행(issued)된 인보이스는 절대 수정하면 안 됩니다. 금액이 틀렸다면 무효(void) 처리하고 새 인보이스를 발행하거나, 대변 인보이스(credit note)를 발행합니다. 발행된 인보이스를 몰래 수정하는 것은 회계 부정으로 간주됩니다.
상태 흐름 다이어그램
+--------+
| draft |
+--------+
| |
issue | | discard
v v
+--------+ +------+
| issued |-->| void |
+--------+ +------+
| |
pay | | overdue (기한 초과)
v v
+------+ +---------+
| paid | | overdue |
+------+ +---------+
| |
refund| | pay
v v
+----------+ +------+
| refunded | | paid |
+----------+ +------+
부분 결제 처리
실무에서는 한 인보이스가 여러 번에 나눠 결제되는 경우가 있습니다. 이때는 인보이스 상태를 단순히 paid/unpaid로 두기보다, 결제(payment)를 별도 엔티티로 두고 인보이스에 대한 결제 총합을 계산하는 방식이 안전합니다.
인보이스 총액 = 110,000원
결제1 (2026-07-05) = 50,000원
결제2 (2026-07-10) = 60,000원
------------------------------------
누적 결제액 = 110,000원 -> 상태 paid
이렇게 결제를 별도 엔티티로 두면 부분 환불, 과다 결제, 재결제 같은 복잡한 케이스도 일관되게 다룰 수 있습니다.
데이터 모델
이제 실제 데이터 모델을 설계해 보겠습니다. 핵심 엔티티는 고객(customer), 인보이스(invoice), 품목 라인(line_item), 결제(payment)입니다.
ASCII ERD
+----------------+ +------------------+
| customers | | invoices |
+----------------+ +------------------+
| id (PK) |1 *| id (PK) |
| name |--------| customer_id (FK) |
| tax_id | | number (unique) |
| country | | status |
| currency | | currency |
| created_at | | subtotal |
+----------------+ | tax_total |
| total |
| issued_at |
| due_at |
| created_at |
+------------------+
|1 |1
| |
|* |*
+----------------+ +------------------+
| line_items | | payments |
+----------------+ +------------------+
| id (PK) | | id (PK) |
| invoice_id (FK)| | invoice_id (FK) |
| description | | amount |
| quantity | | currency |
| unit_price | | status |
| tax_rate | | psp_ref |
| line_subtotal | | idempotency_key |
| line_tax | | created_at |
+----------------+ +------------------+
SQL 스키마
CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
tax_id TEXT,
country CHAR(2) NOT NULL,
currency CHAR(3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE invoices (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
number TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'draft',
currency CHAR(3) NOT NULL,
subtotal BIGINT NOT NULL DEFAULT 0,
tax_total BIGINT NOT NULL DEFAULT 0,
total BIGINT NOT NULL DEFAULT 0,
issued_at TIMESTAMPTZ,
due_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_status CHECK (
status IN ('draft','issued','paid','overdue','void','refunded')
)
);
CREATE TABLE line_items (
id BIGSERIAL PRIMARY KEY,
invoice_id BIGINT NOT NULL REFERENCES invoices(id),
description TEXT NOT NULL,
quantity NUMERIC(18,4) NOT NULL,
unit_price BIGINT NOT NULL,
tax_rate NUMERIC(5,4) NOT NULL DEFAULT 0,
line_subtotal BIGINT NOT NULL,
line_tax BIGINT NOT NULL
);
CREATE TABLE payments (
id BIGSERIAL PRIMARY KEY,
invoice_id BIGINT NOT NULL REFERENCES invoices(id),
amount BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
psp_ref TEXT,
idempotency_key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
여기서 주목할 점이 몇 가지 있습니다.
- 금액은 모두 정수(BIGINT)로 저장합니다. 최소 통화 단위(원, 센트)로 저장해 부동소수점 오차를 원천 차단합니다.
- invoices.number 에 UNIQUE 제약을 걸어 인보이스 번호 중복을 막습니다.
- payments.idempotency_key 에 UNIQUE 제약을 걸어 중복 결제를 데이터베이스 레벨에서 막습니다.
- status 에 CHECK 제약을 걸어 허용되지 않은 상태값이 들어오지 못하게 합니다.
금액을 정수로 저장하는 이유
부동소수점(float, double)으로 돈을 다루면 반드시 오차가 생깁니다. 예를 들어 0.1 더하기 0.2가 정확히 0.3이 되지 않는 문제입니다. 그래서 돈은 반드시 정수(최소 단위) 또는 고정소수점(decimal) 타입으로 다뤄야 합니다.
잘못된 방식: 금액 = 10.10 (float) -> 누적 연산 시 오차 발생
올바른 방식: 금액 = 1010 (정수, 센트 단위) -> 오차 없음
표시할 때: 1010 / 100 = 10.10 으로 변환
결제 연동·수납 대사·멱등성
인보이스를 발행했다면, 이제 실제로 돈을 받아야 합니다. 여기서 결제 대행사(PG, Payment Gateway 또는 PSP, Payment Service Provider)와의 연동이 등장합니다.
PG/PSP 연동의 기본 흐름
[우리 시스템] [PSP] [카드사/은행]
| | |
| 결제 요청 | |
|--------------->| |
| | 승인 요청 |
| |------------------>|
| | 승인 응답 |
| |<------------------|
| 결제 결과 | |
|<---------------| |
| | |
| 웹훅(webhook) | |
|<---------------| |
여기서 중요한 것은 결제 결과를 두 경로로 받는다는 점입니다.
- 동기 응답: 결제 요청에 대한 즉시 응답
- 웹훅: PSP가 나중에 비동기로 보내는 상태 변경 알림
두 경로가 서로 다른 시점에 같은 결제 상태를 전달할 수 있으므로, 어느 쪽이 먼저 도착하든 결과가 같아야 합니다. 이것이 바로 멱등성이 필요한 이유입니다.
멱등성(idempotency)
멱등성이란 "같은 요청을 여러 번 보내도 결과가 한 번 보낸 것과 같다"는 성질입니다. 결제에서 멱등성은 생명입니다. 네트워크 오류로 결제 요청을 재시도했을 때, 실제 청구가 두 번 일어나면 안 되기 때문입니다.
멱등성을 구현하는 표준적인 방법은 멱등 키(idempotency key)입니다.
- 클라이언트가 결제 요청마다 고유한 키를 생성해 함께 보냅니다.
- 서버는 그 키로 이미 처리한 요청이 있는지 확인합니다.
- 이미 있으면 새로 처리하지 않고 저장된 결과를 그대로 반환합니다.
def charge(idempotency_key, invoice_id, amount):
# 이미 처리한 요청인지 확인
existing = payments.find_by_key(idempotency_key)
if existing is not None:
# 중복 요청: 저장된 결과를 그대로 반환
return existing
# UNIQUE 제약으로 동시 요청도 하나만 성공
try:
payment = payments.insert(
idempotency_key=idempotency_key,
invoice_id=invoice_id,
amount=amount,
status="pending",
)
except UniqueViolation:
# 경쟁 상태: 다른 요청이 먼저 삽입함
return payments.find_by_key(idempotency_key)
result = psp.charge(amount)
payment.status = "succeeded" if result.ok else "failed"
payment.psp_ref = result.reference
payment.save()
return payment
핵심은 데이터베이스의 UNIQUE 제약을 안전망으로 쓰는 것입니다. 애플리케이션 레벨의 "먼저 조회하고 없으면 삽입" 로직만으로는 동시 요청 경쟁 상태를 막을 수 없습니다. UNIQUE 제약이 있어야 동시에 들어온 두 요청 중 하나만 성공합니다.
수납 대사 (reconciliation)
수납 대사란 "우리 시스템이 받았다고 기록한 돈"과 "PSP나 은행이 실제로 입금해 준 돈"을 맞춰 보는 작업입니다. 이 둘이 항상 일치하는 것은 아닙니다.
대사에서 흔히 발견되는 불일치는 다음과 같습니다.
| 불일치 유형 | 원인 | 대응 |
|---|---|---|
| 우리는 성공, PSP는 실패 | 웹훅 누락, 상태 미갱신 | PSP 상태로 정정 |
| PSP는 성공, 우리는 없음 | 웹훅 유실, 응답 누락 | 결제 레코드 생성 |
| 금액 불일치 | 수수료 차감, 부분 환불 | 수수료·환불 반영 |
| 통화 불일치 | 환율 변동, 다통화 정산 | 정산 환율로 재계산 |
대사는 보통 매일 배치로 돌립니다. PSP가 제공하는 정산 리포트(settlement report)를 내려받아 우리 결제 레코드와 하나씩 대조하고, 불일치는 별도로 표시해 사람이 확인하게 합니다.
대사에서 가장 중요한 원칙은 "우리 시스템의 기록을 진실의 원천(source of truth)으로 착각하지 않는 것"입니다. 돈이 실제로 오간 것은 은행과 PSP의 기록입니다. 우리 시스템의 기록은 그것과 맞춰져야 하는 대상입니다.
다국가·다통화
글로벌 서비스라면 여러 나라의 고객에게 여러 통화로 청구해야 합니다. 이 영역은 실수가 매우 잦습니다.
통화 코드와 최소 단위
통화는 ISO 4217 표준 코드로 관리합니다. KRW, USD, JPY, EUR 등 세 글자 코드입니다.
주의할 점은 통화마다 소수점 자릿수(최소 단위)가 다르다는 것입니다.
| 통화 | 소수 자릿수 | 최소 단위 | 100의 의미 |
|---|---|---|---|
| USD | 2 | 센트 | 1.00 달러 |
| EUR | 2 | 센트 | 1.00 유로 |
| KRW | 0 | 원 | 100 원 |
| JPY | 0 | 엔 | 100 엔 |
| BHD | 3 | 필스 | 0.100 디나르 |
따라서 "금액을 100으로 나눠서 표시"하는 로직을 모든 통화에 똑같이 적용하면 KRW나 JPY에서 100배 틀린 금액이 나옵니다. 통화별 소수 자릿수를 반드시 테이블로 관리해야 합니다.
환율과 통화 혼용 금지
한 인보이스 안에서는 하나의 통화만 써야 합니다. 서로 다른 통화의 품목을 한 인보이스에 섞으면 총액을 계산할 수 없습니다.
환율이 필요한 경우(예: 원화로 청구했지만 달러로 정산)에는 다음 원칙을 지킵니다.
- 인보이스에는 청구 통화의 금액을 저장합니다.
- 환산이 필요하면 환산 시점의 환율과 환산 결과를 함께 스냅샷으로 저장합니다.
- 환율은 시간에 따라 변하므로, 나중에 다시 계산하지 말고 저장된 값을 씁니다.
청구: KRW 1,100,000
환율: 1 USD = 1,375 KRW (2026-07-01 기준, 스냅샷)
정산: 1,100,000 / 1,375 = USD 800.00
감사 추적 (audit trail)
돈을 다루는 시스템은 "누가, 언제, 무엇을, 왜 바꿨는가"를 반드시 남겨야 합니다. 이것이 감사 추적입니다. 감사 추적은 회계 감사, 분쟁 해결, 부정 탐지의 근거가 됩니다.
감사 추적 설계 원칙
- 불변(append-only): 감사 로그는 절대 수정·삭제하지 않고 추가만 합니다.
- 완전성: 상태를 바꾸는 모든 이벤트를 기록합니다.
- 추적 가능성: 각 이벤트에 행위자(actor), 시각, 이전 값, 이후 값을 남깁니다.
+---------------------------------------------------------------+
| audit_log (append-only) |
+---------------------------------------------------------------+
| id | entity_type | entity_id | action | actor | at | detail |
|----|-------------|-----------|---------|-------|----|---------|
| 1 | invoice | 123 | created | u:42 | .. | {...} |
| 2 | invoice | 123 | issued | u:42 | .. | {...} |
| 3 | payment | 987 | charged | sys | .. | {...} |
| 4 | invoice | 123 | paid | sys | .. | {...} |
+---------------------------------------------------------------+
이벤트 소싱과의 관계
한 걸음 더 나아가면 이벤트 소싱(event sourcing) 패턴을 쓸 수 있습니다. 상태 자체가 아니라 상태를 바꾼 이벤트들의 연속을 진실의 원천으로 삼는 방식입니다. 현재 상태는 이벤트를 재생(replay)해서 계산합니다.
이벤트 소싱은 청구 시스템과 궁합이 좋습니다. 모든 변경 이력이 자연스럽게 남고, 특정 시점의 상태를 재현할 수 있으며, 대사와 감사에 유리하기 때문입니다. 다만 구현 복잡도가 올라가므로, 시스템 규모와 요구사항을 보고 도입을 결정해야 합니다.
실무 함정
마지막으로, 청구 시스템에서 실제로 돈이 새게 만드는 대표적인 함정들을 정리합니다.
반올림(rounding) 함정
앞서 언급했듯, 라인별로 반올림한 뒤 합산할지, 합산한 뒤 반올림할지에 따라 총액이 1원 단위로 달라질 수 있습니다.
라인A: 33.333... -> 반올림 33
라인B: 33.333... -> 반올림 33
라인C: 33.333... -> 반올림 33
라인별 합산: 33 + 33 + 33 = 99
합산 후 반올림: 33.333 곱하기 3 = 99.999 -> 반올림 100
두 방식의 결과가 1 차이남!
일관된 반올림 정책(예: 항상 라인별 반올림 후 합산, 은행가 반올림 사용)을 정하고, 이를 문서화하고 테스트해야 합니다.
세금 경계값 함정
세율이 바뀌는 시점(예: 세법 개정), 면세 대상 판정, 리버스 차지 적용 여부 등에서 실수가 잦습니다. 세율은 반드시 발행 시점 기준으로 스냅샷하고, 세금 로직은 경계값 테스트를 촘촘히 작성합니다.
비례 계산(proration) 함정
구독 요금제를 월 중간에 변경하면, 남은 기간에 대해 비례 계산을 해야 합니다.
월 요금 30,000원 (30일 기준)
15일차에 상위 플랜(월 60,000원)으로 변경
남은 15일 기존 플랜 환불: 30,000 곱하기 15 / 30 = 15,000원
남은 15일 상위 플랜 청구: 60,000 곱하기 15 / 30 = 30,000원
차액 청구: 30,000 - 15,000 = 15,000원
비례 계산에서 "며칠 기준으로 나눌 것인가(실제 일수 vs 30일 고정)", "변경한 날을 어느 쪽에 포함할 것인가"를 명확히 정의하지 않으면 고객마다 금액이 미묘하게 달라집니다.
통화 함정
- 통화별 소수 자릿수를 무시하고 일괄 처리
- 한 인보이스에 여러 통화 혼용
- 환율을 재계산해서 과거 금액이 바뀌는 문제
앞서 다룬 원칙(통화별 최소 단위 테이블, 단일 통화 인보이스, 환율 스냅샷)을 지키면 대부분 방지됩니다.
중복 청구(double-charge) 함정
가장 치명적인 함정입니다. 사용자가 결제 버튼을 두 번 누르거나, 네트워크 타임아웃으로 재시도했을 때 실제 청구가 두 번 일어나는 경우입니다.
방어선은 여러 겹으로 둡니다.
- 클라이언트: 버튼 중복 클릭 방지, 멱등 키 생성
- 서버: 멱등 키 확인
- 데이터베이스: 멱등 키 UNIQUE 제약 (최후의 방어선)
- 대사: 사후에 중복 결제를 찾아내는 배치
함정 요약표
| 함정 | 증상 | 대응 |
|---|---|---|
| 반올림 | 라인 합과 총액 불일치 | 일관된 반올림 정책, 테스트 |
| 세금 경계 | 세율 오적용 | 세율 스냅샷, 경계값 테스트 |
| proration | 고객별 금액 차이 | 명확한 일수 정책 |
| 통화 | 100배 오류, 총액 오류 | 통화별 최소 단위, 단일 통화 |
| 중복 청구 | 이중 결제 | 멱등 키, UNIQUE 제약, 대사 |
마치며
청구 시스템은 화려하지 않지만, 회사의 매출과 신뢰가 걸린 핵심 인프라입니다. 이 글에서 다룬 원칙들을 정리하면 다음과 같습니다.
- 금액은 정수(최소 통화 단위)로 저장해 부동소수점 오차를 없앤다.
- 발행된 인보이스는 수정하지 않고, 무효·환불·대변 인보이스로 정정한다.
- 상태 전이를 명확한 상태 기계로 설계한다.
- 결제는 멱등 키와 UNIQUE 제약으로 중복을 원천 차단한다.
- 우리 기록을 진실의 원천으로 착각하지 말고, PSP·은행 기록과 대사한다.
- 통화별 최소 단위를 존중하고, 한 인보이스에는 한 통화만 쓴다.
- 세율과 환율은 발행 시점 스냅샷으로 저장한다.
- 모든 변경을 감사 추적에 append-only로 남긴다.
돈이 새지 않는 시스템은 한 번에 완성되지 않습니다. 함정을 하나씩 알고, 방어선을 여러 겹으로 두고, 대사로 사후 검증하는 습관이 쌓여야 비로소 믿을 수 있는 청구 시스템이 됩니다.
참고 자료
- Stripe Invoicing 문서: https://stripe.com/docs/invoicing
- Stripe Billing 문서: https://stripe.com/docs/billing
- Stripe Idempotent Requests: https://stripe.com/docs/api/idempotent_requests
- PayPal Developer 문서: https://developer.paypal.com/
- ISO 4217 통화 코드: https://www.iso.org/iso-4217-currency-codes.html
- Idempotence (Wikipedia): https://en.wikipedia.org/wiki/Idempotence
- Martin Fowler, Event Sourcing: https://martinfowler.com/eaaDev/EventSourcing.html
- PostgreSQL 공식 문서: https://www.postgresql.org/docs/
- EU 세무·관세(VAT): https://taxation-customs.ec.europa.eu/
- 국세청 홈택스 전자세금계산서: https://www.hometax.go.kr/