Supabase 완전 정복 시리즈 2편 — 데이터베이스 & 자동 API (REST/GraphQL)




시리즈 목차 1편 – Supabase란 무엇인가? Firebase와 제대로 비교해보기 2편 👉 데이터베이스 & 자동 API (REST/GraphQL) (현재 글) 3편 – 인증(Auth) — 소셜 로그인, JWT, SSO, SAML 4편 – RLS 보안 — Row Level Security 완벽 가이드 …


들어가며

Supabase의 심장은 PostgreSQL입니다. 그리고 Supabase가 개발자들에게 사랑받는 이유 중 하나는, PostgreSQL 테이블을 만드는 순간 REST API와 GraphQL API가 자동으로 생성된다는 점입니다. 별도의 서버 코드, 라우터, 컨트롤러 없이 말이죠.

이번 편에서는 다음 내용을 다룹니다.

  • Supabase 데이터베이스의 구조와 특징
  • 자동 생성 REST API (PostgREST) 원리와 활용
  • 자동 생성 GraphQL API (pg_graphql) 원리와 활용
  • Next.js App Router + TypeScript에서 데이터 CRUD 실전 코드
  • 관계형 데이터 조회, 필터링, 페이지네이션 패턴

Supabase 데이터베이스 기초

PostgreSQL, 그대로입니다

Supabase는 PostgreSQL을 포크하거나 변형하지 않습니다. 완전한 PostgreSQL입니다. 즉, PostgreSQL의 모든 기능을 그대로 사용할 수 있습니다.

  • 외래 키(Foreign Keys), 인덱스, 뷰(Views)
  • 트리거(Triggers), 저장 프로시저(Stored Procedures)
  • 확장(Extensions): pgvector, pg_cron, PostGIS 등
  • ACID 트랜잭션 보장

Supabase는 여기에 대시보드 UI, 자동 API 생성, 실시간 구독, 인증 연동을 얹어서 제공합니다.

테이블 생성 예시

블로그 서비스를 예로 들어 기본 스키마를 만들어 봅시다.

-- 카테고리 테이블
CREATE TABLE categories (
  id   BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name TEXT NOT NULL UNIQUE,
  slug TEXT NOT NULL UNIQUE
);

-- 게시글 테이블
CREATE TABLE posts (
  id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title       TEXT NOT NULL,
  content     TEXT,
  slug        TEXT NOT NULL UNIQUE,
  published   BOOLEAN DEFAULT FALSE,
  category_id BIGINT REFERENCES categories(id) ON DELETE SET NULL,
  author_id   UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  updated_at  TIMESTAMPTZ DEFAULT NOW()
);

-- updated_at 자동 갱신 트리거
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = NOW();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER posts_updated_at
  BEFORE UPDATE ON posts
  FOR EACH ROW EXECUTE FUNCTION update_updated_at();

