React Router v7으로 시작하는 풀스택 웹 개발 (3부): 데이터베이스와 고급 기능

2025년 9월 28일Frontend30분 읽기

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에서 뷰 실행

  1. Supabase Dashboard → SQL Editor 접속
  2. 작성한 SQL 파일 내용을 복사해서 실행
  3. 뷰가 정상 생성되었는지 확인

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 시리즈를 통해 기본 패턴부터 고급 기능까지 살펴봤습니다. 핵심은 서버사이드의 안전함클라이언트사이드의 반응성을 적절히 조합하는 것입니다.

핵심 원칙 요약

  1. Loader/Action 패턴: 서버에서 안전하게 데이터 처리
  2. Zod 검증: 모든 입력에 타입 안전 검증 적용
  3. 적절한 권한 관리: RLS 정책과 인증 확인
  4. 사용자 경험 최적화: Fetcher와 Optimistic UI 활용
  5. 시스템 보안: adminClient는 신중하게 사용

다음 단계

  • 배포: Vercel을 통한 프로덕션 배포
  • 모니터링: 에러 추적 및 성능 모니터링 도구 도입
  • 확장: 마이크로서비스 아키텍처로의 확장 고려

React Router v7으로 만드는 현대적인 웹 애플리케이션, 이제 여러분의 아이디어를 현실로 만들어보세요! 🚀


💡 최종 요약: 뷰로 복잡한 쿼리를 단순화하고, RLS로 보안을 강화하며, AI를 안전하게 통합하고, adminClient는 신중하게 사용하면 됩니다!