현재 사용법 (2026-09-10,
6fbaee7f): 법정공제·계산 기준·확정 검사 · 기능 마스터. 아래는 작성 당시의 변경 설명이며 현재 전체 동작·재측정 결과를 뜻하지 않습니다.
법정공제 반영 게이트 — 조용히 확정되던 급여를 막습니다
한 줄 요약
급여 시트의 「반영」 버튼이 지금까지는 계산되지 않은 항목이나 판정 근거가 없는 직원이 섞여 있어도 "성공"으로 끝났습니다. 이제는 무엇이 문제인지와 대상 직원을 이름·사번으로 알려 줍니다 — 금액이 틀리게 되는 경우(기초 부재·미계산·사유 없는 수동 판정)는 막고, 판정만 비는 경우는 확인을 받고 넘어갑니다(기획 확정 ①, 2026-08-21).
⚠️ 이 문서는 처음에 「판정 불가 = 차단」으로 쓰였다가 기획 확정 ①(같은 PR 안)으로 「확인 후 통과」로 갱신됐습니다. 차단으로 읽히는 문장이 남아 있으면 이 경고가 우선합니다.
직관 — 이 커밋들이 왜 한 PR 인가
근본 원인이 하나입니다. 잘못된 급여가 만들어지는데 화면에 아무 신호가 없다는 것입니다. 형태만 여러 가지였습니다 — 계산이 안 된 항목이 빠진 채 확정되고(조용한 누락), 열 설정을 저장하면 산정 기준이 바뀌고(조용한 유실), 죽은 필터가 비활성 직원을 시트에 넣고(조용한 통과), 낡은 요율이 틀린 금액을 만들고(조용한 오산), 임베드된 화면이 다른 조직 데이터를 보여줍니다(조용한 어긋남). 전부 "그래서 아무도 몰랐다"로 끝나던 자리입니다.
배경 — 지금 동작
hereby 의 급여는 시트 위에서 만들어집니다. 시트는 엑셀과 비슷한 표이고 직원 한 명이 한 행입니다. 열은 두 종류입니다 — 사람이 값을 넣는 리터럴 열과, 다른 열을 참조해 계산하는 수식 열입니다.
시트 상단의 「반영」 버튼(코드에서는 commitSheet)이 시트의 셀 값을 원장에 확정 기록합니다. 원장은 AllowanceRecord(수당)와 DeductionRecord(공제) 두 테이블이고, 급여명세서와 이체 금액이 여기서 나옵니다. 이하 이 동작을 반영이라고 씁니다.
이 변경이 다루는 시트는 둘입니다. 급여대장은 한 달치 수당을 모으는 시트이고, 법정공제 시트는 4대보험(국민연금·건강보험·장기요양·고용보험)과 소득세·지방소득세를 직원별로 계산하는 시트입니다.
법정공제 계산에는 두 가지 입력이 필요합니다. 하나는 보험료 기초(보수월액)로, 요율을 곱할 대상 금액입니다. 다른 하나는 판정으로, 이 사람에게 이 보험이 적용되는지 여부입니다 — 60세에 도달하면 국민연금 자격을 잃고, 주 15시간 미만 초단시간 근로자는 건강보험에서 빠지는 식입니다. 판정은 시트의 수식 열이 하고, 화면에는 「60세 도달」·「초단시간」 같은 열로 보입니다.
여기서 반영에는 구조적인 구멍이 하나 있었습니다. 반영은 값이 없는 셀을 조용히 건너뜁니다. 수당 시트에서는 빈 셀이 정상이므로 이 동작이 맞습니다. 그러나 법정공제 시트에서 같은 일이 벌어지면 항목이 통째로 빠진 급여가 확정된다는 뜻이 됩니다.
실측이 그대로 나왔습니다. 2026-08 데모 병원 18명에서 간이세액표(월급여 구간별 소득세를 찾는 국세청 표) 조회 키인 부양가족 수가 비어 소득세·지방소득세가 전 직원 미계산이었는데, 반영은 72건 "성공"으로 끝났고 화면에는 아무 경고도 없었습니다. 4대보험만 들어간 급여가 확정된 것입니다.
개념 다이어그램
반영 경로
변경 전
변경 후
게이트는 sweep 이전에 섭니다. 반영은 기존 레코드를 지우고 다시 쓰는 방식이라, 거부가 늦게 일어나면 기존 확정분이 이미 지워진 뒤입니다.
보험료 기초의 출처
변경 전
변경 후
원장의 출처 기록
변경 전
변경 후
개념 흐름별 변경
1. 반영을 막는 조건을 시트가 선언하고 서버가 집행합니다
조건의 정의도 문구도 서버가 아니라 시트가 갖습니다. 항목마다 필요한 근거가 다르기 때문입니다 — 4대보험은 연령·근로시간이 필요하고, 소득세는 부양가족 수가 필요합니다.
열 메타에 세 축을 더했습니다. commitBlockers 는 "이 열이 참인 행이 하나라도 있으면 거부", commitWarnings 는 "막지 않되 첫 호출을 되물어 확인받고 건수를 리포트에 남김"(기획 확정 ①), requireValueOnCommit 은 "이 열에 빈 값이 있으면 거부"입니다. 서버는 조건이 참인 행을 셀 뿐입니다.
조건마다 문구를 함께 드는 이유는 고치는 방법이 다르기 때문입니다. 기초 부재는 수당 시트를 먼저 반영해야 풀리고, 사유 없는 수동 판정은 그 셀에 메모를 남겨야 풀립니다. 판정 불가는 막지 않되(확정 ①) 「판정 불가 N명」 확인을 받고, 「해당 행 보기」로 그 행에 바로 갈 수 있습니다. 차단과 확인을 가르는 기준은 **"넘어가면 금액이 틀리는가"**입니다.
0 원은 걸리지 않습니다. 소액부징수나 제외 판정처럼 "계산해 보니 0 원"은 정당한 값이고, 막아야 하는 것은 "계산 자체가 안 된 상태"입니다.
2. 왜 계산이 아니라 확정을 막는가
근거가 비어도 수식은 값을 냅니다. 생년월일이 없으면 연령이 0 으로 읽혀 "60세 미만 = 적용"으로 통과하고, 보수월액이 없으면 국민연금 하한 클램프가 걸려 19,475 원이 계산됩니다. 화면에서 「연령 0」은 값처럼 보여 수백 행 중에서 사람이 알아채지 못합니다.
그래서 계산을 막는 것으로는 부족하고 확정을 막아야 합니다. 그대로 확정되면 잘못된 공제가 급여대장에 들어가고, 되돌리는 비용이 확정을 늦추는 비용보다 큽니다.
3. 제외된 공제는 사유와 함께 원장에 남습니다
판정이 시트 수식 열로만 존재해 "이 사람의 공제가 왜 0 원인지"가 원장에 남지 않았습니다. 시트는 지워지거나 바뀔 수 있으므로, 세무사 감사 자료의 판정 매트릭스를 만들 근거가 사라집니다.
커밋 시점에 사유를 DeductionRecord.exclusionReason 으로 동결합니다(마이그레이션 1886). 마스터는 시트 판정 열이고 레코드는 스냅샷입니다.
서버는 금액이 0 일 때만 사유를 기록합니다. "사유가 있으면 0 원"이라는 불변식을 지키기 위해서입니다 — 수동으로 강제 적용한 경우 자동 제외 조건이 참인 채로 공제가 계산되는데, 이 가드가 없으면 금액이 0 이 아닌 레코드에 「60세 도달로 제외」가 찍히는 모순이 생깁니다.
4. 사유를 왜 열 하나로 못 받는가 — 그리고 왜 숫자여야 하는가
수식 엔진의 함수 테이블은 (args: number[]) => number 입니다. 수식이 문자열을 반환할 수 없습니다. 그래서 사유마다 판정 열(=[age] >= 60)을 두고, 서버가 목록을 순서대로 훑어 처음 참인 것을 사유로 채택합니다. 배열 순서가 곧 우선순위입니다.
여기에 두 번 물린 함정이 있습니다. 수식 엔진은 참조한 셀이 비면 수식 전체를 N/A 로 만들고, 숫자 포맷 열만 빈 참조를 0 으로 읽습니다. 「수동 판정」 열은 평소 비어 있으므로, 판정 열이 이를 직접 참조하면 거의 모든 행에서 판정이 N/A 가 되어 자동 제외가 통째로 무력화됩니다. 60세 직원에게도 국민연금이 계산됩니다.
같은 함정이 기초 부재 가드에서도 재현됐습니다. 급여대장이 없으면 크로스시트 참조가 미해결이라 수식이 N/A 가 되고, 정작 잡아야 할 "기초 없음"에서만 판정이 사라져 게이트가 조용히 열립니다. 두 곳 모두 숫자 포맷 열로 한 번 굳혀 참조하도록 고쳤고, 왜 숫자여야 하는지를 못박는 회귀 가드를 golden test 에 넣었습니다.
5. 자동 판정을 사람이 뒤집는 입구
이중근로 안분처럼 자사 데이터로 판정할 수 없는 예외가 실재하는데, 판정이 전부 수식이라 고칠 방법이 없었습니다. 「수동 판정」 열을 두어 수동이 자동보다 우선하게 했습니다.
수동 판정이 있으면 게이트도 풀립니다. 탈출구가 없으면 판정 불가 행 하나가 급여 전체를 영구히 멈추기 때문입니다.
6. 보험료 기초를 하나로 통일합니다
같은 시트에서 기초를 두 곳에서 가져오고 있었습니다. 4대보험은 급여대장을 읽되 비면 계약 기본급으로 폴백했고, 소득세는 서버 모듈이 수당 레코드를 직접 합산해 폴백이 없었습니다. 실측에서 급여대장 미반영 상태의 한 사람이 4대보험은 300만 기준, 소득세는 0 기준으로 계산됐습니다.
폴백을 제거하고 기초를 급여대장 하나로 통일했습니다. 기초가 없으면 추정으로 메우지 않고 반영을 막습니다.
7. 시더만 고치면 기존 조직에 도달하지 않습니다
시트 템플릿 시더는 이름이 같은 템플릿이 있으면 통째로 건너뜁니다(멱등 삽입). 게다가 시트는 생성 시점에 레이아웃을 복사합니다. 이 두 단계 때문에 템플릿을 고쳐도 이미 깔린 조직과 이미 만들어진 시트에는 반영되지 않습니다.
같은 함정이 이 PR 에서 두 번 나타났습니다. 급여대장의 「보험료 기초」 열이 기존 조직에 없어 게이트가 전 조직에서 영구히 반영을 막을 뻔했고(마이그레이션 1887), 퇴사정산 템플릿에는 2024년 요율이 2026년까지 살아남았습니다(마이그레이션 1889).
퇴사정산 쪽은 한 조직 안에서 두 기준이 공존하던 상태였습니다. 같은 달 재직자는 현행 요율(국민연금 4.75%)로 계산되는데 퇴사자는 2024년 요율(4.5%)로 계산됐습니다. 요율을 파라미터 참조로 바꿔 두 시트가 같은 출처를 읽게 했습니다. 장기요양은 값이 아니라 식 자체가 달라서, 급여 대비 직산에서 공단 고지서 방식인 건강보험료 대비로 통일했습니다.
이미 반영된 시트는 건드리지 않습니다. 확정된 정산의 산정 기준을 소급해 바꾸면 이미 지급·신고된 금액과 시트가 어긋납니다.
8. 원장은 출처를 두 갈래로 받습니다
연말정산 적용과 퇴사정산 적용이 100% 실패하고 있었습니다. 원장의 시트 계보 세 열이 DB 에서 NOT NULL 인데, 두 경로는 시트를 거치지 않으므로 채울 값 자체가 없어 INSERT 단계에서 막힙니다.
원래 설계는 두 갈래였습니다. 시트에서 온 것은 계보가, 시트 밖에서 온 것은 sourceType 이 출처를 가리킵니다. 마이그레이션 1795 가 계보를 NOT NULL 로 굳히면서 두 번째 갈래가 막혔습니다. 원래 의도가 nullable 이었다는 증거는 같은 시기의 유니크 인덱스에 남아 있습니다 — WHERE "sourceSheetId" IS NOT NULL 인 부분 인덱스이고, 열이 NOT NULL 인 동안 그 조건은 참이 될 수밖에 없는 죽은 조건이었습니다.
그냥 nullable 로 되돌리지 않았습니다. 1795 가 지키려던 것(출처 없는 레코드 금지)을 CHECK 로 옮겨, 계보가 완전하거나 sourceType 이 있거나 둘 중 하나를 요구합니다. 되돌리기만 하면 시트 반영 경로의 버그가 출처가 통째로 빈 레코드를 만들어도 DB 가 막지 않습니다.
왜 아무 테스트도 이걸 못 잡았는가가 이 항목의 핵심입니다. 시나리오 8 의 정산 테스트들이 정산 공제를 시트 반영으로 우회해서 만듭니다("via sheet commit"). 시트를 거치지 않는 유일한 원장 writer 인 두 뮤테이션이 한 번도 실행되지 않았습니다. 실제 경로를 태우는 테스트 2건을 추가했고, 되돌린 스키마에서 정확히 이 2건만 실패하는 것을 확인했습니다.
9. 거부 문구를 읽을 수 있게 만듭니다
반영이 막힌 직원을 문장 안에 이어 붙여 보여줬습니다. 인원이 늘수록 요약과 대상이 한 덩어리로 읽혀 둘 다 안 읽힙니다.
서버가 문구를 3부 구조로 던지고(1줄 요약 / 2줄 대상 / 3줄 조치), 화면이 가운데 줄만 별도 상자로 뺍니다. 다이얼로그에 details 축을 더했습니다. 3줄이 아닌 실패는 원문 그대로 둡니다 — 형식을 맞추다 정보를 잃는 것보다 낫습니다.
대상 표기에서 세 가지를 고쳤습니다. 이름 조회가 존재하지 않는 employees.name 을 읽어 Postgres 원문 에러가 사용자에게 그대로 노출됐고(한국식 표기는 성과 이름을 공백 없이 붙인 것입니다), 전원이 막혔는데 3명만 뽑아 "외 N명"이라 쓰면 그 3명이 특별해 보였으며, 이름 오름차순으로 뽑아 화면에서 찾을 수 없었습니다. 전원이면 이름을 나열하지 않고, 일부면 시트 행 순서로 앞 셋을 사번과 함께 뽑습니다.
10. 조용히 깨지던 두 곳
열 설정 저장이 산정 기준을 되돌렸습니다. 저장 시 집계 설정을 통째로 다시 만드는데, 폼에 없는 scope·excludeTaxExempt 가 그 과정에서 사라집니다. 급여대장의 「보험료 기초」 열을 열어 이름만 바꿔 저장해도 전 직원의 산정 기초가 틀어지고 화면에는 아무 경고도 뜨지 않았습니다. 타입에 두 축을 추가하고(타입에 없으면 표현조차 못 합니다) 저장 시 원본에서 값을 실어 나르게 했습니다.
비활성 직원 필터가 죽어 있었습니다. 소문자 'inactive' 와 비교하는데 실제 값은 대문자라 조건이 한 번도 참이 되지 않았습니다. 실측 242행 중 23행이 비활성 직원이었고, 법정공제 시트에서 그들에게도 보험료가 계산되고 있었습니다. 화면에서는 그 행이 흐리게 보일 뿐이라 일부러 넣은 것처럼 보였습니다. 휴직과 퇴직은 종전대로 남깁니다 — 휴직 중에도 4대보험 자격이 유지되고, 퇴직자는 그만둔 달 급여에 공제가 실제로 발생합니다.
11. 임베드된 화면이 다른 조직을 보고 있을 때
admin 은 hr-system 을 iframe 으로 끼웁니다. 개발에서는 앱마다 포트가 달라 출처가 다르고, 그래서 세션이 완전히 따로 놉니다. 바깥은 A 조직으로 로그인돼 있는데 안쪽 화면은 B 조직 데이터를 보여줄 수 있고, 지금까지 아무 신호도 없었습니다. 실측에서 원인을 찾는 데 반나절이 걸렸고 그 사이 잘못된 조직의 급여 시트를 편집할 뻔했습니다.
운영에서는 같은 도메인의 경로만 다르므로 세션이 공유돼 이 상황이 생기지 않습니다. 그래서 세션을 넘겨주는 대신 어긋남을 알리기만 합니다 — 운영에 없는 문제를 위해 운영 코드에 인증 경로를 만들지 않습니다. 오가는 값은 조직 id 와 이름뿐이라 토큰이 창을 건너지 않습니다.
[갱신 2026-08-21] 기획 확정으로 달라진 것
이 문서의 초판 이후 §7 미확정 질문 6건이 확정돼 같은 PR 안에서 반영됐습니다. 상세는 기획서 design-statutory-deduction-phase1.md §7 「확정 결정」을 보십시오.
- ① 판정 불가 = 차단이 아니라 확인.
commitWarnings+acknowledgeWarnings2단계(마이그레이션 1890). 초판이 기술한 차단은 develop 에 간 적 없이 같은 PR 안에서 뒤집혔습니다. - ③ 수동 판정은 사유(셀 메모) 필수.
requireMemoWhenSet(마이그레이션 1891) — 값이 있는데 메모가 없으면 반영이 막힙니다. 권한은 시트 셀 쓰기의payroll:manage가 이미 감당합니다. - ⑤ 월 공제액은 잠정.
DeductionRecord.settlementState(마이그레이션 1892)로 동결되고 급여명세서가 「잠정」 안내를 띄웁니다. - 확인 다이얼로그에 「해당 행 보기」 가 있어, 지목된 행으로 스크롤·플래시합니다(사번으로 찾음 — 동명이인 때문).
화면 — 실측 증거
전부 dev 환경 실제 화면이다(데모 병원, 2026-08). 판정 불가는 직원 한 명의 생년월일을 비워 만들었고 검증 후 복구했다.
반영 게이트
법정공제 시트의 판정 열 — 18명 중 김영희(66세)만 「60세 도달」·「연금 제외」가 참이다. 이 열들이 게이트의 입력이다.![]() |
확인 다이얼로그 — 판정 불가가 있으면 막지 않고 되묻는다(확정 ①). 대상이 이름·사번으로 뜨고, 「해당 행 보기」가 있다.

