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 ์ฑ์ผ๋ก ์ข ํฉ ์ค์ต
๐ฌ ๋๊ธ
โป ๋ก๊ทธ์ธ ํ ๋๊ธ์ ์์ฑํ ์ ์์ต๋๋ค.