Supabase RLS 실전 삽질기 — 33개 시나리오 30%→100% 통과까지
BookSalon에서 Supabase RLS를 적용하며 겪은 실전 삽질기. auth.uid() vs users.id 불일치, .single() 오용, 소셜 피드 정책 충돌까지 3차 수정으로 100% 달성한 과정을 코드와 함께 공유합니다.
솔직히 말씀드리면, 저희는 RLS를 만만하게 봤습니다.
"DB 레벨 보안이잖아, 정책 몇 줄만 추가하면 끝나겠지." 그렇게 생각했던 게 화근이었습니다. 결국 33개 E2E 시나리오 중 10개만 통과하는 30.3% 통과율을 마주했고, 3차에 걸친 수정 끝에 100%를 달성했습니다. 그 과정을 있는 그대로 공유합니다.
RLS가 뭔데 이렇게 어려운가?
RLS(Row Level Security)는 PostgreSQL이 제공하는 행 단위 접근 제어 기능입니다. Supabase는 이를 기반으로 데이터베이스 레벨에서 "누가 어떤 행을 읽고 쓸 수 있는지"를 통제합니다.
Firebase Security Rules에도 비슷한 개념이 있지만, RLS는 SQL 정책으로 표현되기 때문에 관계형 데이터의 복잡한 조건을 훨씬 유연하게 다룰 수 있습니다.
BookSalon은 독서 커뮤니티 플랫폼입니다. Firebase로 시작했다가 Supabase로 마이그레이션했고, 처음부터 RLS를 제대로 적용하기로 결정했습니다. "보안은 나중에"라는 함정을 피하겠다는 의지였는데, 막상 시작해보니 "처음부터 제대로"가 쉬운 일이 아니었습니다.
1차 시도: 기본 RLS 정책 설계
처음 설계한 RLS 정책의 패턴은 심플했습니다. FOR ALL 하나로 대부분의 테이블을 커버하는 방식입니다.
-- 처음에 작성한 방식 (문제 있음)
ALTER TABLE bookmarks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can manage own bookmarks"
ON bookmarks FOR ALL
USING (user_id = get_current_user_id())
WITH CHECK (user_id = get_current_user_id());
get_current_user_id()는 auth.uid()를 통해 현재 사용자의 users.id를 가져오는 헬퍼 함수입니다.
CREATE OR REPLACE FUNCTION get_current_user_id()
RETURNS UUID AS $$
SELECT id FROM users WHERE auth_id = auth.uid();
$$ LANGUAGE sql SECURITY DEFINER;
이론적으로는 완벽해 보였습니다. SELECT, INSERT, UPDATE, DELETE 전부 처리하니까요. 그런데 E2E 테스트를 돌려보니 현실은 달랐습니다.
33개 시나리오 E2E 테스트 — 30% 통과 충격
5개 페르소나(minji, jiho, doyun, subin, hyunwoo)로 33개 시나리오를 돌렸을 때 결과가 나왔습니다.
10개 통과, 23개 실패. 통과율 30.3%.
어디서 뭐가 잘못된 건지 분석에 들어갔고, 3가지 복합 원인을 발견했습니다.
실패 사례 1: 신규 가입 즉시 에러
신규 사용자가 가입 직후 프로필을 설정하려 하면 403이 떴습니다. 원인은 이랬습니다.
get_current_user_id()는 users 테이블에서 현재 사용자를 찾아 id를 반환합니다. 그런데 신규 가입 직후, users 테이블에 레코드가 아직 없는 상태라면 이 함수는 NULL을 반환합니다. FOR ALL 정책의 WITH CHECK는 NULL = NULL이 false로 평가되기 때문에 INSERT가 차단됩니다.
즉, 계정을 만드는 순간 자기 자신의 프로필조차 만들 수 없는 상태가 됩니다.
실패 사례 2: auth.uid() vs users.id 불일치
Firebase에서 넘어온 코드에는 currentUser.uid가 곳곳에 있었습니다. Firebase auth의 UID입니다. Supabase로 넘어왔을 때 이 값은 auth.uid()와 동일하게 매핑되었습니다.
문제는 RLS 정책이 users.id(테이블의 PK, UUID)를 기준으로 동작하는데, 프론트엔드 코드 일부에서는 여전히 Firebase UID 패턴인 currentUser.uid를 쿼리에 넘기고 있었다는 점입니다.
// 문제 있는 코드 (before)
const { data } = await supabase
.from('bookmarks')
.select('*')
.eq('user_id', currentUser.uid); // auth_id를 넘기고 있음
// 올바른 코드 (after)
const { data } = await supabase
.from('bookmarks')
.select('*')
.eq('user_id', userProfile.id); // users 테이블의 PK를 넘김
auth.uid()와 users.id는 다릅니다. auth.uid()는 Supabase Auth 시스템의 식별자고, users.id는 우리가 만든 public.users 테이블의 PK입니다. 이 두 값이 같다고 가정하고 코드를 짜면 RLS 정책이 의도대로 동작하지 않습니다.
실패 사례 3: .single() 오용
팔로잉 여부를 확인하는 코드에서 이런 패턴이 있었습니다.
// 문제 있는 코드
const { data, error } = await supabase
.from('follows')
.select('id')
.eq('follower_id', currentUserId)
.eq('following_id', targetUserId)
.single(); // 결과가 0건이면 406 에러 반환
const isFollowing = !error && !!data;
.single()은 결과가 정확히 1개일 때만 정상 동작합니다. 0건이면 PGRST116 에러(406)를 반환합니다. 팔로우하지 않은 상태를 확인할 때 매번 에러가 발생하니, 에러 핸들링 없이 isFollowing을 판단하던 코드가 오동작했습니다.
// 올바른 코드
const { data } = await supabase
.from('follows')
.select('id')
.eq('follower_id', currentUserId)
.eq('following_id', targetUserId)
.maybeSingle(); // 결과 없으면 null 반환, 에러 없음
const isFollowing = !!data;
실패 사례 4: activities 테이블 — 소셜 피드와의 충돌
activities 테이블에는 이런 SELECT 정책이 있었습니다.
-- 문제 있는 정책
CREATE POLICY "Users can view own activities"
ON activities FOR SELECT
USING (user_id = get_current_user_id());
"자기 것만 볼 수 있다"는 정책인데, 팔로잉 피드 기능은 내가 팔로우하는 사람의 활동(activities)을 가져와야 합니다. 즉, 이 SELECT 정책이 소셜 피드의 핵심 기능을 원천 차단하고 있었습니다.
RLS 정책을 설계할 때 "이 테이블의 데이터를 누가 봐야 하는가?"를 먼저 정의하지 않으면 이런 충돌이 생깁니다.
3차 수정까지의 여정
1차 수정: FOR ALL → 분리 (통과율 51.6%)
가장 먼저 손댄 것은 FOR ALL 정책이었습니다. INSERT와 나머지를 분리했습니다.
-- 수정 전: FOR ALL 하나로 처리
CREATE POLICY "Users can manage own bookmarks"
ON bookmarks FOR ALL
USING (user_id = get_current_user_id())
WITH CHECK (user_id = get_current_user_id());
-- 수정 후: INSERT 분리
CREATE POLICY "Users can insert own bookmarks"
ON bookmarks FOR INSERT
WITH CHECK (
EXISTS (SELECT 1 FROM users WHERE users.id = user_id AND users.auth_id = auth.uid())
);
CREATE POLICY "Users can update own bookmarks"
ON bookmarks FOR UPDATE
USING (user_id = get_current_user_id())
WITH CHECK (user_id = get_current_user_id());
CREATE POLICY "Users can delete own bookmarks"
ON bookmarks FOR DELETE
USING (user_id = get_current_user_id());
INSERT 정책에서 get_current_user_id()를 피하고 auth.uid()로 직접 매핑하는 방식을 썼습니다. 이렇게 하면 users 테이블에 레코드가 없는 신규 사용자도 INSERT 가능합니다.
reading_logs 테이블은 FK 문제까지 있었습니다. user_id가 auth.users(id)를 참조하고 있었는데, 실제 서비스 코드에서는 public.users.id를 넘기고 있었습니다.
-- FK 참조 수정
ALTER TABLE reading_logs DROP CONSTRAINT IF EXISTS reading_logs_user_id_fkey;
ALTER TABLE reading_logs ADD CONSTRAINT reading_logs_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
이 수정으로 51.6%까지 올라갔지만 절반이 여전히 실패했습니다.
2차 수정: Board Advisor가 치명적 결함 2건 발견 (통과율 75%)
여기서 Board Advisor 크로스체크를 진행했습니다. CEO 팀이 미처 보지 못한 문제 2건이 발견됐습니다.
결함 1: activities의 SELECT 정책이 소셜 피드를 차단하고 있다는 것 (앞서 설명한 사례).
결함 2: PostItem.tsx에 이미 userProfile.id를 사용하는 패턴이 존재한다는 것. 일부 컴포넌트는 이미 올바른 ID를 사용하고 있었는데, 다른 컴포넌트는 여전히 currentUser.uid를 사용 중이었습니다. 일관성 없는 ID 사용이 버그의 근본 원인이었습니다.
이 두 가지를 수정했습니다.
-- activities SELECT 정책을 소셜 피드 허용으로 변경
DROP POLICY IF EXISTS "Users can view own activities" ON activities;
-- 팔로잉하는 사람의 활동도 조회 가능하도록
CREATE POLICY "Users can view followees activities"
ON activities FOR SELECT
USING (
user_id = get_current_user_id()
OR EXISTS (
SELECT 1 FROM follows
WHERE follower_id = get_current_user_id()
AND following_id = activities.user_id
)
);
프론트엔드에서는 전체 코드베이스를 검색해 currentUser.uid를 userProfile.id로 통일했습니다. 75%까지 상승했지만 아직 완성이 아니었습니다.
3차 수정: 세부 타이밍 문제 해결 (통과율 100%)
남은 실패 케이스는 두 가지였습니다.
댓글 즉시 반영 문제: 댓글을 작성하면 목록이 즉시 갱신되어야 하는데, PostItem.tsx를 수정했으나 실제 댓글이 렌더링되는 컴포넌트는 PostDetail.tsx였습니다. 수정한 파일과 실제 화면에 표시되는 파일이 달랐던 것입니다.
북마크 타이밍 가드: 북마크 추가/삭제 후 상태가 간헐적으로 어긋나는 현상이 있었습니다. 낙관적 업데이트(optimistic update) 타이밍 문제였고, 가드 로직을 추가했습니다.
이 두 가지를 수정하자 33/33 시나리오 전체 통과, 100%를 달성했습니다.
핵심 교훈 — 다른 개발자에게 전하고 싶은 것
교훈 1: auth_id와 users.id 결정은 처음에
Supabase를 처음 설계할 때 반드시 결정해야 할 것이 있습니다.
RLS 정책은 auth.uid()를 직접 쓸 건가, 아니면 별도 users 테이블의 PK를 쓸 건가?
Firebase에서 마이그레이션했다면 특히 주의가 필요합니다. Firebase UID(currentUser.uid)와 Supabase auth.uid()는 개념적으로 같지만, 이를 public.users.id(애플리케이션의 사용자 PK)와 혼용하면 광범위한 RLS 버그가 발생합니다.
저희는 users 테이블에 auth_id 컬럼을 두고, RLS에서는 auth.uid() = users.auth_id 조건을 사용하는 방식을 선택했습니다. 프론트엔드에서는 userProfile.id(users.id)를 쿼리에 사용합니다. 이 두 가지를 명확하게 구분하고 팀 내 컨벤션으로 정착시켜야 합니다.
교훈 2: .single()은 정말 1개만 올 때만
Supabase .single()은 결과가 0건이면 에러를 반환합니다. 존재 여부를 확인할 때는 반드시 .maybeSingle()을 사용하세요.
// "이미 좋아요를 눌렀나?" 확인하는 올바른 방법
const { data } = await supabase
.from('post_likes')
.select('id')
.eq('post_id', postId)
.eq('user_id', userId)
.maybeSingle(); // 없으면 null, 에러 없음
const isLiked = !!data;
단순한 규칙이지만, 이를 어기면 디버그하기 어려운 간헐적 에러가 발생합니다.
교훈 3: RLS 테스트는 E2E가 필수
RLS 정책은 유닛 테스트만으로는 검증이 어렵습니다. "A 사용자로 로그인해서 B 사용자의 데이터에 접근하면 차단되는가?" 같은 시나리오는 실제 인증 흐름이 필요합니다.
저희는 5개 페르소나로 33개 시나리오를 Playwright로 자동화했습니다. 이 테스트 없이 코드 리뷰만 했다면 30% 통과율 상태로 서비스가 나갔을 것입니다. 코드를 아무리 눈으로 봐도 실제 동작을 대체할 수 없습니다.
교훈 4: 소셜 기능 테이블의 SELECT 정책 설계
소셜 기능(팔로우, 피드, 알림)이 있는 테이블은 SELECT 정책 설계 전에 반드시 물어봐야 할 질문이 있습니다.
이 테이블의 데이터를 누가 봐야 하는가?
activities 테이블을 "자기 것만 조회"로 설계하면 팔로잉 피드가 불가능합니다. 테이블별로 "조회 주체"를 먼저 정의하고 정책을 작성해야 합니다.
| 테이블 | 조회 주체 | |--------|---------| | posts | 모든 사람 (공개 콘텐츠) | | bookmarks | 본인만 | | activities | 본인 + 팔로워 | | notifications | 본인만 |
교훈 5: 실제 렌더링되는 컴포넌트를 확인하라
디버깅 시 "이 파일을 수정하면 된다"고 가정했다가 실제로는 다른 파일이 렌더링되고 있는 경우가 있습니다. 저희는 PostItem.tsx를 수정했지만 실제 댓글 렌더링은 PostDetail.tsx에서 일어났습니다.
수정 전에 반드시 브라우저에서 "이 화면은 어떤 컴포넌트가 그리는가"를 확인하세요.
마무리
RLS는 강력한 도구이지만, "처음부터 제대로" 설계하려면 생각보다 많은 것을 고려해야 합니다. 저희가 겪은 30.3% 통과율의 충격이 이 글을 읽는 분들에게는 예방접종이 되기를 바랍니다.
삽질은 저희가 했으니, 여러분은 교훈만 가져가세요.
관련 글: BookSalon 개발기 - Firebase에서 Supabase로, 그리고 70% 번들 최적화까지
질문이나 다른 접근 방법이 있다면 알려주세요!