메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
Sonamu의 μŠ€μΊν΄λ”© μ‹œμŠ€ν…œμœΌλ‘œ κ΄€λ¦¬μž νŽ˜μ΄μ§€μ˜ λͺ©λ‘κ³Ό 폼 λ·°λ₯Ό μžλ™μœΌλ‘œ μƒμ„±ν•˜λŠ” 방법을 μ•Œμ•„λ΄…λ‹ˆλ‹€.

View Scaffolding κ°œμš”

μžλ™ 생성

λͺ©λ‘/폼 λ·°μ›Ή UI둜 생성

νƒ€μž… μ•ˆμ „

Entity 기반컴파일 νƒ€μž„ 검증

μ»€μŠ€ν„°λ§ˆμ΄μ§•

생성 ν›„ μˆ˜μ •μ™„μ „ν•œ μ œμ–΄

일관성

ν†΅μΌλœ UI/UX베슀트 ν”„λž™ν‹°μŠ€

View Scaffoldingμ΄λž€?

문제: 반볡적인 CRUD λ·° μž‘μ„±

전톡적인 κ°œλ°œμ—μ„œλŠ” 각 μ—”ν‹°ν‹°λ§ˆλ‹€ λΉ„μŠ·ν•œ CRUD 화면을 μˆ˜λ™μœΌλ‘œ 반볡 μž‘μ„±ν•΄μ•Ό ν•©λ‹ˆλ‹€. μˆ˜λ™ μž‘μ„± μ‹œ 문제점:
  1. μ‹œκ°„ λ‚­λΉ„: λͺ©λ‘, 생성, μˆ˜μ • νŽ˜μ΄μ§€λ₯Ό λͺ¨λ‘ μ†μœΌλ‘œ μž‘μ„±
  2. 일관성 λΆ€μ‘±: κ°œλ°œμžλ§ˆλ‹€ λ‹€λ₯Έ μŠ€νƒ€μΌ, λ‹€λ₯Έ ꡬ쑰
  3. μœ μ§€λ³΄μˆ˜ 어렀움: μ—”ν‹°ν‹° λ³€κ²½ μ‹œ λͺ¨λ“  λ·° μˆ˜μ • ν•„μš”
  4. μ‹€μˆ˜ κ°€λŠ₯μ„±: ν•„λ“œ λˆ„λ½, 잘λͺ»λœ νƒ€μž…, μœ νš¨μ„± 검사 λˆ„λ½
  5. λ³΄μΌλŸ¬ν”Œλ ˆμ΄νŠΈ: ν…Œμ΄λΈ”, 폼, λ²„νŠΌ λ“± 반볡 μ½”λ“œ
μ˜ˆμ‹œ: User μ—”ν‹°ν‹° ν•˜λ‚˜μ— ν•„μš”ν•œ νŽ˜μ΄μ§€
/admin/users        β†’ λͺ©λ‘ νŽ˜μ΄μ§€ (ν…Œμ΄λΈ”, νŽ˜μ΄μ§€λ„€μ΄μ…˜, 검색)
/admin/users/form   β†’ 생성/μˆ˜μ • νŽ˜μ΄μ§€ (폼, μœ νš¨μ„± 검사)
각 νŽ˜μ΄μ§€λ§ˆλ‹€ 100~200쀄 μ΄μƒμ˜ μ½”λ“œκ°€ ν•„μš”ν•˜κ³ , 이λ₯Ό λͺ¨λ“  엔티티에 λ°˜λ³΅ν•΄μ•Ό ν•©λ‹ˆλ‹€.

ν•΄κ²°: μžλ™ μŠ€μΊν΄λ”©

SonamuλŠ” Entity μ •μ˜λ₯Ό 기반으둜 κ΄€λ¦¬μž νŽ˜μ΄μ§€ λ·°λ₯Ό μžλ™ μƒμ„±ν•©λ‹ˆλ‹€. μž₯점:
  1. ✨ μ¦‰μ‹œ 생성: μ›Ή UI둜 클릭만으둜 λ·° 생성
  2. ✨ νƒ€μž… μ•ˆμ „: Entity νƒ€μž…μ΄ μ»΄ν¬λ„ŒνŠΈμ— κ·ΈλŒ€λ‘œ 반영
  3. ✨ μΌκ΄€λœ UI: ν†΅μΌλœ λ””μžμΈ μ‹œμŠ€ν…œ
  4. ✨ 베슀트 ν”„λž™ν‹°μŠ€: κ²€μ¦λœ νŒ¨ν„΄ 적용
  5. ✨ μ»€μŠ€ν„°λ§ˆμ΄μ§• κ°€λŠ₯: 생성 ν›„ ν•„μš”ν•œ λΆ€λΆ„λ§Œ μˆ˜μ •
Sonamu의 μŠ€μΊν΄λ”© μ² ν•™:
β€œ80%λŠ” μžλ™μœΌλ‘œ, 20%λŠ” μ»€μŠ€ν„°λ§ˆμ΄μ§•β€
λŒ€λΆ€λΆ„μ˜ CRUD κΈ°λŠ₯은 μžλ™ μƒμ„±ν•˜κ³ , νŠΉλ³„ν•œ λΉ„μ¦ˆλ‹ˆμŠ€ 둜직만 μΆ”κ°€λ‘œ κ΅¬ν˜„ν•©λ‹ˆλ‹€.

μŠ€μΊν΄λ”© μ‹€ν–‰

Sonamu μ›Ή UI μ‚¬μš©

