Supabase 입문: PostgreSQL + Auth + Storage 올인원 백엔드

화면에 뜬 열두 번째 데이터베이스 연결 오류를 바라보다가 한 가지를 깨달았습니다. 프런트엔드 개발자가 풀스택 프로젝트 하나를 만들기는 정말 어렵습니다.
예전에는 백엔드 기능이 필요할 때마다 Node.js와 Express를 배우고 데이터베이스 설정, 사용자 인증, 파일 스토리지까지 해결해야 했습니다. 어느 하나 쉬운 구석이 없었습니다. 그러다 Supabase를 만났습니다.
쉽게 말해 Supabase는 오픈 소스 Firebase 대안입니다. 다만 골치 아픈 NoSQL이 아니라 PostgreSQL을 사용합니다. 데이터베이스, 사용자 인증, 파일 스토리지까지 세 가지를 하나로 묶은 올인원 백엔드 서비스입니다.
이 글에서는 Supabase를 처음부터 시작하며 Database, Auth, Storage라는 세 가지 핵심 기능을 집중적으로 살펴봅니다. 끝까지 읽으면 복잡한 설정 파일에 시달리지 않고도 완전한 백엔드를 빠르게 구축할 수 있을 것입니다.
Supabase란 무엇인가
먼저 Supabase가 정확히 무엇인지 살펴보겠습니다.
Supabase는 BaaS, 즉 Backend as a Service 플랫폼입니다. 서버를 직접 구축하거나 데이터베이스를 설정하고 API를 작성하지 않아도 이런 작업을 대신 처리해 주는 서비스입니다.
Firebase와 달리 Supabase는 완전한 오픈 소스입니다. PostgreSQL을 사용한다는 점도 중요합니다. PostgreSQL은 관계형 데이터베이스이므로 복잡한 SQL 쿼리를 실행할 수 있고 데이터 관계도 명확합니다. 문서형 데이터베이스인 Firestore보다 훨씬 편리하게 사용할 수 있습니다.
Supabase에는 여섯 가지 핵심 기능이 있습니다.
- Database: SQL 쿼리를 지원하고 REST API까지 자동 생성하는 PostgreSQL 데이터베이스
- Auth: 이메일 로그인, Google이나 GitHub 같은 소셜 로그인, JWT Token을 지원하는 사용자 인증 시스템
- Storage: AWS S3와 비슷하지만 더 간단한 파일 스토리지
- Realtime: 채팅 애플리케이션에 적합한 실시간 데이터 동기화
- Edge Functions: AWS Lambda와 비슷한 엣지 컴퓨팅
- Vector Database: AI 애플리케이션에 적합한 벡터 데이터베이스
이 글에서는 그중 가장 핵심인 Database, Auth, Storage 세 가지에만 집중합니다. 나머지 기능은 나중에 살펴보겠습니다.
여기까지 읽으면 이런 생각이 들 수 있습니다. 오픈 소스이고 PostgreSQL을 사용하며 인증과 스토리지까지 제공한다면 Firebase와 비교해 어느 쪽이 더 좋을까요? 뒤에서 자세히 비교하겠지만 먼저 결론부터 말하면, SQL 쿼리가 필요하거나 데이터 관계가 복잡하거나 데이터를 직접 통제하고 싶다면 Supabase를 선택하세요. 모바일 애플리케이션을 만들고 강력한 실시간 동기화가 필요하다면 Firebase가 더 적합합니다.
빠르게 시작하기
Supabase 프로젝트 만들기
첫 단계는 supabase.com에서 계정을 등록하는 것입니다. 등록을 마치면 “New Project”를 눌러 새 프로젝트를 만듭니다.
프로젝트를 만들 때 다음 정보를 입력해야 합니다.
- 프로젝트 이름: 원하는 이름을 사용합니다. 예:
my-first-app - 데이터베이스 비밀번호: 나중에 사용하므로 반드시 기록해 둡니다.
- 리전: 가까운 곳을 선택합니다. 중국에서는 Singapore 또는 Tokyo를 선택합니다.
프로젝트 생성에는 약 3분이 걸립니다. 생성이 끝나면 프로젝트 패널의 Dashboard가 표시됩니다. 왼쪽에는 Table Editor, Authentication, Storage, Edge Functions 등 다양한 기능 메뉴가 있습니다. 많아 보여도 걱정하지 마세요. 이 글에서 하나씩 설명합니다.
설치 및 초기화
이제 프런트엔드 프로젝트에 Supabase를 설치합니다. 먼저 의존성을 설치합니다.
npm install @supabase/supabase-js
설치가 끝나면 Supabase Dashboard에서 Project URL과 Anon Public Key를 찾습니다. 둘 다 Settings > API에서 확인할 수 있습니다.
그런 다음 Supabase Client를 초기화합니다.
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'https://your-project-id.supabase.co';
const supabaseAnonKey = 'your-anon-public-key';
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
이 코드는 Supabase와 통신하는 진입점입니다. 이후의 데이터베이스 쿼리, 사용자 로그인, 파일 업로드는 모두 이 supabase 객체를 통해 수행합니다.
연결 테스트
초기화를 마쳤다면 먼저 연결 여부를 테스트합니다. 가장 간단한 방법은 데이터베이스에 데이터가 있는지 조회하는 것입니다.
const { data, error } = await supabase.from('users').select('*');
if (error) {
console.error('연결 실패:', error.message);
} else {
console.log('연결 성공!', data);
}
users 테이블을 찾을 수 없다는 오류가 나와도 정상입니다. 아직 테이블을 만들지 않았기 때문입니다. 다음 절에서 만드는 방법을 설명합니다.
자주 발생하는 오류는 다음과 같습니다.
- URL이나 Key 오타: 전체 값을 정확히 복사했는지 확인합니다.
- CORS 오류: 프런트엔드 프로젝트에서 CORS 설정이 필요할 수 있지만 Supabase는 기본적으로 허용합니다.
- 네트워크 문제: 가끔 발생할 수 있으므로 몇 분 뒤 다시 시도합니다.
모든 것이 정상이라면 반환된 데이터를 볼 수 있습니다. 빈 배열 []이라면 테이블에 아직 데이터가 없다는 뜻입니다.
Database: 데이터베이스 작업
Database는 Supabase의 핵심입니다. PostgreSQL을 사용해 봤다면 익숙하게 느껴질 것입니다. SQL에 익숙하지 않은 프런트엔드 개발자라도 Supabase의 시각적 Table Editor를 이용하면 SQL 없이 데이터베이스를 조작할 수 있습니다.
데이터 테이블 만들기
테이블은 Table Editor를 사용하는 시각적 방식과 SQL을 작성하는 방식, 두 가지로 만들 수 있습니다.
먼저 Table Editor를 살펴보겠습니다. Dashboard 왼쪽에서 “Table Editor”를 누른 뒤 “Create a new table”을 선택합니다. 테이블 이름, 필드 이름, 필드 타입을 입력해야 합니다.
예를 들어 users 테이블을 만들어 보겠습니다.
| 필드 이름 | 타입 | 제약 조건 |
|---|---|---|
| id | int8 | Primary Key, Auto Increment |
| text | Unique, Not Null | |
| name | text | - |
| created_at | timestamptz | Default: now() |
입력을 마친 뒤 “Save”를 누르면 테이블이 생성됩니다.
개인적으로는 특히 복잡한 작업을 할 때 SQL을 작성하는 편이 더 빠릅니다. Supabase는 Dashboard 왼쪽에 SQL Editor도 제공합니다.
다음 SQL로 users 테이블과 projects 테이블을 만듭니다.
-- 사용자 테이블
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 프로젝트 테이블
CREATE TABLE projects (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
title TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW()
);
여기서 중요한 세부 사항이 있습니다. projects 테이블의 user_id는 users 테이블의 id를 참조하는 외래 키입니다. 따라서 모든 프로젝트는 특정 사용자에게 속합니다. 데이터 관계가 명확하다는 점이 바로 관계형 데이터베이스의 장점입니다.
데이터 CRUD 작업
테이블을 만들었으니 이제 생성, 조회, 수정, 삭제를 해보겠습니다.
데이터 삽입:
// 사용자 한 명 삽입
const { data, error } = await supabase
.from('users')
.insert([
{ email: '[email protected]', name: 'John Doe' }
]);
if (error) {
console.error('삽입 실패:', error.message);
} else {
console.log('삽입 성공:', data);
}
데이터 조회:
// 모든 프로젝트를 관련 사용자 정보와 함께 조회
const { data, error } = await supabase
.from('projects')
.select(`
*,
users (
name,
email
)
`)
.eq('status', 'active')
.order('created_at', { ascending: false });
console.log(data);
이 쿼리는 흥미롭습니다. projects 테이블뿐 아니라 외래 키로 연결된 사용자 정보까지 가져옵니다. 한 번의 쿼리로 두 테이블의 데이터를 얻는 것입니다. Firestore에서 같은 작업을 하려면 여러 차례 쿼리한 뒤 데이터를 직접 합쳐야 합니다.
데이터 수정:
const { data, error } = await supabase
.from('projects')
.update({ status: 'completed' })
.eq('id', 1);
데이터 삭제:
const { data, error } = await supabase
.from('projects')
.delete()
.eq('id', 1);
이 작업들은 모두 직관적입니다. 한 가지 알아둘 점은 .eq()가 “같음”을 뜻하는 조건 필터라는 것입니다. Supabase는 .gt()(초과), .lt()(미만), .like()(패턴 일치) 등 다른 필터 메서드도 많이 제공합니다. SQL에서 자주 쓰는 필터는 대부분 지원합니다.
Row Level Security(RLS)
사용자 관련 기능을 만들 때 특히 중요한 부분입니다.
RLS는 Row Level Security, 즉 행 수준 보안의 약자입니다. 이를 이용하면 사용자가 다른 사람의 데이터가 아닌 자신의 데이터만 볼 수 있게 제어할 수 있습니다.
예를 들어 projects 테이블에 여러 프로젝트가 있지만 로그인한 사용자에게 자신이 만든 프로젝트만 보여주고 싶다고 가정해 보겠습니다.
먼저 RLS를 활성화합니다.
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
그런 다음 Policy를 만듭니다.
-- 사용자는 자신의 프로젝트만 볼 수 있음
CREATE POLICY "Users can view their own projects"
ON projects FOR SELECT
USING (user_id = auth.uid());
auth.uid()는 현재 로그인한 사용자의 ID를 반환하는 Supabase 함수입니다. 이 Policy는 projects 테이블을 조회할 때 user_id가 현재 사용자 ID와 같은 레코드만 반환합니다.
마찬가지로 삽입, 수정, 삭제 Policy도 만들 수 있습니다.
-- 사용자는 자신의 프로젝트만 삽입할 수 있음
CREATE POLICY "Users can insert their own projects"
ON projects FOR INSERT
WITH CHECK (user_id = auth.uid());
-- 사용자는 자신의 프로젝트만 수정할 수 있음
CREATE POLICY "Users can update their own projects"
ON projects FOR UPDATE
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- 사용자는 자신의 프로젝트만 삭제할 수 있음
CREATE POLICY "Users can delete their own projects"
ON projects FOR DELETE
USING (user_id = auth.uid());
이렇게 설정하면 누군가 악의적으로 다른 사람의 데이터를 조회하려 해도 데이터베이스 수준에서 차단됩니다. 프런트엔드 코드에서만 권한을 확인하는 방식보다 훨씬 안전합니다.
Auth: 사용자 인증 시스템
Supabase는 인증 기능을 상당히 완성도 높게 제공합니다. 이메일과 비밀번호 로그인, 비밀번호 없는 Magic Link, 일회용 비밀번호(OTP), Google, GitHub, Apple 등 20개 이상의 플랫폼을 이용한 소셜 로그인, 전화번호 로그인, 엔터프라이즈급 SSO까지 다양한 로그인 방식을 지원합니다.
인증 기능 개요
먼저 Supabase Auth의 핵심 메커니즘을 살펴보겠습니다.
Supabase Auth는 JSON Web Token인 JWT Token을 사용합니다. 사용자가 로그인에 성공하면 Supabase가 Token을 생성하고, 프런트엔드는 이 Token을 가지고 백엔드 API를 요청합니다. 백엔드는 Token을 검증해 사용자의 신원을 확인합니다.
또한 Supabase Auth는 PostgreSQL의 auth.users 테이블에 사용자 정보를 자동으로 저장합니다. 이 테이블은 자동으로 만들어지므로 직접 관리할 필요가 없습니다. 사용자 ID, 이메일, 생성 시간, 마지막 로그인 시간 등이 모두 이 테이블에 들어 있습니다.
Session Persistence, 즉 세션 지속성도 제공합니다. 사용자가 로그인하면 브라우저가 로그인 상태를 기억하므로 다음에 페이지를 열 때 다시 로그인하지 않아도 됩니다. Supabase 프런트엔드 SDK가 이를 자동으로 처리하므로 별도 코드를 작성할 필요가 없습니다.
Email/Password 인증
가장 일반적인 로그인 방식입니다. 먼저 회원가입부터 살펴보겠습니다.
// 사용자 회원가입
const { data, error } = await supabase.auth.signUp({
email: '[email protected]',
password: 'securepassword123',
});
if (error) {
console.error('회원가입 실패:', error.message);
} else {
console.log('회원가입 성공:', data);
}
회원가입에 성공하면 Supabase가 사용자 이메일로 확인 메일을 보냅니다. 사용자가 메일 안의 링크를 누르면 계정이 활성화됩니다. 기본 동작이며 Dashboard에서 끌 수도 있지만 권장하지는 않습니다. 이메일 확인은 악의적인 가입을 막는 데 도움이 됩니다.
다음은 로그인입니다.
// 사용자 로그인
const { data, error } = await supabase.auth.signInWithPassword({
email: '[email protected]',
password: 'securepassword123',
});
if (error) {
console.error('로그인 실패:', error.message);
} else {
console.log('로그인 성공:', data);
}
로그인에 성공하면 사용자 정보를 가져올 수 있습니다.
// 현재 로그인한 사용자 가져오기
const { data: { user } } = await supabase.auth.getUser();
console.log('현재 사용자:', user);
// 다음과 같은 출력: { id: 'abc123', email: '[email protected]', ... }
마지막은 로그아웃입니다.
await supabase.auth.signOut();
로그아웃하면 Session이 지워져 사용자가 다시 로그인해야 합니다.
자주 필요한 비밀번호 재설정 기능도 Supabase에서 바로 제공합니다.
// 비밀번호 재설정 이메일 전송
const { data, error } = await supabase.auth.resetPasswordForEmail(
'[email protected]'
);
사용자는 이메일을 받은 뒤 링크를 눌러 비밀번호를 재설정할 수 있습니다. 전체 과정에 별도 로직을 작성할 필요가 없습니다.
Social Auth 소셜 로그인
소셜 로그인은 인기 있는 기능입니다. 사용자가 양식을 작성하지 않고 Google이나 GitHub 계정으로 바로 로그인할 수 있어 사용 경험이 훨씬 좋아집니다.
먼저 소셜 로그인을 설정합니다. Dashboard에서 Authentication > Providers로 이동해 Google이나 GitHub처럼 원하는 플랫폼을 활성화합니다.
플랫폼마다 설정 방법은 다르지만 대체로 다음 단계를 따릅니다.
- Google 또는 GitHub에서 OAuth App을 만듭니다.
- Client ID와 Client Secret을 Supabase에 복사합니다.
- 콜백 URL(Redirect URL)을 설정합니다.
설정을 마치면 프런트엔드 코드는 매우 간단합니다.
// Google 로그인
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
});
// GitHub 로그인
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
});
호출하면 페이지가 Google 또는 GitHub의 승인 페이지로 이동합니다. 사용자가 “동의”를 누르면 애플리케이션으로 돌아오고 로그인이 완료됩니다.
콜백 URL과 추가 매개변수도 지정할 수 있습니다.
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: 'https://your-app.com/dashboard',
queryParams: {
access_type: 'offline',
prompt: 'consent',
}
}
});
redirectTo는 로그인 성공 뒤 이동할 페이지입니다. queryParams는 오프라인 접근 요청이나 매번 승인 여부 묻기 같은 추가 OAuth 매개변수입니다.
RLS와 Auth 결합하기
앞서 RLS를 설명할 때 언급한 auth.uid()가 여기서 연결됩니다.
사용자가 로그인하면 auth.uid()는 해당 사용자의 ID를 반환합니다. 데이터베이스 Policy에서 이 ID를 사용하면 “사용자가 자신의 데이터에만 접근”하도록 만들 수 있습니다.
예를 들어 로그인한 사용자가 프로젝트를 만들 때 현재 사용자와 프로젝트를 자동으로 연결한다고 가정해 보겠습니다.
// 프로젝트를 만들고 현재 사용자와 자동 연결
const { data: { user } } = await supabase.auth.getUser();
const { data, error } = await supabase
.from('projects')
.insert([
{
title: 'New Project',
user_id: user.id // Auth에서 사용자 ID 가져오기
}
]);
그러면 RLS Policy가 다음을 보장합니다.
- 조회 시
user_id = auth.uid()인 프로젝트만 표시됩니다. - 삽입 시
user_id가 반드시auth.uid()와 같아야 합니다. - 수정과 삭제도 같은 방식으로 제한됩니다.
이제 인증과 권한 제어가 하나로 이어집니다. 사용자 로그인, 데이터 연결, 권한 제한이 완전한 흐름을 이룹니다.
Storage: 파일 스토리지
Supabase Storage는 AWS S3와 비슷하지만 훨씬 간단하게 사용할 수 있는 객체 스토리지 서비스입니다. 사용자 프로필 사진, 이미지, 문서 같은 파일을 저장하는 데 적합합니다.
Storage 기능 개요
핵심 개념은 Bucket과 Object 두 가지입니다.
- Bucket: 폴더와 비슷한 스토리지 버킷입니다. 프로필 사진용
avatars, 문서용documents처럼 여러 Bucket을 만들 수 있습니다. - Object:
avatar.jpg,report.pdf같은 실제 파일입니다.
Bucket에는 두 가지 권한 모드가 있습니다.
- Public: 누구나 파일에 접근할 수 있는 공개 Bucket으로, 공개 이미지에 적합합니다.
- Private: 권한이 있는 사용자만 접근할 수 있는 비공개 Bucket으로, 비공개 문서에 적합합니다.
Bucket 만들기
Dashboard에서 Storage를 누른 뒤 “New Bucket”을 눌러 Bucket을 만듭니다.
예를 들어 사용자 프로필 사진을 저장할 avatars Bucket을 만들어 보겠습니다.
- 이름:
avatars - Public bucket: 프로필 사진은 보통 공개하므로 선택합니다.
- File size limit: 최대 파일 크기를 2MB처럼 설정합니다.
생성을 마치면 이 Bucket에 파일을 업로드할 수 있습니다.
파일 업로드 및 다운로드
파일 업로드:
// 사용자 프로필 사진 업로드
const file = document.getElementById('avatar-input').files[0];
const { data, error } = await supabase.storage
.from('avatars')
.upload('user-id/avatar.jpg', file, {
cacheControl: '3600',
upsert: false
});
if (error) {
console.error('업로드 실패:', error.message);
} else {
console.log('업로드 성공:', data);
}
여기에는 몇 가지 세부 사항이 있습니다.
upload()의 첫 번째 매개변수는 파일 경로인'user-id/avatar.jpg'입니다.cacheControl: 캐시 시간이며 여기서는 3,600초입니다.upsert: 파일이 이미 있을 때 덮어쓸지를 정합니다.false이면 덮어쓰지 않고 오류를 반환하며,true이면 덮어씁니다.
파일 다운로드:
// 파일 다운로드
const { data, error } = await supabase.storage
.from('avatars')
.download('user-id/avatar.jpg');
if (error) {
console.error('다운로드 실패:', error.message);
} else {
// data는 Blob 객체이며 표시할 수 있는 URL로 변환 가능
const url = URL.createObjectURL(data);
document.getElementById('avatar-img').src = url;
}
공개 Bucket이라면 공개 URL을 직접 가져올 수도 있습니다.
// 공개 URL 가져오기
const { data } = supabase.storage
.from('avatars')
.getPublicUrl('user-id/avatar.jpg');
console.log('공개 URL:', data.publicUrl);
// 다음과 같은 출력: https://your-project.supabase.co/storage/v1/object/public/avatars/user-id/avatar.jpg
비공개 Bucket의 파일에는 임시 접근 URL이 필요합니다.
// 1시간 동안 유효한 임시 접근 URL 생성
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrl('private-file.pdf', 3600);
console.log('임시 URL:', data.signedUrl);
파일 삭제:
const { data, error } = await supabase.storage
.from('avatars')
.remove(['user-id/avatar.jpg']);
접근 제어
Storage도 사용자가 접근할 수 있는 파일을 제어하는 RLS Policy를 지원합니다.
예를 들어 사용자가 자신의 폴더에만 파일을 업로드하고 접근하도록 설정해 보겠습니다.
-- 사용자는 자신의 폴더에만 업로드할 수 있음
CREATE POLICY 'Users can upload to their own folder'
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'avatars' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- 사용자는 자신의 폴더에 있는 파일에만 접근할 수 있음
CREATE POLICY 'Users can access their own files'
ON storage.objects FOR SELECT
USING (
bucket_id = 'avatars' AND
(storage.foldername(name))[1] = auth.uid()::text
);
storage.foldername(name)은 파일 경로에서 폴더 부분을 추출합니다. [1]은 첫 번째 폴더 이름, 즉 사용자 ID를 가져옵니다.
이렇게 설정하면 파일 업로드 경로는 반드시 user-id/filename 형식이어야 합니다. RLS가 user-id와 현재 사용자 ID가 같은지 확인하고 다르면 업로드를 거부합니다.
실전 사례: 작업 관리 애플리케이션
지금까지 살펴본 내용을 완전한 사례로 연결해 보겠습니다.
다음 기능을 갖춘 간단한 작업 관리 애플리케이션을 만든다고 가정합니다.
- 사용자 회원가입 및 로그인
- 작업 생성 및 작업 목록 조회
- 작업 첨부 파일 업로드
데이터베이스 설계
먼저 테이블 구조를 설계합니다.
-- 사용자 테이블(Auth가 자동으로 생성하므로 직접 만들 필요 없음)
-- tasks 테이블
CREATE TABLE tasks (
id SERIAL PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) NOT NULL,
title TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'completed')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- RLS 활성화
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
-- Policy: 사용자는 자신의 작업만 관리할 수 있음
CREATE POLICY 'Users can manage their own tasks'
ON tasks FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- attachments 테이블(작업과 연결)
CREATE TABLE task_attachments (
id SERIAL PRIMARY KEY,
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- RLS 활성화
ALTER TABLE task_attachments ENABLE ROW LEVEL SECURITY;
-- Policy: 연결된 작업을 통해 권한 판단
CREATE POLICY 'Users can manage attachments of their tasks'
ON task_attachments FOR ALL
USING (
EXISTS (
SELECT 1 FROM tasks
WHERE tasks.id = task_attachments.task_id
AND tasks.user_id = auth.uid()
)
);
여기서 중요한 점은 task_attachments 테이블의 Policy가 user_id를 직접 확인하지 않는다는 것입니다. 대신 task_id로 tasks 테이블과 연결한 뒤 tasks.user_id를 확인합니다. 따라서 작업 소유자만 첨부 파일을 관리할 수 있습니다.
Storage Bucket 만들기
비공개 task-files Bucket을 만듭니다.
-- Dashboard에서 만들거나 SQL 사용
INSERT INTO storage.buckets (name, public)
VALUES ('task-files', false);
-- Policy: 사용자는 자신의 폴더에만 업로드할 수 있음
CREATE POLICY 'Users can upload task files'
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'task-files' AND
(storage.foldername(name))[1] = auth.uid()::text
);
CREATE POLICY 'Users can access task files'
ON storage.objects FOR SELECT
USING (
bucket_id = 'task-files' AND
(storage.foldername(name))[1] = auth.uid()::text
);
핵심 코드 구현
모든 기능을 연결한 코드는 다음과 같습니다.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
'https://your-project.supabase.co',
'your-anon-key'
);
// 1. 사용자 회원가입
async function register(email: string, password: string) {
const { data, error } = await supabase.auth.signUp({
email,
password,
});
if (error) throw error;
return data;
}
// 2. 사용자 로그인
async function login(email: string, password: string) {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
return data;
}
// 3. 작업 생성
async function createTask(title: string, description?: string) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('로그인하지 않았습니다.');
const { data, error } = await supabase
.from('tasks')
.insert([
{
title,
description,
user_id: user.id,
}
])
.select();
if (error) throw error;
return data[0];
}
// 4. 작업 목록 가져오기
async function getTasks() {
const { data, error } = await supabase
.from('tasks')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
return data;
}
// 5. 첨부 파일 업로드
async function uploadAttachment(taskId: number, file: File) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('로그인하지 않았습니다.');
const filePath = user.id + '/' + taskId + '/' + file.name;
const { data, error } = await supabase.storage
.from('task-files')
.upload(filePath, file);
if (error) throw error;
// task_attachments 테이블에 기록
const { data: attachment, error: dbError } = await supabase
.from('task_attachments')
.insert([
{
task_id: taskId,
file_name: file.name,
file_path: filePath,
file_size: file.size,
}
])
.select();
if (dbError) throw dbError;
return attachment[0];
}
// 6. 작업 첨부 파일 가져오기
async function getTaskAttachments(taskId: number) {
const { data, error } = await supabase
.from('task_attachments')
.select('*')
.eq('task_id', taskId);
if (error) throw error;
// 임시 접근 URL 생성
const attachmentsWithURLs = data.map(async (attachment) => {
const { data: urlData } = await supabase.storage
.from('task-files')
.createSignedUrl(attachment.file_path, 3600);
return {
...attachment,
url: urlData.signedUrl,
};
});
return Promise.all(attachmentsWithURLs);
}
// 7. 작업 완료 처리
async function completeTask(taskId: number) {
const { data, error } = await supabase
.from('tasks')
.update({ status: 'completed', updated_at: new Date() })
.eq('id', taskId)
.select();
if (error) throw error;
return data[0];
}
// 8. 로그아웃
async function logout() {
await supabase.auth.signOut();
}
이 코드는 사용자 회원가입과 로그인, 작업 CRUD, 파일 업로드를 모두 포함합니다. 완전한 애플리케이션의 기본 뼈대가 갖춰진 셈입니다. 이를 바탕으로 작업 분류, 태그, 댓글, 알림 같은 기능을 더할 수 있습니다.
Supabase와 Firebase 비교
이제 Supabase와 Firebase 중 무엇을 선택해야 할지 자세히 비교해 보겠습니다.
핵심 차이
| 비교 항목 | Supabase | Firebase |
|---|---|---|
| 데이터베이스 유형 | PostgreSQL(관계형) | Firestore(문서형 NoSQL) |
| 오픈 소스 여부 | 완전한 오픈 소스 | Google의 비공개 소스 제품 |
| 요금 체계 | 월 $25 고정(Pro Plan) | 사용량에 따라 과금(읽기/쓰기/스토리지 별도 계산) |
| 데이터 소유권 | 100% 소유하고 언제든 내보낼 수 있음 | 데이터가 Google에 있으며 내보내기 번거로움 |
| 쿼리 기능 | 강력한 SQL 쿼리와 복잡한 조인 | 쿼리가 제한적이며 복잡한 관계는 여러 번 조회해야 함 |
| 실시간 기능 | WebSocket, 직접 구독 필요 | Firestore 기본 실시간 기능과 자동 동기화 |
| 오프라인 지원 | 캐시를 직접 구현해야 함 | 기본 오프라인 지원과 자동 동기화 |
| 마이그레이션 난이도 | 표준 PostgreSQL이므로 쉬움 | 독점 형식이라 마이그레이션 비용이 높음 |
사용 사례별 선택 가이드
Supabase가 적합한 경우:
- 데이터 관계가 복잡하고 SQL 쿼리와 조인이 필요한 경우
- 데이터를 완전히 통제하고 싶은 장기 프로젝트
- 팀이 SQL에 익숙하고 PostgreSQL을 사용하려는 경우
- 비용에 민감하고 예산이 제한적인 경우(고정 비용이 사용량 기반 과금보다 안정적임)
- 오픈 소스를 우선하며 기술 스택을 통제하려는 경우
Firebase가 적합한 경우:
- 시간이 촉박한 빠른 프로토타입 개발
- 모바일 애플리케이션이 우선이고 오프라인 기능이 중요한 경우
- 채팅 애플리케이션처럼 실시간 동기화가 핵심 기능인 경우
- 이미 Google Cloud 생태계를 사용하고 있는 경우
- 데이터 구조가 단순하고 쿼리가 복잡하지 않은 경우
솔직히 말해 예전에 Firebase로 몇 가지 프로젝트를 만들었다가 월 $800가 넘는 청구서를 보고 놀란 적이 있습니다. Supabase로 옮긴 뒤 같은 기능의 비용이 약 $25로 안정됐습니다. 차이가 꽤 큽니다.
Firebase는 데이터 내보내기도 번거롭습니다. Firestore 데이터 형식은 독점적이어서 내보낸 뒤에도 변환해야 사용할 수 있습니다. Supabase는 훨씬 간단합니다. SQL 파일을 직접 내보내거나 PostgreSQL 도구로 CSV를 내보내 다른 데이터베이스로 언제든 옮길 수 있습니다.
다만 실시간 기능과 모바일 애플리케이션에서는 Firebase가 확실히 더 강합니다. Firestore의 실시간 동기화와 오프라인 지원은 잘 구성되어 있습니다. 채팅 애플리케이션이나 협업 문서처럼 강력한 실시간 기능이 필요하다면 Firebase가 더 편할 수 있습니다.
요약 및 권장 사항
지금까지 살펴본 내용을 정리해 보겠습니다.
Supabase의 핵심 가치는 다음과 같습니다.
- PostgreSQL 데이터베이스: 강력한 쿼리와 명확한 데이터 관계
- 엔터프라이즈급 인증: 다양한 로그인 방식, JWT Token, RLS 권한
- 객체 스토리지: 간단한 사용법과 접근 제어 지원
- 실시간 동기화와 엣지 컴퓨팅: 이후 탐색할 수 있는 고급 기능
오픈 소스이며 데이터를 완전히 통제할 수 있고 비용도 안정적입니다. 장기 프로젝트에서 중요한 장점입니다.
프런트엔드 개발자로서 백엔드 설정에 시달리지 않고 풀스택 애플리케이션을 빠르게 구축하고 싶다면 Supabase는 좋은 선택입니다. 몇 시간 안에 데이터베이스, 인증, 스토리지라는 세 가지 핵심 기능을 구성할 수 있어 전통적인 백엔드 개발보다 훨씬 빠릅니다.
학습 순서는 다음과 같이 권합니다.
- 먼저 핵심인 Database, Auth, Storage를 확실히 이해합니다.
- 그다음 RLS Policy를 실습해 인증과 권한 제어가 어떻게 연결되는지 알아봅니다.
- 가장 좋은 학습 방법은 실전 프로젝트입니다. 필요한 기능을 정하고 처음부터 애플리케이션을 만들어 보세요.
- 이후 Realtime, Edge Functions, Vector Database 같은 고급 기능을 살펴봅니다.
이것으로 Supabase의 핵심 내용을 거의 모두 살펴봤습니다. 공식 문서가 잘 정리되어 있으니 궁금한 점이 생기면 supabase.com/docs를 확인해 보세요. GitHub의 awesome-supabase 목록처럼 커뮤니티에도 많은 튜토리얼과 사례가 정리되어 있습니다.
한 문장으로 요약하면 이렇습니다. PostgreSQL로 백엔드를 만들고 싶지만 설정에 시간을 쓰고 싶지 않다면, Supabase가 길을 닦아 두었으니 그대로 시작하면 됩니다.
Supabase의 세 가지 핵심 기능 빠르게 시작하기
처음부터 Supabase 프로젝트를 구성하고 Database, Auth, Storage의 세 가지 핵심 기능을 익힙니다.
⏱️ Estimated time: 2 hr
- 1
Step 1: Supabase 프로젝트 만들기
supabase.com에서 계정을 등록하고 새 프로젝트를 만듭니다.
• 프로젝트 이름: my-first-app
• 데이터베이스 비밀번호: 반드시 기록해 둡니다.
• 리전: 가장 가까운 곳을 선택합니다(중국에서는 Singapore 또는 Tokyo).
프로젝트를 만든 뒤 Settings > API에서 Project URL과 Anon Public Key를 확인합니다. - 2
Step 2: 클라이언트 설치 및 초기화
프런트엔드 프로젝트에 의존성을 설치합니다.
npm install @supabase/supabase-js
Supabase Client를 초기화합니다.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
'https://your-project.supabase.co',
'your-anon-key'
); - 3
Step 3: 데이터 테이블 생성 및 RLS 설정
SQL Editor로 테이블을 만듭니다.
CREATE TABLE tasks (
id SERIAL PRIMARY KEY,
user_id UUID REFERENCES auth.users(id),
title TEXT NOT NULL,
status TEXT DEFAULT 'pending'
);
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY 'Users can manage their own tasks'
ON tasks FOR ALL
USING (user_id = auth.uid());
RLS Policy는 사용자가 자신의 데이터에만 접근하도록 보장합니다. - 4
Step 4: 사용자 인증 구현
Email/Password 인증 코드 예제입니다.
// 회원가입
await supabase.auth.signUp({
email: '[email protected]',
password: 'password123'
});
// 로그인
await supabase.auth.signInWithPassword({
email: '[email protected]',
password: 'password123'
});
// 현재 사용자 가져오기
const { data: { user } } = await supabase.auth.getUser();
Google, GitHub 등 20개 이상의 플랫폼을 통한 소셜 로그인을 지원합니다. - 5
Step 5: 파일 스토리지 설정
Storage Bucket을 공개 또는 비공개로 만듭니다.
// 파일 업로드
await supabase.storage
.from('avatars')
.upload('user-id/avatar.jpg', file);
// 공개 URL 가져오기
const { data } = supabase.storage
.from('avatars')
.getPublicUrl('user-id/avatar.jpg');
비공개 파일은 createSignedUrl로 임시 접근 링크를 생성합니다.
FAQ
Supabase와 Firebase의 핵심 차이는 무엇인가요?
Supabase는 어떤 프로젝트에 적합한가요?
• 데이터 관계가 복잡하고 SQL 쿼리와 조인이 필요한 경우
• 데이터를 완전히 통제하려는 장기 프로젝트
• 비용에 민감하고 예산이 제한적인 경우(고정 비용이 더 안정적임)
• 프런트엔드 개발자가 풀스택 애플리케이션을 빠르게 구축하려는 경우
• 오픈 소스를 우선하며 기술 스택을 통제하려는 경우
Row Level Security(RLS)란 무엇이며 어떤 용도로 사용하나요?
Supabase Auth는 어떤 로그인 방식을 지원하나요?
• Email/Password(이메일과 비밀번호)
• Magic Link(비밀번호 없는 로그인)
• OTP(일회용 비밀번호)
• Social Auth(Google, GitHub, Apple 등 20개 이상의 플랫폼)
• Phone Auth(Twilio, MessageBird)
• SSO(엔터프라이즈급 통합 로그인)
Supabase Storage의 공개 Bucket과 비공개 Bucket은 어떻게 다른가요?
Firebase에서 Supabase로 마이그레이션하는 데 얼마나 걸리나요?
Supabase 무료 플랜에는 무엇이 포함되나요?
7분 읽기 · 게시일: 2026년 4월 3일 · 수정일: 2026년 9월 4일



댓글
GitHub로 로그인하여 댓글을 남기세요