서비스 : https://korea-payroll-api.greenteapie2023.workers.dev
API 문서 : https://korea-payroll-api.greenteapie2023.workers.dev/docs
GitHub : https://github.com/GreenteaPIE/korea-payroll-api
npm : https://www.npmjs.com/package/korea-payroll
시작하며
공휴일 API를 만들고 나서 다음으로 만들 API 를 고르다가, 검색이 가장 많은 계산 중 하나인 연봉 실수령액을 골랐다. 이미 만든 연차·급여일 계산과도 이어지고, 무엇보다 “숫자가 하나라도 틀리면 쓸모가 없다”는 점이 마음에 들었다.
실수령액 계산기는 이미 많지만, 대부분 블로그 글에 적힌 요율로 만들어져 있다. 요율이 언제 바뀌었는지, 10원 단위를 어떻게 버리는지까지 출처가 있는 계산기는 드물다. 그래서 목표를 이렇게 잡았다.
- 세금은 법령에 실린 표 그대로 쓴다
- 4대보험은 정부 계산기와 10원까지 같게 만든다
- 요율마다 출처와 적용 기간을 남긴다

1. 간이세액표는 계산식이 아니라 “표”다
매달 월급에서 떼는 소득세는 「소득세법 시행령」 [별표 2] 근로소득 간이세액표로 정해진다. 월급 구간(770천원 ~ 10,000천원)마다 공제대상가족 1~11명의 세액이 적힌 646줄짜리 표다.
표의 비고에 계산 방법(근로소득공제, 특별소득공제 일부 등)이 적혀 있어서 처음에는 공식으로 다시 계산해 볼까 했다. 하지만 표는 구간의 중간값으로 계산한 뒤 반올림한 결과라, 공식으로 만들면 구간 경계에서 몇십 원씩 어긋날 수 있다. 법이 정한 건 공식이 아니라 표에 적힌 금액이다.
그래서 법제처에서 별표 2 PDF 를 받아 표를 그대로 옮기는 쪽을 택했다.
// scripts/build-tax-table.ts (요약)
const text = execFileSync('pdftotext', ['-layout', pdf, '-'], { encoding: 'utf8' })
const ROW = /^\s*([\d,]+)\s+([\d,]+)\s+(.+?)\s*$/ // 이상 미만 가족1 ... 가족11
옮긴 뒤에는 표가 제대로 읽혔는지 검사한다.
- 구간이 770천원부터 10,000천원까지 빈틈없이 이어지는지
- 월급이 늘면 세액이 줄지 않는지, 가족이 늘면 세액이 늘지 않는지
2024년판과 2026년판(2026. 2. 27. 개정)을 둘 다 읽어 비교해 보니 646줄이 완전히 같았고, 8~20세 자녀 공제액만 바뀌었다 (1명 12,500원 → 20,830원). 그래서 표는 하나만 두고 자녀 공제만 기간별로 나눴다. 월급 1,000만원이 넘으면 표 대신 별표에 적힌 계산식(초과분 × 98% × 35% 등)을 쓴다.
2. 4대보험: 정부 모의계산기를 정답지로
4대보험은 요율만 알면 될 것 같지만, 어디서 무엇을 버리는지가 계산기마다 다르다. 4대사회보험 정보연계센터의 모의계산기는 월급을 서버로 보내 계산하는 구조라서, 브라우저로 페이지를 연 뒤 같은 요청을 보내 월급 24가지 × 기업 규모 4가지 = 96건의 결과를 받아 테스트 정답지로 저장했다.
결과를 거꾸로 분석해 보니 규칙은 이랬다.
| 항목 | 규칙 |
|---|---|
| 국민연금 | 월 소득의 천원 미만을 버린 기준소득월액 × 4.75%, 10원 미만 버림 |
| 건강보험 | 보수월액 × 3.595%, 10원 미만 버림 |
| 장기요양 | 10원 미만을 버린 건강보험료 × (0.9448% ÷ 7.19%), 다시 10원 미만 버림 |
| 고용보험 | 근로자 0.9%. 사업주는 0.9% 와 고용안정 요율(0.25~0.85%)을 따로 버린 뒤 더함 |
마지막 줄이 가장 놓치기 쉬웠다. 월 409,999원이면 사업주 몫을 1.15% 한 번에 계산하면 4,710원인데, 정답은 3,680원 + 1,020원 = 4,700원이다.
소수 오차를 없애려고 요율은 ppm(백만분율) 정수로 저장하고, 곱셈 결과를 정수 나눗셈으로 버린다.
export const mulPpm = (amount: number, ppm: number) => Math.floor((amount * ppm) / 1_000_000)
export const floor10 = (won: number) => Math.floor(won / 10) * 10
const pension = floor10(mulPpm(pensionBase, p.ratePpm / 2)) // 9.5% → 95,000 ppm
96건 중 딱 한 군데가 다르게 나왔다. 월 1억 2,772만원이 넘어 건강보험 상한에 걸리는 경우다. 모의계산기는 보수월액을 상한으로 자른 뒤 곱해서 4,591,730원, 「월별 건강보험료액의 상한과 하한에 관한 고시」는 월 보험료 상한을 9,183,480원으로 정하고 있어 절반은 4,591,740원이다. 고시를 따르기로 하고, 이 10원 차이는 테스트와 README 에 이유를 적어 두었다.
3. 요율은 “언제부터”까지 데이터다
| 항목 | 바뀌는 시점 |
|---|---|
| 국민연금 요율 | 1월 (2026년 9.5%, 2027년 10%, 2033년까지 매년 0.5%p) |
| 국민연금 상·하한 | 7월 (2026.7~ 41만~659만원) |
| 건강보험·장기요양 요율 | 1월 |
| 건강보험료 상·하한 | 1월 (보건복지부 고시) |
| 간이세액표·자녀 공제 | 시행령 개정일 (2024.3, 2026.3) |
그래서 요율을 연도가 아니라 적용 시작 월(from)이 있는 기간 목록으로 저장하고, 계산할 때 기준 월에 맞는 기간을 고른다. 각 기간에는 출처를 붙였고, 상·하한 고시는 법제처에서 연혁을 따라가 2024·2025·2026년 원문을 확인했다.
2027년 건강보험료율은 7.19% 동결로 정해졌지만, 장기요양 요율과 상·하한은 아직 발표 전이다.
이런 값은 2026년 값을 쓰되 결과에 provisional: true 를 붙여 알린다.
4. 로컬에서 띄워 보고 찾은 버그
단위 테스트 150여 개가 다 통과해서 안심하고 wrangler dev 로 띄웠는데, Worker 가 아예 시작되지 않았다.
Uncaught TypeError: Incorrect type for map entry 'VERSION':
the provided value is not of type 'function or ExportedHandler'.
진입 파일에서 export const VERSION 을 함께 내보낸 게 원인이었다. Workers 런타임은 진입 모듈의 export 를 모두 핸들러로 보기 때문에, 문자열을 내보내면 시작하지 못한다.
테스트는 app.request() 로 앱만 불러서 이걸 잡지 못했다. 고친 뒤 진입 파일은 default 하나만 내보낸다는 테스트를 추가했다.
화면에서도 하나를 더 찾았다. 연봉별 표의 실수령액 칸에 붙인 class="net" 이 계산기의 큰 숫자 스타일(.net)과 겹쳐 표 숫자가 거대하게 나왔다. 스크린샷으로 확인하지 않았다면 그대로 배포됐을 것이다.
5. 배포: 비밀값은 저장소끼리 공유되지 않는다
새 저장소라 Cloudflare 키를 다시 넣어야 했는데, GitHub Secrets 는 한 번 저장하면 아무도 읽을 수 없어서 다른 저장소로 옮길 수 없다. 대신 키가 있는 공휴일 API 저장소에 이 저장소를 받아 테스트하고 배포하는 워크플로를 두었다. 두 API 가 같은 Cloudflare 계정이라 가능한 방법이다.
마치며
이번 프로젝트에서 가장 시간이 많이 든 건 코드가 아니라 “이 숫자의 근거가 뭔가”를 찾는 일이었다. 블로그마다 다른 요율, 출처가 다른 상·하한, 계산기마다 다른 10원 단위를 하나씩 원문으로 확인하고, 확인한 내용은 테스트로 고정했다.
curl "https://korea-payroll-api.greenteapie2023.workers.dev/v1/net-pay?annual=40000000&nonTaxable=200000"
같은 계산은 npm 라이브러리로도 쓸 수 있다. GitHub Actions 에서 provenance(빌드 출처 증명)를 붙여 배포했다.
// npm install korea-payroll
import { netPay } from 'korea-payroll'
netPay({ annualSalary: 40_000_000, nonTaxable: 200_000, month: '2026-10' }).netPay // 2935813
API 키 없이 무료로 쓸 수 있다. 계산이 다르게 나오는 경우를 찾으면 GitHub 이슈로 알려 주시면 고치겠습니다.
GreenteaPIE