이번 포스트는 Vercel 서버리스 환경에서 파일 업로드 시 발생하는 4.5MB 페이로드 제한 문제를, Presigned URL 아키텍쳐로 구조적으로 해결한 과정에 관한 것이다.
이슈
ToDit(todit.app)에 이미지 업로드 기능을 붙이던 중 이런 에러가 떴다.
413 FUNCTION_PAYLOAD_TOO_LARGE
처음엔 multipart/form-data로 인코딩 방식을 바꾸면 되겠거니 했다. 소용없었다.
Vercel 공식 문서를 뒤져보니 원인이 명확했다.
Vercel Serverless Function은 요청 본문과 응답 본문 모두에 4.5MB 하드 제한을 적용한다. 이 값은 설정으로 변경할 수 없다.
multipart/form-data든 application/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)로 두고 실제 데이터는 클라이언트와 스토리지가 직접 주고받게 하는 것 — 이 원칙 하나로 문제가 깔끔하게 정리됐다.

'Web' 카테고리의 다른 글
| [풀스택 100시간 과정] #4 - 세션, 쿠키, API, 페이징 (0) | 2026.04.13 |
|---|---|
| [풀스택 100시간 과정] #3 - 서버와 HTTP (0) | 2026.04.13 |
| 조코딩 x OpenAI x Primer AI 해커톤 참가 후기 – Actonix를 출품하며 (0) | 2026.03.10 |