-- 댓글 테이블
CREATE TABLE comments (
  id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  post_id    BIGINT REFERENCES posts(id) ON DELETE CASCADE,
  author_id  UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  content    TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

이 SQL을 Supabase 대시보드의 SQL Editor에서 실행하면, 즉시 테이블이 생성되고 API가 준비됩니다.


자동 생성 REST API — PostgREST

작동 원리

Supabase의 REST API는 PostgREST라는 오픈소스 도구를 기반으로 합니다. PostgREST는 PostgreSQL 스키마를 분석해 RESTful API 엔드포인트를 자동으로 만들어줍니다.

테이블 posts를 만들면 다음 엔드포인트가 자동 생성됩니다.

메서드엔드포인트동작
GET/rest/v1/posts목록 조회
GET/rest/v1/posts?id=eq.1단건 조회
POST/rest/v1/posts생성
PATCH/rest/v1/posts?id=eq.1수정
DELETE/rest/v1/posts?id=eq.1삭제

코드 한 줄 없이 완전한 CRUD API가 생깁니다. 실제로는 supabase-js 클라이언트 라이브러리를 통해 이 API를 더 편하게 호출합니다.

supabase-js로 CRUD 작업하기

supabase-js는 PostgREST를 감싸는 타입 안전한 쿼리 빌더입니다. Next.js에서 어떻게 활용하는지 살펴봅니다.

타입 생성 (TypeScript)

먼저 Supabase CLI로 DB 스키마에서 TypeScript 타입을 자동 생성합니다.

npx supabase gen types typescript --project-id your-project-id > types/database.types.ts

생성된 타입을 활용하면 완전한 타입 안전성을 확보할 수 있습니다.

// types/database.types.ts (자동 생성 예시)
export type Database = {
  public: {
    Tables: {
      posts: {
        Row: {
          id: number
          title: string
          content: string | null
          slug: string
          published: boolean
          category_id: number | null
          author_id: string | null
          created_at: string
          updated_at: string
        }
        Insert: {
          title: string
          slug: string
          content?: string | null
          published?: boolean
          category_id?: number | null
          author_id?: string | null
        }
        Update: Partial<Database['public']['Tables']['posts']['Insert']>
      }
      // ... 나머지 테이블
    }
  }
}

클라이언트에 타입 적용

// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { type Database } from '@/types/database.types'

export async function createClient() {
  const cookieStore = await cookies()

  return createServerClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (cookiesToSet) => {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {}
        },
      },
    }
  )
}

READ — 데이터 조회

// app/blog/page.tsx — 게시글 목록 (Server Component)
import { createClient } from '@/lib/supabase/server'

export default async function BlogPage() {
  const supabase = await createClient()

  // 기본 조회
  const { data: posts, error } = await supabase
    .from('posts')
    .select('id, title, slug, created_at')
    .eq('published', true)
    .order('created_at', { ascending: false })
    .limit(10)

  if (error) throw new Error(error.message)

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

관계형 데이터 조회 (JOIN)

Supabase는 외래 키 관계를 자동으로 인식해 중첩 조회를 지원합니다.

// 게시글 + 카테고리 + 댓글 수 한 번에 조회
const { data: posts } = await supabase
  .from('posts')
  .select(`
    id,
    title,
    slug,
    created_at,
    categories ( name, slug ),
    comments ( count )
  `)
  .eq('published', true)
  .order('created_at', { ascending: false })

// 결과 타입 예시:
// {
//   id: 1,
//   title: "Supabase 시작하기",
//   categories: { name: "기술", slug: "tech" },
//   comments: [{ count: 5 }]
// }

필터링 & 검색

PostgREST는 다양한 필터 연산자를 제공합니다.

// 다양한 필터 예시
const { data } = await supabase
  .from('posts')
  .select('*')
  .eq('published', true)          // 같음
  .neq('author_id', null)         // null이 아님
  .gt('created_at', '2024-01-01') // 보다 큼
  .ilike('title', '%supabase%')   // 대소문자 무시 LIKE
  .in('category_id', [1, 2, 3])   // IN 조건
  .not('content', 'is', null)     // NOT null

페이지네이션

// app/blog/page.tsx — 페이지 기반 페이지네이션
interface BlogPageProps {
  searchParams: { page?: string }
}

export default async function BlogPage({ searchParams }: BlogPageProps) {
  const supabase = await createClient()
  const page = Number(searchParams.page ?? 1)
  const pageSize = 10
  const from = (page - 1) * pageSize
  const to = from + pageSize - 1

  const { data: posts, count } = await supabase
    .from('posts')
    .select('id, title, slug, created_at', { count: 'exact' })
    .eq('published', true)
    .order('created_at', { ascending: false })
    .range(from, to)

  const totalPages = Math.ceil((count ?? 0) / pageSize)

  return (
    <div>
      <ul>
        {posts?.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <p>페이지 {page} / {totalPages}</p>
    </div>
  )
}

CREATE — 데이터 생성

// app/blog/new/actions.ts — Server Action으로 게시글 생성
'use server'

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

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

  const { data: { user } } = await supabase.auth.getUser()
  if (!user) redirect('/login')

  const title = formData.get('title') as string
  const content = formData.get('content') as string
  const slug = title.toLowerCase().replace(/\s+/g, '-')

  const { error } = await supabase
    .from('posts')
    .insert({
      title,
      content,
      slug,
      author_id: user.id,
      published: false,
    })

  if (error) throw new Error(error.message)

  revalidatePath('/blog')
  redirect('/blog')
}

UPDATE — 데이터 수정

// app/blog/[slug]/edit/actions.ts
'use server'

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

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

  const { error } = await supabase
    .from('posts')
    .update({
      title: formData.get('title') as string,
      content: formData.get('content') as string,
    })
    .eq('id', postId)

  if (error) throw new Error(error.message)

  revalidatePath('/blog')
}

