Supabase 이미지 업로드 기능 구현

디듀·2026년 3월 2일
post-thumbnail

이번에 사이드 프로젝트인 Trace를 만들면서 백엔드를 간결하게 구현하기 위해 Supabase를 사용했는데, 일반적인 회원가입에 OAuth는 물론이고 DB와 WebSocket에 이미지 업로드까지...... 게다가 제한적이기는 하지만 무료로 사용할 수 있다는 점이 정말 큰 장점인 것 같다. 프론트엔드 개발자가 혼자서 사이드 프로젝트를 작업할 때는 더욱이!

오늘은 다양한 기능 중에서 Supabase를 통해 간단하게 이미지 업로드 기능을 구현하는 방법을 알아보려고 한다.

해당 포스트는 이미 Supabase에서 프로젝트를 생성했고, 나의 어플리케이션과 연동되었다는 가정 하에서 진행된다.

1. 버켓 생성

먼저 Supabase 대시보드의 좌측 사이드바에서 Storage 메뉴를 클릭한다.

그러면 아래 사진과 같은 화면이 나타나고, 사진에 표시된 New bucket을 클릭해서 파일을 저장할 수 있는 일종의 폴더를 생성할 수 있다. uploads는 내가 Trace에서 사용하기 위해 이미 만들어 놓은 버켓이다. 포스트 이미지 및 프로필 이미지를 저장해 놓기 위해 사용하고 있다. 버켓을 클릭해서 내부 구조를 살펴보면...

이렇게 정말 폴더 구조처럼 생긴 것을 볼 수 있다. 가장 상위에는 uuid 형태의 userId로 폴더가 생성되고, 그 다음은 용도에 따라 두 가지의 폴더로 나뉜다. 숫자로 되어 있는 폴더 '69'는 postId를 의미하는데, 말 그대로 포스트에 첨부된 이미지를 저장하는 폴더이며 avatar는 해당 유저의 프로필 이미지를 저장하는 폴더이다. 폴더 구조는 어떻게 정해도 상관없지만, 추후 포스트가 삭제되거나 유저가 삭제되었을 때 함께 삭제하기 용이하도록 다음과 같은 구조로 결정했다.

다시 이전으로 돌아와서, New Bucket 버튼을 클릭하면 버켓에 대한 정보를 입력받는 모달이 나타난다.
첫 번째는 버켓의 이름, 두 번째는 로그인하지 않은 사용자도 파일에 접근할 수 있는지 여부를 설정하는 옵션이며 세 번째는 업로드할 파일의 사이즈를 제한하는 옵션, 마지막은 업로드할 파일의 확장자를 제한하는 옵션이다.
Trace의 경우에는 public bucket으로 설정하였고 (url로 파일 접근을 용이하게 하기 위해) 파일 사이즈는 5MB로 제한, MIME type은 이미지로 제한하였다.

2. 정책 설정

Supabase의 강력한 기능 중 하나는, 웹 페이지에서 간단한 조작으로 CRUD 시의 보안 관련 설정을 할 수 있다는 것이다. 위에서 말한 것처럼 이미지를 조회하는 것은 누구든 가능하지만, 업로드는 로그인한 유저가 본인의 폴더에만 가능해야 하고 수정 및 삭제는 이미지를 올린 본인만 할 수 있도록 막아야 할 필요가 있다. 이것을 클라이언트 사이드에서 제어할 필요가 없이, 방금 봤던 화면의 상단에서 Policies 메뉴를 클릭하여 관련 정책을 등록할 수 있다.
아래 사진을 보면 UPLOADS 버켓에 이미 4가지의 정책이 등록된 것을 확인할 수 있다.

select

누구든 업로드한 이미지를 볼 수 있다.

create, update, delete

추가/수정/삭제를 요청한 유저의 아이디가 버켓의 이름과 동일해야만 삭제할 수 있다.


