| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- javascript
- Vite
- 크롤링
- Android
- 온디바이스AI
- electron
- 소셜로그인
- next.js
- contextBridge
- REST API
- github actions
- FastAPI
- php
- 도메인설계
- playwright
- 보안
- 백엔드
- typescript
- 아키텍처
- 인증
- 배포
- python
- JWT
- 프론트엔드
- Kotlin
- 마이그레이션
- 메일자동화
- React
- sqlite
- 상태관리
- Today
- Total
HM소프트
급여 계산 자동화 도구 개발기 (1) — 4대보험·세금 계산 로직과 엣지케이스 본문

4대보험·소득세 계산 엔진을 React에서 떼어 순수 함수로 짜고, 근로시간부터 통상시급·수당·과세급여·보험·세금까지 6단계 직선 파이프라인으로 순서를 못 박는다. 7가지 근로 유형 분기, 5인 미만 가산 비대칭, 항목별 절사 규칙, 국민연금 상한하한과 두루누리 감면, 간이세액표 lookup, 네트 역산출을 이분법으로 푸는 과정을 다룬다.
매달 반복되는 계산을 코드로 박제하기
매달 급여명세서를 만들 때마다 같은 계산을 반복했다. 기본급에서 4대보험을 빼고, 간이세액표를 뒤져 소득세를 떼고, 지방소득세를 붙이고, 실수령액을 맞춘다. 엑셀로 하다 보면 요율이 바뀐 해에 수식 하나를 놓쳐 전 직원 명세서를 다시 뽑는 일이 생긴다. 게다가 우리가 쓰는 인력 구성은 평범하지 않았다. 주 5일 정규직만 있는 게 아니라, 주 4일 이하 단시간 근로자, 격일 24시간 교대, 일급제, 그리고 "세후로 얼마 받게 해 달라"는 네트(Net) 계약까지 섞여 있었다. 엑셀 시트가 근로 유형마다 따로 자라났고, 결국 어느 시트가 최신인지조차 헷갈렸다.
그래서 급여와 4대보험 공제를 자동으로 계산해 주는 웹 도구를 TypeScript로 만들었다. 두 편으로 나눠 기록한다.
- (이 글) 4대보험·세금 계산 로직과 엣지케이스 — 계산 엔진의 내부
- 서버 없는 구조 — 순수 프론트엔드 전환과 정적 배포
이번 글은 화면 이야기가 아니다. UI는 거들 뿐이고, 이 도구의 본체는 core/ 디렉터리 안에 있는 계산 엔진이다. 처음엔 "요율 곱셈 몇 줄이면 끝나겠지" 싶었는데, 막상 짜 보니 요율 곱셈은 전체 코드의 10%도 안 됐다. 나머지는 전부 근로 유형 분기, 상한·하한, 항목별 절사 규칙, 비과세 분리 같은 예외 처리였다.
엔진을 화면에서 떼어내다 — core/ 와 src/
가장 먼저 한 결정은 계산 로직을 React에서 완전히 분리하는 것이었다. 급여 계산은 순수 함수다. 입력(근로조건·수당·연장근로·보험조건)을 넣으면 결정적인 결과(지급내역·공제내역·사업주부담·실수령액)가 나온다. 이걸 컴포넌트 안에 두면 테스트도 어렵고, 나중에 CLI나 배치로 재사용할 수도 없다. 그래서 core/에는 React를 단 한 줄도 import하지 않는 순수 TypeScript만 두고, src/(React)는 그 결과를 그리기만 하게 했다.

