본문 바로가기

바이브코딩

13. 고객과 관리자 권한 분리 - Supabase RBAC 역할 관리

Supabase RBAC 역할 관리 - 13편. 고객과 관리자 권한 분리

RBAC(역할 기반 접근 제어)로 사용자 권한을 관리합니다.
이 글에서는 역할 데이터 설계, RLS 정책, Server Action 권한 체크, 역할별 UI 표시를 다룹니다.


🤔 역할이 필요한 이유

견적 플랫폼에는 두 종류의 사용자가 있습니다.

  • 고객: 문의를 하고, 견적을 받는 사람
  • 관리자: 문의를 확인하고, 견적을 보내는 사람

같은 데이터를 다르게 봐야 합니다.

고객: 내 문의만 볼 수 있음
관리자: 모든 문의를 볼 수 있음

이걸 RBAC(Role-Based Access Control)라고 합니다.

Claude에게 물었습니다.

고객과 관리자 권한을 어떻게 나눌 수 있어?

역할 기반으로 설계하면 된다고 했습니다.


🗄️ 역할 데이터 설계

users 테이블 수정

-- 기존 테이블에 role 컬럼 추가
alter table users
add column role text default 'customer'
check (role in ('customer', 'admin'));

-- 인덱스 추가 (조회 성능)
create index idx_users_role on users(role);

기본값은 customer입니다. 가입하면 자동으로 고객이 됩니다.

타입 정의

// lib/types/auth.ts
export type UserRole = 'customer' | 'admin'

export interface UserWithRole {
  id: string
  email: string
  name: string | null
  role: UserRole
}

TypeScript에서도 역할을 타입으로 정의했습니다.


🔐 회원가입 시 역할 부여

일반 회원가입

일반 회원가입은 자동으로 customer가 됩니다.

// app/actions/auth.ts
export async function signup(formData: FormData) {
  const supabase = await createClient()

  const { data: authData, error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      data: { name, phone, company }
    }
  })

  if (error) return { error: error.message }

  // users 테이블에도 추가 (role은 기본값 'customer')
  await supabase.from('users').insert({
    id: authData.user!.id,
    email,
    name,
    phone,
    company,
    // role: 'customer' ← 기본값이므로 생략
  })

  return { success: true }
}

관리자 계정 생성

관리자는 직접 DB에서 설정합니다.

-- 직접 업데이트
update users
set role = 'admin'
where email = 'admin@kmetaro.com';

보안상 코드로 관리자를 만들 수 있게 하지 않았습니다.


🎭 역할 기반 접근 제어

1. RLS 정책

DB 레벨에서 먼저 막습니다.

-- 고객은 자신의 데이터만 조회
create policy "Customers can view own data"
  on inquiries for select
  using (user_id = auth.uid());

-- 관리자는 모든 데이터 조회
create policy "Admins can view all data"
  on inquiries for select
  using (
    exists (
      select 1 from users
      where id = auth.uid() and role = 'admin'
    )
  );

고객이 다른 사람의 문의를 보려고 해도 DB가 막습니다.

2. Server Action에서 체크

API 레벨에서도 한 번 더 체크합니다.

// app/actions/admin.ts
'use server'

import { createClient } from '@/lib/supabase/server'

async function requireAdmin() {
  const supabase = await createClient()

  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('로그인이 필요합니다')

  const { data: profile } = await supabase
    .from('users')
    .select('role')
    .eq('id', user.id)
    .single()

  if (profile?.role !== 'admin') {
    throw new Error('관리자 권한이 필요합니다')
  }

  return user
}

export async function deleteInquiry(inquiryId: string) {
  await requireAdmin() // 관리자 체크

  const supabase = await createClient()
  await supabase.from('inquiries').delete().eq('id', inquiryId)

  return { success: true }
}

requireAdmin() 함수를 만들어서 재사용합니다.


📱 역할별 UI 표시

네비게이션 메뉴

// components/layout/Navigation.tsx
import { useAuth } from '@/hooks/useAuth'

export function Navigation() {
  const { user, isAdmin } = useAuth()

  return (
    <nav>
      <Link href="/">홈</Link>
      <Link href="/products">제품</Link>

      {user ? (
        <>
          <Link href="/mypage">마이페이지</Link>

          {/* 관리자만 보이는 메뉴 */}
          {isAdmin && (
            <Link href="/admin">관리자</Link>
          )}

          <LogoutButton />
        </>
      ) : (
        <>
          <Link href="/login">로그인</Link>
          <Link href="/signup">회원가입</Link>
        </>
      )}
    </nav>
  )
}

관리자에게만 "관리자" 메뉴가 보입니다.

조건부 버튼

