기타 / 학생회-프로젝트 / 06 Supabase / 0강

06 · Supabase: 테이블·조회·저장·RPC

이번 강의에서 배울 것

  • terms·departments·positions·memberships 관계를 키로 설명한다.
  • 생성된 Database 타입과 필요한 열만 선택하는 조회를 읽는다.
  • 직접 쓰기와 준비된 RPC를 사용하는 쓰기를 구분한다.
  • migration·seed·local/dev/prod·pgTAP의 역할을 설명한다.

1. 이 강의가 필요한 순간

“왜 조회 결과가 비어 있지?”, “어느 table에 insert해야 하지?”, “RPC가 왜 필요한가?”, “migration과 seed 중 무엇을 바꿔야 하지?”를 판단할 때 봅니다. 웹앱 지도 다음에 읽고, 누가 볼 수 있는지가 핵심이면 인증·권한으로 이어갑니다.

실제 근거는 org_structure migration, membership/auth migration, activation migration, database.types.ts입니다.

2. 테이블·행·열·키를 실제 구조로 읽는다

테이블은 같은 모양의 행 집합이고, 열은 각 행의 속성입니다. 기본키는 한 행을 고유하게 찾고, 외래키는 다른 테이블의 행을 가리킵니다.

terms (기수)
 ├─ departments (부서)  ─┐
 ├─ positions (표시 직책) ├─ memberships (사용자·기수 소속)
 └─ approved_council_accounts ── 첫 로그인 ──┘
 
auth.users ── 1:1 ── profiles

departments.term_id와 positions.term_id는 어느 기수에 속하는지를 나타냅니다. memberships는 사용자·기수·Role과 선택적인 부서·직책을 연결합니다. position 이름은 화면 표시용이고 보안 권한에는 쓰지 않습니다. 실제 권한 Role은 MEMBER, DIRECTOR, EXECUTIVE입니다. 일반 학생은 STUDENT Role 행으로 저장하지 않고 active Membership이 없는 로그인 사용자로 표현합니다.

제약막는 잘못된 상태예
기본키같은 행 ID 중복terms.id
외래키존재하지 않는 기수 참조departments.term_id → terms.id
NOT NULL필수 값 누락기수 이름
UNIQUE같은 기수에서 이름 중복term_id + position.name
부분 unique indexACTIVE 기수 여러 개ACTIVE는 항상 최대 1개

3. 화면 값과 생성 타입 연결

npm run db:types는 local DB 구조를 읽어 src/lib/supabase/database.types.ts를 생성합니다. 이 파일은 직접 고치지 않습니다. DB가 바뀌었는데 타입만 손으로 맞추면 실제 DB와 코드가 서로 다른 계약을 갖게 됩니다.

// 실제 생성 타입에서 꺼내 쓰는 모양
type MembershipRow = Database["public"]["Tables"]["memberships"]["Row"];
type MembershipInsert = Database["public"]["Tables"]["memberships"]["Insert"];
type MemberRole = Database["public"]["Enums"]["member_role"];
type ActivateResult = Database["public"]["Functions"]["activate_membership"]["Returns"];

Row는 조회 결과 한 행, Insert는 insert 가능한 입력 모양, Update는 변경 가능한 입력 모양입니다. 타입은 개발 중 실수를 찾지만 RLS를 대신하지 않습니다. 브라우저에서 값을 조작하면 TypeScript 검사는 이미 사라진 뒤이므로 서버와 DB 검증이 필요합니다.

4. 실제 /me 조회 읽기

플랫폼의 src/app/me/page.tsx는 Server Component에서 서버용 client를 만들고 profile과 membership을 병렬 조회합니다.

const [profileResult, membershipResult] = await Promise.all([
  supabase
    .from("profiles")
    .select("email, display_name")
    .eq("id", userId)
    .single(),
  supabase
    .from("memberships")
    .select(
      "id, role, term:terms(name), department:departments(name), position:positions(name)",
    )
    .eq("user_id", userId)
    .eq("is_active", true),
]);

select는 화면에 필요한 열만 요청합니다. term:terms(name)은 외래키 관계의 이름을 term 속성으로 받습니다. eq는 조건, single()은 반드시 한 행이어야 한다는 기대입니다.

결과의미화면 처리
error === null, 배열에 행 있음조회 성공목록/소속 표시
error === null, []조회 성공, 조건에 맞는 행 없음일반 학생·빈 상태 안내
error 존재쿼리·연결·RLS 등 실패Alert. 빈 목록으로 바꾸지 않음
single()인데 0개 또는 여러 개데이터 기대 불일치error를 조사

select("*")는 편하지만 필요하지 않은 개인정보와 변경 영향을 넓힙니다. 화면이 요구하는 열을 먼저 적고 그 열만 선택합니다.

5. 저장은 왜 RPC를 통하는가

팀원 화면의 쓰기 규칙은 Server Action → requireUser/requireMember → supabase.rpc() → refresh()입니다. browser에서 table에 직접 insert/update/delete하지 않습니다. 여러 table 변경, 상태 전이, activity log가 하나의 업무라면 DB 함수 안의 한 transaction으로 처리해야 중간 상태가 남지 않습니다.

