기술 문서

Delphi에서 CDO로 PDF 이메일 보내기: 아파트먼트 스레딩 함정

Delphi와 C++Builder용 losLab PDF 개발자 라이브러리인 PDFlibPas는 SendDocumentByMail이라는 하나의 평범한 API 호출을 통해 생성된 PDF를 이메일 첨부파일로 보낸다. Windows에서 기본 전송 방식은 운영체제에 내장된 COM 메일 컴포넌트인 CDO(Collaboration Data Objects)를 사용하며, 실제로 다중 스레드 배치 작업을 깨뜨리는 세부사항은 SMTP가 아니라 COM 아파트먼트 초기화다

이 API 뒤에 있는 시나리오는 화려하지 않지만 극히 흔하다: 서비스가 고객당 하나씩 월말 명세서 PDF 배치를 렌더링하고, 사람이 개입하지 않고 각각을 발송해야 한다. 처리량을 위해 이 작업을 스레드 풀에 밀어 넣으면, 발송 중 일부가 같은 코드를 단일 스레드에서 실행하면 절대 재현되지 않는 COM 오류로 실패하기 시작한다. SMTP 서버, PDF, 첨부파일 어느 것도 잘못되지 않았다. 문제는 CDO가 예상하지 못한 스레드에서 CoInitializeEx가 무엇을 반환하는가에 있으며, PDFlibPas는 이 경우를 우연이 아니라 의도적으로 다루도록 작성되어 있다

SendDocumentByMail이 PDFlibPas 내부에서 실제로 하는 일

SendDocumentByMail은 그 자체로 메일 클라이언트가 아니라 얇은 오케스트레이터다. TPDFlib.SendDocumentByMail은 현재 로드된 문서를 자체 임시 PDF로 저장하고, SMTP 설정과 메시지 텍스트를 TPDFlibMailRequest 레코드로 포장한 뒤, 그 레코드를 IPDFlibMailProvider를 구현하는 무언가에 넘기고, 반환되면 임시 파일을 다시 삭제한다. 프로바이더 인터페이스가 실제 메일 클라이언트이며, PDFlibPas는 정확히 하나의 내장 구현을 제공한다: Windows에서만 컴파일되는 CDO 기반 프로바이더다. MailProvider 속성을 먼저 할당하지 않고 SendDocumentByMail을 호출하면, PDFlibPas는 자동으로 그 기본값으로 폴백한다. 반환값은 전체에 걸쳐 의도적으로 좁게 유지된다: 수락되면 1, 그 외에는 무엇이든 0이며, 필수 필드 누락이든, 임시 파일 쓰기 실패든, 프로바이더가 메시지를 거부한 것이든 마찬가지다. 실제 이유는 이후 GetLastMailError에서만 확인할 수 있다

var
  PDF: TPDFlib;
  Sent: Integer;
begin
  PDF := TPDFlib.Create;              // a new instance already holds one blank document
  try
    PDF.SetPageDimensions(612, 792);  // US Letter, in points
    PDF.NewPage;
    // ... draw the statement: fonts, text, totals ...
    Sent := PDF.SendDocumentByMail(
      'smtp.example.com', 0, 1,                 // port 0 with SSL 1 falls back to 465
      'billing@example.com', 'app-password',    // SMTP auth
      'billing@example.com', 'customer@example.com', '', '',
      'Your statement is ready',
      'Please find the attached PDF statement.',
      'statement-4471.pdf');                    // attachment display name
    if Sent <> 1 then
      Writeln('Send failed: ', PDF.GetLastMailError);
  finally
    PDF.Free;
  end;
end;

CoInitializeEx는 왜 S_FALSE를 반환하는가, 그리고 그것은 실패인가?

CoInitializeEx가 반환하는 S_FALSE는 실패가 아니며, 이를 실패로 취급하는 코드는 실제로는 아무 문제도 없는 스레드에서 실패를 보고한다. CoInitializeEx는 스레드가 처음으로 COM을 성공적으로 초기화할 때 S_OK를 반환하고, 그 스레드가 이미 호환되는 동시성 모델로 COM을 초기화한 상태였을 때 S_FALSE를 반환하는데, 어느 쪽이든 같은 스레드별 참조 카운트를 증가시키므로 스레드가 종료되거나 무관한 작업으로 넘어가기 전에 짝을 이루는 CoUninitialize 호출이 필요하다. TPDFlib 자체도 정확히 이 패턴을 따른다: TPDFlib 인스턴스를 생성하는 것은 이미 CoInitialize를 호출하고, 동일한 S_OK-또는-S_FALSE 검사를 사용해 짝이 되는 CoUninitialize가 빚으로 남아 있는지 기록한다. 그래서 SendDocumentByMail이 자신의 CDO 프로바이더에 도달하고 그 프로바이더가 CoInitializeEx를 다시 호출할 무렵에는, 일반적인 경우 그 스레드에서 COM이 이미 초기화되어 있으므로, 프로바이더는 거의 항상 S_OK가 아니라 S_FALSE를 관찰한다. S_FALSE를 성공이 아닌 다른 무언가로 취급하는 것은 이 라이브러리에서 드문 경계 사례가 아니다; 이것이 일반적인 경로다

