Puppeteer2026년 5월 22일8분 읽기

EJS 30개 템플릿을 폐기하고 Puppeteer로 어드민 화면 그대로 PDF로 — 이중 유지보수 끝내기

검사 카드 30+종을 EJS와 Svelte 양쪽에 따로 만들고 있었다. 한쪽 손보면 다른 쪽이 깨졌다. Puppeteer가 어드민 URL을 그대로 렌더링하게 만들어 단일 진실의 원천으로 통합한 과정.

#Puppeteer#PDF#headless browser#아키텍처#SvelteKit#단일 진실의 원천

EJS 30개 템플릿을 폐기하고 Puppeteer로 어드민 화면 그대로 PDF로

PDF 리포트 생성 9s → 1.5s 글의 후속편이다. 그 글에서 PDF 파이프라인의 속도를 해결했다면, 이번엔 유지보수 구조를 해결했다.

1. 한 디자인 변경이 두 곳에서 깨진다

신경심리검사 어드민에서 검사 결과 카드 30+종을 화면에 표시한다. 그리고 같은 검사 결과를 PDF로도 뽑아야 한다. 두 출력의 코드는 따로였다.

출력위치기술
어드민 화면snsb3_adminSvelteKit + Svelte 5
PDFsnsb_pdf_genNode + Express + Puppeteer + EJS 30개 템플릿

이게 무슨 의미냐면 이렇다.

  • 새 검사 타입(SVLT-E 등)이 추가되면 Svelte 카드 + EJS 템플릿을 둘 다 만들어야 한다.
  • 디자인이 바뀌면 양쪽을 같이 손봐야 한다.
  • 한쪽만 손보면 어드민과 PDF가 다르게 보인다. QA에서 자주 잡혔다.

실제로 SNSB-III 추가 작업 중 어드민에 카드 27종을 새로 그렸는데 EJS 쪽은 일부만 따라잡힌 상태였다. 같은 그림을 두 번 그리는 비용이 명백히 한계에 달했다.

2. 두 가지 선택지

옵션 A. EJS를 폐기하고 어드민 URL을 Puppeteer가 그대로 렌더링한다.

text
Puppeteer → 어드민의 /print/raw/[testId] 페이지에 접속
        → 페이지가 다 그려지면 → page.pdf() 호출
        → 다 됨

PDF 서비스가 어드민의 인증과 데이터를 빌려와야 하지만, 카드 컴포넌트는 단 한 곳에만 존재한다.

옵션 B. EJS를 유지하고 카드 컴포넌트 일부를 공유 라이브러리로 추출한다.

Svelte 컴포넌트를 EJS와 공유? 프레임워크가 다르니 자연스럽지 않다. 결국 두 번 그릴 가능성이 높다.

옵션 A를 골랐다. 이유는 두 가지였다. 하나는 속도가 빠르다는 것, 다른 하나는 더 중요한데 템플릿을 수정할 때마다 어드민과 PDF가 자동으로 동기화된다는 거다. 어드민 카드를 고치면 PDF는 그걸 그대로 받아가니까 따로 맞출 일이 없다. 한 줄로 줄이면 이렇다.

어드민에 보이는 게 곧 정답이다. 그렇다면 PDF도 어드민이 만든 그림을 그대로 받자.

3. 어드민에 print 전용 라우트 만들기

먼저 어드민 쪽에 PDF 출력 전용 페이지를 만들었다.

text
/print/raw/[testId]

이 라우트는 평소 사용자에게 노출되지 않는다. Puppeteer만 들어온다. 그래서 일반 페이지와 달라야 할 게 세 가지였다.

1. 사이드바·헤더 없이 본문만

평소 어드민은 사이드바 + 헤더 + 본문 구조다. PDF에 사이드바가 들어가면 안 되니까 이렇게 했다.

