CURRICULUM RECAP · SQL SOURCE WALKTHROUGH

Supabase 백엔드 셋업 스터디 노트

강의에서 다룬 SQL 소스코드를 순서대로 정리했습니다. 소셜 앱(피드) 하나를 기준으로 스키마 → RLS 보안 → 트리거 자동화 → 함수/뷰 순서로 구조를 쌓아 올리는 흐름을 따라갑니다.

18SECTIONS
4CORE TABLES
2STORAGE BUCKETS
~13FUNCTIONS/TRIGGERS
01

Supabase 백엔드 설정 가이드 개요

0:24

전체 SQL 소스코드가 어떤 순서로 실행되어야 하는지 보여주는 오프닝 섹션.

이 커리큘럼은 하나의 소셜 피드 앱(게시글 + 댓글 + 좋아요 + 프로필)을 기준으로, Supabase SQL Editor에서 실행할 스크립트를 계층적으로 쌓아 올립니다. 실행 순서가 중요한 이유는 posts 테이블이 profiles를 참조하고, 트리거가 테이블 생성 이후에 붙고, RLS는 테이블+함수가 준비된 뒤에 켜야 하기 때문입니다.

  1. 테이블 생성 — profiles → posts → comments → likes 순으로 참조 관계를 따라간다.
  2. 스토리지 버킷 생성 — post-images, avatars.
  3. RLS 정책 작성 — 각 테이블/버킷에 대해 SELECT/INSERT/UPDATE/DELETE 정책 분리.
  4. 트리거 함수 — 회원가입 시 프로필 자동 생성, updated_at 자동 갱신, 카운트 캐싱.
  5. RPC 함수 & 뷰 — 클라이언트에서 호출할 비즈니스 로직과 조회용 뷰.
핵심 포인트. Supabase는 "Postgres 위에 얇게 얹은 API"이기 때문에, 앱 로직의 상당 부분을 SQL(테이블 제약, RLS, 트리거, 함수)로 옮겨서 DB 자체가 규칙을 강제하게 만드는 것이 이 강의의 설계 철학입니다.
02

public.profiles 테이블

9:30

인증 유저 정보를 확장하는 공개 프로필 테이블 설계.

auth.users는 Supabase Auth가 관리하는 비공개 스키마라 클라이언트에서 직접 조회/조인하기 어렵습니다. 그래서 앱에서 쓸 공개 프로필public.profiles에 별도로 둡니다.

sql · 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 '앱에서 사용하는 공개 사용자 프로필';

설계 포인트

  • idauth.users.id와 동일한 PK로 사용 — 1:1 관계를 강제하고 별도 FK 컬럼을 두지 않는다.
  • on delete cascade — 유저가 탈퇴하면 프로필도 함께 삭제.
  • usernameunique + 길이 check 제약으로 애플리케이션 레벨 검증을 DB에서도 이중 보장.
  • row가 실제로 언제 만들어지는지는 10. handle_new_user 트리거에서 이어짐.
03

auth.users 테이블 vs public.profiles 테이블

3:09

두 테이블의 책임을 분리해서 이해하기.

구분auth.userspublic.profiles
소유Supabase Auth 내부 스키마개발자가 만든 앱 스키마
접근성클라이언트에서 직접 SELECT 불가 (service_role만)RLS로 제어된 공개 읽기 가능
담는 데이터이메일, 암호화된 비밀번호, provider, 세션username, 아바타, bio 등 표시용 데이터
수정 시점가입/로그인/비밀번호 변경 시 Auth가 관리트리거로 자동 생성 + 유저가 직접 수정
조인 가능 여부PostgREST에서 직접 조인 어려움posts.author_id 등과 자유롭게 조인
흔한 실수. auth.users를 클라이언트 쿼리에서 직접 조인하려다 막히는 경우가 많습니다. 항상 표시용 데이터는 profiles를 통해 노출하세요.
04

public.posts, public.comments, public.likes 테이블

10:27

피드 기능의 핵심 3테이블 — 게시글, 댓글, 좋아요.

sql · posts
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);
sql · comments
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);
sql · likes
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로 "중복 좋아요" 자체를 원천 차단
);
  • likes는 별도 id 없이 (post_id, user_id) 복합 PK로 설계 — 같은 유저가 같은 글에 두 번 좋아요를 누르는 게 DB 레벨에서 불가능해짐.
  • likes_count / comments_count는 매번 count(*)하지 않도록 비정규화된 캐시 컬럼12, 13의 트리거가 동기화 담당.
05

Supabase 스토리지 설정: post-images & avatars

2:53

이미지 업로드를 위한 두 개의 버킷 생성.

