PDF 서버를 Node.js로 분리한 이유

Junyoung·2026년 3월 24일

Virgin Road

목록 보기
4/5

배경

Virgin Road는 결혼식 당일 하객들이 쓴 편지를 PDF로 만들어 신랑/신부에게 이메일로 보낸다. 스케줄러가 매일 19시에 그날 결혼식인 방을 조회해서 PDF를 생성하고 발송한다.

PDF 생성은 처음부터 Java 안에서 구현하려고 했다. Spring Boot 안에서 처리하면 별도 서버 없이 깔끔하니까.


Flying Saucer로 처음 구현

Java에서 PDF 생성 라이브러리를 검토했다.

  • iText - AGPL 라이선스라 상용 서비스엔 사실상 유료
  • Apache PDFBox - 저수준 API, 좌표 직접 계산해야 함
  • Flying Saucer - HTML을 CSS로 스타일링해서 PDF로 변환

Flying Saucer를 선택했다. HTML + CSS로 레이아웃을 짜면 디자인하기 편할 것 같았다.

실제로 써보니 문제가 한두 가지가 아니었다.

한글 폰트 처리가 까다로웠다. Flying Saucer는 폰트를 직접 등록해야 하는데, 한글 폰트 경로 설정과 CSS font-family 연동이 생각처럼 되지 않았다.

CSS 지원 수준이 낮았다. Flying Saucer가 지원하는 CSS는 굉장히 제한적이다. 현대적인 레이아웃(flexbox 등)은 쓸 수 없고, 오래된 CSS 박스 모델만 지원한다. 편지 카드를 예쁘게 배치하려는데 레이아웃이 계속 틀어졌다.

결국 Java 안에서 PDF 생성하는 걸 포기하고 다른 방법을 찾았다.


react-pdf 발견

@react-pdf/renderer는 JSX로 PDF 레이아웃을 작성할 수 있는 Node.js 라이브러리다. Flexbox 기반 레이아웃을 지원하고, 폰트 등록도 간단하다.

Font.register({
  family: 'NanumGothicCoding',
  fonts: [
    { src: fonts('NanumGothicCoding.ttf'), fontWeight: 'normal' },
    { src: fonts('NanumGothicCoding-Bold.ttf'), fontWeight: 'bold' },
  ],
})

레이아웃은 React 컴포넌트 짜듯이 만든다.

<Document>
  <Page style={styles.page}>
    <CoverPage groomName={groomName} brideName={brideName} weddingDate={weddingDate} />
    <AuthorsPage authors={authors} />
    {letterChunks.map((chunk, i) => (
      <LettersPage key={i} letters={chunk} />
    ))}
  </Page>
</Document>

Flying Saucer로 씨름하던 한글 폰트도, 편지 카드 레이아웃도 훨씬 수월하게 해결됐다. 문제는 이게 JavaScript 라이브러리라는 것이다.


그냥 Spring 안에 넣으면 안 됐나

react-pdf가 Node.js 라이브러리라서 어쩔 수 없이 분리한 것도 있지만, 사실 억지로 JVM 위에서 돌릴 방법이 없는 건 아니다. GraalVM으로 JS를 실행하거나, Kotlin/JS를 쓰거나. 그런데 굳이 그렇게 할 이유가 없었다.

PDF 디자인은 자주 바뀐다. 편지 카드 레이아웃, 폰트, 배경 이미지, 페이지 구성 등은 실제 결혼식 PDF를 보면서 계속 다듬을 수밖에 없다. 만약 PDF 코드가 Spring 프로젝트 안에 있었다면 디자인 수정할 때마다 Spring 서버를 빌드하고 재배포해야 한다.

Spring 서버 재배포는 리스크가 있다. Gradle 빌드 시간도 있고, 재시작 중 서비스가 잠깐 끊기는 구간도 생긴다. PDF 레이아웃 조금 바꾸자고 API 서버 전체를 내렸다 올리는 건 낭비다.

분리하면 PDF 서버만 독립적으로 배포할 수 있다. Spring 서버는 건드리지 않고 npm start 하나로 PDF 서버만 교체된다. API 서버 입장에서는 PDF 서버가 어떻게 생겼는지 알 필요가 없다. HTTP 요청 보내고 바이트 받으면 끝이다.


Express 서버로 분리

역할은 단순하게 잡았다. JSON 받아서 PDF 바이트 반환.

// server.jsx
app.post('/pdf/letters', async (req, res) => {
  const { groomName, brideName, weddingDate, letters } = req.body

  const buffer = await renderToBuffer(
    <WeddingPDF
      groomName={groomName}
      brideName={brideName}
      weddingDate={weddingDate}
      letters={letters}
    />
  )

  res.set('Content-Type', 'application/pdf')
  res.send(buffer)
})

app.listen(3001)

Spring 백엔드에서는 RestClient로 이 서버를 호출한다.

// PdfClient.java
public byte[] generatePdf(PdfRequest request) {
    return restClient.post()
            .uri("/pdf/letters")
            .contentType(MediaType.APPLICATION_JSON)
            .body(request)
            .retrieve()
            .body(byte[].class);
}

Spring 입장에서 PDF 서버는 그냥 HTTP API 하나다. 편지 데이터를 조합해서 보내고, 바이트 배열로 받아서 이메일에 첨부한다.


인프라 구성

이 서비스는 클라우드 서버 없이 Mac Mini 홈 서버 위에서 돌아간다. 홈 서버 구축 과정은 홈 서버 구축 일지 시리즈에 따로 정리해뒀다.

PDF 서버도 동일한 홈 서버 위에 Docker 컨테이너로 띄웠다. Spring Boot 백엔드, MySQL, Redis와 같은 Docker 네트워크 안에서 통신한다.

back/   (Spring Boot, :8080)         ─┐
pdf/    (Express + react-pdf, :3001)  ─┤ 같은 Docker 네트워크 (Mac Mini 홈 서버)
mysql/  (:3306)                      ─┤
redis/  (:6379)                      ─┘

PDF 서버는 외부에 노출하지 않는다. Caddy 리버스 프록시를 통해 Spring 백엔드만 외부로 열려 있고, PDF 서버는 내부 네트워크에서만 접근 가능하다.

배포는 GitHub Actions로 자동화했다. main 브랜치에 푸시하면 Tailscale SSH를 통해 홈 서버에 접속해서 컨테이너를 교체한다. PDF 서버도 별도 워크플로우로 동일하게 배포된다.


정리

처음엔 Java 안에서 해결하려고 했는데 Flying Saucer의 한계로 포기했다. react-pdf는 JSX로 레이아웃을 짤 수 있어서 디자인하기가 압도적으로 편했다. 서버를 하나 더 띄우는 게 복잡해 보이지만, 역할이 명확히 분리되어 오히려 관리하기 쉬웠다. Spring은 비즈니스 로직, PDF 서버는 렌더링만 담당한다.

홈 서버 위에서 운영하는 구체적인 인프라 구성은 홈 서버 구축 일지를 참고.

profile
라곰

0개의 댓글