관리자프로그램 PDF 렌더링 성능 개선(1)

이승훈·2025년 8월 7일
0

시행착오

목록 보기
28/28
post-thumbnail

배경 설명

  • 컴포넌트 구성
    • 위 이미지 처럼 생긴 컴포넌트가 문제의 컴포넌트 입니다.
    • 왼쪽 부분은 파일들이 목록으로 보여지고 각 파일들은 여러 페이지로 이루어져 있습니다.
    • 오른쪽 부분은 선택된 PDF 파일의 각각의 페이지들을 미리보기로 보여줍니다.
  • 주된 사용자 패턴
    • 주로 사용자들은 최상단 파일에서 시작하여 키보드 오른쪽 키 누르면서 순서대로 PDF 페이지를 넘기며 봅니다.
    • 선택된 PDF 파일의 마지막 페이지에서 오른쪽 화살표 누르면 다음 파일로 넘어갑니다.(아래 파일의 1페이지로 이동)
  • 사용자 콤퓨타 환경
    • 근데 사용자 콤퓨타의 RAM이 2GB네?(병원에서 쓰는 콤퓨타임)
    • 네트워크 환경도 개구립니다.
    • PDF 하나 그리는데 한세월 걸립니다.

0. PDF 미리보기 성능 개선

📋 전체 개선 개요

PdfPreview 시스템의 성능과 코드 품질을 종합적으로 개선한 프로젝트입니다. 총 3단계의 점진적 개선을 통해 사용자 경험과 개발자 경험을 모두 향상시켰습니다.

🎯 주요 성과

개선 영역BeforeAfter개선율
컴포넌트 크기638줄166줄📉 76% 감소
페이지 전환 속도느림 (매번 재렌더링)빠름 (DOM 재사용)⚡ 즉시 전환
파일 전환 속도매번 새로 로드프리패칭으로 즉시🚀 즉시 전환
초기 로딩단일 페이지만인접 페이지 최적화⚖️ 균형 유지
메모리 사용량기본효율적 관리💾 최적화
코드 복잡도높음 (모놀리식)낮음 (모듈화)📐 SOLID 준수

🔄 3단계 개선 프로세스

🎪 핵심 기술 변경 내용

1️⃣ 페이지 전환 최적화 - DOM 재사용 전략

  • React Key 기반 DOM 재사용: key={} 속성을 활용하여 컴포넌트 식별 후 DOM 재생성 방지
  • 인접 페이지 프리로딩: 이전/현재/다음 페이지만 렌더링 후 css display:none 속성을 활용하여 현재 페이지만 보여짐
  • CSS 기반 표시/숨김: display: none/block으로 빠른 전환

2️⃣ 파일 간 전환 최적화 - 프리패칭 전략

  • 백그라운드 프리패칭: style={{ display: "none" }}로 숨겨진 렌더링
  • 이전 파일: 마지막 페이지 프리로드
  • 다음 파일: 첫 1-2페이지 프리로드
  • 메모리 효율성: 필요한 페이지만 선택적 로딩

3️⃣ SOLID 원칙 리팩토링 - 아키텍처 개선

  • SRP: 각 훅이 단일 책임 (상태관리, 데이터페칭, 네비게이션 등)
  • OCP: 새 기능 추가 시 기존 코드 수정 없이 확장
  • ISP: 필요한 기능만 제공하는 인터페이스 설계
  • DIP: 추상화에 의존하는 테스트 가능한 구조

💡 기술적 우수성

  1. 성능 최적화: React의 렌더링 사이클을 이해한 최적화
  2. 메모리 관리: 필요한 만큼만 로드하는 효율적 메모리 사용
  3. 개발자 경험: 각 모듈의 역할이 명확한 직관적 코드 구조
  4. 유지보수성: 기능 변경 시 영향 범위가 명확한 모듈화
  5. 테스트 가능성: 각 훅을 독립적으로 테스트할 수 있는 구조
  6. 타입 안전성: TypeScript를 활용한 강력한 타입 체크

🔍 상세 내용

아래 섹션들에서 각 단계별 개선 과정과 구체적인 코드 예시를 자세히 확인할 수 있습니다:

  • 1단계: 페이지 전환 최적화 (DOM 재사용 전략)
  • 2단계: 파일 간 전환 최적화 (프리패칭 전략)
  • 3단계: SOLID 원칙 리팩토링 (아키텍처 혁신)

1. PDF 미리보기 성능 개선: 페이지 전환 최적화

여러 페이지를 가진 PDF 파일의 경우 페이지 전환 속도 저하 현상

문제점

  1. 페이지 전환 속도 저하: 여러 페이지로 구성된 파일의 경우, 페이지 전환 시 다음 페이지 로딩 속도가 현저히 느렸습니다.

🚀 페이지 전환 속도 저하 문제 해결 과정