위와 같이 정책을 설정하고 나면 이제 기본적인 세팅은 끝났다고 할 수 있다.

3. 이미지 업로드

이미지 업로드는 크게 어려울 것이 없다. supbase 클라이언트를 불러와서 storage 메서드를 아래와 같이 사용하면 된다. from메서드에는 매개변수로 버켓의 이름(해당 포스트의 경우 UPLOADS)을, upload메서드에는 파일을 저장할 경로와 해당하는 파일을 함께 넘겨주면 된다.
정상적으로 파일이 업로드가 되면 getPublicUrl 메서드를 통해서 실제 해당 파일에 접근할 수 있는 publicUrl을 얻을 수 있다. 업로드 후 img 태그에 해당 URL을 넣어주면 업로드한 이미지를 확인할 수 있는 것이다!

/** -----------------------------
 * @description 이미지 업로드
 * @param file 업로드할 파일
 * @param filePath 파일 경로
 * @returns 업로드된 이미지 URL
 * ----------------------------- */
export const uploadImage = async ({
  file,
  filePath,
}: {
  file: File;
  filePath: string;
}) => {
  const { data, error } = await supabase.storage
    .from(BUCKET_NAME)
    .upload(filePath, file);

  if (error) throw error;

  const {
    data: { publicUrl },
  } = supabase.storage.from(BUCKET_NAME).getPublicUrl(data.path);

  return publicUrl;
};

아래는 실제 사용하고 있는 코드의 예시이다. 먼저 파일 이름이 중복되지 않도록 현재 일시(ms)와 uuid로 새로운 파일명을 만들어주고, 위에서 설명했던 것처럼 /유저아이디/포스트아이디 위치에 저장될 수 있도록 경로를 지정해준다. 그리고 반환된 publicUrl을 post 테이블에 함께 저장해주는 것이다.

if (images.length > 0) {
  // * 2. 이미지 업로드
  imagesUrls = await Promise.all(
    images.map((image) => {
      const fileExtension = image.name.split(".").pop() || "webp";
      const fileName = `${Date.now()}-${crypto.randomUUID()}.${fileExtension}`;
      const filePath = `${userId}/${post.id}/${fileName}`;
      return uploadImage({ file: image, filePath });
    })
  );
}

4. 이미지 삭제

만약 이미지를 첨부했던 포스트가 삭제되었을 경우에는, 굳이 삭제된 포스트의 이미지를 버켓 안에 계속 가지고 있을 이유가 없다. 이때는 우리가 지정했던 경로 내에 있는 파일 리스트를 받아와서 그것들을 삭제하는 방식으로 버켓을 비워준다.

/** -----------------------------
 * @description 이미지 삭제
 * @param path 파일 경로
 * @returns 이미지 삭제 결과
 * ----------------------------- */
export const deleteImagesInPath = async (path: string) => {
  const { data: files, error: fetchFilesError } = await supabase.storage
    .from(BUCKET_NAME)
    .list(path);

  if (!files || files.length === 0) return;
  if (fetchFilesError) throw fetchFilesError;

  const { error: removeError } = await supabase.storage
    .from(BUCKET_NAME)
    .remove(files.map((file) => `${path}/${file.name}`));

  if (removeError) throw removeError;
};

실제로 post 삭제 함수 내에서는 아래와 같은 방식으로 호출하고 있다.

// * 이미지 삭제
if (deletedPost.image_urls && deletedPost.image_urls.length > 0) {
  await deleteImagesInPath(`${deletedPost.author_id}/${deletedPost.id}`);

상당히 간단하게 이미지를 업로드하고, 또 업로드한 이미지를 삭제하는 기능을 구현해 보았다. 알면 알수록 개발을 편리하게 해주는 도구들이 많은 것 같다...... 배움에는 정말 끝이 없구나!

profile
세상에서 가장 부지런한 사람이 되고 싶은 게으름뱅이

0개의 댓글