React Router v7으로 시작하는 풀스택 웹 개발 (3부): 데이터베이스와 고급 기능
React Router v7으로 시작하는 풀스택 웹 개발 (3부): 데이터베이스와 고급 기능
복잡한 데이터 관계부터 AI 통합까지, 실무에서 바로 쓸 수 있는 고급 패턴들
시리즈의 마지막 편에서는 실제 프로덕션 환경에서 마주하게 되는 고급 주제들을 다룹니다. 복잡한 데이터베이스 관계 처리부터 AI API 통합, 시스템 레벨 작업까지 - 진짜 서비스를 만들 때 필요한 실무 패턴들을 알아보겠습니다.
🔗 데이터베이스 뷰 생성 및 타입 관리
실제 서비스에서는 여러 테이블을 조인한 복잡한 쿼리를 자주 사용하게 됩니다. 메시지, 댓글, 사용자 정보를 함께 보여주는 기능을 예로 들어보겠습니다.
뷰를 사용하는 이유
1-- ❌ 매번 복잡한 조인 쿼리 작성2SELECT3 m.message_id,4 m.content,5 m.created_at,6 p.username,7 p.full_name,8 p.avatar_url,9 r.name as room_name10FROM messages m11LEFT JOIN profiles p ON m.profile_id = p.profile_id12LEFT JOIN rooms r ON m.room_id = r.room_id13ORDER BY m.created_at DESC;14 15-- ✅ 뷰로 한 번 정의하고 재사용16SELECT * FROM messages_view17WHERE room_id = $118ORDER BY created_at DESC;
단계별 뷰 생성 과정
1. SQL 뷰 파일 작성
1-- app/sql/views/messages_view.sql2CREATE OR REPLACE VIEW messages_view AS3SELECT4 m.message_id,5 m.content,6 m.created_at,7 m.updated_at,8 m.room_id,9 m.profile_id,10 -- 프로필 정보 조인11 p.username,12 p.full_name,13 p.avatar_url,14 -- 룸 정보 조인15 r.name as room_name,16 r.description as room_description,17 -- 추가 계산 필드18 (SELECT COUNT(*) FROM message_reactions mr WHERE mr.message_id = m.message_id) as reaction_count19FROM messages m20LEFT JOIN profiles p ON m.profile_id = p.profile_id21LEFT JOIN rooms r ON m.room_id = r.room_id22ORDER BY m.created_at DESC;
2. Supabase에서 뷰 실행
- Supabase Dashboard → SQL Editor 접속
- 작성한 SQL 파일 내용을 복사해서 실행
- 뷰가 정상 생성되었는지 확인
3. 타입 생성 및 수정
1# 데이터베이스 스키마에서 타입 생성2npm run db:typegen
🚨 중요: db:typegen 실행 후 뷰의 모든 필드가 nullable로 생성됩니다. 이는 JOIN으로 인한 NULL 가능성 때문입니다.
4. supa-client.ts에서 타입 수정
뷰의 실제 데이터 구조에 맞게 필수 필드를 지정해야 합니다:
1// app/supa-client.ts2export type Database = {3 // ... 기존 타입들4 5 Views: {6 messages_view: {7 Row: {8 // 자동 생성된 타입 (모두 nullable)9 // message_id: string | null ❌10 // content: string | null ❌11 // username: string | null ❌12 // 수동 수정한 정확한 타입13 message_id: string // ✅ 항상 존재14 content: string // ✅ 항상 존재15 created_at: string // ✅ 항상 존재16 updated_at: string | null // ✅ 실제로 null 가능17 room_id: string // ✅ 항상 존재18 profile_id: string // ✅ 항상 존재19 username: string // ✅ 항상 존재 (프로필은 필수)20 full_name: string // ✅ 항상 존재21 avatar_url: string | null // ✅ 실제로 null 가능22 room_name: string // ✅ 항상 존재23 room_description: string | null // ✅ 실제로 null 가능24 reaction_count: number // ✅ COUNT()는 항상 숫자25 }26 }27 }28}
실제 사용 예제
1// app/features/messages/queries.ts2export const getMessagesWithProfiles = async (3 client: SupabaseClient<Database>,4 roomId: string,5) => {6 const { data, error } = await client7 .from('messages_view')8 .select('*')9 .eq('room_id', roomId)10 .order('created_at', { ascending: false })11 .limit(50)12 13 if (error) throw error14 15 // 타입이 정확하므로 안전하게 사용 가능16 return data.map((message) => ({17 id: message.message_id,18 content: message.content,19 createdAt: new Date(message.created_at),20 author: {21 username: message.username, // string (null 체크 불필요!)22 fullName: message.full_name, // string (null 체크 불필요!)23 avatar: message.avatar_url, // string | null24 },25 room: {26 name: message.room_name, // string (null 체크 불필요!)27 description: message.room_description, // string | null28 },29 reactionCount: message.reaction_count, // number30 }))31}32 33// 페이지에서 사용34export const loader = async ({ request, params }: Route.LoaderArgs) => {35 const { client } = makeSSRClient(request)36 const userId = await getLoggedInUserId(client)37 38 const messages = await getMessagesWithProfiles(client, params.roomId)39 40 return { messages }41}
뷰 관리 체크리스트
-
app/sql/views/[name].sql파일 작성 - Supabase Dashboard에서 SQL 실행
-
npm run db:typegen실행 -
app/supa-client.ts에서 Views 타입 확인 - nullable로 생성된 필드 중 실제 필수인 필드를
string으로 수정 - 실제로 null 가능한 필드는
string | null유지 - queries.ts에서 뷰 사용하여 타입 검증
🛡 RLS 정책과 auth 함수 활용하기
Supabase의 Row Level Security(RLS)는 데이터베이스 레벨에서 보안을 강화하는 핵심 기능입니다.
기본 auth 함수들
1-- 현재 인증된 사용자 ID2auth.uid()3 4-- JWT 토큰 전체 내용5auth.jwt()6 7-- 현재 사용자 역할8auth.role()9 10-- 현재 사용자 이메일11auth.email()
Drizzle에서 RLS 정책 정의
1// app/features/community/schema.ts2export const posts = pgTable(3 'posts',4 {5 post_id: bigint({ mode: 'number' })6 .primaryKey()7 .generatedAlwaysAsIdentity(),8 title: text().notNull(),9 content: text().notNull(),10 user_id: uuid().notNull(),11 is_published: boolean().notNull().default(false),12 created_at: timestamp().notNull().defaultNow(),13 },14 (table) => [15 // SELECT 정책: 공개된 게시글만 조회 가능16 pgPolicy('posts-select-published', {17 for: 'select',18 to: 'public',19 as: 'permissive',20 using: sql`${table.is_published} = true`,21 }),22 23 // SELECT 정책: 작성자는 자신의 모든 게시글 조회 가능24 pgPolicy('posts-select-own', {25 for: 'select',26 to: authenticatedRole,27 as: 'permissive',28 using: sql`auth.uid() = ${table.user_id}`,29 }),30 31 // INSERT 정책: 인증된 사용자만 작성 가능, 자신의 ID로만32 pgPolicy('posts-insert-policy', {33 for: 'insert',34 to: authenticatedRole,35 as: 'permissive',36 withCheck: sql`auth.uid() = ${table.user_id}`,37 }),38 39 // UPDATE 정책: 작성자만 수정 가능40 pgPolicy('posts-update-own', {41 for: 'update',42 to: authenticatedRole,43 as: 'permissive',44 using: sql`auth.uid() = ${table.user_id}`,45 withCheck: sql`auth.uid() = ${table.user_id}`,46 }),47 ],48)
고급 RLS 패턴
1export const comments = pgTable(2 'comments',3 {4 comment_id: bigint({ mode: 'number' })5 .primaryKey()6 .generatedAlwaysAsIdentity(),7 post_id: bigint({ mode: 'number' }).notNull(),8 user_id: uuid().notNull(),9 content: text().notNull(),10 created_at: timestamp().notNull().defaultNow(),11 },12 (table) => [13 // 복합 조건: 댓글은 공개된 게시글에만 작성 가능14 pgPolicy('comments-insert-on-published-posts', {15 for: 'insert',16 to: authenticatedRole,17 as: 'permissive',18 withCheck: sql`19 auth.uid() = ${table.user_id} AND20 EXISTS (21 SELECT 1 FROM posts 22 WHERE posts.post_id = ${table.post_id} 23 AND posts.is_published = true24 )25 `,26 }),27 28 // 시간 기반 정책: 24시간 내 댓글만 수정 가능29 pgPolicy('comments-update-within-24h', {30 for: 'update',31 to: authenticatedRole,32 as: 'permissive',33 using: sql`34 auth.uid() = ${table.user_id} AND35 ${table.created_at} > NOW() - INTERVAL '24 hours'36 `,37 }),38 ],39)
권한 vs 데이터 검증의 차이
1-- USING: "누가" 이 데이터에 접근할 수 있는가? (권한)2CREATE POLICY "user_access_control" ON posts3 FOR SELECT USING (auth.uid() = user_id);4 5-- WITH CHECK: "어떤" 데이터가 저장될 수 있는가? (데이터 검증)6CREATE POLICY "data_validation" ON posts7 FOR INSERT WITH CHECK (8 LENGTH(title) >= 5 AND -- 제목 길이 검증9 category IN ('tech', 'business') AND -- 허용된 카테고리10 auth.uid() = user_id -- 권한 검증11 );
🤖 AI API 통합하기 (OpenAI)
React Router v7의 서버사이드 환경에서 AI를 안전하게 활용하는 방법을 알아보겠습니다.
기본 설정 및 Zod 검증
1// app/features/ideas/pages/generate-idea-page.tsx2import OpenAI from 'openai'3import { z } from 'zod'4import { adminClient } from '~/supa-client'5import type { Route } from './+types/generate-idea-page'6 7// OpenAI 인스턴스 생성 (서버 전용)8const openai = new OpenAI({9 apiKey: process.env.OPENAI_API_KEY, // 환경변수로만 관리10})11 12// AI 응답 스키마 정의 (매우 중요!)13const IdeaSchema = z.object({14 title: z.string().min(5).max(100),15 description: z.string().min(10).max(200),16 problem: z.string().min(10),17 solution: z.string().min(10),18 category: z.enum([19 'tech',20 'business',21 'health',22 'education',23 'finance',24 'other',25 ]),26})27 28const ResponseSchema = z.object({29 ideas: z.array(IdeaSchema).length(10), // 정확히 10개30})31 32// Loader에서 AI API 호출33export const loader = async ({ request }: Route.LoaderArgs) => {34 try {35 const completion = await openai.chat.completions.create({36 model: 'gpt-4o', // Structured Output 지원 모델37 messages: [38 {39 role: 'system',40 content: `당신은 전문 스타트업 어드바이저입니다. 창의적이지만 현실적인 비즈니스 아이디어를 생성해주세요.41 42 규칙:43 - 2-3명이 구축 가능한 아이디어여야 함44 - 실제 사람들이 겪는 문제에 집중45 - 구체적인 해결책 제시, 추상적 개념 금지46 - 명확한 가치 제안 포함47 48 JSON 스키마에 맞춰 정확히 응답하세요.`,49 },50 {51 role: 'user',52 content:53 '한국 시장에 적합한 스타트업 아이디어 10개를 한국어로 생성해주세요.',54 },55 ],56 // Structured Output으로 정확한 JSON 형식 보장57 response_format: {58 type: 'json_schema',59 json_schema: {60 name: 'startup-ideas-response',61 strict: true,62 schema: z.toJSONSchema(ResponseSchema, { target: 'draft-7' }),63 },64 },65 })66 67 const content = completion.choices[0]?.message?.content68 if (!content) {69 return Response.json({ error: 'AI 응답 생성 실패' }, { status: 500 })70 }71 72 // AI 응답을 Zod로 엄격하게 검증73 let parsedContent74 try {75 parsedContent = JSON.parse(content)76 } catch (e) {77 return Response.json({ error: 'AI 응답 JSON 파싱 실패' }, { status: 500 })78 }79 80 const validationResult = ResponseSchema.safeParse(parsedContent)81 if (!validationResult.success) {82 console.error('AI 응답 검증 실패:', validationResult.error)83 return Response.json(84 { error: 'AI 응답이 예상 형식과 다릅니다' },85 { status: 500 },86 )87 }88 89 const { ideas } = validationResult.data90 91 // adminClient로 시스템 데이터 저장92 await insertIdeas(adminClient, {93 ideas: ideas.map((idea) => idea.description),94 })95 96 return Response.json({97 success: true,98 generatedCount: ideas.length,99 ideas,100 })101 } catch (error) {102 console.error('OpenAI API 호출 실패:', error)103 return Response.json(104 {105 error:106 'AI 서비스에 일시적인 문제가 있습니다. 잠시 후 다시 시도해주세요.',107 },108 { status: 500 },109 )110 }111}
Action에서 폼과 AI 연동
1const promptSchema = z.object({2 topic: z.string().min(1, '주제를 입력해주세요'),3 style: z.enum(['formal', 'casual', 'creative'], {4 errorMap: () => ({ message: '스타일을 선택해주세요' }),5 }),6 length: z.enum(['short', 'medium', 'long']),7})8 9export const action = async ({ request }: Route.ActionArgs) => {10 const { client } = makeSSRClient(request)11 const userId = await getLoggedInUserId(client)12 13 // 폼 데이터 검증 먼저14 const formData = await request.formData()15 const formResult = promptSchema.safeParse(Object.fromEntries(formData))16 17 if (!formResult.success) {18 return { fieldErrors: formResult.error.flatten().fieldErrors }19 }20 21 const { topic, style, length } = formResult.data22 23 try {24 // 사용자 맞춤 콘텐츠 생성25 const completion = await openai.chat.completions.create({26 model: 'gpt-4o-mini', // 비용 효율적인 모델27 messages: [28 {29 role: 'system',30 content: `You are a professional ${style} writer. Create ${length} content about the given topic. Write in Korean.`,31 },32 {33 role: 'user',34 content: topic,35 },36 ],37 max_tokens: length === 'short' ? 200 : length === 'medium' ? 500 : 1000,38 })39 40 const generatedContent = completion.choices[0]?.message?.content41 if (!generatedContent) {42 return {43 formError: 'AI가 콘텐츠를 생성하지 못했습니다. 다시 시도해주세요.',44 }45 }46 47 // 사용자 데이터에 저장 (인증된 클라이언트 사용)48 await saveGeneratedContent(client, {49 userId,50 topic,51 content: generatedContent,52 style,53 length,54 })55 56 return redirect('/generated-content')57 } catch (error) {58 console.error('AI 콘텐츠 생성 오류:', error)59 return {60 formError:61 'AI 서비스 호출 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',62 }63 }64}
AI 최적화 팁
1// 1. 환경별 모델 선택2const getOptimalModel = () => {3 if (process.env.NODE_ENV === 'development') {4 return 'gpt-4o-mini' // 개발 시 비용 절약5 }6 return 'gpt-4o' // 프로덕션은 품질 우선7}8 9// 2. 토큰 사용량 최적화10const OptimizedSchema = z.object({11 title: z.string().max(50), // 토큰 절약을 위한 길이 제한12 summary: z.string().max(200),13 keyPoints: z.array(z.string().max(100)).max(5),14})15 16// 3. 캐싱으로 중복 호출 방지17const cacheKey = `ai-response:${hashSync(prompt)}`18const cached = await redis.get(cacheKey)19 20if (cached) {21 return JSON.parse(cached)22}23 24const aiResponse = await openai.chat.completions.create(...)25await redis.setex(cacheKey, 3600, JSON.stringify(aiResponse)) // 1시간 캐싱
🔐 Admin Client 사용 가이드
adminClient는 모든 RLS 정책을 우회하는 강력한 권한을 가진 클라이언트입니다.
안전한 사용 원칙
1// app/supa-client.ts2export const adminClient = createClient<Db>(3 process.env.SUPABASE_URL!,4 process.env.SUPABASE_SERVICE_ROLE_KEY!, // ⚠️ 강력한 권한5)
✅ 사용해야 하는 경우:
1// 1. 시스템 레벨 작업 (AI 생성 데이터 일괄 삽입)2export const loader = async () => {3 const aiIdeas = await generateIdeasWithAI()4 await insertIdeas(adminClient, { ideas: aiIdeas }) // ✅ 시스템 작업5 return { ok: true }6}7 8// 2. 관리자 전용 기능 (사용자 관리)9export const action = async ({ request }: Route.ActionArgs) => {10 const { client } = makeSSRClient(request)11 const userId = await getLoggedInUserId(client)12 13 // 반드시 권한 검증 먼저!14 const { data: user } = await client15 .from('profiles')16 .select('role')17 .eq('profile_id', userId)18 .single()19 20 if (user?.role !== 'admin') {21 throw new Response('Forbidden', { status: 403 })22 }23 24 // 검증 후 adminClient 사용25 await adminClient26 .from('users')27 .update({28 status: 'banned',29 })30 .eq('id', targetUserId)31}32 33// 3. 배치 작업 (만료 데이터 정리)34export const cleanupExpiredSessions = async () => {35 const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)36 37 await adminClient38 .from('user_sessions')39 .delete()40 .lt('created_at', oneDayAgo.toISOString())41}
❌ 절대 사용하면 안 되는 경우:
1// ❌ 일반 사용자 CRUD 작업2export const action = async ({ request }: Route.ActionArgs) => {3 // 잘못된 방법 - RLS 우회하여 보안 위험4 await adminClient.from('posts').insert({5 title: formData.get('title'),6 user_id: formData.get('userId'), // ⚠️ 다른 사용자 ID 조작 가능7 })8 9 // ✅ 올바른 방법 - 사용자 인증된 클라이언트 사용10 const { client } = makeSSRClient(request)11 const userId = await getLoggedInUserId(client)12 13 await client.from('posts').insert({14 title: formData.get('title'),15 user_id: userId, // ✅ 현재 사용자로만 제한16 })17}
관리자 대시보드 구현
1// app/features/admin/pages/dashboard.tsx2export const loader = async ({ request }: Route.LoaderArgs) => {3 const { client } = makeSSRClient(request)4 const userId = await getLoggedInUserId(client)5 6 // 1단계: 관리자 권한 확인7 const { data: profile } = await client8 .from('profiles')9 .select('role')10 .eq('profile_id', userId)11 .single()12 13 if (profile?.role !== 'admin') {14 throw redirect('/login?error=unauthorized')15 }16 17 // 2단계: adminClient로 전체 데이터 접근18 const [{ data: allUsers }, { data: systemStats }, { data: reportedContent }] =19 await Promise.all([20 adminClient21 .from('profiles')22 .select('*')23 .order('created_at', { ascending: false }),24 adminClient.from('system_analytics').select('*'),25 adminClient.from('content_reports').select('*').eq('status', 'pending'),26 ])27 28 return {29 users: allUsers,30 stats: systemStats,31 reports: reportedContent,32 }33}
📋 데이터베이스 명령어 완전 가이드
실제 워크플로우
1# 새로운 기능 개발 시2# 1. 스키마 정의 (schema.ts 수정)3# 2. 마이그레이션 생성4npm run db:generate5 6# 3. 데이터베이스에 적용7npm run db:migrate8 9# 4. 타입 업데이트10npm run db:typegen11 12# 5. 필요시 supa-client.ts에서 뷰 타입 수정
명령어별 상세 역할
npm run db:generate
- Drizzle 스키마 변경사항을 SQL 마이그레이션 파일로 변환
drizzle/폴더에 타임스탬프 포함 마이그레이션 생성
npm run db:migrate
- 생성된 마이그레이션을 실제 Supabase 데이터베이스에 적용
- 운영 환경에서는 신중하게 실행
npm run db:typegen
- Supabase 데이터베이스 구조를 읽어 TypeScript 타입 생성
- 뷰 테이블은 nullable로 생성되므로 수동 수정 필요
트러블슈팅
1# 마이그레이션 상태 확인2npx drizzle-kit status3 4# 마이그레이션 롤백 (신중히)5npx drizzle-kit rollback6 7# 타입 강제 재생성8rm database.types.ts && npm run db:typegen
🌟 실무 팁과 베스트 프랙티스
보안 체크리스트
- 환경변수: API 키, 데이터베이스 연결 정보 등 환경변수로만 관리
- RLS 정책: 모든 테이블에 적절한 보안 정책 설정
- 입력 검증: 모든 사용자 입력에 대해 Zod 검증 적용
- 권한 확인: adminClient 사용 전 항상 권한 검증
- 에러 처리: 민감한 정보가 클라이언트에 노출되지 않도록 주의
성능 최적화
1// 1. 데이터베이스 쿼리 최적화2const getPostsWithAuthors = async (client: SupabaseClient, limit = 10) => {3 return client4 .from('posts_view') // 뷰 사용으로 조인 최적화5 .select('*')6 .order('created_at', { ascending: false })7 .limit(limit) // 페이지네이션8 9// 2. 병렬 처리 활용10const loader = async ({ request }: Route.LoaderArgs) => {11 const [posts, categories, userStats] = await Promise.all([12 getPosts(client),13 getCategories(client),14 getUserStats(client, userId),15 ])16 17 return { posts, categories, userStats }18}19 20// 3. 적절한 캐싱21const getCachedCategories = async () => {22 const cacheKey = 'categories'23 const cached = await redis.get(cacheKey)24 25 if (cached) return JSON.parse(cached)26 27 const categories = await getCategories()28 await redis.setex(cacheKey, 3600, JSON.stringify(categories))29 return categories30}
에러 처리 패턴
1// 포괄적 에러 처리2export const action = async ({ request }: Route.ActionArgs) => {3 try {4 const { client } = makeSSRClient(request)5 const userId = await getLoggedInUserId(client)6 7 // 비즈니스 로직...8 } catch (error) {9 if (error instanceof Error) {10 console.error('Action 에러:', error.message)11 12 // 사용자에게는 안전한 메시지만 노출13 return {14 formError: '처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',15 }16 }17 18 throw error // 예상치 못한 에러는 재발생19 }20}
마무리
React Router v7 시리즈를 통해 기본 패턴부터 고급 기능까지 살펴봤습니다. 핵심은 서버사이드의 안전함과 클라이언트사이드의 반응성을 적절히 조합하는 것입니다.
핵심 원칙 요약
- Loader/Action 패턴: 서버에서 안전하게 데이터 처리
- Zod 검증: 모든 입력에 타입 안전 검증 적용
- 적절한 권한 관리: RLS 정책과 인증 확인
- 사용자 경험 최적화: Fetcher와 Optimistic UI 활용
- 시스템 보안: adminClient는 신중하게 사용
다음 단계
- 배포: Vercel을 통한 프로덕션 배포
- 모니터링: 에러 추적 및 성능 모니터링 도구 도입
- 확장: 마이크로서비스 아키텍처로의 확장 고려
React Router v7으로 만드는 현대적인 웹 애플리케이션, 이제 여러분의 아이디어를 현실로 만들어보세요! 🚀
💡 최종 요약: 뷰로 복잡한 쿼리를 단순화하고, RLS로 보안을 강화하며, AI를 안전하게 통합하고, adminClient는 신중하게 사용하면 됩니다!