svelte
<!-- src/routes/+layout.svelte -->
<script>
  import { page } from "$app/state"
  const isPrint = $derived(page.url.pathname.startsWith("/print"))
</script>
 
{#if isPrint}
  <slot />
{:else}
  <Sidebar />
  <Header />
  <main><slot /></main>
{/if}

/print/* 경로면 사이드바 없이 본문만 렌더한다. Svelte 5의 $derived로 처리.

2. 인쇄용 CSS

svelte
<!-- src/routes/(print)/+layout.svelte -->
<style>
  @media print {
    :global(html), :global(body) {
      margin: 0;
      padding: 0;
      background: white;
    }
  }
</style>

처음 짤 때 :global(@media print)로 썼다가 CSS 문법 에러를 만났다. :global()은 at-rule 안쪽에 와야지 at-rule 자체를 감쌀 수 없다.

3. "준비 완료" 신호

이게 까다로웠던 부분 중 하나다. Puppeteer는 페이지가 언제 다 그려졌는지 모른다. domcontentloadednetworkidle만 봐서는 이미지나 async fetch가 빠질 수 있다. 그래서 페이지가 직접 신호를 보내게 했다.

svelte
<!-- /print/raw/[testId]/+page.svelte -->
<script>
  let { data } = $props()
  let testSub = $state(null)
  let snapshot = $state(null)
 
  $effect(async () => {
    testSub = await getTestSub(data.testId)
    snapshot = await getTestSnapshot(data.testId)
    await tick()  // DOM 업데이트 대기
 
    // 이미지 로딩까지 모두 끝났는지 확인
    await Promise.all(
      [...document.images]
        .filter(img => !img.complete)
        .map(img => new Promise(r => { img.onload = img.onerror = r }))
    )
 
    window.__PRINT_READY__ = true  // Puppeteer가 이걸 본다
  })
</script>
 
{#if testSub && snapshot}
  <RawDataPrintView {testSub} {snapshot} />
{/if}

window.__PRINT_READY__ = true. 단순하다. 모든 데이터와 이미지가 준비됐을 때 이 플래그를 켠다. 신호를 안 만들고 시간 기반으로 대충 기다리면 어떤 검사는 멀쩡하고 어떤 검사는 이미지가 빠진 채 PDF로 나온다. 그 들쭉날쭉함을 잡으려고 결국 페이지가 직접 "다 됐다"고 말하게 만든 거다.

4. Puppeteer 서비스 — 이 페이지를 그대로 받기

PDF 서비스(snsb_pdf_gen)에 새 엔드포인트를 추가했다.

js
// pdf_admin_url.js
import puppeteer from "puppeteer"
import archiver from "archiver"
 
// 모듈 레벨에서 브라우저 한 번만 띄움 (cold start 제거)
let browser
async function getBrowser() {
  if (browser && browser.isConnected()) return browser
  browser = await puppeteer.launch({
    headless: "new",
    args: ["--no-sandbox", "--disable-setuid-sandbox"],
  })
  browser.on("disconnected", () => { browser = null })
  return browser
}
 
async function renderPdfFromAdmin(testId, token) {
  const b = await getBrowser()
  const page = await b.newPage()
  try {
    await page.goto(
      `${ADMIN_BASE_URL}/print/raw/${testId}?token=${token}`,
      { waitUntil: "networkidle0" }
    )
    // 페이지가 "다 됐어" 신호를 보낼 때까지 대기
    await page.waitForFunction(() => window.__PRINT_READY__ === true, { timeout: 30_000 })
    return await page.pdf({ format: "A4", printBackground: true })
  } finally {
    await page.close()
  }
}

요점은 이렇다.

  • 브라우저 인스턴스 재사용. 매 요청마다 Puppeteer를 새로 띄우면 cold start만 1~2초씩 든다. 모듈 레벨에 하나만 두고 disconnect 시 재생성한다.
  • waitForFunction(__PRINT_READY__). 페이지가 보내준 신호를 그대로 받는다. 시간 기반(setTimeout) 대기는 절대 안 쓴다.
  • page.close()는 try-finally로 보장한다. 페이지 누수 방지.

5. 다중 testId — 동시성 제어 + ZIP 스트리밍

한 검사 하나면 끝이지만 실제 요구는 "선택한 N건을 한 번에 ZIP으로"였다. 단순히 Promise.all로 묶으면 문제가 생긴다.

  • 50건이면 Puppeteer 페이지 50개가 동시 생성되어 메모리가 폭발한다
  • 한 건 실패하면 전체가 던져진다

직접 동시성을 4로 제한했다.

js
async function withConcurrency(items, limit, fn) {
  const results = []
  let i = 0
  async function worker() {
    while (i < items.length) {
      const idx = i++
      try {
        results[idx] = { ok: true, value: await fn(items[idx]) }
      } catch (e) {
        results[idx] = { ok: false, error: e }
      }
    }
  }
  await Promise.all(Array.from({ length: limit }, worker))
  return results
}
 
// 호출
const buffers = await withConcurrency(testIdList, 4, id => renderPdfFromAdmin(id, token))

p-limit 같은 외부 라이브러리를 쓸 수도 있지만, 20줄짜리 코드 한 번 짜는 게 의존성 늘리는 것보다 깔끔하다.

그리고 ZIP은 archiver로 스트리밍했다.

js
const archive = archiver("zip", { zlib: { level: 9 } })
archive.pipe(res)  // 응답 스트림에 직접 흘려보냄
for (const [i, result] of buffers.entries()) {
  if (result.ok) archive.append(result.value, { name: `${testIdList[i]}.pdf` })
}
archive.finalize()

메모리에 ZIP 전체를 만들지 않고 바이트가 만들어지는 대로 응답에 흘려보낸다. 50개 PDF가 와도 메모리 부담이 일정하다.

6. 인증 — 토큰 릴레이

가장 까다로웠던 부분이다. 어드민의 /print/raw/[testId]가 데이터를 부르려면 인증된 상태여야 한다. 그런데 Puppeteer는 브라우저이지 어드민 사용자가 아니다. 어떻게 인증할 것인가.

세 가지 옵션을 봤다.

옵션방식문제
서비스 어카운트 토큰PDF 서비스가 별도 권한 토큰 보유권한 분리·감사 어려움
쿠키 주입Puppeteer가 쿠키 세팅 후 진입쿠키 도메인·만료 관리 복잡
URL 토큰 릴레이어드민 사용자의 토큰을 그대로 전달URL에 토큰 노출 (내부망 한정)

3번을 골랐다. 어드민이 PDF 서비스에 요청할 때 자기 토큰을 Authorization: Bearer 헤더로 보내고, PDF 서비스가 그 토큰을 URL 쿼리로 어드민 print 페이지에 다시 전달한다.

text
어드민 사용자 → POST /generate-pdf-from-admin
              Authorization: Bearer <user-token>
              { testIdList: [...] }
                  │
                  ▼
PDF 서비스 → page.goto("/print/raw/abc?token=<user-token>")
                  │
                  ▼
어드민 print 페이지 → authStore에 token 주입 → getTestSub(token) 호출

이 방식의 장점은 이렇다.

  • "누가 PDF를 요청했나"가 사용자 토큰 한 개로 추적된다. 별도 감사 로그가 필요 없다.
  • 권한이 사용자 본인 권한과 동일하다. 사용자가 못 보는 검사는 PDF로도 못 받는다.
  • 사용자가 로그아웃하면 토큰도 죽는다. 자연스러운 라이프사이클.

단점은 URL에 토큰이 잠깐 노출된다는 거다. 그래서 PDF 서비스는 내부망에서만 호출 가능하고, 어드민 print 페이지는 외부에 노출되지 않는다. 외부망 운영이라면 옵션 1·2를 다시 봐야 한다.

7. 결과

항목BeforeAfter
검사 카드 코드 위치2곳 (Svelte 카드 + EJS 30개 템플릿)1곳 (Svelte 카드만)
새 검사 추가 비용양쪽 동기화Svelte 카드 1번
디자인 변경 비용양쪽 동기화 + 차이 QASvelte 카드 1번
PDF 생성 시간 (단건)~1.5s (이전 글에서 달성)~1.5s (유지)
다중 출력 동시성 제어없음 (외주 코드)concurrency=4 + ZIP 스트리밍
임시 파일디스크 사용메모리 → 스트리밍

가장 큰 이득은 한 곳에서만 그린다는 거다. SVLT-E 카드 3종을 며칠 안에 추가해야 했는데, Svelte 카드만 만들고 PDF는 자동으로 따라잡혔다. 이전 같으면 EJS 3개를 더 만들어야 했을 일이다.

8. 어떤 PDF에 어울리는가

이 방식이 만능은 아니다. 적합한 경우를 정리하면 이렇다.

A안 (Puppeteer × 어드민 URL)이 좋은 경우

  • 어드민 화면 = PDF 출력 (그림이 같음)
  • 데이터 출처가 어드민과 동일
  • 카드/섹션이 자주 추가됨
  • 내부 사용 (URL 토큰 노출 부담 없음)

B안 (별도 PDF 템플릿)이 더 좋은 경우

  • PDF가 화면과 본질적으로 다른 양식 (계약서, 영수증)
  • 외부 발송용 (예: 이메일 첨부) — Puppeteer 인프라 부담 회피
  • 화면 없이 데이터 → PDF만 (/api/contract/12345.pdf)
  • 매우 높은 동시성 (수백 req/s) — 헤드리스 브라우저는 무겁다

이번 케이스는 분명히 A안이었다. 어드민과 PDF가 같은 그림을 그리는데 두 번 그릴 이유가 없었다.

9. 마치며

이 결정에서 얻은 룰을 정리한다.

  1. 같은 그림을 두 번 그리지 마라. 단일 진실의 원천(single source of truth)은 코드뿐 아니라 출력 레이아웃에도 적용된다.
  2. 헤드리스 브라우저는 "준비 완료" 신호로 동기화하라. 시간 기반 대기(setTimeout)는 절대 쓰지 마라. 페이지가 직접 window.__PRINT_READY__를 켜는 패턴이 가장 깔끔하다.
  3. Puppeteer 브라우저는 한 번만 띄워라. 매 요청마다 새로 띄우면 cold start가 응답 시간을 압도한다.
  4. 다중 출력은 동시성 제한 + 스트리밍이 기본기다. 직접 짜도 20줄, 외부 라이브러리도 가능하다. 둘 다 작은 비용이다.
  5. 토큰 릴레이가 가장 단순한 인증 모델이다. 권한 분리가 필요 없는 내부 도구에서 별도 서비스 어카운트보다 깔끔하다.

이번 선택의 바탕에 있던 건 유지보수다. 속도도 이유였지만, 그보다 어드민 카드 하나만 고치면 PDF가 알아서 따라온다는 게 컸다. 같은 그림을 두 곳에서 관리하는 한 언젠가는 둘이 어긋나고, 그 어긋남을 QA가 잡고, 누군가 다시 맞춘다. 그 반복 비용을 없애는 게 이번 작업의 진짜 목적이었다.

그리고 하나 더. PDF는 흔히 백엔드 작업으로 분류되지만 실제로는 프론트엔드 레이아웃 작업이다. 그렇다면 그 작업의 결과물을 빌려오는 게 가장 자연스럽다. 이번 결정의 한 줄 요약은 이거다.

#Puppeteer#PDF#headless browser#아키텍처#SvelteKit#단일 진실의 원천

황호민

Backend Engineer · Java/Kotlin · Spring Boot · Next.js