Web

Vercel 요청 페이로드 4.5MB 제한 트러블슈팅과 Presigned URL 아키텍쳐 도입

Aidengoldkr 2026. 2. 8. 04:03

이번 포스트는 Vercel 서버리스 환경에서 파일 업로드 시 발생하는 4.5MB 페이로드 제한 문제를, Presigned URL 아키텍쳐로 구조적으로 해결한 과정에 관한 것이다.


이슈

ToDit(todit.app)에 이미지 업로드 기능을 붙이던 중 이런 에러가 떴다.

413 FUNCTION_PAYLOAD_TOO_LARGE

 

처음엔 multipart/form-data로 인코딩 방식을 바꾸면 되겠거니 했다. 소용없었다.

Vercel 공식 문서를 뒤져보니 원인이 명확했다.

Vercel Serverless Function은 요청 본문과 응답 본문 모두에 4.5MB 하드 제한을 적용한다. 이 값은 설정으로 변경할 수 없다.

multipart/form-dataapplication/octet-stream이든 상관없다. 파일 바이트가 API Route를 통과하는 한 이 벽에 반드시 부딪힌다.

 


잘못된 접근들

문제를 인식하고 나서 몇 가지 방향을 먼저 검토했다.

접근 의도 기각 이유
업로드 전 이미지 압축 페이로드를 4.5MB 미만으로 줄이기 품질 저하 발생, 구조적 해결이 아님
청크 업로드 파일을 쪼개 여러 요청으로 전송 서버 측 재조립 로직이 복잡해지고, 역시 서버가 바이트를 받는 구조
Vercel Pro 플랜 전환 제한 상향 기대 비용 증가, Pro 플랜도 동일한 4.5MB 제한 적용

 

결론은 하나였다. 어떤 방향도 문제의 본질을 건드리지 않는다.


원인 분석

이슈의 본질은 파일 크기가 아니라 아키텍쳐다.

기존 흐름을 그리면 이렇다.

Client
  → (이미지 파일 전체, multipart/form-data)
  → Next.js API Route (Vercel Serverless)   ← 여기서 4.5MB 제한에 걸림
  → Supabase Storage

 

API Route가 파일 바이트를 직접 받고 있다. 그런데 서버가 파일 바이트로 실제로 하는 일은 무엇인가? Supabase에 그대로 넘기는 것뿐이다.

서버가 진짜 필요한 정보는 파일이 어디에 저장됐는지 — 즉 스토리지 경로다. 파일 자체를 받을 이유가 없다.

서버를 파일 전송 경로에서 완전히 제거하면, 4.5MB 제한은 구조적으로 무의미해진다.


해결책: Presigned URL 아키텍쳐

Presigned URL은 서버가 스토리지(S3, Supabase Storage 등)에 요청해 발급받는, 시간 제한이 있는 서명된 업로드 전용 URL이다.

클라이언트는 이 URL을 받아, 서버를 거치지 않고 스토리지에 직접 파일을 PUT한다.

변경된 아키텍쳐

1. Client      → API Route:          "업로드용 Presigned URL 발급해줘"
2. API Route   → Supabase:           해당 경로에 대한 서명된 URL 요청
3. Supabase    → API Route → Client: Presigned URL 반환
4. Client      → Supabase Storage:   파일 직접 업로드 (서버는 바이트를 보지 않음)
5. Client      → API Route:          "업로드 완료. 경로는 {path}야"
6. API Route:                         경로 수신 후 후속 처리 (OCR, GPT 등)

 

API Route가 처리하는 페이로드는 메타데이터(파일명, 경로) 뿐이다. 수십 바이트 수준이다. 4.5MB 제한이 들어올 자리가 없다.


구현

1. Presigned URL 발급 (API Route)

Supabase Storage의 createSignedUploadUrl을 사용한다.

// app/api/upload/route.ts
import { createClient } from "@supabase/supabase-js";
import { v4 as uuidv4 } from "uuid";

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

export async function POST(req: Request) {
  const { filename } = await req.json();
  const ext = filename.split(".").pop();
  const path = `uploads/${uuidv4()}.${ext}`;   // 경로 충돌 방지

  const { data, error } = await supabase.storage
    .from("images")
    .createSignedUploadUrl(path);

  if (error || !data) {
    return new Response("URL 발급 실패", { status: 500 });
  }

  return Response.json({ signedUrl: data.signedUrl, path });
}

 

