본문으로 바로가기

Supabase + Next.js 가이드: 인증·RLS·Storage

2026.04.292026.07.10 수정45분 읽기

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

목차

Supabase는 Firebase의 오픈소스 대안으로,
PostgreSQL 데이터베이스·인증·스토리지·Edge Functions를 무료로 시작할 수 있는 BaaS(Backend as a Service)입니다. Next.js App Router와 조합하면 별도 백엔드 서버 없이 풀스택 앱을 빠르게 구축할 수 있습니다.

이 가이드는 Supabase 프로젝트 설정부터 클라이언트 분리, 첫 데이터 조회, 인증 흐름,
Row Level Security(RLS), Storage, Edge Functions, 그리고 흔한 함정과 트러블슈팅까지 한 번에 다룹니다.

이 가이드는 Supabase JS v2, Next.js 14/15 App Router 기준입니다. 버전이 다르면 API가 다를 수 있습니다. 최신 변경사항은 supabase.com/docs에서 확인하세요.

Supabase 프로젝트 생성#

supabase.com에서 무료 계정을 만든 뒤 새 프로젝트를 생성합니다.

프로젝트 생성 후 Project Settings → API 메뉴에서 두 값을 확인합니다.

항목설명
Project URLSupabase API 엔드포인트
anon public key클라이언트에서 사용하는 공개 키
service_role key서버 전용 비밀 키(절대 클라이언트 노출 금지)

anon 키는 RLS 정책 하에 클라이언트에 노출 가능하지만, service_role 키는 서버 사이드 전용이며 모든 RLS를 우회합니다.

Free Tier 제한(2026년 기준)#

  • 데이터베이스: 500 MB
  • 스토리지: 1 GB
  • 월 활성 사용자: 50,000명
  • 7일 비활성 시 일시 정지(다시 접속 시 깨어남)

패키지 설치#

Next.js 프로젝트 루트에서 Supabase 클라이언트와 SSR 헬퍼를 설치합니다.

npm install @supabase/supabase-js @supabase/ssr
  • @supabase/supabase-js: 핵심 클라이언트 라이브러리
  • @supabase/ssr: Next.js App Router 서버 컴포넌트·미들웨어용 쿠키 처리 헬퍼

환경 변수 설정#

프로젝트 루트에 .env.local 파일을 만들고 앞서 확인한 값을 입력합니다.

# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

# 서버 전용(절대 NEXT_PUBLIC_ 접두사 붙이지 않기)
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

주의: .env.local.gitignore에 자동 포함되어 있지만 확인 필수. service_role 키는 절대 git에 커밋하면 안 됩니다.

NEXT_PUBLIC_ 접두사가 없으면 클라이언트 번들에 포함되지 않아 브라우저에서 undefined가 됩니다. 반대로 비밀 키에 NEXT_PUBLIC_ 붙이면 클라이언트에 노출되니 주의.

Supabase 클라이언트 유틸리티 작성#

App Router에서는 클라이언트 컴포넌트용서버 컴포넌트용 클라이언트를 분리해서 사용합니다.

클라이언트 컴포넌트용#

// utils/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
}

서버 컴포넌트·Route Handler용#

// utils/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

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

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // 서버 컴포넌트에서 쿠키 쓰기는 무시
          }
        },
      },
    }
  );
}

두 파일로 분리하는 이유는 Next.js App Router가 서버/클라이언트 경계를 엄격히 구분하기 때문입니다. 서버에서 createBrowserClient를 임포트하면 빌드 오류가 발생합니다.

미들웨어용: 세션 갱신#

// middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  let response = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => request.cookies.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value, options }) => {
            request.cookies.set(name, value);
            response.cookies.set(name, value, options);
          });
        },
      },
    }
  );

  // 세션 자동 갱신
  await supabase.auth.getUser();

  return response;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

미들웨어가 모든 요청에서 세션을 갱신해 만료된 토큰 문제를 방지합니다.

첫 번째 데이터 조회: 서버 컴포넌트#

Supabase 대시보드에서 간단한 posts 테이블을 만든 뒤,
서버 컴포넌트에서 데이터를 가져오는 예시입니다.

// app/posts/page.tsx
import { createClient } from "@/utils/supabase/server";

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

  const { data: posts, error } = await supabase
    .from("posts")
    .select("id, title, created_at")
    .order("created_at", { ascending: false });

  if (error) {
    return <p>데이터를 불러오지 못했습니다.</p>;
  }

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