SonamuλŠ” μ›Ή 기반 관리 UIλ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€.
# Sonamu 개발 μ„œλ²„ μ‹€ν–‰
pnpm dev

# λΈŒλΌμš°μ €μ—μ„œ Sonamu UI 접속
# http://localhost:3000/sonamu
μ›Ή UIμ—μ„œ λ·° 생성:
  1. Sonamu UI 접속
  2. β€œScaffold” 메뉴 선택
  3. Entity 선택 (예: User)
  4. β€œGenerate View List” λ²„νŠΌ 클릭 β†’ index.tsx 생성
  5. β€œ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” λ²„νŠΌ (각 ν–‰)
  • ν…Œμ΄λΈ”μ΄ νŽ˜μ΄μ§€μ— 직접 포함 (별도 μ»΄ν¬λ„ŒνŠΈ μ—†μŒ)

폼 νŽ˜μ΄μ§€ (form.tsx)

생성과 μˆ˜μ •μ„ ν•˜λ‚˜μ˜ 폼으둜 ν†΅ν•©ν•©λ‹ˆλ‹€.
// 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>
  );
}

useTypeForm으둜 폼 μ—…κ·Έλ ˆμ΄λ“œ (ꢌμž₯)

μžλ™ μƒμ„±λœ 폼은 기본적으둜 useState와 직접 이벀트 ν•Έλ“€λŸ¬λ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€. 이λ₯Ό useTypeForm으둜 μ—…κ·Έλ ˆμ΄λ“œν•˜λ©΄ 훨씬 κ°„κ²°ν•˜κ³  νƒ€μž… μ•ˆμ „ν•œ μ½”λ“œκ°€ λ©λ‹ˆλ‹€. μƒμ„±λœ μ½”λ“œ (κΈ°λ³Έ):
// admin/users/form.tsx (μžλ™ 생성)
const [formData, setFormData] = useState({
  email: "",
  username: "",
  bio: "",
});

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 (
  <form onSubmit={handleSubmit}>
    <input
      value={formData.email}
      onChange={(e) => setFormData({ ...formData, email: e.target.value })}
    />
    {/* ... */}
  </form>
);
useTypeForm으둜 μ—…κ·Έλ ˆμ΄λ“œ:
// admin/users/form.tsx (κ°œμ„ )
import { useTypeForm } from "@sonamu-kit/react-components/lib";
import { Input, Button } from "@sonamu-kit/react-components/components";
import { UserSaveParams } from "@/services/user/user.types";

const { register, submit, errors } = useTypeForm(UserSaveParams, {
  email: "",
  username: "",
  bio: "",
});

const saveMutation = UserService.useSaveMutation();

const handleSubmit = submit(async (form) => {
  saveMutation.mutate(
    { spa: [form] },
    {
      onSuccess: () => navigate("/admin/users"),
      onError: () => alert("Failed to save"),
    }
  );
});

return (
  <form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
    <div>
      <Input {...register("email")} placeholder="Email" />
      {errors.email && <span className="error">{errors.email.message}</span>}
    </div>
    {/* ... */}
    <Button type="submit" disabled={saveMutation.isPending}>
      {saveMutation.isPending ? "Saving..." : "Save"}
    </Button>
  </form>
);
κ°œμ„  효과:
  • βœ… μ½”λ“œλŸ‰ 50% κ°μ†Œ: useState, 이벀트 ν•Έλ“€λŸ¬ 제거
  • βœ… μžλ™ μœ νš¨μ„± 검사: SaveParams μŠ€ν‚€λ§ˆ 기반
  • βœ… νƒ€μž… μ•ˆμ „: 컴파일 νƒ€μž„ μ—λŸ¬ 감지
  • βœ… μ—λŸ¬ 처리 톡합: ν•„λ“œλ³„ μ—λŸ¬ λ©”μ‹œμ§€ μžλ™ ν‘œμ‹œ
μžλ™ μƒμ„±λœ νŒŒμΌμ„ Git에 μ»€λ°‹ν•œ ν›„ useTypeForm으둜 μ—…κ·Έλ ˆμ΄λ“œν•˜λ©΄, λ³€κ²½ 사항을 μ‰½κ²Œ 좔적할 수 μžˆμŠ΅λ‹ˆλ‹€.

별도 μ»΄ν¬λ„ŒνŠΈλ‘œ 뢄리

ν•„μš”ν•˜λ©΄ μˆ˜λ™μœΌλ‘œ μ»΄ν¬λ„ŒνŠΈλ₯Ό 뢄리할 수 μžˆμŠ΅λ‹ˆλ‹€:
// 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 μ‚¬μš© μ‹œ μ£Όμ˜μ‚¬ν•­:
  1. μ›Ή UIμ—μ„œλ§Œ 생성 κ°€λŠ₯ (CLI λͺ…λ Ήμ–΄ λΉ„ν™œμ„±ν™”λ¨)
  2. index.tsx, form.tsx만 생성 (별도 μ»΄ν¬λ„ŒνŠΈ μ—†μŒ)
  3. 상세 νŽ˜μ΄μ§€λŠ” μˆ˜λ™ μΆ”κ°€ ν•„μš”
  4. μž¬μƒμ„±ν•˜λ©΄ μ»€μŠ€ν„°λ§ˆμ΄μ§• 손싀 (Git 컀밋 ν•„μˆ˜)
  5. 생성 ν›„ ν•„μš”μ— 따라 μ»΄ν¬λ„ŒνŠΈ 뢄리 ꢌμž₯

λ‹€μŒ 단계