Syw.Frontend

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

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

1-5. ์ƒํƒœ ๊ด€๋ฆฌ & ์ธํ„ฐ๋ž™์…˜

1. Client Component๋ž€?

Next.js์—์„œ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ Server Component๊ฐ€ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.

ํ•˜์ง€๋งŒ useState, useEffect ๊ฐ™์€ React ํ›…์€ ๋ธŒ๋ผ์šฐ์ €์—์„œ๋งŒ ๋™์ž‘ํ•˜๊ธฐ ๋•Œ๋ฌธ์—, ์ด๋Ÿฐ ๊ฒฝ์šฐ์—๋Š” Client Component๋กœ ์„ ์–ธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ‘‰ ๋ฐฉ๋ฒ•: ํŒŒ์ผ ์ƒ๋‹จ์— "use client" ์ถ”๊ฐ€


2. ์ƒํƒœ ๊ด€๋ฆฌ ๊ธฐ์ดˆ (useState)

(1) ์นด์šดํ„ฐ ์˜ˆ์ œ

app/counter/page.tsx

"use client";

import { useState } from "react";

export default function CounterPage() {
  const [count, setCount] = useState(0);

  return (
    <main>
      <h1>์นด์šดํ„ฐ ์˜ˆ์ œ</h1>
      <p>ํ˜„์žฌ ๊ฐ’: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={() => setCount(count - 1)}>-1</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </main>
  );
}

๐Ÿ‘‰ http://localhost:3000/counter ์ ‘์† ํ›„ ๋ฒ„ํŠผ ํด๋ฆญ โ†’ ์ƒํƒœ ๊ฐ’์ด ๋ณ€ํ•˜๋Š” ๊ฑธ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


3. Form ์ฒ˜๋ฆฌ

(1) ๊ฐ„๋‹จํ•œ ์ž…๋ ฅํผ ๋งŒ๋“ค๊ธฐ

app/form/page.tsx

"use client";

import { useState } from "react";

export default function FormPage() {
  const [name, setName] = useState("");
  const [submitted, setSubmitted] = useState("");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    setSubmitted(name);
  };

  return (
    <main>
      <h1>ํผ ์ž…๋ ฅ ์˜ˆ์ œ</h1>
      <form onSubmit={handleSubmit}>
        <input type="text"
          placeholder="์ด๋ฆ„์„ ์ž…๋ ฅํ•˜์„ธ์š”"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
        <button type="submit">์ œ์ถœ</button>
      </form>

      {submitted && <p>์•ˆ๋…•ํ•˜์„ธ์š”, {submitted}๋‹˜!</p>}
    </main>
  );
}

๐Ÿ‘‰ http://localhost:3000/form โ†’ ์ด๋ฆ„ ์ž…๋ ฅ ํ›„ ์ œ์ถœ โ†’ ์ธ์‚ฌ๋ง ํ‘œ์‹œ


4. Todo ์•ฑ ๋งŒ๋“ค๊ธฐ (์‹ค์Šต ์ข…ํ•ฉ)

์ง€๊ธˆ๊นŒ์ง€ ๋ฐฐ์šด ์ƒํƒœ ๊ด€๋ฆฌ + ํผ ์ฒ˜๋ฆฌ๋ฅผ ํ•ฉ์ณ์„œ ๊ฐ„๋‹จํ•œ Todo ์•ฑ์„ ๋งŒ๋“ค์–ด๋ด…๋‹ˆ๋‹ค.

(1) app/todo/page.tsx

"use client";

import { useState } from "react";

export default function TodoPage() {
  const [todos, setTodos] = useState<string[]>([]);
  const [input, setInput] = useState("");

  const addTodo = (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim()) return;
    setTodos([...todos, input]);
    setInput("");
  };

  const removeTodo = (index: number) => {
    setTodos(todos.filter((_, i) => i !== index));
  };

  return (
    <main>
      <h1>Todo ์•ฑ</h1>
      <form onSubmit={addTodo}>
        <inputtype="text"
          placeholder="ํ•  ์ผ์„ ์ž…๋ ฅํ•˜์„ธ์š”"
          value={input}
          onChange={(e) => setInput(e.target.value)}
        />
        <button type="submit">์ถ”๊ฐ€</button>
      </form>

      <ul>
        {todos.map((todo, i) => (
          <li key={i}>
            {todo} <button onClick={() => removeTodo(i)}>์‚ญ์ œ</button>
          </li>
        ))}
      </ul>
    </main>
  );
}