이 경계 덕분에 엔진은 브라우저든 노드든 어디서나 같은 결과를 낸다. 그리고 core/ 안에서도 데이터(tables), 규칙(engine), 계산 보조(utils)를 다시 갈랐다. 요율과 세액표처럼 매년 바뀌는 건 tables/에 데이터로, "어떻게 계산하느냐"는 engine/에 함수로 둔다. 이 분리가 이 글 전체를 관통하는 원칙이다 — 바뀌는 것(데이터)과 바뀌지 않는 것(규칙)을 섞지 않는다.
입력과 출력의 모양은 타입으로 못 박았다. 결과 객체는 단순히 "실수령액 얼마"가 아니라, 명세서에 찍힐 모든 항목과 사업주 부담분, 그리고 계산 근거(reference)까지 담는다.
export interface PayrollResult {
payment: PaymentDetail; // 기본급·주휴·수당·연장·야간·휴일·상여
deduction: DeductionDetail; // 국민연금·건강·장기요양·고용·소득세·지방소득세
employerCost: EmployerCostDetail; // 사업주 부담(+ 산재)
nonTaxableTotal: number;
netPay: number; // 실수령액
reference: ReferenceInfo; // 통상시급·소정근로시간 등 계산 근거
}
reference를 결과에 포함한 게 의외로 중요했다. 급여 계산은 "왜 이 금액이 나왔는지"를 설명할 수 있어야 한다. 통상시급이 얼마였고 월 소정근로시간이 몇 시간으로 잡혔는지를 결과에 같이 실어 두면, 누군가 "이 숫자 왜 이래요"라고 물을 때 화면만 보고 답할 수 있다.
6단계 파이프라인 — 순서가 곧 정확도다
메인 엔진 calculatePayroll()은 정확히 6단계를 순서대로 밟는다. 이 순서를 한 번이라도 뒤집으면 결과가 틀어진다. 예를 들어 4대보험은 비과세를 뺀 과세 급여를 기준으로 매겨야 하는데, 비과세 분리(2단계 이후)를 보험 계산(4단계)보다 늦게 하면 식대까지 보험료에 포함돼 버린다.

근로시간 → 통상시급 → 수당 → 과세급여 → 보험·세금 순이다. 앞 단계의 출력이 뒷 단계의 입력이 되는 직선형 파이프라인이라, 각 단계를 독립 함수로 떼어 두면 단계별로 검증하기 쉽다. 아래는 4·5·6단계가 맞물리는 핵심부다.

여기서 신경 쓴 건 과세 기준액(taxableSalary)을 만드는 곳을 한 군데로 모은 것이다. 보험도 세금도 "총지급 − 비과세"를 봐야 하는데, 이 변환을 호출하는 쪽마다 따로 하면 반드시 한 곳을 빼먹는다. Math.max(..., 0)로 음수 방어까지 같은 자리에서 처리한다. 비과세가 총지급보다 큰 비정상 입력이 들어와도 과세 기준이 음수로 떨어지지 않는다.
통상시급이라는 모든 계산의 뿌리
급여 계산에서 가장 먼저 정확히 잡아야 하는 숫자가 통상시급이다. 연장·야간·휴일수당이 전부 여기에 배수를 곱해 나오기 때문에, 통상시급이 1원 틀리면 모든 가산수당이 줄줄이 틀어진다. 그런데 통상시급을 구하려면 먼저 월 소정근로시간을 알아야 하고, 이건 주휴시간을 포함한다.
주휴시간 계산이 첫 번째 엣지케이스 덩어리였다. 단순히 "주 40시간이면 8시간"이 아니라, 단시간 근로자는 비례 적용을 받고, 초단시간(주 15시간 미만)은 아예 주휴가 없다.

MONTHLY_CONVERSION_FACTOR가 4.345라는 마법 같은 숫자인데, 365 / 7 / 12로 표현해 둔 이유가 있다. 한 해 365일을 7로 나눠 연간 주 수를 구하고, 12개월로 나눈 "월평균 주 수"다. 그냥 4.345라고 박아 두면 나중에 보는 사람이 어디서 온 값인지 못 알아본다. 식 자체를 코드로 남기면 그게 곧 주석이다.
주휴시간의 경계 처리에서 한 번 데었다. 주 15시간을 "초과"로 봐야 하는지 "이상"으로 봐야 하는지를 혼동해, 정확히 15시간인 근로자의 주휴가 0으로 떨어진 적이 있다. >= 15로 바로잡고 나서야 경계값 근로자의 급여가 맞았다. 급여 계산에서 부등호 방향은 거의 매번 사고가 난다 — 그래서 경계값(15, 40, 표 구간의 끝)마다 의식적으로 테스트 입력을 만들어 돌렸다.
7가지 근로 유형을 하나의 엔진으로
이 도구가 평범한 급여 계산기와 다른 지점이 근로 유형(WorkMode)이다. 같은 "급여 계산"이지만 일급제와 격일 교대제는 시작점부터 다르다. 이걸 모드별로 엔진을 따로 만들지 않고, 1·2단계(근로시간·통상시급)에서만 분기하고 3단계 이후는 공통으로 흐르게 했다.