sql · 버킷 생성
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 post-images ≤ 5MB avatars ≤ 2MB jpeg / png / webp만 허용
public 버킷인데 왜 RLS가 필요한가? public: true는 "읽기 URL이 서명 없이 열람 가능"하다는 뜻일 뿐, 업로드/수정/삭제는 여전히 storage.objects 테이블의 RLS로 제어됩니다. 실제 정책은 9. 스토리지 RLS에서 다룹니다.
06

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 정책이 없어도 동작 — 오히려 없는 게 "클라이언트가 임의로 남의 프로필을 만드는" 사고를 막아줍니다.
07

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);
왜 UPDATE에 using과 with check가 둘 다 필요한가? using만 있으면 "내 글"을 골라 author_id를 남의 id로 바꿔서 저장하는 것까지는 막지 못합니다. with check저장되는 최종 결과값까지 검증해서 소유권 이전을 차단합니다.
08

comment 테이블과 likes 테이블 Row Level Security

3:19

posts와 같은 패턴을 재사용하되, 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)로만 동작하기 때문입니다.

09

post-images 스토리지와 avatars 스토리지 Row Level Security

6:52

storage.objects 테이블에 정책을 걸어 "폴더 = 유저 id" 규칙을 강제.

sql · 업로드 경로 컨벤션: {bucket}/{user_id}/{filename}
-- 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]);
storage.foldername(name)는 오브젝트 경로를 / 기준으로 배열로 쪼갭니다. {user_id}/avatar.png 형태로 업로드 규칙을 강제하면 [1]이 바로 uid가 되어 정책이 간단해집니다.
10

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.users row 접근 및 profiles insert가 가능해짐.
  • raw_user_meta_data는 회원가입 시 signUp({ options: { data: {...} } })로 넘긴 값이 들어있는 JSONB 컬럼.
  • set search_path = ''는 스키마 하이재킹 공격을 막기 위한 보안 관례 — 모든 객체를 public. 처럼 풀네임으로 참조해야 함.
11

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, postsupdated_at 컬럼이 있는 모든 테이블에 재사용할 수 있는 것이 포인트.

12

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();
greatest(x - 1, 0)로 음수 방지 — 동시성 이슈나 데이터 정합성이 깨진 상황에서도 카운트가 음수로 내려가지 않도록 방어.
13

update_likes_count 트리거 함수, handle_like 함수

7:16

좋아요 카운트 동기화 + 좋아요 토글을 하나의 RPC로 감싸기.

sql · 카운트 동기화 트리거
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();
sql · 토글 RPC (클라이언트에서 supabase.rpc('handle_like', ...) 로 호출)
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 패턴.

14

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를 받지 않는 것이 보안 설계 포인트.
15

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;
security_invoker = true가 핵심입니다. 기본값(false)이면 뷰가 정의한 사람의 권한으로 실행되어 RLS를 우회할 위험이 있는데, security_invoker를 켜면 뷰를 조회하는 사람의 권한 + RLS가 그대로 적용됩니다.

이 뷰들 덕분에 프론트엔드는 posts + profiles를 각각 조회하고 합칠 필요 없이, select * from post_display_view 한 번으로 피드에 필요한 데이터를 받습니다.

16

get_my_posts 함수, search_posts 함수

7:18

뷰만으로 부족한 조건부/검색 조회는 RPC 함수로 보완.

sql · 내 게시글만 페이지네이션 조회
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;
$$;
sql · 캡션/작성자 검색
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 || '%';
$$;
language sql security invoker returns setof view

이 둘은 security definer가 아니라 security invoker로 선언된 점이 눈여겨볼 부분입니다 — 이미 post_display_view 자체에 RLS 안전장치가 있으므로, 함수는 그냥 호출자 권한을 그대로 물려받게 둬도 안전합니다.

17

create_post, update_post, create_comment, update_comment 함수

10:36

쓰기 작업을 RPC로 감싸 검증 로직을 한 곳에 모으기.

sql · create_post
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;
$$;
sql · update_post
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;
$$;
sql · create_comment / update_comment
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;
$$;
패턴 요약. 네 함수 모두 (1) 입력 검증 → (2) auth.uid()를 소유권 기준으로 사용 → (3) 대상이 없으면 명시적으로 raise exception. RLS가 이미 걸려 있어도, RPC 안에서 한 번 더 소유권을 확인해 "0 rows affected"를 조용히 넘기지 않고 에러로 알리는 것이 이 강의의 스타일입니다.
18

service_role 권한

마지막 정리 — anon / authenticated / service_role 세 롤의 역할 구분.

roleRLS 적용주 사용처
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 키가 프론트엔드 번들에 노출되지 않았는지 최종 점검