페이지 전환 시 발생하는 지연 문제를 해결하기 위해 미리 로딩(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 컴포넌트를 렌더링해야 하므로 전환 속도가 느려지는 원인이 되었습니다.

개선 시도 1: 모든 페이지 미리 로드

페이지 전환 속도를 높이기 위해 모든 페이지를 미리 로드하는 방식을 시도했습니다. 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페이지만 로드하던 방식과 달리 모든 페이지를 동시에 불러오기 때문에 리소스 경합이 발생하여 생긴 문제입니다.

리소스 경합 현상

  1. PDF.js Worker 스레드 경합: 여러 페이지가 동시에 worker에 작업 요청
  2. Canvas 렌더링 병목: 브라우저 렌더링 엔진의 동시 처리 한계
  3. 메모리 대역폭: 여러 PDF 페이지 데이터를 동시에 처리
  4. CPU 코어 점유: 벡터 그래픽 렌더링 작업들이 병렬 실행
  • PDF.js 렌더링 과정 PDF 벡터 데이터 → 파싱 → Canvas 픽셀 변환 → 화면 출력
    1. 벡터 명령어 해석: PDF 내부의 벡터 명령어들을 파싱

    2. 수학적 계산: 좌표, 크기, 변환 행렬 계산

    3. Canvas 렌더링: 브라우저 Canvas API로 픽셀 데이터 생성

    4. 최종 출력: HTML Canvas 요소에 표시

      성능 영향

      벡터 → 픽셀 변환이 CPU 집약적:

    • 복잡한 수학 연산 (좌표 변환, 곡선 계산)

    • 안티앨리어싱 처리

    • 폰트 렌더링

      그래서 여러 페이지를 동시에 벡터→픽셀 변환하면 CPU 병목이 발생하는 것입니다!

      결론: PDF는 벡터 기반이고, 이를 픽셀로 변환하는 과정이 무거워서 동시 렌더링 시 성능 저하가 발생합니다.

개선 시도 2: 인접 페이지 미리 로드 및 DOM 재활용

모든 페이지를 미리 로드하는 방식의 초기 로딩 성능 저하 문제를 해결하기 위해, 현재 페이지와 인접한 페이지(이전, 현재, 다음 페이지)만 미리 로드하는 전략을 채택했습니다. 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 재사용

이 개선 방안의 핵심은 React의 key 속성을 활용한 DOM 재사용입니다.

  • Key를 통한 컴포넌트 식별: key={pageNum}을 사용하여 각 Page 컴포넌트를 고유하게 식별합니다. 이로 인해 React는 페이지 번호가 동일한 경우, 새롭게 컴포넌트를 생성하는 대신 기존의 DOM 요소를 재사용하도록 합니다.
  • CSS 속성만 변경: S.PdfImagePageContainerisVisible prop을 통해 display: none 또는 display: block CSS 속성만 변경합니다. 이는 DOM 요소를 삭제하고 재생성하는 것보다 훨씬 효율적입니다.
  • 렌더링된 DOM 요소 재사용: 이미 한 번 로드되어 렌더링된 <Page /> 컴포넌트는 페이지 전환 시 다시 렌더링할 필요 없이 DOM 요소를 재활용합니다.

성능 개선 결과

구분기존 코드개선 코드 1개선 코드 2
초기 로딩빠름느림빠름
페이지 전환느림빠름빠름
메모리 사용량낮음높음중간

결론: 개선 코드 2는 초기 로딩 속도를 유지하면서 페이지 전환 성능을 크게 개선했습니다. 또한, 필요한 인접 페이지만 로드하고 DOM을 재활용함으로써 메모리 사용량도 절약할 수 있었습니다.

2. PDF 미리보기 성능 개선: 파일 간 전환 최적화

페이지 내 전환 성능 개선 후, 파일 간 전환 시 발생하는 성능 문제를 발견했습니다. 사용자가 여러 PDF 파일을 순차적으로 확인할 때, 다음 파일로 넘어갈 때마다 새로운 PDF 파일을 로드해야 하므로 지연이 발생하는 문제였습니다.

문제점

  1. 파일 전환 지연: 현재 파일에서 다음 파일로 넘어갈 때 새로운 PDF 파일을 처음부터 로드해야 하므로 지연이 발생했습니다.
  2. 사용자 경험 저하: 파일 간 전환 시 흰색 배경이 길게 표시되어 끊김 없는 사용자 경험을 제공하지 못했습니다.

🚀  파일 간 전환 속도 저하 문제 해결 과정

파일 간 전환 시 발생하는 지연 문제를 해결하기 위해 이전/다음 파일 프리패칭 전략을 구현했습니다.

초기 코드 (문제점):

파일 간 전환 성능 개선 전에는 현재 파일의 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 파일을 순차적으로 확인할 때 끊김 없는 부드러운 경험을 제공할 수 있게 되었습니다. 또한, 필요한 페이지만 프리패칭함으로써 메모리 사용량도 적절히 관리할 수 있었습니다.

profile
Beyond the wall

0개의 댓글