현재 실제 RPC인 activate_membership()은 로그인 사용자의 검증된 이메일을 승인 명단과 대조해 Membership을 만듭니다. 승인되지 않은 학교 계정은 오류가 아니라 빈 결과로 일반 학생 상태를 유지하고, 반복 호출해도 active Membership이 중복되지 않도록 설계했습니다.

// 실제 callback의 핵심
const { error: activateError } = await supabase.rpc("activate_membership");
if (activateError) {
  console.error("activate_membership failed", { code: activateError.code });
}

RPC 안에서는 auth.uid()가 없으면 42501, 학교 Google 계정이 아니어도 42501을 냅니다. 함수 실행 권한은 authenticated에만 주고 public, anon에서는 회수합니다. 화면은 DB의 기술 메시지를 그대로 보여 주지 않고 사용자가 할 수 있는 다음 행동으로 번역합니다.

6. Migration·seed·환경의 차이

구분내용안전 규칙
Migrationtable·constraint·RLS·function 변경 이력리드 기반 작업, 순서대로 적용
seed.sqllocal 개발용 가짜 데이터실제 학생 정보 금지
local개인 PC의 Docker DBdb reset --local 허용
devPR Preview가 연결되는 공유 DBreset 금지, migration 적용 여부 확인
prod운영 서비스 DBreset 금지, 배포와 migration을 따로 확인

Vercel 배포가 성공해도 Supabase migration이 자동으로 적용됐다고 단정하지 않습니다. Preview 실패가 코드인지 dev DB의 migration 누락인지 분리합니다. npx supabase db reset --local은 local을 지우고 migration과 seed를 다시 적용하며, 공유 dev/prod에서는 실행하면 안 됩니다.

7. pgTAP에서 성공과 거부를 읽는다

실제 auth_membership_test.sql은 모든 가짜 데이터를 transaction 안에 만들고 끝에 rollback합니다.

begin;
select plan(18);
 
-- 허용: 승인된 가짜 계정은 MEMBER로 활성화된다.
select results_eq(
  $$ select term_id, role from public.activate_membership() $$,
  $$ values ('00000000-0000-0000-0000-000000000001'::uuid,
             'MEMBER'::public.member_role) $$,
  '승인된 계정은 첫 로그인 때 MEMBER로 활성화된다'
);
 
-- 거부: 사용자는 자기 Role을 올릴 수 없다.
select throws_ok(
  $$ update public.memberships set role = 'EXECUTIVE'
     where user_id = 'aaaaaaaa-0000-0000-0000-00000000000a' $$,
  '42501',
  null,
  '사용자는 자기 Membership의 role을 EXECUTIVE로 바꿀 수 없다'
);
 
select * from finish();
rollback;

results_eq는 성공 결과의 값까지, throws_ok는 실패해야 하는 요청의 SQLSTATE까지 확인합니다. 42501은 권한 부족, 23505는 unique 위반입니다. 메시지만 비교하면 번역이나 문구 변경에 취약하므로 오류 code와 보안 결과를 함께 봅니다.

팀원의 초기 DB Issue는 허용된 새 테스트 파일만 추가합니다. Migration·RLS·seed·기존 테스트를 고쳐 실패를 없애는 것은 범위 밖입니다.

8. 흔한 실수와 해결

잘못된 접근문제올바른 접근
browser에서 직접 쓰기권한·transaction 경로를 우회준비된 Action과 RPC
select("*") 남용과다 노출과 결합 증가필요한 열만 선택
error를 []로 바꿈장애를 빈 상태로 위장성공 0건과 실패 분리
타입 파일 직접 수정DB와 TypeScript 계약 불일치migration 뒤 npm run db:types
공유 DB reset팀 데이터 파괴local에서만 --local
허용 테스트만 작성보안 회귀를 놓침허용·거부 사례를 짝으로 작성

9. AI 요청과 PR 설명

계획: “Issue의 화면 값이 어떤 table·열·관계에서 오는지 migration과 생성 타입에서 찾아라. 허용 파일 밖의 migration·RLS·seed·database.types는 수정하지 마라. 성공·0건·오류·권한 거부를 구분하고 실제 RPC 시그니처를 인용해라.”

구현: “Server Component는 필요한 열만 조회하고, 쓰기는 실제 준비된 RPC만 호출해라. browser 직접 쓰기, service_role, 실제 개인정보, 공유 DB reset은 금지한다. 없는 table·RPC는 만들지 말고 리드 질문으로 남겨라.”

리뷰: “타입과 DB 구조 불일치, 과도한 select, error 누락, local/dev/prod 혼동, 허용·거부 DB 테스트 누락을 검사해라.”

PR에는 화면 → select/RPC → RLS/constraint → 결과 UI의 연결, 사용한 가짜 데이터, npx supabase test db 결과 또는 미실행 이유를 적습니다.

공식 참고: Supabase select, Database functions, RLS, Local development.

오늘도 한 걸음 앞으로.

내용을 이해했다면 학습 완료를 체크해 주세요.

학습 기록은 현재 브라우저에 저장됩니다.
HJS / STUDY한지석