본문 바로가기

React

React와 Supabase로 풀스택 앱 구축하기

Supabase는 Postgres 기반 인증, 스토리지, 리얼타임을 제공하는 BaaS입니다. React와 결합하면 서버 없이도 보안이 적용된 풀스택 앱을 빠르게 구축할 수 있습니다. 본 글은 최소 구성으로 인증, CRUD, 리얼타임까지 연결하는 실무 중심 가이드입니다.

1. 프로젝트 부트스트랩

Vite로 React + TypeScript 프로젝트를 생성하고 필요한 패키지를 설치합니다.

# 프로젝트 생성
npm create vite@latest react-supa-app -- --template react-ts
cd react-supa-app

# 라이브러리 설치
npm i @supabase/supabase-js @tanstack/react-query react-router-dom

2. Supabase 프로젝트와 테이블 준비

Supabase 대시보드에서 새 프로젝트를 생성합니다. Project Settings > API에서 Project URL과 anon public key를 확인합니다.

테이블 예시: todos(id uuid PK, user_id uuid, title text, done boolean default false, inserted_at timestamp default now()). RLS(Row Level Security)를 활성화하고 아래 정책을 만듭니다.

- SELECT: user_id = auth.uid()
- INSERT: user_id = auth.uid()
- UPDATE/DELETE: user_id = auth.uid()

이렇게 하면 클라이언트 키(anon)로도 현재 사용자 데이터만 접근 가능합니다.

3. 환경 변수 설정

Vite 환경 변수로 Supabase URL과 Key를 저장합니다.

# .env.local
VITE_SUPABASE_URL=https://YOUR-PROJECT.supabase.co
VITE_SUPABASE_ANON_KEY=eyJ...YOUR_ANON_KEY...

4. Supabase 클라이언트 초기화

공용 클라이언트를 생성합니다.

// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js'

export const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_ANON_KEY
)

5. 인증 상태 관리

Context로 사용자 세션을 전역 관리하고 이메일 매직링크/깃허브 OAuth 로그인 UI를 만듭니다.

// src/auth/AuthProvider.tsx
import { createContext, useContext, useEffect, useState } from 'react'
import { supabase } from '../lib/supabase'

const AuthContext = createContext({ user: null })

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null)

  useEffect(() => {
    let mounted = true
    supabase.auth.getSession().then(({ data }) => {
      if (mounted) setUser(data.session?.user ?? null)
    })
    const { data: sub } = supabase.auth.onAuthStateChange((_event, session) => {
      setUser(session?.user ?? null)
    })
    return () => {
      mounted = false
      sub.subscription.unsubscribe()
    }
  }, [])

  return <AuthContext.Provider value={{ user }}>{children}</AuthContext.Provider>
}

export function useAuth() {
  return useContext(AuthContext)
}
// src/pages/Login.tsx
import { supabase } from '../lib/supabase'

export default function Login() {
  async function handleEmail(e) {
    e.preventDefault()
    const email = new FormData(e.currentTarget).get('email')
    const { error } = await supabase.auth.signInWithOtp({ email })
    if (error) alert(error.message)
    else alert('이메일로 로그인 링크를 보냈습니다')
  }
  async function handleGithub() {
    const { error } = await supabase.auth.signInWithOAuth({ provider: 'github' })
    if (error) alert(error.message)
  }
  return (
    <div>
      <form onSubmit={handleEmail}>
        <input name='email' type='email' placeholder='you@example.com' required />
        <button type='submit'>Email 로그인</button>
      </form>
      <button onClick={handleGithub}>GitHub 로그인</button>
    </div>
  )
}

6. 라우팅과 보호 라우트

로그인이 필요한 화면을 보호합니다.

// src/auth/ProtectedRoute.tsx
import { Navigate } from 'react-router-dom'
import { useAuth } from './AuthProvider'

export default function ProtectedRoute({ children }) {
  const { user } = useAuth()
  return user ? children : <Navigate to='/login' replace />
}
// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider } from './auth/AuthProvider'
import ProtectedRoute from './auth/ProtectedRoute'
import Login from './pages/Login'
import Todos from './pages/Todos'

