블로그
전체 22025. 03. 16.
0
[인프런 워밍업 클럽 Full Stack 3기] 2주차 Dropbox
Part 1. Git Repository 생성 및 초기 설정 진행초기 코드 생성npx create-next-app@latest inflearn-supabase-dropbox-clone cd inflearn-supabase-dropbox-cloneTODO List 코드 복사config/*app/layout.tsx , app/middleware.tsx , app/global.csscomponents/material-tailwind-theme-provider.tsxutils/*package.jsontailwind.config.ts, tsconfig.json , .envPart 2. UI 작업app/page.tsximport UI from "./ui"; export const metadata = { title: "Minibox", description: "A minimalistic Dropbox clone", }; export default function Home() { return ; }app/ui.tsx"use client"; import DropboxImageList from "components/dropbox-image-list"; import FileDragDropZone from "components/file-dragdropzone"; import Logo from "components/logo"; import SearchComponent from "components/search-component"; import Image from "next/image"; import { useState } from "react"; export default function UI() { const [searchInput, setSearchInput] = useState(""); return ( {/* Logo */} {/* Search Component */} {/* File Drag&Drop Zone */} {/* Dropbox Image List */} ); }components/dropbox-image-list.tsx"use client"; import DropboxImage from "./dropbox-image"; export default function DropboxImageList() { return ( ); } components/dropbox-image.tsx"use client"; import { IconButton } from "@material-tailwind/react"; export default function DropboxImage() { return ( {/* Image */} {/* FileName */} cutedog.jpeg {}} color="red"> ); } components/file-dragdropzone.tsx"use client"; export default function FileDragDropZone() { return ( 파일을 여기에 끌어다 놓거나 클릭하여 업로드하세요. ); } components/logo.tsx"use client"; import Image from "next/image"; export default function Logo() { return ( Minibox ); } components/search-component.tsx "use client"; import { Input } from "@material-tailwind/react"; import { useState } from "react"; export default function SearchComponent({ searchInput, setSearchInput }) { return ( setSearchInput(e.target.value)} label="Search Images" icon={} /> ); } Part 3. Supabase Storage 설명 & 파일 업로드 구현Supabase Storage Bucket 생성 및 CRUD Policy 추가 액션 함수 구현actions/storageActions.tsx"use server"; import { createServerSupabaseClient } from "utils/supabase/server"; function handleError(error) { if (error) { console.error(error); throw error; } } export async function uploadFile(formData: FormData) { const supabase = await createServerSupabaseClient(); const file = formData.get("file") as File; const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .upload(file.name, file, { upsert: true }); handleError(error); return data; } export async function searchFiles(search: string = "") { const supabase = await createServerSupabaseClient(); const { data, error } = await supabase.storage .from(process.env.NEXT_PUBLIC_STORAGE_BUCKET) .list(null, { search, }); handleError(error); return data; } components/file-dragdropzone.tsx"use client"; import { Button } from "@material-tailwind/react"; import { useMutation } from "@tanstack/react-query"; import { uploadFile } from "actions/storageActions"; import { queryClient } from "config/ReactQueryClientProvider"; import { useRef } from "react"; export default function FileDragDropZone() { const fileRef = useRef(null); const uploadImageMutation = useMutation({ mutationFn: uploadFile, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["images"], }); }, }); return ( { e.preventDefault(); const file = fileRef.current.files?.[0]; if (file) { const formData = new FormData(); formData.append("file", file); const result = await uploadImageMutation.mutate(formData); console.log(result); } }} className="w-full py-20 border-4 border-dotted border-indigo-700 flex flex-col items-center justify-center" > 파일을 여기에 끌어다 놓거나 클릭하여 업로드하세요. 파일 업로드 ); }Part 4. 필수 라이브러리 설정 - React Query, Supabase라이브러리 추가 설치npm i --save @supabase/ssr @tanstack/react-queryReact 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 ( {children} ); }app/layout.tsximport ReactQueryClientProvider from "config/ReactQueryClientProvider"; export default function RootLayout({ children }) { return ( ... ) } Supabase 설정.envNEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_SUPABASE_SERVICE_ROLE= NEXT_SUPABASE_DB_PASSWORD= utils/supabase/client.ts"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"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 = cookies(), admin: boolean = false ) => { return createServerClient( 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) { // The `set` method was called from a Server Component. // This can be ignored if you have middleware refreshing // user sessions. } }, remove(name: string, options: CookieOptions) { try { cookieStore.set({ name, value: "", ...options }); } catch (error) { // The `delete` method was called from a Server Component. // This can be ignored if you have middleware refreshing // user sessions. } }, }, } ); }; export const createServerSupabaseAdminClient = async ( cookieStore: ReturnType = cookies() ) => { return createServerSupabaseClient(cookieStore, true); }; app/middleware.tsimport { createServerClient, type CookieOptions } from "@supabase/ssr"; import { type NextRequest, NextResponse } from "next/server"; export const applyMiddlewareSupabaseClient = async (request: NextRequest) => { // Create an unmodified response 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) { // If the cookie is updated, update the cookies for the request and response request.cookies.set({ name, value, ...options, }); response = NextResponse.next({ request: { headers: request.headers, }, }); response.cookies.set({ name, value, ...options, }); }, remove(name: string, options: CookieOptions) { // If the cookie is removed, update the cookies for the request and response request.cookies.set({ name, value: "", ...options, }); response = NextResponse.next({ request: { headers: request.headers, }, }); response.cookies.set({ name, value: "", ...options, }); }, }, } ); // refreshing the auth token await supabase.auth.getUser(); return response; }; export async function middleware(request) { return await applyMiddlewareSupabaseClient(request); } export const config = { matcher: [ /* * Match all request paths except for the ones starting with: * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) * Feel free to modify this pattern to include more paths. */ "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], }; Part 5. 할일 CRUD 기능 구현 (feat. Server Action)"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) { console.error(error); throw new Error(error.message); } export async function getTodos({ searchInput = "" }): Promise { 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; }
풀스택
・
Next.js
・
Supabase
・
React-Query
2025. 03. 09.
0
[인프런 워밍업 클럽 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 Dashboard; } 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 { 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 ( {children} ); } app/layout.tsximport ReactQueryClientProvider from "config/ReactQueryClientProvider"; export default function RootLayout({ children }) { return ( ... ); } 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 = cookies(), admin: boolean = false ) => { return createServerClient( 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 = 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 { 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 ? ( created_at: {formatDate(created_at)} ) : ( created_at: {formatDate(created_at)} )} updated_at: {formatDate(todo.updated_at)} 업데이트 내용created_at: 할 일이 처음 생성된 시간 표시updated_at: 할 일이 수정된 최신 시간 표시
풀스택
・
Next.js
・
ReactQuery
・
Recoil
・
TailwindCSS
・
Supabase