InitResult := CoInitializeEx(nil, COINIT_APARTMENTTHREADED);
NeedUninitialize := (InitResult = S_OK) or (InitResult = S_FALSE);
if Failed(InitResult) and (InitResult <> RPC_E_CHANGED_MODE) then
begin
  ErrorText := 'COM initialization failed';
  Exit;
end;
try
  // ... create CDO.Message, CDO.Configuration, send ...
finally
  if NeedUninitialize then
    CoUninitialize;
end;

CoInitializeEx는 왜 RPC_E_CHANGED_MODE를 반환하는가?

RPC_E_CHANGED_MODE는 현재 스레드가 이 호출이 요청하는 것과 다른 동시성 모델로 앞서 COM을 초기화했다는 뜻이며, 일반적으로는 그 스레드가 앞서 다중 스레드(MTA)로 갔는데 CDO가 이제 COINIT_APARTMENTTHREADED를 통해 단일 스레드 아파트먼트(STA) 시맨틱을 요청하는 경우다. 스레드는 아파트먼트 모델을 한 번만 선택하며, 그 스레드의 남은 생애 동안 그 무엇도 그 모델을 바꿀 수 없다; 다른 플래그로 CoInitializeEx를 다시 시도해도 그 불일치는 고쳐지지 않으며, 먼저 CoUninitialize를 호출하면 그 스레드의 다른 코드가 여전히 의존하고 있을지 모르는 아파트먼트를 무너뜨릴 것이다. PDFlibPas는 RPC_E_CHANGED_MODE를 보고할 오류가 아니라 함께 다뤄야 할 조건으로 취급한다: 그 호출이 애초에 해제할 참조를 획득한 적이 없으므로 짝이 되는 CoUninitialize를 건너뛰고, 기존 아파트먼트에서 발송을 계속 진행하게 둔다

RPC_E_CHANGED_MODE는 거의 전적으로 재사용된 스레드에서 나타난다: 스레드 풀 워커, IIS나 서비스 호스트 스레드, 또는 메일 코드가 근처에 오기도 전에 ADO나 WMI 같은 앞선 코드가 이미 COINIT_MULTITHREADEDCoInitializeEx를 호출했던 어떤 스레드든 해당한다. SendDocumentByMail만 호출하는 완전히 새 스레드는 이 경로에 부딪히지 않을 것이다. 배치 스케줄러가 하루에 수천 번 재활용하고 다른 COM 기반 작업과 공유하는 워커 스레드는 확실히 이를 겪을 것이며, 간헐적으로 그렇게 될 것이다. 이것이 정확히 사람들이 먼저 SMTP 서버를, 그다음에야 스레딩 모델을 살펴보게 만드는 패턴이다

메일 첨부파일을 엉뚱한 디렉터리 밖에 두지 않기

PDFlibPas는 모든 나가는 첨부파일을 매 SendDocumentByMail 호출마다 생성하는 GUID의 이름을 딴 새 디렉터리에 쓰는데, 이는 특히 동시 발송이 같은 파일 이름에서 절대 충돌하지 않고 첨부파일 이름이 그 디렉터리 밖으로 빠져나갈 수 없도록 하기 위함이다. 첨부파일로 전달되는 이름은 경로로 신뢰되지 않는다: 이는 PLSanitizeAttachmentName을 거치는데, 이는 어떤 디렉터리 구성요소든 벗겨내고, 빈 문자열과 특수한 ... 이름을 거부하며, Windows가 파일 이름에서 불법으로 취급하는 모든 문자를, 모든 제어 문자와 함께, 밑줄로 바꾼다. 경로 순회의 일부와 불법적인 콜론의 일부를 담은 ..\quarter:report.pdf를 넣으면 디스크에 도달하는 것은 quarter_report.pdf다: 마지막 경로 구분자까지의 모든 것은 버려지고, 콜론은 Windows 파일 이름에 나타날 수 없으므로 밑줄이 된다

function PLSanitizeAttachmentName(const FileName: WideString): WideString;
var
  I, P: Integer;
