Supabase 완전 정복 시리즈 6편 — 스토리지(Storage) 완벽 가이드: 파일 업로드, 이미지 최적화, CDN




시리즈 목차 1편 – Supabase란 무엇인가? Firebase와 제대로 비교해보기 2편 – 데이터베이스 & 자동 API (REST/GraphQL) 3편 – 인증(Auth) — 소셜 로그인, JWT, MFA, SSO 4편 – RLS 보안 — Row Level Security 완벽 가이드 5편 – 실시간 기능(Realtime) — Broadcast, Presence, Postgres Changes 6편 👉 스토리지(Storage) — 파일 업로드, 이미지 최적화, CDN (현재 글) 7편 – Edge Functions — 서버리스 함수 활용법 …


들어가며

파일 업로드는 거의 모든 서비스에서 필요한 기능입니다. 프로필 사진, 게시글 첨부 파일, 상품 이미지 — 이런 파일들을 어디에, 어떻게 저장하고 서빙하느냐는 서비스 성능과 보안 모두에 영향을 미칩니다.

Supabase Storage는 S3 호환 오브젝트 스토리지로, 다음 특징을 갖고 있습니다.

  • PostgreSQL의 RLS와 통합된 세밀한 접근 제어
  • 이미지 리사이징, WebP 변환 등 온디맨드 이미지 변환
  • 전 세계 빠른 서빙을 위한 Smart CDN 내장
  • 공개/비공개 버킷 구분
  • 대용량 파일 Resumable Upload 지원

이번 편에서 다룰 내용:

  • 버킷(Bucket) 개념과 생성
  • 파일 업로드 (단순 업로드 / Resumable Upload)
  • 파일 조회 (공개 URL / Signed URL)
  • 파일 삭제 및 이동
  • 이미지 변환 (리사이징, WebP, 크롭)
  • Next.js Image 컴포넌트와 Supabase 커스텀 로더 연동
  • Smart CDN 캐싱 전략
  • Storage RLS 설정
  • 실전 패턴 (프로필 사진, 게시글 첨부 파일, 드래그 앤 드롭)

스토리지 구조

버킷(Bucket)

Storage의 최상위 단위는 버킷입니다. 버킷은 파일을 담는 컨테이너로, 공개(Public)와 비공개(Private) 두 종류가 있습니다.

종류특징활용 사례
PublicURL만 알면 누구나 접근 가능상품 이미지, 공개 에셋
PrivateRLS 정책 또는 Signed URL로만 접근개인 파일, 첨부 문서

버킷은 대시보드 → Storage → Create bucket에서 생성하거나 SQL로 만들 수 있습니다.

-- SQL로 버킷 생성
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
  'avatars',       -- 버킷 ID
  'avatars',       -- 버킷 이름
  true,            -- 공개 여부
  5242880,         -- 파일 크기 제한: 5MB (bytes)
  ARRAY['image/jpeg', 'image/png', 'image/webp', 'image/gif']  -- 허용 MIME 타입
);

INSERT INTO storage.buckets (id, name, public, file_size_limit)
VALUES (
  'documents',
  'documents',
  false,           -- 비공개
  52428800         -- 50MB
);

파일 경로 설계

버킷 내 파일 경로는 폴더/파일명 형태로 자유롭게 구성합니다. 사용자별 파일 분리를 위한 권장 패턴:

avatars/
  {user_id}/avatar.jpg          → 프로필 사진 (덮어쓰기)
  {user_id}/avatar_v2.jpg       → 버전 관리

posts/
  {user_id}/{post_id}/thumb.jpg → 게시글 썸네일
  {user_id}/{post_id}/image1.jpg

documents/
  {user_id}/{timestamp}-{random}.pdf  → 고유 파일명

파일 업로드

기본 업로드 (Client Component)

// components/FileUpload.tsx
'use client'

import { useState, useRef } from 'react'
import { createClient } from '@/lib/supabase/client'

