ezSplit을 Supabase로 옮기며 배우는 백엔드 (v2)
1차 스터디에서는 trips/trip_members 기반 "공동편집" 모델을 만들었는데, 실제 요구사항을 더 들어보니 "공유하기" 하나로 통합하는 게 맞다는 결론이 나왔습니다. 이 문서는 최종 확정된 users/shares/share_recipients 스키마를 기준으로 다시 씁니다. 요구사항 근거는 요구사항정의서 참고.
v1과 뭐가 달라졌나
재설계이전 스터디(trips/trip_members 기반 공동편집)를 왜 갈아엎었는지 먼저 정리합니다.
| v1 (공동편집 정규화) | v2 (공유하기 통합, 이 문서) | |
|---|---|---|
| 테이블 | trips, trip_members, contributions, expenses, shares (5개) | users, shares, share_recipients (3개) |
| 지출 저장 | 정규화된 SQL 컬럼 | 정규화된 자식 테이블(share_members/share_expenses/share_contributions) — 처음엔 jsonb 통짜였다가 03에서 다시 쪼갬 |
| 참여 방식 | 초대코드로 누구나(공개형) | 총무가 특정 유저 지정(타겟형) |
| 동기화 | Supabase Realtime 자동 push | 새로고침 버튼으로 수동 pull |
| RLS 스타일 | 멤버십 확인 정책(is_trip_member) | 정책 자체를 안 두고 RPC로만 열기 |
users 테이블
profiles 대체 + 크레딧 지갑을 겸함. 로컬 Person 타입과 컬럼명까지 맞췄습니다.
create table public.users (
id uuid references auth.users(id) on delete cascade primary key,
name text not null, -- Person.name
color text not null default '#6A5AE0', -- Person.color
avatar jsonb, -- Person.avatar 그대로
credits integer not null default 100, -- 서버 전용
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
PersonAvatar({kind:'emoji'|'photo', value} 유니언)를 판별 컬럼 패턴(avatar_kind + avatar_value)으로 쪼갰습니다 — v1 스터디에서 지출의 결제자 유니언을 payer_type 판별 컬럼으로 표현했던 것과 같은 발상이었죠. 그런데 이러면 컬럼명 자체가 로컬 필드명(avatar)과 달라집니다. 필드명이 다르면 "이거 다른 자료형 아닌가"라는 의심을 사기 쉽습니다. Trip 전체를 jsonb로 통짜 저장하는 것과 같은 방식으로, avatar 컬럼 하나를 jsonb로 만들어 PersonAvatar 객체를 그대로 저장하면 이름도 구조도 완전히 동일해집니다.create or replace function public.handle_new_user()
returns trigger language plpgsql
security definer set search_path = ''
as $$
declare
v_photo_url text := coalesce(new.raw_user_meta_data->>'avatar_url', new.raw_user_meta_data->>'picture');
begin
insert into public.users (id, name, avatar)
values (
new.id,
coalesce(new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'name', split_part(new.email, '@', 1)),
case when v_photo_url is not null then jsonb_build_object('kind', 'photo', 'value', v_photo_url) else null end
);
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
이 부분은 이전 스터디와 패턴이 같습니다 — auth.users insert 트리거로 공개 프로필을 자동 생성하는 건 거의 모든 Supabase 앱의 표준 관례입니다. 구글 로그인은 raw_user_meta_data에 avatar_url 또는 picture 키로 사진 URL을 넣어주므로, 있으면 jsonb_build_object로 {kind:'photo', value:그 URL} 모양을 만들어 넣고 없으면(다른 로그인 방식 등) null로 둡니다 — 이 경우 유저가 나중에 이모지를 직접 고르게 됩니다.
shares + 정규화 자식 테이블
2026-09-22 재번복여정 데이터를 data jsonb 통짜로 넣었다가, 앱 확장성을 고려해 다시 쪼갰습니다. 그 판단이 왜 뒤집혔는지부터.
(v2 당시 판단, 참고용) "정산 계산은 SQL이 아니라 클라이언트의 settle.ts가 처리한다. 서버가 지출을 하나하나 조회할 일이 없으니 정규화의 이점(부분 조회, 조인, 인덱스)을 쓸 데가 없다" — 이 전제 자체는 지금도 사실이지만, "앞으로도 쭉 그럴 것"이라는 암묵적 가정이 깨졌습니다.
그래서 shares는 스칼라 필드만 컬럼으로 남기고, 배열이었던 members/contributions/expenses는 각각 자식 테이블로 뺐습니다.
create table public.shares (
id text primary key default encode(extensions.gen_random_bytes(6), 'hex'),
owner_id uuid not null references public.users(id),
name text not null, -- Trip.name
start_date text, -- Trip.start
end_date text, -- Trip.end
currency text not null,
treasurer_id text, -- Trip.treasurerId
status text not null,
last_shares jsonb, -- Record<memberId, ratio> — 작은 보조맵이라 jsonb 유지
is_public boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
expires_at timestamptz not null default (now() + interval '1 month'),
deleted_at timestamptz
);
create table public.share_members (
id uuid primary key default gen_random_uuid(),
share_id text not null references public.shares(id) on delete cascade,
local_id text not null, -- Member.id
person_id uuid, -- Member.personId
name text not null, color text not null, avatar jsonb, active boolean,
unique (share_id, local_id)
);
create table public.share_expenses (
id uuid primary key default gen_random_uuid(),
share_id text not null references public.shares(id) on delete cascade,
local_id text not null, -- Expense.id
at text, label text not null, amount numeric not null, currency text,
payer jsonb not null, -- PayerRef 판별 유니온 — 작아서 jsonb 유지
note text, route text[], participants text[],
shares jsonb, share_amount_mode boolean, category text,
created_at text, created_by text, updated_at text, updated_by text,
deleted_at text, deleted_by text,
unique (share_id, local_id)
);
-- share_contributions도 같은 모양 (memberId/amount/kind/at + 감사 필드 4쌍)
payer(3갈래 판별 유니온)와 shares/last_shares(Record<memberId, number> 보조 맵)는 여전히 jsonb입니다. 기준은 "그 필드를 서버가 독립적으로 조회·조인·인덱싱할 일이 있는가" — members/expenses/contributions는 각자 생명주기가 있는 엔티티라 정규화 대상이지만, payer는 expense row 하나에 완전히 종속된 작은 값이라 쪼갤 이유가 없습니다.클라이언트 코드는 안 바뀝니다 — 여전히 Trip 객체 전체를 jsonb로 RPC에 보냅니다. 서버가 그걸 풀고(unpack) 조립하는(assemble) 헬퍼 두 개를 새로 둬서, "로컬 타입과 구조적으로 동일하다"는 원칙은 RPC 경계에서 그대로 유지합니다.
create or replace function public.write_share_trip(p_share_id text, p_data jsonb)
returns void language plpgsql security definer set search_path = '' as $$
begin
update public.shares set name = p_data->>'name', ... where id = p_share_id;
delete from public.share_members where share_id = p_share_id;
insert into public.share_members (share_id, local_id, person_id, name, color, avatar, active)
select p_share_id, m->>'id', nullif(m->>'personId','')::uuid, m->>'name', m->>'color', m->'avatar', (m->>'active')::boolean
from jsonb_array_elements(p_data->'members') as m;
-- expenses/contributions도 같은 패턴: delete-then-insert(멱등 재작성)
end; $$;
왜 delete-then-insert인가, upsert가 아니라? "지출을 하나 지웠다"는 사실은 배열에서 그 원소가 빠졌다는 것으로만 표현되는데(로컬은 deletedAt 소프트삭제라 배열엔 남지만), diff 기반 upsert는 "빠진 걸 지운다"는 로직을 따로 짜야 합니다. 공유는 애초에 "그 순간의 스냅샷 전체를 다시 쓴다"는 의미라, 자식 행을 통째로 지우고 다시 넣는 게 로직도 단순하고 항상 정확합니다 — 트랜잭션 안에서 실행되므로(RPC 함수 하나 = 트랜잭션 하나) 중간 상태가 보일 일도 없습니다.
default (now() + interval '1 month') 처럼 괄호로 감싼 표현식도 컬럼 기본값으로 쓸 수 있습니다 — now()가 상수가 아니라 매 insert 시점에 평가되는 함수라 가능한 패턴입니다.share_recipients — 대상 + 권한 조인 테이블
"누구에게 공유했는지"와 "그 사람이 수정 가능한지"를 한 테이블에.
create table public.share_recipients (
share_id text not null references public.shares(id) on delete cascade,
user_id uuid not null references public.users(id),
can_edit boolean not null default false,
added_at timestamptz not null default now(),
primary key (share_id, user_id)
);
create index share_recipients_user_id_idx on public.share_recipients(user_id);
- 복합 PK
(share_id, user_id)— 같은 사람을 같은 공유에 두 번 추가하는 게 DB 레벨에서 막힙니다. 이전 스터디의likes테이블(복합 PK로 중복 좋아요 차단)과 같은 발상입니다. can_edit컬럼 하나가 "기본 읽기전용, 총무가 개인별로 승격" 요구사항 전체를 담당합니다. 별도의 role 테이블이나 enum 없이 boolean 하나로 충분한 이유는, 권한 단계가 딱 두 가지(읽기/쓰기)뿐이기 때문입니다.user_id_idx는 "내가 받은 공유 목록 보기"(where user_id = auth.uid()) 조회를 위한 인덱스.
핵심 패턴: 테이블을 아예 잠그고 RPC로만 열기
이번에 새로 배우는 패턴1차 스터디의 RLS는 "조건에 맞으면 보여주는" 정책이었다면, 이번엔 "정책을 아예 안 만든다"는 다른 전략을 씁니다.
alter table public.shares enable row level security;
alter table public.share_recipients enable row level security;
alter table public.share_members enable row level security; -- 정규화 자식 테이블(03)도 동일 전략
alter table public.share_expenses enable row level security;
alter table public.share_contributions enable row level security;
-- select/insert/update 정책이 단 하나도 없다.
-- RLS가 켜져 있는데 허용 정책이 없으면 → 기본적으로 전부 거부된다.
-- 유일한 예외:
create policy "Owner can delete own share"
on public.shares for delete
using (owner_id = auth.uid());
shares를 select 정책으로 열어주면(예: "recipient면 볼 수 있음"), anon/authenticated 롤이 테이블에 직접 쿼리할 수 있게 되어 owner_id나 다른 사람들의 data 존재 여부를 이런저런 필터로 캐낼 여지가 생깁니다. 아예 select를 막고 get_share 같은 security definer RPC 하나로만 창구를 좁히면, "권한 없으면 조용히 빈 결과"처럼 훨씬 세밀하게 응답을 제어할 수 있습니다.RPC 함수는 security definer라 RLS를 우회해서 테이블에 접근하므로, "테이블은 잠그고 함수만 연다"는 조합이 성립합니다. 이전 스터디의 is_trip_member() 헬퍼가 "정책 안에서 쓰는 도구"였다면, 이번 can_access_share()는 "함수 안에서 쓰는 도구"라는 차이가 있습니다.
can_access_share / can_edit_share 헬퍼
뒤에 나올 모든 RPC가 이 두 함수를 재사용합니다.
create or replace function public.can_access_share(share_id_input text)
returns boolean language sql
security definer set search_path = '' stable as $$
select exists (select 1 from public.shares
where id = share_id_input and owner_id = auth.uid())
or exists (select 1 from public.share_recipients
where share_id = share_id_input and user_id = auth.uid());
$$;
create or replace function public.can_edit_share(share_id_input text)
returns boolean language sql
security definer set search_path = '' stable as $$
select exists (select 1 from public.shares
where id = share_id_input and owner_id = auth.uid())
or exists (select 1 from public.share_recipients
where share_id = share_id_input and user_id = auth.uid() and can_edit = true);
$$;
두 함수 모두 "소유자는 항상 예외적으로 통과"하는 조건이 앞에 붙어 있습니다 — 총무 본인은 share_recipients에 자기 row가 없어도(자기 자신을 recipient로 등록할 필요가 없으므로) 접근·수정이 항상 가능해야 하기 때문입니다. 이 "owner OR recipient-with-flag" 패턴을 한 곳에 모아두면, 뒤에 나올 6개 RPC가 매번 같은 조건을 반복해서 쓸 필요가 없습니다.
create_share — 크레딧을 원자적으로 차감하기
"조회 후 차감"을 별도 쿼리 두 번으로 하면 동시성 버그가 생깁니다. 그걸 막는 락 패턴.
create or replace function public.create_share(p_data jsonb, p_recipient_ids uuid[])
returns table(share_id text, credits_left integer)
language plpgsql
security definer set search_path = ''
as $$
declare
v_cost integer := 10;
v_credits integer;
v_id text;
v_recipient uuid;
begin
if p_recipient_ids is null or array_length(p_recipient_ids, 1) is null then
raise exception 'at least one recipient is required';
end if;
select credits into v_credits from public.users where id = auth.uid() for update;
if v_credits is null or v_credits < v_cost then
raise exception 'insufficient credits';
end if;
insert into public.shares (owner_id) values (auth.uid()) returning id into v_id;
perform public.write_share_trip(v_id, p_data); -- p_data(jsonb Trip)를 풀어서 share_members/expenses/contributions에 채움 (03 참고)
perform public.record_credit_transaction(auth.uid(), -v_cost, 'share_created', v_id, null);
foreach v_recipient in array p_recipient_ids loop
insert into public.share_recipients (share_id, user_id) values (v_id, v_recipient)
on conflict (share_id, user_id) do nothing;
end loop;
return query select v_id, credits from public.users where id = auth.uid();
end;
$$;
for update가 핵심입니다. 크레딧 잔액을 읽을 때 그 row에 락을 걸어두면, 같은 유저가 거의 동시에 "공유하기"를 두 번 눌러도 두 번째 트랜잭션은 첫 번째가 끝날 때까지 대기했다가 줄어든 잔액을 다시 확인합니다. 락 없이 select → 클라이언트에서 판단 → update로 나눠서 했다면, 크레딧 5개 남았을 때 두 요청이 동시에 "5 ≥ 10? 아니 근데 동시에 통과" 같은 경쟁 조건이 생길 수 있습니다.foreach ... in array ... loop로 대상 유저 배열을 순회하며 share_recipients에 한 명씩 insert — on conflict do nothing은 혹시 같은 사람이 배열에 중복으로 들어와도 에러 없이 넘어가게 합니다.
update public.users set credits = credits - v_cost를 직접 쓰지 않고 perform record_credit_transaction(...)을 부르는 이유는 다음 섹션(10b)에서 다룹니다 — 이 락은 그대로 유지된 채 트랜잭션이 이어지므로 안전성은 똑같습니다.
get_share — 새로고침 버튼이 부르는 함수
권한 없는 사람에게는 "존재하는지조차" 알려주지 않습니다.
create or replace function public.get_share(p_share_id text)
returns table(data jsonb, can_edit boolean, created_at timestamptz, updated_at timestamptz, expires_at timestamptz)
language plpgsql
security definer set search_path = ''
as $$
begin
perform public.sweep_expired_shares();
if not public.can_access_share(p_share_id) then
return; -- 접근 불가(삭제됨 포함) 시 빈 결과 — 존재 여부 자체를 숨긴다
end if;
return query
select public.read_share_trip(p_share_id), public.can_edit_share(p_share_id), s.created_at, s.updated_at, s.expires_at
from public.shares s
where s.id = p_share_id;
end;
$$;
read_share_trip()이 share_members/share_expenses/share_contributions를 각각 jsonb_agg로 모아 Trip 모양 그대로 재조립합니다(03 참고) — 정규화했다고 클라이언트가 받는 모양이 달라지지는 않습니다, 서버 내부 저장 방식만 바뀐 겁니다.
권한이 없을 때 raise exception이 아니라 그냥 빈 테이블을 반환(return;)한다는 점이 의도적입니다. 에러 메시지를 다르게 주면("no such share" vs "forbidden") 공격자가 그 차이로 share_id가 존재하는지 아닌지를 추측(enumeration)할 수 있는데, 항상 똑같이 "빈 결과"만 주면 그 정보가 새지 않습니다.
stable을 뗐습니다. 원래 이 함수는 순수 조회라 stable로 표시돼 있었는데, 이제 맨 앞에서 sweep_expired_shares()(쓰기 작업)를 호출하므로 더 이상 "읽기만 한다"는 게 사실이 아닙니다. 함수의 실제 동작과 선언한 휘발성(volatility)이 다르면 쿼리 플래너가 잘못된 가정을 할 수 있어서, 쓰기가 섞이는 순간 stable 표시를 빼는 게 맞습니다.응답에 can_edit을 같이 실어 보내는 이유는, 클라이언트가 "새로고침" 버튼 하나로 데이터와 "지금 내가 수정 가능한 상태인지"를 한 번에 받아서 UI(수정 버튼 활성화 여부)를 갱신할 수 있게 하기 위해서입니다.
update_share_data — 만료되면 조용히 실패
만료된 공유는 더 이상 수정할 수 없습니다. (2026-09-22: "자동 스냅샷 전환" 대신 이제는 소프트 삭제로 이어집니다 — 다음 섹션 참고)
create or replace function public.update_share_data(p_share_id text, p_data jsonb)
returns boolean
language plpgsql
security definer set search_path = ''
as $$
begin
perform public.sweep_expired_shares();
if not public.can_edit_share(p_share_id) then
return false; -- 삭제(=만료)됐거나 권한 없음
end if;
if not exists (select 1 from public.shares where id = p_share_id and deleted_at is null) then
return false;
end if;
perform public.write_share_trip(p_share_id, p_data); -- 자식 테이블 delete-then-insert로 통째로 재작성 (03 참고)
return true;
end;
$$;
found가 하는 일. plpgsql에서 직전 update/insert/select가 최소 1개 row에 영향을 줬으면 found가 true가 됩니다. 여기서는 can_edit_share가 이미 삭제 여부를 걸러주지만, where 절에도 deleted_at is null을 한 번 더 둬서 이중으로 방어합니다.클라이언트는 이 함수가 false를 돌려주면 "공유가 만료돼서 더는 반영되지 않는다"는 걸 알고 로컬에 저장해둔 expiresAt을 기준으로 이후 호출 자체를 멈출 수 있습니다(불필요한 RPC 호출 방지).
sweep_expired_shares — 크론 없이 청소하기
2026-09-22 추가"스냅샷 전환"(만료 후에도 계속 조회 가능) 개념이 폐기되면서, 만료된 공유는 이제 실제로 지워야 합니다. 근데 Supabase에 아직 별도 스케줄러(pg_cron)를 안 붙였다면 어떻게 정리할까요?
create or replace function public.sweep_expired_shares()
returns void
language sql
security definer set search_path = ''
as $$
update public.shares
set deleted_at = now()
where deleted_at is null and expires_at <= now();
$$;
get_share/update_share_data/join_share_by_code — 즉 공유와 관련된 거의 모든 쓰기·읽기 RPC의 맨 앞에서 이 함수를 먼저 호출합니다. 그러면 "누군가 그 공유를 건드리는 순간"에 자연스럽게 청소가 일어납니다. 크론잡 인프라(pg_cron, Edge Function 스케줄) 없이도 정확성은 보장됩니다 — 접근 제어(can_access_share 등)는 이 스윕과 무관하게 expires_at/deleted_at 조건으로 이미 걸려 있어서, 스윕이 늦게 돌아도 "지워지기 전에 몰래 보이는" 틈은 없습니다.한계. 아무도 그 공유를 다시 열어보지 않으면 deleted_at이 영영 안 채워지고 row가 테이블에 계속 남습니다(데이터는 안 보이지만 저장 공간은 차지). 지금 규모(개인 프로젝트)에선 무시할 만하지만, 나중에 데이터가 쌓이면 pg_cron으로 select cron.schedule('sweep-shares', '0 3 * * *', 'select public.sweep_expired_shares()') 같은 걸 추가해서 진짜 정기 청소로 바꾸면 됩니다 — 지금 만든 함수는 그대로 재사용됩니다, 호출하는 방식만 바뀔 뿐입니다.
set_recipient_edit / add_share_recipient — 총무 전용 액션
"owner 확인 → 없으면 예외" 패턴이 반복됩니다.
create or replace function public.set_recipient_edit(p_share_id text, p_user_id uuid, p_can_edit boolean)
returns void language plpgsql security definer set search_path = '' as $$
begin
if not exists (select 1 from public.shares where id = p_share_id and owner_id = auth.uid()) then
raise exception 'only the owner can change edit permissions';
end if;
update public.share_recipients
set can_edit = p_can_edit
where share_id = p_share_id and user_id = p_user_id;
end;
$$;
create or replace function public.add_share_recipient(p_share_id text, p_user_id uuid)
returns void language plpgsql security definer set search_path = '' as $$
begin
if not exists (select 1 from public.shares where id = p_share_id and owner_id = auth.uid()) then
raise exception 'only the owner can add recipients';
end if;
insert into public.share_recipients (share_id, user_id)
values (p_share_id, p_user_id)
on conflict (share_id, user_id) do nothing;
end;
$$;
이 둘은 왜 can_edit_share() 헬퍼를 안 쓰고 owner_id = auth.uid()를 직접 또 썼을까요? can_edit_share는 "이 사람이 데이터를 수정해도 되는가"(recipient의 can_edit 포함)를 묻는 함수고, 이 두 액션은 총무만 할 수 있어야 하는 더 좁은 권한이기 때문입니다 — 헬퍼를 재사용하면 오히려 "수정 권한 받은 recipient가 다른 사람 권한까지 바꿀 수 있는" 구멍이 생깁니다. 비슷해 보이는 조건도 실제 요구사항이 다르면 재사용하지 않는 게 맞습니다.
credit_transactions — 잔액 하나로는 부족한 이유
2026-09-22 추가users.credits 숫자 하나만 두고 update ... set credits = credits - 10으로 관리해도 지금까지는 잘 동작했습니다. 결제(충전)가 실제로 붙는 순간 왜 부족해지는지 정리합니다.
- 감사 기록이 없습니다. "내 크레딧이 왜 60이 됐지?"에 답할 방법이 없습니다.
- 결제 웹훅은 중복 도착할 수 있습니다. 네트워크 재시도로 같은 결제 완료 알림이 두 번 오면,
credits = credits + 100만 있는 시스템은 두 번 지급해버립니다.
create table public.credit_transactions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.users(id),
amount integer not null, -- 양수=지급, 음수=차감
reason text not null, -- 'signup_bonus' | 'share_created' | 'purchase' | 'refund'
reference_id text,
external_payment_id text unique, -- 결제 게이트웨이 거래ID
created_at timestamptz not null default now(),
constraint amount_not_zero check (amount <> 0)
);
create or replace function public.record_credit_transaction(
p_user_id uuid, p_amount integer, p_reason text,
p_reference_id text default null, p_external_payment_id text default null
)
returns void language plpgsql security definer set search_path = '' as $$
begin
insert into public.credit_transactions (user_id, amount, reason, reference_id, external_payment_id)
values (p_user_id, p_amount, p_reason, p_reference_id, p_external_payment_id)
on conflict (external_payment_id) do nothing;
if found then
update public.users set credits = credits + p_amount, updated_at = now() where id = p_user_id;
end if;
end;
$$;
on conflict (external_payment_id) do nothing가 중복 지급을 막는 원리. 결제 게이트웨이 웹훅이 같은 거래ID로 두 번 호출돼도, 두 번째 insert는 unique 제약에 걸려 조용히 무시됩니다. plpgsql의 found는 "직전 문장이 실제로 row에 영향을 줬는가"를 알려주므로, 중복이면 found = false가 되어 users.credits 갱신도 자동으로 건너뜁니다 — 삽입 성공 여부와 잔액 갱신 여부가 found 하나로 자연스럽게 묶입니다.external_payment_id가 null인 경우(가입 보너스, 공유 발행 차감처럼 결제와 무관한 항목)는 걱정할 필요 없습니다 — Postgres unique 제약에서 null끼리는 서로 충돌하지 않습니다. 그래서 같은 이유로 여러 번 크레딧이 오갈 수 있는 항목들도 문제없이 계속 insert됩니다.
record_credit_transaction은 다른 함수 안에서만 호출되는 내부 헬퍼입니다. 실제 결제가 붙으면, 결제 게이트웨이 웹훅을 받는 서버(Edge Function)만 이 함수를 호출해야 합니다 — "나 결제했다"를 클라이언트 스스로 주장하게 하는 RPC를 열어두면 누구나 위조된 요청으로 크레딧을 받아갈 수 있습니다.users update RLS 정책은 "본인 row"만 확인하고 어떤 컬럼인지는 안 가렸습니다 — 즉 클라이언트가 PostgREST로 직접 update users set credits = 999999를 호출할 수 있었습니다. RLS는 행 단위 필터라 컬럼까지는 못 가리므로, 별도로 컬럼 단위 권한(grant)을 좁혀야 합니다:
revoke update on public.users from authenticated;
grant update (name, color, avatar, updated_at) on public.users to authenticated;
이제 authenticated 롤은 credits 컬럼을 아예 update 권한 목록에서 빼서, RLS 정책을 통과하더라도 그 컬럼만은 건드릴 수 없습니다. record_credit_transaction은 security definer라 이 grant 제한을 우회해서(함수 소유자 권한으로 실행) 정상 동작합니다.set_share_public / join_share_by_code — 디스코드식 초대
총무가 일일이 추가하지 않아도, 코드를 받은 사람이 스스로 들어올 수 있게. 단, 그 창구 자체는 총무가 여닫을 수 있어야 합니다.
create or replace function public.set_share_public(p_share_id text, p_is_public boolean)
returns void language plpgsql security definer set search_path = '' as $$
begin
if not exists (select 1 from public.shares where id = p_share_id and owner_id = auth.uid()) then
raise exception 'only the owner can change public visibility';
end if;
update public.shares set is_public = p_is_public where id = p_share_id;
end;
$$;
create or replace function public.join_share_by_code(p_share_id text)
returns void
language plpgsql
security definer set search_path = ''
as $$
begin
perform public.sweep_expired_shares();
if not exists (
select 1 from public.shares
where id = p_share_id and is_public = true and deleted_at is null and now() < expires_at
) then
raise exception 'invalid, non-public, or expired share code';
end if;
insert into public.share_recipients (share_id, user_id)
values (p_share_id, auth.uid())
on conflict (share_id, user_id) do nothing;
end;
$$;
눈여겨볼 점: join_share_by_code는 누구나(로그인만 하면) 호출 가능합니다 — owner 체크가 없습니다. 대신 is_public = true이고 만료 전인 코드를 정확히 아는 사람만 통과합니다. id가 encode(gen_random_bytes(6), 'hex')로 생성되는 12자리 랜덤 16진수라 추측이 사실상 불가능하므로, "모르면 못 들어온다"를 코드의 무작위성으로 보장하는 디스코드 초대 링크와 동일한 패턴입니다.
add_share_recipient는 총무가 "이 사람"이라고 콕 찍는 타겟형이지만, 이 경로는 "코드를 아는 사람이면 누구나"인 오픈형입니다. is_public이 기본 false인 이유가 여기 있습니다 — 명시적으로 켜지 않으면 코드가 존재해도 이 열린 경로 자체가 잠겨 있습니다.can_edit은 명시하지 않았으니 컬럼 기본값(false)이 그대로 적용됩니다 — 코드로 들어온 사람도 기본은 읽기전용이고, 총무가 set_recipient_edit로 따로 올려줘야 합니다.
find_account_by_email — 더미 멤버를 진성으로
auth 스키마를 안전하게 들여다보는 최소한의 창구.
create or replace function public.find_account_by_email(p_email text)
returns uuid
language sql
security definer stable
set search_path = ''
as $$
select id from auth.users where email = p_email limit 1;
$$;
일반 유저는 auth.users를 절대 직접 조회할 수 없습니다(이전 스터디 3번 섹션 참고). 이 함수는 security definer로 딱 "이메일 → uuid" 한 방향만 열어주는 좁은 구멍입니다. 이메일을 아는 사람만 그 계정의 uuid를 얻을 수 있고, 그 반대(uuid로 이메일 역조회)는 이 함수로 불가능합니다 — 최소 권한 원칙.
select *로 이메일·가입일 등을 통째로 돌려줬다면, 크레딧 잔액이나 다른 개인정보까지 새어나갈 수 있었습니다. RPC를 설계할 때 "이 함수가 꼭 필요한 만큼만 돌려주는가"를 항상 체크하는 습관이 여기서도 드러납니다.avatars 스토리지 — 프로필 사진
users.avatar.value(사진 URL)에 들어갈 값을 실제로 어디에 올려두는가.
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('avatars', 'avatars', true, 2097152, array['image/jpeg','image/png','image/webp']);
create policy "Avatars are publicly accessible"
on storage.objects for select using (bucket_id = '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]);
create policy "Users can delete own avatar"
on storage.objects for delete
using (bucket_id = 'avatars' and auth.uid()::text = (storage.foldername(name))[1]);
업로드 경로를 {user_id}/파일명으로 강제하고 storage.foldername(name)[1](경로를 /로 쪼갠 첫 조각)이 auth.uid()와 같은지만 확인하면, "본인 폴더에만 쓸 수 있다"는 규칙이 별도의 소유자 컬럼 없이 경로 자체로 강제됩니다 — 1차 스터디의 post-images/avatars 스토리지 RLS와 동일한 패턴입니다.
users.avatar 컬럼(jsonb)이 담당합니다. 업로드 성공 후 클라이언트가 그 URL을 받아 update public.users set avatar = jsonb_build_object('kind','photo','value', ...)로 따로 반영해줘야 합니다 — 스토리지 업로드가 자동으로 users 테이블을 갱신해주진 않습니다. 이모지를 고른 경우엔 스토리지를 아예 거치지 않고 avatar = '{"kind":"emoji","value":"🏖️"}'처럼 바로 저장합니다.my_shares — 배열 컬럼 대신 union 쿼리
설계 판단"내가 가진 공유 목록"이 필요할 때, users에 배열 컬럼을 추가하고 싶어지는 순간이 옵니다 — 왜 안 하는지 정리합니다.
create or replace function public.my_shares()
returns table(share_id text, role text, can_edit boolean)
language sql
security definer set search_path = ''
stable
as $$
select id, 'owner', true
from public.shares
where owner_id = auth.uid()
union all
select share_id, 'recipient', can_edit
from public.share_recipients
where user_id = auth.uid();
$$;
users.share_ids uuid[] 같은 컬럼을 만들면 create_share/add_share_recipient/join_share_by_code/공유 삭제 네 곳 모두에서 그 배열을 손으로 동기화해야 하고, 하나라도 빠뜨리면 실제 소유 관계와 배열이 어긋납니다. 게다가 배열 안의 값엔 외래키 제약을 걸 수 없어서, 공유가 삭제돼도 죽은 id가 배열에 남을 수 있습니다.반대로 shares.owner_id와 share_recipients.user_id는 이미 그 자체로 "누가 무엇을 가졌는지"를 표현하는 정규화된 데이터입니다. 새 컬럼이나 테이블 없이 조회 하나(union)로 끝나는 게, 데이터를 두 군데 나눠 저장하고 계속 맞춰주는 것보다 항상 안전합니다 — "저장된 사실"과 "계산해서 보여주는 값"을 구분하는 감각이 스키마 설계에서 중요한 이유입니다.
personId 승격 — 간접참조를 검토했다가 되돌린 이야기
설계 판단, 두 번 뒤집힘서버 스키마 얘기는 아니지만, 이 스키마와 맞물리는 클라이언트 설계를 세 단계로 오갔던 과정을 그대로 남겨둡니다 — 결론만큼 과정도 배울 게 있어서요.
실제 로컬 코드(src/lib/people.ts:108)에 이미 이런 함수가 있었습니다 — 어디서도 호출되지 않는 채로요.
// 로그인해서 personId가 auth uid로 바뀔 때, 기존 여정·사람 목록에 남은 참조를 옮긴다.
export function remapPersonId(trips, people, fromId, toId) {
// member.personId === fromId ? toId로 교체 : 그대로
// people[].id === fromId ? toId로 교체 : 그대로
}
1단계. 이 함수가 personId를 저장하는 필드 중 member.personId/people[].id 두 곳만 처리한다는 걸 발견했습니다. 나머지 세 곳은 그대로 방치돼 있었죠.
| 필드 | 어디 | remapPersonId가 처리하는가 |
|---|---|---|
member.personId | people.ts | ✅ |
people[].id | people.ts | ✅ |
Trip.treasurerId | newTrip.ts:86 | ❌ |
Expense/Contribution.createdBy/updatedBy/deletedBy | fund.ts, compose.ts | ❌ |
dutch.profile.v1의 personId 자체 | profile.ts | ❌ |
2단계 (기각됨). 이 구멍들을 보고 "그럼 애초에 personId를 안 바꾸면 되지 않나" 싶어서, Person에 account?: {id, provider, email} 같은 별도 필드를 얹고 personId는 영구 고정하는 간접참조 안으로 갔습니다. 그럴듯해 보였지만, 이러면 로그인 이후 서버와 관련된 모든 지점에서 "로컬 personId ↔ 실제 uuid"를 매번 번역해야 합니다.
remapPersonId를 5개 필드 전부 처리하도록 확장하고, 매 앱 로드마다 안전하게 재시도되는 멱등 함수로 감싸는 쪽이 정답이었습니다. 간접참조 필드는 만들지 않습니다.로컬 ↔ 서버 경계 + 체크리스트
- 서버를 만지는 클라이언트 코드는
src/lib/share.ts하나로 국한 —createShare/fetchShare(새로고침)/pushMyChanges/setRecipientEdit/addRecipient. - 정산 계산(
settle.ts,fund.ts)은 로컬이든 서버에서 받은 jsonb든 완전히 동일한 순수 함수로 처리 — 데이터 구조를 로컬Trip타입 그대로 유지했기 때문에 가능. - 이미지 스냅샷 저장(
html-to-image)은 서버와 완전히 무관한 별개 기능 — 무료, 아무나 가능.
배포 전 체크리스트
- users/shares/share_recipients 모두 RLS enable, shares/share_recipients는 select/insert/update 정책이 하나도 없는지 확인
- create_share의
select ... for update가 크레딧 차감을 원자적으로 보장하는지 동시 요청으로 테스트 - get_share가 권한 없을 때 에러가 아니라 빈 결과를 돌려주는지 확인 (존재 여부 은닉)
- update_share_data가 만료 후 조용히 false를 돌려주는지 확인
- 만료된 공유는 get_share/my_shares에서도 더 이상 안 보이는지(소프트 삭제됐는지) 확인 — 스냅샷 전환 개념은 폐기됨
- set_recipient_edit / add_share_recipient가 owner 아닌 사람이 호출하면 예외를 던지는지 확인
- find_account_by_email이 uuid 외의 정보를 절대 노출하지 않는지 확인
- avatars 버킷 업로드/수정/삭제 정책이 본인 폴더로만 제한되는지 확인
authenticated가users.credits를 직접 update할 수 없는지(컬럼 grant 확인) —record_credit_transaction으로만 바뀌는지 테스트- 같은
external_payment_id로record_credit_transaction을 두 번 호출해도 크레딧이 한 번만 지급되는지 확인 - service_role 키가 클라이언트 번들에 포함되지 않았는지 최종 점검