losLab의 Delphi PDF 라이브러리인 PDFlibPas는 EN 16931, XRechnung 3.0.2, Peppol BIS Billing 3.0 비즈니스 규칙을 Pascal 네이티브로 검사한다. TPDFlib.ValidateInvoiceBusinessRulesBuiltIn이 Factur-X나 ZUGFeRD PDF에 내장된 XML을 읽어 공식 규칙 ID가 붙은 JSON 리포트를 돌려주는데, Java 런타임도 Saxon도 Schematron 툴체인도 필요 없다. 리포트는 스스로를 subset이라고 표시하는데, 이 표시가 리포트에서 가장 중요한 필드다
이 기능을 밀어 붙인 압력은 대부분 독일에서 왔다. 2025년 1월부터 독일의 모든 국내 사업자는 구조화된 B2B 전자 인보이스를 받을 수 있어야 하고, 발행 의무는 단계적으로 뒤따른다. 그러자 Delphi 회계나 ERP 애플리케이션의 수신 측이 그동안 물어 본 적 없는 질문에 답해야 한다. 이 PDF 안의 XML이 실제로 유효한 EN 16931 인보이스인가? 공식 답은 XSLT 2.0으로 컴파일된 Schematron 아티팩트나 XRechnung용 KoSIT 검증기에서 나오는데, 둘 다 JVM 위에서 돌아간다. 나쁜 인보이스 한 장 거르자고 데스크톱 설치마다 JRE를 실어 나르고 패치하는 비용은 감수하고 싶지 않은 팀이 많다
내장 검증기는 실제로 무엇을 검사하는가?
네이티브 검증기는 인보이스 XML을 EN 16931 비즈니스 용어에 대응시키고, EN 16931 검증 아티팩트 1.3.16의 규칙 가운데 문서화된 subset을 돌리며 각 발견 사항을 공식 규칙 ID로 보고한다. 두 문법을 모두 지원한다. Factur-X와 ZUGFeRD가 내장하는 UN/CEFACT CII D16B, 그리고 OASIS UBL 2.1 Invoice와 CreditNote가 그것이다. 커버되는 영역은 다음과 같다
- 필수 항목: 스펙 식별자, 인보이스 번호, 발행일, 유형 코드, 통화, 매도인과 매수인 이름, 주소, 문서 합계, VAT 내역과 품목(BR-01부터 BR-16, BR-21부터 BR-28, BR-CO-04, BR-CO-18, BR-CO-26, BR-45부터 BR-48)
- 계산: 품목, 공제, 추가요금, 세액, 지급 합계, 그리고 VAT 범주별 과세 대상 금액과 세액(BR-CO-10부터 BR-CO-17, BR-xx-08, BR-xx-09)
- S, Z, E, AE, K, G, O, L, M 범주의 VAT 규칙: 품목 세율, 면제 사유, 범주별로 요구되는 VAT 식별자
- BR-CL-01부터 BR-CL-26까지 코드 목록 전부, BR-CL-23 뒤에 있는 2100개가 넘는 UN/ECE Rec 20/21 단위 코드 포함, 그리고 소수 둘째 자리 규칙 BR-DEC-01, -05, -09부터 -20, -23
- 프로파일 규칙: CII와 UBL 위의 XRechnung 3.0.2(BR-DE-1부터 BR-DE-31, BR-DEX-01부터 BR-DEX-14), UBL 위의 Peppol BIS Billing 3.0.20 core, common, 코드 목록, 국가별 규칙
범위는 암시되지 않고 보고된다. 스키마 파일이 없으면 스키마 단계는 well-formedness와 루트 요소만 확인한다. 각 단계는 subset 밖의 규칙을 notCovered 필드에 나열하고, GetBuiltInInvoiceRuleCatalog가 전체 대응 표를 JSON으로 돌려주므로 UI가 초록 결과가 무엇을 커버하는지 사용자에게 정확히 보여 줄 수 있다
또 하나 놓치기 쉬운 경계가 있다. Factur-X MINIMUM과 BASIC WL 문서는 EN 16931 인보이스가 아니므로 검증기는 이들에게 EN 16931 규칙을 적용하지 않는다. 대신 그렇다고 알려 주는 정보성 BuiltIn.Coverage.Profile 이슈를 하나 추가하는데, 이것이 정직한 답이면서 동시에 힌트이기도 하다. 독일 규칙 아래에서는 이 두 프로파일이 애초에 전자 인보이스로 쳐 주지 않는다
하이브리드 PDF에 내장된 XML 검증하기
ValidateInvoiceBusinessRulesBuiltIn은 인자를 하나 받는데, 내장 파일 이름이며 빈 문자열이 평범한 선택이다. PDFlibPas는 이때 Factur-X 탐지가 가리키는 첨부 파일을 고른다. Factur-X 메타데이터가 없는 PDF라면 유일한 .xml 첨부로 폴백한다. 후보 .xml 첨부가 여러 개이거나 요청한 이름을 가진 첨부가 둘이면 추측 대신 Invoice.AttachmentAmbiguous 오류를 내고, 디코딩은 64 MB로 제한되며, 인보이스가 없으면 LastErrorCode를 401로 설정한다
uses
System.SysUtils, System.JSON, PDFlibrary;
procedure CheckInboundInvoice(const FileName: string);
var
PDF: TPDFlib;
Report: TJSONObject;
begin
PDF := TPDFlib.Create;
try
if PDF.LoadFromFile(FileName, '') <> 1 then
raise Exception.CreateFmt('Cannot open %s (error %d)',
[FileName, PDF.LastErrorCode]);
// 빈 이름: 탐지된 Factur-X / ZUGFeRD 첨부를 검증한다
Report := TJSONObject.ParseJSONValue(
PDF.ValidateInvoiceBusinessRulesBuiltIn('')) as TJSONObject;
try
Writeln('profile: ', Report.GetValue<string>('profile'));
Writeln('valid: ', Report.GetValue<Boolean>('valid'));
Writeln('coverage: ', Report.GetValue<string>('coverage')); // "subset"
Writeln('full: ', Report.GetValue<Boolean>('fullConformance')); // 여기서는 절대 True가 아니다
finally
Report.Free;
end;
finally
PDF.Free;
end;
end;
리포트 최상위에는 standard, version, profile, complete, valid, fullConformance, coverage, schema와 schematron 단계, 그리고 내장 모드에서는 null인 package 객체가 들어 있다. 각 단계는 상태, 엔진, 실행된 규칙 수, 커버리지와 issues 배열을 보고한다. 모든 이슈에는 ruleID, fieldPath, severity, category, line, message가 붙는다
메시지 대신 category로 이슈 읽기
category 필드가 있기에 리포트가 실제 수신함에서 쓸모 있어진다. 누가 문제를 고쳐야 하는지 알려 주기 때문이다. 값은 syntax, schema, cardinality, calculation, vat, codelist, decimal, profile, coverage, processing, integrity다. calculation 발견 사항은 대개 발신자의 반올림 코드를, codelist 발견 사항은 마스터 데이터를, profile 발견 사항은 buyer reference 같은 XRechnung 필드를 가리킨다
procedure ListIssues(Report: TJSONObject);
var
Item: TJSONValue;
Issue: TJSONObject;
begin
for Item in Report.GetValue<TJSONArray>('schematron.issues') do
begin
Issue := Item as TJSONObject;
if Issue.GetValue<string>('severity') = 'info' then
Continue; // BuiltIn.Coverage.Profile 같은 커버리지 노트
Writeln(Format('[%s] %s at %s: %s', [
Issue.GetValue<string>('category'),
Issue.GetValue<string>('ruleID'),
Issue.GetValue<string>('fieldPath'),
Issue.GetValue<string>('message')]));
end;
end;
같은 루프를 schema.issues에도 돌려라. well-formedness와 루트 요소 실패는 이쪽에 떨어지고, 비즈니스 규칙 단계는 파싱조차 못한 문서를 결코 보지 못하기 때문이다
같은 인보이스가 CII에서는 통과하고 UBL에서는 실패하는 이유는?
공식 아티팩트는 두 문법에 같은 수치 허용 오차를 적용하지 않으며, PDFlibPas는 규칙들을 하나로 통일하지 않고 각 문법의 XSLT에서 그대로 가져온다. 가장 뚜렷한 사례가 BR-CO-17이다. 이 규칙은 VAT 범주 세액이 과세 대상 금액 곱하기 세율을 소수 둘째 자리까지 반올림한 값과 같은지 검사하는데, 두 문법 모두 통화 1단위 차이를 허용한다. CII 아티팩트는 이를 <=와 >=로 쓰고, UBL 아티팩트는 엄격한 <를 동반하는 abs를 쓴다
과세 대상 금액 100.00에 세율 19퍼센트, 그러니까 예상 세액은 19.00인데 인보이스는 20.00이라고 선언했다 치자. CII에서는 20.00 빼기 1이 19.00이어서 <=를 만족하고 BR-CO-17이 통과한다. UBL에서는 같은 비교가 엄격해서 19.00은 19.00보다 작지 않고 BR-CO-17이 실패한다. 이런 인보이스를 일부러 보내는 사람은 없지만, 반올림 버그를 가진 생성기가 정확히 이런 경계 위에 앉아 있고, 문법 하나만 커버하는 테스트 스위트는 눈치채지 못한다
차이는 BR-CO-17 이상으로 이어진다. BR-xx-08은 CII에서 소수 둘째 자리 반올림 후 정확히 같아야 한다고 요구하지만 UBL은 ±1을 허용하고, 범주에 실제로 품목·공제·추가요금이 있을 때만 검사한다. BR-O-08은 양쪽에서 정확 일치다. CII 아티팩트는 BR-AF-09와 BR-AG-09를 true()로 정의하므로 이 규칙들은 CII에서 실패하지 않고, UBL은 ±1로 검사한다. 그리고 CII VAT 내역에 세율이 아예 없으면 빈 시퀀스에 대한 XPath every $rate가 참이므로 비(非)O 범주 규칙은 공(空)으로 통과한다. 네이티브 코드도 같은 방식으로 건너뛰어야 한다. 그렇지 않으면 공식 샘플 파일에서 오류를 보고한다
두 번째 함정은 규칙 ID와 관련 있다. 규칙 범주 접두사는 VAT 범주 코드가 아니다. K는 BR-IC로, L은 BR-AF로, M은 BR-AG로 대응된다. 범주 코드에서 ID를 만들어 내면 EN 16931에 존재하지 않는 BR-K-09와 BR-K-10이 튀어나온다. 애플리케이션이 규칙 ID를 도움말 텍스트에 대응시킨다면 ID는 리포트에서 가져오고 세금 범주 코드로 조립하지 마라
XRechnung과 Peppol 규칙은 어떻게 켜지는가?
내장 검증기는 스펙 식별자(BT-24)에서 프로파일 규칙을 고른다. xrechnung이 들어 있는 식별자는 CII나 UBL 위에서 XRechnung 3.0.2 CIUS 규칙을 돌리고, 확장 식별자 urn:xeinkauf.de:kosit:extension:xrechnung_3.0은 추가로 BR-DEX 규칙을 켠다. 하위 인보이스 품목 금액과 VAT 검사(BR-DEX-02, -03, 그리고 -09부터 -14)는 UBL에서만 돌고, CII에서는 BR-DEX-15가 하위 인보이스 품목을 사용할 수 없다고 보고한다. peppol이 들어 있는 식별자는 Peppol BIS Billing 3.0.20 규칙을 돌리는데, Peppol이 이를 정의한 것은 UBL뿐이다. Peppol 커스터마이징을 주장하는 CII 문서는 조용히 통과시키는 대신 그 규칙들은 검사되지 않았다고 알려 주는 정보성 BuiltIn.Coverage.Profile 노트를 받는다. 그 외의 CIUS 프로파일이라면 공식 아티팩트를 쓰라
알아 둘 만한 UBL 디테일이 하나 있다. BR-06과 BR-07은 매도인 또는 매수인 이름으로 PartyLegalEntity/RegistrationName만 받는다. PartyName/Name으로 폴백하면 관대해 보이지만 실제 BR-06 실패를 숨겨 버린다. 그래서 이 검사는 엄격하다
그래도 공식 Schematron이 필요한 때는?
결과에 법적·계약상 무게가 실리는 경우에는 공식 아티팩트, 즉 Saxon을 거치는 EN 16931 Schematron이나 XRechnung용 KoSIT 검증기를 써라. 공급자 인보이스를 거절하거나, 자기 생성기를 인증하거나, 분쟁을 정산할 때가 그렇다. 내장 리포트는 결코 fullConformance를 true로 설정하지 않는다. 이 플래그에는 검증된 규칙 패키지, 양 단계 통과, 양 단계가 패키지 고유의 규칙을 실행하는 엔진으로 돌아갔다는 사실, 그리고 notCovered에 아무것도 남지 않았다는 조건이 필요하다
ValidateInvoiceBusinessRulesWithPackage가 그 경로를 돌린다. 규칙 패키지는 rulepackage.xml 매니페스트를 가진 디렉터리로, CreateInvoiceRulePackage가 작성하며 표준, 버전, 스키마, Schematron 스타일시트 이름을 담고 모든 파일의 SHA-256을 기록한다. PDFlibPas는 패키지가 로드될 때와 엔진이 파일을 읽기 직전에 각 다이제스트를 검사해서, 검증 후 파일이 바뀔 수 있는 창을 줄인다. 경로가 패키지 디렉터리 밖으로 나갈 수 없고, 네이티브 XSD 로더는 매니페스트에 없는 import를 거부한다. xmllint를 설정하면 import된 스키마는 xmllint 자신이 읽으므로 로드 시점 검사만이 이들을 보호한다
const
// 검토하고 승인한 rulepackage.xml의 SHA-256
ApprovedManifestSHA256 = '<64 hex digits from your release review>';
var
Report, Pkg: TJSONObject;
begin
// PDF: 인보이스가 이미 로드된 TPDFlib, 첫 예제와 같다
Report := TJSONObject.ParseJSONValue(
PDF.ValidateInvoiceBusinessRulesWithPackage('',
'C:\Rules\EN16931-CII-1.3.16', ApprovedManifestSHA256,
'', // 빈 값: 네이티브 XSD 단계
'C:\Java\bin\java.exe', 'C:\Saxon\saxon-he.jar',
0)) as TJSONObject; // 0: 도구당 30000 ms
try
if not (Report.FindValue('package') is TJSONObject) then
raise Exception.Create('No rule package in the report');
Pkg := Report.GetValue<TJSONObject>('package');
if not (Pkg.GetValue<Boolean>('pinned') and Pkg.GetValue<Boolean>('verified')) then
raise Exception.Create('Rule package failed its integrity check');
if Report.GetValue<Boolean>('fullConformance') then
Writeln('Conforms to the complete rule package');
finally
Report.Free;
end;
end;
고정해 둔 다이제스트는 장식이 아니다. 파일 다이제스트는 파일이 매니페스트와 일치함을 증명하지만, 패키지 안의 어떤 것도 매니페스트 자체가 진짜임을 증명하지 못한다. 규칙을 다시 쓸 수 있는 공격자는 매니페스트도 함께 다시 쓸 수 있다. 호출자가 코드나 서명된 설정에서 고정한 다이제스트만이 이 틈을 막고, 리포트는 pinned와 verified를 따로 유지해서 실제로 어떤 보장을 갖췄는지 구별하게 해 준다
빈 JavaExecutable을 넘기면 스키마 단계는 여전히 패키지 XSD에 대해 네이티브로 검증한다(v3.672.0부터 facet과 identity constraint 포함). 하지만 비즈니스 규칙은 내장 subset으로 폴백하고 fullConformance는 false로 남는다. substitution group처럼 네이티브 로더가 지원하지 않는 스키마 구성은 건너뛰는 대신 XSD.Unsupported로 로드를 실패시킨다
veraPDF와 Mustang이 맞는 자리
이 기능들 중 그 무엇도 PDF 컨테이너를 들여다보지 않는다. ISO 19005-3 아래의 PDF/A-3 적합성, Factur-X XMP 확장 스키마, associated-file 관계는 별개의 질문이며, 이에 대해서는 veraPDF와 Mustang으로 전자 인보이스 검증하기에서 말하듯 veraPDF와 Mustang이 여전히 기준 도구다. XML 첨부부터 프로파일 선언까지의 생산 측은 Delphi에서 Factur-X와 ZUGFeRD 하이브리드 인보이스 만들기와 Factur-X 메타데이터를 위한 PDF/A-3 확장 스키마가 다룬다
현실적인 분업은 밀리초가 드는 내장 검사를 수신 인보이스마다 프로세스 안에서 돌리는 것이다. base64 첨부가 붙은 1 MB 정도의 UBL 인보이스가 15 ms에 검증되고, UBL 2.1 XSD를 붙이면 79 ms다. 패키지로 묶은 Saxon 실행은 CI와 거절 경로로 들어가고, 컨테이너는 veraPDF가 맡는다. 내장 결과가 어떻든 그것은 표준이 아니라 커버된 subset을 기술한다. coverage 필드가 그 사실을 상기시키려고 존재하는 것이다
인보이스 검증 함수, 규칙 카탈로그, 규칙 패키지 도구는 Delphi와 C++Builder용 Delphi PDF Library와 함께 제공되며, 위에서 쓴 Factur-X, PDF/A-3, 내장 파일 API도 같이 따라온다