포스트

대용량 파일은 직접 넣어 봐야 안다: 90MB PDF와 125만 셀이 알려준 것

대용량 파일은 직접 넣어 봐야 안다: 90MB PDF와 125만 셀이 알려준 것

대용량 고화질 파일을 넣으면 기능이 거의 다 깨진다는 제보를 받았다. 재현이 안 됐다. 내가 가진 테스트 파일이 22MB였고 그건 멀쩡하게 돌았기 때문이다. 그래서 90MB짜리 600dpi 스캔본을 직접 만들어 넣어 봤다.

결과는 예상과 달랐다. 기능은 안 깨졌고 대신 다른 게 드러났다. 크다는 말이 세 가지 다른 축을 뭉뚱그린 표현이었다는 것, 그리고 경계값에서만 나오는 결함이 하나 있었다는 것이다.

이 글은 왜 큰 파일을 굳이 만들어서 넣어 봐야 하는지, 그리고 어떻게 만드는지에 대한 기록이다. 작업한 코드는 Semojum/FE 에 있다.

테스트 파일부터 만들어야 한다

큰 파일을 구하는 게 생각보다 번거롭다. 실사용 파일은 민감하고 인터넷 샘플은 우리 도메인과 다르다. 나는 이미 가진 문서를 고해상도로 다시 렌더해 이미지 PDF로 재조립했다. 문제집 스캔본과 같은 구조가 나온다. 쪽마다 전면 JPEG가 들어간다.

1
2
# 실제 문서를 600dpi로 렌더 → A4 한 쪽이 4959×7017 픽셀, 약 1.8MB
pdftoppm -jpeg -jpegopt quality=90 -r 600 원본.pdf hi

이 JPEG들을 라이브러리 없이 PDF로 묶었다. 재압축이 없으니 원본 JPEG 크기의 합이 곧 파일 크기가 되어 목표 용량을 정확히 맞출 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# JPEG를 /DCTDecode 이미지 XObject로 그대로 품는 PDF를 쓴다 (핵심 부분만)
def build(images, out, dpi=200):
    for path in images:
        w, h, comps = jpeg_size(path)        # SOF 마커에서 크기를 읽는다
        raw = open(path, 'rb').read()
        img_id = add(
            b'<< /Type /XObject /Subtype /Image /Width %d /Height %d '
            b'/ColorSpace /DeviceRGB /BitsPerComponent 8 '
            b'/Filter /DCTDecode /Length %d >>\nstream\n' % (w, h, len(raw))
            + raw + b'\nendstream'
        )
        pw, ph = w * 72.0 / dpi, h * 72.0 / dpi   # 픽셀 → 포인트
        content = b'q %.2f 0 0 %.2f 0 0 cm /Im0 Do Q' % (pw, ph)
        # ... Page 객체에 XObject와 Contents를 연결

같은 이미지를 여러 바퀴 반복해 붙이면 원하는 크기가 나온다.

파일크기쪽수용도
0118MB10실사용 규모의 고화질판
0254MB30중간
0390MB50상한(95MiB) 직전
04108MB60거부되는지 확인용

마지막 한 줄이 중요하다. 상한을 넘는 파일을 넣어 보지 않으면 거부 경로는 영원히 테스트되지 않는다. 실제로 거기서 결함이 나왔다.

크다에는 축이 세 개 있다

측정해 보니 한 덩어리로 말하던 대용량이 서로 다른 세 가지였다.

잘려 보이면 전체 화면으로 열기.

첫째, 바이트 크기는 업로드에만 영향을 준다. 18MB는 6.7초, 54MB는 17.8초, 90MB는 21.6초다. 선형이고 예측 가능하다.

둘째, 쪽 해상도는 완전히 다른 축이다. 재 보니 문서를 여는 비용은 18MB에서 155ms, 54MB에서 37ms, 90MB에서 64ms로 파일 크기와 상관이 없었다. 반면 쪽 하나를 그리는 데는 크기와 무관하게 400~1,300ms가 걸렸다. 원인은 4959×7017, 그러니까 35메가픽셀짜리 JPEG를 푸는 비용이다. 화면에는 1.2메가픽셀만 쓰는데 35메가픽셀을 다 풀어야 한다.

셋째, 출력 규모가 제일 뜻밖이었다. 우리 서비스는 문서를 점자 판면(26줄 × 32칸)으로 바꾼다. 그래서 입력이 작아도 출력이 폭발할 수 있다.

입력출력 판면격자 셀DOM 요소
90MB PDF (50쪽)107면89,02498,630
1.2MB 텍스트1,505면1,252,1601,384,826

1.2MB짜리 텍스트 파일이 90MB PDF보다 14배 큰 화면을 만들었다. 큰 파일을 큰 용량으로만 생각하고 PDF만 키웠다면 이 축은 끝까지 못 봤을 것이다.

옵션으로 못 고치는 것과 고칠 수 있는 것

둘째 축을 줄이려고 pdf.js 옵션을 하나씩 켜 봤다. pdf.js API 문서에 있는 enableHWA(캔버스 하드웨어 가속)와 캔버스 배율을 조합해 같은 쪽을 반복 측정했다.

설정워커(파싱·디코드)그리기
현재 (배율 2)502ms464ms
enableHWA: true435ms435ms
배율 1382ms423ms
둘 다392ms416ms

전부 오차 범위였다. 이유를 확인해 보니 pdf.js 5.x는 이미 WebCodecs의 ImageDecoder, 그러니까 브라우저 네이티브 디코더를 쓰고 있었다. 느린 JS 디코더를 쓰는 게 아니라 35메가픽셀을 푸는 비용 자체였다. 플래그로 줄일 여지가 없었다.