DELETE — 데이터 삭제

// 삭제 Server Action
'use server'

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

export async function deletePost(postId: number) {
  const supabase = await createClient()

  const { error } = await supabase
    .from('posts')
    .delete()
    .eq('id', postId)

  if (error) throw new Error(error.message)

  revalidatePath('/blog')
}

자동 생성 GraphQL API — pg_graphql

작동 원리

Supabase는 pg_graphql이라는 PostgreSQL 확장을 통해 GraphQL API도 자동 생성합니다. SQL 스키마를 분석해 GraphQL 타입, 쿼리, 뮤테이션을 자동으로 만들어줍니다.

예를 들어 posts 테이블이 있으면 다음이 자동 생성됩니다.

# 자동 생성되는 GraphQL 타입
type Post {
  id: BigInt!
  title: String!
  content: String
  slug: String!
  published: Boolean!
  createdAt: Datetime!
  # 외래 키 관계도 자동 생성
  categories: Category
  commentsCollection: CommentsConnection
}

# 자동 생성되는 쿼리
type Query {
  postsCollection(
    filter: PostsFilter
    orderBy: [PostsOrderBy!]
    first: Int
    after: Cursor
  ): PostsConnection
}

# 자동 생성되는 뮤테이션
type Mutation {
  insertIntoPosts(objects: [PostsInsertInput!]!): PostsMutationResponse
  updatePosts(set: PostsUpdateInput!, filter: PostsFilter): PostsMutationResponse
  deleteFromPosts(filter: PostsFilter!): PostsMutationResponse
}

pg_graphql 활성화

2025년 기준, pg_graphql은 보안상 이유로 신규 프로젝트에서 기본 비활성화되어 있습니다. 대시보드 → Database → Extensions에서 pg_graphql을 검색해 활성화하세요.

Next.js에서 GraphQL 사용하기

supabase-js는 GraphQL 쿼리를 직접 지원합니다.

// lib/supabase/graphql.ts
import { createClient } from '@/lib/supabase/server'

export async function graphqlQuery<T>(
  query: string,
  variables?: Record<string, unknown>
): Promise<T> {
  const supabase = await createClient()

  const { data, error } = await supabase
    .rpc('graphql', { query, variables })

  if (error) throw new Error(error.message)
  return data as T
}
// 실제 GraphQL 쿼리 예시
const GET_POSTS = `
  query GetPosts($first: Int, $filter: PostsFilter) {
    postsCollection(
      first: $first
      filter: $filter
      orderBy: [{ createdAt: DescNullsLast }]
    ) {
      edges {
        node {
          id
          title
          slug
          createdAt
          categories {
            name
            slug
          }
          commentsCollection {
            totalCount
          }
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`

// Server Component에서 사용
const data = await graphqlQuery<{ postsCollection: PostsConnection }>(
  GET_POSTS,
  { first: 10, filter: { published: { eq: true } } }
)

REST vs GraphQL — 언제 무엇을 쓸까?

상황추천
단순 CRUDREST (supabase-js)
복잡한 중첩 관계 조회GraphQL
모바일 앱 (트래픽 최소화)GraphQL
빠른 프로토타이핑REST
타입 자동 완성 우선REST (supabase-js 타입 지원 우수)
외부 GraphQL 클라이언트 연동GraphQL