const queryClient = new QueryClient()

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <BrowserRouter>
          <Routes>
            <Route path='/login' element={<Login />} />
            <Route
              path='/'
              element={
                <ProtectedRoute>
                  <Todos />
                </ProtectedRoute>
              }
            />
          </Routes>
        </BrowserRouter>
      </AuthProvider>
    </QueryClientProvider>
  </React.StrictMode>
)

7. CRUD와 React Query

사용자별 투두 목록을 조회/추가/수정/삭제합니다. RLS 정책으로 보안이 적용됩니다.

// src/pages/Todos.tsx
import { useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../auth/AuthProvider'
import { supabase } from '../lib/supabase'

function useTodos(userId) {
  return useQuery({
    queryKey: ['todos', userId],
    queryFn: async () => {
      const { data, error } = await supabase
        .from('todos')
        .select('*')
        .eq('user_id', userId)
        .order('inserted_at', { ascending: false })
      if (error) throw error
      return data
    },
    enabled: !!userId
  })
}

export default function Todos() {
  const { user } = useAuth()
  const qc = useQueryClient()
  const { data: todos = [] } = useTodos(user?.id)

  const add = useMutation({
    mutationFn: async (title) => {
      const { error } = await supabase.from('todos').insert({ title, user_id: user.id })
      if (error) throw error
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['todos', user.id] })
  })

  const toggle = useMutation({
    mutationFn: async (todo) => {
      const { error } = await supabase
        .from('todos')
        .update({ done: !todo.done })
        .eq('id', todo.id)
        .eq('user_id', user.id)
      if (error) throw error
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['todos', user.id] })
  })

  const remove = useMutation({
    mutationFn: async (id) => {
      const { error } = await supabase
        .from('todos')
        .delete()
        .eq('id', id)
        .eq('user_id', user.id)
      if (error) throw error
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['todos', user.id] })
  })

  useEffect(() => {
    if (!user?.id) return
    const channel = supabase
      .channel('public:todos')
      .on(
        'postgres_changes',
        { event: '*', schema: 'public', table: 'todos', filter: `user_id=eq.${user.id}` },
        () => {
          qc.invalidateQueries({ queryKey: ['todos', user.id] })
        }
      )
      .subscribe()
    return () => {
      supabase.removeChannel(channel)
    }
  }, [user?.id, qc])

  return (
    <div>
      <h1>Todos</h1>
      <form
        onSubmit={(e) => {
          e.preventDefault()
          const title = new FormData(e.currentTarget).get('title')
          if (typeof title === 'string' && title.trim()) add.mutate(title.trim())
          e.currentTarget.reset()
        }}
      >
        <input name='title' placeholder='할 일을 입력하세요' />
        <button type='submit'>추가</button>
      </form>
      <ul>
        {todos.map((t) => (
          <li key={t.id}>
            <label>
              <input type='checkbox' checked={!!t.done} onChange={() => toggle.mutate(t)} />
              {t.title}
            </label>
            <button onClick={() => remove.mutate(t.id)}>삭제</button>
          </li>
        ))}
      </ul>
      <button onClick={() => supabase.auth.signOut()}>로그아웃</button>
    </div>
  )
}

8. 리얼타임 동기화

위 예시는 Postgres Changes 프로토콜로 todos 테이블의 변경을 구독합니다. 필터에 user_id=eq.{auth.uid}를 적용하여 본인 데이터만 갱신합니다. 대량 업데이트가 잦다면 invalidate 대신 캐시를 부분 갱신하도록 최적화할 수 있습니다.

9. 배포 체크리스트

- Vercel/Netlify 환경 변수에 VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY를 등록합니다.
- Supabase Authentication에서 Redirect URLs에 배포 도메인을 추가합니다.
- RLS 활성화와 정책을 재확인합니다. 서비스 키를 클라이언트에 노출하지 않습니다.
- 인덱스 최적화: todos(user_id, inserted_at desc) 조합 인덱스를 추가하면 조회 속도가 안정화됩니다.
- 에러 로깅: supabase-js 에러를 중앙에서 핸들링해 사용자 메시지와 로깅을 분리합니다.

10. 마무리

React와 Supabase 조합은 최소 설정으로 인증, 데이터, 리얼타임까지 제공하여 MVP를 빠르게 검증하기에 최적입니다. 위 스캐폴드에서 컴포넌트 분리, 타입 보강, 캐시 최적화만 추가하면 프로덕션 수준의 풀스택 앱으로 확장할 수 있습니다.