C 라이브러리 기반의 Pascal 바인딩은 일반적인 Pascal 코드처럼 읽힙니다. 메서드를 호출하고, 레코드를 반환받으며, 할당한 메모리를 해제합니다. 문제는 PDFium이 자체적인 호출 규칙, 고유한 정수 너비, 그리고 메모리 소유권과 해제에 관한 자체 규칙을 가진 C 및 C++ 라이브러리라는 점입니다. 이러한 규칙 중 어느 것도 언어 경계를 넘어 자동으로 적용되지는 않습니다. 모든 계약은 Pascal 선언문에서 손으로 다시 명시해야 하며, 단어 하나만 잘못 써도 깔끔해 보이는 호출이 스택 손상, 잘린 오프셋, 이중 해제로 바뀝니다. 아래의 각 계약마다 전형적인 실수 방식과 PDFium Component 바인딩이 대신 취하는 구조를 함께 소개합니다. 이는 이 바인딩에만 국한된 이야기가 아니어서 한 번쯤 짚고 넘어갈 가치가 있습니다. Delphi나 Lazarus에서 C API를 감쌀 때 항상 존재하는 위험 요소들입니다
cdecl은 장식이 아니라 함수 유형의 일부입니다
PDFium은 컴파일된 C 라이브러리입니다. Win32에서 익스포트(export) 기능과 더 중요하게는 호출하는 콜백은 cdecl 호출 규칙을 사용합니다. cdecl에서는 호출이 반환된 후 호출자(caller)가 스택을 정리합니다. Delphi의 기본 설정은 register이고, 콜백에 대한 Win32 C 표준은 일부 라이브러리에서 호출되는 쪽(callee)이 스택을 정리하는 stdcall입니다. 구조체가 PDFium에 함수 포인터를 전달할 때 해당 포인터의 유형에서 cdecl을 잊어버리면, 양측은 누가 스택 포인터를 조정할지에 대해 의견이 일치하지 않게 됩니다. 둘 다 조정하거나 둘 다 조정하지 않게 되며, 호출할 때마다 인수 크기만큼 스택 포인터가 어긋나게 됩니다
이런 실수를 찾기 어려운 이유는 손상이 비지역적(non-local)이기 때문입니다. 손상된 호출은 정상적으로 반환되고 멀쩡해 보입니다. 정렬 어긋남은 나중에, 스택 포인터에서 몇 바이트 벗어난 자리에 프레임을 얹은 완전히 무관한 함수에서 나타나며, 엉뚱한 읽기, 잘못된 반환 주소, 또는 실제로 잘못된 콜백과는 전혀 동떨어진 곳을 가리키는 백트레이스를 동반한 크래시로 발현됩니다. 폼 채움(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비트 부호 없는 정수를 의미합니다. 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는 그 별칭입니다. 바인딩의 타입 선언부에는 FPC에서 NativeUInt를 섀도잉하지 말라는 경고 주석이 있는데, FPC에서 이를 32비트 별칭으로 재정의하면 size_t가 32비트로 강제되어 라이브러리로 향하거나 라이브러리가 기록하는 모든 size_t 매개변수가 손상됩니다. 64비트 오프셋이 32비트 매개변수에 도착하면 상위 절반이 사라집니다. 작은 파일이라면 모든 오프셋이 32비트에 들어가므로 아무 문제도 없습니다. 큰 파일에서는 오프셋이 4기가바이트 선을 넘는 순간 잘린 값이 완전히 다른 곳을 가리키고, PDFium은 엉뚱한 바이트 범위의 가용성을 묻게 되며 점진적 로딩은 멈추거나 쓰레기를 읽습니다. 파일이 충분히 커지고 타겟이 size_t가 실제로 넓어지는 그 하나일 때까지 이 실수는 보이지 않습니다
Pascal 예외는 절대로 C 프레임을 통해 언와인드(unwind)되어서는 안 됩니다
세 번째 유형은 예외 모델에 관한 것으로, C에는 없는 개념입니다. PDFium이 콜백 중 하나를 호출할 때, Pascal 코드는 Delphi의 예외 처리 메커니즘에 대해 전혀 모르는 C 및 C++ 프레임 스택 내부에서 실행됩니다. 콜백에서 예외를 발생시키고 그 예외가 전파되도록 놔두면, 원래 언와인드(unwind)되도록 만들어지지 않은 프레임들을 통해 언와인드가 일어납니다. 이로 인해 PDFium 자체의 정리 작업이 실행되지 않고, 내부 상태(invariants)가 절반만 업데이트된 채로 남게 되며, 프로세스는 라이브러리가 전혀 예상하지 못한 상태에 빠지게 됩니다. 이러한 콜백에 대한 계약은 예외가 아니라 반환 코드(return code)입니다
두 개의 콜백이 이를 구체적으로 보여줍니다. FPDF_FILEWRITE는 PDFium이 저장된 문서를 기록하는 싱크(sink)이고, FPDF_FILEACCESS는 PDFium이 입력 문서를 읽어오는 소스(source)입니다. 이 바인딩에서 두 콜백 모두 Delphi TStream을 기반으로 구현되며, 디스크가 가득 차거나 스트림이 닫히거나 읽기가 끝을 지나치는 등 스트림에서 발생할 수 있는 모든 일반적인 오류를 겪을 수 있습니다. 쓰기 콜백은 스트림 쓰기 작업을 감싸고, 실패가 외부로 빠져나가게 하는 대신 어떠한 실패든 PDFium의 실패 코드로 변환합니다
function WriteBlock(
pThis: PFPDF_FILEWRITE;
pData: Pointer;
Size : LongWord): Integer; cdecl;
begin
// PDFium은 1이 아닌 반환을 쓰기 실패로 간주합니다. Pascal 예외가
// 이 cdecl/C++ 프레임을 통과해 풀리면 안 되므로 이를 잡아 대신
// 실패를 보고합니다.
Result := 0;
try
PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
Result := 1;
except
end;
end;
읽기 측도 마찬가지로 작동합니다. 즉, 읽기 실패 시 경계를 넘어 예외를 발생시키는 대신, FPDF_FILEACCESS 계약에 맞춰 0을 반환하여 보고합니다. 예외를 무시하지 말라고 훈련받은 Pascal 프로그래머에게는 예외를 다시 발생(re-raise)시키지 않는 단순한 except 구문이 잘못된 것처럼 보일 수 있으며, 일반적인 Pascal에서는 실제로 잘못된 것입니다. 그러나 ABI 경계에서는 이것이 올바른 형태입니다. C 호출자(caller)에게 전달할 수 있는 유일하게 안전한 값은 호출자가 해석할 줄 아는 상태 코드뿐이기 때문입니다. 실패는 여전히 전파되지만 반환 값을 통해 이루어지며, 제어권이 Pascal 쪽으로 돌아오면 라이브러리 위의 호출 코드가 이를 EPdfError로 표면화합니다
오류 경로에 숨어있는 이중 해제(Double free)
네 번째 위험 요소는 소유권입니다. PDFium 문서 핸들은 라이브러리가 열어주며 FPDF_CloseDocument로 정확히 한 번만 닫혀야 합니다. 위험한 지점은 두 정리 경로가 모두 소유하고 있다고 믿는 핸들을 하나의 오류 경로가 해제하는 상황입니다. 래퍼(wrapper) 객체를 만들고, 새로 연 문서 핸들을 여기에 할당한 뒤, 실패할 수 있는 추가 설정을 수행하는 루틴을 생각해 봅시다. 설정이 예외를 던지면 원시 핸들에 FPDF_CloseDocument를 호출하는 조기 반환 핸들러가 핸들을 닫고, 이어서 객체가 해제될 때 래퍼 객체 자신의 파괴자가 같은 핸들을 다시 닫습니다. 핸들은 두 번 해제되며, 이는 정의되지 않은 동작이고 크래시로 이어질 가능성이 높습니다
이 패턴이 가장 중요해지는 곳은 이미 열려 있는 핸들 주위에 TPdf를 감싸는 임포션(imposition) 방식의 임포트 경로입니다. 처방은 소유권 이전을 단일한 진실의 원천으로 만드는 것입니다. 핸들이 래퍼의 필드에 할당되는 순간 래퍼가 이를 소유하며, 오류 경로에서의 유일한 정리 동작은 래퍼를 해제하는 것입니다. 래퍼의 파괴자가 FPDF_CloseDocument를 대신 호출하므로, 명시적인 두 번째 close 호출은 같은 문서를 이중 해제합니다. 아래 오류 핸들러는 객체를 해제하고 예외를 다시 던지며, close로 가는 경로는 정확히 하나뿐입니다
Result := TPdf.Create(nil);
try
Result.FDocument := NewDoc; // Result가 이제 핸들을 소유함
Result.InitializeFormFill;
Result.ReloadPage;
except
// Result.Free는 핸들을 닫습니다. 여기서 두 번째 FPDF_CloseDocument(NewDoc)를
// 호출하면 동일한 PDFium 문서가 이중 해제됩니다.
Result.Free;
raise;
end;
관리형 레코드(Managed records)와 수많은 익스포트(exports)로 가득 찬 라이브러리 모두 명시적인 정리가 필요합니다
마지막 유형은 컴파일러가 대신 관리해 주는 메모리에 관한 것으로, C의 습관이 조용히 이를 손상시킬 수 있습니다. 이 바인딩의 헬퍼(helper) 함수 중 다수는 WideString 또는 동적 배열을 포함하는 레코드를 반환합니다. 이들은 참조 횟수가 계산(reference-counted)되는 필드이며, 컴파일러는 그 횟수를 유지하기 위해 숨겨진 북키핑(bookkeeping) 코드를 생성합니다. C에서 넘어온 본능적인 습관은 FillChar(Result, SizeOf(Result), 0)를 사용하여 새로운 레코드를 초기화하는 것입니다. 이 코드는 레코드 내부의 관리되는 참조에 대해 참조 횟수를 먼저 감소시키지 않고 0으로 덮어씁니다. 컴파일러는 루프 반복 전체에 걸쳐 함수 결과로 숨겨진 임시 변수 하나를 재사용하므로, 두 번째 반복에서 FillChar는 한 번도 해제되지 않은 유효한 문자열 포인터를 덮어쓰고, 결과적으로 그 포인터가 가리키던 문자열은 메모리 누수(leak)를 일으킵니다. 천 개의 어노테이션(annotations)에 대해 루프를 돌며 함수를 호출하면 천 개의 문자열이 누수됩니다
수정 방법은 관리되는 필드를 0으로 초기화하기 전에 먼저 해제해 주는 Default(T)를 사용하여, 언어가 스스로 알고 있는 방식으로 레코드를 초기화하도록 맡기는 것입니다
// FillChar 대신 Default(): 컴파일러는 루프 반복 전반에 걸쳐
// 함수 결과로 숨겨진 임시 변수 하나를 재사용하므로 FillChar는 라이브
// WideString 포인터를 해제하지 않고 0으로 만듭니다.
Result := Default(TPdfAnnotation);
비슷한 소유권 문제가 라이브러리 로딩 경계에도 있습니다. 이 바인딩은 LoadLibrary 후 GetProcAddress로 PDFium DLL에서 수백 개의 함수 포인터를 가져옵니다. 필수 익스포트 하나가 없으면 부분적으로만 바인딩된 상태가 위험합니다: 수십 개의 포인터는 유효하고 나머지는 nil이거나 오래된 값이며, 이후 이들을 통해 이루어지는 호출은 이미 언로드되었을 수 있는 모듈로 점프합니다. 바인딩은 필수 익스포트 하나라도 해제에 실패하면 라이브러리를 언로드하고 모든 임포트 포인터를 nil로 되돌리는 전체 ClearAllBindings를 실행해 이를 처리합니다. 그 후에는 어떤 함수 포인터도 언로드된 모듈에 걸려 있지 않으므로, 이후 호출은 nil 포인터 검사로 깨끗하게 실패하지 해제된 코드로 분기하지 않습니다
래퍼(wrapper)는 네 가지 계약이 수동 문장으로 다시 명시되는 곳입니다
이 다섯 가지 위험 요소 중 하나도 특별한 것이 아닙니다. C API 위의 얇은 Pascal 계층에서 예측 가능하게 일어나는 실패 양상들이며, 네 가지 별개의 계약을 다시 선언해야 하는 자리가 바로 그 계층이기 때문에 한곳에 몰립니다. 호출 규칙은 모든 콜백에 cdecl로 명시해야 합니다. 정수 너비는 실제로 넓어지는 그 하나의 타겟에서 size_t와 일치해야 합니다. 예외 모델은 Pascal 밖으로 나가는 모든 콜백에서 반환 코드로 변환되어야 합니다. 모든 핸들과 관리형 필드의 소유권은 한 번 명시된 뒤 모든 경로에서 지켜져야 하고, 여기에는 아무도 프로덕션 전에 실행해 보지 않는 오류 경로도 포함됩니다. 하나라도 놓치면 원인과 증상이 멀리 떨어진 결함을 얻게 되며, 바로 이 점이 이 부류의 비용을 크게 만듭니다. 가치는 개별 규칙 하나하나보다, 이 각각을 바인딩 전체에 걸쳐 점검할 별개의 규율로 다루는 태도에 있습니다
가장자리에서 방어만 하는 것이 아니라 실제로 작업을 수행하는 바인딩을 보고 싶다면, 렌더 캐시 및 확대/축소 성능에 대한 노트에서 다룬 렌더 캐시 및 확대/축소 기술에서 렌더링 경로를 확인할 수 있으며, Lazarus 및 FPC 뷰어 빌드하기의 크로스 컴파일러 연습에서는 여기서 설명한 Win64 size_t 동작이 실제로 왜 중요한지 보여줍니다. 두 내용 모두 Delphi, Lazarus 및 C++Builder를 위한 PDFium 컴포넌트에서 제공되는 동일한 메모리 안전성 및 ABI 작업에 기반을 두고 있으며, 이 블로그의 다른 곳에서 다루는 렌더링, 텍스트 추출 및 양식 API와 함께 제공됩니다