프론트 개발자가 Supabase와 Postgres를 맡으면서 배운 것 — 멀티테넌시 전환과 RLS 무음 실패

S_Soo100·2026년 4월 21일

기타

목록 보기
3/3
post-thumbnail

"프론트 잘하고 AI 잘 쓰는 개발자인데, 백엔드 약하면 결국 한계 아니에요?"라는 질문을 자주 받습니다.
최근 SAAS 협업툴의 Postgres 레이어를 맡으면서, 답이 "전문가 되기"가 아니라는 걸 알았습니다.
이 글은 멀티테넌시 전환 한 건과, 초대 role 버그에서 마주친 RLS 무음 실패 한 건을 통해, AI 하네스를 낀 프론트가 어떻게 DB 레벨 판단을 뚫는지 기록한 것입니다.


들어가며, 실전 팀 단위 SAAS개발(토이 프로젝트)

저는 Flutter/React 로 먹고살던 프론트엔드 개발자이고, 지금은 스스로를 AI-native 개발자 혹은 Harness Engineer라고 부릅니다. 최근 작성한 AI관련 블로그 포스팅이 주로 에이전트 시스템 설계에 치중되어 있었다면, 이 글은 같은 논지를 한 개인의 역량 확장에 적용한 버전입니다.

AI의 발달로 인해 러닝 커브는 극단적으로 짧아지고, 개발자도 본인이 잘하는 것만 하거나 개발만 하는 게 아니라 프로덕트를 제대로 이해하고 매니징할 수 있어야 하는 시대가 되었습니다.
그리고 이런 시대의 프론트엔드 출신 개발자는 무조건 이 질문과 조우하게 됩니다.

"AI 잘 쓰는 프론트라도, 백엔드 모르면 결국 반쪽 아니에요?"

결국에는 전 과정을 이해하고 있어야 합니다. 그리고 공부를 하지 않을 변명거리가 너무 궁색해져 버린 현실이기도 합니다. ai는 최고의 선생님이자 보조 개발자니까요.
공부도 마찬가지로 실전 프로젝트가 가장 빨리 늘겠죠? 저는 그래서 팀이 쓸 수 있는 가벼운 관리툴을 저렴하고 손쉬운 Supabase로 만들어보고 있었습니다.
이전에도 백엔드 공부를 독학으로 진행하고 있었지만, 토이 프로젝트이니 만큼 당장 무료플랜이 있느는 Supabase로 차근차근 진행하고 있는데, Supabase에 앱을 올리다 RLS가 막거나, 헬퍼 함수가 순환 참조로 터지거나, 트리거 하나가 흐름을 뒤집는 상황이 생깁니다.
프론트만 봐선 원인이 안 나오는 경우도 많고, 그러므로 우리는 나는 ㅁㅁ하는 사람이야 라고 스스로 선 긋기 보다는 넓게 공부하는게 맞는 것 같습니다.그렇다고 "풀스택 백엔드 전문가"가 되려고 공부하는 건 아닙니다.

전문가가 되기가 아니라, 충분히 판단할 수 있는 개발자가 되기.

제가 목표하는 건 이쪽입니다. AI는 설계 옵션을 나열해줍니다. 이 스키마로 갈지 저 RLS 조합으로 갈지, 어떤 헬퍼를 붙일지. 다만 어느 옵션을 고를지는 결국 사람이 판단해야 합니다.
트레이드오프를 읽고, 프로젝트 요구사항과 맞춰보고, 되돌리기 비용까지 가늠하는 일을 할 수 있어야 하는 시대인거 같습니다.. AI는 재료를 수십 배 빠르게 차려주지만, 선택의 책임을 대신 지지는 않습니다.

영역 확장 = AI 하네스로 판단 반경을 넓히는 것. 이 글은 그 프레임을 SAAS 협업툴에서 실제로 부딪힌 두 사건으로 풀어낸 기록입니다. 하나는 스키마 레벨의 큰 전환, 다른 하나는 "버그 하나가 나를 이틀 잡아먹은" 작은 사건. 둘 다 영역 확장의 다른 얼굴입니다.

토이라고 해서 문제 크기까지 토이는 아니었습니다. "팀 단위로 격리해서 여러 곳이 같이 쓰게 해달라" 같은 요구가 들어오면, 해결해야 할 DB 레벨 문제는 현장과 똑같아지거든요.


에피소드 1 : 단일 팀 전제로 만들었는데 "야 이거 옆 팀도 쓰고싶대"

