동일한 오브젝트 파스칼(Object Pascal) 소스 코드이더라도 Delphi와 FPC/Lazarus 컴파일러 환경에 따라 다르게 작동하며 PDFium Component의 개발을 수차례 방해했던 대표적인 4가지 함정이 있습니다. 첫째, FPC는 in 집합 대조가 끝나기 전에 함수가 반환한 임시 레코드 변수를 조기 메모리 해제해 버립니다. 둘째, Delphi의 dcc32 컴파일러는 기본 범위 초과 검사를 끈 채로 빌드하므로 배열 범위 초과 인덱스 조회 시 에러 없이 가비지 데이터를 침묵 속에서 읽어 들입니다. 셋째, 오직 최신 Delphi 13 컴파일러만 명시적 캐스팅 없이 무명 array of Byte를 TBytes형에 대입하는 것을 허용합니다. 넷째, Delphi의 AnsiString 문자열 결합(concatenation)은 시스템 코드페이지 변환을 유발해 $80 이상의 물리 바이트 데이터를 16진수 문자 '?'($3F)로 왜곡시켜 파괴합니다. 이러한 차이점들로 인해 한쪽 컴파일러 빌드에서는 정상 합격(green)을 주지만, 다른 컴파일러 빌드에서는 예외 에러(red)를 내뱉거나 혹은 소리 소문 없이 망가진 결과물(silently wrong)을 생성하게 됩니다
두 컴파일러를 동시에 지원하는 크로스 컴파일 프로젝트를 처음 설계하시는 개발자라면, 패키지 설정 방법이나 검색 경로 지정 및 화면 렌더링 뷰어 구현의 정석을 담은 Lazarus 및 FPC PDF 뷰어 구현 안내서를 먼저 참고하십시오. 반면 본 문서는 튜토리얼이 아닙니다. 두 컴파일러의 CI 빌드가 무사 통과한 상황에서, 한쪽 컴파일러 통과 코드를 갱신했더니 다른 쪽 컴파일러에서 충돌을 일으키며 발견된 실제 장애 사례집입니다. 이하에 기술된 모든 문제들은 실제 PDFiumPas 테스트 유닛이나 예제 소스 가동 중 발생했던 결함들이며, 커밋 단계의 세밀한 분석을 통해 원인을 규명하고 해결 방법을 최소한의 재현 코드와 함께 표준 가이드라인으로 정리했습니다
왜 FPC 빌드에서만 집합(set) 데이터가 조용히 빈 값으로 조회될까요?
한 줄 요약: FPC 컴파일러는 함수가 반환한 임시 레코드 결과물의 소멸자 처리를 조기 실행하므로, X in Func().Issues와 같이 반환 객체의 필드 집합에 대조 연산을 수행할 때 이미 메모리 상에서 해제된 가상 데이터 블록을 상대로 연산을 대조하는 결함 현상을 겪게 되며 Delphi에서는 정상 구동됩니다. PDF/E 호환성 검증 테스트 유닛 최초 작성 시 이 문제가 처음 발견되었습니다. 검증 함수가 위반 사항 플래그 집합인 Issues 필드를 포함하는 레코드를 리턴하고, 테스트 코드 상에서 인라인 함수 호출 형태로 이를 대조하고 있었습니다
// Unreliable under FPC: the function-result record temporary
// can be released before the 'in' test reads Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);
// Reliable on both compilers: pin the result to a local first
var
Vr: TPdfEValidationResult;
begin
Vr := ValidateAnsi(Pdf);
AssertTrue(pveiLzwUsed in Vr.Issues);
end;
인라인 형태로 호출했을 때, FPC 빌드 하에서는 집합 데이터가 항상 비어 있는 것처럼 해독되어 테스트 실패가 기록되었으나, 동일 코드가 Delphi 빌드에서는 이상 없이 통과했습니다. 근본적인 원인은 복잡한 표현식 내부에서 함수 결과 임시 레코드의 생존 기간(lifetime)을 관리하는 두 컴파일러의 기술적 편차 때문입니다. Delphi 컴파일러는 현재 문장(statement) 계산이 완전히 끝날 때까지 임시 레코드 메모리를 보존해 주는 반면, FPC는 집합(set) 대조 연산이 메모리를 읽고 있는 와중에 임시 레코드의 소멸자를 먼저 가동해 메모리를 기습 해제해 버립니다. 당사는 앞서 PDF/A 테스트 유닛의 FlagPresent 도우미 함수 주석을 통해 이 문제를 한 차례 정리했음에도 불구하고, 새 테스트 작성 시 무심코 인라인 형태로 다시 작성하여 버그를 재발시켰으며, 이는 인라인 호출 형태가 얼마나 직관적이고 친숙한 양식인지를 역설해 줍니다. 해결 방법은 간단하며 모든 크로스 컴파일 프로젝트에 공통 규칙으로 선언해 지킬 만한 가치가 있습니다. 즉, 레코드(record) 구조체를 리턴하는 함수에 마침표를 찍고 필드 주소나 집합 대조를 다이렉트로 호출하지 말고, 반드시 리턴 값을 로컬 임시 변수에 기입해 고정한 다음 필드 값을 조회하십시오. 단 한 줄의 코드 추가만으로 컴파일러 빌드에 의존하는 불완전한 예외 현상을 원천 차단해 줍니다
왜 Delphi에서는 통과된 배열 인덱스가 FPC에서는 컴파일 에러를 일으킬까요?
꼭짓점 타입인 TQuadrilateralPoint는 1부터 인덱싱이 시작하는 array [1..4] of TPdfPoint형으로 선언되어 있습니다. 0-based 인덱스 습관을 가진 대다수의 개발자들이 실수하기 쉬운 지점입니다. 코드 상에 A.AttachmentPoints[0]를 작성하고 Delphi의 기본 컴파일러(dcc32)로 빌드를 누르면 아무런 경고도 없이 빌드가 완료됩니다. 델파이 컴파일러는 기본 설정 상 배열 인덱스 범위 초과 검사(range checking)를 수행하지 않기 때문입니다. 결국 실행 시점에 이 0번 인덱스 기입 코드는 사각형 데이터 메모리 앞쪽에 맞닿아 있는 TPdfAnnotation의 다른 엉뚱한 필드 메모리 블록을 짓밟고 변조시키는 치명적인 침묵의 훼손(silent corruption) 버그를 일으키게 됩니다. 형광펜의 꼭짓점 하나가 이상한 좌표로 날아가 꼬이거나, 인근의 엉뚱한 속성값 하나가 깨져서 오작동하는데 에러 로그조차 발생하지 않는 끔찍한 현상이 발생합니다. Free Pascal 컴파일러 환경으로 Lazarus 포팅 작업을 하던 중, 다행히 FPC 컴파일러가 상수 인덱스 범위 초과 검사를 빌드 시점에 기습 감지해 에러를 보고하면서 이 허점을 잡을 수 있었습니다. 이 배열 범위 인덱싱 실수와 앞서 설명한 C API의 Set/Append 호출 오판 현상은 그렇게 디버깅 단계를 거쳐 한 번에 완벽하게 교정되었습니다
var
I: Integer;
begin
for I := 0 to 3 do // wrong: the array is [1..4]
Data.AttachmentPoints[I] := Corner[I]; // dcc32 default: compiles, index 0
// silently touches adjacent memory
// FPC: compile-time range check error
for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
Data.AttachmentPoints[I] := Corner[I - 1]; // correct on both compilers
end;
Delphi 빌드 환경은 컴파일러의 범위 검사가 꺼져 있는 기본값 탓에 에러 없이 빌드가 통과된 착시 현상이었을 뿐입니다. 배열 앞에 배치된 엉뚱한 레코드 주소 공간을 0번 데이터가 짓밟고 있었음에도 예제 프로그램이 마치 무사히 구동되는 것처럼 겉보기에만 연출되었던 것입니다. 해당 코드를 Lazarus 환경으로 포팅하자마자 FPC 컴파일러가 대번에 인덱스 경계 에러를 감지했고, 이 0번 인덱스 버그를 고치자 그동안 가려져 있었던 주석 처리 API의 더 깊은 논리 오류(앞서 PDFium QuadPoints 주석 생성 안내서에서 해부한 Set/Append 문제)까지 함께 검출해 수정할 수 있었습니다. 이 사건은 두 가지 값진 교훈을 줍니다. 첫째, 0번 인덱스를 강제 가정해 코딩하지 말고 배열 순환 시 항상 Low() 및 High() 메서드를 사용해 범위를 안전하게 획득하십시오. 둘째, 새로운 데모 코드나 라이브러리 작성 시 반드시 한 번은 FPC 컴파일러로 검사하거나, 최소한 델파이 상에서 {$R+} 컴파일러 지시자를 명시적으로 켜서 빌드 정합성을 점검하십시오. 컴파일러가 정상 빌드해 준 사실과 프로그램이 크래시 없이 열린다는 사실은 소스 상에 결함이 없음을 입증해 주지 못합니다
오직 최신 Delphi 13만 허용하는 TBytes 대입 규칙의 함정
한 줄 요약: 무명으로 지정된 array of Byte 배열 필드를 일반 TBytes 변수에 직접 대입하는 것은 최신 Delphi 13(컴파일러 버전 37.0)에서는 정상 컴파일되지만, Delphi 12 Athens 이하의 하위 버전에서는 E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array' 타입 컴파일 에러를 일으키며 실패합니다. 이는 Delphi와 FPC 컴파일러 간의 호환성이라기보다 Delphi 신구 컴파일러 버전 간의 차이지만 크로스 컴파일 프로젝트에 똑같이 치명적인 결함을 유발합니다. 가장 최신 컴파일러가 하위 버전에서는 기각되는 잘못된 형변환 구문을 조용히 수락하기 때문입니다
type
TValidator = class
private
FBuffer: array of Byte; // anonymous dynamic array type
end;
var
OrigBytes: TBytes;
begin
OrigBytes := FBuffer; // Delphi 13 only; E2010 on Delphi 12
// Athens and earlier
OrigBytes := TBytes(FBuffer); // compiles everywhere; same byte layout,
// safe hard cast
end;
개발팀이 로컬 컴퓨터에 설치된 최신 Delphi 13 환경에서 소스 빌드가 정상 통과되는 것을 보고 검증 모듈을 작성해 패키지를 배포했습니다. 그런데 Delphi 12 이하의 컴파일러를 사용하는 다수의 외부 사용자들로부터 패키지가 컴파일되지 않는다는 에러 리포트가 접수되었습니다. 해결 방법은 위와 같이 명시적인 형변환(casting) 코드를 명시하여 무명 바이트 배열을 강제 인코딩하는 것입니다. 두 타입은 물리적 힙 배열 메모리 배치 레이아웃이 동일하므로 형변환이 매우 안전하게 구동됩니다. 혹은 애초에 변수 정의 필드 자체를 무명 타입 대신 명확한 정규 TBytes 타입으로 단일화해 설계해 두는 것이 가장 이상적입니다. 무엇보다 중요한 아키텍처 규칙은 '내 컴퓨터에서 정상 빌드된 소스 코드라고 해서 하위 버전의 사용자의 컴파일러에서도 컴파일될 것이라고 안일하게 가정해선 안 된다'는 점입니다. 당사는 이 컴파일 호환성 문제를 해결하기 위해, 현재 릴리즈 스크립트가 모든 하위 버전 컴파일러들을 기동하여 순차 컴파일 무결성 검증을 마친 뒤 배포를 승인하도록 시스템을 개선했습니다
중국어 로케일 Windows 환경에서 조용히 증발하는 AnsiString 특수 바이트
한 줄 요약: $80 이상의 물리 특수 바이트를 AnsiString에 + 기호로 직접 더해 기입하면, Delphi 내부의 문자열 결합 표현 연산 단계에서 문자열을 UnicodeString으로 자동 형변환했다가 다시 AnsiString으로 변환하는 코드페이지 역순환 필터를 태우므로 정상 문자열이 아닌 $80 이상의 깨진 문자들이 기호 '?'($3F)로 자동 변조되어 파괴되는 현상을 겪게 됩니다. 이 문제는 ISO 19005-2 clause 6.1.8 규격을 검증하기 위해, 비표준 UTF-8 데이터 유입에 대해 검증기가 올바르게 오류 감지 필터를 동작하는지 테스트하고자 임시 가상 특수 바이트인 $FE 문자열을 수식 연산으로 엮어 주입하는 단위 테스트를 가동하다가 발견되었습니다
var
BadName: AnsiString;
begin
// On Delphi with a multi-byte system code page (observed on CP936),
// the concatenation round-trips through UnicodeString and $FE, which
// is not a valid CP936 sequence, comes back as '?' ($3F)
BadName := '/Bad' + AnsiChar($FE) + 'Name';
// Safe: build with an ASCII placeholder, then patch the byte in place;
// indexed assignment into a settled AnsiString does not round-trip
BadName := '/Bad' + #1 + 'Name';
BadName[5] := AnsiChar($FE);
end;
중국어 번역 언어 팩 로케일(코드페이지 936)이 적용된 Windows 환경에서 결합된 테스트 문자열 내부를 검사해 보니 $FE 문자가 감쪽같이 사라진 상태였습니다. 검증기는 원래 파일에 $FE가 없었으니 정상으로 판정했고, 테스트 프로그램은 에러를 내뱉어 라이브러리 결함처럼 오진되었습니다. 실제 라이브러리는 무죄였으며 FPC로 컴파일해 실제로 $FE 바이트가 파일 내부 헤더에 정상 포함되어 발송된 문서에 대해서는 검증기가 올바르게 오류를 보고하고 있었습니다. 진짜 파손은 Delphi의 유니코드 가공 단계에서 문자열 결합 연산 수행 시 발생했던 것입니다. 델파이의 유니코드 우선 문자열 모델은 AnsiString 수식 연산 시 무조건 UnicodeString으로 데이터를 임시 승격시켜 연산한 뒤 다시 AnsiString으로 환원하는데, $FE 바이트는 중국어 코드페이지 CP936 규격상 정상적인 선두 문자가 아니므로 변환 실패 표식인 '?' 문자로 자동 강제 대입되었던 것입니다. 이 버그가 무서운 이유는 서구권 서식인 CP1252 코드페이지 로케일의 개발자 장비에서는 정상 작동하여 발견되지 않다가 다국어 환경 CI 러너 환경이나 동아시아 사용자 장비에서만 갑자기 예외 충돌을 일으키기 때문입니다. 당사는 소스 상에 다음과 같은 엄격한 룰을 제정해 방어하고 있습니다. 즉, $80 이상의 특수 바이트를 엮어야 하는 테스트 데이터나 문자열 가공 시 절대 문자열 더하기(+) 수식을 사용해 조립하지 마십시오. 문자열을 먼저 변수에 대입해 고정(settle)시킨 후 위와 같이 특정 배열 위치에 문자를 인덱스 수식 할당(setter)으로 직접 덮어쓰거나, 혹은 애초에 TBytes 바이트 스트림 변수를 통해 수치를 하나씩 기입해 구축해 나가야 합니다
듀얼 컴파일 크로스 개발 환경에서 지켜야 할 체크리스트
4가지 함정들은 한 가지 본질을 증명해 줍니다. 바로 각 컴파일러는 저마다 다른 관점에서 여러분의 코딩 결함을 찾아내 조언해 준다는 사실입니다. FPC의 엄격한 빌드 범위 검사 엔진은 dcc32가 에러 없이 조용히 통과시켰던 치명적인 인덱스 배열 침범 실수를 즉시 발견해 주었고, dcc32의 유니코드 자동 전환 메커니즘은 FPC 바이트 스캔 빌드만으로는 절대 찾을 수 없었던 로케일 코드페이지 종속성 결함을 검출해 경고해 주었습니다. 따라서 한쪽 빌드가 성공했다고 해서 안심해서는 안 됩니다. 크로스 컴파일 호환성 확보는 플랫폼 확장 기능 추가뿐만 아니라, PDFium VCL 하드닝 보안 가이드에서 안내해 드린 안전 보호막 설계 원리처럼 소스 코드의 신뢰성과 에러 정합성을 다각도로 검출해 내는 훌륭한 제2의 코드 정밀 진단 수단으로 활용될 수 있습니다
도출된 핵심 수칙들은 상시 암기해 둘 만큼 간결합니다. 함수가 레코드를 반환하면 필드 조회 전에 반드시 로컬 변수에 먼저 기입하십시오. 고정 범위 배열을 다룰 때는 인덱스 하드코딩 대신 Low()와 High()를 사용하고 컴파일러 범위 검사를 켜서 빌드하십시오. 무명 동적 배열은 명시적 형변환을 적용하거나 처음부터 정규 이름 정의 타입을 지정해 변수를 선언하고, 릴리즈 배포 전 모든 컴파일러 플랫폼 매트릭스 검사를 거치십시오. $80 이상의 특수 바이트는 문자열 결합(+) 연산에 절대 섞어서 연산하지 마십시오. 습관화하고 나면 추가적인 공수가 전혀 들지 않는 상식적인 코딩 규칙들이며 단일 컴파일러 환경에서는 도저히 찾을 수 없었던 깊은 메모리 버그들을 사전에 원천 차단해 줄 것입니다
기재된 4대 호환성 함정과 세부 교정 사양들은 Delphi, C++Builder, Lazarus FPC 개발 장비를 모두 공식 단일 패키지로 교차 호환 지원하는 PDFium Component 제품군 개발에 완벽하게 반영되어 해결되었습니다. 모든 결함들은 테스트 유닛 유효성 검사 코드로 방어막이 쳐져 작동하므로 사후 장애 유발 걱정 없이 안심하고 연동해 사용하실 수 있습니다