기술 문서

Delphi 인플레이스 폼 필드 에디터의 OnExit 재진입

PDFlibPas의 TPDFlibViewer 컨트롤은 에디터 자신의 OnExit 이벤트를 통해 인플레이스 폼 필드 에디터를 커밋하며, 이 설계 선택은 고전적인 Delphi VCL 함정을 숨기고 있다: 포커스를 가진 컨트롤을 자신의 OnExit 핸들러 안에서 숨기거나, 부모를 바꾸거나, 파괴하면 첫 번째 호출이 반환되기도 전에 OnExit가 두 번째로 발생해 커밋 로직을 자기 자신 안으로 다시 보낼 수 있다

이것이 만들어내는 실패는 깔끔한 재현을 거부한다. 사용자가 스캔된 신청서 양식의 텍스트 필드들을 빠르게 탭으로 넘어가다 보면, 가끔 뷰어가 액세스 위반을 일으키거나, 더 나쁘게는 계속 실행되면서 조용히 두 탭 전의 필드에 잘못된 값을 써넣는다. 이를 요구에 따라 재현해 보면 버그가 돌이켜보면 명백해 보이지만, 단일 고객의 크래시 보고서로부터 이를 쫓다 보면 유령처럼 보이는데, 두 번째 OnExit가 실제로 발생하는지는 필드 유형, 타이핑 속도, 그 순간 메시지 큐가 하고 있는 다른 무엇이든에 따라 바뀌는 창 핸들과 포커스 타이밍에 달려 있기 때문이다

TPDFlibViewer는 렌더링된 페이지 위에 진짜 에디터를 어떻게 올리는가

TPDFlibViewer는 각 PDF 페이지를 비트맵으로 렌더링하며 기본적으로 폼 필드를 살아있는 VCL 컨트롤로 바꾸지 않는다. 그래서 BeginEditFormField가 그 두 세계를 잇는 메서드다: 필드 인덱스와 함께 호출되면, 그 필드의 사각형을 찾아 클라이언트 좌표로 변환한 다음, 텍스트 필드에는 진짜 TEdit나 TMemo를, 선택 필드에는 csDropDownList 스타일의 TComboBox를 그 사각형 위에 놓고 필드의 현재 값을 이미 로드해 완성한다. ISO 32000-2 §12.7은 PDF 안에서 텍스트나 선택 폼 필드가 무엇인지 정의하지만, 그 스펙 어디에도 Windows 애플리케이션이 어떻게 누군가로 하여금 그 안에 입력하게 해야 하는지는 말하지 않으며, BeginEditFormField가 채우기 위해 존재하는 것이 정확히 그 공백이다. OnKeyDown과 OnExit 둘 다 TPDFlibViewer가 만드는 모든 에디터에서 같은 두 뷰어 메서드인 InplaceEditorKeyDown과 InplaceEditorExit에 연결되며, 이 짝짓기는 v3.220.0에서 인터랙티브 폼 채우기가 처음 도입된 이래 변경 없이 배포되어 왔다. 그리고 OnExit가 바로 문제가 시작되는 곳이다

에디터를 숨기면 왜 OnExit가 두 번째로 발생하는가?

VCL의 TWinControl은 포커스를 가진 컨트롤의 Visible이나 Parent 변경을 그로부터 즉시 포커스를 옮길 이유로 취급하며, 컨트롤에서 포커스를 옮기는 것이 정확히 그 컨트롤의 OnExit 이벤트를 발생시키는 것이다. 그것도 동기적으로, 그것을 촉발한 속성 대입이 반환되기도 전에 말이다. PDFlibPas가 인플레이스 에디터를 닫고 그 값을 폼 필드에 다시 쓰기 위해 사용하는 메서드인 CommitInplaceEditor는 나가는 길에 정확히 그 두 가지를 해야 한다: 컨트롤이 페이지 위에 그려지는 것과 입력을 받는 것을 멈추도록 Editor.Visible을 False로, Editor.Parent를 nil로 설정하는 것이다. 사용자가 방금 그것을 떠났으므로 거의 항상 그렇듯 에디터가 여전히 포커스를 가지고 있는 동안 그 둘 중 하나를 실행하면, 그 에디터의 OnExit가 마지막으로 발생시켜야 했던 바로 그 호출 도중에 OnExit가 다시 발생한다

CommitInplaceEditor가 자기 자신에 재진입하면 무엇이 잘못되는가?

