[인프런 워밍업 클럽 Full Stack 3기] 1주차 TODO
Next.js 기본 개념 정리 (Part 1)Next.js란?“개인이 풀스택 개발을 하기에 최적화된 웹 프레임워크”서버사이드 렌더링(SSR) 지원 → SEO(검색 최적화) 강점별도 서버 구축 없이 API까지 개발 가능Next.js 프로젝트 생성npx create-next-app@latest [프로젝트명] 폴더 구조 및 주요 파일폴더 구조 = Route(URL) 구조와 동일app/movies → /moviesapp/movies/[id] → /movies/1 (Dynamic Route)주요 파일page.tsx(js): 해당 경로의 페이지 컴포넌트layout.tsx(js): 페이지 레이아웃 관리route.ts(js): API 서버 역할 (GET, POST, PUT, DELETE 등 구현 가능)Link 사용a 태그 대신 Link 컴포넌트 사용 권장 (클라이언트 사이드 라우팅 최적화)import Link from 'next/link'; export default function Page() { return <Link href="/dashboard">Dashboard</Link>; } Next.js Part 2 - Metadata & Server ActionServer Action이란?Next.js에서 백엔드 API 없이 서버에서 직접 데이터 처리 가능기존 fetch + REST API 방식보다 간단하고 빠름Server Action 사용 예시유저 검색 기능을 구현할 때 두 가지 방식 비교기존 방식 → fetch + REST API개선 방식 → Server Action 활용Metadata (SEO 및 공유성 강화)Next.js는 SEO 및 링크 미리보기를 위해 메타데이터 API 제공Static Metadata (정적 메타데이터 설정)import type { Metadata } from 'next' export const metadata: Metadata = { title: '페이지 제목', description: '페이지 설명', } export default function Page() {} Dynamic Metadata (동적 메타데이터 설정)import type { Metadata, ResolvingMetadata } from 'next' type Props = { params: { id: string } searchParams: { [key: string]: string | string[] | undefined } } export async function generateMetadata( { params }: Props, parent: ResolvingMetadata ): Promise<Metadata> { const id = params.id const product = await fetch(`https://.../${id}`).then((res) => res.json()) const previousImages = (await parent).openGraph?.images || [] return { title: product.title, openGraph: { images: ['/some-specific-page-image.jpg', ...previousImages], }, } } export default function Page({ params, searchParams }: Props) {} Next.js Part 3 - Supabase 프로젝트 생성1. Supabase 가입 및 프로젝트 설정Supabase 회원가입 후 대시보드 접속새 프로젝트 생성 → 프로젝트명: inflearn-supabase-projects테이블 생성 (todo 테이블)title (text)completed (boolean)created_at (timestampz)updated_at (timestampz)2. .env 파일 설정NEXT_PUBLIC_SUPABASE_URL=https://[project_id].supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=[anon_key] NEXT_SUPABASE_SERVICE_ROLE=[service_role_key] NEXT_SUPABASE_DB_PASSWORD=[db_password] 3. package.json 수정 (타입 자동 생성 스크립트 추가)"scripts": { "generate-types": "npx supabase gen types typescript --project-id [project_id] --schema public > types_db.ts" } 4. Supabase CLI 설치 및 로그인npx supabase loginNext.js Part 4 - 필수 라이브러리 설정 (React Query, Supabase)1. 필수 라이브러리 설치npm i @supabase/ssr @tanstack/react-query 2. React Query 설정config/ReactQueryClientProvider.tsx"use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; export const queryClient = new QueryClient({}); export default function ReactQueryClientProvider({ children, }: React.PropsWithChildren) { return ( <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ); } app/layout.tsximport ReactQueryClientProvider from "config/ReactQueryClientProvider"; export default function RootLayout({ children }) { return ( <ReactQueryClientProvider> ... </ReactQueryClientProvider> ); } 3. Supabase 설정환경 변수 설정 (.env)NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_SUPABASE_SERVICE_ROLE= NEXT_SUPABASE_DB_PASSWORD= utils/supabase/client.ts (브라우저용 Supabase 클라이언트)"use client"; import { createBrowserClient } from "@supabase/ssr"; export const createBrowserSupabaseClient = () => createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); utils/supabase/server.ts (서버용 Supabase 클라이언트)"use server"; import { createServerClient, type CookieOptions } from "@supabase/ssr"; import { cookies } from "next/headers"; import { Database } from "types_db"; export const createServerSupabaseClient = async ( cookieStore: ReturnType<typeof cookies> = cookies(), admin: boolean = false ) => { return createServerClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, admin ? process.env.NEXT_SUPABASE_SERVICE_ROLE! : process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { get(name: string) { return cookieStore.get(name)?.value; }, set(name: string, value: string, options: CookieOptions) { try { cookieStore.set({ name, value, ...options }); } catch (error) { // 서버 컴포넌트에서 set 호출 시 무시 } }, remove(name: string, options: CookieOptions) { try { cookieStore.set({ name, value: "", ...options }); } catch (error) { // 서버 컴포넌트에서 delete 호출 시 무시 } }, }, } ); }; export const createServerSupabaseAdminClient = async ( cookieStore: ReturnType<typeof cookies> = cookies() ) => { return createServerSupabaseClient(cookieStore, true); }; 4. Middleware 설정app/middleware.tsimport { createServerClient, type CookieOptions } from "@supabase/ssr"; import { type NextRequest, NextResponse } from "next/server"; export const applyMiddlewareSupabaseClient = async (request: NextRequest) => { let response = NextResponse.next({ request: { headers: request.headers, }, }); const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { get(name: string) { return request.cookies.get(name)?.value; }, set(name: string, value: string, options: CookieOptions) { request.cookies.set({ name, value, ...options }); response = NextResponse.next({ request: { headers: request.headers } }); response.cookies.set({ name, value, ...options }); }, remove(name: string, options: CookieOptions) { request.cookies.set({ name, value: "", ...options }); response = NextResponse.next({ request: { headers: request.headers } }); response.cookies.set({ name, value: "", ...options }); }, }, } ); await supabase.auth.getUser(); return response; }; export async function middleware(request: NextRequest) { return await applyMiddlewareSupabaseClient(request); } export const config = { matcher: [ "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], };Part 5. 할일 CRUD 기능 구현 (feat. Server Action)1. CRUD Server Action 만들기server/actions/todo.ts"use server"; import { Database } from "types_db"; import { createServerSupabaseClient } from "utils/supabase/server"; export type TodoRow = Database["public"]["Tables"]["todo"]["Row"]; export type TodoRowInsert = Database["public"]["Tables"]["todo"]["Insert"]; export type TodoRowUpdate = Database["public"]["Tables"]["todo"]["Update"]; function handleError(error: any) { console.error(error); throw new Error(error.message); } export async function getTodos({ searchInput = "" }): Promise<TodoRow[]> { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase .from("todo") .select("*") .like("title", `%${searchInput}%`) .order("created_at", { ascending: true }); if (error) { handleError(error); } return data; } export async function createTodo(todo: TodoRowInsert) { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.from("todo").insert({ ...todo, created_at: new Date().toISOString(), }); if (error) { handleError(error); } return data; } export async function updateTodo(todo: TodoRowUpdate) { const supabase = await createServerSupabaseClient(); console.log(todo); const { data, error } = await supabase .from("todo") .update({ ...todo, updated_at: new Date().toISOString(), }) .eq("id", todo.id); if (error) { handleError(error); } return data; } export async function deleteTodo(id: number) { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.from("todo").delete().eq("id", id); if (error) { handleError(error); } return data; }Part 6. 추가 과제 - 할 일의 생성 및 업데이트 시간 표시기능 추가: 할 일 목록에 created_at(생성 시간)과 updated_at(업데이트 시간) 표시{setCreated_at ? ( <p className="flex-1">created_at: {formatDate(created_at)}</p> ) : ( <p className="flex-1">created_at: {formatDate(created_at)}</p> )} <p className="flex-1">updated_at: {formatDate(todo.updated_at)}</p> 업데이트 내용created_at: 할 일이 처음 생성된 시간 표시updated_at: 할 일이 수정된 최신 시간 표시