begin
  P := LastDelimiter('/\', string(FileName));
  Result := Copy(FileName, P + 1, MaxInt);       // strip any directory part
  if (Result = '') or (Result = '.') or (Result = '..') then
    Result := 'document.pdf';
  for I := 1 to Length(Result) do
    if (Ord(Result[I]) < 32) or (Pos(Result[I], WideString('<>:"/\|?*')) > 0) then
      Result[I] := '_';
end;

호출별 전용 디렉터리는 단순한 정돈 이상이다. SendDocumentByMail은 메시지가 발송된 뒤 finally 블록에서 자신이 썼던 바로 그 경로를 사용해 임시 파일을 삭제하고 그 디렉터리를 제거한다. 그래서 위생 처리되지 않은 채로 그 코드에 도달한 첨부파일 이름은 단순히 쓰기 위치를 잘못 두는 데 그치지 않았을 것이다. 그 위생 처리되지 않은 경로는 그다음 더 묻지도 않고 DeleteFile을 호출하는 정리 단계에 도달했을 것이고, 공유 임시 폴더에서는 두 개의 동시 발송이 어느 쪽 배달도 끝나기 전에 같은 이름 아래 서로의 첨부파일을 조용히 덮어쓸 수도 있었을 것이다. 이름 위생 처리는 순회 경우를 막고, 호출별 GUID 디렉터리는 충돌 경우를 막으며, 둘 중 어느 하나만으로는 충분하지 않았을 것이다

워커 풀에서 COM 수명을 스레드 수명에 맞추기

배치 메일러에서 아파트먼트 스레딩 실패에 가장 신뢰할 만한 수정은 모든 SendDocumentByMail 호출을 자신만의 고립된 COM 수명으로 취급하는 것을 멈추고, 대신 워커 스레드마다 그 스레드의 생애 동안 COM을 한 번만 초기화하는 것이다. 시작할 때 CoInitializeEx(nil, COINIT_APARTMENTTHREADED)를 호출하고, 자신이 하는 모든 SendDocumentByMail 호출을 위해 그 아파트먼트를 유지하며, 종료할 때 정확히 한 번 CoUninitialize를 호출하는 워커는 자신의 메일 발송에서 RPC_E_CHANGED_MODE를 절대 겪지 않는데, 그 스레드의 다른 어떤 것도 먼저 충돌하는 모드로 COM을 초기화할 기회를 얻지 못하기 때문이다. 이 패턴 하에서도 각 개별 SendDocumentByMail 호출은 여전히 내부적으로 자신만의 CoInitializeExCoUninitialize 쌍을 실행하며, 그것은 무해하다: 워커 스레드가 이미 아파트먼트를 세워둔 상태이므로, 그 내부 호출들은 이제 모두 S_FALSE를 보고, 같은 참조 카운트를 증가시키고 감소시키며, 워커 스레드 자체의 COM 아파트먼트를 건드리지 않은 채로 남긴다

type
  TMailWorker = class(TThread)
  protected
    procedure Execute; override;
  end;

procedure TMailWorker.Execute;
var
  PDF: TPDFlib;
  Job: TStatementJob;
begin
  CoInitializeEx(nil, COINIT_APARTMENTTHREADED);
  try
    while not Terminated do
    begin
      if not TryGetNextJob(Job) then
        Break;
      PDF := TPDFlib.Create;
      try
        BuildStatement(PDF, Job);
        if PDF.SendDocumentByMail(Job.Host, 0, 1, Job.User, Job.Pass,
             Job.From, Job.Recipient, '', '', Job.Subject, Job.Body,
             Job.AttachmentName) <> 1 then
          LogFailure(Job, PDF.GetLastMailError);
      finally
        PDF.Free;
      end;
    end;
  finally
    CoUninitialize;
  end;
end;

실패 진단하기, 그리고 실제 메일함 없이 테스트하기

GetLastMailError는 처음부터 로깅에 포함시켜 둘 가치가 있는 이 API의 나머지 절반인데, 1 또는 0이라는 반환값만으로는 실패한 발송이 COM 초기화 문제였는지, SMTP 인증 거부였는지, 첨부파일 누락이었는지 말해주지 않기 때문이다. MailProvider 속성은 실제 메일함 없이도 이 전체 경로를 테스트 가능하게 만들어주는 것이다: 요청을 발송하는 대신 기록하는 IPDFlibMailProvider 구현을 할당하고, CI 파이프라인에서 그 가짜 프로바이더에 대해 배치 작업을 실행하면, MailProvider를 설정하지 않은 채로 두고 PDFlibPas가 프로덕션에서 내장 CDO 전송으로 폴백하게 되어도 같은 SendDocumentByMail 호출 지점은 변경 없이 계속 작동한다

명세서를 이메일로 보내는 배치 작업은 발송에서 멈추는 경우가 드물다: 같은 파이프라인이 발송 전에 PDF를 검증하고 서명해야 하는 경우가 많으며, 이는 컴플라이언스 및 서명 워크벤치 글에서 별도로 다룬다. 둘이 연이어 실행되더라도 프리플라이트와 서명 검증은 메일 전송과는 다른 관심사이기 때문이다. 발송되는 문서 자체가 단일한 새로 만든 PDF가 아니라 대규모 병합이나 분할 작업의 출력물일 때는, 대용량 PDF 직접 접근 가이드가 그 생성 단계를 다룬다. 여기서 설명한 SendDocumentByMail과 메일 프로바이더 모델은 Delphi와 C++Builder용 표준 PDFlibPas PDF 개발자 라이브러리의 일부이며, 제품 페이지에는 체험판 다운로드와 함께 전체 API 레퍼런스가 실려 있다