서버 컴포넌트에서 데이터를 직접 조회하므로 useEffect + fetch 패턴 없이 깔끔하게 처리됩니다.

클라이언트 컴포넌트에서 실시간 구독#

상태가 자주 변하는 데이터(채팅·알림 등)는 클라이언트 컴포넌트에서 실시간 구독(Realtime)을 활용합니다.

// app/chat/ChatMessages.tsx
"use client";

import { useEffect, useState } from "react";
import { createClient } from "@/utils/supabase/client";

type Message = { id: number; content: string };

export default function ChatMessages() {
  const [messages, setMessages] = useState<Message[]>([]);
  const supabase = createClient();

  useEffect(() => {
    // 초기 데이터 로드
    supabase
      .from("messages")
      .select("id, content")
      .then(({ data }) => setMessages(data ?? []));

    // 실시간 구독
    const channel = supabase
      .channel("messages")
      .on(
        "postgres_changes",
        { event: "INSERT", schema: "public", table: "messages" },
        (payload) => {
          setMessages((prev) => [...prev, payload.new as Message]);
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, []);

  return (
    <ul>
      {messages.map((m) => (
        <li key={m.id}>{m.content}</li>
      ))}
    </ul>
  );
}

인증: Email/Password + OAuth#

이메일·비밀번호 가입#

"use client";
import { createClient } from "@/utils/supabase/client";

export async function signUp(email: string, password: string) {
  const supabase = createClient();
  const { data, error } = await supabase.auth.signUp({
    email,
    password,
  });
  return { data, error };
}

export async function signIn(email: string, password: string) {
  const supabase = createClient();
  const { data, error } = await supabase.auth.signInWithPassword({
    email,
    password,
  });
  return { data, error };
}

export async function signOut() {
  const supabase = createClient();
  await supabase.auth.signOut();
}

OAuth(Google·GitHub·Kakao)#

const { data, error } = await supabase.auth.signInWithOAuth({
  provider: "google",
  options: {
    redirectTo: `${window.location.origin}/auth/callback`,
  },
});

Kakao·Naver 등 한국 OAuth 제공자도 Supabase Dashboard → Authentication → Providers에서 설정 가능합니다.

OAuth 콜백 처리#

// app/auth/callback/route.ts
import { createClient } from "@/utils/supabase/server";
import { NextResponse } from "next/server";

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get("code");

  if (code) {
    const supabase = await createClient();
    await supabase.auth.exchangeCodeForSession(code);
  }

  return NextResponse.redirect(`${origin}/`);
}

Row Level Security(RLS): 데이터 보호의 핵심#

Supabase는 기본적으로 모든 테이블에 RLS가 활성화되어 있습니다. 정책을 추가하지 않으면 인증된 사용자도 데이터에 접근할 수 없습니다.

기본 RLS 정책#

Supabase Dashboard → Database → Policies에서 SQL로 정책 작성:

-- 누구나 게시글 읽기 가능
CREATE POLICY "Public read access" ON posts
  FOR SELECT USING (true);

-- 작성자만 자신의 글 수정 가능
CREATE POLICY "Users can update own posts" ON posts
  FOR UPDATE USING (auth.uid() = user_id);

-- 인증된 사용자만 글 작성 가능
CREATE POLICY "Authenticated users can insert" ON posts
  FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);

-- 작성자만 삭제 가능
CREATE POLICY "Users can delete own posts" ON posts
  FOR DELETE USING (auth.uid() = user_id);

auth.uid()는 현재 인증된 사용자의 UUID를 반환합니다.

흔한 RLS 실수#

  • 정책 없이 RLS 활성 → 모든 쿼리 빈 결과
  • INSERT 정책 누락 → 가입 후 프로필 생성 실패
  • 같은 정책 중복 등록 → 의도치 않은 권한

Storage: 파일 업로드·다운로드#

// 업로드
const { data, error } = await supabase.storage
  .from("avatars")
  .upload(`${userId}/avatar.png`, file);

// Public URL
const { data: urlData } = supabase.storage
  .from("avatars")
  .getPublicUrl(`${userId}/avatar.png`);

// 다운로드
const { data: downloadData } = await supabase.storage
  .from("avatars")
  .download(`${userId}/avatar.png`);

Storage도 RLS 정책으로 권한 관리. 버킷별로 정책 작성 가능.

Edge Functions: Deno 기반 서버리스#

// supabase/functions/hello/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";

serve(async (req) => {
  const { name } = await req.json();
  return new Response(JSON.stringify({ message: `Hello ${name}!` }), {
    headers: { "Content-Type": "application/json" },
  });
});

배포: supabase functions deploy hello. 웹훅·결제 처리·이미지 변환 등에 활용.

흔한 실수와 트러블슈팅#

1. cookies()await 없이 사용하기#

Next.js 15부터 cookies()가 Promise를 반환합니다. await cookies()로 호출해야 합니다.

2. 서버/클라이언트 클라이언트 혼용#

서버 컴포넌트에서 createBrowserClient를 임포트하면 window is not defined 오류가 납니다. 반드시 파일별로 분리해서 사용합니다.

3. RLS 미설정으로 빈 결과#

// 데이터가 있는데 빈 배열 반환
const { data } = await supabase.from("posts").select("*");
console.log(data);  // []

RLS 정책 누락이 99% 원인. Dashboard에서 SELECT 정책 추가.

4. 환경 변수 이름 오류#

NEXT_PUBLIC_ 접두사가 없으면 클라이언트 번들에 포함되지 않아 브라우저에서 undefined. 반대로 service_role 키에 NEXT_PUBLIC_ 붙이면 보안 사고.

5. select에 컬럼 누락#

// 잘못된 예 — 모든 컬럼 가져오기 (성능 저하·보안 위험)
const { data } = await supabase.from("posts").select("*");

// 권장 — 필요한 컬럼만 명시
const { data } = await supabase.from("posts").select("id, title, created_at");

6. 무한 루프 useEffect#

useEffect(() => {
  supabase.from("posts").select("*").then(setData);
  // supabase 객체가 매 렌더링마다 새로 생성되어 무한 루프
}, [supabase]);

supabase 클라이언트는 useMemo 또는 컴포넌트 외부에서 한 번만 생성:

const supabase = useMemo(() => createClient(), []);

JWT 토큰과 인증 흐름#

Supabase 인증은 내부적으로 JWT를 사용합니다. 발급된 토큰의 클레임·만료 시각을 확인하고 싶다면 JWT 디코더를 활용해보세요.

인증 관련 포스트: JWT란 무엇인가? 구조와 디코딩 방법

자주 묻는 질문#

Q. Supabase는 정말 무료인가요?

A. Free Tier(500MB DB, 1GB 스토리지, 50,000 MAU)는 평생 무료. Pro($25/월)부터는 7일 비활성 일시정지 없음, 더 많은 자원 제공.

Q. Firebase에서 Supabase로 마이그레이션할 수 있나요?

A. 가능합니다. Supabase는 PostgreSQL 기반이라 데이터 모델 차이(NoSQL → SQL)를 처리해야 하지만,
마이그레이션 도구와 가이드가 잘 갖춰져 있습니다.

Q. RLS 정책 디버깅은 어떻게 하나요?

A. SQL Editor에서 SET ROLE authenticated; 후 쿼리 실행으로 정책 효과 확인 가능. 또는 Dashboard의 "Test policies" 기능 활용.

Q. Supabase와 Vercel 배포 시 주의점은?

A. 환경 변수를 Vercel Dashboard → Project Settings → Environment Variables에 동일하게 등록. NEXT_PUBLIC_ 변수는 빌드 시점에 인라인되므로 변경 후 재배포 필요.

Q. PostgreSQL 직접 접근할 수 있나요?

A. 가능합니다. Project Settings → Database에서 connection string 제공. Prisma·Drizzle·다른 PostgreSQL 도구 모두 호환.

다음 단계#

이 가이드에서는 Supabase 연동의 핵심을 다뤘습니다. 실제 앱 개발에서는 다음 순서로 확장합니다.

  • 인증 강화: MFA, Magic Link, Phone Auth. Supabase Auth 공식 문서
  • 타입 자동 생성: npx supabase gen types typescript --project-id xxxx > types.ts
  • 로컬 개발: supabase start로 도커 기반 로컬 환경
  • 실시간 협업: Presence·Broadcast 채널

JWT 디코더로 토큰 확인하기 → | 정규식 가이드 | Base64 가이드


참고: 이 가이드는 Supabase JS v2, Next.js 14/15 App Router 기준입니다. 각 버전의 최신 변경사항은 공식 문서에서 확인하세요.

이런 글도 읽어보세요

전체 글 보기

관련 도구