기술 기사

Delphi에서 취소 가능한 Future를 사용한 백그라운드 PDF 렌더링

PDFium에서 페이지 렌더링은 동기적으로 수행됩니다. 라이브러리를 호출하면, 라이브러리가 제공된 비트맵에 래스터화(rasterise)를 수행하고 픽셀이 기록된 후에 제어권이 다시 돌아옵니다. 특정 확대 수준에서 화면 크기의 단일 페이지인 경우 몇 밀리초가 소요되어 아무도 눈치채지 못합니다. 하지만 200페이지짜리 문서를 300dpi로 내보내거나 모든 페이지를 한 번에 래스터화해야 하는 썸네일 스트립(thumbnail strip)의 경우, 같은 호출이라도 몇 초가 걸립니다. 만약 이를 주 스레드(main thread)에서 호출하면 메시지 루프가 중지되고 창이 다시 그려지지 않으며 Windows는 제목 표시줄 위에 두려운 "응답 없음"을 띄웁니다. 작업 자체는 올바르지만 실행한 위치가 잘못된 것입니다

해결책은 이 긴 렌더링 작업을 백그라운드 스레드로 이동하고 그 결과를 다시 주 스레드로 가져와서 제어(control)에 비트맵을 전달하는 것입니다. PDFium 자체는 이런 방식을 막지 않지만, "작업자(worker)에서 실행하고 UI에서 응답"하는 구조 주변의 버그 발생 가능성이 넓고 간헐적인 오류를 발생시키므로 바인딩에서 안전하게 전달되도록 해야 합니다. PDFiumPas의 FPdfAsync 유닛은 오랜 시간이 걸리는 렌더링 작업의 실제 동작 방식에 맞는 취소 모델과 함께 이 패턴에 대한 하나의 올바른 구현을 제공하기 위해 존재합니다

작업의 형태

프레임 한 번을 넘어서는 렌더링이 발생하는 주요 사례는 세 가지가 있습니다. 첫째는 일괄 렌더링으로, 페이지 범위를 순회하며 일반적으로 각 페이지를 디스크로 래스터화합니다. 둘째는 다중 페이지 내보내기이며 방식은 같으나 출력물을 하나의 파일로 조합합니다. 셋째는 백그라운드 페이지 렌더링으로, 사용자가 아직 캐시에 없는 페이지로 이동할 때 뷰어가 비트맵을 스레드 밖에서 생성하여 준비되면 표시하는 작업입니다. 세 경우 모두 동일한 제약 조건을 공유합니다. UI 스레드에 두기에는 실행 시간이 너무 길며, 결국 UI 스레드가 필요로 하는 결과를 생성하고, 사용자가 중간에 작업을 포기할 수도 있습니다. 문서를 닫거나 해당 페이지를 지나쳐 스크롤하거나 취소를 누르는 행위는 사용자가 더 이상 원하지 않는 출력을 기다리게 하는 대신 작업을 즉시 중단해야 합니다

마지막 제약 조건이 이 설계를 구체화하는 요인입니다. 취소할 수 없는 렌더링은 그 결과가 더 이상 중요하지 않게 된 후에도 문서를 열어둔 채 CPU를 소모하는 렌더링입니다. 그래서 이 유닛은 결합 가능한 두 개의 기본 구조를 중심으로 만들어졌습니다. 결과를 되돌려 보내는 future와 취소 요청을 앞으로 전달하는 token이 그것입니다

실행 후 잊어버리는 Future (Fire-and-forget future)

TPdfFuture<T>.Run은 작업자(worker), 응답(reply), 그리고 선택적으로 취소 토큰(cancellation token)을 받습니다. 이 메서드는 백그라운드 스레드에서 작업자를 시작하고 작업자가 끝나면 주 스레드로 응답을 전달합니다. 제네릭 매개변수 T는 렌더링이 생산하는 어떤 것이든 될 수 있으며 보통 비트맵 핸들이나 상태 레코드입니다. 작업자는 스레드 밖에서 실행되고, 응답은 VCL을 건드려도 안전한 곳에서 실행됩니다

