테마 전환

Supabase 데이터베이스 설계 완벽 가이드: 테이블 구조, 관계, Row Level Security

Easton editorial illustration: database service control desk

Supabase Dashboard에 표시된 빨간색 경고, 즉 “RLS not enabled”를 바라보고 있었습니다. 사용자 게시물 데이터가 유출되지는 않을까? 이 외래 키 관계는 올바를까? 다대다 관계는 도대체 어떻게 만들어야 할까? 머릿속에 온갖 질문이 떠올랐습니다.

Supabase를 처음 사용했을 때는 정말 많은 시행착오를 겪었습니다. 테이블을 만든 뒤 RLS 활성화를 잊어 누구나 모든 데이터를 읽을 수 있었고, 외래 키를 제대로 설계하지 않아 사용자를 삭제해도 게시물이 남았습니다. 다대다 관계의 연결 테이블 대신 배열에 데이터를 저장해 보기도 했는데, 결과는 완전한 실패였습니다.

몇 달 동안 씨름한 끝에 Supabase 데이터베이스 설계 방식을 제대로 파악했습니다. 이 글에서는 그동안 얻은 경험을 정리합니다.

1. 테이블 구조 설계: PostgreSQL 명명 규칙

1.1 명명 규칙: snake_case가 정답입니다

PostgreSQL에는 조금 독특한 특성이 있습니다. 큰따옴표를 사용하지 않으면 식별자를 모두 소문자로 변환하고, 큰따옴표를 사용하면 작성한 대소문자를 그대로 구분합니다.

이것이 무엇을 의미할까요? 카멜 케이스(UserProfile)를 사용하면 모든 곳에서 큰따옴표로 감싸야 합니다. 너무 번거롭습니다.

그래서 PostgreSQL 커뮤니티에서는 snake_case(밑줄로 단어 구분), 복수형 테이블명, 단수형 컬럼명을 관례로 사용합니다.

-- ✅ 권장
CREATE TABLE users (
  id UUID PRIMARY KEY,
  email TEXT UNIQUE,
  created_at TIMESTAMPTZ
);

1.2 컬럼 타입 선택: MySQL 방식에 얽매이지 마세요

실수 1: TEXT 대신 VARCHAR 사용

PostgreSQL에서는 TEXT와 VARCHAR의 성능이 완전히 같습니다. 차이는 VARCHAR(n)에 길이 제한이 있다는 점뿐입니다. 길이를 꼭 제한해야 하는 경우가 아니라면 TEXT를 사용하면 됩니다.

실수 2: TIMESTAMPTZ 대신 TIMESTAMP 사용

TIMESTAMP는 시간대 정보를 저장하지 않습니다. 서버는 미국에 있고 사용자는 중국에 있다면 표시 시간이 엉킬 수 있습니다. TIMESTAMPTZ는 시간대를 자동으로 변환합니다.

실수 3: UUID 대신 SERIAL 사용

SERIAL은 자동 증가 정수입니다. 단일 서버 애플리케이션에서는 문제가 없지만 분산 시스템에서는 충돌할 수 있습니다. UUID는 전역적으로 고유합니다.

2. 세 가지 테이블 관계: 일대일, 일대다, 다대다

2.1 일대일: UNIQUE만 추가하면 됩니다

가장 흔한 사례는 사용자와 프로필의 관계입니다.

CREATE TABLE profiles (
  id UUID PRIMARY KEY,
  user_id UUID UNIQUE REFERENCES users(id) ON DELETE CASCADE,
  bio TEXT
);

핵심은 user_id UUID UNIQUE입니다. UNIQUE 제약 조건은 사용자 한 명당 프로필을 하나만 가질 수 있도록 보장합니다.

2.2 일대다: 가장 일반적인 외래 키

저자와 책의 관계를 예로 들어 보겠습니다. 저자 한 명은 여러 권의 책을 쓸 수 있습니다.

CREATE TABLE books (
  id UUID PRIMARY KEY,
  author_id UUID REFERENCES authors(id) ON DELETE CASCADE,
  title TEXT
);

조회할 때는 Supabase JS에서 연관 데이터를 중첩해 바로 가져올 수 있습니다.

2.3 다대다: 연결 테이블이 핵심입니다

학생과 강의의 관계를 생각해 봅시다. 학생 한 명은 여러 강의를 수강할 수 있고, 강의 하나에는 여러 학생이 참여할 수 있습니다.

해결 방법은 연결 테이블을 만드는 것입니다.

CREATE TABLE enrollments (
  student_id UUID REFERENCES students(id) ON DELETE CASCADE,
  course_id UUID REFERENCES courses(id) ON DELETE CASCADE,
  PRIMARY KEY (student_id, course_id)
);

3. Row Level Security: 데이터베이스가 직접 보안 담당자가 됩니다