일반적으로 Next.js 웹 개발에서는 supabase-js의 REST 방식이 더 편리하고 타입 안전성도 뛰어납니다. GraphQL은 복잡한 중첩 데이터를 한 번에 가져와야 하는 경우나, 이미 GraphQL 클라이언트(Apollo, urql 등)를 사용하는 프로젝트에 적합합니다.


고급 패턴

RPC — 커스텀 함수 호출

복잡한 비즈니스 로직은 PostgreSQL 함수로 만들고 RPC로 호출할 수 있습니다.

-- DB에 함수 생성
CREATE OR REPLACE FUNCTION get_popular_posts(limit_count INT DEFAULT 5)
RETURNS TABLE (
  id BIGINT,
  title TEXT,
  comment_count BIGINT
) AS $$
  SELECT
    p.id,
    p.title,
    COUNT(c.id) AS comment_count
  FROM posts p
  LEFT JOIN comments c ON c.post_id = p.id
  WHERE p.published = TRUE
  GROUP BY p.id
  ORDER BY comment_count DESC
  LIMIT limit_count;
$$ LANGUAGE sql;
// Next.js에서 RPC 호출
const { data: popularPosts } = await supabase
  .rpc('get_popular_posts', { limit_count: 5 })

뷰(View) 활용

자주 쓰는 복잡한 쿼리는 뷰로 만들면 API가 자동 생성됩니다.

-- 게시글 요약 뷰
CREATE VIEW post_summaries AS
SELECT
  p.id,
  p.title,
  p.slug,
  p.created_at,
  c.name  AS category_name,
  c.slug  AS category_slug,
  COUNT(cm.id) AS comment_count
FROM posts p
LEFT JOIN categories c ON c.id = p.category_id
LEFT JOIN comments cm ON cm.post_id = p.id
WHERE p.published = TRUE
GROUP BY p.id, c.name, c.slug;
// 뷰도 테이블처럼 바로 조회 가능
const { data } = await supabase
  .from('post_summaries')
  .select('*')
  .order('created_at', { ascending: false })

실시간 카운트와 집계

// 특정 조건의 레코드 수 조회
const { count } = await supabase
  .from('posts')
  .select('*', { count: 'exact', head: true }) // head: true면 데이터는 안 가져옴
  .eq('published', true)

console.log(`공개 게시글 수: ${count}`)

실수하기 쉬운 포인트

1. N+1 문제 주의

// ❌ 나쁜 예 — N+1 문제 발생
const { data: posts } = await supabase.from('posts').select('*')
for (const post of posts) {
  const { data: comments } = await supabase
    .from('comments')
    .select('*')
    .eq('post_id', post.id)
}

// ✅ 좋은 예 — 한 번에 조인
const { data: posts } = await supabase
  .from('posts')
  .select('*, comments(*)')

2. select()에 필요한 컬럼만 명시

// ❌ 불필요한 데이터 과다 조회
const { data } = await supabase.from('posts').select('*')

// ✅ 필요한 컬럼만 선택 (성능 및 타입 정확도 향상)
const { data } = await supabase
  .from('posts')
  .select('id, title, slug, created_at')

3. anon key의 한계 이해

NEXT_PUBLIC_SUPABASE_ANON_KEY는 클라이언트에 노출됩니다. 이 키로는 RLS(Row Level Security) 정책이 적용된 데이터만 접근 가능합니다. 관리자 작업이 필요하다면 Server Action이나 API Route에서 service_role 키를 사용하되, 절대 클라이언트에 노출하면 안 됩니다.


마치며

Supabase의 자동 API 생성은 백엔드 개발 생산성을 획기적으로 높여줍니다. 테이블을 설계하면 REST와 GraphQL API가 즉시 준비되고, supabase-js의 타입 안전한 쿼리 빌더 덕분에 TypeScript와의 궁합도 훌륭합니다.

다음 편에서는 Supabase의 인증(Auth) 시스템을 깊게 다룹니다. 이메일/비밀번호 로그인부터 Google, GitHub 소셜 로그인, JWT 세션 관리, 그리고 Next.js App Router에서의 미들웨어 연동까지 꼼꼼히 살펴봅니다.




댓글 남기기