Skip to main content

35장. 권한 처리: 내 데이터와 남의 데이터 구분하기

지난 장에서 우리는 글을 만들고, 수정하고, 삭제하는 흐름을 완성했다. 여기까지 오면 정말 뿌듯하다. 버튼도 눌리고, 데이터도 바뀌고, 화면도 갱신된다. 그런데 바로 이 지점에서 서비스는 아주 중요한 질문 하나를 던진다.

누가 이 버튼을 눌러도 되는가?

이 질문에 답하지 못하면 서비스는 그럴듯해 보여도 아직 안전하지 않다. 로그인만 했다는 이유로 아무나 남의 글을 고칠 수 있다면, 그건 카페 문은 열어 줬는데 다른 사람 사물함 열쇠까지 같이 준 것과 비슷하다.

이번 장에서는 바로 그 문제를 다룬다. 인증과 권한의 차이를 분명히 이해하고, 사용자별로 데이터 접근을 나누는 기본 규칙을 우리 서비스에 적용해 보자.

[6부] 권한 처리: 내 데이터와 남의 데이터 구분하기

[6부] 권한 처리: 내 데이터와 남의 데이터 구분하기

[6부] 권한 처리: 내 데이터와 남의 데이터 구분하기

로그인했다고 다 할 수 있는 것은 아니다

초보자가 가장 자주 헷갈리는 부분이 여기다. 로그인 기능이 붙으면 서비스가 안전해졌다고 느끼기 쉽다. 하지만 로그인은 절반만 해결한 것이다.

Next.js 문서에서도 이 흐름을 세 가지로 나누어 설명한다. 인증은 사용자가 누구인지 확인하는 일이고, 세션은 그 로그인 상태를 유지하는 장치이며, 권한 부여는 그 사용자가 어떤 화면과 데이터에 접근할 수 있는지 결정하는 일이다.1

쉽게 말해 이렇게 구분하면 된다.

인증은 "너 누구야?"를 묻는 단계다. 권한은 "그래서 어디까지 할 수 있어?"를 정하는 단계다.

지난 장의 코드는 일부러 흐름 이해에 집중했다. 그래서 로그인한 사용자라면 수정과 삭제 기능에 닿을 수 있는 구조였다. 이번 장에서는 거기에 진짜 자물쇠를 달아 줄 차례다.

버튼을 숨기는 것만으로는 부족하다

많은 초보자가 처음에는 이런 식으로 생각한다.

"내가 쓴 글에만 수정 버튼을 보이게 하면 되는 거 아닌가요?"

절반은 맞고, 절반은 틀리다.

수정 버튼을 숨기는 것은 필요하다. 사용자가 괜히 잘못 누르지 않게 하고, 화면도 자연스러워진다. 하지만 그건 어디까지나 겉문을 닫는 일에 가깝다. 주소를 직접 입력하거나, 개발자 도구나 네트워크 요청을 건드리면 버튼 없이도 수정 요청을 보낼 수 있다.

Next.js 문서도 권한 검사를 두 층으로 생각하라고 말한다. 화면에서 버튼을 보이거나 숨기고, 경로 접근을 빠르게 걸러내는 것은 비교적 가벼운 검사다. 반면 정말 중요한 데이터 변경은 데이터에 가까운 곳에서 더 안전하게 확인해야 한다.1

이 장에서 기억할 핵심은 이것이다.

UI 검사는 안내판이고, 서버와 데이터베이스 검사는 자물쇠다.

안내판만 세워 두면 얌전한 사람은 멈추지만, 악의적이거나 우연히 경로를 아는 사람은 지나갈 수 있다.

우리 서비스에서 먼저 정할 권한 규칙

권한 처리는 코드를 쓰기 전에 규칙부터 정해야 한다. 규칙이 흐리면 구현도 흐려진다.