격일 근무제의 3.5가 재미있는 부분이다. 격일은 "하루 일하고 하루 쉬는" 패턴이라 주 7일 중 절반인 평균 3.5일을 일한다. 24시간 교대에서 휴게를 뺀 실근로시간에 3.5를 곱하면 주 소정근로시간이 나오는데, 이게 법정 한도인 40시간을 넘으면 상한에서 멈춘다. 현실 인력의 패턴을 숫자 하나로 압축한 셈인데, 이런 도메인 상수야말로 주석과 함께 코드에 남겨 둬야 6개월 뒤의 내가 산다.
일급제는 통상시급의 출처가 아예 다르다. 일반 모드는 "통상임금 ÷ 월 소정근로시간"으로 시급을 역산하지만, 일급제는 입력된 일급을 1일 실근로시간으로 나눠 시급을 구한다. 그리고 3단계에서 기본급을 "일급 × 월 근무일수"로 다시 계산하는 특수 처리가 붙는다. 분기는 최소한으로 두되, 진짜 다른 부분만 모드별로 갈라 둔 결과다.
수당 엔진 — 5인 미만의 함정
3단계 수당 계산은 통상시급에 시간과 배수를 곱하는 일이다. 단순해 보이지만 여기에 5인 미만 사업장이라는 큰 갈래가 숨어 있다. 5인 미만은 연장·휴일근로의 가산(1.5배)이 법적으로 면제되지만, 야간근로 가산은 면제가 아니다. 이 차이를 한 곳에서 틀리면 영세 사업장 급여가 통째로 어긋난다.

extMultiplier와 holMultiplier는 사업장 규모에 따라 갈리지만 nightPay의 1.5는 무조건 고정이라는 점 — 이 비대칭이 핵심이다. 코드만 봐도 "야간만 분기 없이 1.5"가 눈에 띄도록 일부러 같은 모양으로 나란히 뒀다. 세 줄을 보면 어느 항목이 규모 의존이고 어느 항목이 아닌지 한눈에 들어온다. 가독성이 곧 검수 가능성이다.
기본급과 주휴수당은 통상시급에 (주 소정근로시간 + 주휴시간)과 월 환산 계수를 곱해 나온다. 이렇게 모든 수당이 통상시급이라는 단일 뿌리에서 갈라지므로, 1·2단계에서 통상시급만 정확히 잡으면 3단계는 곱셈일 뿐이다 — 절사만 빼면.
항목별 절사 규칙 — 십 원이 어긋나는 자리
급여 계산에서 가장 자주 어긋나는 게 절사 규칙이다. 처음엔 "전부 버림"으로 통일했다가 실제 명세서와 대조하니 항목마다 몇십 원씩 안 맞았다. 보험료는 십 원 미만을 버리고(trunc10), 수당은 원 미만을 버린다(truncWon). 규칙이 항목마다 다르다는 걸 인정하고, 그걸 함수 이름으로 박제했다.

세 줄짜리 함수지만, 이걸 인라인 Math.floor로 흩뿌리지 않고 이름 붙은 함수로 강제한 게 핵심이다. 코드 어디를 봐도 trunc10(...)이 보이면 "아, 여긴 십 원 단위 보험료구나"를 즉시 안다. 그리고 이 함수를 거치지 않은 금액은 화면에 내보내지 않는다는 규칙을 세웠다. JavaScript의 number로 돈을 다루면 0.1 + 0.2 !== 0.3 문제를 피할 수 없는데, 요율 곱셈 직후 즉시 절사해 정수로 돌려놓으면 소수가 떠다니다 1원 어긋나는 사고가 사라진다.
지방소득세는 절사가 두 번 겹친다. 소득세의 10%인데, 그 10%를 다시 십 원 단위로 버린다. Math.floor(incomeTax * 0.1 / 10) * 10 — 곱하고, 나누고, 버리고, 다시 곱한다. 한 줄에 절사 두 번이 들어가는 이런 자리가 가장 버그가 잘 나는 곳이라, 별도 함수 getLocalIncomeTax로 떼어 이름을 붙였다.
4대보험 — 상한·하한과 두루누리 감면
보험 엔진은 네 가지 보험과 산재, 그리고 두루누리 감면까지 처리한다. 가장 먼저 국민연금의 기준소득월액 상한·하한이 등장한다. 월급이 아무리 높아도 보험료는 상한에서 멈추고, 아무리 낮아도 하한 기준으로 매긴다. 이걸 빼먹으면 고연봉 케이스에서 공제액이 실제보다 부풀고, 저소득 케이스에서 모자란다.

