Delphi 생성자가 예외를 던지면 RTL은 즉시 반쯤 지어진 인스턴스의 소멸자를 호출한다. 그러므로 모든 소멸자는 한 번도 대입되지 않은 필드와 씨름할 수 있어야 한다. HotXLS는 이 규칙을 자기 소멸자에 적용하며 TXLSXRichText와 TXLSXCells도 예외가 아니다. 이들 생성자 안의 할당 실패는 정리 과정에서 튀어 나온 2차 액세스 위반이 아니라 원본 EOutOfMemory 그대로 여러분 코드에 도달한다
이 버그 부류는 오래됐고 놓치기 쉽다. 서비스가 바쁜 서버에서 300 MB 워크북을 변환하는데 메모리가 모자라지고, 로그에는 소멸자 안의 액세스 위반이 찍힌다. 그 메시지 어디에도 '메모리 부족'이라는 말은 없고, 나중에 다시 시도하면 될 거라는 힌트도 없으며, 스택 추적은 완벽하게 올바르게 생긴 소멸자를 가리킨다. 원본 예외는 몇 마이크로초 동안만 존재했다가 교체됐다. 이 글은 왜 그런 일이 일어나는지, HotXLS가 소멸자를 어떻게 구조화해 이를 피하는지, 그리고 여러분 클래스가 저지르지 않도록 어떻게 쓰는지 훑는다
Delphi 생성자가 예외를 던지면 무슨 일이 벌어지는가?
예외가 호출자에게 닿기 전에 객체가 대신 파괴되고, 소멸자는 생성자가 도달한 그 시점의 상태 그대로 돈다. 순서는 컴파일러와 RTL이 고정한다. NewInstance가 인스턴스를 할당하고 InitInstance가 0으로 채우므로 모든 객체 참조는 nil에서, 모든 정수는 0에서, 모든 문자열은 빈 값에서 출발한다. 생성자 몸통은 암시적 예외 처리기 안에서 돌고, 그 몸통에서 무엇이든 예외를 던지면 생성된 코드가 가상 소멸자를 호출한다. BeforeDestruction이 돌고, 여러분의 Destroy가 돌고, FreeInstance가 메모리를 놓아주고, 마지막으로 예외가 다시 던져진다. AfterConstruction은 영영 돌지 않고, X := TFoo.Create 왼쪽의 변수는 대입되지 않는다
그러니까 소멸자는 생성자의 임의 한 줄에서 멈춘 객체를 만난다. 실패 전에 대입된 필드는 진짜 객체를 들고 있고, 그 뒤의 필드는 여전히 nil이다. 생성이 끝났다고 가정해 쓴 소멸자는 그 nil 필드 중 하나를 역참조해 자기만의 액세스 위반을 일으킨다
진짜 피해는 그 두 번째 예외다. Delphi는 이 정리 과정에서 던져진 예외를 사슬로 잇지 않는다. 액세스 위반이 EOutOfMemory를 대체하고, 원본 예외는 버려지며, 호출자가 보는 것은 AV뿐이다. '할당 하나가 실패했다, 메모리를 더 확보해 다시 시도하라'라는 쓸모 있는 진단은 사라지고, 그 자리에 메모리 깨짐처럼 보이는 무언가가 앉는다
2차 액세스 위반은 어디서 오는가?
생성자가 완료됐다고 가정하는 소멸자가, 맨 첫 내부 할당이 실패한 순간 nil인 리스트 필드를 만지는 데서 온다. HotXLS 클래스 둘이 이 모양을 선명하게 보여 준다. 독립형 TXLSXRichText.Create와 comment 소유 오버로드 TXLSXRichText.Create(AOwner: TXLSXComment)는 둘 다 inherited Create를 부른 뒤 FRuns := TList.Create로 할당한다. TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet)는 packed-string 테이블과 packed 셀 저장소를 만들기 전에 FList := TList.Create로 같은 짓을 한다. Lib/lxHandleX.pas에서 두 소멸자는 이 필드들을 지킨다
procedure TXLSXRichText.ClearCore;
var
i: Integer;
begin
if FRuns=nil then Exit; // 생성자가 FRuns 전에 멈췄을 수 있다
for i:= 0 to FRuns.Count- 1 do
TXLSXRichTextRun(FRuns[i]).Free;
FRuns.Clear;
end;
destructor TXLSXCells.Destroy;
begin
FPackedStorageEnabled:= False;
if FList<>nil then Clear; // TList.Create가 실패하면 FList는 nil이다
// ... packed 저장소, 문자열 테이블, FList는 Free로 놓아준다
inherited Destroy;
end;
실패 지점은 정확하다. 생성 중 힙 할당을 세면 첫 번째는 인스턴스 자신이다. NewInstance가 실패하면 생성자 몸통도 소멸자도 돌지 않으므로 이 경우는 언제나 깨끗하다. 두 번째 할당이 TList다. 여기서 실패하면 FRuns나 FList는 nil로 남고, RTL이 Destroy를 부르며, 방어되지 않은 Destroy는 nil 참조로 Count를 읽는다. 정확히 그 2차 액세스 위반이다. TXLSXCells.Create 안의 이후 실패는 무해하다. FList가 존재하는 한 Clear는 packed 저장소를 쓰기 전에 FPackedStorage <> nil을 검사하기 때문이다
이 클래스들이 문제되는 이유는 대량으로 생성되기 때문이다. 로드된 워크시트마다 TXLSXCells 컬렉션이 만들어지고, 서식 있는 run을 지닌 셀이나 comment마다 TXLSXRichText가 만들어진다. rich text가 무거운 큰 워크북은 이것을 수천 개 만들므로, 메모리 압박 아래에서 마침내 실패하는 할당이 이 줄들 중 하나에 떨어질 실제 확률이 있다
Free는 nil 안전, 필드 접근은 아니다
TObject.Free는 Destroy를 부르기 전에 Self <> nil을 검사하므로, nil 필드의 FRuns.Free는 no-op다. FRuns.Count 읽기는 아니다. Count는 인스턴스 포인터에서 오프셋만큼 떨어진 필드를 읽고, FRuns.Clear 같은 가상 호출은 인스턴스에서 VMT 포인터를 먼저 읽는다. 둘 다 nil에서 잘못된다. 이 비대칭이 함정의 전부다. 전형적 소멸자의 해제 호출은 이미 안전하고, 그 앞에 놓인 루프와 정리 호출이 문제가 사는 곳이다. 여러분 자신의 작은 클래스에서 나타나면 이런 모양이다
type
TStyledText = class
private
FRuns: TList;
FIndex: TStringList;
public
constructor Create;
destructor Destroy; override;
end;
constructor TStyledText.Create;
begin
inherited Create;
FRuns := TList.Create; // 여기서 EOutOfMemory가 나면 FRuns = nil로 남는다
FIndex := TStringList.Create; // ... 여기서는 FIndex = nil
end;
destructor TStyledText.Destroy; // 나쁜 예: 생성자가 끝났다고 가정한다
var
I: Integer;
begin
for I := 0 to FRuns.Count - 1 do // FRuns = nil이면 액세스 위반
TObject(FRuns[I]).Free;
FIndex.Free;
FRuns.Free;
inherited Destroy;
end;
고친 소멸자는 모든 역참조에 방어막을 두고 필드는 nil 안전 호출로만 놓아준다
destructor TStyledText.Destroy;
var
I: Integer;
begin
if FRuns <> nil then
for I := 0 to FRuns.Count - 1 do
TObject(FRuns[I]).Free;
FreeAndNil(FIndex);
FreeAndNil(FRuns);
inherited Destroy;
end;
여기서 도출되는 규칙은 코드 리뷰에 바로 쓸 만큼 짧다
- 소멸자에서 소유 필드를 쓰는 모든 자리,
Free나FreeAndNil빼고는nil또는Assigned검사가 필요하다. 필드를 순회하는Clear나ClearCore같은 헬퍼 호출도 포함이다 - 소유 객체는
Free나FreeAndNil로 놓아줘라.nil검사를 건너뛰는Destroy직접 호출은 금물이다 inherited Destroy는 마지막 문장으로 남겨라. 그래야 조상이 여러분 필드가 사라진 뒤 자기 필드를 정리한다- 생성자의 필드 순서로 추론하지 마라. 다음 유지보수자가 여러분 할당 위에 새 할당을 끼워 넣고 실패 지점을 조용히 옮겨 놓는다
inherited Create가 앉아야 할 자리
inherited Create를 먼저 부른다. HotXLS 생성자 둘 다 그렇게 한다. 그래야 조상의 필드가 여러분 코드가 돌기 전에 존재하고, 소멸자의 거울상인 마지막 inherited Destroy가 역순으로 풀어 낸다. 조상 생성자 자체가 예외를 던져도 여러분 소멸자는 모든 필드가 nil인 채로 도는데, nil 안전 소멸자는 특수 사례 없이 이를 처리한다. 생성자의 FOwner := nil 같은 명시적 대입은 InitInstance 뒤에는 중복이지만 무해하고, 문서화 가치가 있으면 남겨도 좋다
생성자 안에서 잡아서 해제하지 마라
선의의 패턴 둘이 상황을 악화시킨다. 첫째는 생성자 몸통을 try ... except Free; raise; end로 감싸는 것이다. RTL이 이미 소멸자를 부를 예정이므로 객체가 두 번 파괴된다. 둘째는 '계속 진행하려고' 예외를 삼키는 것이다. 호출자는 nil 필드를 지닌 객체의 멀쩡해 보이는 참조를 받고, 실패는 한참 뒤 무관한 메서드 호출로 옮겨간다. 생성자가 간직하지 않을 임시 객체를 만든다면 지역 try ... finally로 그 임시 객체를 해제하고 예외는 그대로 위로 흘려보내라
생성자 실패 경로는 어떻게 테스트하나?
생성자의 각 할당 지점을 일부러 하나씩 실패시키고, 매번 EOutOfMemory를 요구하라. 그 뒤 정상적인 생성이 성공하는 것까지 요구한다. 도구는 GetMem, AllocMem, ReallocMem이 양수 크기 요청을 세고 지정된 서수에서 nil을 돌려주는 교체 메모리 매니저다. 먼저 목표 빌드에서 생성자가 몇 번 할당하는지 세고, 서수마다 주입 실패 하나씩 돌려라
주입 실패마다 할당자를 복구한 뒤 같은 클래스를 다시 생성하고 운동시켜라. rich text 클래스라면 run을 추가하고 텍스트를 설정하고 plain text가 기대대로 돌아오는지 확인하는 식이다. 평범한 수명 주기 검사도 나란히 둔다. 서식 있는 run과 comment를 지닌 워크북을 파일과 스트림으로 저장하고, 원본 워크북을 해제하고, 두 사본을 다시 열어 비교하는 식이다
할당 횟수는 하나의 컴파일러와 플랫폼에 속한다. 다른 컴파일러나 RTL은 다르게 할당할 수 있으므로, 출하하는 빌드에서 측정하라. HotXLS는 이 nil 안전 규칙을 라이브러리 전반에 적용한다. 스타일, 차트, 피벗 컬렉션부터 해시 테이블, zip 라이터, PDF 익스포터까지. 해시 테이블은 특기할 만하다. 재할당이 실패하기 전에 마스크를 바꿔 버린 테이블은 소멸자가 옛 테이블을 지나 메모리를 걷게 되므로, 마스크는 새 저장소가 존재한 뒤에만 바뀌어야 한다. RTL 자체에도 한 사례가 있다. TInterfaceList는 내부 할당이 실패하면 소멸자에서 잘못된다. 이 모든 것은 중간에 실패하는 임의 연산의 롤백이 아니며, 일반적인 메모리 부족 복구 보장도 아니다
큰 워크북을 여는 코드에게 이것이 바꾸는 것은?
실전에서는 이제 이 생성 경로의 EOutOfMemory가 잡아서 행동할 수 있는 신호가 된다. 크래시로 취급해야 하는 액세스 위반이 아니라는 뜻이다. TXLSXWorkbook.Create는 파라미터를 받지 않고, TXLSXWorkbook.Open(const FileName: WideString): Integer은 성공에 1, 실패에 -1을 돌려준다. 로딩 중 던져진 예외는 워크북의 Diagnostics 컬렉션에 xlsDiagnosticUnhandledException으로 기록된 뒤 다시 던져지는데, 이는 HotXLS 구조화된 진단 글이 기술하는 동작이다. 재시도 래퍼는 이런 모양이다
type
EWorkbookOpenFailed = class(Exception);
function OpenWorkbookWithRetry(const FileName: WideString;
MaxAttempts: Integer): TXLSXWorkbook;
var
Attempt: Integer;
Book: TXLSXWorkbook;
begin
Result := nil;
for Attempt := 1 to MaxAttempts do
begin
Book := nil;
try
try
Book := TXLSXWorkbook.Create; // 생성자 인자 없음
if Book.Open(FileName) <> 1 then // 1 = 성공, -1 = 실패
raise EWorkbookOpenFailed.CreateFmt('HotXLS could not open %s',
[string(FileName)]);
Result := Book;
Book := nil; // 소유권이 호출자로 넘어간다
Exit;
finally
Book.Free; // 넘겨준 뒤에는 nil 안전
end;
except
on E: EOutOfMemory do
begin
if Attempt = MaxAttempts then
raise;
ReleaseApplicationCaches; // 여러분 코드: 캐시와 다른 워크북을 놓아준다
end;
end;
end;
end;
래퍼의 몇 가지 디테일은 의도된 것이다. 시도마다 새 TXLSXWorkbook을 만든다. nil 안전 소멸자가 생성 실패를 깨끗하게 만들어 주지만 반쯤 끝난 Open을 재개 가능하게 하지는 않으므로, 실패한 인스턴스는 그냥 해제된다. 재시도는 EOutOfMemory뿐이다. EWorkbookOpenFailed, 비밀번호 오류, 그 외의 것은 첫 시도에서 그대로 전파된다. EAccessViolation은 아예 재시도하면 안 된다. 자원 부족이 아니라 결함을 신호하기 때문이다. 그리고 시도 사이에 뭔가가 바뀌어야 재시도가 의미 있으므로 처리기가 먼저 메모리를 놓아준다. 실패 시도의 진단 흔적이 필요하면 finally 블록이 해제하기 전에 Book.Diagnostics를 읽어라
재시도는 완화책이지 용량 계획이 아니다. 큰 파일에서 EOutOfMemory를 계속 맞는 32비트 프로세스는 대개 물리 메모리보다 연속 주소 공간이 부족한 것이고, 근본 해답은 Win64 빌드다. 같은 생각이 쓰기 쪽에도 적용된다. HotXLS의 단계별 임시 파일 크래시 세이프 저장은 실패한 저장이 기존 파일을 훼손하지 못하게 한다. 여러 스레드가 하나의 워크북을 공유한다면 HotXLS 읽기 리스와 쓰기 가드가 그 인스턴스 접근 조율 방식을 설명한다
여러분 자신의 Delphi 클래스를 위한 체크리스트
방어막 자체는 한 줄씩이고, 같은 감사는 여러분 코드에서 몇 분이면 끝난다. 필드를 해제하기 전에 Clear, Reset, ClearCore 메서드를 부르는 소멸자를 찾고, 그 메서드가 생성자가 도달하지 못했을 수 있는 필드를 만지는지 검사하라. nil 검사 없이 소유 리스트를 순회하는 루프를 찾아라. 예외를 잡는 생성자를 찾아 삼킴과 수동 Free를 둘 다 제거하라. 점검 대신 증거가 필요하다면 앞서 기술한 카운팅 메모리 매니저가 한 페이지 코드에 들어가고 생성자의 모든 할당을 테스트 사례로 바꿔 준다
전체 기능 세트, 지원 Delphi와 C++Builder 버전, 라이선스는 HotXLS Delphi Excel 컴포넌트 제품 페이지를 보라