class procedure TPdfFuture<T>.Run(
  const AWorker: TPdfFutureWorker<T>;
  const AReply: TPdfFutureReply<T>;
  const AToken: IPdfCancellationToken = nil); static;

여기서 Wait와 같은 종류가 의도적으로 빠져 있습니다. 호출자가 future가 완료될 때까지 기다리게 만드는 차단(block) 메서드는 없으며, 이는 단순한 누락이 아닙니다. 주 스레드에서 호출된 Wait은 UI 교착 상태(deadlock)를 일으키는 전형적인 방법입니다. 작업자는 Synchronize를 통해 응답을 주 스레드에서 실행해야 하고, 주 스레드는 Wait 내부에서 멈춰 있어 양쪽 모두 진행되지 못합니다. 이 기본 요소를 제공하지 않음으로써, future는 스스로 이런 코드를 작성하려다 흔히 실패하게 만드는 패턴을 원천 차단합니다. 정말로 차단이 필요한 코드는 순수 TThread를 사용하고 그 결과를 책임져야 합니다. 이 future는 백그라운드 렌더링의 실제 모습인 '실행 후 잊어버리는(fire-and-forget)' 사례를 위한 것입니다

결과는 TPdfFutureResult<T>에 래핑되어 응답 측에 세 가지 상황 중 어떤 것이 발생했는지 알려줍니다. IsSuccess는 작업자가 정상적으로 반환되었으며 Value가 렌더링 결과를 지니고 있음을 의미합니다. IsCancelled는 토큰이 활성화되어 작업자가 취소 지점에서 중단했음을 의미합니다. IsFailure는 작업자에서 예외가 발생했음을 의미하며 ErrorMessage가 텍스트를 전달합니다. 응답 측은 반환된 비트맵이 진짜인지 지시값(sentinel value)을 추측하는 대신 이 상태를 확인하여 분기합니다

응답 전달 방식을 바꾼 v1.61.0의 경쟁 조건 (Race condition)

이 유닛에서 가장 교훈적인 부분은 이해하는 데 꽤 오랜 시간이 걸렸던 한 줄의 변경 사항입니다. 초기 버전들에서는 작업자 스레드가 TThread.Queue를 통해 응답을 전달했습니다. Queue는 응답을 주 스레드의 큐에 넣고 즉시 반환하는데, 이는 언뜻 '실행 후 잊어버리는' future가 정확히 원하는 동작처럼 읽힙니다. 하지만 이는 틀렸으며, 그 이유는 여러분이 생각하는 모든 테스트를 다 통과하면서도 문제를 일으키는 종류의 버그이기 때문에 자세히 설명할 가치가 있습니다

작업자 스레드는 FreeOnTerminate := True로 생성됩니다. 즉, Execute가 반환되는 순간 스레드는 스스로를 해체하며 TThread.Destroy가 정리의 일부로 RemoveQueuedEvents(Self)를 호출합니다. RemoveQueuedEvents는 죽어가는 스레드를 대상으로 큐에 대기 중인 모든 메서드를 제거합니다. 따라서 그 순서는 다음과 같았습니다. 작업자가 완료되고, 응답을 자기 자신을 향해 큐에 넣고, Execute가 반환되고, 스레드가 자신을 파괴하며, RemoveQueuedEvents가 주 스레드에서 아직 실행하지 않은 응답을 삭제해 버립니다. 그 결과는 그저 사라져 버렸습니다. 더 나쁜 것은 주 스레드가 큐에서 응답을 꺼내 실행하기 시작한 바로 그 시점에 스레드가 해제되는 좁은 타이밍에서는, 응답이 절반쯤 파괴된 객체의 필드를 건드리게 되면서 해제 후 사용(use-after-free) 오류를 발생시킵니다