๐Ÿ‘‰ http://localhost:3000/todo ์ ‘์† โ†’ ํ•  ์ผ์„ ์ž…๋ ฅ/์ถ”๊ฐ€/์‚ญ์ œ ๊ฐ€๋Šฅ


๐ŸŽจ 5. ์Šคํƒ€์ผ๋ง

0) ์ „์—ญ ์œ ํ‹ธ ์ถ”๊ฐ€ (styles/globals.scss)

๋ฒ„ํŠผ/์ธํ’‹์˜ ๊ธฐ๋ณธ ๋ฆฌ์…‹๊ณผ ํฌ์ปค์Šค ์Šคํƒ€์ผ์„ ํ†ต์ผํ•ฉ๋‹ˆ๋‹ค.

/* ๋ฒ„ํŠผ/์ธํ’‹ ๊ธฐ๋ณธ ๋ฆฌ์…‹ + ํฌ์ปค์Šค */
button, input {
  font: inherit;
  border: 1px solid #ddd;
  background: #fff;
  outline: none;
}

button {
  cursor: pointer;
  border-radius: 8px;
  padding: 0.6rem 0.9rem;
  border: 1px solid #dcdcdc;
  transition: box-shadow .2s, transform .02s;
}
button:hover { box-shadow: 0 2px 10px rgba(0,0,0,0.06); }
button:active { transform: translateY(1px); }

input {
  border-radius: 8px;
  padding: 0.6rem 0.8rem;
  width: 100%;
  border: 1px solid #dcdcdc;
}

:focus-visible {
  outline: 2px solid #2f78ff;
  outline-offset: 2px;
}

1) ๊ณตํ†ต ์นด๋“œ ๋ ˆ์ด์•„์›ƒ (styles/card.module.scss)

3๊ฐœ ํŽ˜์ด์ง€(์นด์šดํ„ฐ/ํผ/ํˆฌ๋‘)์—์„œ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์นด๋“œ UI์ž…๋‹ˆ๋‹ค.

.card {
  background: #fff;
  border: 1px solid #eee;
  border-radius: 16px;
  padding: 1.25rem;
  max-width: 720px;
  margin: 2rem auto;
  box-shadow: 0 6px 30px rgba(0,0,0,0.05);
}

.title {
  font-size: 1.6rem;
  font-weight: 700;
  margin: 0 0 1rem;
}

.desc {
  color: #666;
  margin: 0 0 1.25rem;
}

.row {
  display: flex;
  gap: .6rem;
  align-items: center;
}

.actions {
  display: flex;
  gap: .5rem;
  flex-wrap: wrap;
  margin-top: 1rem;
}

.list {
  margin-top: 1rem;
  display: grid;
  gap: .6rem;
}

.badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 36px;
  height: 28px;
  padding: 0 .5rem;
  border-radius: 999px;
  background: #f5f7ff;
  color: #2f78ff;
  font-weight: 600;
  border: 1px solid #e6ecff;
}

2) ์นด์šดํ„ฐ ํŽ˜์ด์ง€ ์Šคํƒ€์ผ (styles/counter.module.scss)

.value {
  font-size: 2.25rem;
  font-weight: 800;
  letter-spacing: .5px;
}

