IRS의 Tax Withholding Estimator를 만들며 얻은 교훈: 복잡한 선언적 규격을 표현할 때 XML은 장황하지만 명확하고, JSON보다 DSL에 유리하며, 보편적인 파서와 도구 생태계를 ‘공짜로’ 제공한다.
March 13, 2026
어제 IRS는 제가 올여름부터 기술 리드로 엔지니어링을 이끌어 온 프로젝트, 새로운 Tax Withholding Estimator (TWE)의 공개를 발표했다. 납세자는 소득, 예상 공제액, 기타 관련 정보를 입력해 연말에 내야 할 세금을 추정하고, 급여에서 원천징수되는 금액을 조정할 수 있다. 이 도구는 무료이며 오픈 소스이고, IRS로서는 중요한 최초 사례로 공개 기여를 받는다.
TWE에는 공공 부문 소프트웨어라는 분야에 대한 흥미로운 배움이 가득하다. 그런데 나답게, 그중에서도 단연코 가장 건조한 주제부터 쓰려고 한다. XML이다.
(이 글은 연방 공무원으로서의 직무가 아니라, 오픈 소스 공개물을 바탕으로 한 개인 자격의 글이다.)
XML은 기껏해야 투박하고, 최악의 경우에는 구식으로 여겨진다. SOAP 설정과 J2EE를 떠올리게 한다(그 약어들이 무슨 뜻인지 몰라도 괜찮다. 오히려 좋다). 하지만 Tax Withholding Estimator를 통해 얻은 내 경험은, XML이 현대 소프트웨어 개발에서 분명한 자리를 갖고 있으며, 어떤 크로스플랫폼 선언적 명세를 만들든 유력한 선택지로 고려되어야 한다는 점을 보여줬다.
TWE는 _두 개_의 XML 설정으로부터 생성되는 정적 사이트다. 첫 번째 설정은 Fact Dictionary로, 미국 세법(US Tax Code)을 표현하는 우리의 모델이다. 두 번째는 다음 블로그 글의 주제가 될 것이다.
우리는 Fact Dictionary에 정의된 사실(fact)을 바탕으로 납세자의 세금 의무(그리고 원천징수액)를 계산하기 위해 Fact Graph라는 로직 엔진을 사용한다. Fact Graph는 원래 IRS Direct File을 위해 만들어졌고, 지금은 TWE에도 사용한다. 내가 Fact Graph를 처음 접했던 방식 그대로 소개하겠다. 불 예제로.
잠시 XML에 대한 선입견은 접어두고, 다음 fact가 무엇을 설명하는지, 그리고 얼마나 잘 설명하는지 스스로에게 물어보자.
<Fact path="/totalOwed">
<Derived>
<Subtract>
<Minuend>
<Dependency path="/totalTax"/>
</Minuend>
<Subtrahends>
<Dependency path="/totalPayments"/>
</Subtrahends>
</Subtract>
</Derived>
</Fact>
이 fact는 /totalOwed라는 fact를 설명하는데, 이는 /totalTax에서 /totalPayments를 뺀 값으로 도출된다. 세금 용어로 말하면, 이는 연말에 IRS에 추가로 납부해야 하는 금액을 뜻한다. 이 “총 미납액(total owed)”은 소득에 대해 내야 하는 총 세액(“total tax”)과 이미 납부한 금액(“total payments”)의 차이다.
처음 봤을 때 내 반응은, 꽤 장황하지만 꽤나 명확하다는 것이었다. 지금도 대체로 그렇게 느낀다.
몇 개만 보면 구조는 금방 감이 온다. 예를 들어 환급 가능한 세액공제(refundable credits) 계산을 보자. 환급 가능한 공제는 세금 잔액을 음수로 만들 수 있는 공제다. 즉, 내야 할 세금보다 환급 가능한 공제를 더 많이 받을 자격이 있으면, 정부가 돈을 돌려준다. TWE는 Earned Income Credit, Child Tax Credit (CTC), American Opportunity Credit, Adoption Credit의 환급 가능 부분, 그리고 Schedule 3의 기타 항목 등을 합산해 환급 가능한 공제의 총액을 계산한다.
<Fact path="/totalRefundableCredits">
<Description>
Form 1040 Line 32. Schedule 3 Line 15 + EITC,ACTC, AOTC,
refundable portion of Adoption
</Description>
<Derived>
<Add>
<Dependency path="/earnedIncomeCredit"/>
<Dependency path="/additionalCtc"/>
<Dependency path="/americanOpportunityCredit"/>
<Dependency path="/adoptionCreditRefundable"/>
<Dependency path="/schedule3OtherPaymentsAndRefundableCreditsTotal"/>
</Add>
</Derived>
</Fact>
반대로 비환급 세액공제(non-refundable tax credits)는 세금 부담을 0까지 낮출 수 있지만, 음수로 만들지는 못한다. TWE는 <GreaterOf> 연산자를 사용해, 잠정 세금 부담에서 비환급 공제를 빼되 0 아래로 내려가지 않도록 모델링한다.
<Fact path="/tentativeTaxNetNonRefundableCredits">
<Description>
Total tentative tax after applying non-refundable credits, but before
applying refundable credits.
</Description>
<Derived>
<GreaterOf>
<Dollar>0</Dollar>
<Subtract>
<Minuend>
<Dependency path="/totalTentativeTax"/>
</Minuend>
<Subtrahends>
<Dependency path="/totalNonRefundableCredits"/>
</Subtrahends>
</Subtract>
</GreaterOf>
</Derived>
</Fact>
확실히 매우 장황하긴 하지만, 중첩 구조는 따라가기 쉽다. 비환급 공제 적용 후 세금은 이렇게 도출된다. “두 값 중 더 큰 것을 달라: 0, 또는 잠정 세금에서 비환급 공제를 뺀 값.”
마지막으로 입력(input)은 어떨까? 당연히 납세자가 정보를 제공할 곳이 있어야 하고, 그래야 다른 모든 값을 계산할 수 있다.
<Fact path="/totalEstimatedTaxesPaid">
<Writable>
<Dollar/>
</Writable>
</Fact>
좋다. <Derived> 대신 <Writable>을 쓴다. 값이… 쓸 수 있으니까(writable). 납득. <Dollar/>는 이 fact가 어떤 타입의 값을 받는지 나타낸다. 참/거짓 질문은 <Boolean/>을 쓴다. 예를 들어 납세자가 65세 이상인지 기록하는 fact는 다음과 같다.
<Fact path="/primaryFilerAge65OrOlder">
<Writable>
<Boolean/>
</Writable>
</Fact>
(훨씬) 더 긴 fact들도 있지만, 이 정도면 중간값(중앙값) fact가 어떤 모양인지 충분히 대표한다. fact들은 다른 fact에 의존하고, 어떤 것은 도출되고 어떤 것은 입력이며, 결국 마지막에는 최종 세금 숫자들로 합쳐진다. 그런데 왜 전통적 표기보다 훨씬 투박해 보이는 방식으로 수학을 이런 식으로 인코딩할까?
수많은 주류 프로그래밍 언어라면 이 계산을 더 일반적인 수학 표기처럼 보이는 방식으로 쓰게 해준다. 예를 들어 JavaScript로 쓰면 초등 대수처럼 보인다.
const totalOwed = totalTax - totalPayments
이게 더 좋아 보인다! 훨씬 간결하고 읽기 쉬우며, “minuend”와 “subtrahend”를 명시적으로 라벨링할 필요도 없다.
totalTax와 totalPayments의 정의까지 추가해 보자.
const totalTax = tentativeTaxNetNonRefundableCredits + totalOtherTaxes
const totalPayments = totalEstimatedTaxesPaid +
totalTaxesPaidOnSocialSecurityIncome +
totalRefundableCredits
const totalOwed = totalTax - totalPayments
여전히 나쁘지 않다. 총 세액은 비환급 공제 적용 후 세금(앞서 논의한 값)에 “기타 세금(other taxes)”을 더해 계산한다. 총 납부액은 이미 납부한 추정세, 사회보장 소득에 대해 납부한 세금, 그리고 환급 가능한 공제의 합이다.
JavaScript 표현의 문제는 이것이 명령형(imperative) 이라는 점이다. 순서대로 수행할 동작을 опис하고, 시퀀스가 끝나면 중간 단계는 사라진다. 이 문제는 한 단계 더 내려가, totalTax와 totalPayments가 의존하는 모든 값의 정의를 추가하면 더 분명해진다.
// Total tax calculation
const totalOtherTaxes = selfEmploymentTax + additionalMedicareTax + netInvestmentIncomeTax
const tentativeTaxNetNonRefundableCredits = Math.max(totalTentativeTax - totalNonRefundableCredits, 0)
const totalTax = tentativeTaxNetNonRefundableCredits + totalOtherTaxes
// Total payments calculation
const totalEstimatedTaxesPaid = getInput()
const totalTaxesPaidOnSocialSecurityIncome = socialSecuritySources
.map(source => source.totalTaxesPaid)
.reduce((acc, val) => { return acc+val }, 0)
const totalRefundableCredits = earnedIncomeCredit +
additionalCtc +
americanOpportunityCredit +
adoptionCreditRefundable +
schedule3OtherPaymentsAndRefundableCreditsTotal
const totalPayments = totalEstimatedTaxesPaid +
totalTaxesPaidOnSocialSecurityIncome +
totalRefundableCredits
// Total owed
const totalOwed = totalTax - totalPayments
우리는 빠르게 미묘한 문제가 많은 상황에 도달하고 있다.
한 가지 문제는 실행 순서다. 가상의 getInput() 함수는 납세자에게 답을 물어보는데, 이는 프로그램이 계속 진행하기 전에 반드시 일어나야 한다. “총 추정세(total estimated taxes)”를 알 필요가 없는 계산들까지도 사용자를 기다리느라 멈춰 서며, 그 값을 필요로 하는 계산들은 반드시 그 뒤에 배치되어야 한다.
또는 사회보장 소득에 대해 납부한 세금을 합산하는 부분을 자세히 보자.
const totalTaxesPaidOnSocialSecurityIncome = socialSecuritySources
.map(source => source.totalTaxesPaid)
.reduce((acc, val) => { return acc+val }, 0)
갑자기 우리는 JavaScript의 세부 구현에 깊이 들어가 버린다. map과 reduce는 표준 라이브러리에 있고 기본적인 함수형 패러다임도 요즘 널리 쓰이니, 어려운 개념은 아니다. 하지만 이것들은 세금 수학 개념이 아니다. 구현 디테일이다.
같은 값을 Fact로 표현한 것과 비교해 보자.
<Fact path="/totalTaxesPaidOnSocialSecurityIncome">
<Derived>
<CollectionSum>
<Dependency path="/socialSecuritySources/*/totalFederalTaxesPaid"/>
</CollectionSum>
</Derived>
</Fact>
완벽하진 않다. 각 사회보장 소득 원천을 나타내는 *는 약간 편법 같다. 하지만 의미는 훨씬 명확하다. 사회보장 소득에 대해 납부한 총 세금은 무엇인가? 각 사회보장 소득에 대해 납부한 세금의 합이다. 컬렉션의 모든 항목을 더하는 방법은? <CollectionSum>.
게다가 이건 다른 fact들과 같은 방식으로 읽힌다. 컬렉션의 모든 항목을 더해야 한다고 해서 갑자기 전혀 새로운 개념 세계로 튕겨 나가지 않는다.
이 둘의 철학적 차이는, JavaScript가 명령형 인 반면 Fact Dictionary는 선언형(declarative) 이라는 점이다. 컴퓨터가 어떤 단계를 어떤 순서로 밟을지를 정확히 묘사하지 않고, 이름이 붙은 계산들과 그 의존 관계를 묘사한다. 엔진이 그 계산을 어떻게 실행할지 자동으로 결정한다.
(상대적으로) 읽기 친화적이라는 점 외에도, 선언형 세금 모델의 가장 중요한 이점은 프로그램에게 “어떤 값을 어떻게 계산했는지”를 물어볼 수 있다는 것이다. Fact Graph의 원 저자 Chris Given의 말로 하면:
The Fact Graph provides us with a means of proving that none of the unasked questions would have changed the bottom line of your tax return and that you’re getting every tax benefit to which you’re entitled.
totalOwed 값이 뭔가 이상해 보인다고 하자. JavaScript 버전에서는 중간 값들이 이미 폐기되었기 때문에 “그 숫자에 어떻게 도달했지?”라고 물을 수가 없다. 명령형 프로그램은 보통 로그를 추가하거나 디버거로 한 단계씩 실행해 멈춰서 각 값을 확인하며 디버깅한다. 중간 값이 적을 때는 잘 동작한다. 하지만 미국 세법처럼 최종 값이 수백, 수백 개의 중간 계산에 기반해 결정되는 경우에는 전혀 확장되지 않는다.
선언형 그래프 표현을 쓰면, 모든 단일 계산에 대해 감사 가능성(auditability)과 자기성찰(introspection)을 공짜로 얻는다.
TurboTax를 만든 Intuit도 같은 결론에 도달했고 2020년에 그들의 “Tax Knowledge Graph”에 대한 화이트페이퍼를 공개했다. 다만 그 구현은 오픈 소스가 아닌 듯하다(적어도 나는 찾지 못했다). IRS Fact Graph는 오픈 소스이자 퍼블릭 도메인이어서, 누구나 연구하고 공유하고 확장할 수 있다.
세법을 선언적으로 표현할 데이터 표현이 필요하다는 점을 받아들인다면, 그 형태는 무엇이어야 할까?
사람들이 예전에 XML을 마주치곤 했던 네트워크 데이터 전송이나 설정 파일 같은 많은 영역에서, XML은 JSON으로 대체되었다. 나는 JSON이 전송 포맷(wire format)으로는 꽤 괜찮고, 설정 포맷으로는 고통스럽다고 느끼지만, 어느 쪽이든 XML을 쓰고 싶진 않다(후자의 경우는 박빙이긴 하다).
하지만 Fact Dictionary는 다르다. 단순한 설정 더미나 키-값 쌍이 아니다. 독특하고 복잡한 문제 공간을 모델링하는 맞춤형 언어다. 프로그래밍에서는 이를 도메인 특화 언어(domain-specific language), 줄여서 DSL이라 부른다.
연습 삼아 앞서의 /tentativeTaxNetNonRefundableCredits fact를 그럴듯한 JSON으로 표현해 보려고 했다.
{
"description": "Total tentative tax after applying non-refundable credits, but before applying refundable credits.",
"definition": {
"type": "Expression",
"kind": "GreaterOf",
"children": [
{
"type": "Value",
"kind": "Dollar",
"value": 0
},
{
"type": "Expression",
"kind": "Subtract",
"minuend": {
"type": "Dependency",
"path": "/totalTentativeTax"
},
"subtrahend": {
"type": "Dependency",
"path": "/totalNonRefundableCredits"
}
}
]
}
}
이건 그리 복잡한 fact도 아닌데, JSON이 임의로 중첩된 표현식을 잘 다루지 못한다는 점이 즉시 드러난다. JSON에서 사용 가능한 복합 자료구조는 객체(object)뿐이라서, 모든 자식 객체가 자신이 어떤 종류의 객체인지 선언해야 한다. 반면 XML에서는 객체의 “종류”가 구분자 자체에 내장되어 있다.
<Fact path="/tentativeTaxNetNonRefundableCredits">
<Description>
Total tentative tax after applying non-refundable credits, but before
applying refundable credits.
</Description>
<Derived>
<GreaterOf>
<Dollar>0</Dollar>
<Subtract>
<Minuend>
<Dependency path="/totalTentativeTax"/>
</Minuend>
<Subtrahends>
<Dependency path="/totalNonRefundableCredits"/>
</Subtrahends>
</Subtract>
</GreaterOf>
</Derived>
</Fact>
이 XML 표현도 개선의 여지는 있지만, 현재 형태만으로도 JSON보다 확실히 낫다. (재미있게도 몇 줄 더 짧기까지 하다.) 속성과 이름 붙은 자식 요소는, 언어가 무엇을 강조하고 무엇을 강조하지 않을지에 대한 선택을 할 만큼의 표현력을 딱 제공한다. 특정 자료형 집합에 묶이지 않기 때문에 “dollars”와 “integers” 같은 구분을 직접 정의하는 것도 합리적이다.
우리가 JSON과 함께 “원래 그런 것”이라며 내면화해 온 여러 사소한 불편은 사실 JSON 고유의 문제다. 예를 들어 XML에는 주석이 있다. 좋다. 또한 공백과 줄바꿈 처리가 합리적이며, 이는 설명 텍스트가 길어지는 경우가 많을 때 중요하다. 어느 정도 길이나 형태를 가진 텍스트라면, XML은 JSON보다 사람이 직접 읽고 편집하기에 훨씬 쾌적하다.
여전히 장황함을 줄일 여지는 있다. 특히 switch 문(페이지 길이를 존중해 여기서는 생략)에서 그렇다. 우선은 명시적인 “minuend”와 “subtrahend”부터 없앨 것이다.
<Fact path="/tentativeTaxNetNonRefundableCredits">
<Description>
Total tentative tax after applying non-refundable credits, but before
applying refundable credits.
</Description>
<Derived>
<GreaterOf>
<Dollar>0</Dollar>
<Subtract>
<Dependency path="/totalTentativeTax"/>
<Dependency path="/totalNonRefundableCredits"/>
</Subtract>
</GreaterOf>
</Derived>
</Fact>
원래 팀이 이렇게 하지 않은 건, 자식 요소의 순서가 의미(semantics)에 영향을 주지 않게 하고 싶었기 때문이라고 본다. 이해는 하지만, XML에서는 순서가 보장 되고, 추가적인 중첩과 단어들이 득보다 실이 더 크다고 생각한다.
YAML은 어떨까? Chris Given의 말로 다시 인용하자면:
whatever you do, don’t try to express the logic of the Internal Revenue Code as YAML
마지막으로, 이 DSL을 s-expression으로 만들 수도 있다는 주장도 충분히 설득력이 있다. 여러 면에서 읽고 편집하기 가장 좋은 문법이다.
(Fact
(Path "/tentativeTaxNetNonRefundableCredits")
(Description "Total tentative tax after applying non-refundable
credits, but before applying refundable credits.")
(Derived
(GreaterOf
(Dollar 0)
(Subtract
(Minuend (Dependency "/totalTentativeTax"))
(Subtrahends (Dependency "/totalNonRefundableCredits"))))))
HackerNews 사용자 ok123456가 묻는다: “왜 Prolog/Datalog 대신 이걸 쓰고 싶지?” 나는 Prolog 팬이다! 이것도 가능하다.
fact(
path("/tentativeTaxNetNonRefundableCredits"),
description("Total tentative tax after applying non-refundable credits, but before applying refundable credits."),
derived(
greaterOf(
dollar(0),
subtract(
minued(dependency("/totalTentativeTax")),
subtrahends(dependency("/totalNonRefundableCredits"))))))
내 친구 Deniz는 참지 못하고 KDL로 다시 써버렸다. 멋진 건데, 나도 검색해서 알았다.
fact /tentativeTaxNetNonRefundableCredits {
description """
Total tentative tax after applying non-refundable credits, but before
applying refundable credits.
"""
derived {
greater-of {
dollar 0
subtract {
dependency /totalTentativeTax
dependency /totalNonRefundableCredits
}
}
}
}
내 눈에는 이 모든 것이 XML 버전보다 더 쾌적해 보인다. Fact Graph 작업을 시작했을 때, 나는 s-expression으로의 전환을 진지하게 제안하는 것을 강하게 고려했다. 초안 설계 문서에 반쯤 농담으로 넣기까지 했다. 하지만 Fact Graph 위에 실제로 무언가를 구축하는 과정은 XML의 가치에 대한 매우 중요한 사실을 내게 가르쳐 주었다.
XML을 사용하면 파서와 범용 도구 생태계를 공짜로 얻는다.
예를 들어 Prolog를 보자. 단 하나의 predicate로 XML을 Prolog term과 연관 지을 수 있다. Fact Dictionary를 Prolog에서 탐색하거나, Fact Graph의 대체 구현을 통째로 만들고 싶다면, 사실상 Prolog 표현을 기본 제공으로 얻는 셈이다.
S-expression은 Lisp에서 훌륭하고 Prolog term은 Prolog에서 훌륭하다. XML은 거의 네이티브하게 무엇으로든 변환할 수 있다. 그래서 훌륭한 정준(canonical) 크로스플랫폼 데이터 포맷이 된다.
도구 성숙도와 가용성 면에서 XML에 맞먹는 것은 JSON뿐이다. 한때 나는 path로 Fact 정의를 퍼지 검색할 수 있으면 유용하겠다고 생각했다. “overtime”이라고 치면 초과근무와 관련된 모든 fact가 나오면 좋겠다. 코드베이스에서의 일반 검색은 참조와 의존성 때문에 결과가 지저분했다.
이건 내 컴퓨터에 이미 있는 셸 명령들만으로 전부 가능했다.
cat facts.xml | xpath -q -e '//Fact/@path' | grep -o '/[^"]*' | fzf
이 명령은 XPath로 모든 fact path를 질의하고, grep으로 출력 값을 정리하고, fzf로 결과를 대화형 검색한다. 나는 사소한 bash 원라이너로 문제를 해결했다. 그리고 계속 욕심을 내서, path를 검색하는 것뿐 아니라, path 하나를 선택하면 그 정의를 보여주고 싶어졌다.
쉽다. 첫 번째 명령의 결과(어떤 path 속성)를 받아 두 번째 XPath 질의에 넣으면 된다.
path=$(cat facts.xml | xpath -q -e '//Fact/@path' | grep -o '/[^"]*' | fzf)
cat facts.xml | xpath -q -e "//Fact[@path=\"$path\"]" | format
나는 Andy Chu가 설명한 종류의 “$0 Dispatch Pattern” 스크립트로 이걸 조금 과하게 확장해 버렸다. (참고로 Andy는 블로깅 아이콘이다.) 의존성 검색도 추가했다. 즉, fact의 정의를 조회할 수 있을 뿐 아니라, “어떤 fact들이 이것에 의존하는가?”를 물어 의존성 체인을 위로 올라갈 수도 있다.
직접 해보려면 repo를 클론하고 ./scripts/fgs.sh를 실행하면 된다(fzf 설치 필요). 오류 처리는 좀 엉성하지만, 어느 오후에 60줄짜리 bash로 만든 것치고는 꽤 단단하다. 나는 거의 매일 쓴다.
내 스크립트를 몇 명이나 썼는지는 모르겠지만, 다른 팀원들도 비슷하게 빠르고 강력한 디버깅 도구를 뚝딱 만들어 모두의 워크플로에 포함시켰다. 이 도구들은 전부, XML 표현을 손쉽게 파싱해 문제에 가장 적합한 언어로 다룰 수 있다는 점에 의존했다. Scala로 된 Fact Graph의 실제 구현을 건드릴 필요가 없었다.
여기서 내가 얻은 교훈은, 범용 데이터 표현은 금과 같은 가치가 있다는 점이다. 이 범주에는 선택지가 정확히 두 개뿐이다. 대부분의 경우 JSON을 선택해야 한다. 하지만 DSL이 필요하다면, XML은 단연코 가장 값싼 선택지이며, 그 위에 구축하는 비용 대비 효율은 팀이 혁신 예산을 다른 곳에 쓰도록 해줄 것이다.
초안에 대한 피드백을 준 Chris Given과 Deniz Akşimşek에게 감사한다.
grex라는 도구를 썼다. Martijn Faassen은 Rust로 현대적인 XPath 및 XSLT 엔진을 만들고 있다.