법정공제 시스템 설계 (세무 처리 1차) — 기획 초안
급여정산의 1차 세무 처리로서, 국내 규제에 해당하는 급여 법정공제 항목(4대보험·소득세·지방소득세 등)을 시스템이 계산하도록 만들기 위한 기획 문서. 향후 세무 시스템(원천세 신고·연말정산 등)으로의 확장을 전제로 하되, 이 문서의 범위는 월 급여 공제 계산까지다.
상태: v2 (2026-08-20) — §7의 결정 8건 확정. PoC 6차까지 구현·머지(PR #579)되어 4대보험·소득세가 실제로 계산·커밋된다. 개발 착수 전 선행 과제: 법적 책임 구조 법률 검토 (§7 결정 4).
읽는 순서 주의. §0(현황)과 §4-5(선결 수리)는 2026-08-19 착수 시점의 스냅샷이다. 그 뒤 PoC가 4대보험·소득세를 동작시켰으므로, 지금 무엇이 되고 무엇이 안 되는지는 §6-1~§6-7과 각 절의
현황표기를 봐야 한다. §0만 읽으면 "계산 경로가 하나도 없다"로 오해한다.
핵심 요구사항:
- 공제 계산은 기존 sheet 기능 위에서 동작한다.
- 관련 법령·시행규칙을 면밀히 반영하고, 개정 시 지속적으로 갱신할 수 있어야 한다.
- 공제 항목이 시스템에 제대로 반영되고 있는지 세무사가 audit할 수 있어야 한다.
- 무엇보다 공제 항목이 빠지지 않고(누락 없이) 적용되는 방식이어야 한다.
문서 구성: §0 현황 → §1 무엇을 계산해야 하나(도메인) → §2 남들은 어떻게 하나(리서치) → §3 누락을 어떻게 막나(핵심) → §4 hereby에 어떻게 얹나(설계) → §5 운영 → §6 로드맵 → §7 결정 사항.
0. 현황 — 코드와 문서에 지금 있는 것
2026-08-19 코드베이스 전수 조사 결과 요약. 파일·라인 단위 상세는 조사 보고서 원문 참조.
한 줄 요약 (착수 시점): 공제 레코드를 담는 원장(ledger)과 시트 커밋 파이프라인은 완성돼 있지만, 법정공제 금액을 실제로 계산해서 그 원장에 넣는 경로는 현재 하나도 살아있지 않다.
→ 2026-08-20 현재: 이 공백은 PoC로 메워졌다. 4대보험·소득세가 시스템 템플릿의 수식/모듈로 계산되고 원장까지 커밋된다(§6-1~§6-7). 아래 두 표는 당시 진단 기록으로 남기되, 각 행의 현황 열이 지금 상태를 가리킨다.
0-1. 이미 있는 것 (이 기획이 재사용할 자산)
| 자산 | 상태 |
|---|---|
공제 원장 DeductionRecord | sheet lineage 3필드(sourceSheetId/RowId/ColumnKey) NOT NULL + 유니크 인덱스. 시트 "✓ 커밋"이 유일한 생성 경로 (직접 create API는 의도적으로 throw). 감사 필드 inputSnapshot·appliedParameters·formulaVersion 보유 |
커밋 파이프라인 commitSheet | sweep-then-insert(재커밋 안전), 수식 컬럼도 리졸브 후 커밋, 다중 직원 분배, 급여대장 aggregate 앵커(targetSheetSlug/targetColumnKey) 합산까지 완결 |
파라미터 스토어 OrganizationParameter + OrganizationParameterVersion | 엔티티·라우터·SDK·admin 설정 UI 전부 존재, 실사용 중(인센티브). Version에 effectiveFrom이 있어 시행일별 이력 저장 가능 |
| 수식 엔진의 시점 정합 파라미터 참조 | 시트 수식 [$namespace.key]가 시트 귀속월 기준으로 그 시점에 유효했던 파라미터 버전을 자동 선택 (resolver.ts L2938~). 요율 개정 대응의 핵심 메커니즘이 이미 구현돼 있다 |
| 수식 엔진 일반 | 24개 함수(MIN/MAX/ROUND/IF 포함) + SUMIF(S)/COUNTIF(S) + 크로스시트 참조 + 사이클 감지 — 상하한(cap/floor) 같은 산식을 수식만으로 표현 가능한 수준 |
| codeModule | insurance.ts(4대보험 요율 4개 파라미터화) / tax-bracket.ts(누진 직산) + 컬럼 설정 UI(모듈·키 선택, 파라미터 폼)까지 존재 |
| 연도별 세율 구간 테이블 | TaxBracketConfig (effectiveYear + CRUD) — 유일하게 실재하는 법정 요율 테이블 |
0-2. 없는 것 / 깨진 것 (진단 = 2026-08-19)
현황열은 2026-08-20 재검토 결과다. 10건 중 5건이 PoC로 해소됐고, 4건이 남았다.
| 문제 | 내용 | 현황 (2026-08-20) |
|---|---|---|
| 🔴 4대보험·소득세 computed 컬럼이 서버에서 동작 안 함 | compute.ts의 입력 로더가 "insurance"/"tax_bracket" 문자열로 분기하는데 enum 실제 값은 "payroll_rate_config"/"tax_bracket_config" — 모든 분기를 통과 못 해 빈 입력을 받고 조용히 아무 셀도 안 씀. UI에서는 모듈 선택이 가능하게 보임 | ✅ 해소 (§6-1) |
🔴 DeductionPipeline 미실행 | generateForEmployee() 호출부가 레포 전체에 0건 — 메타데이터 조회에만 쓰임 | ⬛ 설계상 폐기 — 결정 6(수식 일원화)이 이 경로를 대체한다. 호출부는 여전히 0건이며 정리 대상 |
| 🔴 국민연금 상·하한 미구현 | codeModule 주석이 "상하한은 elsewhere에서 적용"이라 하나 그 코드는 존재하지 않음 | ✅ 해소 — 템플릿 수식의 MIN/MAX 클램프 + 「연금 기준소득월액」 열로 가시화 (§6-1) |
| 🔴 간이세액표 전무 | 레포 전체 grep 0건. 현행 tax-bracket은 "월급×12 연환산 후 누진세율 직산" — 실제 원천징수 방식(간이세액표 조회 + 부양가족 수)과 다름 | ✅ 해소 — 공식 실표 7,128행 적재 + 자녀 차감 + 1,000만 초과 산식 (§6-6·§6-7) |
| 🔴 유일하게 "동작하는" 4대보험 = seed 템플릿 하드코딩 수식 | 2024년 요율이 수식에 박제(* 0.045 등). 커밋 컬럼 미설정이라 원장에 도달하지도 않음. 장기요양 공식이 codeModule(건보료×12.95%)과 seed 수식(급여×0.4591%)에서 서로 다름 | 🟡 부분 — 신규 법정공제 템플릿은 [$statutory.*] 참조. 그러나 퇴사정산 템플릿에는 2024년 요율이 그대로 박제돼 있다 (seed-sheet-templates.ts L2147 설명·L2190·L2199) |
🔴 commitSheet가 sourceType/formulaVersion 미기록 | 공제 출처 구분 불가 + 소득세 계산의 priorDeductions 필터가 NULL 비교로 무력화 | ✅ 해소 (§6-1) |
| 🔴 연말정산·퇴사정산 → 공제 반영 경로 2곳이 NOT NULL 위반으로 실패 | year-end-settlement.router.ts L290~, resignation-record.router.ts L390~ — lineage 없이 INSERT 시도 | ❌ 미해소 |
| ❌ 법정 vs 임의 공제 구분 수단 없음 | DeductionSubCategory enum은 정의만 되고 사용처 0건. 구분은 reason 문자열뿐 | ❌ 미해소 (§4-4 과제) |
| ❌ 요율 데이터 없음 | 4대보험 요율을 담은 organization_parameters seed 없음, namespace 명명 규약 미정. core의 INSURANCE_RATES 상수는 프로덕션 사용처 0건인 dead constant | ✅ 해소 — statutory.* 7종 시드 + 전 조직 일괄 갱신 스크립트 + 신규 조직 자동 시드 (§6-1·§6-5 ④) |
| 🟡 eCount 공제 import = 미완성 스캐폴드 | 현재 기획돼 있던 운영 경로: hereby→eCount로 수당 export → eCount 급여모듈이 공제 계산 → xlsx를 다시 import→커밋. 스테이징·커밋 배관은 있으나 컬럼 매핑 파서 미완성 (§7 방향 D) | ❌ 미해소 (결정 2 — 모드의 하나로 유지) |
0-3. 문서에 정리된 상태
| 문서 | 상태 |
|---|---|
payroll-schema-refactor.md | 골격은 이 기획과 정합: 금액은 원장에만, "요율·배수·단가는 Layer 2(sheet column metadata + org parameter store)에만" 원칙 명시. 단 요율을 어떤 namespace로 저장할지, 연도별 개정을 어떻게 처리할지는 미정의. 미결 #5: 휴직 시 보험료 일할계산 calculator 부재 |
payroll-provenance.md | 일부 stale — PayrollRecord에 국민연금 등 개별 컬럼이 있다고 서술하나 실제 엔티티엔 totalDeductions뿐, itemCodeId drop(migration 1816) 미반영 |
calculators.md | DeductionPipeline 흐름을 서술하지만 그것이 실행되지 않는다는 사실은 없음 |
korean-payroll.md (guides) | 4대보험 요율·세율표 참고 자료 — 코드와 연결 없는 정책 문서 |
| 간이세액표·요율 개정 프로세스 문서 | statutory-deduction-release-validation.md(홈택스·공단 표본 대조 절차 + 러너), legal-review-questions-statutory-deduction.md(결정 4 질의서), packages/database/data/README.md(실표 출처·변환 검증 체크리스트) |
정리: 문서의 아키텍처 원칙(3계층, 요율은 L2)은 유효하고 이 기획의 전제로 삼을 수 있으나, "법정공제를 실제로 계산하는 층"은 문서·코드 모두에서 설계 공백이다. 이 문서가 그 공백을 채운다.
1. 계산해야 하는 것 — 법정공제 도메인 지도
이 절의 모든 요율·금액은 2026-08 리서치 기준이며 구현 시 관보·공단 고시로 재확인해야 한다. 바로 이 "수치가 계속 바뀐다"는 사실이 §3~§5 설계의 출발점이다.
1-1. 공제 항목 전체 목록
상시 법정공제 — 모든 상용 근로자 급여에서 매월 공제 (6종)
| # | 항목 | 2026 요율/방식 (근로자 부담분) | 근거 법령 | 관장 기관 |
|---|---|---|---|---|
| 1 | 국민연금 | 기준소득월액 × 4.75% (하한 41만·상한 659만) | 국민연금법 §88 | 국민연금공단 |
| 2 | 건강보험 | 보수월액 × 3.595% | 국민건강보험법 §69·70 | 건보공단 |
| 3 | 장기요양보험 | 건강보험료 × 13.14% ÷ 2 (고지서는 건보료 연동 방식) | 노인장기요양보험법 §9 | 건보공단 |
| 4 | 고용보험 | 보수 × 0.9% (실업급여 계정만 — 고안·직능 요율은 사업주 전용, 근로자 전가 시 위법) | 보험료징수법 §13·14 | 근로복지공단 |
| 5 | 소득세 | 간이세액표 조회 (월급여 구간 × 공제대상 가족 수 × 자녀 수) + 근로자별 80/100/120% 선택 | 소득세법 §134, 시행령 §194·별표2 | 국세청 |
| 6 | 지방소득세 | 소득세 × 10% (절사 규칙 별도) | 지방세법 §103조의13 | 지자체 |
상황부 법정공제 — 조건 충족 시 공제 의무 발생
| 항목 | 트리거 | 계산 방식 |
|---|---|---|
| 학자금 상환(ICL) 원천공제 | 국세청이 회사에 통지 (6월) | 통지된 연 상환액 ÷ 12를 7월~다음해 6월 공제 |
| 급여 압류·가압류 | 법원 명령 수령 | 구간식 계산, 압류금지 하한 월 250만 (2026.2.1~) |
| 건강보험 연말·퇴직 정산분 | 매년 4월 / 퇴직 시 | 공단 정산 고지액 |
| 소득세 연말정산 추납/환급 | 매년 2~3월 급여 | 연말정산 결과액 |
법정공제가 아닌 것 (혼동 주의)
- 산재보험은 전액 사업주 부담 — 급여명세서 공제란에 나타나면 그 자체가 오류. 시스템이 이를 구조적으로 막아야 한다.
- 임의공제 (노조비·사우회비·사내대출 상환, 그리고 라온 미팅 §5의 상품권·기숙사·주차장 공제 등): 근로기준법 §43 전액불 원칙상 법령 또는 단체협약 근거 없는 공제는 위법. 법정공제와 별도 카테고리로 분리하고, 임의공제 항목에는 근거(동의) 메타데이터를 요구해야 한다.
1-2. 계산 유형은 3가지뿐이다
모든 법정공제는 다음 3가지로 환원된다. 이 분류가 엔진 설계의 골격이 된다.
| 유형 | 항목 | 시스템이 필요로 하는 것 |
|---|---|---|
| (a) 요율 계산형 | 국민연금·건강·장기요양·고용 | 시행일별 요율 + 상하한 + 기초금액(비과세 제외 보수) |
| (b) 조회표형 | 소득세(간이세액표) + 지방소득세(×10%) | 시행일별 구간표(수천 행) + 부양가족 수 축 + 절사 규칙 |
| (c) 통지 금액형 | ICL·압류·정산분·공단 고지액 오버라이드 | 외부에서 통지된 금액/기간의 입력·분할·종료 관리 |
1-3. "잠정 징수 → 정산" 이중 구조
월 공제액은 대부분 확정액이 아니라 잠정액이다:
- 건강보험: 신고 보수월액 기준 잠정 부과 → 3월 보수총액 신고 → 4월 정산(추가징수/환급). 퇴직 시 퇴직정산.
- 소득세: 간이세액표는 잠정 징수 — 연말정산이 확정.
- 국민연금: 5월 소득총액 신고 → 매년 7월 기준소득월액 갱신으로 공제액 변동.
"월 공제"와 "정산 이벤트 공제"는 별개 모델이다. Phase 1 범위는 월 공제이되, 정산분이 (c) 통지 금액형으로 들어올 자리는 처음부터 둔다.
1-4. 규칙이 바뀌는 주기 — 서로 다르다
| 항목 | 변경 주기 | 최근 사례 |
|---|---|---|
| 국민연금 요율 | 법 개정 시 — 2026~2033년 매년 0.5%p 인상 스케줄 확정 (9.5%→13%) | 2026.1월 9.5% |
| 국민연금 상·하한 | 매년 7월 1일 | 2026.7월 41만/659만 |
| 건강보험 요율 | 매년 (전년 말 고시 → 1월 1일 적용) | 2026년 7.19% |
| 장기요양 요율 | 매년 (전년 말) | 2026년 13.14% |
| 고용보험 요율 | 비정기 | 2022.7월 이후 유지 |
| 간이세액표 | 비정기 — 연중 개정 가능 | 2026년은 3월 1일 지급분부터 신표 (1~2월은 구표) |
| 압류금지 하한 | 비정기 | 2026.2.1 185만→250만 |
같은 해 안에서도 항목마다 시행일이 다르다. 연 1회 일괄 업데이트로는 대응 불가 — 파라미터마다 독립된 시행일 버전이 필수인 이유다. 다행히 §0-1대로 시점 정합 조회 메커니즘은 이미 있다.
1-5. 특수 케이스 체크리스트
적용/제외 판정 로직(§3 Layer 1)이 알아야 하는 조건들. 상세 수치·근거는 리서치 원문 참조.
- 일용직·단시간: 보험별 가입 기준이 다름 (국민연금 월 8일/60시간/소득 기준, 건보 월 8일, 고용보험은 주 15시간 미만 초단시간 제외). 일용근로 소득세는 간이세액표가 아닌 분리과세 별도 산식.
- 60세 이상: 국민연금 자격 상실 → 공제 중단 (계속 근무해도). 건강·고용은 연령 무관 (65세 이후 신규 고용 시 고용보험 실업급여 미적용).
- 이중근로: 국민연금 상한 초과 시 사업장별 안분 — 자사 데이터만으로 계산 불가 → 고지액 오버라이드 경로 필수. 고용보험은 주 사업장에서만.
- 비과세 소득: 식대 20만·자가운전보조금 20만 등은 소득세뿐 아니라 4대보험 산정 기초에서도 제외 → 급여 항목에 "비과세 여부+한도" 속성 필요. 일부 항목은 보험별 포함 여부가 달라 예외 테이블 재확인 필요.
- 휴직: 부분월 휴직 시 기본급 일할계산 → 보험료 기초 변동 (payroll-schema-refactor 미결 #5와 동일 항목).
- 두루누리: 지원금은 고지서에서 차감 — 공제 계산은 정상 산출, 명세서 표시 방식은 정책 결정 필요.
- 피부양자: 건보 공제액에 영향 없음 (부양가족 수는 간이세액표에만 영향).
2. 타 시스템 리서치 — 규칙을 최신·누락 없이·감사 가능하게 유지하는 법
2-1. Rules-as-code: 파라미터와 수식의 분리 (OpenFisca)
프랑스 정부 오픈소스 OpenFisca는 법령 규칙 코드화의 사실상 표준 참조 모델이다 (프랑스·뉴질랜드·캐나다 정부 채택).
- 요율·상한·기준액은 코드가 아니라 파라미터 — 법이 바뀌면 코드는 그대로 두고 파라미터에 새 시행일 값만 추가.
- 파라미터는 시행일별 값의 목록:
2025-01-01: 7.09%,2026-01-01: 7.19%… 계산은 항상 "귀속 기간"을 받아 그 시점의 유효값을 조회 → 과거 월 재계산 시 자동으로 당시 요율 적용. - 파라미터마다 법령 근거 메타데이터(
metadata.reference) — 감사 시 "이 값의 출처"를 즉답. - 누진 구간표는 1급 타입 — threshold+rate 목록, 구간 값도 개별 버전 관리.
- 폐지/미입력 파라미터는 명시적 에러 — 유효값 없는 기간의 계산은 0이 아니라 실패. 조용한 누락을 타입 수준에서 차단.
- 회귀 검증은 입력→기대 산출의 선언적 YAML 테스트를 규칙 저장소에 동거시키는 방식.
참고: 법조문 대응 검증 언어 Catala는 미국·프랑스 세법을 코드화하는 과정에서 공식 구현체의 버그를 발견 — 법조문을 구조적으로 코드화하는 행위 자체가 검증 수단이 된다.
2-2. 상용 엔진: 세율표는 벤더 책임 + 버전 릴리스
- QuickBooks: 세율표를 버전 번호 + 릴리스 노트가 붙은 payroll update 패키지로 배포 ("릴리스 22613: 신규 원천징수 테이블, 주 실업보험 상한 조정…").
- ADP: 전담 조직이 11,000여 관할구역 법령을 상시 모니터링. Gusto: 계산·신고·납부를 통째로 서비스로 흡수.
- Symmetry Tax Engine (급여 SaaS들이 임베드하는 계산 전용 엔진): 핵심은 계산기가 아니라 연구 조직 — 전임 세무 리서치 팀이 고시를 모니터링해 요율 테이블로 컴파일하고, 변경 시 구독 고객에게 알림 발송. 상위 티어는 "왜 이 세액인가" 설명 기능까지 상품화 (문의 티켓 74% 감소 주장).
공통 패턴: 요율 데이터 + 변경 알림 + 설명가능성이 하나의 상품 단위.
2-3. 한국 서비스
| 서비스 | 접근 방식 |
|---|---|
| 더존 (Smart A/WEHAGO) | 요율·간이세액표·공휴일을 프로그램 업데이트로 배포, 4대보험 자동계산(두루누리 감면 포함), WEHAGO T로 4대보험 일괄신고 연계 |
| 뉴플로이 | "법·요율이 바뀌어도 사용자는 아무것도 안 해도 된다"를 명시적으로 홍보. 파트너 회계법인이 원천세 신고·납부까지 수행 — 전문가 검수·신고 책임을 플랫폼 뒤에 결합 |
| flex | 법 개정 자동 반영 명시 + 공제 산정 방식의 다중화: 4대보험을 (a) 공단 고지서 업로드 (b) 보수월액 기준 산정 (c) 수동 업로드 중 조직이 선택. 절사 정책·계산 방식도 조직별 설정으로 노출 |
| 시프티 | 매년 "요율 변경 총정리" 콘텐츠 발행 — 요율 최신성을 마케팅 자산화 |
한국 시장에서 "요율 자동 업데이트"는 벤더 책임으로 흡수하는 것이 표준 기대치. 차별화는 (1) 신고·납부까지 수직 통합, (2) 세무사/회계법인 파트너 검수, (3) 컴플라이언스 콘텐츠.
특히 flex의 "정률 계산 vs 고지 기반" 이원화는 중요한 통찰이다: 4대보험의 진실 원천은 결국 공단 고지액이고, 정률 계산은 그 예측치다. 시스템이 이 차이를 인지해야 한다 (§1-3, §3 Layer 4).
2-4. 정합성 검증 기법
- HMRC PAYE Recognition (영국): 정부가 표준 테스트 시나리오를 제공해 급여 소프트웨어를 검사·인증 목록 게시 — "정부가 golden test set을 주는" 모델. 한국엔 이 제도가 없지만, 국세청 간이세액표 공표값·홈택스 모의계산·공단 모의계산기가 사실상의 golden 소스 역할을 할 수 있다.
- 간이세액표는 기계가독 소스가 존재: 공공데이터포털 파일데이터(Excel/CSV) + 홈택스 배포. 실시간 API는 없음 → "파일 파싱 → 스테이징 → 사람 승인 → 반영" 반자동 파이프라인이 현실적.
- 병행운영(parallel run): 급여 시스템 전환의 표준 검증 절차 — 기존 계산(수기/세무사/타 시스템)과 신규 모듈을 동일 기간 동시 운영, gross·공제·net을 라인 단위 대조, 불일치를 원인별 분류·해소 후 절체.
- SAP 소급 처리(retro accounting): 과거 확정분은 불변으로 보존하고, 소급 변경은 재계산 차액을 당기 급여에 별도 항목으로 반영. 직원별 소급 하한일로 마감 통제. "확정분 보존 vs 재계산" 논쟁의 업계 표준 답.
3. 핵심 문제: 공제 항목 누락을 어떻게 막는가
누락은 세 층위에서 발생하고, 층위마다 방어 장치가 다르다. §0-2의 "모듈 키 불일치로 셀이 조용히 안 써지는" 버그가 바로 이 시스템의 실물 사례다 — 조용한 실패를 구조적으로 불가능하게 만드는 것이 이 설계의 제1원칙이다.
| 누락 층위 | 예시 | 방어 |
|---|---|---|
| 항목 누락 — 있어야 할 공제가 아예 없음 | 조직 세팅 시 장기요양 누락; ICL 통지 미반영 | Layer 1 |
| 값 누락/부패 — 항목은 있는데 요율이 구버전 | 2027년 급여를 2026년 건보 요율로 계산 | Layer 2 |
| 적용 오류 — 요율은 맞는데 대상 판정이 틀림 | 60세 직원에게 국민연금 공제 지속; 비과세 미제외 | Layer 3~5 |
Layer 1 — 법정공제 레지스트리와 전수 판정 (구조적 방어)
시스템이 아는 법정공제 항목의 전체 카탈로그를 시드 데이터로 유지한다 (§1-1의 표가 초기값). 각 항목은 적용 판정 조건(고용 형태·연령·근로 시간 등)과 계산 유형(§1-2)을 갖는다.
핵심 규칙: 월 급여 확정 시, 재직자 전원 × 레지스트리 전 항목에 대해 "적용/제외" 판정을 강제 수행하고 결과를 기록한다. 제외는 반드시 사유 코드(60세 도달·초단시간·이중근로 부사업장…)와 함께 남는다. "판정 자체가 없는" 상태 — 조용한 누락 — 를 만들 수 없게 하는 것이 목적이다. 세무사 audit은 이 판정 매트릭스(전원 × 전 항목 × 적용/제외+사유)를 보는 데서 출발한다.
[정정 2026-08-20 → 확정 2026-08-21] 판정 없는 확정을 막는다 — 단, 판정 불가는 확인으로
⚠️ 이 절은 두 번 바뀌었다. 2026-08-20 에 fail-safe(그냥 통과) → 차단으로 되돌렸고, 2026-08-21 기획 확정(§7 ①)으로 차단 → 확인이 됐다. 아래 표의 T1 이 그 최종 상태다. 「차단」이라고 적힌 옛 서술이 남아 있으면 이 경고가 우선한다.
PoC 구현(§6-4 ①)은 fail-safe를 택했다: 제외 판정이 명시적으로 참일 때만 공제를 0으로 만들고, 판정 입력이 없으면 공제를 그대로 계산해 통과시킨다. 이는 위 핵심 규칙("판정 자체가 없는 상태를 만들 수 없게")과 정면으로 어긋난다.
결론: 기획을 구현에 맞추지 않고, 구현을 기획에 맞춘다. 판정 없이 확정된 급여를 되돌리는 비용이, 판정을 요구해 확정을 늦추는 비용보다 크다.
다만 막는 것과 확인받는 것은 다르다. 2026-08-21 확정(§7 ①)은 판정 불가를 막지 않는다 — 직원 한 명의 생년월일이 비었다고 그 달 급여 전체가 멈추는 것은 실무에서 감당되지 않는다. 대신 몇 명인지 반드시 보고 넘어가게 하고, 넘어간 사실을 리포트에 남긴다. 금액이 틀리게 되는 조건(기초 부재)만 막는다.
| # | 작업 | 상태 |
|---|---|---|
| T2 | 제외 사유를 원장에 동결 — DeductionRecord.exclusionReason + 열 메타 exclusionReasons(사유별 판정 열 매핑) | ✅ 구현 (migration 1886) |
| T1 | 판정 불가 행은 막지 않고 확인받는다 — 반영 직전 「판정 불가 N명」 확인 + 결과 리포트 기록 (열 메타 commitWarnings, 마이그 1890). ⚠️ 2026-08-20 에 차단으로 구현했다가 기획 확정 ①로 뒤집었다 | ✅ 구현 (확인 방식) |
| T6 | 기초 부재는 그대로 막는다 — 보수월액이 없으면 하한 클램프로 최소 보험료가 조용히 계산된다(§4-2b). 판정 불가와 갈리는 기준은 "넘어가면 금액이 틀리는가" | ✅ 구현 (차단 유지) |
| T3 | 자동 판정 오버라이드 입구 — 이중근로·일용직 등 예외를 사람이 뒤집고 사유를 남긴다 | ✅ 구현 |
순서는 T2 → T1 → T3: 사유를 담을 자리가 먼저 있어야 게이트가 "무엇이 판정됐고 무엇이 아닌지"를 판단할 수 있고, 오버라이드도 그 위에 얹힌다.
fail-safe 실험이 드러낸 요구사항은 차단을 복원한 뒤에도 그대로 유효하다 — 막는 것만으로는 부족하고, 왜 막혔는지가 보여야 관리자가 풀 수 있다:
판정 불가는 값이 아니라 상태로 표시한다. 현재는 생년월일 미등록이 「연령 = 0」이라는 숫자로 나타난다 — 0은 빈칸이 아니라 값처럼 보이고, 수백 행에서 사람이 눈으로 찾아내야 한다. PoC가 실제로 겪은 사고(보수 0인 직원에게 하한 클램프가 걸려 국민연금 19,475원 계산, §6-1)가 이 구조다.
판정 불가 건수는 집계돼 커밋 흐름에 나타난다. 막지는 않되, 확정 전에 "판정 불가 N건"이 보이고 그 행으로 갈 수 있어야 한다. — 🟡 부분 구현. 건수와 **대상 직원(이름·사번)**은 확인 다이얼로그에 뜨지만, 그 행으로 이동하는 링크는 없다. 사용자가 시트에서 직접 찾아야 한다.
제외 사유는 원장에 남는다. 현재 판정은 시트 수식 열의 True/False로만 존재하고
DeductionRecord에는 사유가 기록되지 않는다 — 이 상태로는 audit 패키지의 판정 매트릭스(Layer 5)를 만들 수 없다.자동 판정을 사람이 뒤집는 입구가 있어야 한다.— ✅ 구현 (T3). 시트에 「수동 판정」 select 열(강제 적용 / 제외 — 이중근로 부사업장 / 제외 — 기타)을 두고, 판정 수식이 자동 조건보다 수동을 우선한다. 수동 제외는 사유 매핑에서도 자동 사유보다 앞서 기록되고, 수동 판정이 있으면 T1 게이트가 풀린다(탈출구). 일용직 월 8일 기준·피부양 전환의 자동 판정은 여전히 미반영이나, 이제 사람이 처리할 수 있다.⚠️ 수식 엔진의 빈 셀 함정 — 참조한 셀이 비면 수식 전체가 N/A(null)가 되고, 숫자 포맷 열만 빈 참조를 0으로 읽는다. 「수동 판정」은 평소 비어 있으므로 판정 열이 그것을 직접 참조하면 거의 모든 행에서 판정이 N/A가 되어 자동 제외가 통째로 무력화된다(60세 직원도 공제가 계산된다). 그래서 숫자 포맷 열(
mjCode)로 한 번 받아 0/1/2/3으로 굳힌 뒤 그것만 참조한다. 판정 게이트 열도 같은 이유로 숫자 포맷이어야 한다 — 그 열이 잡아야 하는 것이 바로 "생년월일이 비어 있음"인데, boolean 포맷이면 잡아야 할 케이스에서만 판정이 사라진다. golden test에 회귀 가드를 박아 뒀다.
표시 형식(무엇을 어떤 모양으로 보여줄지)은 §4-7에서 정의한다.
Layer 2 — 시행일 버전 파라미터 + 유효값 없으면 계산 실패
OpenFisca 패턴이며, hereby에는 저장소(OrganizationParameterVersion.effectiveFrom)와 시점 조회(수식 [$ns.key])가 이미 있다. 보강할 것은 실패 정책이다: 귀속월에 유효한 파라미터가 없으면 0이나 live 값 폴백이 아니라 명시적 에러. 2027년 1월 급여 계산 시 2027년 건보 요율이 미입력이면 계산이 실패하고 관리자에게 알림 — 구요율로 조용히 계산되는 사고를 차단한다. 여기에 만료 예고(12월에 "내년 요율 미입력" 경고)를 붙이면 운영 알림(§5)으로 전환된다.
현황 (2026-08-20) — 셋 중 하나만 구현됐다.
| 요소 | 상태 |
|---|---|
| 커밋 거부 | ✅ 구현 (§6-5 ②) — 귀속월 유효값이 없으면 커밋을 막는다 |
| 화면 표시 | ❌ 미구현 — 셀은 기존 N/A로만 남는다. 즉 커밋을 눌러야 문제를 알게 된다 |
| 관리자 알림 · 만료 예고 | ❌ 미구현 |
또한 커밋 거부 문구가 책임 주체와 어긋난다: 결정 3은 요율 관리를 hereby 전담으로 정했는데, 현재 메시지는 조직 관리자에게 「관리자 설정 → 파라미터에서 등록해 주세요」라고 지시하고 내부 키를 원문(statutory.national_pension.employee_rate)으로 노출한다 (compute.ts L1089). 문구 수정 필요.
조용한 누락 — ✅ 해소 (T5). 소득세는 표/구간이 없거나 조회 키(부양가족수)가 비면 모듈이 출력을 내지 않아 셀이 비고, 커밋은 값 없는 셀을 조용히 건너뛴다. 경고는 서버 logger.warn뿐이라 화면에 흔적이 없었다 — "조용한 0"은 막았지만 "조용한 항목 누락"이 남아 있었다(§3 표의 첫 층위 그 자체).
실측(2026-08, 데모 병원 18명): 소득세·지방소득세가 전 직원 미계산인데 커밋이 72건 성공으로 끝났다. 4대보험만 들어간 급여가 확정된 것이다.
조치 — 커밋 열 메타에 **requireValueOnCommit**를 두고, 그 열에 빈 값이 있으면 커밋을 거부한다(파라미터 게이트와 같이 sweep 이전). 법정공제 템플릿의 커밋 열 6종에 적용했다. 0원은 걸리지 않는다 — 소액부징수·제외 판정처럼 "계산해 보니 0원"은 정당한 값이고, 막아야 하는 것은 "계산 자체가 안 된 상태"다.
검증: 세액표 미적재 상태 → 거부(「소득세 18행, 지방소득세 18행」) → 실표 적재 + 부양가족수 입력 + runCompute → 108건 커밋, 스킵 0.
Layer 3 — Golden test set (규칙 갱신마다 자동 회귀)
국세청 간이세액표 공표 예시값, 공단 모의계산기 값, 실제 고지액 사례를 입력→기대 공제액의 표준 케이스로 축적. 파라미터 갱신·수식 변경 때마다 전체 케이스를 자동 재실행 (HMRC/OpenFisca 모델). §1-5의 특수 케이스마다 최소 1개의 golden case를 요구하는 규칙을 두면 카탈로그와 테스트가 함께 자란다. 이 리포지토리의 "silent breakage를 테스트하라"는 기존 테스트 정책과 정확히 같은 문제의식이다.
Layer 4 — 고지액 대사 (reconciliation)
4대보험의 진실 원천은 공단 고지액이다. 매월 공단 고지서(EDI/파일 업로드)와 시스템 계산치를 직원 단위로 자동 대조하고 차이 리포트를 생성한다. 차이는 정상 사유(기준소득월액 차이·정산분·두루누리)로 분류되거나, 미분류 차이로 남아 조사 대상이 된다. flex의 "고지 기반" 모드가 이 문제의식의 다른 형태다.
Layer 5 — 병행운영 + 세무사 audit 패키지
- 도입 조직은 최소 2~3 급여 주기를 기존 계산(현재는 eCount/세무사)과 병행운영, 라인 단위 대사 리포트로 불일치 0 확인 후 절체.
- audit 패키지: 세무사에게 (1) 판정 매트릭스, (2) 공제액별 계산 근거 스냅샷 — 사용된 파라미터 버전·요율·기초금액·산식, (3) 파라미터 변경 이력(누가·언제·근거 고시), (4) 고지액 대사 리포트를 내보낼 수 있게 한다. "재현 가능한 계산"이 audit의 전제 조건이며, hereby의
inputSnapshot/appliedParameters커밋 스냅샷이 그 토대다.
4. hereby 설계 제안 — sheet 위에 어떻게 얹나
기존 자산(§0-1)을 최대한 재사용한다. 신규 엔진을 만드는 것이 아니라, 이미 있는 파라미터 스토어·수식 엔진·커밋 파이프라인을 법정공제용으로 조립하고, 죽어 있는 경로를 수리하는 그림이다.
4-1. 요율 저장: 파라미터 스토어 + statutory.* namespace
OrganizationParameter/Version을 그대로 쓰되 namespace 규약을 확정한다 (예시):
statutory.national_pension.employee_rate = 4.75 (%)
statutory.national_pension.income_floor = 410000 (원)
statutory.national_pension.income_ceiling = 6590000 (원)
statutory.health_insurance.employee_rate = 3.595 (%)
statutory.long_term_care.surcharge_rate = 13.14 (건보료 대비 %)
statutory.employment_insurance.employee_rate = 0.9 (%)
statutory.local_income_tax.rate = 10 (%)- 시행일 관리는
effectiveFrom버전으로. 각 버전에 근거 고시 URL/조문 메타데이터를 붙인다 (OpenFiscametadata.reference패턴 — Version 엔티티에 reference 필드 추가 검토). - 글로벌 vs 조직 문제: 법정 요율은 전 조직 동일인데 현 스토어는 org 단위다. 선택지는 (i) 시스템 기본값(글로벌) 계층 신설 + org 미설정 시 폴백 ← 권고: 갱신 1회로 전 조직 반영, org 단위 override도 허용, (ii) 전 조직에 seed 복제 + 중앙 갱신 잡. (i)가 §5 운영 모델("hereby가 벤더로서 요율 관리")과 맞는다.
4-1b. 검토한 대안: "공제 파라미터 시트" — 요율을 스토어가 아닌 시트에 두는 방식
요율·상하한을 별도의 파라미터 시트(행 = 항목, 열 = 값)에 두고, 공제 시트 수식이 크로스시트 참조로 끌어오는 방식. 기술적으로 지금 바로 가능하다 — 수식 엔진에 직원 무관 VLOOKUP 형태 [시트:값컬럼:키컬럼:내키컬럼]이 이미 있고(resolver.ts L3664~), 치료일 시트가 관리자 편집 단가 시트에서 단가를 끌어오는 동일 패턴이 프로덕션에서 쓰이고 있다. 시트 slug 해석이 호스트 시트의 (연,월) 기준이라, 월별 파라미터 시트를 두면 과거 월 재계산 시 그 달의 시트 값이 쓰이는 시점 고정도 자연히 된다.
| 기준 | 파라미터 시트 | 파라미터 스토어 [$statutory.*] |
|---|---|---|
| 구현 소요 | 최소 (기존 패턴 그대로) | 소 (글로벌 계층 + 실패 정책 추가) |
| 요율 가시성 | ◎ 시트에서 직접 보고 수정 | ○ 수식 칩 호버(기존 org-parameter-tip) + 읽기전용 뷰 필요 |
| 중앙 갱신 | ✗ 요율이 조직×월 시트 사본에 흩어짐 — 개정 시 전 조직 시트를 고칠 방법이 없음 (벤더 책임 모델과 충돌) | ◎ 글로벌 기본값 1회 갱신 → 전 조직 반영 |
| 누락 시 거동 | ✗ 미해결 크로스시트 참조는 0으로 평가 (resolver.ts L1388 명시) — 시트 부재·키 오타·carry-forward 갱신 누락이 전부 "공제 0원"으로 조용히 통과 | ◎ "유효값 없으면 명시적 실패" 정책을 얹을 수 있음 (Layer 2) |
| 시행일 정밀도 | △ 월 복사 단위 — 7월 상하한 갱신 등을 사람이 그 달 시트에서 바꿔야 하고, 안 바꾸면 구값이 계속 복사됨 | ◎ effectiveFrom 임의 날짜 + 버전 이력 |
| 거버넌스/audit | △ 셀 편집 권한만 있으면 수정 가능, 근거 고시 메타·변경 이력 약함 | ◎ history 테이블 + updatedBy + 근거 reference + 커밋 시 appliedParameters 스냅샷 |
| 수식 가독성 | △ LOOKUP 키가 "현재 행의 컬럼 값"이라 파라미터별 히든 키 컬럼 필요 (리터럴 키 지원을 추가하면 완화) | ○ [$statutory.national_pension.employee_rate] |
결론 — 역할 분담이 답이다. 이 방식의 최대 강점(가시성·직접 편집)은 정확히 조직 고유 값(임의공제 단가·기숙사비·상품권 등)에 필요한 성질이고, 최대 약점(중앙 갱신 불가·누락 시 0)은 정확히 법정 요율이 감당 못 하는 성질이다. 따라서:
- 법정 요율·상하한·세액표 → 파라미터 스토어 (전 조직 공통, 벤더 중앙 갱신, 시행일 정밀, 누락=실패). 가시성 요구는 스토어를 읽기전용 "법정 요율 시트 뷰"로 렌더링해 보완 — 파라미터 시트의 UX를 스토어 위에 얹는 것.
- 조직 고유 단가·임의공제 파라미터 → 파라미터 시트 + LOOKUP (사용자 제안 방식, 단가 시트 선례 그대로).
즉 사용자 제안은 기각이 아니라 적용 대상을 임의공제·조직 파라미터로 특정하는 것이 권고안이다. §7 결정 질문 7로 추가.
4-2. 계산 표면: 4대보험은 수식, 소득세는 조회표 모듈
(a) 4대보험 — 시스템 제공 시트 템플릿의 수식 컬럼이 1급 경로. 상하한 포함 산식이 기존 수식 함수만으로 표현된다:
국민연금 = ROUND(MIN(MAX([보수월액], [$statutory.national_pension.income_floor]),
[$statutory.national_pension.income_ceiling])
* [$statutory.national_pension.employee_rate] / 100)[$ns.key]는 시트 귀속월 기준 시점 조회가 이미 구현돼 있으므로 과거 월 재계산도 자동으로 당시 요율 — 신규 개발 없이 §1-4 요건 충족.- 수식 경로의 결정적 장점: 세무사가 시트에서 산식과 요율 칩을 직접 눈으로 확인할 수 있다(감사 요구와 부합), 셀 단위 override(고지액 차이 보정)도 기존 기능.
- 각 공제 컬럼에
commitToKind=deduction+ 커밋 앵커를 세팅해 "✓ 커밋" 시 원장으로. [확정 — §7 결정 6] 계산 경로는 수식 컬럼으로 일원화한다: insurance codeModule은 폐기 (같은 공제에 두 계산 경로가 있으면 §0-2의 "장기요양 공식 불일치"가 재발한다). 조회표 특성상 수식으로 불가능한 소득세만 전용 모듈을 유지한다(아래 (b)). 사전 검토 결과는 §4-6.
(b) 소득세 — 간이세액표는 수식으로 불가능(수천 행 × 가족수 축), 조회표 모듈로.
- 신규 테이블(예:
simplified_tax_table): 시행일(effectiveFrom) + 급여 구간 + 부양가족 수별 세액. 공공데이터포털/홈택스 Excel을 적재하는 파이프라인(§5)으로 채운다 — importer 앱의 Excel 파싱 경험 재사용. tax-bracketcodeModule을 간이세액표 조회 방식으로 교체(현행 연환산 직산은 실제 원천징수와 다름). 직원 데이터에 공제대상 가족 수·자녀 수·원천징수 비율(80/100/120) 필드 신설 필요.- 지방소득세는
소득세 × [$statutory.local_income_tax.rate] / 100수식 + 절사 규칙.
(c) 통지 금액형(ICL·압류·정산분) — Phase 1에서는 시트의 수동 입력 컬럼 + 커밋으로 수용(임의공제와 같은 표면). 분할(splitMonths)·기간 관리는 Phase 3.
4-2b. 산정 기초(보수월액) — 급여대장 연동 설계 (2026-08-19 검토)
PoC는 산정 기초를 계약 기본급으로 단순화했지만, 정합성 있는 기초는 급여대장의 최종 수당 합산(비과세 제외) 이다. 기존 시트 연동 구조를 코드로 검토한 결과:
확인된 사실
- 크로스시트 참조(
[payroll_ledger:콜키])는 대상 시트를 완전 리졸브한다 — 집계(aggregate) 열 값 포함 (resolver.ts프리로드가resolveSheetRows재귀 호출, 사이클은 얕은 해석으로 절단). 퇴사정산 요약 시트가 자매 시트를 slug-ref로 집계하는 동일 선례가 있다. → 급여대장 수당 열을 공제 시트 수식에서 읽는 것은 기존 메커니즘으로 가능. - 그러나 급여대장 템플릿에는 표준 "수당 합계" 열이 없다 — 집계 열은 조직이 수당 항목별로 직접 추가하며 키가 조직마다 다르다. aggregate 열은 앵커 전용(자기 slug+콜키로 커밋된 레코드만 SUM)이라 "이 시트로 커밋된 전 수당 합산" 열도 현재는 만들 수 없다.
- 비과세 구분 수단이 코드베이스에 전무하다 (taxable/taxFree/비과세 마커 0건) — §1-5의 "급여 항목에 비과세 속성 필요" 요건이 실측으로 확인됨.
설계 제안
- 급여대장에 표준 "보험료 기초(과세보수)" 열 신설 — 구현 방식 권고: aggregate의 시트 범위 변형 신설 (
aggregate: { recordKind: "allowance", scope: "sheet" }— targetColumnKey 무관하게 해당 시트로 커밋된 전 수당을 SUM, resolver 확장 소규모). 여기에 급여대장 집계 열 메타에insurableBase: false플래그(기본 true)를 추가해 비과세 항목(식대·차량유지비 열)을 기초에서 제외한다 — 보험별 포함 예외는 Phase 2+. - 법정공제 시트의 보수월액 =
[payroll_ledger:insurable_gross]크로스시트 참조로 교체. 계약 기본급 열은 참고/폴백으로 유지. - 항목별 산정 기초는 하나가 아니다 — 당월 실보수 기준이 정확한 것은 고용보험(과 건보 정산·소득세)뿐이고, 국민연금은 공단이 확정한 기준소득월액(전년 소득 기반, 매년 7월 갱신), 건강보험 월 고지도 신고 보수월액 기준(실보수와의 차이는 4월 정산)이다. 따라서 정률 모드에서도 항목별로 기초 소스를 선택할 수 있어야 하며(당월 과세보수 / 신고 보수월액 / 공단 고지), 이는 §7 결정 1(모드 선택제)의 열 단위 버전이다. Phase 1은 "당월 과세보수" 단일 기초로 시작하되 이 한계를 명시한다.
4-2c. 과세성(비과세) 플래그의 자리 — 열 메타 vs AllowanceRecord (2026-08-19 검토)
전제 확인: 급여대장의 실체는 AllowanceRecord의 합산이며, 주 원천 급여 데이터는 시트가 아니라 커밋 테이블(레코드)이다. 그렇다면 과세성 플래그는 어디에 사는가.
코드로 확인된 사실
- 레코드는 마스터가 될 수 없다 — commitSheet는 재커밋마다 해당 시트의 레코드를 sweep(soft-delete) 후 전량 재삽입한다. 레코드에 직접 단 플래그는 다음 커밋에서 증발하므로, 레코드의 플래그는 반드시 "어딘가의 마스터에서 커밋 시점에 복사된 스냅샷"이어야 한다.
- 현 아키텍처에서 '급여 항목'의 실체는 급여대장 aggregate 열이다 — eCount A코드도 record FK가 아니라(1816에서 제거) 급여대장 열 메타의
allowanceItemCodeId에 붙어 있고, export는 record의(targetSheetSlug, targetColumnKey)→ 열 메타를 read-time jsonb 조인으로 해석한다 (payroll.router.tsL2052~2105). - 그러나 이 read-time 조인 패턴을 과세성에 쓰면 안 된다 — 열 메타를 나중에 고치면 과거 확정분의 과세성 해석이 소급 변동한다. eCount 코드 매핑은 표시/전송용이라 용인되지만, 과세성은 보험료·세액 금액 계산의 입력이므로 확정 시점에 동결돼야 한다 (§2-4 SAP retro 원칙과 동일).
- 레코드에는 이미 커밋 시점 분류 enum(
paymentMethod)이 있으나 commitSheet가 스탬프하지 않아 기본값으로만 남는 반쪽 선례 — 커밋 시점 스냅샷 패턴을 이번에 제대로 세울 자리다.
권고: 양쪽 다, 역할을 나눠서 (마스터=열 메타, 스냅샷=레코드)
- 마스터: 급여대장 aggregate 열 메타에
taxExempt: boolean(기본 false) +taxExemptType(식대/차량유지/보육 등 enum, core enums에) — 항목 설정 UI가 이미 열 설정에 있고, 월별 시트 사본 구조라 과거 월의 과세성이 당시 기준으로 자연 동결된다. - 스냅샷: commitSheet가 커밋 시점에 타깃 급여대장 열 메타를 조회해
AllowanceRecord.taxExempt(+type)로 스탬프. 이후 모든 소비자(보험료 기초 합산·간이세액 기초·신고 자료)는 시트 조인 없이 record 쿼리만으로SUM(amount) WHERE NOT taxExempt를 얻는다 — "레코드가 주 원천"이라는 원칙과 합치. - §4-2b의 "보험료 기초" 열(
scope:"sheet"aggregate)도 결국 record 쿼리이므로 이 플래그 필터가 그대로 적용된다.
대안 비교 (기각 사유 포함)
| 위치 | 평가 |
|---|---|
| AllowanceRecord 단독 (마스터) | ✗ sweep-재생성으로 증발 — 구조적으로 불가 |
| 열 메타 단독 + read-time 조인 | ✗ 사후 수정이 확정분 계산 근거를 소급 변경 — audit 위반 |
| 별도 항목 마스터 테이블 (AllowanceItemCode 확장/신설) | △ 기간 무관 식별자·스키마 검증은 장점이나, record↔itemCode 연결을 1816에서 의도적으로 끊었고 AllowanceItemCode는 eCount 전용 성격. 열 메타 규모가 커지면(보험별 예외 등) 승격 재검토 |
| 열 메타(마스터) + 레코드(커밋 스냅샷) | ✅ 권고 — 기존 커밋 동결 철학(inputSnapshot/appliedParameters)과 동일 패턴 |
미해결 주의점 — 비과세는 boolean이 아니라 한도부다. 식대는 "월 20만 원까지" 비과세 — 식대 30만 지급 시 20만 비과세 + 10만 과세로 금액이 갈라진다. 항목 단위 플래그(Phase 1)는 "비과세 항목 열은 한도 이내로 운영"이라는 운영 전제 위에서만 정확하며, 한도 초과 자동 분리(레코드의 비과세 인정액 필드 또는 레코드 분리)는 Phase 2 과제로 명시한다.
⚠️ 시스템 템플릿은 한 번 깔리면 갱신되지 않는다 (2026-08-20 실측). 시트 템플릿 시더는 이름이 같은 템플릿이 있으면 통째로 건너뛴다(멱등 삽입). 그래서 PoC가 급여대장 템플릿에 추가한 「보험료 기초(과세보수)」 열은 템플릿이 이미 깔린 조직에 영원히 도달하지 못한다 — 새로 만드는 DB에만 생긴다. dev DB의 급여대장 템플릿·시트 어디에도 그 열이 없었다.
그대로 두면 법정공제 시트가 보수월액을 구하지 못하고, 커밋 차단 게이트가 전 조직에서 영구히 커밋을 막는다 — 게이트는 기초가 "아직 없는" 상태를 막으라고 만든 것이지, 열이 없어서 영원히 못 만드는 상태를 막으라고 만든 게 아니다. 마이그레이션 1887로 템플릿과 기존 급여대장 시트 양쪽을 보강했다(시트는 생성 시점에 레이아웃을 복사하므로 템플릿만 고치면 기존 시트는 비어 있다).
앞으로 시스템 템플릿에 열을 추가할 때는 시더만 고치면 안 되고 마이그레이션을 함께 써야 한다.
정합성 가드 (필수)
- 참조는 수당 열만 — 실지급액(수당−공제) 류 열을 기초로 잡으면 record-level 순환(공제 커밋 → 실지급 변동 → 공제 재계산)이 생기며, 수식 사이클 절단은 formula-level만 끊는다. 수당 합산만 참조하면 한 번에 수렴(공제는 수당에 영향 없음).
- 급여대장 부재/미커밋 시 조용한 0 방지 — ✅ 해소 (T6). 미해결 크로스시트 참조는 0으로 평가되므로 보수월액 0 → 하한 클램프 → 최소 보험료(국민연금 19,475원)가 조용히 계산된다. 계약 기본급 폴백을 제거해 기초를 급여대장 하나로 통일하고, 「보험료 기초 없음」 열을 커밋 차단 조건으로 걸었다.
- 폴백이 만든 실제 결함: 4대보험은 계약 기본급(폴백)으로, 소득세는 수당 레코드 합산으로 계산돼 한 사람의 한 달 급여가 두 기준으로 나왔다(실측 2026-08 — 4대보험 300만 기준 / 소득세 0 기준).
- ⚠️ 구현 함정: 이 가드 열을 boolean 포맷으로 두면 무력화된다. 급여대장이 없으면 크로스시트 참조가 미해결이라 수식 전체가 N/A가 되고, 정작 잡아야 할 "기초 없음"에서만 판정이 사라진다. 숫자 포맷 열로 받아야 한다(
mjCode·judgeIncompleteNum과 같은 이유). 실측으로 한 번 물렸다.
- 커밋 순서와 신선도 — 운영 순서는 "수당 시트 전부 커밋 → 급여대장 수당 확정 → 법정공제 계산·커밋". 공제 커밋 후 수당이 재커밋되면 공제 레코드가 stale — 커밋 스탬프(migration 1827)를 활용한 stale 감지/재커밋 유도 필요.
4-3. 판정 매트릭스 (Layer 1의 구현)
- 월 법정공제 시트 생성 시 재직자 전원을 auto-populate (기존
setAutoPopulate활용). — ✅ 구현 - 항목별 "적용/제외/사유" 판정 컬럼을 둔다. 판정 로직(연령·고용형태·시간 기준)은 코드가 계산하고, 결과는 시트에 가시화 — 판정이 틀렸으면 세무사·관리자가 시트에서 바로 본다. — 🟡 판정 열은 구현, 사유 기록과 표현은 미완(아래).
- 커밋 게이트에 "법정공제 시트는 판정 미수행 행이 있으면 커밋 불가" 규칙을 추가. — 🟡 구현하되 정책이 바뀌었다 (T1 → 확정 ①). 2026-08-20 에는 커밋을 거부했으나, 2026-08-21 기획 확정으로 막지 않고 확인받는다. 템플릿의 「판정 불가」 열(숫자 포맷
judgeIncompleteNum— 생년월일·입사일·주 소정근로시간 결측)을 공제 열 메타commitWarnings가 가리키고,commitSheet가 sweep 이전에 해당 행을 세어 첫 호출을 되묻는다(직원 이름·사번까지 안내). 사용자가 확인하면 진행하고 건수를 리포트에 남긴다. ⚠️ 옛 이름judgmentGuardColumn은commitBlockers(차단)/commitWarnings(경고)로 갈라졌다. 판정 불가의 정의는 시트 수식이 갖는다 — 항목마다 필요한 근거가 다르기 때문(소득세는 부양가족 수라 이 경고에 아직 포함되지 않는다).- 다만 이 문장은 대상을 잘못 지목했다:
apps/hr-system/src/sheets/workbook/commit-gate.ts는 열 설정을 저장할 때 그 열의 반영 대상을 남길지 버릴지 판정하는 클라이언트 순수 함수이며, 행 단위 판정과 무관하다. 필요한 게이트는 열 반영 게이트(열 설정 드로어)와 행 판정 게이트(서버commitSheet, 파라미터 게이트 옆)로 서로 다른 자리에 있다.
- 다만 이 문장은 대상을 잘못 지목했다:
구현 결과 (§6-4 ①) — 판정은 시트 수식 열로 들어왔다: 숨김 입력 4열(생년월일·입사일·주 소정근로시간·급여 마감일) + 판정 5열(연령·초단시간·연금 제외·건보 제외·고용 제외) + 공제 수식의 게이트(IF([npsExcluded], 0, …)).
남은 갭 4건
| # | 갭 | 왜 문제인가 |
|---|---|---|
| 1 | 판정 열이 영문 True/False로 표시 | 급여 시트에 불리언 5열이 늘어서고, True가 파란 굵은 글씨라 긍정으로 읽힌다 — 실제 뜻은 「이 사람은 이 보험에서 빠짐」 (workbook-adapter.ts L2240) |
| 2 | 경고를 데이터 열로 표현 | 「⚠ 기본급 폴백」이 다른 판정 열과 똑같은 파란 True로 뜬다. 경고는 열이 아니라 상태여야 한다 |
| 3 | 판정 근거가 숨김 | 근거 4열이 visible: false라 「연금 제외 True」의 이유를 화면에서 볼 수 없다 — 세무사 audit 요구와 충돌 |
| 4 | ✅ 해소 (T2) — 커밋 시점에 DeductionRecord.exclusionReason 으로 동결한다 (§4-4) |
1~3의 표시 형식은 §4-7, 4는 §4-4(원장 보강)에서 다룬다.
4-4. 원장 보강 (audit 요건)
deductionType(법정 6종 + 상황부 + 임의) 구분을 원장에 추가 — 정의만 있는DeductionSubCategoryenum을 확장·실사용하거나 신규 enum. 현재는reason문자열뿐이라 audit 집계가 불가능하다. — ❌ 미구현commitSheet가sourceType·formulaVersion을 기록하도록 보완,appliedParameters스냅샷에 파라미터 버전 id까지 포함 → 공제액마다 "어떤 규칙 버전으로 계산됐나"를 재현 가능하게 (rule lineage). — ✅ 구현 (§6-3·§6-1)- 판정 결과(적용/제외 + 사유)도 커밋 시점에 레코드로 동결한다. — ✅ 구현 (T2, migration 1886). 과세성 스냅샷(결정 8)과 같은 패턴이다: 시트 판정 열이 마스터,
DeductionRecord.exclusionReason이 커밋 시점 스냅샷. 어느 사유인지는 공제 열 메타의exclusionReasons(사유별 판정 열 매핑, 배열 순서 = 우선순위)가 정한다. NULL = 적용이고, 값이 있으면 제외이며 그때amount는 0 이다 — 0 원 레코드도 커밋되므로 제외 사실이 원장에 남는다.- ⚠️ 수식 엔진의 함수 테이블은
(args: number[]) => number라 수식이 사유 문자열을 직접 낼 수 없다. 그래서 사유마다 판정 열(age60==[age] >= 60,hire65,shortTime)을 두고 서버가 첫 참을 채택한다.npsExcluded/uiExcluded는 이 원자 열들의 OR 로만 표현해 판정 로직의 출처를 하나로 유지한다.
- ⚠️ 수식 엔진의 함수 테이블은
- audit 패키지 export(§3 Layer 5)는 이 필드들의 조회·리포트다. — ❌ 미구현
4-5. 선결 수리 목록 — 기획 확정과 무관하게 고쳐야 할 것
| # | 수리 | 위치 | 현황 (2026-08-20) |
|---|---|---|---|
| 1 | computed 컬럼 모듈 키 불일치 ("insurance"/"tax_bracket" → enum 값) + "미지원 모듈은 조용히 skip" 제거(에러화) | packages/api/src/routers/sheet-rows/compute.ts L231·L579·L660 | ✅ 완료 (§6-1) |
| 2 | commitSheet의 sourceType/formulaVersion 미기록 | 같은 파일 L1124~ | ✅ 완료 (§6-1) |
| 3 | 연말정산·퇴사정산 → 공제 반영이 lineage NOT NULL 위반으로 실패 | year-end-settlement.router.ts L290~, resignation-record.router.ts L378~ | ❌ 미완 |
| 4 | TaxBracketCalculator가 귀속연도 아닌 현재연도 로드 | tax-bracket.calculator.ts L34 | ✅ 해소 — 다만 "고쳤다"기보다 구 누진 직산 로드를 통째로 제거했다. 어댑터는 module-registry 메타데이터 전용 스텁이 됐고, 실제 소득세 경로는 sheet compute다 |
| 5 | 장기요양 공식 이원화(건보료 대비 vs 급여 대비) 단일화 + seed 템플릿의 하드코딩 요율을 [$statutory.*] 참조로 교체 | insurance.ts vs seed-sheet-templates.ts L1802~ | 🟡 부분 — 퇴사정산 템플릿에 2024년 요율이 잔존(L2190·L2199). insurance.ts 모듈도 아직 삭제되지 않음(결정 6 = 폐기) |
| 6 | codeModule 파라미터의 $: 해석이 live 값만 조회(시점 미고려) — 수식 경로와 시점 정책 일원화 | compute.ts L46~108 | ✅ 완료 (§6-3 resolveOrgConstantValues 공유) |
4-6. codeModule 사용 실태 — 결정 6의 사전 검토 결과 (2026-08-19)
- 레지스트리에 모듈 9개.
loadInputsByModule분기 키와 enum 값이 일치하는 7개(초과근무·근로계약·고정인센티브·교육비·퇴사·연말정산·manual)는 수당 쪽에서 정상 사용 중 — 레지스트리 + 입력 로더 + 파라미터 스키마 아키텍처 자체는 건전하며, 전면 리팩토링은 불필요하다. - 죽어 있는 것은 정확히 공제 쪽 2개(insurance/tax-bracket — §0-2의 키 불일치)와, 존재하지 않는 모듈을 참조하는 seed 템플릿 컬럼 3개(
treatment_daily— 레지스트리·enum 어디에도 없음 → 해당 컬럼은 조용히 미계산.seed-sheet-templates.tsL860·870·880). - 조치: ① insurance 모듈 폐기(수식 일원화), ② tax-bracket 모듈은 간이세액표 조회 방식으로 교체, ③ "미지원 모듈은 조용히 skip"(
compute.tsL660)을 에러로 승격 —treatment_daily같은 유령 참조도 이 조치로 드러난다, ④$:시점 정합화(§4-5 #6). 수당 쪽 7개 모듈은 손대지 않는다.
4-7. 상태 표시 요구사항 — 발견 장치의 화면 규격 (2026-08-20)
§3 [개정]이 차단을 포기한 대가로 요구되는 것. 지금까지 이 문서는 계산·저장만 규정했고 화면은 세 문장(§0-2 "UI에서는 모듈 선택이 가능하게 보임", Layer 2 "관리자에게 알림", §4-3 "시트에 가시화")뿐이었다. 그 공백을 여기서 규격으로 만든다. 세부 시각 설계는 별도 디자인 작업(브랜치 design/statutory-deduction-states)에서 이어진다.
원칙 2
- 색은 심각도, 형태와 행동은 책임 주체. 현행
.ds-notice4종(info·warning·success·danger)은 심각도 축만 있어 hereby가 처리 중인 것과 조직이 입력해야 하는 것이 같은 모양으로 뜬다. 색을 늘리는 대신 이동 경로(CTA) 유무를 규칙으로 고정한다 — 조직이 고칠 수 있으면 반드시 달고, hereby 소관이면 절대 달지 않는다. 커밋 거부 문구가 결정 3과 어긋난 것(§3 Layer 2 현황)이 이 규칙이 없어서 생긴 사고다. - 실패·미판정을 숫자 자리에 숫자로 쓰지 않는다. 공제 0원은 제외 판정된 직원의 정당한 값이므로, 0과 빈칸은 정상에만 쓴다. 현재 「연령 0」·「소득세 빈칸」이 이 원칙을 어기고 있다.
표시할 상태 (5)
| 상태 | 뜻 | 고치는 주체 | 반영(커밋) |
|---|---|---|---|
| 판정 불가 | 적용/제외를 정할 근거가 없음 (생년월일·근로시간 결측) | 조직 관리자 | 허용(fail-safe) + 집계 노출 |
| 계산 실패 | 규칙은 있으나 값이 안 나옴 (요율·표 부재) | hereby | 거부 (§6-5 ②) |
| 입력 필요 | 조직이 채울 값이 비어 멈춤 (부양가족 수, 고지서) | 조직 관리자 | 항목별 판단 |
| 외부 확정 대기 | 공단·국세청 확정이 있어야 정해짐 (이중근로 안분, 정산분) | 없음 | 허용 + 잠정 표식 |
| 사전 경고 | 아직 실패 아님, 곧 실패 (내년 요율 미입력, 상류 stale) | hereby / 조직 | 허용 |
표시 자리 (5계층) — 아래로 갈수록 구체적, 위로 갈수록 집계. 같은 문장을 다섯 번 반복하지 않는다.
| 계층 | 무엇을 말하나 |
|---|---|
| 셀 | 이 값이 왜 없는지 — 상태어(숫자 아님) |
| 행 | 이 직원에게 몇 건 — 모아 보기 대상 |
| 열 헤더 | 항목 전체에 걸친 규칙 문제 (요율 부재는 전 직원 동시 발생이므로 셀마다 칠하지 않는다) |
| 시트 배너 · 반영 버튼 | 집계와 거부 사유 |
| 알림 | 마감까지 남은 시간, 조직 밖에서 온 변화 (요율 개정·표 갱신) |
확정 필요 — 디자인 착수 전 기획 확인 사항은 §7 미확정 질문 참조.
5. 운영 프로세스 — 요율 개정을 반영하는 워크플로
기술이 아니라 운영이 절반이다. 요율 공식 API는 사실상 없으므로(간이세액표만 파일 제공), 사람이 개입하는 반자동 파이프라인을 정식 프로세스로 설계한다.
[감지] 고시·개정 모니터링 — 연간 캘린더(1월 요율 / 2~3월 간이세액표 / 7월 국민연금 상하한) + 비정기 감시
[스테이징] 새 파라미터 버전 입력 (시행일 + 근거 고시 URL) — 아직 미적용
[검증] golden test 자동 재실행 + 영향 시뮬레이션 (직원별 공제액 변동 미리보기)
[승인] 선택적 승인 — 승인 없이도 활성화 가능하되, 릴리스마다 승인 여부·승인자·시점이 기록·조회 가능
[릴리스] 버전 번호 + 릴리스 노트 ("2027-01 요율 패키지: 건보 7.19→x.xx%, 근거 고시 …")
[알림] 조직 관리자에게 변경 알림 (기존 notification 시스템 재사용)- [확정 — §7 결정 3] 파라미터 관리 주체는 hereby 팀 (벤더 책임 모델 — 한국 시장 표준 기대치). 세무사 승인은 필수가 아니되, audit 과정을 기록할 수 있는 경로를 제공한다: 각 파라미터 릴리스에 승인 상태(미승인/승인, 승인자, 시점)가 남고 audit 패키지(§3 Layer 5)에서 항상 확인 가능하다.
- 간이세액표는 "공공데이터포털/홈택스 파일 파싱 → 스테이징 → diff 미리보기 → 승인 → 반영"의 동일 파이프라인.
- [검증] 단계의 실행 절차는 별도 문서로 확정 —
statutory-deduction-release-validation.md: 홈택스/공단 계산기와의 표본 대조(경계 배치 23표본 + 4대보험 4표본), 대조 러너verify-tax-samples.ts(전건 일치 = 종료 코드 0 — 릴리스 게이트에 직결 가능), 불일치 분류(변환/산식/개정 시차), 결과 기록 규칙.
6. 단계별 로드맵 (안)
진행 상황 (2026-08-20). PoC가 로드맵을 앞질렀다 — Phase 2(소득세)가 Phase 1보다 먼저 사실상 끝났고, Phase 3의 일부(비과세 한도 초과 분리)도 앞당겨졌다. 반대로 Phase 1의 모드 선택·상태 표시·병행운영은 손도 대지 않았다. 남은 것은 "계산"이 아니라 운영과 화면이다.
| Phase | 내용 | 진행 |
|---|---|---|
| 0. 기반 수리 + 요율 스토어 | §4-5 수리 6건(+§4-6 조치) · statutory.* namespace 확정 · 글로벌 기본값 계층 · 2026 요율 + 근거 메타 seed · "유효값 없으면 실패" 정책 · 법적 책임 구조 법률 검토(결정 4) | 🟡 대부분 완료 — 잔여: 수리 3·5, insurance.ts 폐기, 글로벌 계층(일괄 갱신으로 대체 — 결정 5), 법률 검토는 질의서 작성까지(회신 대기, 여전히 Phase 1 착수의 게이트) |
| 1. 4대보험 월 공제 | 법정공제 레지스트리 + 판정 매트릭스 · 시스템 시트 템플릿(수식+상하한+커밋 앵커) · 공제 산정 모드 선택 골격(정률 계산/고지 기반/수동/외부 import — 결정 1·2) · eCount import 파서 완성(외부 계산 모드) · golden test 1차 세트 | 🟡 계산·판정·커밋·golden test 완료. 미착수: 모드 선택제 · eCount 파서 · 상태 표시(§4-7) · 병행운영 절차 |
| 2. 소득세·지방소득세 | 간이세액표 테이블 + 파일 적재 파이프라인 · 부양가족/원천징수비율 직원 필드 · 조회 모듈 교체 | ✅ 대부분 완료 (§6-6·§6-7). 단 부양가족 수·자녀 수·원천징수비율이 직원 필드가 아니라 시트 리터럴 열로 구현됐다 → 미확정 질문 ② |
| 3. 정산·통지형·대사 | 고지액 업로드 + 자동 대사(Layer 4) · 건보 4월 정산 · ICL/압류 (분할 관리) · 휴직 일할계산 | ❌ 미착수. 단 비과세 한도 초과 분리는 앞당겨 완료 (§6-5 ①) |
| 4. 세무 시스템 확장 | 원천세 신고 자료 생성 · 지급명세서 · 연말정산 | 별도 기획 · 추후 예정 |
6-1. PoC 구현 결과 (2026-08-19, 브랜치 feat/statutory-deduction-poc)
방향 확인용 수직 슬라이스를 구현·검증했다. 커밋 533dbad5.
구현한 것
statutory.*요율 파라미터 시드 7종 — 시행일별 history 버전 + 근거 메타 (seed-statutory-parameters.ts)- 시스템 템플릿 「법정공제 — 4대보험 (월별)」 — 재직자 1인 1행(컬럼
sourceTable: "employee"바인딩, 급여대장과 동일 방식), 수식이[$statutory.*]참조 + MIN/MAX 상하한 클램프, 공제 4열은 커밋 앵커(payroll_ledger/ded_statutory_*) 장착 - §4-5 수리 #1(모듈 키 불일치)·#2 일부(commitSheet
sourceType기록 + priorDeductions NULL 필터 수리)·조용한 skip에 warn 로그
검증된 것 (전부 API 레벨 E2E)
- 템플릿 적용 → 재직자 17행 자동 생성, 수식 계산값이 기대값과 전 행 일치
- 시점 정합: 2026-08 시트는 2026 요율(4.75/3.595/13.14), 2025-12 시트는 자동으로 2025 요율(4.5/3.545/12.95) — 신규 개발 없이 §1-4 요건 충족 확인
- ✓ 커밋 →
DeductionRecord68건(17명×4항목), sourceType·타깃 앵커 정상
PoC가 드러낸 갭 (Phase 0~1 과제로 승격)
- 수식 커밋의 rule lineage 부재 —
inputSnapshot에 수식 문자열만 남고 그때 사용된[$statutory.*]값은 안 남는다 (appliedParameters는 codeModule 경로 전용). 감사 재현성을 위해 resolver가 사용한 org constant 값을 커밋 스냅샷에 포함해야 한다. - 판정 매트릭스 필요성의 실증 — 계약 없는(보수 0) 직원에게 하한 클램프가 작동해 국민연금 19,475원이 계산됐다. "적용/제외 판정 없이 계산부터 하는" 구조의 실제 오류 사례 (§3 Layer 1).
[$...]미해결 시 여전히 N/A/0으로 조용히 통과 — "유효값 없으면 실패" 정책(Layer 2)은 미구현.- PoC 단순화 잔여:
산정 기초=계약 기본급(→ §6-2에서 해소), 건보 상하한 미적용, 절사 단순 ROUND.
6-2. PoC 2차 — 산정 기초의 급여대장 연동 + 과세성 스냅샷 (2026-08-19)
§4-2b·§4-2c 확정 설계(결정 8)를 같은 브랜치에 구현·검증했다.
구현한 것
TaxExemptTypeenum(core) + 급여대장 aggregate 열 메타taxExempt/taxExemptType(마스터) +AllowanceRecord.taxExempt/taxExemptType(스냅샷, migration 1883)commitSheet가 커밋 시점에 타깃 급여대장 열 메타를 읽어 수당 레코드에 과세성을 동결 스탬프- aggregate
scope:"sheet"변형 — "이 시트로 커밋된 전 수당 SUM, 비과세 제외" (excludeTaxExempt) - 급여대장 템플릿에 표준 열
insurable_gross「보험료 기초(과세보수)」 추가; 법정공제 템플릿의 보수월액을[payroll_ledger:insurable_gross]크로스시트 참조로 교체 (미커밋 시 계약 기본급 폴백 — Phase 1 목표는 명시적 실패)
E2E 검증 (2026-09 신규 기간, 전부 통과)
- 수당 시트에서 기본급 300만/700만 + 식대 20만 커밋 → 식대 레코드에
taxExempt=true, type=meal스탬프 확인 - 급여대장
insurable_gross= 300만/700만 — 식대 20만이 기초에서 제외됨 - 법정공제 시트 보수월액이 급여대장 값으로 채워지고, 700만 직원은 국민연금 상한(659만) 클램프 후 313,025원 — 전 항목 기대값 일치
- 수당 미커밋 직원은 계약 기본급 폴백 동작
후속 과제
과세성 플래그 편집 UI→ 완료 (§6-3)- 신규 시트의 설정 라이프사이클(잠금)과 커밋 순서 UX — E2E에서 reopenSetup/completeSetup 수동 호출이 필요했음
폴백 → 명시적 실패/경고 전환→ ✅ 완료 (T6, §4-2b 가드 2).비과세 한도 초과 분리(§4-2c)→ ✅ 완료 (§6-5 ①)
6-3. PoC 3차 — rule lineage + 과세성 편집 UI (2026-08-19)
- rule lineage (§6-1 갭 1 해소): 수식 커밋 시 시트의 수식들이 참조한
[$ns.key]상수를 귀속월 기준으로 해석해 레코드의appliedParameters로 동결한다 — 시점 해석 로직은 화면 평가와 같은 함수(resolveOrgConstantValues, resolver에서 추출·공유)를 쓴다. 검증: 2026-09 국민연금 313,025원 레코드에statutory.*요율·상하한 6종의 당시 값이 스냅샷으로 남음 — "이 공제액이 어떤 규칙 버전으로 계산됐나"를 레코드만으로 재현 가능. - 과세성 편집 UI: hr-system 열 설정 패널의 집계(수당) 섹션에 「비과세 항목」 체크박스 + 유형 select 추가 (
column-settings-panel.html). 저장은 열 메타taxExempt/taxExemptType(과세는 필드 미기록), 안내 문구로 "커밋 시점 동결 — 변경 후 수당 시트 재커밋 필요"를 명시. preflight(Fast) 통과.
6-4. PoC 4차 — 판정 매트릭스 · 신선도 감지 · 간이세액표 · 법률 질의서 (2026-08-19)
① 판정 매트릭스 (Layer 1, §4-3 구현) — 법정공제 템플릿에 판정 클러스터 추가: 숨김 identity 입력(생년월일·입사일·주 소정근로시간·급여 마감일) + 수식 판정 열(연령·초단시간·연금/건보/고용 제외) + 공제 수식 게이트(IF([npsExcluded], 0, …)). 설계 원칙 그대로 fail-safe: 제외가 명시적으로 참일 때만 0, 판정 입력 부재 시 공제는 계산되고 이상값(연령 0)이 화면에 드러난다. 연령 기준일은 TODAY가 아닌 payEndDate라 과거 월 재계산에도 정확. E2E: 63세 직원 연금만 0원, 게이트 일관성 17/17. 실측 버그 2건 발견·수정: (a) 날짜 직렬값이 1970 기준이라 1970년 이전 출생의 > 0 존재 검사가 거짓 — 부호 무관 OR(>0,<0)로 교체.
② 상류 신선도 감지 (§4-2b 가드 3) — commitStatus 응답에 upstreamAllowanceLatestAt/staleAgainstAllowances 추가: 공제 커밋 후 수당이 재커밋되면 stale로 표시. E2E: 커밋(false) → 수당 수정·재커밋(true) → 공제 재커밋(false) 라이프사이클 확인. UI 배지 노출은 후속.
③ 간이세액표 (Phase 2 선행) — simplified_tax_tables(effectiveFrom 버전 × 구간 × 가족수, migration 1884) + CSV 적재 파이프라인(import-simplified-tax-table.ts, 버전 단위 적재·부분 갱신 거부) + tax 모듈 재작성(과세보수=수당합산−비과세 스냅샷, 시트 리터럴 입력 부양가족수·원천징수비율, 조견표 조회, 소액부징수·10원 절사 — ⚠재확인 표기). 표/구간 없으면 셀을 비워 두고 warn — 조용한 0 방지가 여기도 관철됨(커밋도 스킵). 템플릿에 소득세·지방소득세 열 추가. E2E: 84,850×120%=101,820 / dep4 420,000 / 지방세 10원 절사 / 커밋 sourceType=tax_bracket_config. 실측 버그: (b) date 컬럼 SELECT 시 pg 드라이버의 로컬 TZ Date 파싱으로 버전 등호 매칭이 하루 어긋남 — to_char 문자열 고정으로 수정. ⚠ 실제 간이세액표 데이터는 미적재 — 공공데이터포털 파일을 CSV 변환해 적재 + 홈택스 표본 대조(Layer 3)가 상용 전 필수.
④ 법률 검토 질의서 — docs/internal/legal-review-questions-statutory-deduction.md (결정 4의 선행 과제): 계산 오류 책임 경계, 모드별 책임, 파라미터 중앙 관리·선택적 승인의 효력, 세무사법상 세무대리 해당 여부, 개인정보(부양가족 수·주민번호), 기록 보존, 통지형 공제 — 7개 쟁점 + 요청 산출물 정리.
남은 후속 — 실표 적재+golden test, stale/폴백 UI 배지, 커밋 순서 UX, 비과세 한도 초과 분리, 글로벌 파라미터 계층, → §6-5에서 대부분 해소.[$...]·기초 0의 명시적 실패 정책
6-5. PoC 5차 — 후속 일괄 처리 (2026-08-19)
① 비과세 한도 초과 분리 (§4-2c 해소) — 열 메타 taxExemptLimit(비과세 월 한도) + AllowanceRecord.taxExemptAmount(비과세 인정액, 커밋 시점 min(금액,한도) 동결, migration 1885) 신설. 기초 합산이 boolean 필터에서 SUM(amount − taxExemptAmount) 금액 차감으로 바뀌어 한도 초과분이 자동으로 과세 기초에 포함된다. 열 설정 UI에 한도 입력(식대/자가운전 선택 시 20만 자동 프리필). E2E: 식대 30만 커밋 → 인정액 20만 스탬프, 보험료 기초 = 기본급 300만 + 초과분 10만 = 310만.
② [$…] 명시적 실패 승격 (Layer 2 완성) — 커밋 시 수식이 참조한 상수 중 귀속월 유효값이 없는 것이 있으면 커밋을 거부한다 ("귀속월(YYYY-MM)에 유효한 파라미터가 없어 커밋할 수 없어요: …"). 게이트는 sweep 이전에 서서 기존 확정분을 건드리지 않음을 검증. 화면 표시는 기존 N/A 유지 — 확정만 막는다.
③ stale UI 배지 (커밋 순서 UX) — 시트 헤더 커밋 칩에 「수당 변경됨 — 재반영 필요」 빨간 라벨 + 툴팁 조치 안내 (staleAgainstAllowances 소비). 기존 「반영 필요/반영됨」 배지보다 우선.
④ 전 조직 일괄 갱신 + 신규 조직 자동 시드 (글로벌 계층 대체) — scripts/update-statutory-parameter.ts: 개정 1회 실행으로 전 조직에 시행일 버전 + 근거 고시를 릴리스 (멱등). 워크스페이스 프로비저닝이 seedStatutoryParameters를 자동 호출해 신규 조직에도 요율이 깔린다. 진짜 글로벌 기본값 테이블은 계속 후속(운영상 필요성 낮아짐).
⑤ golden test (Layer 3 착수) — statutory-formula.golden.test.ts (19 케이스): 실제 템플릿 수식을 evaluateFormula로 직접 실행 — 상하한 클램프·판정 게이트·1970 이전 날짜 가드·fail-safe(null 판정 시 공제 유지)·폴백, tax 모듈의 절사·소액부징수·80/120% 비율·"표 없으면 출력 없음". 수식 사본은 템플릿과 의도적 동거(변경 시 테스트가 깨져 리뷰에 드러남). ⚠ "참값" 검증(국세청 공표값 대조)은 실표 적재와 함께 별도.
진짜 남은 것 — 실표(간이세액표) 적재 → §6-6에서 완료.
6-6. PoC 6차 — 실표 적재 + 자녀 공제 차감 (2026-08-19)
① 실표 적재 완료. 공공데이터포털 「국세청_근로소득 간이세액표_20240229」의 공식 HWPX(소득세법 시행령 별표2 <개정 2024.2.29>)를 내려받아 OWPML 표를 파싱 → 646구간 × 가족수 11 = 7,106행 + 최저구간 미만 0원 규칙행 11행 = 7,117행 CSV로 변환, 리포에 보존(packages/database/data/simplified-tax-table-20240229.csv + 출처·검증 README). 변환 검증: 구간 연속성(빈틈 0)·소득 단조성·가족수 감소 전수 통과, 표본 대조 월 300만/1인 = 74,350원(공표값 일치). dev DB의 HEREBY_DEMO·DEMO_HOSP에 적재(effectiveFrom 2024-02-29), 픽스처 제거.
② 확인된 도메인 사실 — 2026-03-01 개정은 본표가 아니라 자녀 차감액이다. 별표2 비고 3: 8~20세 자녀가 있으면 조견표 세액에서 자녀수별 금액을 차감(음수→0). 2024판 12,500/29,160/+25,000 (공식 문서 원문) → 2026-03-01 지급분부터 20,830/45,830/+33,330 (자녀세액공제 연 25/30/40만의 월할 — 복수 출처 교차 확인). 본표(월급여×가족수)는 2024-02-29판이 계속 유효.
③ 자녀 공제 차감 엔진 구현. 차감액을 시행일별 파라미터 statutory.income_tax.child1_deduction/child2_deduction/child_extra_deduction(2024·2026 두 버전 시드)로 관리 — 귀속월 기준 자동 선택되므로 2026-02 시트는 구액, 2026-03 이후는 신액. 템플릿에 childrenCount(8~20세 자녀수) 리터럴 열 추가, 모듈이 "조견표 세액 − 자녀 차감(음수→0) → 80/100/120% → 절사·소액부징수" 순으로 계산. golden test 24케이스로 확장.
E2E (실표, 전부 일치): 3.1M/1인/120% → 82,900×1.2 = 99,480 / 지방세 9,940; 7M/4인/자녀2 → 527,350 − 45,830 = 481,520 / 48,150. 커밋 스냅샷에 표값·자녀수·차감액 lineage 동결. 소득 0원 직원도 규칙행(0~770천원=0)에 매칭돼 스킵 0건.
한계 (문서화): 표는 월급여 1천만 원까지 — 초과분 미지원 → 초과 산식 구현 완료 (§6-7). 상용 적재 시 조직별 실행 필요(현재 dev 2개 조직), 홈택스 모의계산과의 표본 대조를 §5 릴리스 절차에 포함할 것.
6-7. 월급여 1,000만 원 초과 — 별표2 하단 산식 구현 (2026-08-20)
표가 끝나는 1,000만 원 초과 구간은 별표2 하단의 법정 산식이다: 세액 = "10,000천원인 경우의 해당 세액"(가족수별 전용 행 — 1인 1,507,400원) + 구간별 가산액(10~14M: 초과분×98%의 35%+25,000 / 14~28M: 1,397,000+초과분×98%의 38% / 28~30M / 30~45M / 45~87M / 87M 초과: 31,034,600+초과분의 45%). 공식 문서에서 산식 원문을 추출해 구현했다.
- 전용 행 11개(가족수별)를 표에 개방 상단 행으로 추가(CSV 7,128행), 어댑터가 초과 케이스에서 기저값으로 조회.
- 산식 상수는 모듈
overCapAddon에 개정 주기 경고 주석과 함께 — 표 재적재 시 산식도 함께 대조하도록 data README 체크리스트화. - 자녀 차감·80/100/120%·절사·소액부징수가 산식 결과에도 동일 순서로 적용.
- golden test 30케이스 (경계 14M에서 가산식이 다음 구간 상수 1,397,000과 정확히 이어지는 산식 자체 정합성 포함). E2E: 12.1M/1인/120% → 2,703,240 / 지방세 270,320 정확 일치.
7. 결정 사항 — 대안 비교와 확정 결론
아래 대안 비교를 배경으로, 2026-08-19 결정 7건이 확정됐다 (§7 말미 표).
| 방향 | 내용 | 장점 | 단점/리스크 |
|---|---|---|---|
| A. 자체 계산 엔진 (이 문서의 주안) | 파라미터 스토어 + sheet 수식/모듈로 전 항목 자체 계산 | 제품 경쟁력, 세무 시스템 확장의 토대, 설명가능성 | 규칙 유지 운영 부담이 hereby에 귀속, 법적 책임 설계 필요 |
| B. 고지액 중심 | 4대보험은 공단 고지서 업로드가 1급 경로, 계산은 검증용 보조 | 법적으로 안전(진실 원천=고지액), 구현 작음 | 소득세는 여전히 계산 필요, 자동화 가치 반감 |
| C. 외부 검수 결합 | A/B + 세무사·회계법인 파트너가 승인·신고 수행 (뉴플로이 모델) | 책임 분산, audit 요구를 파트너십으로 해결 | 파트너 확보·수익 배분, 제품 단독 완결성 저하 |
| D. eCount 위임 (현재 코드의 스캐폴드) | eCount 급여모듈이 공제 계산 → xlsx를 hereby로 import→커밋 | 계산·컴플라이언스 책임이 eCount에 | 수작업 왕복(Open API 미제공), 자동화·제품 가치 없음, audit 근거도 외부. A를 하면 D는 대체됨 |
확정 결정 (2026-08-19)
| # | 질문 | 결정 |
|---|---|---|
| 1 | 4대보험의 1급 경로 | 조직별 모드 선택제 — 정률 계산 / 고지 기반 / 수동. 어느 모드든 Layer 4 대사가 양쪽을 잇는다 |
| 2 | eCount 왕복 스캐폴드 | 대체하지 않고 요구사항으로 같이 제공 — 모드의 하나(외부 계산 import)로 유지하고 파서를 완성한다 |
| 3 | 파라미터 관리 주체 | hereby(회사) 전담. 세무사 승인은 필수가 아니되, audit 과정을 기록하는 경로를 제공 — 릴리스마다 승인 여부·승인자·시점이 기록되고 항상 확인 가능 (§5) |
| 4 | 계산 오류 시 법적 책임 | 개발 착수 전 법률 검토 리서치 선행 — 이용약관·면책·모드별 책임 경계(자체 계산 vs 고지 vs 외부 import). Phase 0에 편성, Phase 1 착수의 게이트 |
| 5 | 대상 범위 | 전 조직 기능 (단일 조직 파일럿 아님). |
| 6 | 계산 경로 | 수식 컬럼으로 일원화 — insurance codeModule 폐기, 조회표형(간이세액표)만 전용 모듈 유지. 사전 검토 결과(§4-6): 수당 쪽 7개 모듈은 정상 사용 중, 전면 리팩토링 불필요 |
| 7 | 요율 저장 위치 | 역할 분담 — 법정 = 파라미터 스토어, 조직 고유 = 파라미터 시트 (§4-1b) |
| 8 | 과세성(비과세) 플래그 위치 | 마스터 = 급여대장 aggregate 열 메타 / 사용값 = AllowanceRecord 커밋 시점 스냅샷 (§4-2c). 산정 기초는 급여대장 「보험료 기초(과세보수)」 열(§4-2b) |
확정 결정 (2026-08-21) — §7 미확정 질문 6건 회신
2026-08-20 에 제기한 질문에 대한 확정 답이다. 브랜치 design/statutory-deduction-states (PR #581)에 구현·검증까지 반영했다.
| # | 질문 | 확정 | 구현 |
|---|---|---|---|
| ① | 판정 불가 행을 반영에서 어떻게 다루나 | 기획서 제안대로 — 막지 않고, 반영 직전에 「판정 불가 N명」을 확인받고 결과를 리포트에 남긴다 | ✅ 열 메타 commitWarnings + acknowledgeWarnings 2단계. 직전 구현(차단)을 뒤집었다. 기초 부재는 그대로 차단 — 넘어가면 금액이 틀리기 때문(하한 클램프) |
| ② | 부양가족 수·자녀 수·원천징수비율의 자리 | 현행 유지 (시트 리터럴 열). 시트를 볼 수 있는 사람은 이미 직원 정보에 접근할 수 있는 사람이므로 개인정보 쟁점이 성립하지 않는다 | ✅ 변경 없음. ⚠️ 리뷰어 확인 요망 — 기획 초안의 "직원 필드로 승격" 제안을 채택하지 않은 결정이다. 매월 재입력이 필요한 점은 감수한다 |
| ③ | 자동 판정을 사람이 뒤집을 수 있게 하나 | 허용하되 사유 입력을 강제하고, 급여 권한자만 가능 | ✅ 열 메타 requireMemoWhenSet — 값이 있는데 셀 메모가 없으면 반영을 막는다(작성자·시각이 메모에 함께 남는다). 권한은 이미 충족 — 시트 셀 쓰기가 전부 payroll:manage 를 요구한다(sheet-cell.router.ts), 그래서 새 검사를 얹지 않았다 |
| ④ | ✅ 해소 | requireValueOnCommit (T5) | |
| ⑤ | 잠정/확정 표기를 Phase 1에 넣나 | 기획서 제안대로 — 처리는 Phase 3, 표기 자리는 Phase 1에 확보. 원장과 명세서 양쪽 | ✅ DeductionRecord.settlementState (마이그 1892) + 열 메타 settlementState + 명세서 안내. 값이 실제로 잠정일 때만 표시한다 — 임의공제만 있는 달에는 사실이 아니다 |
| ⑥ | 두루누리 지원금의 명세서 표시 | 표시하기로 결정. 단 이번 PR에서는 제외 | ❌ 미구현. 표시할 값이 코드 어디에도 없다 — 전수 검색 결과 언급이 이 문서 한 곳뿐이고, 계산에 들어가지 않으며 저장되는 값도 없다. 고지서에서만 차감되므로 자동 계산이 불가능하다. 선결: 그 숫자가 어디서 오는가(관리자 월별 입력 / 고지서 import)를 정해야 화면을 만들 수 있다 |
리뷰어가 알아야 할 것
- ①은 이미 출하된 동작을 뒤집는다. PR #581 의 앞선 커밋은 판정 불가를 차단했는데, 기획 확정은 "통과 + 확인"이다. 같은 PR 안에서 뒤집었으므로 develop 에는 차단 동작이 간 적이 없다.
- ②는 기획 초안을 채택하지 않은 결정이다. 초안은 개인정보 쟁점을 들어 직원 필드 승격을 제안했으나, 접근 권한이 실질적으로 같다는 판단으로 시트 열을 유지한다.
- ③의 권한 요구는 새로 만들지 않았다. 이미 같은 권한이 걸려 있어, 얹으면 같은 규칙이 두 곳에 생긴다.
- ⑥은 결정은 났으나 구현이 빠졌다. 데이터 출처가 정해지지 않아 지금 만들 수 있는 것은 빈 칸뿐이다.
후속: 서비스 사양서(docs/spec/) 동기화
이 변경은 PM 소유 사양서가 기술하는 화면 동작을 바꾼다. 사양서는 "라이브에서 확인된 것만 기술"이 원칙이고(docs/spec/GUIDE.md), 갱신은 docs/spec-sync-* 전용 PR 로 따로 올라간다(#575·#580 선례). 그래서 이 PR 은 사양서를 건드리지 않는다 — 머지 후 동기화 대상으로 남긴다.
| 사양서 | 무엇이 달라졌나 |
|---|---|
HBHR201_전체 확정 (commit) | TBU 두 개가 해소된다. ① "확정 직전 검증(commit preflight) … 구현 여부 = TBU" → 차단(commitBlockers) + 확인(commitWarnings)으로 구현. ② "확정 파이프라인 실패 피드백 UI (라이브 미검증)" → 3부 구조(요약/대상/조치) 다이얼로그 + 대상 직원 이름·사번. 스킵 규칙 표에 판정 불가 확인과 사유 없는 수동 판정 차단 행이 추가돼야 한다 |
HBEA530_급여명세서 모달 | 「공제 내역」 위에 잠정 안내가 새로 뜬다(잠정 레코드가 있을 때만). 현재 사양표에는 "6. 공제 내역 · 공제 항목별 금액" 한 줄뿐이다 |
HBHR100_시트 워크스페이스 | 「수동 판정」 열이 사유(셀 메모) 없이는 반영을 막는다 — 셀 메모가 선택이 아니라 필수가 되는 첫 사례 |
POL_급여정본경계("검증/AI 는 경고·차단까지만, record 생성은 사람의 확인으로만 실행")와는 어긋나지 않는다 — 오히려 그 정책이 P1 로 정의해 둔 것을 구현한 것이다.
8. 참고 출처
- 규정: 국민연금공단 · 건보공단 모의계산 · 국세청 간이세액표 · 공공데이터포털 간이세액표 파일 · 4대사회보험 정보연계센터
- 사례: OpenFisca Parameters · Catala · Symmetry Tax Engine · QuickBooks tax table update · flex 급여정산 · 뉴플로이 · SAP Retroactive Payroll · IRIS Parallel Testing
- 내부:
docs/packages/reference/payroll-schema-refactor.md·payroll-provenance.md·calculators.md·docs/guides/korean-payroll.md - 요율 등 수치는 전부 "구현 시 재확인" 대상이며, 상세 근거 URL은 리서치 보고서 원문 참조.