v1.61.0에서의 해결책은 응답을 Queue 대신 Synchronize를 통해 전달하는 것이었습니다. Synchronize는 주 스레드가 응답 실행을 완전히 마칠 때까지 작업자 스레드를 차단합니다. 작업자는 응답이 실행되는 동안 여전히 살아있으므로 해제될 위험이 없으며, 스레드는 응답이 전달될 때까지 Execute에서 반환되지 않습니다(따라서 스스로의 파괴를 시작하지도 않습니다). 이렇게 하여 전달이 보장되고, 해제 후 사용 문제가 발생할 수 있는 좁은 틈새가 막힙니다

procedure TPdfFutureThread<T>.Execute;
begin
  FResult.Status := pfsSuccess;
  FResult.ErrorMessage := '';
  try
    FToken.ThrowIfCancelled;          // 이미 취소되었는가? 작업자를 건너뜀
    FResult.Value := FWorker(FToken);
  except
    on E: EPdfOperationCancelled do
    begin
      FResult.Status := pfsCancelled;
      FResult.ErrorMessage := E.Message;
    end;
    on E: Exception do
    begin
      FResult.Status := pfsFailure;
      FResult.ErrorMessage := E.Message;
    end;
  end;

  if Assigned(FReply) then
    // Queue가 아닌 Synchronize를 사용: 이 스레드는 FreeOnTerminate 속성이므로, 대기 중인 응답은
    // 주 스레드가 이를 실행하기도 전에 RemoveQueuedEvents에 의해 삭제될 수 있기 때문입니다.
    Synchronize(DispatchReply);
end;

여기서 얻는 일반적인 교훈은 이 특정 수정 사항을 넘어섭니다. 실행 후 잊어버리는 비동기 콜백은 동시성 패턴 중에서도 미묘하게 틀리기 가장 쉬운 패턴입니다. 정상 작동 경로(happy path)는 첫 시도에서 동작하지만, 스레 해체 순서와 큐 간의 상호 작용 속에 버그가 숨어 있기 때문입니다. 이 버그는 원할 때 재현되지 않습니다. 작업자가 파괴를 완료하기 전에 주 스레드가 우연히 큐를 모두 처리했는지 여부에 달려 있으며, 이는 스케줄러가 매번 실행할 때마다 다르게 결정하는 타이밍 문제입니다. 바인딩 내에서 한 번 올바르게 작성된 기본 도구는, 백그라운드 렌더링이 필요한 모든 응용 프로그램에서 같은 코드를 매번 다시 작성하는 것보다 훨씬 더 큰 가치가 있습니다

콜백이 메서드 포인터인 이유

작업자와 응답은 익명 메서드가 아닙니다. 이들은 TPdfFutureWorker<T>TPdfFutureReply<T>와 같은 procedure of object 타입이며, 이 선택은 컴파일러 지원 매트릭스에 의해 강제되었습니다. PDFiumPas는 Delphi XE5 이후 버전과 Delphi 모드의 Free Pascal 3.2에서 컴파일되는데, 해당 모드의 FPC 3.2는 익명 메서드를 지원하지 않습니다. 지역 변수를 캡처하는 프로시저 참조 콜백은 Delphi에서는 컴파일되지만 FPC에서는 실패하므로, 이 유닛은 두 컴파일러가 모두 허용하는 최소 공통 분모를 사용합니다

실제적인 결과는 상태(state)가 어디에 위치하느냐의 차이입니다. 익명 메서드는 지역 변수들을 포함하여 닫히지만(closes over), 메서드 포인터는 그렇지 않습니다. 따라서 페이지 인덱스, 줌, 출력 경로와 같이 작업자가 필요로 하는 어떤 상태나, 대상 이미지 컨트롤, 진행 상태 레이블처럼 응답 측이 업데이트해야 하는 상태는 콜백으로 전달되는 메서드를 소유한 객체에 종속되어야 합니다. 뷰어에서 그 객체는 주로 폼(form)이거나 폼이 소유한 렌더러 컨트롤러가 됩니다. 이는 마지못해 도입한 임시방편이 아닙니다. 오히려 클로저 내부에 숨기지 않고 수신 측 객체에 그 상태의 소유권을 명시적이고 눈에 띄게 유지시켜 줍니다