첫 번째 덩어리는 "단일 팀 전제로 만들어진 SAAS 협업툴을 여러 회사가 각자 격리된 상태로 쓸 수 있게 바꾸는 것"이었습니다. 프론트 언어로 번역하면 "전역 싱글톤 store를 회사별 scope로 나누는 것"에 가깝습니다. 다만 대상이 Postgres입니다.

전환 전에는 모든 RLS가 auth.uid()만 믿고 "본인 것만"이라는 축으로 작성돼 있었고, company_id 컨셉 자체가 없었습니다. 회사 개념을 끼워 넣으려면 스키마·RLS·RPC를 전부 건드려야 합니다. 되돌리기 비용이 큰 작업이었습니다.

한 번에 안 하고 3파일로 쪼갰다

처음엔 SQL 파일 하나를 크게 쓸까 싶었고 AI도 단일 파일 쪽을 선호했지만, 회귀 감지 축에서 보면 나쁜 설계였습니다. 어디서 터졌는지 원인을 끊어 보기 어렵거든요. 3파일로 나눴습니다.

Part파일하는 일
1multi_tenancy_1_schema.sqlcompanies 테이블 신설 + 기존 업무 테이블에 company_id 추가
2multi_tenancy_2_rls.sql기존 RLS 정책 전체를 DROP 후 company_id 필터 버전으로 재생성
3multi_tenancy_3_rpc.sql모든 RPC 함수에 "회사 검증" 보일러플레이트 삽입

목적은 단순합니다. Supabase SQL Editor에서 하나씩 실행하며 즉시 회귀를 감지하기 위해서. 스키마만 올리고 앱을 돌려보면 어떤 쿼리가 깨지는지 눈에 보이고, RLS를 올리면 또 다른 증상이 나옵니다. 원인을 실행 단위로 분리하는 것이 3파일 분할의 실체입니다. 프론트의 "작은 PR로 쪼개기"와 같은 감각. 다만 대상이 스키마라 실수의 비용이 훨씬 큽니다.

헬퍼 함수에 SECURITY DEFINER를 붙인 이유

회사 범위로 필터링하려면 "지금 로그인한 사용자의 회사 ID"를 꺼내는 함수가 필요합니다.

CREATE OR REPLACE FUNCTION get_my_company_id()
RETURNS UUID
LANGUAGE sql
SECURITY DEFINER     -- 함수 소유자 권한으로 실행
STABLE               -- 같은 쿼리 안에서 결과 캐시
AS $$
  SELECT company_id FROM profiles WHERE id = auth.uid()
$$;

SECURITY INVOKER(기본값)로 두면 RLS가 걸린 profilesprofiles RLS 안에서 읽어 순환 참조가 납니다. SECURITY DEFINER가 이 순환을 끊는 장치입니다. STABLE은 쿼리 내 캐시용인데, 하나의 FOR ALL 정책이 네 종류 커맨드(SELECT/INSERT/UPDATE/DELETE)에 전부 걸리고 각 정책에서 이 함수가 여러 번 호출되기 때문에, 이 한 줄이 I/O에 제법 영향을 줍니다.

이 지점에서 AI는 함수로 뺄지, 서브쿼리 직삽할지, JWT claim으로 뺄지를 나란히 제시했습니다. 고르는 건 제 몫이었고, 팀 전환 요구 때문에 JWT는 탈락, 직삽은 가독성 문제로 탈락이었습니다.

RLS 정책 텍스트가 곧 권한 명세가 된다

기존 정책을 전부 갈아엎으면서 골격을 통일했습니다. 소속 체크는 USING (company_id = get_my_company_id())로 기본 필터를 박고, 역할 체크는 EXISTS 서브쿼리로 별도 확인. "게스트는 민감 컬럼 못 봄" 같은 규칙을 정책 텍스트에 그대로 박았습니다. 권한을 별도 문서로 만드는 대신, RLS 정책 텍스트 자체가 권한 명세가 되는 구조입니다. 문서와 실코드의 드리프트가 생기지 않습니다. 프론트에서 권한을 Route Guard에 박아온 선호를 RLS에도 그대로 옮긴 셈입니다.

role을 의도적으로 두 군데에 저장했다

"한명의 유저가 여러 팀 개념에 속할 수 있다"는 요구가 추가되면서 user_companies junction 테이블이 생겼습니다. roleuser_companies 하나에만 둘지, 아니면 profiles.role과 이중으로 둘지 선택지가 있었고, 저는 이중 저장 + 단일 진입점을 택했습니다. 프론트 가드가 여러 군데서 profiles.role을 읽는 경로를 바꾸는 건 영향 범위가 넓어서, DB 쪽에서 한 스텝 감당하는 편이 합리적이었습니다. 대신 update_member_role, switch_company 같은 단일 진입점 RPC를 통해서만 역할이 바뀌게 걸었습니다. "프로젝트 구조와 어디서 role을 읽고 있는가"를 아는 사람만 할 수 있는 결정입니다. AI가 대신 짜주지 않습니다.