function InquiryCard({ inquiry }: { inquiry: Inquiry }) {
  const { isAdmin } = useAuth()

  return (
    <div>
      <h3>{inquiry.title}</h3>
      <p>{inquiry.content}</p>

      {isAdmin && (
        <button onClick={() => deleteInquiry(inquiry.id)}>
          삭제
        </button>
      )}
    </div>
  )
}

삭제 버튼은 관리자만 볼 수 있습니다.


🔄 역할 변경 기능

관리자가 다른 사용자의 역할을 바꿀 수 있어야 합니다.

역할 변경 API

// app/actions/admin.ts
export async function updateUserRole(
  userId: string,
  role: UserRole
) {
  await requireAdmin()

  const supabase = await createClient()

  const { error } = await supabase
    .from('users')
    .update({ role })
    .eq('id', userId)

  if (error) return { error: error.message }

  return { success: true }
}

관리자 페이지 UI

// app/admin/users/[id]/page.tsx
'use client'

import { updateUserRole } from '@/app/actions/admin'

export default function UserDetailPage({ params }) {
  const [role, setRole] = useState(user.role)

  const handleRoleChange = async (newRole: UserRole) => {
    const result = await updateUserRole(params.id, newRole)

    if (result.success) {
      setRole(newRole)
      alert('역할이 변경되었습니다')
    }
  }

  return (
    <div>
      <h1>{user.name}</h1>

      <select
        value={role}
        onChange={(e) => handleRoleChange(e.target.value as UserRole)}
      >
        <option value="customer">고객</option>
        <option value="admin">관리자</option>
      </select>
    </div>
  )
}

🛡️ 보안 주의사항

Claude가 중요한 점을 알려줬습니다.

클라이언트에서만 체크하면 안 됨

// ❌ 나쁜 예: 프론트엔드에서만 숨김
{isAdmin && <DeleteButton />}

// 버튼이 안 보여도 API는 호출 가능!
// 개발자 도구로 얼마든지 호출할 수 있음

항상 서버에서 검증

// ✅ 좋은 예: Server Action에서 체크
export async function deleteUser(userId: string) {
  const admin = await requireAdmin() // 필수!

  // 삭제 로직
}

RLS도 필수

-- 관리자만 삭제 가능
create policy "Only admins can delete"
  on users for delete
  using (
    exists (
      select 1 from users
      where id = auth.uid() and role = 'admin'
    )
  );

3중 체크입니다. 미들웨어, Server Action, RLS.


⚠️ 삽질했던 것들

역할이 업데이트 안 됨

역할을 바꾸려는데 아무 반응이 없었습니다.

RLS가 update를 막고 있었습니다.

-- 관리자만 역할 변경 가능
create policy "Admins can update user roles"
  on users for update
  using (
    exists (
      select 1 from users
      where id = auth.uid() and role = 'admin'
    )
  );

자기 자신의 역할 변경

관리자가 실수로 자기 역할을 customer로 바꿔버렸습니다.

관리자 페이지에 접근 못하게 됐습니다.

export async function updateUserRole(userId: string, role: UserRole) {
  const admin = await requireAdmin()

  // 자기 자신은 변경 불가
  if (admin.id === userId) {
    return { error: '자신의 역할은 변경할 수 없습니다' }
  }

  // ...
}

자기 자신은 변경 못하게 막았습니다.

역할 캐싱 문제

역할을 바꿨는데 UI가 그대로였습니다.

useAuth 훅이 캐시된 값을 쓰고 있었습니다. router.refresh()로 해결했습니다.


💡 역할 확장

나중에 역할이 더 필요할 수 있습니다.

type UserRole = 'customer' | 'staff' | 'manager' | 'admin'

권한 기반으로 체크하는 방법도 있습니다.

const permissions = {
  customer: ['read:own'],
  staff: ['read:all', 'create:quotation'],
  manager: ['read:all', 'create:quotation', 'delete:inquiry'],
  admin: ['*'], // 모든 권한
}

function hasPermission(role: UserRole, permission: string) {
  const rolePermissions = permissions[role]
  return rolePermissions.includes('*') || rolePermissions.includes(permission)
}

지금은 고객/관리자로 충분해서 이것까지는 구현하지 않았습니다.


🎯 체크 위치 정리

체크 위치 용도 필수 여부
미들웨어 페이지 접근 제어 선택
Server Action API 권한 체크 필수
RLS DB 레벨 보호 필수
클라이언트 UI 표시 제어 UX용

백엔드에서 반드시 체크하세요!


📋 이번 편 요약

항목 내용
다룬 기능 RBAC 역할 관리, 권한 체크
역할 종류 customer, admin
체크 위치 미들웨어, Server Action, RLS
핵심 원칙 백엔드에서 반드시 검증

Part 3 인증 파트가 끝났습니다. 다음 Part 4에서는 고객용 기능을 구현합니다.

💬 역할 관리 관련 질문 있으면 댓글로 남겨주세요!