Syw.Frontend

๐Ÿ“˜ Next.js ๊ธฐ์ดˆ ๊ฐ•์ขŒ

1๋‹จ๊ณ„. Next.js ์ž…๋ฌธ (๊ฐœ๋… & ํ™œ์šฉ ๊ธฐ์ดˆ)

1-7. ๋ฐฐํฌ์™€ ์ตœ์ ํ™” (feat. ํƒ€์ž… ์—๋Ÿฌ ํ•ด๊ฒฐ)

1. Vercel ๋ฐฐํฌ

Next.js๋Š” Vercel์—์„œ ๋งŒ๋“  ํ”„๋ ˆ์ž„์›Œํฌ๋ผ์„œ, ๋ฐฐํฌ ๊ณผ์ •์ด ์•„์ฃผ ๋‹จ์ˆœํ•ฉ๋‹ˆ๋‹ค.

(1) GitHub์— ์ฝ”๋“œ ์˜ฌ๋ฆฌ๊ธฐ

git init
git add .
git commit -m "first next.js app"
git branch -M main
git remote add origin https://github.com/๋‚ด์•„์ด๋””/next-tutorial.git
git push -u origin main

(2) Vercel ์—ฐ๊ฒฐ

  1. Vercel ์ ‘์† โ†’ GitHub ์—ฐ๋™

  2. "New Project" โ†’ ๋ฐฉ๊ธˆ ์˜ฌ๋ฆฐ ์ €์žฅ์†Œ ์„ ํƒ

  3. Framework: Next.js ์ž๋™ ๊ฐ์ง€

  4. Deploy ๋ฒ„ํŠผ ํด๋ฆญ โ†’ ์ž๋™ ๋ฐฐํฌ ์™„๋ฃŒ ๐ŸŽ‰

    • ๊ธฐ๋ณธ URL: https://next-tutorial.vercel.app
      • ํ•˜์ง€๋งŒ ์œ„ URL์ด ์ด๋ฏธ ์žˆ์œผ๋ฏ€๋กœ ๋ฐฐํฌ์— ์„ฑ๊ณตํ•˜๋ฉด ๋‹ค๋ฅธ URL์ด ๋žœ๋คํ•˜๊ฒŒ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.
      • ํ•˜๊ธฐ ๋งํฌ๋Š” ์ œ๊ฐ€ ๋ฐฐํฌํ•˜๋ฉด์„œ ์˜ฌ๋ฆฐ ๋งํฌ์ž…๋‹ˆ๋‹ค.
      • https://next-tutorial-ten-teal.vercel.app

    ํ˜น ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์—๋Ÿฌ๊ฐ€ ๋‚  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

    ์—๋Ÿฌ๊ฐ€ ๋‚˜๋Š” ์›์ธ์€ ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.

    1) ESLint ๊ทœ์น™ ์œ„๋ฐ˜(no-explicit-any)๋กœ ๋นŒ๋“œ ์‹คํŒจ

    src/app/posts/page.tsx: Unexpected any
    src/app/posts-client/page.tsx: Unexpected any
    

    2) Route Handler ๋‘ ๋ฒˆ์งธ ์ธ์ž ํƒ€์ž… ๋ถˆ์ผ์น˜๋กœ ๋นŒ๋“œ ์‹คํŒจ

    Route "src/app/api/todos/[id]/route.ts" has an invalid "DELETE" export:
    Type "Params" is not a valid type for the function's second argument.
    ...
    Type "{ params: { id: string } }" is not a valid type for the function's second argument.
    

    โœ… ํ•ด๊ฒฐ: Post ํƒ€์ž… ์„ ์–ธํ•ด์„œ any ์ œ๊ฑฐ

    1. src/app/posts/page.tsx
    type Post = {
      userId: number;
      id: number;
      title: string;
      body: string;
    };
    
    export default async function PostsPage() {
      const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
        // ๋ฐ๋ชจ์—์„  ํ•ญ์ƒ ์ตœ์‹  ๋ณด๊ธฐ
        cache: "no-store",
      });
      const posts: Post[] = await res.json();
    
      return (
        <main>
          <h1>Posts ๋ชฉ๋ก</h1>
          <ul>
            {posts.slice(0, 5).map((post) => (
              <li key={post.id}>
                <strong>{post.title}</strong>
                <p>{post.body}</p>
              </li>
            ))}
          </ul>
        </main>
      );
    }
    
    1. src/app/posts-client/page.tsx
    "use client";
    
    import { useState } from "react";
    
    type Post = {
      userId: number;
      id: number;
      title: string;
      body: string;
    };
    
    export default function PostsClientPage() {
      const [posts, setPosts] = useState<Post[]>([]);
    
      const loadPosts = async () => {
        const res = await fetch("https://jsonplaceholder.typicode.com/posts");
        const data: Post[] = await res.json();
        setPosts(data.slice(0, 5));
      };
    
      return (
        <main>
          <h1>Client Fetch Example</h1>
          <button onClick={loadPosts}>๊ธ€ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ</button>
          <ul>
            {posts.map((post) => (
              <li key={post.id}>
                <strong>{post.title}</strong>
                <p>{post.body}</p>
              </li>
            ))}
          </ul>
        </main>
      );
    }
    
    1. src/app/api/todos/[id]/route.ts
    import { NextResponse } from "next/server";
    import { store } from "../store";
    
    export async function DELETE(req: Request) {
      const { pathname } = new URL(req.url);
      const id = pathname.split("/").pop();
    
      if (!id) {
        return NextResponse.json({ error: "Bad Request" }, { status: 400 });
      }
    
      const ok = store.remove(id);
      if (!ok) {
        return NextResponse.json({ error: "Not Found" }, { status: 404 });
      }
      return NextResponse.json({ ok: true });
    }
    

    ์ด๋ ‡๊ฒŒ ์ˆ˜์ • ํ›„์— ๋‹ค์‹œ ๋ฐฐํฌํ•˜์‹œ๋ฉด ๋นŒ๋“œ ํ†ต๊ณผํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

    1) ESLint ๊ทœ์น™ ์œ„๋ฐ˜(no-explicit-any) ์กฐ์น˜

    • Next.js๋Š” next build ๋‹จ๊ณ„์—์„œ ESLint + ํƒ€์ž…์ฒดํฌ๋ฅผ ํ•จ๊ป˜ ๋Œ๋ฆฝ๋‹ˆ๋‹ค.
    • 3๊ฐ• ์˜ˆ์ œ์—์„œ ๋น ๋ฅด๊ฒŒ ๋ณด์—ฌ๋“œ๋ฆฌ๋ ค๊ณ  any๋ฅผ ์“ด ๋ถ€๋ถ„์ด, ํ”„๋กœ์ ํŠธ์˜ ESLint ๊ทœ์น™ @typescript-eslint/no-explicit-any์— ๊ฑธ๋ ค ๋นŒ๋“œ๊ฐ€ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.
    • ์กฐ์น˜: Post ํƒ€์ž…์„ ๋ช…ํ™•ํžˆ ์„ ์–ธํ•˜๊ณ , ์ƒํƒœ(useState<Post[]>)์™€ fetch ์‘๋‹ต์— ํƒ€์ž…์„ ๋ถ€์—ฌํ•˜์—ฌ ํ•ด๊ฒฐํ–ˆ์Šต๋‹ˆ๋‹ค.

    2) Route Handler ๋‘ ๋ฒˆ์งธ ์ธ์ž ํƒ€์ž… ๋ถˆ์ผ์น˜ ์กฐ์น˜

    Route "src/app/api/todos/[id]/route.ts" has an invalid "DELETE" export:
    Type "Params" is not a valid type for the function's second argument.
    ...
    Type "{ params: { id: string } }" is not a valid type for the function's second argument.
    
    • Next.js(App Router)์—์„œ๋Š” Route Handler ์‹œ๊ทธ๋‹ˆ์ฒ˜๋ฅผ ์—„๊ฒฉํ•˜๊ฒŒ ๊ฒ€์‚ฌํ•ฉ๋‹ˆ๋‹ค.

    • ์กฐ์น˜: ๋‘ ๋ฒˆ์งธ ์ธ์ž ์ž์ฒด๋ฅผ ์—†์• ๊ณ , req.url์—์„œ id๋ฅผ ํŒŒ์‹ฑํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋ณ€๊ฒฝํ–ˆ์Šต๋‹ˆ๋‹ค.

      ์ด๋Ÿฌ๋ฉด Next์˜ โ€œ๋‘ ๋ฒˆ์งธ ์ธ์ž ํƒ€์ž… ๊ฒ€์‚ฌโ€๋ฅผ ์•„์˜ˆ ํšŒํ”ผํ•  ์ˆ˜ ์žˆ์–ด ์•ˆ์ •์ ์œผ๋กœ ๋นŒ๋“œ๊ฐ€ ํ†ต๊ณผํ•ฉ๋‹ˆ๋‹ค.

    next-tutorial-ten-teal.vercel.app


2. ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๊ด€๋ฆฌ

์‹ค์ œ ํ”„๋กœ์ ํŠธ์—์„œ๋Š” API ํ‚ค, DB ์ ‘์† ์ •๋ณด ๋“ฑ ๋ฏผ๊ฐํ•œ ๊ฐ’์„ ์ง์ ‘ ์ฝ”๋“œ์— ๋„ฃ์œผ๋ฉด ์•ˆ ๋ฉ๋‹ˆ๋‹ค.

Next.js์—์„œ๋Š” .env.local ํŒŒ์ผ์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

(1) .env.local ์ƒ์„ฑ

NEXT_PUBLIC_API_URL=https://myapi.com
SECRET_KEY=abcdef123456
  • NEXT_PUBLIC_ prefix๊ฐ€ ์žˆ์œผ๋ฉด ํด๋ผ์ด์–ธํŠธ์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅ
  • prefix๊ฐ€ ์—†์œผ๋ฉด ์„œ๋ฒ„์—์„œ๋งŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅ

(2) ์‚ฌ์šฉํ•˜๊ธฐ

// ์„œ๋ฒ„ ์ „์šฉ (app/api/hello/route.ts)
export async function GET() {
  console.log(process.env.SECRET_KEY); // ์„œ๋ฒ„ ์ „์šฉ
  return Response.json({ ok: true });
}