CASCADE 대신 SET NULL을 쓴 이유

profiles.company_id FK에 ON DELETE CASCADE가 아니라 SET NULL을 썼습니다. CASCADE였다면 "회사 삭제 → 소속 프로필 전부 삭제 → 실질적 유저 삭제"라는 재앙이 납니다. 반면 SET NULL이면 "회사가 없는 유저"라는 어색한 상태가 되지만, 복구 가능합니다. 둘 중 한 방향으로 터질 수밖에 없다면 복구 가능한 쪽으로 터지게 만든다는, fail-closed 원칙 그대로입니다.

초대 테이블엔 아직도 찜찜한 정책 하나가 있다

초대받은 사람은 아직 회사에 소속되지 않은 상태로 토큰을 수락합니다. 수락 시점에 company_id가 없습니다. 그래서 invitations RLS는 두 정책이 병렬로 걸립니다. admin이 자기 회사 초대를 관리하는 쪽은 USING (company_id = get_my_company_id()), 초대받은 사람이 자기 초대를 확인하는 쪽은 USING (true). 후자는 토큰 UUID의 무작위성에 보안을 맡깁니다. "토큰을 알면 조회 가능"으로 풀고 토큰의 추측 불가능성에 기대는 식. 이 프로젝트 RLS에서 가장 불편한 트레이드오프이고, 에피소드 2의 복선이 되는 지점이기도 합니다.

다음날 체크리스트 몇 개가 더 붙었다

정직하게 쓰자면, 이 작업을 끝낸 다음날 몇 가지 항목을 더 넣었습니다. 묻지 않으면 AI도 안 꺼내주는 종류였습니다. "모르는 걸 묻지 않으면 AI도 안 꺼낸다"는 감각이 또 박혔습니다. AI가 완벽한 초안을 주지 않기 때문에 판단자와 체크리스트가 더 중요해진다는 게 결론입니다.


에피소드 2 : 초대 role 버그, 그리고 RLS 무음 실패

에피소드 1이 큰 덩어리 하나였다면, 에피소드 2는 작은 버그 하나가 이틀을 잡아먹은 이야기입니다.

로그인은 되는데 전부 guest로 들어온다

프론트에서는 분명 member / admin 같은 역할을 선택해 초대 메일을 보냈는데, 받는 쪽 계정은 전부 guest로 저장됐습니다. 프론트 payload를 찍어봐도 role은 제대로 실려 있었고, invitations 테이블에 저장된 값도 맞았습니다. 그런데 유저가 토큰을 수락한 뒤 profiles.role을 열어보면 guest.

범인은 트리거의 한 줄

범인은 auth.users에 걸린 트리거였습니다.

CREATE OR REPLACE FUNCTION handle_new_user() RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO profiles (id, email, role)
  VALUES (NEW.id, NEW.email, 'guest');  -- ← 무조건 guest, 하드코딩
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

NEW.raw_user_meta_data->>'role'을 읽지 않고 guest를 박아버립니다. 신규 가입자는 전원 guest로 태어납니다. 초대 role이 아무리 맞게 날아와도 트리거가 한 번 뭉개고 나면 끝.

트리거는 건드리지 않고 RPC로 덮었다

"원인을 알았으면 트리거를 고치면 되지 않나?" 쪽이 자연스러워 보입니다. 그런데 트리거는 안 건드리기로 했습니다. 대신 accept_invitation RPC가 수락 시점에 profiles.role을 덮어쓰게 했습니다.

CREATE FUNCTION accept_invitation(p_token TEXT, p_user_id UUID) ... AS $$
  SELECT * INTO v_inv FROM invitations
  WHERE token = p_token AND accepted_at IS NULL AND expires_at > NOW();

  UPDATE profiles
     SET role = v_inv.role, company_id = v_inv.company_id
   WHERE id = p_user_id;
$$;

왜 트리거를 안 고쳤느냐. 여기서부터는 추정입니다. (a) 트리거는 auth.users에 걸려 있어 건드리면 회원가입 흐름 전체에 영향을 줍니다. 되돌리기 비용이 큽니다. (b) 초대 없이 그냥 signUp한 유저는 실제로 guest가 맞습니다. 즉 하드코딩이 기본값 역할로는 정상 동작합니다. 고쳐야 할 것이 "기본값"이 아니라 "초대 수락 시점의 덮어쓰기 경로 부재"라면, 건드릴 곳은 트리거가 아니라 RPC 쪽이 됩니다.