순진한 커밋 메서드는 이 대가를 둘 중 하나의 방식으로 치른다. 원래 호출에서 한 번, 그리고 첫 번째 호출이 자신의 상태를 건드리는 것을 마치기도 전에 몰래 끼어든 재진입 호출에서 한 번, 필드의 값을 두 번 쓰거나, 콜 스택 아래쪽의 프레임이 여전히 같은 컨트롤 자신의 이벤트 핸들러 안에 있는 동안 그 에디터 컨트롤을 해제하려 시도하는데, 이는 VCL에서 정의되지 않은 영역이며 실제 원인이 아닌 거의 어떤 줄이든 가리킬 수 있는 액세스 위반으로 나타난다. 어느 실패든 촉발하는 데 큰 폼이 필요하지 않다; 사용자가 OS가 여전히 첫 번째 필드로부터의 포커스 메시지를 되감고 있을 만큼 빠르게 두 번째 필드를 떠난다면, 필드 두 개짜리 문서로도 충분하다

// Naive version: reads fine in review, fails only under real typing speed
procedure TMyPdfViewer.EditorExit(Sender: TObject);
begin
  CommitEditor;               // still running inside FEditor's own OnExit
end;

procedure TMyPdfViewer.CommitEditor;
begin
  if not Assigned(FEditor) then
    Exit;
  SaveFieldValue(FEditor.Text);
  FEditor.Parent := nil;      // focused control reparented here: OnExit
                               // fires again, re-entering this same method
  FEditor.Free;                // freed while a caller further down the
  FEditor := nil;              // stack is still inside its OnExit handler
end;

컨트롤을 건드리기 전에 참조를 nil로 만들어라

PDFlibPas가 배포하는 수정은 단순한 순서 재배치다: 에디터를 로컬 변수에 캡처하고, 그것을 가리키는 필드를 지운 다음, 그때야 비로소 컨트롤의 속성을 바꾸기 시작하는 것이다. CommitInplaceEditor는 FInplaceEditor를 로컬 Editor 변수로 읽어들이고, 즉시 FInplaceEditor를 nil로 설정한 뒤, 그 이후에야 Editor.Visible과 Editor.Parent를 대입한다. 그 두 대입 중 어느 것에 의해 촉발된 재진입 호출도 FInplaceEditor 자체를 읽고, 그것이 이미 nil임을 발견하고, Editor를 건드리거나 필드 값을 두 번째로 쓰기도 전에 자신의 첫 줄에서 빠져나간다

procedure TPDFlibViewer.CommitInplaceEditor(Save: Boolean);
var
  Editor: TWinControl;
begin
  Editor := FInplaceEditor;
  if not Assigned(Editor) then
    Exit;                      // a reentrant call lands here and stops
  FInplaceEditor := nil;       // detach before the control is touched at all
  if Save then
    SaveEditorValue(Editor);   // safe: FInplaceEditor is already nil
  Editor.Visible := False;
  Editor.Parent := nil;        // may fire OnExit again; the guard above
                                // turns that reentrant call into a no-op
  ReapDeadEditor;               // free whatever was parked last cycle
  FDeadEditor := Editor;        // park this one instead of freeing it here
end;

그 목록 안의 SaveEditorValue는 Editor가 TComboBox인지, TMemo인지, TEdit인지 확인하고 그에 따라 값을 읽는 실제 분기를 대신하는데, PDFlibPas는 필드가 텍스트 필드인지 선택 필드인지에 따라 다른 컨트롤을 만들기 때문이다. 이 보호 장치는 어느 분기가 실행되는지는 신경 쓰지 않고, 오직 OnExit를 촉발할 수 있는 무언가가 실행되기 전에 FInplaceEditor가 nil이라는 것만 신경 쓰는데, 이것이 나머지 메서드를 그 외에는 자연스러운 어떤 스타일로든 안전하게 작성할 수 있게 해주는 유일한 순서 제약이다

컨트롤을 자신의 이벤트 안에서 절대 해제하지 마라

TPDFlibViewer.CommitInplaceEditor는 절대 Editor.Free를 직접 호출하지 않으며, 이는 의도적이다: 같은 컨트롤 자신의 이벤트 디스패치에 속한 스택 프레임이 그것을 해제하는 호출 위에서 여전히 되감기고 있을지도 모르는 동안(재진입한 OnExit이든 아니든) 컨트롤을 해제하는 것은 안전하지 않다. PDFlibPas는 대신 분리된 에디터를 단일 슬롯 주차 공간인 FDeadEditor에 넘긴다. 이전 편집 주기에서 거기 앉아 있던 것은 작은 헬퍼인 ReapDeadEditor를 통해 해제되는데, 이는 다음 BeginEditFormField 시작 시점과 CloseDocument에서 한 번 더 호출된다; 뷰어가 만드는 모든 에디터도 뷰어 자신이 소유한다. TEdit.Create(nil)가 아니라 TEdit.Create(Self)이므로, 뷰어가 파괴될 때 여전히 FDeadEditor에 주차되어 있는 컨트롤조차 유출되는 대신 평범한 VCL 컴포넌트 소유권에 의해 쓸려나간다