// ํด๋ผ์ด์–ธํŠธ (app/page.tsx)
export default function Home() {
  return <p>API URL: {process.env.NEXT_PUBLIC_API_URL}</p>;
}

โš ๏ธ .env.local์€ git์— ์˜ฌ๋ฆฌ์ง€ ์•Š๋„๋ก .gitignore์— ํฌํ•จํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.


3. ์ด๋ฏธ์ง€ ์ตœ์ ํ™”

Next.js์˜ <Image> ์ปดํฌ๋„ŒํŠธ๋Š” ์ž๋™์œผ๋กœ:

  • ํฌ๊ธฐ ์ตœ์ ํ™”
  • WebP ๋ณ€ํ™˜
  • ์ง€์—ฐ ๋กœ๋”ฉ(lazy loading)
  • ๋ฐ˜์‘ํ˜• ์‚ฌ์ด์ฆˆ ์กฐ์ •

์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

(1) ์‚ฌ์šฉ ์˜ˆ์‹œ

import Image from "next/image";
import profilePic from "../public/profile.png";

export default function Profile() {
  return (
    <div>
      <h2>ํ”„๋กœํ•„</h2>
      <Image src={profilePic}
        alt="Profile"
        width={200}
        height={200}
        priority
      />
    </div>
  );
}

๐Ÿ‘‰ public/ ํด๋”์˜ ์ด๋ฏธ์ง€ ์™ธ์—๋„ ์™ธ๋ถ€ ๋„๋ฉ”์ธ ์ด๋ฏธ์ง€๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด

next.config.mjs์—์„œ ํ—ˆ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค:

export default {
  images: {
    domains: ["example.com"],
  },
};

4. SEO ๊ธฐ๋ณธ ์ตœ์ ํ™”

(1) ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์„ค์ •

app/layout.tsx ๋˜๋Š” ๊ฐ page.tsx์—์„œ export const metadata๋กœ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.

export const metadata = {
  title: "Next.js ๊ธฐ์ดˆ ๊ฐ•์ขŒ",
  description: "Next.js ๊ฐœ๋…๊ณผ ์‚ฌ์šฉ๋ฒ•์„ ๋ฐฐ์šฐ๋Š” ๊ธฐ์ดˆ ๊ฐ•์ขŒ",
  openGraph: {
    title: "Next.js ๊ธฐ์ดˆ ๊ฐ•์ขŒ",
    description: "Next.js ๊ฐœ๋…๊ณผ ์‚ฌ์šฉ๋ฒ•์„ ๋ฐฐ์šฐ๋Š” ๊ธฐ์ดˆ ๊ฐ•์ขŒ",
    url: "https://next-tutorial.vercel.app",
    siteName: "Next.js ๊ฐ•์ขŒ",
    images: [
      {
        url: "https://next-tutorial.vercel.app/og-image.png",
        width: 1200,
        height: 630,
      },
    ],
    locale: "ko_KR",
    type: "website",
  },
  twitter: {
    card: "summary_large_image",
    title: "Next.js ๊ธฐ์ดˆ ๊ฐ•์ขŒ",
    description: "Next.js ๊ฐœ๋…๊ณผ ์‚ฌ์šฉ๋ฒ•์„ ๋ฐฐ์šฐ๋Š” ๊ธฐ์ดˆ ๊ฐ•์ขŒ",
    images: ["https://next-tutorial.vercel.app/og-image.png"],
  },
};

๐Ÿ‘‰ ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๊ตฌ๊ธ€ ๊ฒ€์ƒ‰ + SNS ๊ณต์œ  ์นด๋“œ๊นŒ์ง€ ์ž๋™ ๋Œ€์‘๋ฉ๋‹ˆ๋‹ค.

(2) Canonical ๋งํฌ ์ถ”๊ฐ€ (์ค‘๋ณต URL ๋ฐฉ์ง€)

app/layout.tsx

export const metadata = {
  metadataBase: new URL("https://next-tutorial.vercel.app"),
  alternates: {
    canonical: "/",
  },
};

5. ์˜ค๋Š˜์˜ ์ •๋ฆฌ

  • GitHub + Vercel๋กœ ์ž๋™ ๋ฐฐํฌ ํŒŒ์ดํ”„๋ผ์ธ ๊ตฌ์ถ•
  • .env.local ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ๋ณด์•ˆ์„ฑ ํ™•๋ณด
  • <Image> ์ปดํฌ๋„ŒํŠธ๋กœ ์ด๋ฏธ์ง€ ์ตœ์ ํ™”
  • metadata API๋กœ SEO/SNS ๋Œ€์‘

๐Ÿ“š ์ €์žฅ์†Œ

GitHub - heroyooi/next-tutorial at ch1_7

๐Ÿ’ฌ ๋Œ“๊ธ€

    โ€ป ๋กœ๊ทธ์ธ ํ›„ ๋Œ“๊ธ€์„ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.