판단을 한 줄로 적으면 이렇습니다. "고쳐야 할 것을 고치지 않고 우회한다"가 맞을 때가 있다. 영향 범위가 넓고 기본값으로는 정상인 경로는 건드리지 않고, 실제로 틀린 경로에 패치를 얹는 쪽이 되돌리기 비용이 낮습니다.

멀티팀 지원을 넣고 싶어지면서 이 RPC에 user_companies INSERT와 "신규 유저만 profiles.role을 덮어쓴다"는 가드도 더 붙었습니다. 기본 뼈대는 같습니다.

진짜 오래 걸린 이유는 RLS 무음 실패였다

사실 트리거와 RPC 구조를 파악하는 건 한나절이면 됐습니다. 제가 당시 이 버그에 이틀을 쓴 이유는 따로 있습니다.

처음 accept_invitation을 고쳤을 때, RPC는 성공했다고 응답했습니다. 에러 없음. 프론트는 조용히 다음 화면으로 넘어갔고, 재조회해 보니 profiles.role은 여전히 guest였습니다.

원인은 UPDATE가 RLS에 막혀서 0행이 갱신된 것이었습니다. Postgres의 UPDATE는 조건에 맞는 행이 0개여도 에러가 아닙니다. "0 rows updated"는 정상 결과이기 때문입니다. RLS가 행을 필터링해서 0행이 된 경우도 똑같이 "정상 실행"으로 처리됩니다. RPC 관점에서는 "UPDATE 문이 정상 실행됐다"가 사실이고, 프론트 관점에서는 "RPC가 성공 응답을 줬다"가 사실입니다. 양쪽 다 거짓말을 한 게 아닌데, 실제 DB 상태는 안 바뀌었습니다.

프론트 디버거로 payload를 아무리 찍어봐도 원인이 안 나옵니다. 네트워크 응답도 200입니다. 진짜 보스는 프론트도 RPC 내부 로직도 아니고, "RLS에 막혀서 조용히 실패하는 mutation"이었습니다.

사건을 Don't 노트에 박았다

결국 사건이 끝난 뒤 내 Don't 노트에 룰을 하나 승격시켰습니다. "Supabase mutation 후에는 .select() + row count 검증 필수". mutation 결과를 .select()로 받아와 실제로 몇 행이 바뀌었는지 확인하지 않으면, 다음에도 같은 함정에 다시 빠집니다.

RLS가 에러 없이 실패하는 세계에서, .select() 없는 mutation은 어둠 속 발사.

에피소드 2를 복기하면 배움이 세 가지로 압축됩니다. 첫째, 증상의 출처와 원인의 출처가 다를 수 있다. 프론트 버그처럼 보였지만 범인은 DB 트리거였고, 그 다음 범인은 RLS였습니다. 둘째, 고칠 곳을 고치지 않고 우회하는 판단이 맞을 때가 있다. 트리거는 영향 범위가 넓고 기본값으로는 정상이니, 건드릴 곳은 RPC였습니다. 셋째, 침묵하는 실패가 가장 비싸다. RLS 무음 실패처럼 에러를 안 던지는 실패는 체크 코드로 강제 소음화해야만 발견됩니다. 그리고 한 번 당한 건 개인 지식으로 끝내지 않고 내 Don't 노트에 박아두는 게 재발 방지의 실체입니다.


모르는 영역을 조금씩 찍먹해 봅시다

두 에피소드가 보여준 건 성격이 다른 두 종류의 판단입니다. 에피소드 1은 큰 덩어리를 어떻게 쪼갤지의 판단이었고, 에피소드 2는 작은 버그를 어디서 잡을지, 그리고 왜 안 잡히는지의 판단이었습니다. 스키마 설계와 디버깅, 결이 다른 영역이지만 공통점은 하나입니다. AI는 옵션을 나열해줬고, 선택의 책임은 저에게 있었다는 것.

이 두 판단을 하며 느낀 건 분명합니다. 백엔드 전문가가 된 게 아니라, 백엔드를 두려워하지 않게 된 것입니다. 스키마를 쪼개고, 트리거와 RPC 사이에서 어느 쪽을 건드려야 되돌리기 비용이 낮은지 가늠하고, RLS가 에러 없이 실패할 수 있다는 걸 아는 것. 이 정도면 판단 가능한 프론트로서 프로덕트 전 과정에 참여할 수 있습니다. 그게 제가 말하는 풀스택의 교두보입니다.

AI 하네스는 이 과정에서 옵션 제공자 역할을 합니다. 선택과 책임은 엔지니어의 몫입니다.

profile
Ai agent 설계를 잘 하고싶은 개발자

0개의 댓글