대용량 업로드는 메모리에 올리지 않는 것부터 시작합니다

관리자 화면에서 엑셀, 영상, 이미지 묶음, 백업 파일을 받는 API는 평소에는 정상처럼 보여도 파일 크기와 동시 요청이 늘면 쉽게 불안정해집니다. 가장 흔한 원인은 요청 본문 전체를 Buffer로 만든 뒤 처리하는 방식입니다. 500MB 파일을 동시에 여러 건 받으면 Node.js 프로세스의 힙뿐 아니라 운영체제 메모리까지 압박받고, GC 지연과 응답 시간 증가가 연쇄적으로 발생할 수 있습니다.

안정적인 업로드 API의 기본 원칙은 요청 데이터를 스트림으로 받아 디스크나 객체 스토리지로 순차 전달하는 것입니다. 애플리케이션은 파일 전체가 아니라 작은 조각만 처리하므로 메모리 사용량을 예측하기 쉬워집니다. 다만 스트림을 쓴다고 끝나는 것은 아닙니다. 허용 크기, 확장자와 실제 형식, 중단된 요청의 임시 파일, 저장 후 처리 작업, 다운로드 권한까지 함께 설계해야 운영 사고를 줄일 수 있습니다.

처리 흐름을 먼저 나눕니다

  • HTTP 레이어에서 인증, Content-Length의 빠른 사전 점검, 요청 타임아웃을 적용합니다.
  • multipart 파서는 파일 수와 파일별 크기를 제한하고, 허용된 필드만 받습니다.
  • 업로드 스트림은 임시 경로 또는 S3 호환 객체 스토리지로 바로 전송합니다.
  • 저장이 끝난 뒤에만 DB에 파일 메타데이터를 기록합니다.
  • 썸네일 생성, 바이러스 검사, 문서 변환처럼 오래 걸리는 일은 큐 작업으로 분리합니다.
  • 실패하거나 연결이 끊긴 경우에는 저장 대상을 정리하고, 정리 실패도 로그와 재시도 대상으로 남깁니다.

특히 업로드 요청 안에서 이미지 변환이나 OCR을 모두 끝내려 하면 웹 서버 워커가 오래 점유됩니다. 저장 완료 시점과 후처리 완료 시점을 구분해 사용자에게 상태를 보여 주는 편이 훨씬 안전합니다.

Express와 Busboy로 스트리밍 업로드 구현하기

아래 예시는 파일 하나를 서버의 임시 폴더로 저장하는 최소 구성입니다. 운영 환경에서는 임시 폴더 권한을 전용 계정으로 제한하고, 저장 파일명은 사용자가 보낸 이름을 그대로 쓰지 않아야 합니다. 원본 이름은 DB 메타데이터로만 보관하고 실제 객체 키는 UUID처럼 충돌하지 않는 값으로 생성합니다.

import express from 'express';
import Busboy from 'busboy';
import { createWriteStream, promises as fs } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import path from 'node:path';
import crypto from 'node:crypto';

const app = express();
const uploadDir = '/var/app/uploads/tmp';
const maxBytes = 50 * 1024 * 1024;

app.post('/api/files', async (req, res) => {
  const busboy = Busboy({ headers: req.headers, limits: { files: 1, fileSize: maxBytes } });
  let savedPath;
  let uploadError;

  busboy.on('file', async (_field, file, info) => {
    const ext = path.extname(info.filename).toLowerCase();
    if (!['.csv', '.xlsx', '.pdf'].includes(ext)) {
      file.resume();
      uploadError = new Error('허용하지 않는 파일 형식입니다.');
      return;
    }
    savedPath = path.join(uploadDir, crypto.randomUUID() + ext);
    try {
      await pipeline(file, createWriteStream(savedPath, { flags: 'wx' }));
    } catch (error) {
      uploadError = error;
    }
  });

  busboy.on('finish', async () => {
    if (uploadError || !savedPath) {
      if (savedPath) await fs.unlink(savedPath).catch(() => {});
      return res.status(400).json({ message: uploadError?.message ?? '파일이 없습니다.' });
    }
    res.status(201).json({ status: 'stored' });
  });
  req.pipe(busboy);
});

