ezSplit (dutch) · local-dev → server-dev

ezSplit을 Supabase로 옮기며 배우는 백엔드

projects/dutchlocal-dev 브랜치는 localStorage에만 데이터를 저장합니다. 이걸 여러 기기·여러 인원이 실시간으로 같은 여정을 보는 서버 버전으로 옮기는 걸 기준으로, 앞서 만든 소셜 피드 예제 대신 "여정을 공유하는 사람들" 구조에 맞춰 스키마·RLS·함수를 다시 설계합니다.

5CORE TABLES
1NEW CONCEPT: 멤버십 RLS
2NEW FEATURE: 초대코드 · Realtime
1마이그레이션 스크립트
01

로컬 모델 → 서버 스키마 매핑

src/types.tsTrip / Member / Person / Contribution / Expense를 그대로 테이블로 옮기되, "배열로 중첩"된 구조를 "관계형 테이블"로 풀어줍니다.

로컬 (types.ts)서버 테이블바뀌는 이유
Person (기기 안의 사람 목록)profiles기기가 아니라 계정에 귀속되도록. auth.users와 1:1.
Triptrips최상위 테이블. members / contributions / expenses 배열은 분리.
Trip.members[]trip_members여정↔사람의 다대다 조인 테이블. 계정 없는 게스트 멤버도 담아야 함.
Trip.contributions[]contributionstrip_id FK로 분리, RLS는 소속 여정 기준.
Trip.expenses[]expenses가장 필드가 많은 테이블. payer 유니언, shares 맵은 jsonb로.
핵심 전환점. 로컬 버전은 "내 브라우저 = 내 데이터"라 소유자 개념이 없었습니다. 서버 버전은 한 여정을 여러 계정이 같이 보고 고치는 구조라, 지난 스터디의 "글쓴이 본인만" RLS 패턴이 그대로는 안 맞고 "이 여정의 멤버인가"를 묻는 RLS로 바뀝니다 → 08에서 이어짐.
02

profiles 테이블 — Person을 계정에 연결

types.tsPerson { 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.

03

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.id FK.
  • invite_code는 로컬 버전엔 없던 컬럼 — 서버 버전에서 "여정 참여"가 새로 필요해져 추가.
04

trip_members 테이블 — 계정 없는 멤버도 허용

여기가 이 앱 스키마에서 가장 중요한 설계 결정입니다.

로컬 코드 주석에 이렇게 적혀 있습니다: "전역 Person.id — 실제 로그인 계정과 연결된 '진성' 멤버만 갖는다. 임의로 추가한 더미 멤버는 없다." 즉 여행 멤버 중에는 앱을 안 쓰는 사람(현금으로만 정산할 친구)도 있어야 합니다. 그래서 person_idnullable로 둡니다.

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;
주의. Postgres의 unique(trip_id, person_id)person_idnull인 행끼리는 서로 다른 값 취급이라 게스트 멤버는 몇 명이든 중복 제약에 안 걸립니다 — 의도한 동작이라 별도 처리가 필요 없습니다.
05

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에 기록된 시점"을 분리하는 흔한 관례입니다.

06

expenses 테이블 — payer 유니언과 shares 맵 저장

가장 필드가 많고, 로컬 타입의 유니언(PayerRef)을 SQL로 어떻게 표현할지가 핵심.

타입스크립트 원본: PayerRef 유니언
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;
왜 shares/participants까지 정규화 안 했나? expense_participants 같은 별도 테이블로 쪼갤 수도 있지만, 이 값들은 정산 계산(settle.ts)에서만 통째로 읽고 쓰는 값이라 조인·집계 쿼리 대상이 아닙니다. 로컬 코드의 Record<string, number> 모양을 그대로 jsonb로 옮기면 프론트 코드(fund.ts, shares.ts) 변경을 최소화할 수 있습니다. payer_shape_check 같은 constraint로 "정규화 안 한 대신 DB가 모양을 검증"하게 보완하는 절충입니다.
07

소프트 삭제(deleted_at) 패턴을 DB에서 구현하기

로컬 코드의 activeExpenses() / activeContributions() 필터를 서버에서는 어떻게 재현할까.

타입스크립트 원본 (types.ts)
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;
$$;
실제 DELETE 정책은 만들지 않습니다. posts 예제와 다르게, 이 앱은 정산 이력 추적을 위해 "완전 삭제"를 허용하지 않습니다 — 대신 update 정책만 열고 soft_delete_* 함수를 통해서만 지우게 합니다.
08

RLS 사고방식 전환: "소유자 기반" vs "멤버십 기반"

달라진 부분

지난 소셜 피드 스터디에서는 auth.uid() = author_id면 충분했습니다. 이 앱은 다릅니다.

소셜 피드 (posts)ezSplit (trips)
행 하나의 주인글쓴이 한 명여정에 속한 여러 명
읽기 권한전체 공개여정 멤버만 (비공개 지출 내역)
쓰기 권한 조건auth.uid() = author_idauth.uid()trip_members에 존재하는지 서브쿼리로 확인
정책이 참조하는 테이블자기 자신 하나다른 테이블(trip_members)을 조인해서 판단
이번에 새로 배우는 패턴. "이 행에 접근해도 되는 사람인가"를 판단하려면 다른 테이블을 참조하는 정책이 필요합니다. 이때 정책끼리 서로를 참조하면서 무한 재귀에 빠지지 않도록, security definer 헬퍼 함수로 멤버십 체크를 감싸는 게 표준 패턴입니다 → 09.
09

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()
  );
$$;
왜 security definer인가? 이 함수 안의 selecttrip_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로 감싸는 걸 권장).

10

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 컬럼으로 "누가 마지막으로 고쳤는지"는 남습니다 — 앱 특성에 맞춘 의도적 설계입니다.
11

초대 코드로 여정 참여하기

로컬엔 없던 기능

로컬 버전 README에 "로그인·여정 공유·서버 동기화를 전부 걷어냈다"고 적혀 있던 바로 그 기능을 이제 만듭니다.

친구→ 코드 입력 →trip_members에 자동 추가계정만 있으면 됨
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 정책만으로는 자기 자신을 추가할 권한이 없습니다. 이 함수가 "초대 코드를 아는 사람"이라는 별도 인증 수단으로 그 권한을 대신 부여합니다.

invite_code 생성. trips insert 시 트리거로 substr(md5(random()::text), 1, 6) 같은 짧은 코드를 자동 채워주면 카카오톡 공유 링크(ezsplit.app/join/ABC123)로 바로 연결하기 좋습니다.
12

Realtime으로 여러 명이 같은 여정을 동시에 보기

로컬엔 없던 기능

서버로 옮기는 가장 큰 이유 — 여행 중 누가 지출을 추가하면 다른 사람 화면에도 바로 뜨게.

alter publication supabase_realtime add table public.expenses;
alter publication supabase_realtime add table public.contributions;
클라이언트 (App.tsx의 전역 여정 상태 옆에 추가)
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를 같이 쓰는 장점입니다.

13

localStorage → Supabase 마이그레이션 + 체크리스트

기존 사용자가 로그인하는 순간, dutch.trips.v4에 쌓인 로컬 데이터를 서버로 올려줍니다.

클라이언트 1회성 마이그레이션 스케치
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_idexpenses.sharestrip_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 앱 번들)에 절대 포함되지 않았는지 최종 점검