Supabase 백엔드 셋업 스터디 노트
강의에서 다룬 SQL 소스코드를 순서대로 정리했습니다. 소셜 앱(피드) 하나를 기준으로 스키마 → RLS 보안 → 트리거 자동화 → 함수/뷰 순서로 구조를 쌓아 올리는 흐름을 따라갑니다.
Supabase 백엔드 설정 가이드 개요
0:24전체 SQL 소스코드가 어떤 순서로 실행되어야 하는지 보여주는 오프닝 섹션.
이 커리큘럼은 하나의 소셜 피드 앱(게시글 + 댓글 + 좋아요 + 프로필)을 기준으로, Supabase SQL Editor에서 실행할 스크립트를 계층적으로 쌓아 올립니다. 실행 순서가 중요한 이유는 posts 테이블이 profiles를 참조하고, 트리거가 테이블 생성 이후에 붙고, RLS는 테이블+함수가 준비된 뒤에 켜야 하기 때문입니다.
- 테이블 생성 — profiles → posts → comments → likes 순으로 참조 관계를 따라간다.
- 스토리지 버킷 생성 — post-images, avatars.
- RLS 정책 작성 — 각 테이블/버킷에 대해 SELECT/INSERT/UPDATE/DELETE 정책 분리.
- 트리거 함수 — 회원가입 시 프로필 자동 생성, updated_at 자동 갱신, 카운트 캐싱.
- RPC 함수 & 뷰 — 클라이언트에서 호출할 비즈니스 로직과 조회용 뷰.
public.profiles 테이블
9:30인증 유저 정보를 확장하는 공개 프로필 테이블 설계.
auth.users는 Supabase Auth가 관리하는 비공개 스키마라 클라이언트에서 직접 조회/조인하기 어렵습니다. 그래서 앱에서 쓸 공개 프로필을 public.profiles에 별도로 둡니다.
create table public.profiles (
id uuid references auth.users(id) on delete cascade primary key,
username text unique not null,
full_name text,
avatar_url text,
bio text,
website text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint username_length check (char_length(username) >= 3)
);
comment on table public.profiles is '앱에서 사용하는 공개 사용자 프로필';
설계 포인트
id를 auth.users.id와 동일한 PK로 사용 — 1:1 관계를 강제하고 별도 FK 컬럼을 두지 않는다.on delete cascade— 유저가 탈퇴하면 프로필도 함께 삭제.username은unique+ 길이check제약으로 애플리케이션 레벨 검증을 DB에서도 이중 보장.- row가 실제로 언제 만들어지는지는 10. handle_new_user 트리거에서 이어짐.
auth.users 테이블 vs public.profiles 테이블
3:09두 테이블의 책임을 분리해서 이해하기.
| 구분 | auth.users | public.profiles |
|---|---|---|
| 소유 | Supabase Auth 내부 스키마 | 개발자가 만든 앱 스키마 |
| 접근성 | 클라이언트에서 직접 SELECT 불가 (service_role만) | RLS로 제어된 공개 읽기 가능 |
| 담는 데이터 | 이메일, 암호화된 비밀번호, provider, 세션 | username, 아바타, bio 등 표시용 데이터 |
| 수정 시점 | 가입/로그인/비밀번호 변경 시 Auth가 관리 | 트리거로 자동 생성 + 유저가 직접 수정 |
| 조인 가능 여부 | PostgREST에서 직접 조인 어려움 | posts.author_id 등과 자유롭게 조인 |
auth.users를 클라이언트 쿼리에서 직접 조인하려다 막히는 경우가 많습니다. 항상 표시용 데이터는 profiles를 통해 노출하세요.public.posts, public.comments, public.likes 테이블
10:27피드 기능의 핵심 3테이블 — 게시글, 댓글, 좋아요.
create table public.posts (
id uuid primary key default gen_random_uuid(),
author_id uuid not null references public.profiles(id) on delete cascade,
caption text,
image_url text not null,
likes_count integer not null default 0,
comments_count integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index posts_author_id_idx on public.posts(author_id);
create index posts_created_at_idx on public.posts(created_at desc);
create table public.comments (
id uuid primary key default gen_random_uuid(),
post_id uuid not null references public.posts(id) on delete cascade,
author_id uuid not null references public.profiles(id) on delete cascade,
content text not null check (char_length(content) > 0),
created_at timestamptz not null default now()
);
create index comments_post_id_idx on public.comments(post_id);
create table public.likes (
post_id uuid not null references public.posts(id) on delete cascade,
user_id uuid not null references public.profiles(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (post_id, user_id) -- 복합 PK로 "중복 좋아요" 자체를 원천 차단
);
Supabase 스토리지 설정: post-images & avatars
2:53이미지 업로드를 위한 두 개의 버킷 생성.
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values
('post-images', 'post-images', true, 5242880, array['image/jpeg','image/png','image/webp']),
('avatars', 'avatars', true, 2097152, array['image/jpeg','image/png','image/webp']);
public: true는 "읽기 URL이 서명 없이 열람 가능"하다는 뜻일 뿐, 업로드/수정/삭제는 여전히 storage.objects 테이블의 RLS로 제어됩니다. 실제 정책은 9. 스토리지 RLS에서 다룹니다.profiles 테이블 Row Level Security
7:20누구나 프로필을 볼 수 있지만, 자기 것만 수정 가능하게.
alter table public.profiles enable row level security;
-- 1) 전체 공개 읽기
create policy "Profiles are viewable by everyone"
on public.profiles for select
using (true);
-- 2) 본인만 자신의 프로필 수정
create policy "Users can update own profile"
on public.profiles for update
using (auth.uid() = id)
with check (auth.uid() = id);
-- 3) insert는 트리거(handle_new_user)만 수행 → 클라이언트 insert 정책은 만들지 않음
using은 어떤 행을 볼 수 있는지(읽기 필터),with check는 쓰기 결과가 통과해야 하는 조건을 검사합니다.- 프로필
insert는 회원가입 트리거에서security definer로 처리하므로 별도 insert 정책이 없어도 동작 — 오히려 없는 게 "클라이언트가 임의로 남의 프로필을 만드는" 사고를 막아줍니다.
posts 테이블 Row Level Security
13:39가장 길게 다뤄진 섹션 — CRUD 4개 정책을 각각 분리해서 설계.
alter table public.posts enable row level security;
-- SELECT: 로그인 여부와 무관하게 모든 게시글 열람 가능
create policy "Posts are viewable by everyone"
on public.posts for select
using (true);
-- INSERT: 로그인한 사용자가 "자기 자신"을 author로 지정할 때만
create policy "Authenticated users can create posts"
on public.posts for insert
with check (auth.uid() = author_id);
-- UPDATE: 작성자 본인만, 그리고 author_id를 다른 사람으로 바꿔치기 못하게
create policy "Users can update own posts"
on public.posts for update
using (auth.uid() = author_id)
with check (auth.uid() = author_id);
-- DELETE: 작성자 본인만
create policy "Users can delete own posts"
on public.posts for delete
using (auth.uid() = author_id);
using만 있으면 "내 글"을 골라 author_id를 남의 id로 바꿔서 저장하는 것까지는 막지 못합니다. with check가 저장되는 최종 결과값까지 검증해서 소유권 이전을 차단합니다.comment 테이블과 likes 테이블 Row Level Security
3:19posts와 같은 패턴을 재사용하되, likes는 delete만 필요.
alter table public.comments enable row level security;
alter table public.likes enable row level security;
-- comments: 전체 열람, 본인만 작성/삭제
create policy "Comments are viewable by everyone" on public.comments for select using (true);
create policy "Authenticated users can comment" on public.comments for insert with check (auth.uid() = author_id);
create policy "Users can delete own comments" on public.comments for delete using (auth.uid() = author_id);
-- likes: 전체 열람, 본인 이름으로만 좋아요/취소
create policy "Likes are viewable by everyone" on public.likes for select using (true);
create policy "Authenticated users can like" on public.likes for insert with check (auth.uid() = user_id);
create policy "Users can unlike" on public.likes for delete using (auth.uid() = user_id);
comments/likes는 update 정책이 없습니다 — 댓글 내용을 수정하는 기능은 이 스키마에서 지원하지 않고(삭제 후 재작성), 좋아요는 애초에 토글(insert/delete)로만 동작하기 때문입니다.
post-images 스토리지와 avatars 스토리지 Row Level Security
6:52storage.objects 테이블에 정책을 걸어 "폴더 = 유저 id" 규칙을 강제.
-- post-images: 로그인 유저는 자기 uid 폴더 아래에만 업로드 가능
create policy "Users can upload post images to own folder"
on storage.objects for insert
with check (
bucket_id = 'post-images'
and auth.uid()::text = (storage.foldername(name))[1]
);
-- 누구나 이미지 열람(공개 버킷)
create policy "Post images are publicly accessible"
on storage.objects for select
using (bucket_id = 'post-images');
-- 본인 소유 파일만 삭제
create policy "Users can delete own post images"
on storage.objects for delete
using (
bucket_id = 'post-images'
and auth.uid()::text = (storage.foldername(name))[1]
);
-- avatars: 업로드 + 자기 파일만 갱신(덮어쓰기)
create policy "Users can upload own avatar"
on storage.objects for insert
with check (bucket_id = 'avatars' and auth.uid()::text = (storage.foldername(name))[1]);
create policy "Users can update own avatar"
on storage.objects for update
using (bucket_id = 'avatars' and auth.uid()::text = (storage.foldername(name))[1]);
/ 기준으로 배열로 쪼갭니다. {user_id}/avatar.png 형태로 업로드 규칙을 강제하면 [1]이 바로 uid가 되어 정책이 간단해집니다.handle_new_user 트리거 함수
6:05회원가입 즉시 profiles row를 자동 생성.
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles (id, username, full_name, avatar_url)
values (
new.id,
coalesce(new.raw_user_meta_data->>'username', split_part(new.email, '@', 1)),
new.raw_user_meta_data->>'full_name',
new.raw_user_meta_data->>'avatar_url'
);
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
security definer— 트리거를 소유한 관리자 권한으로 실행되므로, 일반 유저 RLS로는 불가능한auth.usersrow 접근 및profilesinsert가 가능해짐.raw_user_meta_data는 회원가입 시signUp({ options: { data: {...} } })로 넘긴 값이 들어있는 JSONB 컬럼.set search_path = ''는 스키마 하이재킹 공격을 막기 위한 보안 관례 — 모든 객체를public.처럼 풀네임으로 참조해야 함.
updated_at 트리거 함수
5:46여러 테이블에 재사용하는 범용 트리거.
create or replace function public.handle_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger set_updated_at
before update on public.profiles
for each row execute procedure public.handle_updated_at();
create trigger set_updated_at
before update on public.posts
for each row execute procedure public.handle_updated_at();
before update이기 때문에 new row를 직접 수정해서 실제 저장 전에 updated_at을 덮어씁니다. 같은 함수를 profiles, posts 등 updated_at 컬럼이 있는 모든 테이블에 재사용할 수 있는 것이 포인트.
update_comments_count 트리거 함수
2:28댓글 추가/삭제 시 posts.comments_count 자동 동기화.
create or replace function public.update_comments_count()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
if (tg_op = 'INSERT') then
update public.posts set comments_count = comments_count + 1 where id = new.post_id;
return new;
elsif (tg_op = 'DELETE') then
update public.posts set comments_count = greatest(comments_count - 1, 0) where id = old.post_id;
return old;
end if;
end;
$$;
create trigger on_comment_change
after insert or delete on public.comments
for each row execute procedure public.update_comments_count();
update_likes_count 트리거 함수, handle_like 함수
7:16좋아요 카운트 동기화 + 좋아요 토글을 하나의 RPC로 감싸기.
create or replace function public.update_likes_count()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
if (tg_op = 'INSERT') then
update public.posts set likes_count = likes_count + 1 where id = new.post_id;
return new;
elsif (tg_op = 'DELETE') then
update public.posts set likes_count = greatest(likes_count - 1, 0) where id = old.post_id;
return old;
end if;
end;
$$;
create trigger on_like_change
after insert or delete on public.likes
for each row execute procedure public.update_likes_count();
create or replace function public.handle_like(post_id_input uuid)
returns boolean -- true = 좋아요 추가됨, false = 좋아요 취소됨
language plpgsql
security definer set search_path = ''
as $$
declare
already_liked boolean;
begin
select exists(
select 1 from public.likes
where post_id = post_id_input and user_id = auth.uid()
) into already_liked;
if already_liked then
delete from public.likes where post_id = post_id_input and user_id = auth.uid();
return false;
else
insert into public.likes (post_id, user_id) values (post_id_input, auth.uid());
return true;
end if;
end;
$$;
클라이언트는 "좋아요 있는지 확인 → insert 또는 delete" 로직을 직접 짤 필요 없이 handle_like(post_id) 하나만 호출하면 됩니다. 토글 로직을 서버(DB) 쪽에 캡슐화하는 전형적인 RPC 패턴.
update_user_profile 함수
6:46부분 업데이트(partial update)를 지원하는 프로필 수정 RPC.
create or replace function public.update_user_profile(
new_username text default null,
new_full_name text default null,
new_bio text default null,
new_avatar_url text default null
)
returns public.profiles
language plpgsql
security definer set search_path = ''
as $$
declare
updated_row public.profiles;
begin
update public.profiles
set
username = coalesce(new_username, username),
full_name = coalesce(new_full_name, full_name),
bio = coalesce(new_bio, bio),
avatar_url = coalesce(new_avatar_url, avatar_url)
where id = auth.uid()
returning * into updated_row;
return updated_row;
end;
$$;
- 모든 파라미터가
default null+coalesce패턴 → 필드 하나만 바꾸고 싶을 때 나머지는null로 안 넘기면 기존 값 유지. where id = auth.uid()가 하드코딩되어 있어 함수 자체가 "내 프로필만 수정 가능"을 강제 — 파라미터로user_id를 받지 않는 것이 보안 설계 포인트.
Views: post_display_view, comment_display_view
9:35클라이언트에서 매번 조인하지 않도록 미리 조립된 조회 뷰.
create view public.post_display_view with (security_invoker = true) as
select
p.id,
p.caption,
p.image_url,
p.likes_count,
p.comments_count,
p.created_at,
pr.id as author_id,
pr.username as author_username,
pr.avatar_url as author_avatar_url
from public.posts p
join public.profiles pr on pr.id = p.author_id
order by p.created_at desc;
create view public.comment_display_view with (security_invoker = true) as
select
c.id,
c.post_id,
c.content,
c.created_at,
pr.id as author_id,
pr.username as author_username,
pr.avatar_url as author_avatar_url
from public.comments c
join public.profiles pr on pr.id = c.author_id
order by c.created_at asc;
false)이면 뷰가 정의한 사람의 권한으로 실행되어 RLS를 우회할 위험이 있는데, security_invoker를 켜면 뷰를 조회하는 사람의 권한 + RLS가 그대로 적용됩니다.이 뷰들 덕분에 프론트엔드는 posts + profiles를 각각 조회하고 합칠 필요 없이, select * from post_display_view 한 번으로 피드에 필요한 데이터를 받습니다.
get_my_posts 함수, search_posts 함수
7:18뷰만으로 부족한 조건부/검색 조회는 RPC 함수로 보완.
create or replace function public.get_my_posts(page_size int default 10, page_offset int default 0)
returns setof public.post_display_view
language sql
security invoker set search_path = ''
as $$
select * from public.post_display_view
where author_id = auth.uid()
limit page_size offset page_offset;
$$;
create or replace function public.search_posts(search_term text)
returns setof public.post_display_view
language sql
security invoker set search_path = ''
as $$
select * from public.post_display_view
where caption ilike '%' || search_term || '%'
or author_username ilike '%' || search_term || '%';
$$;
이 둘은 security definer가 아니라 security invoker로 선언된 점이 눈여겨볼 부분입니다 — 이미 post_display_view 자체에 RLS 안전장치가 있으므로, 함수는 그냥 호출자 권한을 그대로 물려받게 둬도 안전합니다.
create_post, update_post, create_comment, update_comment 함수
10:36쓰기 작업을 RPC로 감싸 검증 로직을 한 곳에 모으기.
create or replace function public.create_post(caption_input text, image_url_input text)
returns public.posts
language plpgsql
security invoker set search_path = ''
as $$
declare
new_post public.posts;
begin
if image_url_input is null or image_url_input = '' then
raise exception 'image_url is required';
end if;
insert into public.posts (author_id, caption, image_url)
values (auth.uid(), caption_input, image_url_input)
returning * into new_post;
return new_post;
end;
$$;
create or replace function public.update_post(post_id_input uuid, new_caption text)
returns public.posts
language plpgsql
security invoker set search_path = ''
as $$
declare
updated_post public.posts;
begin
update public.posts set caption = new_caption
where id = post_id_input and author_id = auth.uid()
returning * into updated_post;
if updated_post is null then
raise exception 'post not found or not owned by current user';
end if;
return updated_post;
end;
$$;
create or replace function public.create_comment(post_id_input uuid, content_input text)
returns public.comments
language plpgsql
security invoker set search_path = ''
as $$
declare
new_comment public.comments;
begin
if length(trim(content_input)) = 0 then
raise exception 'comment content cannot be empty';
end if;
insert into public.comments (post_id, author_id, content)
values (post_id_input, auth.uid(), content_input)
returning * into new_comment;
return new_comment;
end;
$$;
create or replace function public.update_comment(comment_id_input uuid, new_content text)
returns public.comments
language plpgsql
security invoker set search_path = ''
as $$
declare
updated_comment public.comments;
begin
update public.comments set content = new_content
where id = comment_id_input and author_id = auth.uid()
returning * into updated_comment;
if updated_comment is null then
raise exception 'comment not found or not owned by current user';
end if;
return updated_comment;
end;
$$;
auth.uid()를 소유권 기준으로 사용 → (3) 대상이 없으면 명시적으로 raise exception. RLS가 이미 걸려 있어도, RPC 안에서 한 번 더 소유권을 확인해 "0 rows affected"를 조용히 넘기지 않고 에러로 알리는 것이 이 강의의 스타일입니다.service_role 권한
—마지막 정리 — anon / authenticated / service_role 세 롤의 역할 구분.
| role | RLS 적용 | 주 사용처 |
|---|---|---|
anon | 적용됨 | 로그인 전 클라이언트 (공개 SELECT만) |
authenticated | 적용됨 | 로그인한 유저의 브라우저/앱 요청 |
service_role | 모두 우회 | 서버(Edge Function, 백엔드 크론)에서만 사용 |
-- 예: 관리자 전용 정리 작업에서 service_role로 실행할 함수는
-- 클라이언트에 노출되지 않도록 REVOKE로 명시적으로 막아준다
revoke execute on function public.admin_purge_reported_posts() from anon, authenticated;
grant execute on function public.admin_purge_reported_posts() to service_role;
service_role 키는 RLS를 완전히 무시하므로 클라이언트(브라우저/모바일 앱) 코드에 절대 포함하면 안 됩니다. 오직 서버 환경(Edge Function, 별도 백엔드)의 환경 변수로만 보관합니다.전체 체크리스트
- profiles / posts / comments / likes 4개 테이블 모두 RLS
enable확인 - post-images / avatars 버킷 정책에서 "본인 폴더" 규칙 검증
- handle_new_user 트리거가 auth.users insert 이후 정상 동작하는지 가입 테스트
- likes_count / comments_count 트리거로 posts 캐시 값이 실제 개수와 일치하는지 확인
- service_role 키가 프론트엔드 번들에 노출되지 않았는지 최종 점검