.btnPrimary { border-color: #2f78ff; color: #fff; background: #2f78ff; }
.btnGhost   { background: #fff; }
.btnDanger  { background: #fff; color: #e75151; border-color: #f0cccc; }

app/counter/page.tsx (์Šคํƒ€์ผ ์ ์šฉ)

"use client";

import { useState } from "react";
import card from "../../styles/card.module.scss";
import styles from "../../styles/counter.module.scss";

export default function CounterPage() {
  const [count, setCount] = useState(0);

  return (
    <section className={card.card}>
      <h1 className={card.title}>์นด์šดํ„ฐ ์˜ˆ์ œ</h1>
      <p className={card.desc}>๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฐ’์„ ์ฆ๊ฐ€/๊ฐ์†Œํ•ด๋ณด์„ธ์š”.</p>

      <div className={card.row} style={{ justifyContent: "space-between" }}>
        <span className={`${card.badge} ${styles.value}`}>{count}</span>
        <div className={card.actions}>
          <button className={styles.btnPrimary} onClick={() => setCount(c => c + 1)}>+1</button>
          <button className={styles.btnGhost} onClick={() => setCount(c => c - 1)}>-1</button>
          <button className={styles.btnDanger} onClick={() => setCount(0)}>Reset</button>
        </div>
      </div>
    </section>
  );
}

3) ํผ ํŽ˜์ด์ง€ ์Šคํƒ€์ผ (styles/form.module.scss)

.form {
  display: grid;
  gap: .8rem;
}

.label {
  font-weight: 600;
}

.hint {
  color: #888;
  font-size: .9rem;
}

.submit {
  border-color: #2f78ff;
  color: #fff;
  background: #2f78ff;
  width: fit-content;
}

app/form/page.tsx (์Šคํƒ€์ผ ์ ์šฉ)

"use client";

import { useState } from "react";
import card from "../../styles/card.module.scss";
import styles from "../../styles/form.module.scss";

export default function FormPage() {
  const [name, setName] = useState("");
  const [submitted, setSubmitted] = useState("");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    setSubmitted(name.trim());
  };

  return (
    <section className={card.card}>
      <h1 className={card.title}>ํผ ์ž…๋ ฅ ์˜ˆ์ œ</h1>
      <p className={card.desc}>์ด๋ฆ„์„ ์ž…๋ ฅํ•˜๊ณ  ์ œ์ถœํ•ด ๋ณด์„ธ์š”.</p>

      <form className={styles.form} onSubmit={handleSubmit}>
        <label className={styles.label} htmlFor="name">์ด๋ฆ„</label>
        <inputid="name"
          placeholder="์ด๋ฆ„์„ ์ž…๋ ฅํ•˜์„ธ์š”"
          value={name}
          onChange={(e) => setName(e.target.value)}
          aria-describedby="name-hint"
        />
        <span id="name-hint" className={styles.hint}>์˜ˆ: ํ™๊ธธ๋™</span>

        <button className={styles.submit} type="submit">์ œ์ถœ</button>
      </form>

      {submitted && <p style={{ marginTop: "1rem" }}>์•ˆ๋…•ํ•˜์„ธ์š”, <strong>{submitted}</strong>๋‹˜! ๐Ÿ‘‹</p>}
    </section>
  );
}

4) ํˆฌ๋‘ ํŽ˜์ด์ง€ ์Šคํƒ€์ผ (styles/todo.module.scss)

.form {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: .6rem;
}

.add {
  border-color: #2f78ff;
  color: #fff;
  background: #2f78ff;
}

.item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .8rem;
  padding: .75rem .9rem;
  border: 1px solid #eee;
  border-radius: 12px;
  background: #fff;
}

.text {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.remove {
  background: #fff;
  color: #e75151;
  border-color: #f0cccc;
}

app/todo/page.tsx (์Šคํƒ€์ผ ์ ์šฉ)

"use client";

import { useState } from "react";
import card from "../../styles/card.module.scss";
import styles from "../../styles/todo.module.scss";

export default function TodoPage() {
  const [todos, setTodos] = useState<string[]>([]);
  const [input, setInput] = useState("");

  const addTodo = (e: React.FormEvent) => {
    e.preventDefault();
    const v = input.trim();
    if (!v) return;
    setTodos([...todos, v]);
    setInput("");
  };

  const removeTodo = (index: number) => {
    setTodos(todos.filter((_, i) => i !== index));
  };

  return (
    <section className={card.card}>
      <h1 className={card.title}>Todo ์•ฑ</h1>
      <p className={card.desc}>ํ•  ์ผ์„ ์ถ”๊ฐ€/์‚ญ์ œํ•ด ๋ณด์„ธ์š”.</p>

      <form className={styles.form} onSubmit={addTodo}>
        <inputplaceholder="ํ•  ์ผ์„ ์ž…๋ ฅํ•˜์„ธ์š”"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          aria-label="ํ•  ์ผ ์ž…๋ ฅ"
        />
        <button className={styles.add} type="submit">์ถ”๊ฐ€</button>
      </form>

      <div className={card.list}>
        {todos.map((todo, i) => (
          <div key={i} className={styles.item}>
            <span className={styles.text}>{todo}</span>
            <button className={styles.remove} onClick={() => removeTodo(i)}>์‚ญ์ œ</button>
          </div>
        ))}
      </div>
    </section>
  );
}

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

  • Client Component: "use client" ์„ ์–ธ ํ•„์š”
  • useState๋กœ ์ƒํƒœ ๊ด€๋ฆฌ (์นด์šดํ„ฐ ์˜ˆ์ œ)
  • ํผ ์ฒ˜๋ฆฌ โ†’ ์ž…๋ ฅ๊ฐ’ ๋ฐ›์•„์„œ ์ถœ๋ ฅ
  • Todo ์•ฑ์œผ๋กœ ์ข…ํ•ฉ ์‹ค์Šต

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

GitHub - heroyooi/next-tutorial at ch1_5

๐Ÿ’ฌ ๋Œ“๊ธ€

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