이 책의 예제 서비스에서는 다음처럼 정하면 가장 이해하기 쉽다.

  • 글 목록과 상세 화면은 누구나 볼 수 있다.

  • 글 작성은 로그인한 사용자만 할 수 있다.

  • 글 수정과 삭제는 그 글을 쓴 작성자만 할 수 있다.

이 규칙을 한 문장으로 줄이면 더 선명해진다.

읽기는 넓게, 쓰기는 좁게.

물론 서비스에 따라 다를 수 있다. 사내 도구처럼 로그인한 사람만 글을 읽게 만들 수도 있다. 하지만 지금 단계에서는 공개 조회, 작성자만 수정/삭제 규칙이 가장 직관적이다.

권한의 기준점은 결국 user_id다

권한 처리가 어렵게 느껴지는 이유는 사실 단순하다. 데이터를 사람과 연결하는 기준이 눈에 잘 안 보이기 때문이다.

우리는 이미 지난 장에서 posts 테이블에 user_id를 넣었다. 이 값이 바로 기준점이다. 글 한 개마다 "이 글은 누구 것인가"가 적혀 있는 셈이다.

그러면 권한 규칙도 자연스럽게 바뀐다.

현재 로그인한 사용자의 ID와 글의 user_id가 같으면 수정 가능, 다르면 수정 불가다. 삭제도 똑같다. 생각보다 철학적인 문제가 아니라, 결국 같으냐 다르냐를 따지는 문제다.

이 기준이 분명해지면 서비스가 훨씬 안정된다. 내 데이터와 남의 데이터가 비로소 갈라지기 시작한다.

Supabase RLS는 데이터베이스에 붙는 보이지 않는 경비원이다

이제 진짜 핵심으로 들어가 보자. Supabase에서는 권한 처리를 데이터베이스 레벨에서 강제하는 대표적인 방법으로 RLS, 즉 Row Level Security를 쓴다.

이 이름은 처음 들으면 조금 무섭다. 하지만 개념은 의외로 쉽다. Supabase 공식 문서는 RLS를 "모든 쿼리에 자동으로 붙는 조건"처럼 이해해도 된다고 설명한다.2

예를 들어 사용자가 posts를 조회할 때, 겉으로는 그냥 이렇게 요청할 수 있다.

const { data } = await supabase.from('posts').select('*')

그런데 RLS 정책이 있으면 데이터베이스가 뒤에서 몰래 이렇게 바꿔 생각한다.

"잠깐, 이 사용자가 볼 수 있는 행만 남기자."

즉, 개발자가 실수로 필터를 빼먹어도 데이터베이스가 마지막 방어선을 맡아 주는 셈이다. 바로 이 점 때문에 RLS가 중요하다. 서버 코드, 클라이언트 코드, 라우트 처리 중 어딘가가 허술해져도 데이터베이스가 한 번 더 막아 준다.2

서비스가 진짜 서비스가 되는 순간은 종종 화려한 UI가 아니라, 이런 보이지 않는 안전장치가 생길 때다.

posts 테이블에 RLS를 켜자

먼저 posts 테이블에 RLS를 활성화하자.

alter table public.posts enable row level security;

이 한 줄은 의미가 꽤 크다. 이제부터 posts 테이블은 정책이 없으면 기본적으로 쉽게 열리지 않는다. 즉, "일단 모두 허용"이 아니라 "명시적으로 허용한 것만 통과"에 가까운 태도로 바뀌는 것이다.2

초보자 입장에서는 이렇게 이해하면 편하다.

지금까지의 데이터베이스가 직원증 없이도 사무실 복도까지는 들어갈 수 있는 건물이었다면, 이제부터는 출입 게이트가 생긴 셈이다.

조회, 작성, 수정, 삭제 정책을 각각 나누어 생각하자

권한 정책은 한 번에 뭉쳐서 생각하면 헷갈린다. CRUD처럼 나누면 훨씬 쉽다.

우리 서비스 기준으로는 이런 정책이 자연스럽다.

create policy "Anyone can read posts"
on public.posts
for select
to anon, authenticated
using (true);