createSignedUploadUrl이 반환하는 URL의 유효 시간은 기본 60초다. 업로드 URL은 짧을수록 좋다 — 유출되더라도 쓰기 전용 + 단기 유효라 피해 범위가 작다.

2. 클라이언트에서 직접 업로드

// 1단계: Presigned URL 발급 요청
const { signedUrl, path } = await fetch("/api/upload", {
  method: "POST",
  body: JSON.stringify({ filename: file.name }),
  headers: { "Content-Type": "application/json" },
}).then((r) => r.json());

// 2단계: 스토리지에 직접 PUT — Vercel을 거치지 않음
await fetch(signedUrl, {
  method: "PUT",
  body: file,
  headers: { "Content-Type": file.type },
});

// 3단계: 서버에 경로만 전달
await fetch("/api/confirm", {
  method: "POST",
  body: JSON.stringify({ path }),
  headers: { "Content-Type": "application/json" },
});

 

파일 바이트는 signedUrl로 직접 간다. API Route는 관여하지 않는다.

3. S3를 쓴다면 (AWS SDK v3)

Supabase 대신 S3를 쓰는 경우도 패턴은 같다.

// app/api/upload/route.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: process.env.AWS_REGION });

export async function POST(req: Request) {
  const { filename, contentType } = await req.json();
  const key = `uploads/${Date.now()}-${filename}`;

  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET!,
    Key: key,
    ContentType: contentType,
  });

  // 300초(5분) 후 만료
  const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 300 });
  return Response.json({ uploadUrl, key });
}

주의사항

몇 가지 빠지기 쉬운 함정이 있다.

 

CORS 설정 필수 — 브라우저가 스토리지 도메인으로 직접 PUT을 보내므로, 버킷에서 앱 도메인의 PUT 요청을 허용해야 한다. 이 설정을 빠뜨리면 업로드가 네트워크 에러로 조용히 실패한다.

 

Content-Type 불일치 — Presigned URL을 만들 때 지정한 ContentType과 클라이언트가 PUT 헤더에 보내는 값이 달라지면 S3는 403 SignatureDoesNotMatch를 반환한다. 클라이언트에서 file.type을 그대로 넘기고, 서버도 동일한 값으로 URL을 서명해야 한다.

 

임시 자격증명과 만료 시간 — Lambda나 Vercel 환경에서 IAM 역할로 자격증명을 쓰는 경우, Presigned URL의 실제 만료는 expiresIn이 아니라 자격증명 세션 기간에 의해 더 짧게 끊길 수 있다.

 

업로드 전 서버 측 검증 불가 — 파일이 스토리지에 바로 올라가기 때문에, 서버가 콘텐츠를 사전에 검사할 수 없다. 바이러스 스캔이나 파일 타입 검증이 필요하다면, S3 이벤트 트리거 또는 업로드 완료 후 confirm 엔드포인트에서 처리해야 한다.


결과

  • 413 에러 완전 해소
  • API Route 실행 시간 단축 — 파일 수신/버퍼링 오버헤드 제거
  • 실질적인 파일 크기 상한은 이제 Supabase Storage 정책만이 결정

ToDit에서는 업로드 완료 후 스토리지의 파일을 삭제하는 정책을 추가로 적용했다. 임시 파일이 누적되지 않도록 하기 위해서다.


마무리

이번 이슈의 핵심은 파일을 "어떻게 더 작게 보낼까"가 아니라 "서버가 파일 바이트를 볼 이유가 있는가"였다.

Presigned URL 패턴은 대용량 CSV 업로드, 비디오 파일, 벌크 이미지 처리 파이프라인 등 파일이 서버를 경유할 이유가 없는 모든 상황에 적용된다. Vercel이 아니더라도, API Gateway + Lambda 조합에서도 동일한 구조적 한계가 존재하고, 해법도 같다.

서버는 조정자(coordinator)로 두고 실제 데이터는 클라이언트와 스토리지가 직접 주고받게 하는 것 — 이 원칙 하나로 문제가 깔끔하게 정리됐다.