강제 종료가 아닌 협력적 취소

이곳의 취소는 협력적입니다. 작업자 스레드에 개입하여 이를 종료시키는 API는 없습니다. 렌더링 도중에 스레드를 종료하면 PDFium이 락(locks)이나 부분적으로 쓰인 비트맵을 쥐고 있는 상태가 되며 강제 종료 후의 프로세스 상태는 어찌 될지 알 수 없기 때문입니다. 대신 작업자에게 읽기 전용 토큰이 전달되고 작업자가 이를 확인하도록 기대하며, 렌더링 루프는 페이지나 타일 사이 등 깔끔하게 중단할 수 있는 시점에서 이를 점검하도록 작성됩니다

토큰은 취소를 관찰할 수 있는 세 가지 방법을 제공합니다. IsCancelled는 반복문 안에서 스스로 테스트하고 결정하길 원할 때 사용할 수 있는 저렴한 부울 검사(boolean poll)입니다. ThrowIfCancelled는 일반적인 경우로, 자연스러운 취소 시점에서 호출하며 만약 취소가 요청되었다면 EPdfOperationCancelled를 발생시켜 작업자를 future로 바로 돌려보냅니다. RegisterCallback은 소스가 취소될 때 한 번 실행되는 단발성 알림을 연결합니다. 이는 꽉 막힌 반복문에 앉아있지 않고 인터럽트할 수 있는 무언가에 작업자가 차단되어 있을 때 유용합니다

예외는 스레드 경계가 중요해지는 지점입니다. 작업자가 EPdfOperationCancelled를 발생시키면 future가 이를 포착하여 취소된 상태로 변환하므로 응답 측은 오류가 아닌 IsCancelled를 봅니다. 예외 객체 자체는 주 스레드로 마샬링(marshaling)되지 않습니다. 이는 작업자 스레드에서 생성되고 소멸되며, 오직 예외의 메시지 문자열만 ErrorMessage로 복사됩니다. 스레드 간에 살아있는 예외 객체를 마샬링하는 것은 종료 중인 스레드가 소유한 메모리에 접근하는 것을 의미하며 이는 Synchronize 수정이 방지하고자 했던 것과 동일한 종류의 실수입니다. 상태 코드와 문자열은 경계를 깔끔하게 넘어오지만 객체는 그렇지 않습니다

작업자가 자신을 취소할 수 없도록 나눈 두 개의 인터페이스

의도적으로 두 개의 인터페이스에 취소 기능이 나뉘어 있습니다. IPdfCancellationTokenSource는 쓰기 쪽입니다. 여기에는 Cancel이 있으며 이를 생성하는 소유자(주로 폼)가 이를 유지하다가 사용자가 버튼을 클릭하거나 폼을 닫을 때 Cancel을 호출합니다. IPdfCancellationToken은 읽기 쪽입니다. 여기에는 IsCancelled, ThrowIfCancelled, 그리고 RegisterCallback이 있으며 이것이 작업자가 받는 전부입니다. 하나의 구체화된 객체가 이 둘을 모두 구현하지만 작업자는 항상 토큰만 전달받으므로 자신이 실행 중인 작업을 스스로 취소할 방법이 없습니다. 이 분리는 API 수준의 안전망입니다. 작업자가 토큰을 통해 Cancel에 도달할 수 있다면 혼란스러운 코드가 스스로를 취소하게 만들 수 있으며 타입 시스템이 그 가능성 자체를 제거합니다

