이 글은 누구를 위한 것인가
- 프리랜서·파트너·크리에이터에게 대가를 지급하는 플랫폼을 만드는 팀
- 정산 금액이 1원씩 어긋나 회계팀과 매달 대사하고 있는 경우
- 지급명세서를 엑셀로 만들어 제출하다가 자동화하려는 개발자
들어가며
플랫폼이 개인에게 돈을 지급하기 시작하면, 그 순간부터 세무 처리가 제품 기능이 된다.
판매자 정산 1,000,000원
↓
실제 입금액은 얼마인가?
국세청에 무엇을 언제 신고하는가?
받는 사람은 5월에 무엇을 해야 하는가?
이 세 질문에 코드로 답하지 못하면, 정산은 매달 엑셀과 수작업으로 돌아간다. 그리고 규모가 커지면 반드시 어긋난다.
이 글은 사업소득 원천징수를 다루는 시스템의 설계 결정들을 정리한다. 세법 해설이 아니라, 그것을 코드와 스키마로 옮길 때 부딪히는 지점들이다.
1. 소득 구분이 먼저다 — 3.3%는 결과이지 전제가 아니다
가장 흔한 설계 실수는 모든 개인 지급에 일괄로 3.3%를 적용하는 것이다.
[소득 구분에 따라 처리가 완전히 다르다]
사업소득 독립적·계속적으로 용역 제공
원천징수 3.3% (소득세 3% + 지방소득세 0.3%)
→ 받는 사람이 5월에 종합소득세 신고
기타소득 일시적·우발적 소득 (강연료, 원고료 등)
필요경비 공제 후 원천징수 — 세율 체계가 다름
근로소득 사용자의 지휘·감독을 받는 관계
간이세액표에 따라 원천징수 + 연말정산
"3.3%로 떼면 사업소득"이 아니라, 사업소득이라서 3.3%인 것이다. 실질이 근로관계인데 3.3%로 처리하면 나중에 문제가 된다. 이 판단은 개발자가 할 수 없으므로, 소득 구분을 데이터로 받아야 한다.
CREATE TYPE income_type AS ENUM ('business', 'other', 'employment');
CREATE TABLE payout_recipients (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
income_type income_type NOT NULL, -- 계약 형태에 따라 결정
resident BOOLEAN NOT NULL DEFAULT true, -- 거주자/비거주자
tax_id_hash BYTEA, -- 주민등록번호는 평문 저장 금지
verified_at TIMESTAMPTZ,
UNIQUE (user_id)
);
주민등록번호를 평문으로 두지 않는다. 수집·보관의 법적 요건은 개인정보포털의 고유식별정보 처리 기준을 확인해야 한다. 지급명세서 제출에는 필요하지만, DB에는 암호화해서 보관하고 접근을 감사 로그로 남긴다. 이건 선택이 아니라 개인정보 보호의 기본 요건이다.
세율과 구분 기준은 자주 바뀌므로 코드에 상수로 박지 말고 날짜 구간을 가진 설정 테이블로 둔다.
CREATE TABLE withholding_rates (
income_type income_type NOT NULL,
income_tax NUMERIC(5,4) NOT NULL, -- 0.0300
local_tax NUMERIC(5,4) NOT NULL, -- 0.0030
valid_from DATE NOT NULL,
valid_to DATE,
PRIMARY KEY (income_type, valid_from)
);
정확한 세율·구분 기준은 국세청 안내를 1차 출처로 삼고, 실제 신고·제출 절차는 홈택스의 원천세 메뉴를 기준으로 확인한다. 코드에 적힌 3.3%가 정답인지 매년 확인하는 담당자를 정해두는 것이 시스템의 일부다.
2. 반올림 — 1원 차이가 매달 쌓인다
가장 실무적인 문제다. 소득세와 지방소득세를 각각 계산할 것인가, 합쳐서 계산할 것인가에 따라 결과가 달라진다.
지급액 1,234,567원
[방식 A] 합산 후 절사
1,234,567 × 0.033 = 40,740.711 → 40,740
[방식 B] 각각 계산 후 절사
소득세 1,234,567 × 0.03 = 37,037.01 → 37,037
지방소득세 37,037 × 0.10 = 3,703.7 → 3,703
합계 40,740
[방식 C] 지방소득세를 지급액 기준으로
소득세 1,234,567 × 0.03 = 37,037
지방소득세 1,234,567 × 0.003 = 3,703.701 → 3,703
합계 40,740
세 방식이 우연히 같아 보이지만, 금액에 따라 1원씩 갈린다. **지방소득세는 소득세액의 10%**이므로 방식 B가 원칙이다.
/** 원 단위 절사 (10원 미만 버림이 아니라 1원 미만 버림) */
function truncateWon(v: number): number {
return Math.floor(v);
}
export function computeWithholding(gross: number, rate: { incomeTax: number }) {
const incomeTax = truncateWon(gross * rate.incomeTax); // 소득세
const localTax = truncateWon(incomeTax * 0.1); // 지방소득세 = 소득세의 10%
const total = incomeTax + localTax;
return { gross, incomeTax, localTax, total, net: gross - total };
}
부동소수점을 쓰지 않는 편이 안전하다. 금액이 커지면 1234567 * 0.03이 37036.999...로 나오는 경우가 있고, 그러면 절사 결과가 1원 틀린다.
// 정수 연산으로 (세율을 만분율로)
function computeWithholdingSafe(grossWon: number, rateBp: number) { // rateBp: 300 = 3%
const incomeTax = Math.floor((grossWon * rateBp) / 10_000);
const localTax = Math.floor(incomeTax / 10);
return { incomeTax, localTax, total: incomeTax + localTax, net: grossWon - incomeTax - localTax };
}
DB 컬럼도 NUMERIC이나 BIGINT(원 단위 정수)를 쓰고, 절대 FLOAT/DOUBLE을 쓰지 않는다. 부동소수점 타입의 오차 특성은 해당 문서에 명시돼 있다.
[검증 방법]
실제 지급 이력 1,000건을 두 구현으로 계산해 비교
1원이라도 다르면 그 케이스를 회귀 테스트로 고정
3. 원장 — 계산 결과가 아니라 계산 근거를 남긴다
지급 시점의 세율·구분·반올림 결과를 그때의 값 그대로 보관해야 한다.
CREATE TABLE payouts (
id BIGSERIAL PRIMARY KEY,
recipient_id BIGINT NOT NULL REFERENCES payout_recipients(id),
period DATE NOT NULL, -- 귀속 연월
paid_at TIMESTAMPTZ NOT NULL, -- 지급일
gross_won BIGINT NOT NULL, -- 지급총액 (정수, 원)
income_tax_won BIGINT NOT NULL,
local_tax_won BIGINT NOT NULL,
net_won BIGINT NOT NULL,
-- 계산 근거 스냅샷
income_type income_type NOT NULL,
rate_income NUMERIC(5,4) NOT NULL,
rate_local NUMERIC(5,4) NOT NULL,
calc_version TEXT NOT NULL, -- 'v2026.1' — 계산 로직 버전
CONSTRAINT net_matches CHECK (net_won = gross_won - income_tax_won - local_tax_won)
);
CHECK 제약이 실질적으로 유용하다. 코드 버그로 합이 안 맞는 행이 들어오는 걸 DB가 막아준다. 정산 데이터에서 이 한 줄이 잡아내는 사고가 생각보다 많다.
calc_version을 남기는 이유는 계산 로직을 고쳤을 때 "이 건은 어느 버전으로 계산됐는가"에 답하기 위해서다. 반올림 방식을 바꾸면 과거 데이터를 재계산할 수 없으므로, 버전으로 구분해 공존시켜야 한다.
귀속 연월(period)과 지급일(paid_at)을 분리하는 것도 중요하다. 12월분을 1월에 지급하면 둘이 달라지고, 신고는 지급일 기준이지만 정산 리포트는 귀속월 기준인 경우가 많다.
4. 지급명세서 — 월별 신고와 연간 제출
원천징수는 두 종류의 의무로 나뉜다.
[원천세 신고·납부]
지급한 달의 다음 달 10일까지
→ 지급 건별이 아니라 월 합계
[지급명세서 제출]
사업소득: 매월 제출 (간이지급명세서)
→ 받는 사람별 명세
시스템 관점에서 필요한 것은 집계 두 축이다.
-- 월별 원천세 신고용 합계
SELECT
date_trunc('month', paid_at)::date AS pay_month,
income_type,
count(*) AS cnt,
sum(gross_won) AS gross,
sum(income_tax_won) AS income_tax,
sum(local_tax_won) AS local_tax
FROM payouts
WHERE paid_at >= :from AND paid_at < :to
GROUP BY 1, 2;
-- 수령자별 명세
SELECT
r.user_id, r.income_type,
sum(p.gross_won) AS gross,
sum(p.income_tax_won) AS income_tax,
sum(p.local_tax_won) AS local_tax
FROM payouts p
JOIN payout_recipients r ON r.id = p.recipient_id
WHERE p.paid_at >= :from AND p.paid_at < :to
GROUP BY 1, 2;
제출 이력을 별도 테이블로 남긴다. 무엇을 언제 제출했는지 모르면 수정 신고가 불가능해진다.
CREATE TABLE withholding_filings (
id BIGSERIAL PRIMARY KEY,
pay_month DATE NOT NULL,
kind TEXT NOT NULL, -- 'monthly_tax' | 'simplified_statement'
filed_at TIMESTAMPTZ NOT NULL,
payout_ids BIGINT[] NOT NULL, -- 이 신고에 포함된 지급 건
amount_total BIGINT NOT NULL,
revision_of BIGINT REFERENCES withholding_filings(id), -- 수정 신고면 원본
note TEXT
);
payout_ids를 남기면 **"이 지급 건이 신고에 포함됐는가"**를 즉시 알 수 있다. 지급 후 신고 전에 취소·수정이 일어나는 경우가 실제로 자주 있고, 그때 이 배열이 없으면 대사가 불가능하다.
5. 취소·환불이 발생하면
지급을 취소하거나 금액을 정정할 때, 원본 행을 수정하지 않는다.
-- 취소는 역분개로
INSERT INTO payouts (recipient_id, period, paid_at, gross_won,
income_tax_won, local_tax_won, net_won, ...)
VALUES (:recipient, :period, now(), -:gross, -:incomeTax, -:localTax, -:net, ...);
**음수 행을 추가하는 방식(역분개)**이 원본 수정보다 나은 이유가 셋이다.
· 이미 제출한 신고와의 대사가 가능하다
(원본을 고치면 제출본과 DB가 어긋난다)
· 언제 취소됐는지가 시간축에 남는다
· 합계 쿼리를 바꿀 필요가 없다 (그냥 더하면 맞음)
신고 전 취소와 신고 후 취소는 처리가 다르다.
신고 전 → 역분개 후 원본과 함께 신고 대상에서 제외
신고 후 → 역분개 + 수정 신고 대상으로 표시
이 구분을 시스템이 자동으로 하려면 4절의 withholding_filings.payout_ids가 필요하다. 신고에 포함된 적 있는 지급 건인지 조회해서 분기한다.
6. 받는 사람에게 무엇을 보여줄 것인가
정산 화면에서 가장 많은 문의가 나오는 지점은 **"왜 이만큼만 들어왔나요"**다.
❌ 정산 완료 · 1,193,827원 입금
✅ 정산 내역
지급총액 1,234,567원
소득세 (3%) -37,037원
지방소득세 (0.3%) -3,703원
─────────────────────────
실지급액 1,193,827원
* 원천징수된 세금은 5월 종합소득세 신고 시
정산되어 일부 환급받을 수 있습니다.
마지막 안내 문장이 문의를 크게 줄인다. "떼였다"가 아니라 "미리 낸 것이고 정산된다"는 사실을 모르는 경우가 많기 때문이다.
이 설명을 얼마나 풀어써야 하는지는 대상에 따라 다르다. 야간·교대 근무자 대상 정보 사이트인 토닥의 3.3% 환급 안내를 보면, "3.3%가 무슨 돈인가 → 왜 4대보험이 아닌가 → 5월에 무슨 일이 일어나나 → 신고는 어떻게 하나" 순으로 단계를 나눠 설명한다. 세율을 알려주는 것과 "그래서 내가 뭘 해야 하는가"에 답하는 것은 다른 일이라는 걸 보여주는 구성이다. 정산 화면의 도움말도 이 순서를 참고할 만하다.
연간 자료 다운로드를 제공하는 것도 문의를 줄인다. 5월에 신고하려면 연간 지급총액과 원천징수액 합계가 필요한데, 이걸 못 받으면 전부 CS로 온다.
[내 정산] → [연간 자료]
2025년 지급 내역 (CSV)
2025년 원천징수 합계
7. 운영 점검 항목
[월 마감 체크]
□ 지급 건수 = 신고 대상 건수 (역분개 반영 후)
□ sum(net_won) = 실제 이체 총액 (은행 대사)
□ CHECK 제약 위반 0건
□ income_type 미지정 수령자 0명
□ 세율 테이블의 valid_to 가 지나지 않았는지
[연 1회]
□ 세율·구분 기준 변경 여부 확인 (담당자 지정)
□ calc_version 갱신 필요 여부
□ 연간 자료 다운로드 정상 동작
**"세율 확인 담당자를 지정한다"**가 기술 항목처럼 보이지 않지만 가장 중요하다. 코드는 바뀌지 않고 법이 바뀌므로, 이걸 사람이 챙기지 않으면 시스템은 조용히 틀린 답을 계속 낸다.
8. 정리
1. 소득 구분을 데이터로 받는다 (3.3%는 결과이지 전제가 아님)
2. 세율은 상수가 아니라 날짜 구간 설정 테이블로
3. 지방소득세 = 소득세액의 10%, 정수 연산으로 계산
4. 원장에 계산 근거(세율·구분·버전) 스냅샷을 남긴다
5. CHECK 제약으로 합계 정합성을 DB가 지키게
6. 취소는 원본 수정이 아니라 역분개
7. 신고 이력에 포함된 payout_ids를 남긴다
8. 사용자 화면에 공제 내역과 "5월에 정산된다"는 안내를 함께
이 중 하나만 먼저 넣는다면 5번의 CHECK 제약이다. 한 줄로 정산 데이터의 가장 치명적인 오류 유형을 막을 수 있고, 나중에 넣으려면 기존 데이터를 전부 검사해야 해서 훨씬 비싸진다.