Sonamu์ ์ค์บํด๋ฉ ์์คํ
์ผ๋ก ๊ด๋ฆฌ์ ํ์ด์ง์ ๋ชฉ๋ก๊ณผ ํผ ๋ทฐ๋ฅผ ์๋์ผ๋ก ์์ฑํ๋ ๋ฐฉ๋ฒ์ ์์๋ด
๋๋ค.
View Scaffolding ๊ฐ์
์๋ ์์ฑ
๋ชฉ๋ก/ํผ ๋ทฐ์น UI๋ก ์์ฑ
ํ์
์์
Entity ๊ธฐ๋ฐ์ปดํ์ผ ํ์ ๊ฒ์ฆ
์ปค์คํฐ๋ง์ด์ง
์์ฑ ํ ์์ ์์ ํ ์ ์ด
์ผ๊ด์ฑ
ํต์ผ๋ UI/UX๋ฒ ์คํธ ํ๋ํฐ์ค
View Scaffolding์ด๋?
๋ฌธ์ : ๋ฐ๋ณต์ ์ธ CRUD ๋ทฐ ์์ฑ
์ ํต์ ์ธ ๊ฐ๋ฐ์์๋ ๊ฐ ์ํฐํฐ๋ง๋ค ๋น์ทํ CRUD ํ๋ฉด์ ์๋์ผ๋ก ๋ฐ๋ณต ์์ฑํด์ผ ํฉ๋๋ค.
์๋ ์์ฑ ์ ๋ฌธ์ ์ :
- ์๊ฐ ๋ญ๋น: ๋ชฉ๋ก, ์์ฑ, ์์ ํ์ด์ง๋ฅผ ๋ชจ๋ ์์ผ๋ก ์์ฑ
- ์ผ๊ด์ฑ ๋ถ์กฑ: ๊ฐ๋ฐ์๋ง๋ค ๋ค๋ฅธ ์คํ์ผ, ๋ค๋ฅธ ๊ตฌ์กฐ
- ์ ์ง๋ณด์ ์ด๋ ค์: ์ํฐํฐ ๋ณ๊ฒฝ ์ ๋ชจ๋ ๋ทฐ ์์ ํ์
- ์ค์ ๊ฐ๋ฅ์ฑ: ํ๋ ๋๋ฝ, ์๋ชป๋ ํ์
, ์ ํจ์ฑ ๊ฒ์ฌ ๋๋ฝ
- ๋ณด์ผ๋ฌํ๋ ์ดํธ: ํ
์ด๋ธ, ํผ, ๋ฒํผ ๋ฑ ๋ฐ๋ณต ์ฝ๋
์์: User ์ํฐํฐ ํ๋์ ํ์ํ ํ์ด์ง
/admin/users โ ๋ชฉ๋ก ํ์ด์ง (ํ
์ด๋ธ, ํ์ด์ง๋ค์ด์
, ๊ฒ์)
/admin/users/form โ ์์ฑ/์์ ํ์ด์ง (ํผ, ์ ํจ์ฑ ๊ฒ์ฌ)
๊ฐ ํ์ด์ง๋ง๋ค 100~200์ค ์ด์์ ์ฝ๋๊ฐ ํ์ํ๊ณ , ์ด๋ฅผ ๋ชจ๋ ์ํฐํฐ์ ๋ฐ๋ณตํด์ผ ํฉ๋๋ค.
ํด๊ฒฐ: ์๋ ์ค์บํด๋ฉ
Sonamu๋ Entity ์ ์๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ๊ด๋ฆฌ์ ํ์ด์ง ๋ทฐ๋ฅผ ์๋ ์์ฑํฉ๋๋ค.
์ฅ์ :
- โจ ์ฆ์ ์์ฑ: ์น UI๋ก ํด๋ฆญ๋ง์ผ๋ก ๋ทฐ ์์ฑ
- โจ ํ์
์์ : Entity ํ์
์ด ์ปดํฌ๋ํธ์ ๊ทธ๋๋ก ๋ฐ์
- โจ ์ผ๊ด๋ UI: ํต์ผ๋ ๋์์ธ ์์คํ
- โจ ๋ฒ ์คํธ ํ๋ํฐ์ค: ๊ฒ์ฆ๋ ํจํด ์ ์ฉ
- โจ ์ปค์คํฐ๋ง์ด์ง ๊ฐ๋ฅ: ์์ฑ ํ ํ์ํ ๋ถ๋ถ๋ง ์์
Sonamu์ ์ค์บํด๋ฉ ์ฒ ํ:
โ80%๋ ์๋์ผ๋ก, 20%๋ ์ปค์คํฐ๋ง์ด์งโ
๋๋ถ๋ถ์ CRUD ๊ธฐ๋ฅ์ ์๋ ์์ฑํ๊ณ , ํน๋ณํ ๋น์ฆ๋์ค ๋ก์ง๋ง ์ถ๊ฐ๋ก ๊ตฌํํฉ๋๋ค.
์ค์บํด๋ฉ ์คํ
Sonamu ์น UI ์ฌ์ฉ
Sonamu๋ ์น ๊ธฐ๋ฐ ๊ด๋ฆฌ UI๋ฅผ ์ ๊ณตํฉ๋๋ค.
# Sonamu ๊ฐ๋ฐ ์๋ฒ ์คํ
pnpm dev
# ๋ธ๋ผ์ฐ์ ์์ Sonamu UI ์ ์
# http://localhost:3000/sonamu
์น UI์์ ๋ทฐ ์์ฑ:
- Sonamu UI ์ ์
- โScaffoldโ ๋ฉ๋ด ์ ํ
- Entity ์ ํ (์: User)
- โGenerate View Listโ ๋ฒํผ ํด๋ฆญ โ
index.tsx ์์ฑ
- โGenerate View Formโ ๋ฒํผ ํด๋ฆญ โ
form.tsx ์์ฑ
CLI ๋ช
๋ น์ด๋ ํ์ฌ ๋นํ์ฑํ๋์ด ์์ต๋๋ค. ๋ทฐ ์์ฑ์ Sonamu ์น UI๋ฅผ ํตํด์๋ง ๊ฐ๋ฅํฉ๋๋ค.
์์ฑ๋๋ ํ์ผ ๊ตฌ์กฐ
web/src/routes/admin/
โโโ users/
โโโ index.tsx # ๋ชฉ๋ก ํ์ด์ง (ํ
์ด๋ธ ํฌํจ)
โโโ form.tsx # ์์ฑ/์์ ํตํฉ ํผ
์ค์ ์์ฑ๋๋ ํ์ผ:
- index.tsx: ๋ชฉ๋ก ํ
์ด๋ธ์ด ์ง์ ํฌํจ๋ ํ์ด์ง
- form.tsx: ์์ฑ๊ณผ ์์ ์ ํ๋์ ํผ์ผ๋ก ํตํฉ
์ฃผ์:
- ๋ณ๋์ ์ปดํฌ๋ํธ ํ์ผ(UserTable.tsx, UserForm.tsx)์ ์์ฑ๋์ง ์์
- ์์ธ ํ์ด์ง([id].tsx)๋ ์๋ ์์ฑ๋์ง ์์
- ๋ชจ๋ ๋ก์ง์ด ๋ ๊ฐ์ ํ์ด์ง ํ์ผ์ ํตํฉ๋จ
์์ฑ๋ ๋ทฐ ๊ตฌ์กฐ
๋ชฉ๋ก ํ์ด์ง (index.tsx)
๋ชฉ๋ก์ ํ
์ด๋ธ๋ก ํ์ํ๊ณ ํ์ด์ง๋ค์ด์
, ๊ฒ์ ๊ธฐ๋ฅ์ ์ ๊ณตํฉ๋๋ค.
// web/src/routes/admin/users/index.tsx (์๋ ์์ฑ)
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { UserService } from "@/services/services.generated";
export default function UsersPage() {
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
// TanStack Query Hook ์ฌ์ฉ
const { data: users, isLoading } = UserService.useUsers("A", {
page,
pageSize: 20,
search,
});
return (
<div className="admin-page">
<div className="header">
<h1>Users</h1>
<button onClick={() => navigate("/admin/users/form")}>
Create New User
</button>
</div>
<div className="search">
<input
type="text"
placeholder="Search users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* ํ
์ด๋ธ์ด ์ง์ ํฌํจ๋จ */}
<table className="data-table">
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Username</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users?.map((user) => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.email}</td>
<td>{user.username}</td>
<td>
<button onClick={() => navigate(`/admin/users/form?id=${user.id}`)}>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
{/* ํ์ด์ง๋ค์ด์
*/}
<div className="pagination">
<button disabled={page === 1} onClick={() => setPage(page - 1)}>
Previous
</button>
<span>Page {page}</span>
<button onClick={() => setPage(page + 1)}>
Next
</button>
</div>
</div>
);
}
๊ธฐ๋ฅ:
- ํ์ด์ง๋ค์ด์
- ๊ฒ์ ํํฐ
- โCreate Newโ ๋ฒํผ
- โEditโ ๋ฒํผ (๊ฐ ํ)
- ํ
์ด๋ธ์ด ํ์ด์ง์ ์ง์ ํฌํจ (๋ณ๋ ์ปดํฌ๋ํธ ์์)
์์ฑ๊ณผ ์์ ์ ํ๋์ ํผ์ผ๋ก ํตํฉํฉ๋๋ค.
// web/src/routes/admin/users/form.tsx (์๋ ์์ฑ)
import { useState, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { UserService } from "@/services/services.generated";
export default function UserFormPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const userId = searchParams.get("id");
const [formData, setFormData] = useState({
email: "",
username: "",
bio: "",
});
const [saving, setSaving] = useState(false);
// ์์ ๋ชจ๋: ๊ธฐ์กด ๋ฐ์ดํฐ ๋ก๋
useEffect(() => {
if (userId) {
UserService.getUser("C", parseInt(userId)).then((user) => {
setFormData({
email: user.email,
username: user.username,
bio: user.bio || "",
});
});
}
}, [userId]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
if (userId) {
// ์์
await UserService.update(parseInt(userId), formData);
} else {
// ์์ฑ
await UserService.create(formData);
}
navigate("/admin/users");
} catch (error) {
alert("Failed to save");
} finally {
setSaving(false);
}
}
return (
<div className="admin-page">
<div className="header">
<h1>{userId ? "Edit User" : "Create New User"}</h1>
<button onClick={() => navigate("/admin/users")}>
Back to List
</button>
</div>
<form onSubmit={handleSubmit} className="entity-form">
<div className="form-field">
<label>Email *</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
</div>
<div className="form-field">
<label>Username *</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
required
/>
</div>
<div className="form-field">
<label>Bio</label>
<textarea
value={formData.bio}
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
rows={4}
/>
</div>
<div className="form-actions">
<button type="submit" disabled={saving}>
{saving ? "Saving..." : "Save"}
</button>
</div>
</form>
</div>
);
}
๊ธฐ๋ฅ:
- ์์ฑ/์์ ํตํฉ: URL ํ๋ผ๋ฏธํฐ(
?id=)๋ก ๋ชจ๋ ๊ตฌ๋ถ
- ์์ ์ ๊ธฐ์กด ๋ฐ์ดํฐ ์๋ ๋ก๋
- ์ ํจ์ฑ ๊ฒ์ฌ
- ์ ์ฅ ์ค ๋ฒํผ ๋นํ์ฑํ
- โBack to Listโ ๋ฒํผ
์์ฑ ํ์ผ์ ํน์ง
ํตํฉ ๊ตฌ์กฐ
Sonamu์ ์ค์บํด๋ฉ์ ์ต์ํ์ ํ์ผ๋ก ๊ตฌ์ฑ๋ฉ๋๋ค:
์ ํต์ ์ธ ๊ตฌ์กฐ (๋ณต์กํจ):
pages/users/
โโโ index.tsx
โโโ [id].tsx
โโโ new.tsx
โโโ [id]/edit.tsx
components/users/
โโโ UserTable.tsx
โโโ UserDetail.tsx
โโโ UserForm.tsx
Sonamu ๊ตฌ์กฐ (๊ฐ๊ฒฐํจ):
admin/users/
โโโ index.tsx # ํ
์ด๋ธ ํฌํจ
โโโ form.tsx # ์์ฑ/์์ ํตํฉ
ํตํฉ ๊ตฌ์กฐ์ ์ฅ์ :
- ํ์ผ ์ ์ต์ํ
- ํ์ผ ๊ฐ ์ด๋ ๊ฐ์
- ์ ์ง๋ณด์ ๊ฐํธ
- ์ฝ๋ ์์ง๋ ๋์
๋ผ์ฐํ
ํจํด
GET /admin/users โ index.tsx (๋ชฉ๋ก)
GET /admin/users/form โ form.tsx (์์ฑ)
GET /admin/users/form?id=1 โ form.tsx (์์ )
URL ํ๋ผ๋ฏธํฐ๋ก ์์ฑ/์์ ๋ชจ๋๋ฅผ ๊ตฌ๋ถํฉ๋๋ค.
์ปค์คํฐ๋ง์ด์ง
์์ฑ ํ ์์
์์ฑ๋ ํ์ผ์ ์์ ํ ์์ ๊ฐ๋ฅํฉ๋๋ค.
// web/src/routes/admin/users/index.tsx (์ปค์คํฐ๋ง์ด์ง)
export default function UsersPage() {
// ์ถ๊ฐ: ์ญํ ๋ณ ํํฐ
const [role, setRole] = useState<string | null>(null);
const { data: users } = UserService.useUsers("A", {
page,
pageSize: 20,
search,
role, // ์ปค์คํ
ํ๋ผ๋ฏธํฐ
});
return (
<div>
{/* ์ถ๊ฐ: ์ญํ ํํฐ UI */}
<select value={role || ""} onChange={(e) => setRole(e.target.value || null)}>
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
{/* ๊ธฐ์กด ํ
์ด๋ธ... */}
</div>
);
}
๋ณ๋ ์ปดํฌ๋ํธ๋ก ๋ถ๋ฆฌ
ํ์ํ๋ฉด ์๋์ผ๋ก ์ปดํฌ๋ํธ๋ฅผ ๋ถ๋ฆฌํ ์ ์์ต๋๋ค:
// components/users/UserTable.tsx (์๋ ์์ฑ)
export function UserTable({ users }: { users: User[] }) {
return (
<table className="data-table">
{/* ํ
์ด๋ธ ๋ด์ฉ */}
</table>
);
}
// admin/users/index.tsx (์์ )
import { UserTable } from "@/components/users/UserTable";
export default function UsersPage() {
const { data: users } = UserService.useUsers("A", { page, pageSize: 20 });
return (
<div>
<UserTable users={users || []} />
</div>
);
}
์์ธ ํ์ด์ง ์ถ๊ฐ
์๋ ์์ฑ๋์ง ์๋ ์์ธ ํ์ด์ง๋ ์๋์ผ๋ก ์ถ๊ฐ:
// admin/users/[id].tsx (์๋ ์์ฑ)
import { useParams } from "react-router-dom";
import { UserService } from "@/services/services.generated";
export default function UserDetailPage() {
const { id } = useParams();
const { data: user } = UserService.useUser("C", parseInt(id!));
return (
<div>
<h1>{user?.username}</h1>
<p>{user?.email}</p>
<p>{user?.bio}</p>
</div>
);
}
๋ฒ ์คํธ ํ๋ํฐ์ค
1. ์์ฑ ํ ์ฆ์ Git ์ปค๋ฐ
# Sonamu UI์์ ๋ทฐ ์์ฑ
git add web/src/routes/admin/users/
git commit -m "feat: scaffold User views"
์ด๋ ๊ฒ ํ๋ฉด ์ปค์คํฐ๋ง์ด์ง ๋ด์ญ์ ์ถ์ ํ ์ ์์ต๋๋ค.
2. ๊ณตํต ๋ก์ง์ Hook์ผ๋ก ์ถ์ถ
// hooks/useEntityList.ts
export function useEntityList<T>(
entityName: string,
useHook: any
) {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const { data, isLoading } = useHook("A", {
page,
pageSize: 20,
search,
});
return { data, isLoading, page, setPage, search, setSearch };
}
3. ์คํ์ผ์ ์ ์ญ์ผ๋ก ๊ด๋ฆฌ
/* styles/admin.css */
.admin-page { }
.data-table { }
.entity-form { }
๋ชจ๋ ๊ด๋ฆฌ์ ํ์ด์ง์์ ์ผ๊ด๋ ์คํ์ผ ์ฌ์ฉ.
์ฃผ์์ฌํญ
View Scaffolding ์ฌ์ฉ ์ ์ฃผ์์ฌํญ:
- ์น UI์์๋ง ์์ฑ ๊ฐ๋ฅ (CLI ๋ช
๋ น์ด ๋นํ์ฑํ๋จ)
- index.tsx, form.tsx๋ง ์์ฑ (๋ณ๋ ์ปดํฌ๋ํธ ์์)
- ์์ธ ํ์ด์ง๋ ์๋ ์ถ๊ฐ ํ์
- ์ฌ์์ฑํ๋ฉด ์ปค์คํฐ๋ง์ด์ง ์์ค (Git ์ปค๋ฐ ํ์)
- ์์ฑ ํ ํ์์ ๋ฐ๋ผ ์ปดํฌ๋ํธ ๋ถ๋ฆฌ ๊ถ์ฅ
๋ค์ ๋จ๊ณ