호출자가 렌더링을 원하면서도 취소할 의도가 전혀 없는 경우를 위한 세부 사항도 존재합니다. 호출마다 새 소스를 강제하는 대신 이 유닛은 영구적으로 '취소되지 않음' 상태에 있는 싱글톤(singleton) 토큰인 PdfNoCancellationToken을 노출합니다. Run은 토큰 매개변수가 nil로 남겨지면 이를 대체해서 사용합니다. 이 싱글톤은 처음 사용할 때 게으르게(lazily) 구성되지 않고 유닛 초기화 시점에 적극적으로 구성되는데, 그 이유 역시 동시성 때문입니다. 다른 작업자 스레드에서 여러 Run 호출이 게으르게 생성되는 싱글톤에 동시에 도달하면 구성 과정에서 경쟁하거나 중복을 누출하거나 혹은 절반만 초기화된 인스턴스를 잠깐 보게 될 위험이 있습니다. 어떤 작업자도 실행되기 전에 이를 구성함으로써 그 경쟁 상태를 완전히 제거합니다

취소 가능한 렌더링 실행하기

실제 사용에서는 소스를 생성하고 이를 폼에 보관하며, 작업자 메서드와 응답 메서드와 나란히 TokenRun으로 전달하고, 취소 버튼을 그 소스에 연결합니다. 작업자는 렌더링하는 동안 토큰을 확인하고 응답 측은 결과를 가져온 후 UI를 업데이트합니다. 콜백이 메서드 포인터이기 때문에 작업자와 응답은 폼의 필드에서 필요한 내용을 읽어올 수 있습니다

procedure TMainForm.StartRender;
begin
  FCancelSource := TPdfCancellationTokenSource.New;  // 폼에 상주하는 필드
  TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;

procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
  if Assigned(FCancelSource) then
    FCancelSource.Cancel;   // 작업자가 다음 취소 지점에서 이를 관찰합니다
end;

// 백그라운드 스레드에서 실행됩니다. 폼에서 FPageRange / FOutputDir를 읽어옵니다.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
  PageIndex: Integer;
begin
  for PageIndex := FFirstPage to FLastPage do
  begin
    AToken.ThrowIfCancelled;        // 페이지 간 깔끔한 정지
    RenderOnePage(PageIndex);       // 동기적 PDFium 래스터화
  end;
  Result := True;
end;

// 주 스레드에서 실행됩니다. 여기서 VCL을 만지는 것이 안전합니다.
procedure TMainForm.RenderReply(const AResult: TPdfFutureResult<Boolean>);
begin
  if AResult.IsSuccess then
    StatusLabel.Caption := 'Render complete'
  else if AResult.IsCancelled then
    StatusLabel.Caption := 'Cancelled'
  else
    StatusLabel.Caption := 'Failed: ' + AResult.ErrorMessage;
end;

도달 가능한 세 가지 결과를 모두 갖추고 있기 때문에 응답 측은 세 가지 결과 모두를 처리합니다. 완료된 렌더링은 성공을 보고하고, 취소를 누른 사용자는 취소된 분기(branch)를 보게 되며, 파일이 쓰이지 않았거나 페이지 구문 분석에 실패하면 메시지와 함께 오류로 전달됩니다. 이 분기 중 어떤 것도 차단되지 않으며 어떤 것도 작업자 스레드를 건드리지 않습니다. 그리고 작업자가 생산한 비트맵이나 상태는 오직 future가 이를 UI 소유 스레드에 가져다준 후에만 읽힙니다

이와 같은 스레딩 원칙은 뷰어의 다른 부분에서도 성과를 거둡니다. 줌 변화 전반에 걸쳐 렌더링된 비트맵을 보관하고 재사용하는 방법은 렌더 캐시 및 줌 성능에 대한 기사에서 다루고 있으며, Delphi 아래에서 PDFium 경계를 안전하게 유지하는 더 폭넓은 주제는 메모리 안전을 위한 PDFium VCL ABI 강화 기사에서 확인할 수 있습니다. 여기서 설명한 비동기 인프라는 이 블로그의 다른 곳에서 다루는 렌더링, 텍스트 및 양식 API와 함께 Delphi 및 C++Builder용 PDFium Component의 일부로 제공됩니다