🔥딱 8일간! 인프런x토스x허먼밀러 역대급 혜택

블로그

조내일

[국내 최초] 인프런 풀스택 클론코딩 Part 1 - Next.js x NestJS 후기

✨ 들어가며기존에 Supabase + Next.js 기반의 사이드 프로젝트를 진행하면서, Supabase에 대한 정보가 많지 않아 시행착오를 겪었습니다. 그러던 중 로펀님의[풀스택 입문] Firebase보다 10배 좋은 Supabase와[풀스택 완성] Supabase로 웹사이트 3개 클론하기 (Next.js 14)강의를 통해 제가 놓쳤던 부분들을 많이 보완할 수 있었고, 사이드 프로젝트에도 직접 적용해볼 수 있어 좋은 경험이 되었습니다.이후 이름만 들어봤던 NestJS와 Prisma를 활용한 백엔드 구성에 대해 궁금함이 생겼고, 실제 업무에서도 써볼 수 있을 것 같다는 판단이 들어 이 강의를 수강하게 되었습니다. 🧱 강의 소개- 강의명: [국내 최초] 인프런 풀스택 클론코딩 Part 1 - Next.js x NestJS - 강사: 로펀 - 기술 스택: Next.js 15 (App Router) + NestJS + PostgreSQL + Prisma + AWS S3/CloudFront + Shadcn/UI 등실제 인프런 사이트를 클론 코딩하면서 프론트엔드와 백엔드 구조 전반을 설계/개발하는 과정 중심으로 구성되어 있습니다.💻 프로젝트 결과물🧩 사용 기술 및 인사이트✅ NestJS & Prisma 백엔드 구성강의에서는 NestJS의 의존성 주입, 모듈화, 서비스/컨트롤러 구조를 활용한 확장성 있는 백엔드 설계를 다룹니다.또한 Prisma를 활용해 아래와 같이 모델을 정의하고 PostgreSQL과 연결하여 데이터베이스 연동을 진행했습니다./backend/prisma/schema.prismadatasource db { provider = "postgresql" url = env("DATABASE_URL") } generator prismaClassGenerator { provider = "prisma-class-generator" dryRun = "false" separateRelationFields = "false" } generator client { provider = "prisma-client-js" } model Account { id String @id @default(cuid()) userId String @map("user_id") type String provider String providerAccountId String @map("provider_account_id") refresh_token String? @db.Text access_token String? @db.Text expires_at Int? token_type String? scope String? id_token String? @db.Text session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) @@map("accounts") } model Session { id String @id @default(cuid()) sessionToken String @unique @map("session_token") userId String @map("user_id") expires DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("sessions") } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? @map("email_verified") hashedPassword String? @map("hashed_password") image String? bio String? accounts Account[] sessions Session[] courses Course[] courseEnrollments CourseEnrollment[] courseReviews CourseReview[] courseQuestions CourseQuestion[] courseComments CourseComment[] lectureActivities LectureActivity[] @@map("users") } model VerificationToken { identifier String token String expires DateTime @@unique([identifier, token]) @@map("verification_tokens") } model Course { id String @id @default(uuid()) slug String @unique title String shortDescription String? @map("short_description") description String? @map("description") thumbnailUrl String? @map("thumbnail_url") price Int @default(0) discountPrice Int? @map("discount_price") level String @default("BEGINEER") status String @default("DRAFT") instructorId String @map("instructor_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") sections Section[] lectures Lecture[] categories CourseCategory[] courseEnrollments CourseEnrollment[] instructor User @relation(fields: [instructorId], references: [id]) courseReviews CourseReview[] courseQuestions CourseQuestion[] @@map("courses") } model Section { id String @id @default(uuid()) title String description String? order Int courseId String @map("course_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) lectures Lecture[] @@map("sections") } model Lecture { id String @id @default(uuid()) title String description String? order Int duration Int? isPreview Boolean @default(false) @map("is_preview") sectionId String @map("section_id") courseId String @map("course_id") videoStorageInfo Json? @map("video_storage_info") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade) course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) activities LectureActivity[] @@map("lectures") } model CourseCategory { id String @id @default(uuid()) name String slug String @unique description String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") courses Course[] @@map("course_categories") } model CourseEnrollment { id String @id @default(uuid()) userId String @map("user_id") courseId String @map("course_id") enrolledAt DateTime @default(now()) @map("enrolled_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, courseId]) @@map("course_enrollments") } model CourseReview { id String @id @default(uuid()) content String rating Int userId String @map("user_id") courseId String @map("course_id") instructorReply String? @map("instructor_reply") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, courseId]) @@map("course_reviews") } model CourseQuestion { id String @id @default(uuid()) title String content String userId String @map("user_id") courseId String @map("course_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) courseComments CourseComment[] @@map("course_questions") } model CourseComment { id String @id @default(uuid()) content String userId String @map("user_id") questionId String @map("question_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") courseQuestions CourseQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("course_comments") } model LectureActivity { id String @id @default(uuid()) userId String @map("user_id") lectureId String @map("lecture_id") progress Int @default(0) isCompleted Boolean @default(false) @map("is_completed") lastWatchedAt DateTime @default(now()) @map("last_watched_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) lecture Lecture @relation(fields: [lectureId], references: [id], onDelete: Cascade) @@unique([userId, lectureId]) @@map("lecture_activities") } 이를 통해 관계형 데이터 설계 및 실제 쿼리 구현에 대한 감을 익힐 수 있었습니다. 🎨Shadcn UI + TailwindCSS + Cursor AI개인적으로는 TailwindCSS를 선호하지 않지만, 이번 강의에서 함께 다룬 Shadcn UI 라이브러리를 통해 Tailwind 기반 컴포넌트를 쉽고 빠르게 구성할 수 있다는 점을 새롭게 알게 되었습니다.실제로 로그인, 대시보드 UI 등을 구현할 때 Shadcn UI 컴포넌트를 활용해 프론트엔드를 빠르게 구성할 수 있었고, 컴포넌트의 재사용성이나 커스터마이징 측면에서도 꽤 실용적이라는 인상을 받았습니다.또한, Cursor AI의 에이전트 모드를 활용해 UI를 구현하는 노하우를 알게 되어, 실무에서도 개발 생산성을 크게 높일 수 있을 것이라는 기대감이 생겼습니다. ⚠ 시행착오 & 해결 방법NestJS를 사용하면서 Windows 환경에서 개행문자(EOL) 이슈가 발생했습니다.VSCode에서 줄 끝에 빨간 줄이 생기는 현상이 있었고, 다음과 같이 해결할 수 있었습니다.1. Ctrl + Shift + P 또는 F1 키 입력2. Change End Of Line Sequence 검색3. LF로 변경 🛫 S3 + CloudFront영상을 S3에 업로드 후 CloudFront를 통한 스트리밍 구현까지 다루어, 실제 서비스에서 사용되는 미디어 배포 방식을 경험할 수 있었습니다.✨ 느낀 점과 다음 목표이번 강의를 통해 기획 → DB 설계 → API 작성 → 프론트 연동 → 인프라(S3+ CloudFront)까지서비스 전체 흐름을 실습할 수 있었고, 프론트엔드 개발자로서의 시야를 한층 넓힐 수 있었습니다.NestJS와 Prisma를 통한 구조적인 백엔드 설계는 처음이었지만,이제는 스스로 서버를 설계할 수 있다는 자신감을 얻었습니다.✅ 마무리단순한 클론 코딩을 넘어서 실제 서비스에 근접한 구조와 스택을 경험할 수 있어 정말 유익한 강의였습니다.풀스택 개발을 지향하는 분들, 특히 프론트엔드 개발자 분들이 백엔드 기초를 잡아보기에 정말 좋은 강의입니다.👀 Part 2에서 강의 탐색·수강·수강평·Q&A 기능도 기대되며, 최종적으로 AWS 배포 강의도 무척 기대가 됩니다!

풀스택Nest.jsPrismaNext15ShadcnUITailwindCSS

김진현

[인프런 워밍업 클럽 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: 할 일이 수정된 최신 시간 표시

풀스택Next.jsReactQueryRecoilTailwindCSSSupabase

채널톡 아이콘