[kakao tech bootcamp] React 컴포넌트를 PDF로 만들기(react-pdf(O), react-to-pdf(X))
2024.11.12- -
본 포스팅은 '카카오 테크 부트캠프'에서 진행한 프로젝트인 '깃트폴리오'를 진행하면서 겪었던 트러블 중 react-pdf과 관련된 내용을 바탕으로 작성하였습니다.
1. 들어가며
'깃트폴리오'에서는 사용자의 이력서를 자동으로 생성해주며 AI와 같이 수정해 나가면서 최종적으로 나에게 온전히 맞춰진 이력서를 손쉽게 만들어 주는 것에 초점을 두고 있습니다.
'이력서를 만들어준다'라는 것은 AI를 통해 만들어진 이력서의 내용을 단순히 보는 것에 그치는 것이 아니라 해당 이력서를 pdf 등의 문서로 저장하는 것까지 포함해야 했습니다.
react 컴포넌트를 pdf로 저장하는 기능을 구현하려는 분들에게 제가 대신 겪었던 문제와 해결 방법을 공유하여 이 글이 조그만 이정표가 되어줬으면 하는 바램으로 글을 작성해보았습니다.
2. 비슷한 걸 해본 적이 있었나?
제가 이 기능을 구현하기 시작하면서 이와 비슷한 기능을 구현해본 적이 있었는지를 생각해봤는데 정확히 같은 기능을 구현해 본 것은 아니지만 비슷한 요구사항에 대해 구현을 해본 적이 있었다는 사실이 떠올랐습니다.
이미지 캡쳐 기능
진행했던 프로젝트 중에 특정 화면에서 이미지 캡쳐 기능을 구현해야 했던 적이 있었는데, 이때는 특정 태그에 ref를 지정하여 해당 태그 하위에 대한 부분이 이미지로 저장되도록 기능을 구현했었습니다.
사실상 pdf도 여러 이미지를 묶어낸 파일에 그치지 않기 때문에 이때 사용했던 라이브러리와 같거나 유사한 라이브러리를 사용할 것이라는 짐작을 했었습니다.
PDF 렌더링
또한 pdf와 관련해서는 이번 요구사항과는 반대로 PDF 파일을 가져오면 이를 화면에 렌더링하는 기능이었습니다. 이 기능 역시 좋은 라이브러리 덕에 어렵지 않게(물론 pdf를 넘기는 버튼이나 전체화면과 관련해서는 많은 문제를 겪었지만) 구현을 한 기억이 있습니다.
기존에 구현했던 기능들이 구현가능 했던 걸 보면 이번 요구사항도 어렵지 않게 구현할 수 있을 것이라는 안일한 생각을 해버렸습니다.
이를 구현하면서 굉장히 이런 저런 트러블을 많이 겪었는데 하나의 요구사항을(react 컴포넌트를 pdf로 저장한다) 중심으로 해결한 과정에 대해 소개해드리고자 합니다.
3. 첫 번째 시도: react-to-pdf
제가 찾아봤던 라이브러리들 중 가장 먼저 눈에 띄었던 것은 react-to-pdf라는 라이브러리였습니다. 물론 기존에 pdf 렌더링 기능을 구현하면서 사용한 react-pdf라는 라이브러리가 있었지만 이 라이브러리는 당연히 렌더링만 해주는 거겠지? 라는 멍청한 생각으로 알아보지도 않고 처음부터 배제를 시켰습니다. (나 자신 저주해~)
해당 라이브러리는 weekly downloads 11,000건 정도에 레퍼런스도 꽤 많이 있던 라이브러리라 곧 바로 사용하기에 큰 문제가 없었습니다. 특히 react hooks 기반의 인터페이스를 통해 간편한 사용을 할 수 있다는 장점이 있기도 합니다.
react-to-pdf 예시 코드:
import { usePDF } from 'react-to-pdf';
const Component = () => {
const { toPDF, targetRef } = usePDF({filename: 'page.pdf'});
return (
<div>
<button onClick={() => toPDF()}>Download PDF</button>
<div ref={targetRef}>
Content to be generated to PDF
</div>
</div>
)
}
위 코드를 보면 알 수 있듯 사용법이 굉장히 간단합니다. pdf 저장을 원하는 태그 부분의 ref에 usePDF로부터 받아온 targetRef를 넣어주고 버튼에 toPDF 핸들러를 달아주기만 하면 됩니다.
기존에 만들어 두었던 이력서 화면에서 해당 기능을 테스트 해 본 결과 다음과 같이 pdf로 저장이 잘 되는 것을 확인할 수 있었습니다.
하지만 몇 가지 문제점이 존재했습니다.
이미지가 왼쪽 윗 부분을 잘라서 보여주는 문제
위 이력서에 들어가는 유저의 프로필 사진 부분을 보면 사진이 이상하게 잘려져 있는 것을 확인할 수 있습니다. 사실 이 문제는 앞서 말씀 드렸던 이미지 캡쳐 기능을 구현했던 프로젝트에서도 발생했던 문제였는데요.
해당 문제는 제가 사용했던 라이브러리(html2canvas) 자체의 문제로 'htmlToImage' 라이브러리로 바꾸어 해결할 수 있었습니다.
지금에서야 알게된 사실이지만 이 react-to-pdf 라이브러리 자체에서 요구되는 라이브러리 중에 html2canvas가 있어 동일한 문제 현상이 발생했던 것으로 파악하였습니다.
페이지가 넘어가면서 여백이 없어 내용이 잘려보이는 문제
또 다른 문제는 이력서의 내용이 A4 한장의 크기가 기본 설정되어 있기 때문에 그보다 많은 내용을 담게 되면 다음 페이지로 넘어가 내용이 표시가 되는데 이 때 페이지 상,하단 여백이 존재하지 않아 위 사진과 같이 내용이 부자연스럽게 표시가 되는 문제가 있었습니다.
react-to-pdf 라이브러리 내의 margin 설정이 있긴 했지만 제 경우에 유효하게 문제를 해결해주지는 못했습니다.
PDF 에 텍스트가 인식이 되지 않음 (이미지니까)
이 문제는 사실 이전부터 예상했던 것이긴 하지만 우선 구현에 초점을 맞추어 진행을 했었기 때문에 잠깐 넘어갔던던 문제입니다. 위 문제들은 어떻게 잘 라이브러리를 커스텀하고 해결 방법을 잘 찾아낼 수 있다고 생각을 하고 있었지만 이 텍스트가 인식되지 않는 문제는 해당 라이브러리를 사용하는 이상 절대 해결할 수 없는 문제였습니다.
그 이유는 앞서 잠깐 나왔던 html2canvas 라이브러리 자체가 이미지를 캡쳐하여 jspdf라는 라이브러리를 통해 pdf로 만들어 주었던 것이기 때문입니다.
사실 이력서에서 프로필 사진은 사실상 필수라고 봐야하기 때문에 빠지면 안되는 굉장히 중요한 요소라고 볼 수도 있습니다.
하지만 이 방식의 장점은 이미지를 그대로 캡쳐하는 것이기 때문에 폰트가 깨질 일이 없다는 것인데 이것 하나 때문에
그 뿐만 아니라 이력서를 pdf로 제출 시 본인과 관련된 링크나 포트폴리오 링크를 제출하는 경우가 다반사인데 이미지로 되어 있는 경우 해당 링크 주소에 접속할 수 없다는 문제가 있기도 했습니다.
4. 두 번째 시도: react-pdf
사실 이게 정답인 걸 마음속에서는 알면서도 사용 방법이 굉장히 복잡해 보여 모른체 하고 있었던 것도 사실이었습니다.
사실 처음에 react-to-pdf를 사용해서 뽑아낸 결과를 봤을 때, 꽤 만족스러운 결과를 얻어서 이대로 디벨롭을 해야 하나 고민을 하기도 했지만 위에서 언급한 문제들이 너무 크리티컬한 문제들이라 다른 라이브러리를 찾아볼 수밖에 없었습니다.
react-to-pdf 깃허브 README에 보면 대안 라이브러리로 @react-pdf/renderer와 react-pdf를 소개하고 있습니다.
두 라이브러리 모두 같은 목적을 위해서 만들어진 것 같은데 github 페이지에 들어가 각 라이브러리에 대한 소개를 읽어보면 그냥 react-pdf는 기존의 pdf를 렌더링하는 데 사용되는 반면, @react-pdf/renderer를 사용하면 React로 PDF를 생성할 수 있다고 나와있습니다. (서로의 레파지토리에서 서로를 홍보하고 있음)
사용법은 여기를 참조하면 되는데 문서 자체가 그렇게 친절하지는 않아 원하는 요구사항을 구현하는 데 조금 애를 먹을 수 있긴한데, example 코드를 보면서 조금씩 따라하다 보면 만들어지는 것을 볼 수 있습니다.
import React from 'react';
import { Page, Text, View, Document, StyleSheet } from '@react-pdf/renderer';
// Create styles
const styles = StyleSheet.create({
page: {
flexDirection: 'row',
backgroundColor: '#E4E4E4'
},
section: {
margin: 10,
padding: 10,
flexGrow: 1
}
});
// Create Document Component
const MyDocument = () => (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.section}>
<Text>Section #1</Text>
</View>
<View style={styles.section}>
<Text>Section #2</Text>
</View>
</Page>
</Document>
);
코드 구조가 처음 봤을 때는 조금 복잡해 보일 수 있지만 구조를 알고 나면 굉장히 합리적이고 직관적인 방식이라는 것을 알 수 있습니다.
해당 라이브러리에서는 tailwind css 가 먹히지 않기 때문에 어쩔 수 없이 위 코드와 같이 StyleSheet와 결합하여 사용한 것을 보실 수 있습니다.
우선 StyleSheet로라도 구현한 코드를 먼저 보면 다음과 같습니다.
import React from "react";
import { saveAs } from "file-saver";
import { pdf } from "@react-pdf/renderer";
import { Button } from "@/components/ui/button";
import ResumeDocument from "./ResumeDocument";
import { ResumeDetailResponse } from "../../../community/_hooks/useResumeQuery";
import { FileText } from "lucide-react";
type Props = {
resume: ResumeDetailResponse;
};
const PdfDownloadButton = ({ resume }: Props) => {
const downloadPdf = async () => {
const fileName = `${resume.result.memberName}_이력서_깃트폴리오`;
const blob = await pdf(<ResumeDocument resume={resume} />).toBlob();
saveAs(blob, fileName);
};
return (
<Button className="flex gap-2 bg-red-500 pr-6" onClick={downloadPdf}>
<FileText />
<div>pdf로 저장</div>
</Button>
);
};
export default PdfDownloadButton;
ResumeDocument는 분량이 너무 길어 아래 링크를 참고하여 만들면 됩니다.
그 결과를 한 번 볼까요?
정말 놀랍게도 위에서 발생했던 문제들(이미지 잘림, 페이지 여백, 이미지라 텍스트 인식 하지 못함)을 한 번에 모두 해결한 모습을 볼 수 있습니다. 그냥 react를 다루면서 pdf와 관련된 작업을 해야한다 하면 사실상 reat-pdf를 사용하는 것이 제일 현명한 선택이 아닐까 싶습니다.
하지만 이상한 점들이 한 두 가지가 보이는데 먼저 스타일링이 조금 아쉽다는 것과 폰트가 화면에서 보여주던 Pretendard 폰트와 다르다는 점이었습니다.
tailwind를 사용하여 스타일링을 하고 싶었기 때문에 tailwind와 결합된 라이브리러리가 있는지 찾아다녔고 역시나 어느 멋진 분이 만들어주신 라이브러리(react-pdf-tailwind)가 있었습니다.
5. 폰트
폰트를 적용하는 방식은 굉장히 간단합니다. 우선 공식문서에서도 자세히 나와있어 참고하시면 좋을 것 같습니다.
type FontWeight = "normal" | "bold" | "light" | undefined;
Font.register({
family: "Pretendard",
fonts: [
{
src: "../fonts/Pretendard-Regular.ttf",
fontWeight: "300" as FontWeight,
},
{
src: "../fonts/Pretendard-SemiBold.ttf",
fontWeight: "600" as FontWeight,
},
{
src: "../fonts/Pretendard-Bold.ttf",
fontWeight: "700" as FontWeight,
},
],
});
react-pdf에서 폰트가 적용되는 방식은 폰트를 외부 소스로부터 다운로드 받거나 현재 next.js 서버에서 서빙해주는 폰트를 다운로드 받아 사용하는 방식입니다.
방식은 다음과 같습니다.
적용하고자 하는 폰트 파일을 정적 파일을 서빙하는 public/ 아래에 위치시킵니다. 그 후 위 코드에서처럼 src를 지정하는데, 이 때 경로는 서빙되는 statisc asset의 위치에 대해 현재 페이지로부터 상대 경로를 적어주면됩니다.
예를 들어 현재 제가 /resume 페이지에 있었기 때문에 ../fonts/... 와 같이 명시해주었던 것입니다.
6. tailwind 스타일링
react-pdf-tailwind가 스타일링을 지원한다고 하긴 하지만 존재하지 않는 alias가 꽤 많이 있으니 기본적인 것들로만 활용을 하도록 해서 구현하였습니다.
예시 코드는 아래와 같습니다.
import {
...
Image as PImage,
...
} from "@react-pdf/renderer";
...
<Document>
<Page size="A4" style={tw("p-8 px-32 font-pretendard")}>
{/* 헤더 */}
<View style={tw("flex flex-row mb-2")}>
<View style={tw("flex-1")}>
<Text style={tw("text-3xl font-bold")}>
{resume.result.memberName}
</Text>
<Text style={tw("text-lg")}>
{positionTypeMap[resume.result.position as PositionType]} 개발자
</Text>
</View>
<PImage
style={tw("w-52 h-52 rounded-lg")}
src={resume.result.avatarUrl}
/>
</View>
...
여기서 또 주의해야 할 점은 react-pdf에서 이미지 컴포넌트를 pdf에 임베딩하기 위해 <Image/>와 같이 사용하는데 이는 Next.js의 Image 컴포넌트와 이름이 같기 때문에 오류를 방지하기 위해 react-pdf의 Image의 이름을 PImage로 바꾸어 사용한 것입니다.
7. 완성
존재하지 않는 속성이 있다보니까 스타일링 하는 부분이 굉장히 어려웠는데 하나씩 차근차근 구현을 하였습니다. 게다가 적용이 잘 되었는지 확인을 하려면 페이지를 한 번 새로고침을 한 다음, pdf로 저장을 하고 pdf를 연 다음 확인해야 한다는 점이 굉장히 힘들었습니다.
이를 조금 더 효율적으로 하려면 한 화면에 렌더링 하는 부분을 추가하여 어떤 모습으로 보이게 될 지 미리 확인하면서 개발을 진행한다면 더 편해질 수 있을 것 같아 다음번에 해당 부분을 추가해볼 예정입니다.
'Front-end' 카테고리의 다른 글
[React] Zustand Deep DIVE 하기 (2) | 2024.11.15 |
---|---|
[Next.js] Sentry로 효율적인 에러 모니터링하는 법(자동화 에러 추적 도구) (2) | 2024.11.14 |
OpenAI(ChatGPT)가 Next.js에서 Remix로 전환한 이유 (1) | 2024.11.11 |
[React] Redux가 좋은 경우 vs Zustand가 좋은 경우 (0) | 2024.11.10 |
[kakao tech bootcamp] React에서 드래그한 텍스트를 관리 하는 방법 (text selection, getSelection API) (8) | 2024.11.08 |
소중한 공감 감사합니다