interface Props {
  bucket: string
  path: string
  onUpload: (url: string) => void
  accept?: string
  maxSizeMB?: number
}

export default function FileUpload({
  bucket,
  path,
  onUpload,
  accept = 'image/*',
  maxSizeMB = 5,
}: Props) {
  const [uploading, setUploading] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const inputRef = useRef<HTMLInputElement>(null)
  const supabase = createClient()

  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return

    // 파일 크기 검증
    if (file.size > maxSizeMB * 1024 * 1024) {
      setError(`파일 크기는 ${maxSizeMB}MB 이하여야 합니다.`)
      return
    }

    setUploading(true)
    setError(null)

    try {
      // 고유 파일명 생성 (충돌 방지)
      const ext = file.name.split('.').pop()
      const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
      const filePath = `${path}/${fileName}`

      const { error: uploadError } = await supabase.storage
        .from(bucket)
        .upload(filePath, file, {
          cacheControl: '3600',   // 브라우저 캐시 1시간
          upsert: false,          // 중복 파일 덮어쓰기 방지
        })

      if (uploadError) throw uploadError

      // 공개 URL 생성
      const { data: { publicUrl } } = supabase.storage
        .from(bucket)
        .getPublicUrl(filePath)

      onUpload(publicUrl)
    } catch (err: unknown) {
      setError(err instanceof Error ? err.message : '업로드 실패')
    } finally {
      setUploading(false)
      if (inputRef.current) inputRef.current.value = ''
    }
  }

  return (
    <div>
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        onChange={handleUpload}
        disabled={uploading}
        className="hidden"
        id="file-upload"
      />
      <label
        htmlFor="file-upload"
        className={`cursor-pointer inline-flex items-center gap-2 px-4 py-2 border rounded-lg
          ${uploading ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50'}`}
      >
        {uploading ? '업로드 중...' : '파일 선택'}
      </label>
      {error && <p className="text-red-500 text-sm mt-1">{error}</p>}
    </div>
  )
}

프로필 사진 업로드 (덮어쓰기 패턴)

프로필 사진은 항상 같은 경로에 덮어쓰는 것이 일반적입니다.

// app/settings/profile/actions.ts
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

export async function uploadAvatar(formData: FormData) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('로그인이 필요합니다.')

  const file = formData.get('avatar') as File
  if (!file || file.size === 0) throw new Error('파일을 선택해주세요.')

  // 허용 타입 검증
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
  if (!allowedTypes.includes(file.type)) {
    throw new Error('JPG, PNG, WebP 형식만 허용됩니다.')
  }

  // 사용자 ID 기반 고정 경로 (덮어쓰기)
  const ext = file.type.split('/')[1]
  const filePath = `${user.id}/avatar.${ext}`

  const { error } = await supabase.storage
    .from('avatars')
    .upload(filePath, file, {
      upsert: true,  // 덮어쓰기 허용
      cacheControl: '3600',
    })

  if (error) throw new Error(error.message)

  // profiles 테이블에 URL 저장
  const { data: { publicUrl } } = supabase.storage
    .from('avatars')
    .getPublicUrl(filePath)

  await supabase
    .from('profiles')
    .update({ avatar_url: publicUrl })
    .eq('id', user.id)

  revalidatePath('/settings/profile')
  return { url: publicUrl }
}

대용량 파일 — Resumable Upload

5MB 이상의 대용량 파일은 Resumable Upload를 사용합니다. 네트워크가 끊겨도 이어서 업로드할 수 있습니다.

// 대용량 파일 Resumable Upload
import { createClient } from '@/lib/supabase/client'

async function uploadLargeFile(file: File, filePath: string) {
  const supabase = createClient()

  const { data, error } = await supabase.storage
    .from('documents')
    .uploadToSignedUrl(filePath, '', file, {
      // 업로드 진행률 추적
      onUploadProgress: (progress) => {
        const percent = (progress.loaded / progress.total) * 100
        console.log(`업로드 진행률: ${percent.toFixed(1)}%`)
      },
    })

  return { data, error }
}

