[인프런 워밍업 클럽 Full Stack 3기] 1주차 TODO

[인프런 워밍업 클럽 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/movies

    • app/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 Action

Server 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 가입 및 프로젝트 설정

  1. Supabase 회원가입 후 대시보드 접속

  2. 새 프로젝트 생성 → 프로젝트명: inflearn-supabase-projects

  3. 테이블 생성 (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 login


Next.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.tsx

import 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.ts

import { 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: 할 일이 수정된 최신 시간 표시

댓글을 작성해보세요.

채널톡 아이콘