3.1 RLS의 “기본 거부” 원칙

Supabase를 처음 사용했을 때 posts 테이블을 만든 뒤 프론트엔드에서 anon key로 바로 조회했습니다. 그런데 모든 데이터가 반환되었습니다. 깜짝 놀랐습니다.

Supabase는 기본적으로 Row Level Security(RLS)를 활성화하지 않습니다. RLS가 비활성화되어 있으면 anon key를 가진 사람은 누구나 모든 데이터를 읽고 쓸 수 있습니다.

따라서 첫 번째 철칙은 테이블을 만든 즉시 RLS를 활성화하는 것입니다.

ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

RLS를 활성화했다고 끝난 것은 아닙니다. 정책 없이 RLS만 활성화하면 “모든 접근 거부” 상태가 됩니다. 최소한 하나의 정책을 만들어야 합니다.

3.2 정책 문법: USING과 WITH CHECK

  • USING: 기존 행 필터링(SELECT, UPDATE, DELETE)
  • WITH CHECK: 새 행 검증(INSERT, UPDATE)

3.3 자주 쓰는 네 가지 정책 패턴

패턴 1: 사용자가 자신의 데이터에 접근

CREATE POLICY "Users manage own data"
ON posts FOR ALL
TO authenticated
USING (user_id = auth.uid());

패턴 2: 공개 데이터와 비공개 데이터 혼합

게시된 글은 누구나 볼 수 있고, 초안은 작성자만 볼 수 있습니다.

패턴 3: 멀티테넌트 격리

팀 구성원은 자신이 속한 팀의 데이터에만 접근할 수 있습니다.

패턴 4: RBAC 역할 기반 제어

관리자에게 특별한 권한을 부여합니다.

4. RLS 성능 최적화

4.1 성능 저하의 주범: 각 행에서 한 번씩 실행되는 서브쿼리

RLS 정책의 서브쿼리는 데이터의 각 행마다 한 번씩 실행됩니다. 행이 10만 개인데 정책 안에서 서브쿼리로 팀 관계를 확인하자 쿼리가 3분 뒤 타임아웃되었습니다.

4.2 최적화 방법 1: 인덱스 추가

RLS 정책에서 사용하는 컬럼에는 반드시 인덱스를 추가해야 합니다.

CREATE INDEX idx_posts_user_id ON posts(user_id);

Supabase 공식 테스트에서는 인덱스가 없을 때 450ms, 있을 때 45ms가 걸렸습니다. 10배 빨라진 결과입니다.

4.3 최적화 방법 2: SECURITY DEFINER 함수

서브쿼리를 함수로 감싸 한 번만 실행되게 합니다.

CREATE OR REPLACE FUNCTION user_teams()
RETURNS SETOF UUID
LANGUAGE SQL SECURITY DEFINER STABLE
AS $$ SELECT team_id FROM team_members WHERE user_id = auth.uid(); $$;

5. 실전 사례

5.1 블로그 시스템: 게시물, 카테고리, 태그

전체 구현에는 테이블 구조, RLS 정책, 인덱스 구성이 포함됩니다.

5.2 멀티테넌트 SaaS: 팀 협업

팀 데이터 격리, 팀 구성원 접근, 관리자 권한 제어를 구현합니다.

마무리

핵심 내용은 다음과 같습니다.

  • snake_case 명명 규칙
  • UUID 기본 키, TEXT 문자열, TIMESTAMPTZ 시간 타입
  • RLS는 반드시 활성화
  • 인덱스 + SECURITY DEFINER 함수로 최적화

FAQ

테이블을 만든 뒤 RLS를 즉시 활성화해야 하나요?
네. 반드시 지켜야 할 보안 원칙입니다. Supabase는 기본적으로 RLS를 활성화하지 않으므로 anon key를 가진 사람은 누구나 모든 데이터를 읽고 쓸 수 있습니다.
PostgreSQL에서 snake_case를 권장하는 이유는 무엇인가요?
PostgreSQL은 따옴표로 감싸지 않은 식별자를 소문자로 변환합니다. 카멜 케이스를 사용하면 매번 큰따옴표로 감싸야 해서 번거롭습니다.
RLS 정책에서 FOR ALL을 사용하면 안 되는 이유는 무엇인가요?
FOR ALL은 네 가지 정책을 분리했을 때보다 성능이 떨어집니다. 정책을 분리하면 PostgreSQL이 작업별로 인덱스 사용을 최적화할 수 있습니다.
RLS 상태는 어떻게 확인하나요?
Supabase Dashboard의 데이터베이스 페이지에서 각 테이블의 RLS 상태를 확인할 수 있습니다. 빨간색은 비활성화 상태를 뜻합니다.

2분 읽기 · 게시일: 2026년 4월 4일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog