본문으로 건너뛰기

PDF 렌더러 교체 이력

2026년 2월 8일부터 2026년 6월 21일 사이에 report-svc는 현재 PDF 경로에 정착하기 전까지 렌더링 스택을 여러 번 바꿨습니다. 이 변화는 렌더러 관련 커밋에서 가장 뚜렷하게 보입니다.

날짜커밋변경
2026-02-08543ebb3bPlaywright가 WeasyPrint로 대체되었습니다.
2026-03-15373b6d44, cd6ea3b7, 4ac41e5b, bdd2fbfd, 5311a873WeasyPrint는 Playwright로 교체된 후 브라우저 워밍업 및 병렬 백엔드 가져오기로 최적화되었습니다.
2026-03-15ae1fc2c2, 6ca3ccfc, 5822041bWeasyPrint+PyMuPDF는 imgkit로 교체되었고, wkhtmltopdf용 Bookworm에 고정된 후 같은 날 되돌렸습니다.
2026-06-216ee24b1d, bc2595a8, 21059827렌더링이 ReportLab으로 이동하고 일괄 작업이 PDF를 MinIO에 직접 업로드하기 시작했으며 이미지 빌드가 복구되었습니다.

거듭해서 변한 것

불안정한 경계는 보고서 데이터 수집이 아니었습니다. 렌더링 런타임이었습니다.

  • Playwright는 2026년 3월 15일 시리즈 동안 services/report/main.pyservices/report/services/report_generator.py에서 브라우저 프로세스와 시작 워밍업을 요구했습니다.
  • WeasyPrint 및 fitz는 렌더링을 다시 Python으로 옮겼지만 여전히 services/report/services/report_generator.py의 HTML 템플릿과 PDF-이미지 변환에 의존했습니다.
  • imgkit는 이를 wkhtmltoimage로 대체했습니다. 이는 services/report/Dockerfile에 OS 패키지 종속성을 추가하고 6ca3ccfc의 Bookworm에 고정해야 했습니다.
  • ReportLab은 services/report/src/adapters/reportlab_renderer.py에서 직접 PDF를 그려 런타임 경로에서 브라우저 및 HTML 템플릿 종속성을 제거했습니다.

마지막 조치는 비동기 안전을 유지하면서 렌더링 경로를 워커 프로세스 내부에 완전히 유지합니다.

class ReportLabReportGenerator:
async def generate_pdf(self, report_data: ReportData) -> bytes:
return await asyncio.to_thread(self._generate_pdf_sync, report_data)

async def warm_up(self) -> None:
return None

출처: lumie-worker/services/report/src/adapters/reportlab_renderer.py

안정화된 설계

2026년 6월 21일에 등장한 안정적인 설계는 세 부분으로 구성됩니다.

1. PDF 바이트를 직접 렌더링

이제 GenerateReportUseCase.execute(){"report_bytes": bytes}를 반환하고 보고서를 스크린샷 아티팩트로 변환하지 않습니다. execute_batch()는 청크의 각 학생 항목에 대해 동일한 PDF 렌더러를 사용합니다.

2. 일괄 콜백은 저장된 파일 참조를 전달합니다

bc2595a8services/report/src/adapters/minio_store.pyMinioReportStore.upload_pdf()를 도입하고 인라인 reportBytes 대신 fileKey를 보내도록 일괄 성공 콜백을 변경했습니다. 현재 스키마는 성공 페이로드에 정확히 하나의 아티팩트 참조가 포함되도록 강제합니다.

class SuccessCallbackPayload(BaseModel):
fileKey: str | None = None
reportBytes: str | None = None

@model_validator(mode="after")
def require_exactly_one_artifact_reference(self) -> SuccessCallbackPayload:
has_file_key = self.fileKey is not None
has_report_bytes = self.reportBytes is not None
if has_file_key == has_report_bytes:
raise ValueError("success callback must include exactly one of fileKey or reportBytes")
return self

출처: lumie-worker/services/report/src/schema.py

3. 이미지 빌드는 더 복잡해지지 않고 단순해졌습니다

현재 services/report/Dockerfile는 더 이상 Playwright 또는 wkhtmltopdf를 설치하지 않습니다. 빌더 단계에서 Python 종속성을 설치하고 python:3.11-slim-bookworm를 유지하며, ReportLab 글꼴 검색을 위해 글꼴 패키지와 fontconfig만 추가합니다. 21059827는 ReportLab 마이그레이션 후 fontconfig를 다시 추가한 작지만 중요한 수정입니다.

현재 정상 상태

현재 보고서 흐름은 다음과 같습니다.

  1. services/report/src/usecase.py는 백엔드 데이터를 동시에 수집합니다.
  2. services/report/src/adapters/reportlab_renderer.pyasyncio.to_thread(...)를 사용하여 워커 스레드에서 PDF를 렌더링합니다.
  3. 단일 보고서 경로는 services/report/main.py를 통해 인라인 PDF 바이트를 반환합니다.
  4. 일괄 경로는 services/report/src/adapters/minio_store.py를 통해 PDF를 업로드하고 services/report/src/mq/consumer.py를 통해 MinIO 키를 반환합니다.

2026년 6월 검사된 정상 상태에서 렌더링 계약, 배치 계약 및 컨테이너 이미지는 모두 동일한 방향을 가리키고 있습니다. 즉, 런타임 종속성이 적고 프로세스 간 예상치 못한 문제가 적습니다.

유지할 불변성

  • 렌더러를 프로세스 내부에 유지하세요. 출력 계약에서 실제로 요구하지 않는 한 브라우저 기반 또는 외부 바이너리 기반 렌더링을 다시 도입하지 마세요.
  • 비동기 서비스가 계속 응답할 수 있도록 asyncio.to_thread(...) 뒤에 CPU 바인딩된 그리기 작업을 유지합니다.
  • 단일 메시지 성공을 위한 인라인 reportBytes와 업로드된 배치 아티팩트를 위한 fileKey 간에 배치 콜백 계약 분할을 유지합니다.
  • 이미지 변경을 최소화하세요. 렌더링 변경에 새 패키지가 필요한 경우 services/report/Dockerfile이 더 이상 가벼운 ReportLab 경로에 머물 수 없는 이유를 문서화하세요.

관련 수명주기 및 비동기 복구 패턴은 Async-First Worker Recovery를 참조하세요.