장기요양보험에서 흔한 실수가 장기요양료를 급여에 곱하는 것이다. 실제로는 급여가 아니라 이미 계산된 건강보험료에 비율을 곱한다(healthEmployee * longTermRate). 즉 보험료의 보험료라는 연쇄 구조다. 코드에서 monthlySalary가 아니라 healthEmployee를 곱하고 있다는 점을 명시적으로 보이게 두는 게 중요했다.
두루누리 감면은 소규모 사업장 근로자의 국민연금·고용보험을 정부가 지원하는 제도다. 신규 가입은 80%, 기존은 36%를 감면한다. 보험료를 다 계산한 뒤 지원액을 빼는 방식이라, 감면 로직을 보험료 산정과 분리해 "계산 후 차감" 순서로 뒀다. 산재보험은 근로자 부담이 0이고 사업주 전액 부담이라 별도로 처리하는데, 이 모든 사업주 부담분을 employerCost로 따로 집계해 둔 게 결과 객체의 또 다른 축이다. 근로자 실수령액과 사업주 총비용을 한 번에 보여주기 위해서다.
요율은 절대 코드에 박지 않고 연도별 테이블에서 꺼낸다. 매년 1월이면 이 객체 하나만 갈아 끼우면 된다.

getInsuranceRates에 폴백을 둔 게 작은 안전장치다. 등록되지 않은 연도가 들어와도 최신 요율로 떨어지게 해서, 연도 업데이트를 깜빡해도 계산 자체가 멈추진 않는다. 산재보험료율도 같은 방식으로 14개 업종을 테이블로 두고 업종 코드로 조회한다 — 사무직 0.7%부터 건설업 3.7%까지 업종 격차가 크기 때문이다.
소득세 — 수식이 아니라 거대한 lookup
소득세는 계산식이 아니라 간이세액표 lookup이다. 월 급여 구간과 부양가족 수로 표를 찾아 세액을 얻는다. 표 자체는 국세청이 정하는 데이터라 코드에 박지 않고 구간 배열로 분리하고, 검색만 함수로 뒀다.

이 작은 함수에 엣지케이스가 네 개나 몰려 있다. ① 부양가족이 0이나 음수면 최소 1명(본인)으로, ② 11명을 넘으면 마지막 열로 고정, ③ 급여가 표 하한 미만이면 세액 0, ④ 급여가 표 상한을 넘으면 마지막 구간 적용. 그리고 구간 경계는 >= from && < to로 이상/미만을 명확히 했다. 한 번 이 부등호를 반대로 적어, 정확히 구간 경계 금액에서 한 칸 위 세액이 튀어나오는 버그가 있었다. 경계값(각 구간의 from)을 테스트 입력으로 만들어 두고 나서야 표를 안심하고 갱신할 수 있게 됐다.
한 가지 솔직히 적어 두면, 현재 표는 국세청 간이세액표 수백 행을 다 옮긴 게 아니라 주요 구간만 발췌한 상태다. 코드 주석에도 "마스터 엑셀 수령 후 전체 테이블로 교체 예정"이라고 남겨 뒀다. 핵심은 표가 바뀌어도 getSimplifiedTax의 검색 로직은 그대로라는 점 — 데이터와 규칙을 분리해 둔 보람이 여기서 나온다.
네트(Net) 역산출 — 이분법으로 거꾸로 풀기
마지막이 가장 까다로운 모드였다. "세후 280만 원을 손에 쥐게 해 달라"는 네트 계약은 역방향 계산이다. 그런데 세전 급여에서 실수령액으로 가는 함수에는 상한·하한·구간별 세액·절사가 잔뜩 끼어 있어, 깔끔한 역함수가 존재하지 않는다. 대수적으로 못 풀면 수치적으로 푼다 — 이분법(bisection)이다.