create policy "Authenticated users can create posts"
on public.posts
for insert
to authenticated
with check ((select auth.uid()) = user_id);

create policy "Users can update own posts"
on public.posts
for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);

create policy "Users can delete own posts"
on public.posts
for delete
to authenticated
using ((select auth.uid()) = user_id);

처음 보면 길어 보이지만, 읽는 법만 알면 어렵지 않다.

for select는 조회 정책이다. 누구나 읽게 하고 싶으니 using (true)로 열어 둔다.

for insert는 새 글 작성 정책이다. 로그인한 사용자만 쓸 수 있고, 그 글의 user_id가 현재 로그인 사용자와 같아야 통과한다.

for update는 수정 정책이다. 여기서 중요한 점이 하나 있다. 수정은 기존 행을 고르는 조건과, 수정한 뒤에도 그 행이 규칙을 만족하는지를 둘 다 봐야 한다. 그래서 using과 with check를 함께 쓴다.2

for delete는 삭제 정책이다. 현재 사용자가 자기 글일 때만 지울 수 있다.

여기서 가장 중요한 문장은 사실 이것 하나다.

수정과 삭제는 "로그인했는가"가 아니라 "내 글인가"를 본다.

using과 with check는 이렇게 이해하면 쉽다

RLS에서 가장 많이 헷갈리는 단어가 using과 with check다. 하지만 어렵게 볼 필요 없다.

using은 "이 행을 건드릴 자격이 있나?"를 묻는 조건이다. 주로 조회, 수정, 삭제에서 기존 데이터를 대상으로 검사할 때 쓰인다.

with check는 "지금 쓰려는 새 값도 규칙에 맞나?"를 묻는 조건이다. 주로 삽입과 수정에서 쓴다.2

예를 들어 수정에서 using만 있고 with check가 없다면, 사용자가 자기 글을 수정하는 척하면서 user_id를 다른 사람 값으로 바꾸는 이상한 시도를 막기 어려워질 수 있다. 그래서 수정에서는 둘 다 넣는 편이 안전하다.

말하자면 using은 입장 심사이고, with check는 퇴장 검사다. 들어올 때도 보고, 나갈 때도 보는 것이다.

auth.uid()는 현재 로그인 사용자의 번호표다

Supabase 정책에서 자주 보이는 auth.uid()는 현재 요청을 보낸 사용자의 ID를 뜻한다.2

그러니까 이 문장,

(select auth.uid()) = user_id

의 뜻은 아주 단순하다.

"지금 요청한 사람의 ID와 이 글의 작성자 ID가 같아야 한다."

여기서 괄호 안에 select를 한 번 더 감싼 형태가 낯설 수 있다. 최근 실전 가이드들은 이처럼 (select auth.uid()) 형태를 쓰는 편이 정책 평가 성능에 유리하다고 설명한다.3

처음 배우는 단계에서는 그냥 이렇게 기억해도 충분하다.

권한 비교의 핵심은 현재 사용자 번호표와 데이터 소유자 번호표를 맞춰 보는 일이다.

Next.js에서도 한 번 더 확인하자

RLS를 켰다고 해서 애플리케이션 코드에서 아무것도 안 해도 되는 것은 아니다. 데이터베이스가 막아 주더라도, 사용자 경험은 애플리케이션에서 챙겨야 한다.

예를 들어 수정 페이지에 남의 글 주소를 직접 입력해 들어온 사용자가 있다고 하자. RLS만 믿고 페이지를 그대로 열어 두면, 사용자는 폼까지 본 뒤 저장 시점에서야 실패를 만나게 될 수 있다. 그러면 서비스가 투박하게 느껴진다.

그래서 서버 컴포넌트에서도 한 번 더 작성자 여부를 확인하는 편이 좋다.