「해당 행 보기」 착지 — 7행(임보라)이 노란 플래시로 잡히고 화면 안으로 스크롤된다. 그 행의 「판정 불가 = True」·「연령 = 0」이 왜 걸렸는지 그대로 보인다.

급여명세서 — 잠정 표기 (확정 ⑤)
「공제 내역」 위에 안내가 붙는다. 원장에 잠정으로 표시된 레코드가 있을 때만 뜬다 — 임의공제만 있는 달에는 사실이 아니기 때문이다.

개발 환경 — 임베드 동일 출처
admin 껍데기 안에 hr-system 급여 시트가 같은 조직으로 떠 있다. 주소가 localhost:4001/hr-system/… 로 부모와 같은 출처이고, 안쪽 앱이 바깥과 같은 토큰을 쓴다.

토스트 — 단일 슬롯에서 스택으로
같은 순간을 수정 전후로 찍었다. 왼쪽은 사용자가 방금 누른 안내 하나만 남고 나머지가 지워진 상태, 오른쪽은 최신이 맨 위에 오고 나머지가 각자 수명을 사는 상태다.
| 수정 전 (단일 슬롯) | 수정 후 (스택, 최신이 위) |
|---|---|
![]() | ![]() |
권한 없는 기능은 감춘다 (단, 명세서는 예외)
| 권한 없음 — 메신저 탭 사라짐 | 권한 있음 — 4탭 |
|---|---|
![]() | ![]() |
왼쪽에서 급여명세서 메뉴는 그대로 남아 있다 — 본인 급여는 모든 직원에게 있어야 하는 기능이라 감추지 않고, 눌렀을 때 권한을 요청하라고 안내한다(제품 결정 2026-08-21).