핵심 아이디어는 단순하다. 시급을 올리면 실수령액이 단조 증가하므로, 목표보다 적게 나오면 구간의 아래 절반을 버리고 더 나오면 위 절반을 버린다. 이렇게 절반씩 좁히면 최대 100번 안에 ±10원으로 수렴한다.

여기서 두 가지를 의식적으로 챙겼다. 첫째, 절사 때문에 정확히 목표 금액이 안 나올 수 있다. 실수령액은 십 원 단위로 절사되므로 280만 원 정각을 못 맞추는 입력이 존재한다. 그래서 매 반복마다 "지금까지 가장 가까웠던 결과(bestResult)"를 보관해, 완전 수렴에 실패해도 최선의 근삿값을 돌려준다. 둘째, 무한 루프 방지다. MAX_ITERATIONS = 100과 low > high 종료 조건을 둘 다 둬서, 어떤 입력에도 멈춘다. 500000 범위를 이분하면 19번이면 1원 단위까지 좁혀지니 100번은 넉넉한 안전마진이다.
이분법이 성립하는 전제는 "시급↑ → 실수령액↑이 단조"라는 것인데, 상한·하한·구간 세액이 끼어도 전체적으로는 단조 증가가 유지된다. 만약 비단조 구간이 있었다면 이분법이 깨졌을 텐데, 다행히 급여 구조는 계단형이라도 우상향이라 안전했다. 역방향 모드를 별도 엔진으로 두되 내부에서 순방향 엔진(calculatePayroll)을 그대로 호출한다는 점도 설계의 이득이다 — 순방향 로직이 한 군데뿐이니, 역산출이 순방향과 어긋날 일이 없다.
정리하며
- 급여 계산의 본체는 요율 곱셈이 아니라 근로유형 분기·상한하한·항목별 절사·비과세 분리 같은 예외 규칙이다. 곱셈은 10%, 나머지 90%가 예외였다.
- 엔진을 React에서 떼어
core/(순수 함수)와src/(화면)를 분리했고,core/안에서도 데이터(tables)·규칙(engine)·보조(utils)를 갈랐다. 바뀌는 것과 안 바뀌는 것을 섞지 않는다. - 메인 엔진은 6단계 직선 파이프라인. 과세급여(총지급−비과세)를 만드는 곳을 한 군데로 모아, 보험·세금이 같은 기준을 보게 했다.
- 모든 수당은 통상시급이라는 단일 뿌리에서 갈라진다. 5인 미만 사업장은 연장·휴일은 면제지만 야간 가산은 면제가 아니라는 비대칭을 코드로 드러냈다.
- 절사는 항목마다 다르다.
truncWon·trunc10처럼 규칙을 함수 이름으로 박제하고, 이 함수를 거치지 않은 금액은 화면에 내보내지 않는다. - 4대보험은 국민연금 상한·하한, 장기요양=건강보험료×비율의 연쇄, 두루누리 감면을 처리한다. 소득세는 간이세액표 lookup이고 구간·열 경계값 테스트가 필수다.
- 네트 역산출은 역함수가 없어 이분법으로 풀었다. 절사로 정확히 못 맞출 때를 대비해 최선 근삿값을 보관하고, 반복·구간 종료조건으로 무한 루프를 막았다.
다음 2편에서는 이 계산기를 서버 없이 순수 프론트엔드로 전환한 이야기 — 초기 구현에 남아 있던 백엔드·데스크톱 잔재를 걷어내고, 급여 데이터가 브라우저 밖으로 나가지 않는 구조와 정적 호스팅 자동 배포를 다룬다.
'외주 개발일지' 카테고리의 다른 글
| 브랜드 웹사이트 개발기 (2) — 기획 문서와 콘텐츠를 정확히 맞추기 (0) | 2026.06.23 |
|---|---|
| 브랜드 웹사이트 개발기 (1) — 서버 설정 없는 PHP + SQLite와 콘텐츠 관리 (0) | 2026.06.23 |
| 시간제 시설 예약 시스템 개발기 (3) — 시간표 무중단 전환·일괄 차단·SEO·테스트 (0) | 2026.06.23 |
| 시간제 시설 예약 시스템 개발기 (2) — 알림톡 대체 발송과 단말 POS 실시간 연동 (0) | 2026.06.23 |
| 시간제 시설 예약 시스템 개발기 (1) — 슬롯 도메인과 동시성, 그리고 자원 배정 (0) | 2026.06.23 |