PdfPreview
시스템의 성능과 코드 품질을 종합적으로 개선한 프로젝트입니다. 총 3단계의 점진적 개선을 통해 사용자 경험과 개발자 경험을 모두 향상시켰습니다.
개선 영역 | Before | After | 개선율 |
---|---|---|---|
컴포넌트 크기 | 638줄 | 166줄 | 📉 76% 감소 |
페이지 전환 속도 | 느림 (매번 재렌더링) | 빠름 (DOM 재사용) | ⚡ 즉시 전환 |
파일 전환 속도 | 매번 새로 로드 | 프리패칭으로 즉시 | 🚀 즉시 전환 |
초기 로딩 | 단일 페이지만 | 인접 페이지 최적화 | ⚖️ 균형 유지 |
메모리 사용량 | 기본 | 효율적 관리 | 💾 최적화 |
코드 복잡도 | 높음 (모놀리식) | 낮음 (모듈화) | 📐 SOLID 준수 |
key={}
속성을 활용하여 컴포넌트 식별 후 DOM 재생성 방지display:none
속성을 활용하여 현재 페이지만 보여짐display: none/block
으로 빠른 전환style={{ display: "none" }}
로 숨겨진 렌더링아래 섹션들에서 각 단계별 개선 과정과 구체적인 코드 예시를 자세히 확인할 수 있습니다:
여러 페이지를 가진 PDF 파일의 경우 페이지 전환 속도 저하 현상 이
페이지 전환 시 발생하는 지연 문제를 해결하기 위해 미리 로딩(Pre-loading) 전략을 고려했습니다.
초기 코드에서는 Document
컴포넌트 내에 Page
컴포넌트를 하나만 렌더링하여 현재 pageNumber
에 해당하는 페이지만 표시했습니다.
export default function PdfImagePreviewArea({
pdfInfo,
currentFileIndex,
pageNumber,
pageCount,
onClickNextPage,
onClickPrevPage,
}: Props) {
// ... (생략된 코드)
if (!pdfInfo) return null;
return (
<S.PdfImagePreviewArea>
<S.PdfImagePreviewTitle>PDF 파일 미리보기</S.PdfImagePreviewTitle>
<S.PdfImageDocumentContainer ref={containerRef}>
<Document
key={`pdf-${currentFileIndex}-${pdfInfo[currentFileIndex]?.id}`}
file={pdfInfo[currentFileIndex].url}
loading={<S.PdfPageLoadingContainer />}
error={<div>PDF 로드 실패</div>}
>
<Page
key={`page-${pageNumber}-${currentFileIndex}`}
width={containerWidth}
height={(containerWidth * 960) / 680}
pageNumber={pageNumber}
renderTextLayer={true}
renderAnnotationLayer={true}
scale={1}
loading={<S.PdfPageLoadingContainer />}
/>
</Document>
</S.PdfImageDocumentContainer>
<S.PageController>
<S.DirectionLeft src={directionRight} onClick={onClickPrevPage} />
<S.PageText>
{pageNumber} / {pageCount}
</S.PageText>
<S.DirectionRight src={directionRight} onClick={onClickNextPage} />
</S.PageController>
</S.PdfImagePreviewArea>
);
}
이 방식은 페이지 전환 시마다 새로운 Page
컴포넌트를 렌더링해야 하므로 전환 속도가 느려지는 원인이 되었습니다.
페이지 전환 속도를 높이기 위해 모든 페이지를 미리 로드하는 방식을 시도했습니다. Array.from({ length: pageCount }).map
을 사용하여 PDF의 모든 페이지를 Document
컴포넌트 내에 렌더링하고, isVisible
속성을 통해 현재 페이지에 해당하는 Page
컴포넌트만 보이도록 처리했습니다.
export default function PdfImagePreviewArea({
pdfInfo,
currentFileIndex,
pageNumber,
pageCount,
onClickNextPage,
onClickPrevPage,
}: Props) {
// ... (생략된 코드)
if (!pdfInfo) return null;
return (
<S.PdfImagePreviewArea>
<S.PdfImagePreviewTitle>PDF 파일 미리보기</S.PdfImagePreviewTitle>
<S.PdfImageDocumentContainer ref={containerRef}>
<Document
key={`pdf-${currentFileIndex}-${pdfInfo[currentFileIndex]?.id}`}
file={pdfInfo[currentFileIndex].url}
loading={<S.PdfPageLoadingContainer />}
error={<div>PDF 로드 실패</div>}
>
{Array.from({ length: pageCount }).map((_, index) => (
<S.PdfImagePageContainer isVisible={index + 1 === pageNumber}>
<Page
width={containerWidth}
height={(containerWidth * 960) / 680}
pageNumber={index + 1}
renderTextLayer={true}
renderAnnotationLayer={true}
scale={1}
loading={<S.PdfPageLoadingContainer />}
/>
</S.PdfImagePageContainer>
))}
</Document>
</S.PdfImageDocumentContainer>
<S.PageController>
<S.DirectionLeft src={directionRight} onClick={onClickPrevPage} />
<S.PageText>
{pageNumber} / {pageCount}
</S.PageText>
<S.DirectionRight src={directionRight} onClick={onClickNextPage} />
</S.PageController>
</S.PdfImagePreviewArea>
);
}
결과: 이 방식은 페이지 전환 속도를 크게 향상시켰습니다. 그러나 모든 페이지를 한 번에 로드하기 때문에 초기 로딩 시간이 훨씬 길어지는 새로운 문제가 발생했습니다. 이는 기존에 1페이지만 로드하던 방식과 달리 모든 페이지를 동시에 불러오기 때문에 리소스 경합이 발생하여 생긴 문제입니다.
리소스 경합 현상
PDF 벡터 데이터 → 파싱 → Canvas 픽셀 변환 → 화면 출력
벡터 명령어 해석: PDF 내부의 벡터 명령어들을 파싱
수학적 계산: 좌표, 크기, 변환 행렬 계산
Canvas 렌더링: 브라우저 Canvas API로 픽셀 데이터 생성
최종 출력: HTML Canvas 요소에 표시
벡터 → 픽셀 변환이 CPU 집약적:
복잡한 수학 연산 (좌표 변환, 곡선 계산)
안티앨리어싱 처리
폰트 렌더링
그래서 여러 페이지를 동시에 벡터→픽셀 변환하면 CPU 병목이 발생하는 것입니다!
결론: PDF는 벡터 기반이고, 이를 픽셀로 변환하는 과정이 무거워서 동시 렌더링 시 성능 저하가 발생합니다.
모든 페이지를 미리 로드하는 방식의 초기 로딩 성능 저하 문제를 해결하기 위해, 현재 페이지와 인접한 페이지(이전, 현재, 다음 페이지)만 미리 로드하는 전략을 채택했습니다. useMemo
훅을 사용하여 렌더링할 페이지 번호를 효율적으로 관리했습니다.
export default function PdfImagePreviewArea({
pdfInfo,
currentFileIndex,
pageNumber,
pageCount,
onClickNextPage,
onClickPrevPage,
}: Props) {
// ... (생략된 코드)
// 현재 페이지와 인접한 페이지들을 계산
const pagesToRender = useMemo(() => {
const pages = new Set<number>();
// 현재 페이지
pages.add(pageNumber);
// 이전 페이지 (존재하는 경우)
if (pageNumber > 1) {
pages.add(pageNumber - 1);
}
// 다음 페이지 (존재하는 경우)
if (pageNumber < pageCount) {
pages.add(pageNumber + 1);
}
return Array.from(pages).sort((a, b) => a - b);
}, [pageNumber, pageCount]);
// ... (생략된 코드)
if (!pdfInfo) return null;
return (
<S.PdfImagePreviewArea>
<S.PdfImagePreviewTitle>PDF 파일 미리보기</S.PdfImagePreviewTitle>
<S.PdfImageDocumentContainer ref={containerRef}>
<Document
key={`pdf-${currentFileIndex}-${pdfInfo[currentFileIndex]?.id}`}
file={pdfInfo[currentFileIndex].url}
loading={<S.PdfPageLoadingContainer />}
error={<div>PDF 로드 실패</div>}
>
{pagesToRender.map((pageNum) => (
<S.PdfImagePageContainer
key={pageNum}
isVisible={pageNum === pageNumber}
>
<Page
width={containerWidth}
height={(containerWidth * 960) / 680}
pageNumber={pageNum}
renderTextLayer={true}
renderAnnotationLayer={true}
scale={1}
loading={<S.PdfPageLoadingContainer />}
/>
</S.PdfImagePageContainer>
))}
</Document>
</S.PdfImageDocumentContainer>
<S.PageController>
<S.DirectionLeft src={directionRight} onClick={onClickPrevPage} />
<S.PageText>
{pageNumber} / {pageCount}
</S.PageText>
<S.DirectionRight src={directionRight} onClick={onClickNextPage} />
</S.PageController>
</S.PdfImagePreviewArea>
);
}
이 개선 방안의 핵심은 React의 key
속성을 활용한 DOM 재사용입니다.
key={pageNum}
을 사용하여 각 Page
컴포넌트를 고유하게 식별합니다. 이로 인해 React는 페이지 번호가 동일한 경우, 새롭게 컴포넌트를 생성하는 대신 기존의 DOM 요소를 재사용하도록 합니다.S.PdfImagePageContainer
의 isVisible
prop을 통해 display: none
또는 display: block
CSS 속성만 변경합니다. 이는 DOM 요소를 삭제하고 재생성하는 것보다 훨씬 효율적입니다.<Page />
컴포넌트는 페이지 전환 시 다시 렌더링할 필요 없이 DOM 요소를 재활용합니다.구분 | 기존 코드 | 개선 코드 1 | 개선 코드 2 |
---|---|---|---|
초기 로딩 | 빠름 | 느림 | 빠름 |
페이지 전환 | 느림 | 빠름 | 빠름 |
메모리 사용량 | 낮음 | 높음 | 중간 |
결론: 개선 코드 2는 초기 로딩 속도를 유지하면서 페이지 전환 성능을 크게 개선했습니다. 또한, 필요한 인접 페이지만 로드하고 DOM을 재활용함으로써 메모리 사용량도 절약할 수 있었습니다.
페이지 내 전환 성능 개선 후, 파일 간 전환 시 발생하는 성능 문제를 발견했습니다. 사용자가 여러 PDF 파일을 순차적으로 확인할 때, 다음 파일로 넘어갈 때마다 새로운 PDF 파일을 로드해야 하므로 지연이 발생하는 문제였습니다.
파일 간 전환 시 발생하는 지연 문제를 해결하기 위해 이전/다음 파일 프리패칭 전략을 구현했습니다.
파일 간 전환 성능 개선 전에는 현재 파일의 PDF만 렌더링하여, 파일 전환 시 새로운 PDF를 처음부터 로드해야 했습니다.
export default function PdfImagePreviewArea({
pdfInfo,
currentFileIndex,
pageNumber,
pageCount,
onClickNextPage,
onClickPrevPage,
}: Props) {
// ... (생략된 코드)
if (!pdfInfo) return null;
return (
<S.PdfImagePreviewArea>
<S.PdfImagePreviewTitle>PDF 파일 미리보기</S.PdfImagePreviewTitle>
<S.PdfImageDocumentContainer ref={containerRef}>
{/* 현재 PDF 파일만 렌더링 */}
<Document
key={`pdf-${currentFileIndex}-${pdfInfo[currentFileIndex]?.id}`}
file={pdfInfo[currentFileIndex].url}
loading={<S.PdfPageLoadingContainer />}
error={<div>PDF 로드 실패</div>}
>
{pagesToRender.map((pageNum) => (
<S.PdfImagePageContainer
key={pageNum}
$isVisible={pageNum === pageNumber}
>
<Page
width={containerWidth}
height={(containerWidth * 960) / 680}
pageNumber={pageNum}
renderTextLayer={true}
renderAnnotationLayer={true}
scale={1}
loading={<S.PdfPageLoadingContainer />}
/>
</S.PdfImagePageContainer>
))}
</Document>
</S.PdfImageDocumentContainer>
{/* ... (생략된 코드) */}
</S.PdfImagePreviewArea>
);
}
이 방식은 파일 전환 시마다 새로운 PDF 파일을 처음부터 로드해야 하므로 전환 속도가 느려지는 원인이 되었습니다.
파일 간 전환 속도를 높이기 위해 이전 파일과 다음 파일을 미리 로드하는 전략을 구현했습니다. style={{ display: "none" }}
을 사용하여 프리패칭된 파일들을 숨겨진 상태로 렌더링하고, 파일 전환 시 즉시 표시할 수 있도록 했습니다.
export default function PdfImagePreviewArea({
pdfInfo,
currentFileIndex,
pageNumber,
pageCount,
onClickNextPage,
onClickPrevPage,
}: Props) {
// ... (생략된 코드)
// 이전 파일 인덱스 계산
const previousFileIndex = useMemo(() => {
return currentFileIndex !== 0 ? currentFileIndex - 1 : null;
}, [currentFileIndex]);
const previousFilePageCount =
previousFileIndex && pdfInfo[previousFileIndex].pageCount;
// 다음 파일 인덱스 계산
const nextFileIndex = useMemo(() => {
return currentFileIndex + 1 < pdfInfo.length ? currentFileIndex + 1 : null;
}, [currentFileIndex, pdfInfo.length]);
if (!pdfInfo) return null;
return (
<S.PdfImagePreviewArea>
<S.PdfImagePreviewTitle>PDF 파일 미리보기</S.PdfImagePreviewTitle>
<S.PdfImageDocumentContainer ref={containerRef}>
{/* 이전 PDF 파일 프리패칭 (숨겨진 상태) */}
<div
key={
(previousFileIndex !== null && pdfInfo[previousFileIndex]?.url) ||
""
}
style={{ display: "none" }}
>
{previousFileIndex !== null && (
<Document
key={`pdf-${previousFileIndex}-${pdfInfo[previousFileIndex]?.id}`}
file={pdfInfo[previousFileIndex].url}
loading={null}
error={null}
>
{previousFilePageCount !== null &&
[previousFilePageCount].map((previousFilePageCount) => {
const previousFileLastPageNum = previousFilePageCount + 1;
return (
<S.PdfImagePageContainer
key={previousFileLastPageNum}
$isVisible={previousFileLastPageNum === pageNumber}
>
<Page
width={containerWidth}
height={(containerWidth * 960) / 680}
pageNumber={previousFileLastPageNum}
renderTextLayer={true}
renderAnnotationLayer={true}
scale={1}
loading={<S.PdfPageLoadingContainer />}
/>
</S.PdfImagePageContainer>
);
})}
</Document>
)}
</div>
{/* 현재 PDF 파일 */}
<div key={pdfInfo[currentFileIndex].url}>
<Document
key={`pdf-${currentFileIndex}-${pdfInfo[currentFileIndex]?.id}`}
file={pdfInfo[currentFileIndex].url}
loading={<S.PdfPageLoadingContainer />}
error={<div>PDF 로드 실패</div>}
>
{pagesToRender.map((pageNum) => (
<S.PdfImagePageContainer
key={pageNum}
$isVisible={pageNum === pageNumber}
>
<Page
width={containerWidth}
height={(containerWidth * 960) / 680}
pageNumber={pageNum}
renderTextLayer={true}
renderAnnotationLayer={true}
scale={1}
loading={<S.PdfPageLoadingContainer />}
/>
</S.PdfImagePageContainer>
))}
</Document>
</div>
{/* 다음 PDF 파일 프리패칭 (숨겨진 상태) */}
<div
key={nextFileIndex && pdfInfo[nextFileIndex]?.url}
style={{ display: "none" }}
>
{nextFileIndex !== null && (
<Document
key={`pdf-${nextFileIndex}-${pdfInfo[nextFileIndex]?.id}`}
file={pdfInfo[nextFileIndex].url}
loading={null}
error={null}
>
{[1, 2].map((pageNum) => (
<S.PdfImagePageContainer
key={pageNum}
$isVisible={pageNum === pageNumber}
>
<Page
width={containerWidth}
height={(containerWidth * 960) / 680}
pageNumber={pageNum}
renderTextLayer={true}
renderAnnotationLayer={true}
scale={1}
loading={<S.PdfPageLoadingContainer />}
/>
</S.PdfImagePageContainer>
))}
</Document>
)}
</div>
</S.PdfImageDocumentContainer>
{/* ... (생략된 코드) */}
</S.PdfImagePreviewArea>
);
}
이 개선 방안의 핵심은 백그라운드에서 인접 파일들을 미리 로드하는 것입니다.
previousFileIndex
가 존재하는 경우, 이전 파일의 마지막 페이지를 미리 로드합니다. 이는 사용자가 이전 파일로 돌아갈 때 즉시 표시할 수 있도록 합니다.nextFileIndex
가 존재하는 경우, 다음 파일의 첫 번째와 두 번째 페이지를 미리 로드합니다. 이는 사용자가 다음 파일로 넘어갈 때 빠른 전환을 가능하게 합니다.style={{ display: "none" }}
을 사용하여 프리패칭된 파일들을 화면에 표시하지 않으면서도 백그라운드에서 로드합니다.loading={null}
과 error={null}
을 사용하여 로딩 인디케이터를 표시하지 않습니다.구분 | 기존 코드 | 개선 코드 |
---|---|---|
파일 전환 속도 | 느림 | 빠름 |
초기 로딩 | 빠름 | 빠름 |
메모리 사용량 | 낮음 | 중간 |
사용자 경험 | 끊김 있음 | 끊김 없음 |
결론: 파일 간 프리패칭을 통해 파일 전환 시 발생하는 지연을 크게 개선했습니다. 사용자가 여러 PDF 파일을 순차적으로 확인할 때 끊김 없는 부드러운 경험을 제공할 수 있게 되었습니다. 또한, 필요한 페이지만 프리패칭함으로써 메모리 사용량도 적절히 관리할 수 있었습니다.