예시는 개념을 보여 주기 위한 것이므로 실제 서비스에서는 이벤트 흐름을 더 엄격히 관리해야 합니다. 예를 들어 파일 크기 제한을 넘으면 Busboy의 제한 이벤트를 감지해 결과를 실패로 확정하고, 클라이언트 연결이 끊긴 경우에도 파일 스트림과 대상 쓰기 스트림을 중단해야 합니다. 프록시 서버의 최대 본문 크기와 애플리케이션 제한 값도 서로 맞춰야 합니다. Nginx가 100MB를 허용하는데 API가 50MB만 받으면 사용자에게 일관된 오류 메시지를 제공하도록 API의 정책을 기준으로 정리하는 것이 좋습니다.

확장자만 믿지 말고 업로드 정책을 계층화합니다

확장자 검사는 사용자 경험을 위한 첫 단계일 뿐 보안 검증이 아닙니다. 실행 파일을 PDF 확장자로 바꾸거나 파일 헤더를 위장할 수 있기 때문입니다. 필요한 경우 파일의 매직 바이트를 검사하고, 이미지나 문서는 신뢰할 수 있는 별도 워커에서 다시 인코딩하거나 검사합니다. 웹에서 바로 열릴 파일은 업로드 저장소를 애플리케이션 도메인과 분리하고, 다운로드 시 Content-Disposition과 Content-Type을 명시하는 편이 안전합니다.

또한 사용자가 지정한 파일명으로 경로를 조합하면 ../ 같은 경로 이동 문제가 생길 수 있습니다. 경로에는 서버가 만든 식별자만 넣고, 파일명은 화면 표시용으로만 사용해야 합니다. 업로드 권한은 단순 로그인 여부가 아니라 조직, 프로젝트, 데이터 분류별로 검사하고, 다운로드 API도 같은 권한 검사를 반복해야 합니다.

객체 스토리지와 비동기 후처리로 운영 부담 줄이기

서버 로컬 디스크는 빠르게 시작하기 좋지만, 컨테이너 재배포와 수평 확장 환경에서는 파일 위치를 공유하기 어렵습니다. 규모가 커지면 애플리케이션이 발급한 짧은 만료의 사전 서명 URL로 브라우저가 객체 스토리지에 직접 업로드하게 할 수 있습니다. 이때도 서버는 업로드 완료 콜백을 무조건 신뢰하지 말고 객체 크기, 콘텐츠 형식, 소유자 메타데이터를 다시 확인한 뒤 DB 상태를 완료로 변경해야 합니다.

업로드 레코드는 pending, stored, scanning, ready, failed 같은 상태를 두면 장애를 추적하기 쉽습니다. 정기 작업으로 오래된 pending 항목과 임시 파일을 제거하되, 삭제 기준은 업로드 시작 시각과 최종 갱신 시각을 함께 고려합니다. 실패한 변환 작업은 제한된 횟수로 재시도하고, 최종 실패 사유를 운영자가 확인할 수 있게 남깁니다.

배포 전 체크리스트

  • 파일 크기, 파일 수, 요청 시간 제한이 프록시와 Node.js에 모두 설정되어 있는지 확인합니다.
  • 전체 파일을 메모리에 적재하는 미들웨어를 사용하지 않는지 점검합니다.
  • 저장 키는 서버가 생성하고, 원본 파일명은 경로로 사용하지 않는지 확인합니다.
  • 중단·제한 초과·저장 실패 때 임시 파일이 정리되는지 테스트합니다.
  • 후처리는 큐로 분리하고 파일 상태와 재시도 횟수를 기록합니다.
  • 업로드와 다운로드 모두 리소스 단위 권한 검사를 수행합니다.

대용량 업로드의 핵심은 스트림 처리, 명확한 제한, 실패 시 정리, 그리고 저장과 후처리의 분리입니다. 이 네 가지를 갖추면 메모리 급증과 고아 파일을 줄이고, 파일 규모가 커져도 예측 가능한 방식으로 서비스를 운영할 수 있습니다.