C 라이브러리를 래핑하는 Pascal 바인딩은 일반적인 Pascal 코드처럼 읽힙니다. 메서드를 호출하고, 레코드를 반환받고, 할당한 메모리를 해제하는 식입니다. 문제는 PDFium이 자체적인 호출 규약(calling convention), 정수 너비, 그리고 메모리 소유권 및 해제에 대한 독자적인 규칙을 가진 C 및 C++ 라이브러리라는 점입니다. 이러한 규칙 중 어느 것도 언어 경계를 스스로 넘어가지 않습니다. 모든 계약 조건은 Pascal 선언문에서 수작업으로 다시 선언되어야 하며, 단 하나의 단어만 잘못 설정되어도 겉보기에 깔끔한 호출이 스택 손상, 오프셋 값 잘림 또는 이중 해제(double free)로 변질됩니다. PDFium VCL 바인딩의 v1.61.0 감사 과정에서 각 유형의 결함이 하나씩 발견되었습니다. 이 결함들은 이 바인딩에만 국한된 것이 아니라 Delphi나 Lazarus에서 C API를 래핑할 때 발생하는 고질적인 위험이므로 자세히 분석해 볼 가치가 있습니다.
cdecl은 꾸밈 장식이 아니라 함수 타입의 일부입니다
PDFium은 컴파일된 C 라이브러리입니다. Win32에서 라이브러리의 내보내기 함수들과, 더 중요하게는 라이브러리가 호출하는 콜백은 cdecl 호출 규약을 사용합니다. cdecl 환경에서 호출자(caller)는 호출이 반환된 후 스택을 정리합니다. Delphi의 기본 방식은 register이고, 일부 라이브러리의 Win32 C 콜백 표준은 피호출자(callee)가 스택을 정리하는 stdcall입니다. 구조체가 PDFium에 함수 포인터를 전달할 때 포인터의 타입 선언에서 cdecl을 누락하면, 양측은 스택 포인터를 누가 조정해야 하는지에 대해 다른 결론을 내리게 됩니다. 양쪽이 모두 조정하거나 아무도 조정하지 않게 되어, 호출할 때마다 스택 포인터가 인수들의 크기만큼 어긋나게 됩니다.
이 결함을 찾기 어려운 이유는 손상이 일어난 로컬 영역 외부에서 문제가 감지되기 때문입니다. 손상된 호출 자체는 올바르게 반환된 것처럼 보입니다. 스택 포인터의 불일치는 나중에 몇 바이트만큼 어긋난 스택 포인터 위에 프레임이 배치되는 무관한 다른 함수가 실행될 때 발생하며, 잘못된 메모리 읽기, 손상된 반환 주소, 혹은 실제 오류가 발생한 콜백 위치와 완전히 무관한 곳을 가리키는 백트레이스 크래시 등으로 나타납니다. 양식 채우기(form-fill)가 이러한 문제가 빈번하게 발생하는 대표적인 영역입니다. 양식 채우기 인터페이스는 PDFium이 호출하는 콜백들로 가득 찬 레코드 구조이기 때문입니다. 그중 하나인 FFI_OpenFile은 PDFium이 외부 파일을 열기 위해 호출하는 함수를 전달하며, 이 함수는 function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl로 선언되어 있습니다. 접미사 cdecl은 반드시 누락 없이 작성해야 합니다. 이를 누락해도 컴파일과 링크가 정상적으로 수행되며, PDFium이 해당 함수를 실제로 호출할 때까지 프로그램이 정상 작동하는 것처럼 보입니다. 호출 규약은 함수 타입 자체에 포함되는 속성입니다. 선택적인 장식이 아니며, 일반 함수 타입 역시 완전히 유효한 Pascal 타입이므로 컴파일러는 누락에 대해 경고하지 않습니다. 유일한 해결책은 내보내기 시그니처와 외부로 전달하는 모든 콜백의 필수 필드로 호출 규약을 간주하고 검증하는 것입니다.
size_t는 포인터 너비 크기이며, FPC Win64에서는 64비트를 의미합니다
두 번째 결함은 특정 타겟에서만 나타나는 정수 너비 불일치 문제입니다. C언어의 size_t는 모든 개체의 크기를 담을 수 있을 만큼 충분히 크도록 정의되어 있으며, 이는 64비트 플랫폼에서 64비트 부호 없는 정수(unsigned integer)를 의미합니다. PDFium의 점진적 로딩(progressive-loading) 인터페이스는 size_t 바이트 오프셋 단위로 통신합니다. 가용성 제공자의 FX_FILEAVAIL 레코드는 PDFium이 오프셋 및 크기와 함께 호출하는 IsDataAvail 콜백을 가지며, FX_DOWNLOADHINTS 레코드의 AddSegment 콜백도 동일하게 값을 전달받습니다. 두 매개변수 모두 size_t 타입입니다.
IsDataAvail = function(
pThis : PFX_FILEAVAIL;
offset, size: size_t): FPDF_BOOL; cdecl;
AddSegment = procedure(
pThis : PFX_DOWNLOADHINTS;
offset, size: size_t); cdecl;
만약 이러한 오프셋들을 32비트 타입으로 선언하면, 이 바인딩은 Win32 및 Delphi Win64에서는 잘 작동하다가 FPC 및 Lazarus Win64에서는 오류 없이 작동을 멈추게 됩니다. 원인은 미묘합니다. FPC Win64에서 NativeUInt는 순수한 포인터 크기의 64비트 타입이며, size_t는 이를 가리키는 별칭(alias)입니다. 바인딩의 타입 섹션에는 FPC에서 NativeUInt를 덮어쓰는 것을 경고하는 주석이 명확히 기재되어 있습니다. 해당 환경에서 32비트 별칭으로 재정의하면 size_t가 32비트로 강제되어 라이브러리에 전달되거나 라이브러리에서 쓰여지는 모든 size_t 매개변수가 손상되기 때문입니다. 32비트 매개변수로 전달되는 64비트 오프셋은 상위 비트를 잃게 됩니다. 작은 파일의 경우 모든 오프셋이 32비트에 들어맞으므로 문제가 발견되지 않습니다. 그러나 큰 파일의 경우, 오프셋이 4GB 경계를 넘어서는 순간 잘려 나간 값이 완전히 다른 엉뚱한 위치를 가리키게 되고, PDFium은 잘못된 바이트 범위의 가용성을 쿼리하므로 점진적 로딩이 중단되거나 잘못된 데이터를 읽게 됩니다. 파일 크기가 충분히 커지고 size_t가 실제로 확장되는 타겟 플랫폼 환경을 만나기 전까지는 이 결함이 드러나지 않습니다.
Pascal 예외는 절대로 C 프레임을 통해 해제되어서는 안 됩니다
세 번째 범주의 결함은 C언어에 존재하지 않는 예외 모델에 관한 것입니다. PDFium이 콜백 중 하나를 호출하면, Pascal 코드는 Delphi의 예외 처리 메커니즘을 전혀 인식하지 못하는 C 및 C++ 스택 프레임 내부에서 실행됩니다. 만약 콜백에서 예외가 발생하여 이를 그대로 전파하면, 예외 처리를 위해 설계되지 않은 프레임들을 해제(unwind)하며 지나가게 됩니다. 이로 인해 PDFium 자체의 정리 코드가 작동하지 않고, 내부 불변성이 손상된 상태로 유지되며, 결국 라이브러리가 예상하지 못한 상태에 빠지게 됩니다. 이러한 콜백과의 계약 조건은 예외 발생이 아닌 상태 반환 코드여야 합니다.
두 개의 콜백이 이를 구체적으로 보여줍니다. FPDF_FILEWRITE는 PDFium이 저장된 문서를 기록하는 싱크(sink)이고, FPDF_FILEACCESS는 입력 문서를 읽어오는 소스(source)입니다. 두 콜백 모두 Delphi TStream을 기반으로 구현되어 있으며, 디스크 공간 부족, 스트림의 예기치 않은 종료, 범위를 벗어난 읽기 등 일반적인 스트림 오류가 동일하게 발생할 수 있습니다. 쓰기 콜백은 예외가 외부로 유출되는 것을 차단하기 위해 스트림 쓰기를 감싸 발생한 실패를 PDFium의 오류 코드로 변환해 반환합니다.
function WriteBlock(
pThis: PFPDF_FILEWRITE;
pData: Pointer;
Size : LongWord): Integer; cdecl;
begin
// PDFium treats any non-1 return as a write failure. A Pascal exception
// must not unwind through this cdecl/C++ frame, so trap it and report
// failure instead.
Result := 0;
try
PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
Result := 1;
except
end;
end;
읽기 콜백 역시 동일하게 처리됩니다. 읽기 실패 시 경계를 넘어 예외를 발생시키는 대신, FPDF_FILEACCESS 계약에 맞춰 0을 반환합니다. 다시 발생시키지 않는(no re-raise) 단순한 except 구문은 예외를 무시하지 않도록 훈련받은 Pascal 개발자에게는 잘못된 형태처럼 보일 수 있고, 실제로도 일반적인 Pascal에서는 적절하지 않습니다. 그러나 ABI 경계에서는 이것이 올바른 구조입니다. C 호출자에게 전달할 수 있는 유일하게 안전한 값은 그것이 해석할 수 있는 상태 코드뿐이기 때문입니다. 오류는 반환 값을 통해 정상적으로 전달되며, 제어권이 다시 Pascal 영역으로 돌아온 후에 라이브러리 상위 수준의 호출 코드가 이를 EPdfError로 노출합니다.
이중 해제는 예외 처리 경로에 숨어 있습니다
네 번째 결함은 소유권에 관한 것입니다. PDFium 문서 핸들은 라이브러리에 의해 열리며 반드시 FPDF_CloseDocument를 통해 한 번만 닫혀야 합니다. 위험한 부분은 추가적인 정리 단계가 소유한 핸들을 중복해서 해제하는 예외 처리 경로입니다. 래퍼(wrapper) 개체를 생성하고, 방금 열린 문서 핸들을 여기에 할당한 뒤, 실패할 가능성이 있는 추가 설정을 수행하는 루틴을 가정해 보십시오. 추가 설정 중에 예외가 발생했을 때 로컬 예외 처리기가 원본 핸들에 대해 FPDF_CloseDocument를 호출하여 닫으면, 이후 래퍼 개체가 파괴될 때 소멸자가 동일한 핸들을 다시 닫으려 시도하게 됩니다. 핸들이 두 번 해제되는 이중 해제(double free)는 정의되지 않은 동작을 유발하며 주로 크래시로 이어집니다.
감사 결과, 이미 열려 있는 핸들을 중심으로 TPdf를 빌드하는 임포지션 스타일의 가져오기 경로에서 이러한 문제가 확인되었습니다. 해결 방법은 소유권 이전을 단일 진실 공급원(single source of truth)으로 보장하는 것입니다. 핸들이 래퍼의 필드에 할당된 이후에는 래퍼가 그 소유권을 가지며, 예외 처리 경로에서 수행할 유일한 작업은 래퍼를 해제하는 것입니다. 래퍼의 소멸자가 FPDF_CloseDocument를 대신 호출하므로, 명시적으로 종료 함수를 추가 호출하면 동일한 문서를 이중 해제하게 됩니다. 수정된 예외 처리기는 개체만 해제하고 예외를 다시 발생시켜 단 하나의 소멸 경로만 거치도록 만듭니다.
Result := TPdf.Create(nil);
try
Result.FDocument := NewDoc; // Result now owns the handle
Result.InitializeFormFill;
Result.ReloadPage;
except
// Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
// here would double-free the same PDFium document.
Result.Free;
raise;
end;
관리형 레코드와 다수의 내보내기 함수를 가진 라이브러리 모두 명시적인 정리가 필요합니다
마지막 결함 범주는 컴파일러가 대신 관리하는 메모리에 관한 것으로, C언어 작성 습관으로 인해 조용히 손상될 수 있는 부분입니다. 이 바인딩의 여러 헬퍼 함수들은 WideString 또는 동적 배열을 포함하는 레코드를 반환합니다. 이 필드들은 참조 횟수(reference-counted)를 기반으로 작동하며, 컴파일러는 참조 횟수를 관리하기 위한 숨겨진 코드를 생성합니다. C언어의 관성대로 새 레코드를 초기화하기 위해 FillChar(Result, SizeOf(Result), 0)를 호출하면 문제가 생깁니다. 이는 먼저 참조 카운트를 감소시키지 않고 레코드 내부의 관리형 참조 필드 위치를 0으로 덮어쓰게 됩니다. 컴파일러는 루프 반복 전체에 걸쳐 함수 결과로 반환되는 하나의 숨겨진 임시 변수를 재사용하므로, 두 번째 반복 시 FillChar가 해제되지 않은 활성 상태의 문자열 포인터를 0으로 덮어써 포인터가 가리키던 문자열이 유출(leak)됩니다. 이 함수를 루프 내에서 1,000번 호출하면 1,000개의 문자열이 유출되는 것입니다.
해결 방법은 컴파일러가 레코드를 자체 방식대로 정리하도록 Default(T)를 사용하는 것입니다. Default(T)는 관리형 필드를 안전하게 해제한 후 메모리를 0으로 초기화합니다.
// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);
유사한 소유권 문제가 라이브러리 로드 경계에서도 나타납니다. 이 바인딩은 LoadLibrary를 수행한 후에 GetProcAddress를 사용하여 PDFium DLL로부터 수백 개의 함수 포인터를 확인합니다. 필수 내보내기 함수 중 하나라도 누락되면 일부 포인터만 활성화된 위험한 상태가 됩니다. 수십 개의 포인터는 유효하지만 나머지는 nil이거나 유효하지 않으며, 나중에 그중 하나를 호출하려고 시도하면 이미 해제되었을 수 있는 모듈 영역으로 제어권이 넘어갑니다. 이 바인딩은 필수 내보내기 함수 해결에 실패할 때마다 라이브러리를 언로드하고 가져온 모든 포인터를 nil로 초기화하는 ClearAllBindings를 실행하여 이를 방지합니다. 이렇게 하면 유효하지 않은 모듈을 가리키는 함수 포인터가 남지 않으므로, 해제된 코드 영역을 호출하는 대신 명확하게 nil 포인터 오류가 발생하여 프로그램이 안전하게 제어됩니다.
래퍼는 네 가지 계약이 수작업으로 재정의되는 공간입니다
이 다섯 가지 결함 중 어느 것도 기이한 오류가 아닙니다. C API 위에 올려진 얇은 Pascal 계층에서 예상할 수 있는 고전적인 실패 유형들이며, 해당 계층이 네 가지 개별 계약을 명시적으로 재선언해야 하는 정확한 지점이기 때문에 집중적으로 발생합니다. 즉, 모든 콜백에 호출 규약 cdecl이 기재되어야 합니다. 실제로 정수 크기가 커지는 특정 타겟 플랫폼에서는 정수 너비가 size_t와 일치해야 합니다. Pascal 경계를 벗어나는 모든 콜백에서 예외 모델이 반환 코드로 변환되어야 합니다. 마지막으로, 상용 배포 전까지 테스트 과정에서 실행되지 않는 예외 처리 경로를 포함하여, 모든 핸들 및 관리형 필드의 소유권이 명확히 정의되고 지켜져야 합니다. 이 중 하나라도 누락되면 근본 원인과 한참 동떨어진 증상을 가진 파악하기 어려운 결함이 발생합니다. 이번 감사의 핵심적인 가치는 단일 수정보다 바인딩 전반에 걸쳐 검증해야 할 핵심 규칙들을 도출해 낸 데 있습니다.
바인딩가 보호 코드를 넘어 실제 비즈니스 로직을 수행하는 모습을 확인하려면 렌더 캐시 및 줌 성능 아티클에서 렌더링 경로를 확인해 볼 수 있으며, Lazarus 및 FPC 뷰어 구축 가이드는 본 문서에서 설명한 Win64 size_t 세부 속성이 실제로 유효하게 작용하는 지점입니다. 두 시나리오 모두 본 블로그의 다른 곳에서 다루는 렌더링, 텍스트 추출 및 양식 API와 함께 Delphi, Lazarus 및 C++Builder용 PDFium Component의 기초가 되는 메모리 안전성 및 ABI 작업에 기반을 두고 있습니다.