import { notFound, redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'

export default async function EditPostPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const supabase = await createClient()

  const {
    data: { user },
  } = await supabase.auth.getUser()

  if (!user) {
    redirect('/login')
  }

  const { data: post } = await supabase
    .from('posts')
    .select('id, title, content, user_id')
    .eq('id', id)
    .single()

  if (!post) {
    notFound()
  }

  if (post.user_id !== user.id) {
    redirect('/posts?message=forbidden')
  }

  return (
    <main>
      <h1>글 수정</h1>
      <form>{/* 수정 폼 */}</form>
    </main>
  )
}

이 검사는 RLS와 역할이 조금 다르다. RLS는 데이터 자체를 지키는 최후의 방어선이고, 이 코드는 사용자를 덜 헷갈리게 만드는 친절한 안내원이다.

좋은 서비스는 보안만 강한 것이 아니라, 막힐 때도 왜 막혔는지 덜 억울하게 느껴지게 만든다.

서버 액션에서도 작성자 기준을 함께 넣어 두면 읽기 쉽다

수정 액션이나 삭제 액션에서도 작성자 기준 필터를 한 번 더 적어 두면 코드가 더 분명해진다.

'use server'

import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'

export async function updatePost(formData: FormData) {
  const supabase = await createClient()

  const {
    data: { user },
  } = await supabase.auth.getUser()

  if (!user) {
    redirect('/login')
  }

  const id = formData.get('id')?.toString()
  const title = formData.get('title')?.toString().trim()
  const content = formData.get('content')?.toString().trim()

  if (!id || !title || !content) {
    redirect('/posts?message=invalid-update')
  }

  const { data, error } = await supabase
    .from('posts')
    .update({
      title,
      content,
      updated_at: new Date().toISOString(),
    })
    .eq('id', id)
    .eq('user_id', user.id)
    .select()
    .single()

  if (error || !data) {
    redirect('/posts?message=forbidden-or-failed')
  }

  revalidatePath('/posts')
  revalidatePath(`/posts/${id}`)
  redirect(`/posts/${id}`)
}

여기서 진짜 보안을 담당하는 것은 RLS다. 하지만 .eq('user_id', user.id)까지 적어 두면 코드를 읽는 사람도 "아, 이 액션은 작성자만 수정할 수 있게 의도했구나"를 바로 이해할 수 있다.

보안은 강해야 하고, 의도는 읽혀야 한다. 둘 중 하나만 챙기면 반쪽이다.

서비스 롤 키는 만능 열쇠라서 더 위험하다

여기서 꼭 짚고 넘어가야 할 것이 하나 있다. Supabase의 service role 키는 RLS를 우회할 수 있는 강한 권한을 가진다.2

이 말은 멋있게 들리지만, 초보자에게는 거의 "건물 전체 마스터키"라는 뜻이다.

그래서 이 키를 브라우저 코드에 넣으면 안 된다. NEXT_PUBLIC_로 노출하는 순간, 권한 처리를 정성껏 만들어 놓고도 스스로 뒷문을 활짝 열어 두는 셈이 된다.

이 책의 실습에서는 브라우저에서 공개 키만 사용하고, 강한 권한이 필요한 작업은 서버에서만 다룬다. 이 원칙은 정말 중요하다. 권한 처리는 잘 짜는 것도 중요하지만, 우회 가능한 지름길을 만들지 않는 것이 더 중요할 때가 많다.

권한 처리는 글 기능에서 끝나지 않는다

이번 장에서 다루는 예시는 글이지만, 사실 거의 모든 기능에 그대로 이어진다.

프로필 수정은 내 프로필만 가능해야 한다. 댓글 삭제는 내 댓글만 가능해야 한다. 파일 업로드도 내 파일과 남의 파일을 구분해야 한다. 나중에 관리자 기능을 붙이면 또 다른 규칙이 생긴다. 예를 들어 관리자는 전체 글을 볼 수 있지만, 일반 사용자는 자기 데이터만 관리하게 만들 수 있다.

즉, 권한 처리는 부가 기능이 아니라 서비스 구조의 일부다. 기능 하나 만들 때마다 "이건 누가 볼 수 있지? 누가 바꿀 수 있지?"를 묻는 습관이 생겨야 한다.

