ezSplit을 Supabase로 옮기며 배우는 백엔드
projects/dutch의 local-dev 브랜치는 localStorage에만 데이터를 저장합니다. 이걸 여러 기기·여러 인원이 실시간으로 같은 여정을 보는 서버 버전으로 옮기는 걸 기준으로, 앞서 만든 소셜 피드 예제 대신 "여정을 공유하는 사람들" 구조에 맞춰 스키마·RLS·함수를 다시 설계합니다.
로컬 모델 → 서버 스키마 매핑
src/types.ts의 Trip / Member / Person / Contribution / Expense를 그대로 테이블로 옮기되, "배열로 중첩"된 구조를 "관계형 테이블"로 풀어줍니다.
| 로컬 (types.ts) | 서버 테이블 | 바뀌는 이유 |
|---|---|---|
Person (기기 안의 사람 목록) | profiles | 기기가 아니라 계정에 귀속되도록. auth.users와 1:1. |
Trip | trips | 최상위 테이블. members / contributions / expenses 배열은 분리. |
Trip.members[] | trip_members | 여정↔사람의 다대다 조인 테이블. 계정 없는 게스트 멤버도 담아야 함. |
Trip.contributions[] | contributions | trip_id FK로 분리, RLS는 소속 여정 기준. |
Trip.expenses[] | expenses | 가장 필드가 많은 테이블. payer 유니언, shares 맵은 jsonb로. |
profiles 테이블 — Person을 계정에 연결
types.ts의 Person { id, name, color, avatar }을 그대로 계정용 프로필로 승격.
create table public.profiles (
id uuid references auth.users(id) on delete cascade primary key,
name text not null,
color text not null default '#3ecf8e',
avatar_kind text check (avatar_kind in ('emoji','photo')),
avatar_value text,
created_at timestamptz not null default now()
);
alter table public.profiles enable row level security;
-- 같은 여정 멤버끼리는 서로 이름/아바타를 봐야 하므로 전체 공개 읽기
create policy "Profiles are viewable by everyone"
on public.profiles for select using (true);
create policy "Users can update own profile"
on public.profiles for update
using (auth.uid() = id) with check (auth.uid() = id);
가입 시 자동 생성은 지난 스터디의 handle_new_user 트리거를 그대로 재사용하면 됩니다 — raw_user_meta_data에서 name, 랜덤 color 하나를 뽑아 insert.
trips 테이블
Trip에서 배열(members/contributions/expenses)만 빼면 거의 그대로 컬럼이 됩니다.
create table public.trips (
id uuid primary key default gen_random_uuid(),
name text not null,
start_date date,
end_date date,
currency text not null, -- ISO 4217, 예: 'KRW'
status text not null default 'ongoing' check (status in ('ongoing','ended')),
treasurer_id uuid references public.profiles(id),
last_shares jsonb, -- 직전 지출의 부담 비율 기본값 캐시
invite_code text unique, -- 11. 초대 코드에서 사용
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
status는 로컬 코드의TRIP_STATUSES = ['ongoing','ended']그대로check제약으로 이식.treasurer_id는 로컬 주석처럼 personId(uuid)를 직접 가리킵니다 —trip_members.id가 아니라profiles.idFK.invite_code는 로컬 버전엔 없던 컬럼 — 서버 버전에서 "여정 참여"가 새로 필요해져 추가.
trip_members 테이블 — 계정 없는 멤버도 허용
여기가 이 앱 스키마에서 가장 중요한 설계 결정입니다.
로컬 코드 주석에 이렇게 적혀 있습니다: "전역 Person.id — 실제 로그인 계정과 연결된 '진성' 멤버만 갖는다. 임의로 추가한 더미 멤버는 없다." 즉 여행 멤버 중에는 앱을 안 쓰는 사람(현금으로만 정산할 친구)도 있어야 합니다. 그래서 person_id를 nullable로 둡니다.
create table public.trip_members (
id uuid primary key default gen_random_uuid(),
trip_id uuid not null references public.trips(id) on delete cascade,
person_id uuid references public.profiles(id), -- null = 계정 없는 게스트 멤버
name text not null,
color text not null,
avatar_kind text check (avatar_kind in ('emoji','photo')),
avatar_value text,
active boolean not null default true,
created_at timestamptz not null default now(),
-- 같은 사람이 한 여정에 중복 멤버로 들어오는 것만 막는다 (person_id가 있을 때만)
unique (trip_id, person_id)
);
create index trip_members_trip_id_idx on public.trip_members(trip_id);
create index trip_members_person_id_idx on public.trip_members(person_id) where person_id is not null;
unique(trip_id, person_id)는 person_id가 null인 행끼리는 서로 다른 값 취급이라 게스트 멤버는 몇 명이든 중복 제약에 안 걸립니다 — 의도한 동작이라 별도 처리가 필요 없습니다.contributions 테이블 — 공금 지갑
Contribution도 필드가 거의 그대로 옮겨집니다.
create table public.contributions (
id uuid primary key default gen_random_uuid(),
trip_id uuid not null references public.trips(id) on delete cascade,
member_id uuid not null references public.trip_members(id) on delete cascade,
amount numeric not null check (amount > 0),
kind text not null default 'in' check (kind in ('in','out')),
occurred_at timestamptz,
created_at timestamptz not null default now(),
created_by uuid references public.profiles(id),
updated_at timestamptz,
updated_by uuid references public.profiles(id),
deleted_at timestamptz, -- 07. 소프트 삭제
deleted_by uuid references public.profiles(id)
);
create index contributions_trip_id_idx on public.contributions(trip_id) where deleted_at is null;
at(로컬 필드명)는 다른 테이블의 created_at과 헷갈리지 않도록 occurred_at으로 이름을 바꿨습니다 — "실제 있었던 시점" vs "DB에 기록된 시점"을 분리하는 흔한 관례입니다.
expenses 테이블 — payer 유니언과 shares 맵 저장
가장 필드가 많고, 로컬 타입의 유니언(PayerRef)을 SQL로 어떻게 표현할지가 핵심.
type PayerRef =
| { type: 'fund' }
| { type: 'member'; id: string }
| { type: 'mixed'; id: string; fund: number }
Postgres에는 TS 유니언 타입이 없으니, 판별 컬럼(discriminator) + nullable 컬럼들로 풀어냅니다.
create table public.expenses (
id uuid primary key default gen_random_uuid(),
trip_id uuid not null references public.trips(id) on delete cascade,
occurred_at timestamptz,
label text not null,
amount numeric not null check (amount >= 0),
currency text, -- null이면 trips.currency 사용
-- PayerRef 유니언 → 판별 컬럼 패턴
payer_type text not null check (payer_type in ('fund','member','mixed')),
payer_member_id uuid references public.trip_members(id), -- member/mixed일 때만
payer_fund_amount numeric, -- mixed일 때 공금이 낸 금액
note text,
route_from text, -- [from, to] → 두 컬럼으로
route_to text,
participants jsonb, -- string[] | null(=전원). trip_members.id 배열
shares jsonb, -- Record<memberId, weight>
share_amount_mode boolean not null default false,
category text,
created_at timestamptz not null default now(),
created_by uuid references public.profiles(id),
updated_at timestamptz,
updated_by uuid references public.profiles(id),
deleted_at timestamptz,
deleted_by uuid references public.profiles(id),
-- 유니언 규칙을 DB에서도 강제: mixed일 때만 payer_fund_amount가 있어야 함
constraint payer_shape_check check (
(payer_type = 'fund' and payer_member_id is null and payer_fund_amount is null) or
(payer_type = 'member' and payer_member_id is not null and payer_fund_amount is null) or
(payer_type = 'mixed' and payer_member_id is not null and payer_fund_amount is not null)
)
);
create index expenses_trip_id_idx on public.expenses(trip_id) where deleted_at is null;
expense_participants 같은 별도 테이블로 쪼갤 수도 있지만, 이 값들은 정산 계산(settle.ts)에서만 통째로 읽고 쓰는 값이라 조인·집계 쿼리 대상이 아닙니다. 로컬 코드의 Record<string, number> 모양을 그대로 jsonb로 옮기면 프론트 코드(fund.ts, shares.ts) 변경을 최소화할 수 있습니다. payer_shape_check 같은 constraint로 "정규화 안 한 대신 DB가 모양을 검증"하게 보완하는 절충입니다.소프트 삭제(deleted_at) 패턴을 DB에서 구현하기
로컬 코드의 activeExpenses() / activeContributions() 필터를 서버에서는 어떻게 재현할까.
export function activeExpenses(trip): Expense[] {
return trip.expenses.filter((e) => !e.deletedAt)
}
클라이언트가 매번 where deleted_at is null을 빼먹을 위험이 있으니, "삭제 안 된 것만 보이는 뷰"를 하나 더 만들어 기본 조회용으로 씁니다 (RLS는 09에서 다루는 멤버십 체크가 이 뷰에도 그대로 적용됩니다).
create view public.active_expenses with (security_invoker = true) as
select * from public.expenses where deleted_at is null;
create view public.active_contributions with (security_invoker = true) as
select * from public.contributions where deleted_at is null;
-- 실제 delete 대신 항상 UPDATE로 소프트 삭제 처리 (RLS의 delete 정책은 아예 안 씀)
create or replace function public.soft_delete_expense(expense_id_input uuid)
returns void language plpgsql security invoker set search_path = '' as $$
begin
update public.expenses
set deleted_at = now(), deleted_by = auth.uid()
where id = expense_id_input;
end;
$$;
update 정책만 열고 soft_delete_* 함수를 통해서만 지우게 합니다.RLS 사고방식 전환: "소유자 기반" vs "멤버십 기반"
달라진 부분지난 소셜 피드 스터디에서는 auth.uid() = author_id면 충분했습니다. 이 앱은 다릅니다.
| 소셜 피드 (posts) | ezSplit (trips) | |
|---|---|---|
| 행 하나의 주인 | 글쓴이 한 명 | 여정에 속한 여러 명 |
| 읽기 권한 | 전체 공개 | 여정 멤버만 (비공개 지출 내역) |
| 쓰기 권한 조건 | auth.uid() = author_id | auth.uid()가 trip_members에 존재하는지 서브쿼리로 확인 |
| 정책이 참조하는 테이블 | 자기 자신 하나 | 다른 테이블(trip_members)을 조인해서 판단 |
is_trip_member() 헬퍼 함수 & trips / trip_members RLS
모든 RLS 정책이 재사용할 멤버십 판정 함수를 먼저 만듭니다.
create or replace function public.is_trip_member(trip_id_input uuid)
returns boolean
language sql
security definer set search_path = ''
stable
as $$
select exists (
select 1 from public.trip_members
where trip_id = trip_id_input and person_id = auth.uid()
);
$$;
select가 trip_members의 RLS를 다시 타면, trip_members 정책이 또 is_trip_member()를 부르는 무한 루프가 될 수 있습니다. security definer로 만들어 이 함수 내부만큼은 RLS를 우회한 관리자 권한으로 "멤버인지 사실 확인"만 하고 끝냅니다.alter table public.trips enable row level security;
alter table public.trip_members enable row level security;
-- trips: 멤버만 조회, 멤버만 수정
create policy "Members can view their trips"
on public.trips for select
using (public.is_trip_member(id));
create policy "Logged-in users can create a trip"
on public.trips for insert
with check (auth.uid() = treasurer_id);
create policy "Members can update their trip"
on public.trips for update
using (public.is_trip_member(id))
with check (public.is_trip_member(id));
-- trip_members: 같은 여정 멤버끼리는 서로 보여야 정산 화면이 뜬다
create policy "Members can view trip roster"
on public.trip_members for select
using (public.is_trip_member(trip_id));
create policy "Members can add trip members"
on public.trip_members for insert
with check (public.is_trip_member(trip_id));
여정을 처음 만들 때는 아직 trip_members에 아무도 없어 is_trip_member가 항상 false이므로, trips insert는 별도로 treasurer_id 본인 확인만 하고, 만든 직후 트리거로 본인을 첫 멤버로 넣어줍니다 (다음 섹션들과 함께 create_trip RPC로 감싸는 걸 권장).
expenses / contributions RLS — 같은 멤버십 규칙 재사용
is_trip_member() 하나로 두 테이블 모두 처리됩니다 — 헬퍼 함수를 만든 보람.
alter table public.expenses enable row level security;
alter table public.contributions enable row level security;
create policy "Members can view expenses" on public.expenses
for select using (public.is_trip_member(trip_id));
create policy "Members can add expenses" on public.expenses
for insert with check (public.is_trip_member(trip_id));
create policy "Members can edit expenses" on public.expenses
for update using (public.is_trip_member(trip_id)) with check (public.is_trip_member(trip_id));
-- contributions도 동일 패턴
create policy "Members can view contributions" on public.contributions
for select using (public.is_trip_member(trip_id));
create policy "Members can add contributions" on public.contributions
for insert with check (public.is_trip_member(trip_id));
create policy "Members can edit contributions" on public.contributions
for update using (public.is_trip_member(trip_id)) with check (public.is_trip_member(trip_id));
updated_by 컬럼으로 "누가 마지막으로 고쳤는지"는 남습니다 — 앱 특성에 맞춘 의도적 설계입니다.초대 코드로 여정 참여하기
로컬엔 없던 기능로컬 버전 README에 "로그인·여정 공유·서버 동기화를 전부 걷어냈다"고 적혀 있던 바로 그 기능을 이제 만듭니다.
create or replace function public.join_trip_by_code(code_input text)
returns public.trips
language plpgsql
security definer set search_path = ''
as $$
declare
target_trip public.trips;
my_name text;
my_color text;
begin
select * into target_trip from public.trips where invite_code = code_input;
if target_trip.id is null then
raise exception 'invalid invite code';
end if;
select name, color into my_name, my_color from public.profiles where id = auth.uid();
insert into public.trip_members (trip_id, person_id, name, color)
values (target_trip.id, auth.uid(), my_name, my_color)
on conflict (trip_id, person_id) do nothing;
return target_trip;
end;
$$;
security definer가 필요한 이유는 08에서 본 것과 같습니다 — 초대받은 사람은 아직 trip_members에 없어 is_trip_member()가 false인 상태라, 일반 insert 정책만으로는 자기 자신을 추가할 권한이 없습니다. 이 함수가 "초대 코드를 아는 사람"이라는 별도 인증 수단으로 그 권한을 대신 부여합니다.
trips insert 시 트리거로 substr(md5(random()::text), 1, 6) 같은 짧은 코드를 자동 채워주면 카카오톡 공유 링크(ezsplit.app/join/ABC123)로 바로 연결하기 좋습니다.Realtime으로 여러 명이 같은 여정을 동시에 보기
로컬엔 없던 기능서버로 옮기는 가장 큰 이유 — 여행 중 누가 지출을 추가하면 다른 사람 화면에도 바로 뜨게.
alter publication supabase_realtime add table public.expenses;
alter publication supabase_realtime add table public.contributions;
supabase
.channel(`trip-${tripId}`)
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'expenses', filter: `trip_id=eq.${tripId}` },
(payload) => refetchExpenses()
)
.subscribe()
RLS는 Realtime 구독에도 그대로 적용되므로, is_trip_member()를 통과 못 하는 사람에게는 애초에 변경 이벤트가 전달되지 않습니다 — 별도의 "구독 권한" 설정이 필요 없는 것이 Supabase Realtime과 RLS를 같이 쓰는 장점입니다.
localStorage → Supabase 마이그레이션 + 체크리스트
기존 사용자가 로그인하는 순간, dutch.trips.v4에 쌓인 로컬 데이터를 서버로 올려줍니다.
async function migrateLocalTripsToServer(userId: string) {
const raw = localStorage.getItem('dutch.trips.v4')
if (!raw) return
const trips: Trip[] = JSON.parse(raw)
for (const trip of trips) {
const { data: newTrip } = await supabase.from('trips').insert({
name: trip.name, start_date: trip.start, end_date: trip.end,
currency: trip.currency, status: trip.status, treasurer_id: userId,
}).select().single()
// members → trip_members, contributions, expenses 순으로 동일하게 insert
// (FK 순서: trip → trip_members → contributions/expenses)
}
localStorage.setItem('dutch.migrated', 'true') // 재실행 방지
}
expenses.payer_member_id와 expenses.shares가 trip_members.id를 참조하므로, 멤버를 먼저 insert해서 로컬 memberId → 서버 trip_members.id 매핑 테이블을 만든 뒤 지출/공금을 넣어야 FK가 깨지지 않습니다.최종 체크리스트
- trips / trip_members / contributions / expenses 4개 테이블 모두 RLS enable
- is_trip_member() 헬퍼가 security definer로 선언돼 무한 재귀 없는지 확인
- expenses의 payer_shape_check 제약이 fund/member/mixed 세 경우를 모두 막아주는지 테스트
- 소프트 삭제 함수(soft_delete_expense 등)만 열려 있고 실제 delete 정책은 없는지 확인
- join_trip_by_code, handle_new_user 등 security definer 함수의 search_path가 빈 문자열인지 확인
- service_role 키가 프론트(Capacitor 앱 번들)에 절대 포함되지 않았는지 최종 점검