procedure TPDFlibViewer.ReapDeadEditor;
begin
  if Assigned(FDeadEditor) then
  begin
    FDeadEditor.Free;          // safe now: this control's own OnExit
    FDeadEditor := nil;        // finished at least one edit cycle ago
  end;
end;

function TPDFlibViewer.BeginEditFormField(FieldIndex: Integer): Integer;
var
  Edit: TEdit;
begin
  Result := 0;
  CommitInplaceEditor(True);   // flush whatever editor is still open
  // ... field lookup and rectangle conversion omitted ...
  ReapDeadEditor;              // now safe to free last cycle's parked editor
  Edit := TEdit.Create(Self);
  Edit.Parent := Self;
  Edit.OnExit := InplaceEditorExit;
  FInplaceEditor := Edit;
  FInplaceEditor.SetFocus;
  Result := 1;
end;

이것은 왜 빠른 Tab 내비게이션 중에 가장 심하게 나타나는가?

PDFlibPas가 v3.226.0에서 추가한, 폼을 가로지르는 Tab과 Shift+Tab 내비게이션을 구동하는 메서드인 FocusNextFormField는 매 이동마다 다음으로 자격 있는 필드에 대해 BeginEditFormField를 호출하고, BeginEditFormField는 이전 필드가 열어둔 채로 남긴 어떤 에디터든 흘려보내기 위해 CommitInplaceEditor(True)를 호출하는 것으로 시작한다. 그 말은 사용자가 여러 필드를 가진 폼을 채우는 동안 누르는 모든 Tab 입력이 위에서 설명한 분리-후-건드리기 시퀀스를 정확히 한 번씩 실행한다는 뜻이며, 이것이 정확히 Visible과 Parent가 바뀌는 순간 컨트롤이 진짜로 포커스를 가지고 있을 가능성이 가장 큰 코드 경로다. Tab은 새 에디터가 요청할 때까지 나가는 에디터가 포커스를 계속 붙들고 있을 것이 거의 확실한 유일한 상호작용이기 때문이다

이 중 어느 것도 이 버그를 시연하기 쉽게 만들지 않으며, 이는 얼버무리는 대신 명확히 말할 가치가 있다. 주어진 Visible이나 Parent 대입이 실제로 동기적인 OnExit를 강제하는지는 디버거가 붙어 있다는 것만으로 바뀌는 포커스와 창 핸들 상태에, 무관한 다시 그리기나 타이머가 어지럽힐 수 있는 것에, 그리고 그때 작동 중인 컨트롤이 TEdit, TMemo, TComboBox 중 무엇이냐에 따라 다르게 동작하는 것에 달려 있다. 오직 가끔씩만 실행되는 보호 장치야말로 이런 종류의 결함이 코드 리뷰와 수동 테스트 모두를 살아남는 이유이며, 또한 이 수정이 몇 번의 수동 테스트 패스가 우연히 관찰한 동작에 의해서가 아니라 구조적으로 올바라야 하는 이유다 — 다른 무엇보다 먼저 참조를 nil로 만드는 것 말이다

이 수정의 일반적인 형태는 뷰어 컨트롤 하나를 훨씬 넘어 적용된다. 렌더링된 콘텐츠 위에 살아있는 VCL 컨트롤을 겹쳐서 만든 어떤 커스텀 편집 표면이든, PDF 폼 필드뿐 아니라, 닫기-그리고-커밋 로직이 명시적인 사용자 동작과 암묵적인 포커스 변경 둘 다에 의해 촉발될 수 있게 되는 순간 같은 위험을 물려받으며, 같은 두 부분짜리 답이 적용된다: 자신의 종료 이벤트를 촉발할지 모르는 무언가를 하기 전에 활성 컨트롤을 식별하는 참조를 지우고, 그 컨트롤 자신의 이벤트 디스패치 아래에서 여전히 실행 중일 수 있는 코드 경로에서 절대 Free를 호출하지 마라. 어느 필드에 어느 컨트롤 유형을 보여줄지 결정하는 방법을 포함한 TPDFlibViewer의 더 넓은 폼 채우기 및 렌더링 표면은 PDFlibPas로 Delphi VCL에서 인터랙티브 PDF 뷰어 컨트롤 만들기 개요에서 다루며, SetFormFieldValueAndRefresh가 커밋된 모든 편집마다 무효화해야 하는 페이지 비트맵 캐시는 뷰어의 모니터별 DPI 디스크 페이지 캐시에 관한 글에서 별도로 다룬다

인플레이스 폼 필드 편집, Tab으로 구동되는 필드 내비게이션, 그리고 그 둘 뒤에 있는 재진입 안전 커밋 경로는 Delphi와 C++Builder용 PDF 라이브러리인 PDFlibPas와 함께 제공되는 인터랙티브 뷰어 컨트롤의 일부이며, 나머지 페이지 렌더링, 주석, 폼 필드 API 표면과 함께 제공된다