Delphi 프로그램은 ERangeError와 맨말 "Range check error"와 함께 멈춘다. 흔히 두 빌드 구성 중 하나에서만 그렇다. Delphi는 범위 검사를 켠 상태({$R+})로 컴파일된 코드가 배열이나 문자열을 경계 밖의 인덱스로 접근하거나, 대상 서수(ordinal) 타입에 들어맞지 않는 값을 저장할 때 이 예외를 일으킨다. 수정은 거의 언제나 잘못된 인덱스나 값이 계산된 자리에 속하며, 그 자리는 디버거가 멈추는 줄과 다른 곳인 경우가 태반이다
Delphi에서 "Range check error"는 무엇을 뜻하는가?
ERangeError는 범위 검사가 켜져 있을 때 컴파일러가 삽입하는 검사에서 나온다. {$R+}(긴 형태 {$RANGECHECKS ON}, 또는 Project Options › Building › Delphi Compiler › Compiling › Range checking)이면 모든 배열·문자열 첨자가 선언된 경계와 비교되고, 더 작은 서수·서브레인지·열거형 변수로의 모든 대입이 그 타입의 범위와 비교된다. 기본 상태는 {$R-}이며 이렇게 컴파일된 코드에는 검사가 전혀 없어서, 경계 밖 쓰기는 그냥 옆자리 메모리에 떨어진다. 검사가 실패하면 SysUtils가 링크되어 있을 때 리소스 문자열 "Range check error"를 단 ERangeError를 일으킨다. SysUtils를 전혀 쓰지 않는 프로그램은 대신 런타임 오류 201로 종료한다. 컴파일 시점 오류 E1012(Constant expression violates subrange bounds)는 컴파일러가 상수로 보는 인덱스만 다룬다. 실행 시점에 계산되는 것은 전부 {$R+}에 달려 있다. 이 스위치는 유닛 단위로도 동작한다: 미리 컴파일된 DCU는 빌드 당시의 상태를 유지하고, 유닛 맨 위의 {$R-}은 그 유닛에 한해 프로젝트 옵션을 덮어쓴다
오버플로 검사는 별개의 스위치이며 별개의 예외를 가진다. {$Q+}({$OVERFLOWCHECKS ON})는 +, -, *, Inc, Dec 같은 정수 산술을 검사해 결과가 안 들어맞으면 EIntOverflow를 일으킨다. {$R+}는 인덱스와 저장을 검사한다. 둘은 작은 타입에서 만난다. Byte 산술은 Integer로 평가되므로 B = 255인 B := B + 1은 오버플로 없이 256을 만들고, Byte로 되돌리는 저장이 범위 검사를 실패시킨다. 같은 값의 Inc(B)는 대신 오버플로 검사를 거치는 연산이다. {$Q+}에서는 EIntOverflow를 일으키고, {$R+}만 있으면 조용히 0으로 wrap한다. 어느 구성이 이 스위치들을 켜고 있는지는 Delphi 버전과 프로젝트 생성 방식에 달려 있다. 오류가 한 빌드에서만 나타나는 이유에 대한 이론을 믿기 전에 각 빌드 구성의 Project Options에서 실제 값을 읽어라
흔한 원인들과 최소 재현
실제 Delphi 코드의 범위 검사 오류 대부분은 몇 가지 패턴으로 떨어지며, 각각에는 작고 기계적인 수정이 붙는다. 배열이 가장 흔한 사례다. 정적 array[0..9]는 인덱스 0부터 9까지 받고, 동적 배열이나 TBytes는 0..Length(A) - 1을 받으며, 문자열은 1 기반이므로 하드코딩된 한계 대신 Low와 High로 루프를 쓰라. 빈 경우가 코드를 깨뜨리는 지점이다. 빈 동적 배열의 High는 -1이므로 Integer 카운터의 for I := 0 to High(A) 루프는 정확히 0번 돌지만, "마지막 원소"를 가져오려는 A[High(A)]나 A[Count - 1]는 인덱스 -1을 읽는다. 비슷한 사례들은 다르게 동작한다:
- 컨테이너 클래스는
{$R}상태와 무관하게 자기 인덱스를 검사한다.TList와TStringList는EListError나EStringListError를 일으키고,TList<T>는EArgumentOutOfRangeException을 일으킨다 - 파일 헤더나 API에서
Cardinal이나DWORD로 도착하는 개수는 0에서 위험하다.Count - 1이 무엇으로 평가되는지는 컴파일러가 그 식을 어떤 타입으로 보는지에 달려 있다. 루프 카운터는Integer로 유지하고, 개수는 한 번 검증해 변환한 뒤Count > 0을 명시적으로 검사하라
procedure ArrayRepros(const Data: TBytes; Buf: Pointer);
var
Last: Byte;
P: PByte;
I: Integer;
begin
// 빈 배열: High(Data) = -1이므로 Data[-1]을 읽는다
Last := Data[High(Data)]; // {$R+}에서 ERangeError
// 수정: 마지막 원소를 물기 전에 길이를 검사한다
if Length(Data) > 0 then
Last := Data[High(Data)];
// TByteArray는 array[0..32767] of Byte로 선언되어 있으므로 {$R+}에서는
// Buf가 메가바이트를 담고 있어도 32767을 넘는 인덱스는 실패한다
I := 40000;
Last := PByteArray(Buf)^[I]; // I = 40000에서 ERangeError
// 수정: 실제 버퍼를 인덱싱하거나 PByte 산술을 쓴다
P := PByte(Buf);
Inc(P, I);
Last := P^; // 범위 검사가 없으므로 I는 직접 검증한다
end;
PByteArray 사례는 베테랑도 잡아 넘어뜨린다. 선언된 경계는 버퍼와 아무 관련이 없기 때문이다. Delphi의 SysUtils와 Free Pascal의 것 모두 TByteArray를 array[0..32767] of Byte로 선언하는데, 16비트 시절로 거슬러 올라가는 32KB 창이다. {$R+}에서는 검사가 인덱스를 32767과 비교하므로 100KB 스트림 안의 정당한 오프셋이 ERangeError를 일으킨다. {$R-}에서는 검사가 없고, 실제 버퍼 끝을 넘은 인덱스는 결코 잡히지 않는다. TBytes 자체를 인덱싱하면 실제 길이에 대한 검사를 받으므로 그것이 더 나은 기본값이다. PByte 산술은 전혀 범위 검사를 받지 않는다. 버퍼 크기에 대한 명시적 검사와 짝지어 써라
서수 저장이 또 다른 큰 계열이다. Byte에 256, Word에 70000, 1..12 서브레인지에 13을 대입하는 것은 검사에 실패하고, 음수 Integer를 Cardinal에 대입하는 것도 그렇다. Delphi는 섞인 부호 있는/없는 32비트 피연산자를 Int64로 평가하므로 산술 자체는 보통 올바르고 오류는 저장에서 나타난다. 그 덕에 타입이 너무 좁은 변수를 가리켜 준다. 이 그물의 빈틈은 하드 타입캐스트다. Byte(I), Cardinal(I), TMyEnum(I)는 범위 검사를 만들지 않고 조용히 잘라 내거나 재해석한다. 파일에서 읽은 32비트 값이 열거형 멤버를 고르는 코드라면, 캐스트 전에 Cardinal로 Ord(High(TMyEnum))과 비교하라
var
B: Byte;
C: Cardinal;
I: Integer;
Addr: Integer;
Obj: TObject;
begin
B := 255;
B := B + 1; // Integer 256으로 평가, 저장이 실패: ERangeError
I := -1;
C := I; // Cardinal에 음수 대입: ERangeError
C := Cardinal(I); // 하드 캐스트: 검사 없음, C = 4294967295
// Win64: 포인터는 8바이트, Integer는 4 유지
Obj := TObject.Create;
Addr := NativeInt(Obj); // Integer: 2GB 넘으면 실패 (Cardinal: 4GB 넘으면)
// 수정: 포인터 크기 값은 NativeInt / NativeUInt에 담는다
end;
// Delphi와 FPC에서 같게 동작해야 하는 mod 2^32 산술
function AddMod32(A, B: Cardinal): Cardinal; inline;
begin
Result := Cardinal((UInt64(A) + UInt64(B)) and $FFFFFFFF);
end;
Win64는 같은 실수의 포인터 크기 변형을 더한다. Pointer, NativeInt, TComponent.Tag(XE2부터 NativeInt), LPARAM, WPARAM은 Win64에서 모두 8바이트인 반면 Integer, Longint, Cardinal은 4바이트에 머문다. 범위 검사를 거치는 대입으로 주소를 저장하면, 주소가 대상 타입의 범위 밖에 있는 순간 — Integer는 2GB 위, Cardinal은 4GB 위 — ERangeError가 일어난다. 하드 캐스트로 저장하면 잘려 나가서 나중에 액세스 위반이 된다. 작은 테스트 프로그램은 주소 공간의 낮은 곳을 할당받는 경우가 많아, 이 부류의 버그는 유닛 테스트보다 오래 도는 프로세스에서 모습을 드러내는 경향이 있다. 포인터, 핸들, 메시지 매개변수를 담는 것은 전부 NativeInt나 NativeUInt로 선언하라. Linux64의 Longint와 포인터 함정은 Longint 자체의 크기가 바뀌는 LP64 타겟에서 같은 논리를 따른다
왜 Debug에서만, 또는 FPC에서만 오류가 나는가?
한 빌드에서만 나타나는 범위 검사 오류는 거의 언제나 진짜 경계 밖 접근이며, 다른 빌드는 그것을 검사하지 않을 뿐이다. {$R-}인 릴리스 빌드는 같은 잘못된 인덱스를 아무 불평 없이 돌려 이웃 메모리를 읽거나 망가뜨리는데, 이는 예외보다 나쁘다. Free Pascal에는 자체 스위치가 있다. {$mode delphi}는 언어 문법과 의미를 바꾸지만 어떤 검사도 켜지 않는다. FPC의 범위 검사는 {$R+}나 -Cr 명령줄 옵션에서 오고, 오버플로 검사는 -Co에서 오며, Lazarus는 이를 Project Options › Compiler Options › Debugging 아래 보여 준다. FPC는 정수 식을 네이티브 정수 크기 이상으로 평가하므로, 32비트 Delphi에서 조용히 wrap하는 식이 64비트 FPC 타겟에서는 High(Cardinal)를 넘는 값을 만들 수 있고, 그것을 Cardinal로 되돌리는 저장이 검사에 걸린다. 우리 SHA-256 코드에서 정확히 이것을 만났다. 평범한 Cardinal 합으로 쓴 mod 232 덧셈이 검사 켜진 FPC 빌드에서 Range check error를 일으켰다. 위의 AddMod32처럼 UInt64에서 합산하고 마스크하면 유닛 전체의 검사를 끄지 않고도 두 컴파일러에서 출력이 바이트 단위로 동일하게 유지된다. 이런 종류의 이식성 함정은 FPC에서 우연히만 동작하는 Delphi 코드에 모아 두었다
디버거를 예외가 일어난 줄에서 멈추게 만들기
범위 검사 오류를 찾는 가장 빠른 길은 IDE 디버거가 그 지점에서 멈추게 하고 호출 스택을 거슬러 잘못된 값을 만든 코드까지 올라가는 것이다. 예외는 검사가 발화한 위치를 표시한다. 원인은 보통 몇 프레임 아래에 앉아 있다:
- 범위 검사를 켠 Debug 구성으로 실행하고, 디버거 옵션의 Language Exceptions 페이지의 무시 목록에
ERangeError가 없는지 확인하라 - 대화상자가 "raised exception class ERangeError with message 'Range check error'"를 보고하면 Break를 고르고 Call Stack 창을 열어라. 맨 위 프레임 아래의 프레임들이 인덱스나 값이 어디서 왔는지 보여 준다
- 맨 위 프레임들이 RTL이나 VCL 안쪽이면 "Use debug .dcus"를 켜서 그 프레임들이 소스로 resolve되게 하라
- 인덱스를 계산하는 대입에 조건부 중단점(예:
Index < 0)을 걸어 예외보다 한 발 앞서 실수를 잡아라 - 고객 머신에서만 나는 오류라면 범위 검사를 켠 채로 배포하고 madExcept, EurekaLog, JCL Debug 같은 스택 트레이스 로거를 붙여라. 같은 호출 스택을 로그 파일에 기록해 준다
범위 검사를 꺼야 하는가?
모든 Debug와 CI 빌드에서는 범위 검사를 켜 두고, 직접 측정한 핫 루프 주변에서 경계를 스스로 검증할 때만 국소적으로 꺼라. 전역으로 끄는 것은 버그를 없애는 게 아니라 보고를 없앤다. 고전적인 예로 SetLength(Result, 16)을 호출하고 17번째부터 24번째 문자를 쓰는 헥스 포맷팅 루틴이 있다. {$R+}에서는 즉시 실패하고, {$R-}에서는 호출마다 문자열 끝을 넘어 여덟 문자를 그 뒤를 따르는 힙 메모리에 쓰고, 한참 뒤 무관한 어딘가에서 크래시한다. 루틴이 정말로 검사를 감당할 수 없을 만큼 뜨겁다면 변경 범위를 올바르게 한정하라. {$R-} 뒤에 {$R+}를 붙이는 것은 단정해 보이지만 유닛 나머지 부분의 검사를 프로젝트가 껐더라도 강제로 켜 버린다. 이전 상태를 저장하고 복원하고, 검사 없는 구간은 호출자가 경계를 집행하는 하나의 작은 루틴 전체로 유지하라
{$IFOPT R+}
{$DEFINE RANGECHECKS_WERE_ON}
{$R-}
{$ENDIF}
procedure XorBlock(Dst, Src: PByte; Count: Integer);
var
I: Integer;
begin
// 호출자가 Count가 두 버퍼 모두에 들어맞음을 보장한다. 루프 밖에서 한 번 검사
for I := 0 to Count - 1 do
Dst[I] := Dst[I] xor Src[I];
end;
{$IFDEF RANGECHECKS_WERE_ON}
{$UNDEF RANGECHECKS_WERE_ON}
{$R+}
{$ENDIF}
PDF 파싱 코드의 범위 검사 오류
PDF 파일을 파싱하는 코드는 특히 노출되어 있다. 인덱스로 쓰는 숫자 거의 전부가 파일 그 자체에서 오기 때문이다. 교차 참조 표의 바이트 오프셋, 스트림의 /Length 값, 교차 참조 스트림의 /W 배열의 필드 폭(ISO 32000-1 §7.5.8), 객체 개수, 서명의 /ByteRange 항목은 변형되거나 적대적인 파일에서 전부 신뢰할 수 없다. if A + B > Length(Data) 같은 순진한 가드는 조작된 /ByteRange [9223372036854775807 1 0 0] 앞에서 무너진다. 오버플로 검사가 꺼져 있으면 A + B가 음수 Int64로 wrap하고, 검사는 통과하며, Data[A]가 나중에 실패한다. 각 값을 남은 길이와 비교해((A >= 0) and (B >= 0) and (A <= Len) and (B <= Len - A)) 어떤 덧셈도 wrap하지 못하게 하라. 복합 가드에는 또 하나의 함정이 숨어 있다. Delphi는 and를 왼쪽부터 단락 평가하지만, 상한 검사 하나만으로는 음수 인덱스를 거절하지 못한다. -1 < Length(FPages)는 True이기 때문이다. 부호 있는 하한이 상한보다 먼저 검사되어야 한다
// 안전해 보이지만 음수 PageIndex가 상한 검사를 통과한다:
// if FDocOpen and (PageIndex < Length(FPages)) and
// (FPages[PageIndex].PageObj <> nil) then ...
if (PageIndex >= 0) and (PageIndex < Length(FPages)) then
begin
if FDocOpen and (FPages[PageIndex].PageObj <> nil) then
Result := FPages[PageIndex].PageObj
else
Result := nil;
end
else
raise ERangeError.CreateFmt(
'Page index %d is out of range (0..%d)',
[PageIndex, Length(FPages) - 1]);
가드는 크래시를 막을 뿐 인덱스가 왜 틀렸는지는 설명하지 않으며, 페이지 처리 코드에서 그 이유는 대개 두 순서 사이의 불일치다. ISO 32000-1 §7.7.3은 페이지 순서를 페이지 트리의 Kids 배열로 정의한다. 객체 번호와 바이트 오프셋은 그것에 대해 아무 말도 하지 않으며, 객체 20이 페이지 1을 담고 객체 3이 페이지 2를 담을 수 있다. 파일 순서대로 객체를 스캔해 페이지 목록을 만드는 코드는 생성기가 페이지를 순서대로 쓴 모든 문서에서 동작하고, 그렇지 않은 하나 — 나중에 표지 페이지를 붙인 편집 파일 같은 — 에서 틀린다. 호출자가 표시 순서로 계산한 인덱스는 잘못된 페이지나 목록 끝 밖에 떨어진다. PDF 페이지 순서 사례 연구는 qpdf로 불일치를 확인하고 페이지 트리에서 페이지 목록을 다시 만드는 방법을 보여 준다
이런 오류를 자기 파서에서 털어 내려면 {$R+}와 {$Q+}로 빌드해 다양한 코퍼스에 돌려라. Word, LaTeX, 스캐너 펌웨어, 분할·병합 유틸리티가 만든 파일과 일부러 잘라 둔 파일 몇 개. 예외를 일으키는 파일 하나하나는 여러분 코드가 깔고 있는 가정에 대한 데이터 점이다. 다음 소스에서 엉뚱한 때에 발화할 생성기별 특수 사례를 하나 더 추가하는 대신, 가정을 한 번 고쳐라. PDF 라이브러리를 부르는 코드라면 라이브러리의 페이지 개수를 권위로 취급하라. HotPDF Delphi PDF component에서 LoadFromFile은 로드된 문서의 페이지 개수를 돌려주고 LoadedPageCount가 이후 그 값을 보고한다. 페이지 인덱스는 페이지 트리의 표시 순서를 따르므로, 외부 데이터에서 유도한 어떤 인덱스든 0..LoadedPageCount - 1이 안전한 범위다