그런데 같은 측정에서 이게 나왔다.

 시간
처음 보는 쪽 그리기497ms
이미 본 쪽 다시 그리기43ms

pdf.js가 디코드 결과를 문서 안에 캐시한다. 그러니 남는 비용은 처음 보는 쪽뿐이다. 이웃 쪽을 화면 밖에서 미리 한 번 그려 두면 넘기는 순간에는 43ms 경로만 탄다. 옵션이 아니라 순서를 바꾸는 것이 답이었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 지금 쪽을 다 그린 뒤, 앞뒤 쪽을 화면 밖 캔버스에 한 번 그려 둔다.
// 실패해도 그만이다. 그 쪽으로 갈 때 정식 경로가 다시 그린다.
for (const n of [currentPage + 1, currentPage - 1]) {
  if (n < 1 || n > pdf.numPages) continue;
  const page = await pdf.getPage(n);
  const base = page.getViewport({ scale: 1 });
  const viewport = page.getViewport({ scale: (width * dpr) / base.width });
  const canvas = document.createElement('canvas');
  canvas.width = viewport.width;
  canvas.height = viewport.height;
  await page.render({
    canvasContext: canvas.getContext('2d', { alpha: false })!,
    viewport,
  }).promise;
}

셋째 축은 이미 손을 써 둔 상태였다. 판면을 면 단위로 메모하고, 셀 핸들러를 컨테이너로 위임하고, content-visibility: auto 로 화면 밖 면의 배치와 그리기를 건너뛰게 해 뒀다. 그 덕에 DOM 138만 개짜리 문서에서 13,802건을 모두 바꾸는 동안 메인 스레드 멈춤이 0ms였다.

무엇으로 판정할 것인가

버벅인다를 눈으로 판정하면 사람마다 다르다. 나는 Long Tasks API 로 메인 스레드를 50ms 넘게 붙잡은 작업만 세었다. 사람이 끊김을 느끼는 경계가 대략 100ms라 이 값이 그대로 판정 기준이 된다.

1
2
3
4
5
6
7
8
9
// 조작 하나가 UI를 얼마나 붙잡는지 잰다
const stalls = [];
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    stalls.push({ t: Math.round(e.startTime), ms: Math.round(e.duration) });
  }
}).observe({ entryTypes: ['longtask'] });

const since = (t0) => stalls.filter((s) => s.t >= t0).reduce((sum, s) => sum + s.ms, 0);

모드 두 개와 파일 여섯 개를 조합한 전체 시험에서 긴 작업은 7건, 최대 141ms였다. 숫자가 있으니 괜찮아 보인다가 아니라 괜찮다고 말할 수 있다.

정작 결함은 경계에서 나왔다

기능은 안 깨졌다. 대신 108MB 파일을 넣었을 때 결함이 하나 나왔다.

업로드 요청은 정상적으로 안 나갔다(용량 검사는 동작했다). 그런데 화면이 이랬다.

  • 상단 탭과 원본 칸에 파일명이 그대로 표시돼 올라간 것처럼 보인다.
  • 결과 칸에는 업로드 실패만 뜨고 이유가 없다.
  • 원본 칸은 불러오지 못했다는 문구와 함께 스피너가 계속 돈다.

원인은 검사 위치였다. 용량 검사가 업로드 단계에만 있어서, 그 전에 파일이 이미 화면 상태로 올라간 뒤였다. 파일을 받는 시점으로 검사를 앞당기고, 안내 문구도 실제 임계값에 맞췄다.

1
2
3
4
5
6
7
8
9
// 용량은 받는 자리에서 거른다. 업로드 단계에서만 보면 이미 화면에 올라간 뒤다.
const sizeError = fileSizeMessage(file);
if (sizeError) {
  setFileState((prev) => {
    if (prev.previewUrl) URL.revokeObjectURL(prev.previewUrl);
    return { ...EMPTY_STATE, error: sizeError };
  });
  return;
}

덤으로 문구 자체도 틀려 있었다. 안내는 100MB까지라고 하는데 실제 임계값은 95MiB라, 97MB 파일이 왜 막히는지 알 수 없었다. 지금은 이렇게 나온다.

1
업로드할 수 있는 파일 크기는 95MB까지입니다. (넣으신 파일 108MB)

이 결함은 상한을 넘는 파일을 실제로 넣어 보지 않으면 나오지 않는다. 정상 범위 파일로는 그 코드 경로에 진입조차 하지 않는다.

정리

큰 파일을 굳이 만들어 넣어 봐야 하는 이유는 네 가지다.

  • 경계 경로는 경계값으로만 열린다. 상한 초과와 0바이트, 잘린 파일까지, 정상 파일 100개보다 경계 파일 하나가 더 많은 걸 찾는다.
  • 크다의 축을 분리해야 병목이 보인다. 바이트 크기와 해상도, 출력 규모는 각각 다른 곳에서 비용을 만든다. 한 축만 키우면 나머지는 그대로 숨는다.
  • 입력 크기와 출력 규모는 비례하지 않는다. 1.2MB 텍스트가 90MB PDF보다 14배 큰 화면을 만들었다. 변환하는 서비스라면 출력을 기준으로도 시험해야 한다.
  • 숫자로 판정해야 한다. 긴 작업 합계 같은 기준을 정해 두면 느린 것 같다가 최대 141ms가 된다.

이번에도 확인한 게 하나 있다. 나는 22MB 파일로 문제없다고 결론 내릴 뻔했다. 재현이 안 될 때 가장 먼저 의심할 것은 내가 쓰는 입력이 사용자의 입력과 다르다는 가능성이다.

참고 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.