Supabase 이미지 업로드: Signed URL 방식

hyejinJo·2025년 11월 30일

에러 처리

목록 보기
7/7
post-thumbnail

Next.js와 Supabase를 사용한 프로젝트에서 이미지 업로드 기능을 구현할 때, 초기에는 Next.js 서버(API Route)를 경유하는 방식으로 구현했다. 하지만 그 과정에서 여러 문제점들이 발견되어 Supabase의 Signed URL 방식을 활용한 직접 업로드 방식으로 전환하게 되었다

지인들에게 각각 모바일, pc 로 이미지테스트를 부탁했을 때 아래와 같은 에러들이 발생한 상황이었다..

기존 이미지 업로드 방식

구현 방식

기존에는 클라이언트에서 파일을 Next.js 서버(API Route)로 전송하고, Next.js 서버에서 Supabase Storage에 업로드하는 방식이었다.

클라이언트 코드 (PostForm.tsx)

// 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('파일 업로드 중 알 수 없는 오류가 발생했습니다.');
  }
};

서버 코드 (api/upload/route.ts)

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 });
  }
}

⚠️ 기존 방식의 문제점

  • 다수의 이미지 업로드 시 413 Payload Too Large 에러 발생
  • 업로드되는 파일이 Next 서버를 경유하면서 자체 파일 용량을 제한
    (Next.js의 API Route나 Server Actions은 기본적으로 요청 본문(Payload) 크기를 1MB에서 4MB 사이로 매우 작게 설정한다고 한다.. 어쩐지 4mb 정도가 넘어가면 에러가 계속 떴는데 이 부분도 원인이었다..)
  • 에러 응답이 JSON 형식이 아닐 때 파싱 실패

✅ 해결 방법: Signed URL 방식

Supabase의 Signed URL 방식을 사용하면 클라이언트에서 직접 Supabase Storage에 업로드할 수 있다고 한다.
이 방식을 사용하게 되면 Next.js 서버는 딱 Signed URL을 발급하는 역할만 하게 된다.
Signed URL은 일정 시간 동안만 유효하고 Next.js 서버에서 인증된 사용자에게만 발급이 되는데, 이에 따라서 보안상 안전하게 사용할 수 있다고 한다.

새로운 구현 방식

서버 코드 (api/upload/route.ts) - Signed URL 발급만 담당

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' } },
    );
  }
}

클라이언트 코드 (PostForm.tsx) - 직접 업로드

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 파싱 에러 등)낮음 (명확한 에러 처리)
  • Next.js 서버는 파일을 받지 않고 Signed URL만 발급하므로 메모리 사용량이 크게 감소
  • Next.js 서버를 거치지 않으므로 Next.js의 자체의 파일 크기 제한 없이 업로드 가능 (Supabase Storage의 제한만 따름)
  • 순차 업로드로 변경하여 JSON 파싱 에러를 방지
  • 서버 응답 형식 검증 강화
  • 클라이언트에서 직접 Supabase로 업로드하므로 더 빠른 업로드 속도

이렇게 Supabase의 Signed URL 방식을 사용하여 이미지 업로드 기능을 개선했다. 파일 크기 제한을 완화하는것에서 그치지 않고 Next.js 서버 부하를 줄일 수 있었고, 부가적으로 개별 파일에 대한 에러나 JSON 관련 등 에러 처리를 강화했다.

위와 같은 개선을 통해 사용자 경험을 향상시키고, Next.js 서버 리소스를 효율적으로 사용할 수 있게 되었다.

profile
Frontend💡

0개의 댓글