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를 빠르게 검증하기에 최적입니다. 위 스캐폴드에서 컴포넌트 분리, 타입 보강, 캐시 최적화만 추가하면 프로덕션 수준의 풀스택 앱으로 확장할 수 있습니다.
'React' 카테고리의 다른 글
| React에서 커스텀 렌더러 구현하기 (0) | 2026.06.02 |
|---|---|
| React 앱에서 파일 다운로드 및 저장 처리하기 (0) | 2026.06.01 |
| React로 사용자 맞춤형 대시보드 구현하기 (0) | 2026.06.01 |
| React에서 비동기 데이터 다중 병합 처리하기 (0) | 2026.05.30 |
| React 앱에서 이미지 필터 효과 적용하기 (0) | 2026.05.30 |