한 번 이 감각이 잡히면, 서비스가 훨씬 덜 불안해진다.

자주 하는 실수

권한 처리를 처음 붙일 때는 비슷한 실수를 반복하기 쉽다.

  • 로그인 여부만 확인하고 작성자 여부는 검사하지 않는다.

  • 수정 버튼만 숨기고, 서버 액션이나 데이터베이스는 열어 둔다.

  • RLS를 만들었지만 테이블에 RLS 자체를 켜지 않았다.

  • update 정책에서 with check를 빼먹는다.

  • service role 키를 어디선가 슬쩍 클라이언트 쪽에 둔다.

이 중 특히 위험한 것은 "화면에서는 막혀 보이는데 실제로는 안 막힌 상태"다. 이런 경우가 가장 사람을 속인다.

그래서 권한 기능을 붙인 날에는 꼭 실제 사용자처럼 테스트해 보는 것이 좋다. 다른 계정으로 로그인해서 남의 글 수정 주소에 직접 들어가 보고, 네트워크 요청도 다시 확인해 보자. 권한은 믿음이 아니라 검증의 영역이다.

AI에게 이렇게 요청해 보자

이번 장의 주제는 AI와도 궁합이 좋다. 다만 "권한 처리 다 해줘"라고 크게 던지면 과한 구조나 현재 프로젝트와 맞지 않는 예제가 나오기 쉽다. 기능을 작은 덩어리로 나누는 편이 훨씬 낫다.

  • 현재 Next.js + Supabase 프로젝트에서 posts 테이블에 RLS를 적용하려고 해. 글은 누구나 조회 가능, 작성은 로그인 사용자만, 수정/삭제는 작성자만 가능하도록 SQL 정책을 작성해줘.

  • posts/[id]/edit 페이지에서 로그인 사용자가 작성자가 아니면 /posts로 리다이렉트하도록 서버 컴포넌트 코드를 수정해줘.

  • updatePost와 deletePost 서버 액션에서 현재 사용자 user.id 기준으로 한 번 더 필터링하도록 안전하게 바꿔줘.

좋은 요청은 늘 구체적이다. 테이블 이름, 허용 규칙, 원하는 리다이렉트 위치까지 말해 주면 Claude Code나 Codex도 훨씬 덜 헷갈린다.

정리

이번 장에서 우리는 서비스의 중요한 경계선을 만들었다. 로그인은 사용자를 안으로 들이는 일이었고, 권한 처리는 그 안에서 어디까지 움직일 수 있는지 정하는 일이었다.

핵심은 세 가지다.

첫째, 로그인 여부와 데이터 소유권은 다른 문제다. 로그인했다고 남의 데이터를 만질 수 있으면 안 된다.

둘째, 버튼을 숨기는 것만으로는 부족하다. 화면, 서버, 데이터베이스가 함께 막아야 한다.

셋째, Supabase RLS는 데이터베이스 수준에서 내 데이터와 남의 데이터를 가르는 강력한 기준선이 된다.

이제 우리 서비스는 조금 더 진짜 서비스다. 사용자는 단지 들어오는 사람이 아니라, 자기 데이터의 주인이 된다. 그리고 서비스는 그 소유권을 존중하는 쪽으로 한 걸음 더 성장한다.

다음 장에서는 이 감각을 바탕으로 파일 업로드와 외부 서비스 연결까지 넓혀 보겠다. 기능은 더 풍성해지겠지만, 오늘 배운 질문은 그대로 따라간다.

이건 누가 사용할 수 있어야 하지?

그 질문을 잊지 않으면 서비스는 훨씬 오래 버틴다.

참고

1Authentication – Next.js 한글 문서

2Row Level Security | Supabase Docs

3Supabase 완전 정복 시리즈 4편 — RLS(Row Level Security) 완벽 가이드

책 목록

바이브 코딩으로 완성하는 첫 풀스택 웹 서비스

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기