드래그 앤 드롭 업로드

// components/DropZone.tsx
'use client'

import { useState, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'

interface Props {
  bucket: string
  userId: string
  onUpload: (urls: string[]) => void
}

export default function DropZone({ bucket, userId, onUpload }: Props) {
  const [isDragging, setIsDragging] = useState(false)
  const [uploading, setUploading] = useState(false)
  const supabase = createClient()

  const handleDrop = useCallback(
    async (e: React.DragEvent) => {
      e.preventDefault()
      setIsDragging(false)

      const files = Array.from(e.dataTransfer.files).filter((f) =>
        f.type.startsWith('image/')
      )
      if (!files.length) return

      setUploading(true)

      const uploadedUrls = await Promise.all(
        files.map(async (file) => {
          const ext = file.name.split('.').pop()
          const filePath = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`

          await supabase.storage.from(bucket).upload(filePath, file)

          const { data: { publicUrl } } = supabase.storage
            .from(bucket)
            .getPublicUrl(filePath)

          return publicUrl
        })
      )

      onUpload(uploadedUrls)
      setUploading(false)
    },

[bucket, userId, onUpload]

) return ( <div onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }} onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} className={`border-2 border-dashed rounded-xl p-12 text-center transition-colors ${isDragging ? ‘border-blue-500 bg-blue-50’ : ‘border-gray-300’} ${uploading ? ‘opacity-50 pointer-events-none’ : ”}`} > {uploading ? ( <p className=”text-gray-500″>업로드 중…</p> ) : ( <p className=”text-gray-500″> 이미지를 드래그하거나 <span className=”text-blue-500 underline cursor-pointer”>클릭</span>하여 업로드 </p> )} </div> ) }


파일 조회

공개 URL (Public Bucket)

// 공개 버킷의 파일 URL 생성
const { data: { publicUrl } } = supabase.storage
  .from('avatars')
  .getPublicUrl('user-id/avatar.jpg')

// publicUrl: https://[project].supabase.co/storage/v1/object/public/avatars/user-id/avatar.jpg

Signed URL (Private Bucket)

비공개 파일은 서명된 임시 URL을 생성해서 제공합니다.

// Server Action / API Route에서 Signed URL 생성
export async function getSignedUrl(filePath: string) {
  const supabase = await createClient()

  const { data, error } = await supabase.storage
    .from('documents')
    .createSignedUrl(
      filePath,
      60 * 60, // 유효 시간: 1시간 (초 단위)
    )

  if (error) throw error
  return data.signedUrl
}

// 여러 파일의 Signed URL 한 번에 생성
export async function getMultipleSignedUrls(paths: string[]) {
  const supabase = await createClient()

  const { data, error } = await supabase.storage
    .from('documents')
    .createSignedUrls(paths, 3600)

  if (error) throw error
  return data
}

파일 목록 조회

// 특정 폴더의 파일 목록
const { data: files, error } = await supabase.storage
  .from('documents')
  .list(`${userId}/`, {
    limit: 20,
    offset: 0,
    sortBy: { column: 'created_at', order: 'desc' },
  })

파일 삭제 및 이동

// 단일 파일 삭제
const { error } = await supabase.storage
  .from('avatars')
  .remove([`${userId}/avatar.jpg`])

// 여러 파일 한 번에 삭제
const { error } = await supabase.storage
  .from('documents')
  .remove([
    `${userId}/file1.pdf`,
    `${userId}/file2.pdf`,
  ])

// 파일 이동 (복사 후 삭제)
const { error } = await supabase.storage
  .from('documents')
  .move(
    `${userId}/old-path/file.pdf`,   // 원본 경로
    `${userId}/new-path/file.pdf`    // 대상 경로
  )

// 파일 복사
const { error } = await supabase.storage
  .from('documents')
  .copy(
    `${userId}/original.pdf`,
    `${userId}/copy.pdf`
  )

이미지 변환 (Image Transformations)

Supabase Storage는 이미지를 온디맨드로 변환해 서빙합니다. Pro 플랜 이상에서 사용 가능합니다.

변환 옵션

파라미터설명
width너비 (px)정수
height높이 (px)정수
quality품질20 ~ 100 (기본값: 80)
resize리사이즈 모드cover, contain, fill
format출력 포맷origin, avif (자동 WebP 변환)

getPublicUrl로 변환된 URL 생성

// lib/storage.ts
import { createClient } from '@/lib/supabase/client'

const supabase = createClient()

// 썸네일 URL 생성 (200x200, WebP)
export function getThumbnailUrl(bucket: string, path: string) {
  const { data: { publicUrl } } = supabase.storage
    .from(bucket)
    .getPublicUrl(path, {
      transform: {
        width: 200,
        height: 200,
        resize: 'cover',
        quality: 80,
      },
    })
  return publicUrl
}

// 다양한 크기별 URL 생성
export function getResponsiveUrls(bucket: string, path: string) {
  const sizes = [
    { name: 'thumbnail', width: 150, height: 150 },
    { name: 'small',     width: 400, height: 300 },
    { name: 'medium',    width: 800, height: 600 },
    { name: 'large',     width: 1200, height: 900 },
  ]

  return sizes.reduce((acc, size) => {
    const { data: { publicUrl } } = supabase.storage
      .from(bucket)
      .getPublicUrl(path, {
        transform: {
          width: size.width,
          height: size.height,
          resize: 'cover',
          quality: 80,
        },
      })
    acc[size.name] = publicUrl
    return acc
  }, {} as Record<string, string>)
}

Signed URL로 변환된 비공개 이미지 서빙

// 비공개 이미지도 변환 가능
const { data } = await supabase.storage
  .from('private-images')
  .createSignedUrl('user-id/photo.jpg', 3600, {
    transform: {
      width: 400,
      height: 400,
      resize: 'cover',
    },
  })

Next.js Image 컴포넌트 연동

커스텀 로더 설정

Supabase Storage의 이미지 변환 기능을 Next.js <Image> 컴포넌트와 연동하면 반응형 이미지 최적화가 자동으로 됩니다.

// supabase-image-loader.js (프로젝트 루트)
const projectId = process.env.NEXT_PUBLIC_SUPABASE_PROJECT_ID

export default function supabaseLoader({ src, width, quality }) {
  return `https://${projectId}.supabase.co/storage/v1/render/image/public/${src}?width=${width}&quality=${quality ?? 75}`
}
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    loader: 'custom',
    loaderFile: './supabase-image-loader.js',
  },
}

module.exports = nextConfig
// 사용 예시 — Next.js Image 컴포넌트
import Image from 'next/image'

interface Props {
  avatarPath: string  // 예: 'avatars/user-id/avatar.jpg'
  name: string
}

export function Avatar({ avatarPath, name }: Props) {
  return (
    <Image
      src={avatarPath}          // 버킷명/경로 형태
      alt={name}
      width={64}
      height={64}
      className="rounded-full object-cover"
      // Next.js가 srcset으로 자동 크기별 URL 요청 →
      // Supabase가 해당 크기로 자동 리사이징
    />
  )
}

로더 없이 직접 변환 URL 사용

// 커스텀 로더 없이 변환 URL 직접 사용할 때
import Image from 'next/image'

const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL

function PostImage({ bucket, path }: { bucket: string; path: string }) {
  const imageUrl = `${SUPABASE_URL}/storage/v1/render/image/public/${bucket}/${path}`

  return (
    <Image
      src={imageUrl}
      alt="게시글 이미지"
      width={800}
      height={600}
      sizes="(max-width: 768px) 100vw, 800px"
    />
  )
}

Smart CDN 캐싱 전략

Supabase Storage는 Smart CDN을 통해 파일을 전 세계 엣지 서버에 캐싱합니다.

업로드 시 캐시 제어

// 업로드 시 Cache-Control 헤더 설정
await supabase.storage.from('avatars').upload(filePath, file, {
  cacheControl: '31536000', // 1년 캐싱 (자주 바뀌지 않는 에셋)
  upsert: true,
})

// 자주 갱신되는 파일
await supabase.storage.from('documents').upload(filePath, file, {
  cacheControl: '3600', // 1시간
})

캐시 무효화 패턴

파일을 업데이트할 때 CDN 캐시는 최대 60초 후 자동 무효화됩니다. 즉각적인 갱신이 필요하다면 파일 경로에 버전을 포함하는 패턴을 사용합니다.

// ❌ 같은 경로에 덮어쓰면 CDN 캐시가 60초간 유지될 수 있음
await supabase.storage.from('images').upload('user/profile.jpg', file, { upsert: true })

// ✅ 버전을 경로에 포함해 즉각적인 갱신
const version = Date.now()
const filePath = `user/profile_v${version}.jpg`
await supabase.storage.from('images').upload(filePath, file)

// DB에 새 경로 저장
await supabase.from('profiles').update({ avatar_path: filePath }).eq('id', userId)

// 이전 파일 삭제
await supabase.storage.from('images').remove([oldPath])

Storage RLS 설정

Storage의 보안은 storage.objects 테이블에 RLS 정책을 적용해 제어합니다. (4편 RLS 가이드 참고)

-- 1. 공개 버킷(avatars): 누구나 읽기, 본인만 쓰기
CREATE POLICY "아바타 공개 읽기"
  ON storage.objects FOR SELECT
  TO public
  USING (bucket_id = 'avatars');

CREATE POLICY "본인 아바타 업로드"
  ON storage.objects FOR INSERT
  TO authenticated
  WITH CHECK (
    bucket_id = 'avatars'
    AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
  );

CREATE POLICY "본인 아바타 수정"
  ON storage.objects FOR UPDATE
  TO authenticated
  USING (
    bucket_id = 'avatars'
    AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
  );

CREATE POLICY "본인 아바타 삭제"
  ON storage.objects FOR DELETE
  TO authenticated
  USING (
    bucket_id = 'avatars'
    AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
  );

-- 2. 비공개 버킷(documents): 본인 파일만 접근
CREATE POLICY "본인 문서 읽기"
  ON storage.objects FOR SELECT
  TO authenticated
  USING (
    bucket_id = 'documents'
    AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
  );

CREATE POLICY "본인 문서 업로드"
  ON storage.objects FOR INSERT
  TO authenticated
  WITH CHECK (
    bucket_id = 'documents'
    AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
  );

CREATE POLICY "본인 문서 삭제"
  ON storage.objects FOR DELETE
  TO authenticated
  USING (
    bucket_id = 'documents'
    AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
  );

storage.foldername(name)[1]은 파일 경로의 첫 번째 폴더명을 반환합니다. 경로를 {user_id}/파일명 구조로 설계하면 이 패턴이 깔끔하게 동작합니다.


실전: 게시글 이미지 업로드 완성 예시

// app/posts/new/ImageUploader.tsx
'use client'

import { useState } from 'react'
import Image from 'next/image'
import { createClient } from '@/lib/supabase/client'

interface Props {
  userId: string
  postId: string
  onImagesChange: (paths: string[]) => void
}

export default function ImageUploader({ userId, postId, onImagesChange }: Props) {
  const [images, setImages] = useState<{ path: string; url: string }[]>([])
  const [uploading, setUploading] = useState(false)
  const supabase = createClient()

  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = Array.from(e.target.files ?? [])
    if (!files.length) return

    // 최대 5장 제한
    if (images.length + files.length > 5) {
      alert('이미지는 최대 5장까지 업로드할 수 있습니다.')
      return
    }

    setUploading(true)

    const uploaded = await Promise.all(
      files.map(async (file) => {
        const ext = file.name.split('.').pop()
        const path = `${userId}/${postId}/${Date.now()}.${ext}`

        await supabase.storage.from('posts').upload(path, file, {
          cacheControl: '86400', // 1일
        })

        const { data: { publicUrl } } = supabase.storage
          .from('posts')
          .getPublicUrl(path, {
            transform: { width: 800, quality: 85 },
          })

        return { path, url: publicUrl }
      })
    )

    const newImages = [...images, ...uploaded]
    setImages(newImages)
    onImagesChange(newImages.map((img) => img.path))
    setUploading(false)
  }

  const removeImage = async (index: number) => {
    const { path } = images[index]

    await supabase.storage.from('posts').remove([path])

    const newImages = images.filter((_, i) => i !== index)
    setImages(newImages)
    onImagesChange(newImages.map((img) => img.path))
  }

  return (
    <div className="space-y-4">
      {/* 이미지 미리보기 */}
      <div className="grid grid-cols-3 gap-2">
        {images.map((img, index) => (
          <div key={img.path} className="relative aspect-square">
            <Image
              src={img.url}
              alt={`업로드 이미지 ${index + 1}`}
              fill
              className="object-cover rounded-lg"
            />
            <button
              onClick={() => removeImage(index)}
              className="absolute top-1 right-1 bg-black/50 text-white rounded-full w-6 h-6 text-sm"
            >
              ×
            </button>
          </div>
        ))}

        {/* 업로드 버튼 */}
        {images.length < 5 && (
          <label className="aspect-square border-2 border-dashed rounded-lg flex items-center justify-center cursor-pointer hover:bg-gray-50">
            <input
              type="file"
              accept="image/*"
              multiple
              onChange={handleFileChange}
              disabled={uploading}
              className="hidden"
            />
            <span className="text-3xl text-gray-400">
              {uploading ? '⏳' : '+'}
            </span>
          </label>
        )}
      </div>

      <p className="text-xs text-gray-500">
        {images.length}/5장 · JPG, PNG, WebP · 최대 5MB
      </p>
    </div>
  )
}

자주 하는 실수

1. 공개 버킷에 민감한 파일 저장

❌ 공개 버킷에 개인정보, 계약서, 내부 문서 저장
✅ 민감한 파일은 반드시 비공개 버킷 + Signed URL 사용

2. 파일명에 한글/특수문자 사용

// ❌ 원본 파일명 그대로 사용 → 한글, 공백, 특수문자 문제
await supabase.storage.from('bucket').upload(file.name, file)

// ✅ 안전한 파일명 생성
const safeFileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`

3. RLS 없이 버킷 운영

버킷을 Public으로 설정해도 RLS로 업로드/삭제 권한은 반드시 제어해야 합니다. RLS 없이는 누구든 파일을 업로드하거나 삭제할 수 있습니다.

4. 파일 삭제 없이 DB 레코드만 삭제

// ❌ DB에서만 삭제 → Storage에 고아 파일 남음
await supabase.from('posts').delete().eq('id', postId)

// ✅ Storage 파일도 함께 삭제
const { data: post } = await supabase.from('posts').select('image_paths').eq('id', postId).single()
if (post?.image_paths?.length) {
  await supabase.storage.from('posts').remove(post.image_paths)
}
await supabase.from('posts').delete().eq('id', postId)

마치며

Supabase Storage는 단순한 파일 저장소를 넘어, 이미지 변환, Smart CDN, RLS 기반 접근 제어까지 갖춘 프로덕션급 스토리지 솔루션입니다. 특히 Next.js <Image> 컴포넌트와 커스텀 로더를 결합하면, 별도의 이미지 최적화 서비스 없이도 반응형 이미지를 손쉽게 제공할 수 있습니다.

다음 편에서는 Edge Functions 를 다룹니다. Deno 기반의 서버리스 함수로 커스텀 API, 웹훅, 이메일 발송, 결제 처리 등 백엔드 로직을 Next.js App Router와 함께 어떻게 구현하는지 살펴봅니다.




댓글 남기기