Svelte

프로덕션 환경에서 파일 업로드 용량 제한으로 삽질한 이야기 (feat. busboy, BODY_SIZE_LIMIT)

#SvelteKit#Vite#busboy#.env#OCI#pm2
Svelte 23 2026. 1. 3. 2026. 1. 4.

이 블로그는 게시글을 올릴 때, 파일을 업로드할 수 있게 해두었으며, 파일을 올리면 서버를 거쳐 Oracle Cloud Infrastructure(OCI)오브젝트 스토리지(Object Storage) 에 파일이 올라간다.

파일 다운로드 URL이 API 주소로 인해 너무 길어지는 문제가 있고 다소 지저분하다는 느낌이 들어 실제 다운로드 URL은 DB에 저장하고 서버에 다운로드 URL을 요청하면 DB에서 해당하는 문서를 찾아 리다이렉트시켜 다운로드 받을 수 있게 해준다.

이는 본문에 삽입된 이미지도 마찬가지이다.

다운로드 URL 리다이렉션 증거(?)

사건의 발단


처음에는 다른 블로그에서 Tiptap에 이미지를 업로드하고 표시하는 예제를 따라 FormData로 전송하고 그것을 File 객체로 받고 ArrayBuffer로 변환해 OCI에 업로드 하는 방법을 사용했었다.

그러나 이미지 파일 크기가 작을 때에는 문제가 없었는데 용량이 조금만 커지만 자꾸 업로드가 실패하는 문제가 발생했다.

그래서 OCI에 파일을 업로드하는 두번째 방법인 Stream 객체로 업로드 하는 방법을 사용하기 위해 FormData -> ArrayBuffer -> Stream 으로 변환하였다.

