
Next.js와 Supabase를 사용한 프로젝트에서 이미지 업로드 기능을 구현할 때, 초기에는 Next.js 서버(API Route)를 경유하는 방식으로 구현했다. 하지만 그 과정에서 여러 문제점들이 발견되어 Supabase의 Signed URL 방식을 활용한 직접 업로드 방식으로 전환하게 되었다
지인들에게 각각 모바일, pc 로 이미지테스트를 부탁했을 때 아래와 같은 에러들이 발생한 상황이었다..


기존에는 클라이언트에서 파일을 Next.js 서버(API Route)로 전송하고, Next.js 서버에서 Supabase Storage에 업로드하는 방식이었다.
// API Route로 파일 업로드
const handleUpload = async (formData: FormData) => {
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const result = await res.json();
if (!res.ok) {
const errorMessage = result.error || `업로드 실패 (상태 코드: ${res.status})`;
throw new Error(errorMessage);
}
return result;
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
throw new Error('네트워크 연결에 문제가 있습니다. 인터넷 연결을 확인해주세요.');
}
throw error;
}
throw new Error('파일 업로드 중 알 수 없는 오류가 발생했습니다.');
}
};
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const files = Array.from(formData.entries()).map(([, file]) => file as File);
const result = await Promise.all(
// 여러 파일 한 번에 업로드 처리
files.map(async (file) => {
const supabase = await createServerSupabaseClient();
const { data, error } = await supabase.storage
.from(process.env.NEXT_PUBLIC_STORAGE_BUCKET!)
.upload(file.name, file, { upsert: true });
if (error) {
throw new Error(error.message);
}
return { data };
}),
);
return NextResponse.json({ result });
} catch (err: unknown) {
const error = err as Error;
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
Supabase의 Signed URL 방식을 사용하면 클라이언트에서 직접 Supabase Storage에 업로드할 수 있다고 한다.
이 방식을 사용하게 되면 Next.js 서버는 딱 Signed URL을 발급하는 역할만 하게 된다.
Signed URL은 일정 시간 동안만 유효하고 Next.js 서버에서 인증된 사용자에게만 발급이 되는데, 이에 따라서 보안상 안전하게 사용할 수 있다고 한다.
supabase 의 createSignedUploadUrl() 를 통해 presignedUrl 을 발급해준다
import { NextRequest, NextResponse } from 'next/server';
import { createServerSupabaseClient } from 'utils/supabase/server';
export const runtime = 'nodejs';
export const maxDuration = 60;
// Signed URL 발급 전용 API
export async function POST(req: NextRequest) {
try {
const supabase = await createServerSupabaseClient();
const bucket = process.env.NEXT_PUBLIC_STORAGE_BUCKET;
if (!bucket) {
return NextResponse.json(
{ error: 'Missing bucket name' },
{ status: 500, headers: { 'Content-Type': 'application/json' } },
);
}
const body = await req.json();
const { fileName, fileType } = body;
if (!fileName || !fileType) {
return NextResponse.json(
{ error: 'fileName & fileType required' },
{ status: 400, headers: { 'Content-Type': 'application/json' } },
);
}
// 안전한 파일명 생성
const safeNameBase = fileName.replace(/[^a-zA-Z0-9._-]/g, '_') || 'image';
const fileExt = fileName.split('.').pop()?.toLowerCase() || 'jpg';
const finalName = `${Date.now()}_${Math.random()
.toString(36)
.substring(2, 8)}_${safeNameBase}.${fileExt}`;
// Signed URL 생성
const { data, error } = await supabase.storage.from(bucket).createSignedUploadUrl(finalName);
if (error) {
return NextResponse.json(
{ error: error.message },
{ status: 500, headers: { 'Content-Type': 'application/json' } },
);
}
if (!data?.signedUrl || !data?.path) {
return NextResponse.json(
{ error: 'Failed to create signed URL' },
{ status: 500, headers: { 'Content-Type': 'application/json' } },
);
}
return NextResponse.json(
{ signedUrl: data.signedUrl, path: data.path },
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
} catch (err) {
return NextResponse.json(
{ error: (err as Error).message ?? 'Unknown error' },
{ status: 500, headers: { 'Content-Type': 'application/json' } },
);
}
}
const handleUpload = async (files: File[]): Promise<Array<{ path: string }>> => {
// 순차 업로드로 JSON 파싱 에러 방지
const uploadResults: Array<{ path: string }> = [];
for (const file of files) {
try {
if (!file) throw new Error('파일이 없습니다.');
// 1) 서버에서 signed URL 요청
const signRes = await fetch('/api/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
fileType: file.type,
}),
});
// 응답이 JSON인지 확인
const contentType = signRes.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const text = await signRes.text();
throw new Error(`서버 응답 오류 (상태: ${signRes.status}): ${text.substring(0, 100)}`);
}
let signData;
try {
signData = await signRes.json();
} catch {
const text = await signRes.text();
throw new Error(`JSON 파싱 실패: ${text.substring(0, 100)}`);
}
if (!signRes.ok) {
throw new Error(signData.error || 'Signed URL 발급 실패');
}
if (!signData.signedUrl || !signData.path) {
throw new Error('Signed URL 또는 경로가 없습니다.');
}
// 2) signed URL로 직접 Supabase에 업로드
const uploadRes = await fetch(signData.signedUrl, {
method: 'PUT',
headers: {
'Content-Type': file.type,
},
body: file,
});
if (!uploadRes.ok) {
throw new Error(`Supabase 업로드 실패: ${uploadRes.statusText}`);
}
// 최종적으로 저장된 경로 반환
uploadResults.push({ path: signData.path });
} catch (err) {
throw new Error(
err instanceof Error ? `업로드 실패 (${file.name}): ${err.message}` : '업로드 실패',
);
}
}
return uploadResults;
};
| 항목 | 기존 방식 | Signed URL 방식 |
|---|---|---|
| 업로드 경로 | 클라이언트 → Next.js 서버 → Supabase | 클라이언트 → Supabase |
| Next.js 서버 역할 | 파일 수신 및 업로드 | Signed URL 발급만 |
| 파일 크기 제한 | 총 4MB | 총 10MB (개별 10MB) |
| Next.js 서버 부하 | 높음 (파일 전송) | 낮음 (URL 발급만) |
| 에러 발생 빈도 | 높음 (JSON 파싱 에러 등) | 낮음 (명확한 에러 처리) |
이렇게 Supabase의 Signed URL 방식을 사용하여 이미지 업로드 기능을 개선했다. 파일 크기 제한을 완화하는것에서 그치지 않고 Next.js 서버 부하를 줄일 수 있었고, 부가적으로 개별 파일에 대한 에러나 JSON 관련 등 에러 처리를 강화했다.
위와 같은 개선을 통해 사용자 경험을 향상시키고, Next.js 서버 리소스를 효율적으로 사용할 수 있게 되었다.