...
export const POST: RequestHandler = async (event) => {
  const body = await event.request.formData();
  const file = body.get("file") as File;
  let filename = file.name;
  const nodeStream = Readable.from(Buffer.from(await file.arrayBuffer()));

  const provider = new common.ConfigFileAuthenticationDetailsProvider(path.join(process.cwd(), "/config"));
  const objectStorageClient = new ObjectStorageClient({ authenticationDetailsProvider: provider });

  const namespace = process.env.CLOUD_BUCKET_NAME_SPACE;
  const bucketName = process.env.CLOUD_BUCKET_NAME;
  if (!(namespace && bucketName))
    return json("can't find config data.", {
      status: 500,
    });

  console.log(file.size, file.type);

  const uuid = crypto.randomUUID();

  let url = `https://objectstorage.${provider.getRegion().regionId}.oraclecloud.com/n/${namespace}/b/${bucketName}/o/${encodeURIComponent(filename)}`;

  const checkUrlExists = await fetch(url, {
    method: "HEAD"
  });

  if (checkUrlExists.ok) {
    let [name, ext] = file.name.split(".");
    name = name + '_' + uuid;
    filename = [name, ext].join(".");
    url = `https://objectstorage.${provider.getRegion().regionId}.oraclecloud.com/n/${namespace}/b/${bucketName}/o/${encodeURIComponent(filename)}`;
  }

  try {
    const putObjectResponse = await objectStorageClient.putObject({
      namespaceName: namespace,
      bucketName,
      objectName: filename,
      contentLength: file.size,
      putObjectBody: nodeStream,
      retryConfiguration: {
        terminationStrategy: new common.MaxAttemptsTerminationStrategy(3),
      },
    });
...

룰루랄라,

이 방법을 사용하여 업로드가 정상적으로 이루어지는 것을 확인하여 발 뻗고 잠을 잘 수가 있었는데...

문제는 블로그의 형태를 다 갖췄다고 생각하여 빌드하여 프로덕션 모드로 운용하는 상태에서 발생했다.


아니 되던게 왜 안되누

이제 에디터는 거의 완벽해! 라고 생각하고 예전에 작성했던 일기 글에 라멘 사진을 폰에서 올리려고 했는데,
사진이 올라가지 않는 것이었다.

2025년 12월 11일 | 일상 - Hangmin's Blog.

물론 지금은 업로드에 성공하여 이미지가 보이지만 말이다.

모바일이라서 개발자 도구도 열 수 없고...

어쩔 수 없이 다시 노트북을 열고 폰에 디버깅을 켜고 개발자 도구를 열어서 문제를 확인해 보는데 업로드 자체가 실패를 했던 것이다.

그래서 노트북으로도 해보니 동일하게 발생하는 오류...

프로덕션 모드라 제대로 된 로그를 확인하는 것은 불가능할 것 같다는 생각이었지만 혹시 모르는 마음에 pm2 log 0으로 로그를 확인해보니

Content-length of xxxxxx exceeds limit of bytes xxxxxx.

이런 오류가 발생하더라.

분명 dev로 돌렸을 땐 잘 됐었다.


"선생님... 혹시 Vite에 업로드 사이즈를 제한하는 설정 같은게 있나요?"

ChatGPT에 물어봤다. 당연히 SvelteKit쪽이 아니라 Vite쪽에서 그럴 것이라 생각했기에...

"결론부터 말하면 Vite 자체에는 dev / production에 따라 파일 업로드 크기를 제한하는 설정은 없다.
지금 증상은 거의 확실하게 Vite 바깥(서버·프록시·런타임) 에서 생긴 차이다."

이렇게 말하는 선생님 말씀에 다른 문제구나 생각했다.

Nginx 문제? - 아님


얘가 Nginx에서 client_max_body_size를 제한한 거 아니냐고 물어보길래 확인해봤더니 이미 제한을 풀어놨었다.

그래서 그냥 업로드 코드 로직을 다 던져줘봤다.

FormData를 받아서 처리하는 게 문제? - 반만 맞음


일단 이때 눈치를 챘으면 시간을 좀 더 절약했을텐데, 결과적으로 최적화도 얻게 되었으니 이렇게 글도 작성하는 것이 아니겠는가...

"코드 기준으로 보면 Vite 문제가 아니라, prod에서 event.request.formData()를 쓰는 방식 자체가 병목이다.
dev에서는 통과하지만 빌드 후에 작은 파일만 올라가는 패턴이 정확히 그 증상이다."

뭔가 시원하게 이렇게 답변을 해주길래 오... 했다.

그러면서 내놓는 해결책이 FormData를 Buffer에 담지 말고 바로 스트림하여 보내라는 것이었다.

그래서 ChatGPT의 제안대로 얼떨결에 Busboy라는 라이브러리도 도입하게 되었다.

근데 사용방법을 모르니... 일단은 형태가 그냥 메인 로직을 bb.on() 이라는 함수의 내부 콜백으로 집어넣는 것 같길래 물어봤더니 기존에

return json() 한 부분이 있기 때문에 이건 밖으로 빼고 비동기로 받으라고 하더라.

개선 전

export const POST: RequestHandler = async (event) => {
  const body = await event.request.formData();
  const file = body.get("file") as File;
  let filename = file.name;
  const nodeStream = Readable.from(Buffer.from(await file.arrayBuffer()));

  const provider = new common.ConfigFileAuthenticationDetailsProvider(path.join(process.cwd(), "/config"));
  const objectStorageClient = new ObjectStorageClient({ authenticationDetailsProvider: provider });

  const namespace = process.env.CLOUD_BUCKET_NAME_SPACE;
  const bucketName = process.env.CLOUD_BUCKET_NAME;
  if (!(namespace && bucketName))
    return json("can't find config data.", {
      status: 500,
    });

  console.log(file.size, file.type);

  const uuid = crypto.randomUUID();

  let url = `https://objectstorage.${provider.getRegion().regionId}.oraclecloud.com/n/${namespace}/b/${bucketName}/o/${encodeURIComponent(filename)}`;

  const checkUrlExists = await fetch(url, {
    method: "HEAD"
  });

  if (checkUrlExists.ok) {
    let [name, ext] = file.name.split(".");
    name = name + '_' + uuid;
    filename = [name, ext].join(".");
    url = `https://objectstorage.${provider.getRegion().regionId}.oraclecloud.com/n/${namespace}/b/${bucketName}/o/${encodeURIComponent(filename)}`;
  }

  try {
    const putObjectResponse = await objectStorageClient.putObject({
      namespaceName: namespace,
      bucketName,
      objectName: filename,
      contentLength: file.size,
      putObjectBody: nodeStream,
      retryConfiguration: {
        terminationStrategy: new common.MaxAttemptsTerminationStrategy(3),
      },
    });

    const returnData: FileType = {
      ...putObjectResponse,
      url,
      filename,
      uuid,
      length: file.size,
      lastModified: file.lastModified,
      type: file.type,
    };

    await dbInsertOne("file", returnData);

    console.log(returnData);
    return json(
      { ...returnData, url: `/file/${uuid}` },
      {
        status: 200,
      },
    );
  } catch (e) {
    const error = e as OciError;
    console.error(e);
    return json(error.message, {
      status: error.statusCode,
    });
  }
};

개선 후

...
  const bb = Busboy({
    headers: Object.fromEntries(event.request.headers)
  });

  let result;

  await new Promise<void>((resolve, reject)=>{
    let handled = false;
    bb.on('file', async (name, file, info) => {
      if (handled) {
        file.resume();
        return;
      }
      handled = true;
  
      let filename = info.filename;
      const uuid = crypto.randomUUID();
    
      let url = `https://objectstorage.${provider.getRegion().regionId}.oraclecloud.com/n/${namespace}/b/${bucketName}/o/${encodeURIComponent(filename)}`;
    
      const checkUrlExists = await fetch(url, {
          method: 'HEAD'
      });

      if (checkUrlExists.ok) {
        let [uploadFilename, ext] = info.filename.split('.');
        uploadFilename = uploadFilename + '_' + uuid;
        filename = [uploadFilename, ext].join('.');
        url = `https://objectstorage.${provider.getRegion().regionId}.oraclecloud.com/n/${namespace}/b/${bucketName}/o/${encodeURIComponent(filename)}`;
      }
      
      try {
        const putObjectResponse = await objectStorageClient.putObject({
          namespaceName: namespace,
          bucketName,
          objectName: filename,
          putObjectBody: file,
          retryConfiguration: {
            terminationStrategy: new common.MaxAttemptsTerminationStrategy(3)
          }
        });

        const returnData: FileType = {
          ...putObjectResponse,
          url,
          filename,
          uuid,
          lastModified: new Date(event.request.headers.get('last-modified')??Date.now()).getTime(),
          type: info.mimeType
        };

        await dbInsertOne('file', returnData);

        result = returnData;
        resolve();
      } catch (e) {
        const error = e as OciError;
        console.error(e);
        result = e;
        reject(e);
      }
    });

    bb.on('error', reject);
    bb.on('finish', ()=>{
      if (!handled) {
        reject(new Error("no file uploaded"));
    }});
  
    const nodeStream = Readable.fromWeb((event.request.body as ReadableStream<any>)!);
    nodeStream.pipe(bb);
  })
...

주요 로직은 다음과 같다:

  1. 먼저 파일 이름과 동일한 실제 다운로드 링크가 있는지 HEAD 요청을 통해 확인한다.
  2. 다운로드 링크가 있으면 중복된 파일 이름으로 간주하고 파일 이름 뒤에 randomUUID를 추가해준다.
  3. 환경변수에 있는 리전과 버킷 이름, 파일 이름을 조합하여 다운로드 URL을 변수에 담는다. <- 여기까지 기존 메인 로직에 있던 기능인데 이것을 busboy 콜백에 넣었다.
  4. Busboy 객체를 추가하고 헤더를 매개변수로 넣어준다.
  5. 헤더로부터 파일을 발견하면 실행되도록 bb.on('file',...) 이벤트를 추가한다.
  6. OCI로 파일을 전송하는 객체에서는 putObjectBodyFormData에서 변환된 Stream이 아닌 busboy로부터 생성?되는 스트림인 file 매개변수로 넣어준다.
  7. 파일 스트림을 정상적으로 읽어들였으면 handle 변수를 통해 bb.on('finish') 동작이 실행되지 못하도록 막고 파일이 업로드되면 직접 프로미스를 resolve해준다. (파일이 없으면 handle 변수를 false로 둠으로써 업로드 로직이 실행되지 못하도록 한다.)
  8. result 변수에 파일 업로드 후 리턴할 데이터를 객체에 담아서 json으로 리턴해준다.

파일 업로드는 정상적으로 성공, 그러나 여전히 프로덕션에서는 실패...


싱글벙글 dev에서는 정상 작동하는 것을 확인하고 다시 빌드하여 프로덕션에서 테스트하는데 여전히 동일한 문제가 발생하는 것이었다.

...

결국 그래서 뭐가 문제였는가 하면...

SvelteKit의 adapter-node에는 BODY_SIZE_LIMIT 환경 변수가 있다.


주인장의 블로그는 SvelteKit의 빌드 옵션으로 adapter-node가 사용된다. Node servers • SvelteKit Docs

그리고 문제는 여기에 있었다.

BODY_SIZE_LIMIT


The maximum request body size to accept in bytes including while streaming. The body size can also be specified with a unit suffix for kilobytes (K), megabytes (M), or gigabytes (G). For example, 512K or 1M. Defaults to 512kb. You can disable this option with a value of Infinity (0 in older versions of the adapter) and implement a custom check in handle if you need something more advanced.

이게 Vite쪽 문제가 아닐거라고는 생각을 못하고 있었던 것이다.

결국 .env 파일에 해당 환경변수를 넣어서 다시 빌드해주는 것으로 깔끔하게 해결되었다.

P.S.

.env 등 환경 변수 파일을 수정했으면 pm2 재시작 시 --update-env 옵션을 넣거나, 컨테이너를 삭제하고 다시 생성하도록 하자...

(이것도 몰라서 시간을 더욱 낭비했다는 것은 안비밀.)