메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
μ™Έλž˜ν‚€ 관계λ₯Ό μ²˜λ¦¬ν•˜κΈ° μœ„ν•œ ID Async Select μ»΄ν¬λ„ŒνŠΈ μ‚¬μš©λ²•μ„ μ•Œμ•„λ΄…λ‹ˆλ‹€.

ID Async Select κ°œμš”

비동기 λ‘œλ”©

API ν˜ΈμΆœμžλ™ μ™„μ„±

νƒ€μž… μ•ˆμ „

Entity 기반ID νƒ€μž… 보μž₯

검색 지원

μ‹€μ‹œκ°„ ν•„ν„°λ§λ””λ°”μš΄μŠ€

닀쀑 선택

단일/닀쀑 λͺ¨λ“œλ°°μ—΄ λ°˜ν™˜

ID Async Selectλž€?

문제: μ™Έλž˜ν‚€ μ„ νƒμ˜ λ³΅μž‘μ„±

λ°μ΄ν„°λ² μ΄μŠ€μ—μ„œ μ™Έλž˜ν‚€ 관계λ₯Ό μ²˜λ¦¬ν•  λ•Œ, ν”„λ‘ νŠΈμ—”λ“œμ—μ„œλŠ” κ΄€λ ¨ μ—”ν‹°ν‹°λ₯Ό 선택해야 ν•©λ‹ˆλ‹€. 전톡적인 λ°©μ‹μ˜ 문제점:
// ❌ λͺ¨λ“  μ‚¬μš©μžλ₯Ό ν•œ λ²ˆμ— λ‘œλ“œ (λΉ„νš¨μœ¨μ )
const [users, setUsers] = useState([]);

useEffect(() => {
  userService.list({ pageSize: 1000 }).then(({ users }) => {
    setUsers(users); // 1000λͺ… μ „λΆ€ λ‘œλ“œ!
  });
}, []);

return (
  <select>
    {users.map((user) => (
      <option key={user.id} value={user.id}>
        {user.username}
      </option>
    ))}
  </select>
);
문제점:
  1. μ„±λŠ₯: 데이터가 많으면 λ‘œλ”© μ‹œκ°„ 증가
  2. λ©”λͺ¨λ¦¬: λΆˆν•„μš”ν•œ 데이터λ₯Ό λͺ¨λ‘ λ©”λͺ¨λ¦¬μ— 적재
  3. UX: μ‚¬μš©μžκ°€ μ›ν•˜λŠ” ν•­λͺ©μ„ μ°ΎκΈ° 어렀움
  4. ν™•μž₯μ„±: 데이터가 계속 λŠ˜μ–΄λ‚˜λ©΄ 감당 λΆˆκ°€

ν•΄κ²°: ID Async Select

Sonamu의 ID Async SelectλŠ” ν•„μš”ν•  λ•Œλ§Œ λΉ„λ™κΈ°λ‘œ λ‘œλ“œν•˜κ³  검색 κΈ°λŠ₯을 μ œκ³΅ν•©λ‹ˆλ‹€.
// βœ… 검색어 기반으둜 ν•„μš”ν•œ λ°μ΄ν„°λ§Œ λ‘œλ“œ
import { IdAsyncSelect } from "@sonamu-kit/react-components";
import { UserAsyncIdConfig } from "@/services/services.generated";

<IdAsyncSelect
  config={UserAsyncIdConfig}
  subset="A"
  value={selectedUserId}
  onValueChange={setSelectedUserId}
/>
μž₯점:
  1. ✨ μ„±λŠ₯: μ²˜μŒμ—λŠ” μ΅œμ†Œν•œλ§Œ λ‘œλ“œ, 검색 μ‹œ ν•„μš”ν•œ κ²ƒλ§Œ
  2. ✨ μ‚¬μš©μž κ²½ν—˜: μžλ™μ™„μ„±μœΌλ‘œ λΉ λ₯Έ 선택
  3. ✨ νƒ€μž… μ•ˆμ „: Entity νƒ€μž…μ΄ μžλ™μœΌλ‘œ 적용
  4. ✨ ν™•μž₯μ„±: 데이터가 아무리 λ§Žμ•„λ„ λ¬Έμ œμ—†μŒ

κΈ°λ³Έ μ‚¬μš©λ²•

ν•„μˆ˜ Props

ID Async SelectλŠ” λ‹€μŒ 두 κ°€μ§€ 핡심 propsλ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€:
  • config: services.generated.tsμ—μ„œ μžλ™ μƒμ„±λœ AsyncIdConfig 객체
  • subset: μ‘°νšŒν•  Subset ν‚€ (예: β€œA”, β€œD” λ“±)
import { IdAsyncSelect } from "@sonamu-kit/react-components";
import { UserAsyncIdConfig } from "@/services/services.generated";

<IdAsyncSelect
  config={UserAsyncIdConfig}
  subset="A"
  value={userId}
  onValueChange={setUserId}
/>

단일 선택

ν•˜λ‚˜μ˜ μ—”ν‹°ν‹°λ₯Ό μ„ νƒν•©λ‹ˆλ‹€.
import { IdAsyncSelect } from "@sonamu-kit/react-components";
import { UserAsyncIdConfig } from "@/services/services.generated";
import { useState } from "react";

function PostForm() {
  const [authorId, setAuthorId] = useState<number | null>(null);

  return (
    <div>
      <label>Author</label>
      <IdAsyncSelect
        config={UserAsyncIdConfig}
        subset="A"
        value={authorId}
        onValueChange={setAuthorId}
        displayField="username"
        placeholder="Select author..."
      />
    </div>
  );
}
λ™μž‘ 방식:
  1. μ‚¬μš©μžκ°€ νƒ€μ΄ν•‘ν•˜λ©΄ 검색 API 호좜
  2. 검색 κ²°κ³Όλ₯Ό λ“œλ‘­λ‹€μš΄μ— ν‘œμ‹œ
  3. 선택 μ‹œ ID만 μ €μž₯ (λ©”λͺ¨λ¦¬ 효율적)

닀쀑 선택

μ—¬λŸ¬ μ—”ν‹°ν‹°λ₯Ό μ„ νƒν•©λ‹ˆλ‹€.
import { TagAsyncIdConfig } from "@/services/services.generated";

function PostForm() {
  const [tagIds, setTagIds] = useState<number[]>([]);

  return (
    <div>
      <label>Tags</label>
      <IdAsyncSelect
        config={TagAsyncIdConfig}
        subset="A"
        value={tagIds}
        onValueChange={setTagIds}
        displayField="name"
        multiple
        placeholder="Select tags..."
      />
    </div>
  );
}
닀쀑 선택 νŠΉμ§•:
  • μ„ νƒλœ ν•­λͺ©μ„ νƒœκ·Έ ν˜•νƒœλ‘œ ν‘œμ‹œ
  • X λ²„νŠΌμœΌλ‘œ κ°œλ³„ 제거
  • λ°°μ—΄ ν˜•νƒœλ‘œ ID λ°˜ν™˜ (number[])

μ΄ˆκΈ°κ°’ μ„€μ •

κΈ°μ‘΄ 데이터 μˆ˜μ • μ‹œ μ΄ˆκΈ°κ°’μ„ μ„€μ •ν•©λ‹ˆλ‹€.
function EditPostForm({ post }: { post: Post }) {
  const [authorId, setAuthorId] = useState(post.author_id);

  return (
    <IdAsyncSelect
      config={UserAsyncIdConfig}
      subset="A"
      value={authorId}
      onValueChange={setAuthorId}
      displayField="username"
    />
  );
}
μ΄ˆκΈ°κ°’ 처리:
  • value에 IDλ₯Ό μ „λ‹¬ν•˜λ©΄ μžλ™μœΌλ‘œ ν•΄λ‹Ή μ—”ν‹°ν‹° λ‘œλ“œ
  • λ ˆμ΄λΈ” ν‘œμ‹œλ₯Ό μœ„ν•΄ λ°±κ·ΈλΌμš΄λ“œμ—μ„œ 단건 쑰회
  • λ‘œλ”© μ€‘μ—λŠ” ID만 ν‘œμ‹œ

displayField μ˜΅μ…˜

ν•„λ“œλͺ…μœΌλ‘œ μ§€μ •

κ°€μž₯ κ°„λ‹¨ν•œ λ°©λ²•μœΌλ‘œ, ν‘œμ‹œν•  ν•„λ“œλͺ…을 λ¬Έμžμ—΄λ‘œ μ§€μ •ν•©λ‹ˆλ‹€.
<IdAsyncSelect
  config={UserAsyncIdConfig}
  subset="A"
  value={userId}
  onValueChange={setUserId}
  displayField="username"
/>

콜백 ν•¨μˆ˜λ‘œ μ§€μ •

λ³΅μž‘ν•œ λ ˆμ΄λΈ”μ΄ ν•„μš”ν•œ 경우 콜백 ν•¨μˆ˜λ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€.
<IdAsyncSelect
  config={EmployeeAsyncIdConfig}
  subset="A"
  value={employeeId}
  onValueChange={setEmployeeId}
  displayField={(row) =>
    `${row.employee_number} (μ†Œμ† λΆ€μ„œ: ${row.department?.name})`
  }
/>

μžλ™ 탐지 (displayField μƒλž΅)

displayFieldλ₯Ό μƒλž΅ν•˜λ©΄ μžλ™μœΌλ‘œ μ μ ˆν•œ ν•„λ“œλ₯Ό νƒμ§€ν•©λ‹ˆλ‹€.
// displayField μƒλž΅ μ‹œ μžλ™ 탐지 적용
<IdAsyncSelect
  config={UserAsyncIdConfig}
  subset="A"
  value={userId}
  onValueChange={setUserId}
/>
탐지 μš°μ„ μˆœμœ„:
  1. name-like ν•„λ“œ: name, title, label, display_name, username
  2. 첫 번째 string νƒ€μž… 컬럼 (id μ œμ™Έ)
  3. fallback: id

κ³ κΈ‰ μ‚¬μš©λ²•

baseListParams둜 검색 쑰건 전달

κΈ°λ³Έ 검색 νŒŒλΌλ―Έν„°λ₯Ό μ„€μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
<IdAsyncSelect
  config={UserAsyncIdConfig}
  subset="A"
  value={userId}
  onValueChange={setUserId}
  displayField="username"
  // 검색 μ‹œ μΆ”κ°€ 쑰건 적용
  baseListParams={{
    search: "username",
    role: "admin"
  }}
/>

onRowChange둜 전체 Row 데이터 λ°›κΈ°

ID뿐만 μ•„λ‹ˆλΌ μ„ νƒλœ Row 전체 데이터가 ν•„μš”ν•œ 경우:
function SelectWithRowData() {
  const [userId, setUserId] = useState<number | null>(null);
  const [selectedUser, setSelectedUser] = useState<User | undefined>();

  return (
    <IdAsyncSelect
      config={UserAsyncIdConfig}
      subset="A"
      value={userId}
      onValueChange={setUserId}
      onRowChange={(row) => setSelectedUser(row as User)}
      displayField="username"
    />
  );
}

valueField λ³€κ²½

기본적으둜 id ν•„λ“œλ₯Ό κ°’μœΌλ‘œ μ‚¬μš©ν•˜μ§€λ§Œ, λ‹€λ₯Έ ν•„λ“œλ₯Ό μ‚¬μš©ν•  μˆ˜λ„ μžˆμŠ΅λ‹ˆλ‹€.
<IdAsyncSelect
  config={UserAsyncIdConfig}
  subset="A"
  value={userUuid}
  onValueChange={setUserUuid}
  valueField="uuid"
  displayField="username"
/>

νƒ€μž… μ•ˆμ „μ„±

AsyncIdConfig ꡬ쑰

services.generated.tsμ—μ„œ μžλ™ μƒμ„±λ˜λŠ” ConfigλŠ” λ‹€μŒ ꡬ쑰λ₯Ό κ°–μŠ΅λ‹ˆλ‹€:
// services.generated.tsμ—μ„œ μžλ™ 생성
export const UserAsyncIdConfig: AsyncIdConfig<
  "A" | "D",              // TSubsetKey - μ‚¬μš© κ°€λŠ₯ν•œ Subset ν‚€
  UserSubsetMapping,      // TSubsetMapping - Subset별 νƒ€μž… λ§€ν•‘
  UserListParams          // TListParams - 검색 νŒŒλΌλ―Έν„° νƒ€μž…
> = {
  placeholderKey: "user.placeholder",
  useList: UserService.useList,
};

νƒ€μž… μΆ”λ‘  ν™œμš©

Configλ₯Ό 톡해 νƒ€μž…μ΄ μžλ™μœΌλ‘œ μΆ”λ‘ λ©λ‹ˆλ‹€:
<IdAsyncSelect
  config={UserAsyncIdConfig}
  subset="A"                    // "A" | "D" 쀑 선택
  displayField="username"       // UserSubsetA의 ν•„λ“œλ§Œ μžλ™μ™„μ„±
  baseListParams={{ role: "admin" }}  // UserListParams 기반 μžλ™μ™„μ„±
  value={userId}
  onValueChange={setUserId}
  onRowChange={(row) => {
    // row의 νƒ€μž…μ΄ UserSubsetA둜 좔둠됨
    console.log(row.username);
  }}
/>

μ‹€μ „ 예제

κ²Œμ‹œκΈ€ μž‘μ„± 폼

import { useState } from "react";
import { IdAsyncSelect } from "@sonamu-kit/react-components";
import {
  UserAsyncIdConfig,
  CategoryAsyncIdConfig,
  TagAsyncIdConfig
} from "@/services/services.generated";
import { postService } from "@/services/post.service";

function CreatePostForm() {
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [authorId, setAuthorId] = useState<number | null>(null);
  const [categoryId, setCategoryId] = useState<number | null>(null);
  const [tagIds, setTagIds] = useState<number[]>([]);

  async function handleSubmit() {
    if (!authorId || !categoryId) {
      alert("Please select author and category");
      return;
    }

    await postService.create({
      title,
      content,
      author_id: authorId,
      category_id: categoryId,
      tag_ids: tagIds,
    });
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Title</label>
        <input
          value={title}
          onChange={(e) => setTitle(e.target.value)}
        />
      </div>

      <div>
        <label>Content</label>
        <textarea
          value={content}
          onChange={(e) => setContent(e.target.value)}
        />
      </div>

      {/* μž‘μ„±μž 선택 (단일) */}
      <div>
        <label>Author *</label>
        <IdAsyncSelect
          config={UserAsyncIdConfig}
          subset="A"
          value={authorId}
          onValueChange={setAuthorId}
          displayField="username"
          placeholder="Select author..."
        />
      </div>

      {/* μΉ΄ν…Œκ³ λ¦¬ 선택 (단일) */}
      <div>
        <label>Category *</label>
        <IdAsyncSelect
          config={CategoryAsyncIdConfig}
          subset="A"
          value={categoryId}
          onValueChange={setCategoryId}
          displayField="name"
          placeholder="Select category..."
        />
      </div>

      {/* νƒœκ·Έ 선택 (닀쀑) */}
      <div>
        <label>Tags</label>
        <IdAsyncSelect
          config={TagAsyncIdConfig}
          subset="A"
          value={tagIds}
          onValueChange={setTagIds}
          displayField="name"
          multiple
          placeholder="Select tags..."
        />
      </div>

      <button type="submit">Create Post</button>
    </form>
  );
}

계측적 선택

λΆ€λͺ¨λ₯Ό μ„ νƒν•œ ν›„ μžμ‹μ„ μ„ νƒν•˜λŠ” νŒ¨ν„΄μž…λ‹ˆλ‹€.
import {
  DepartmentAsyncIdConfig,
  EmployeeAsyncIdConfig
} from "@/services/services.generated";

function EmployeeAssignForm() {
  const [departmentId, setDepartmentId] = useState<number | null>(null);
  const [employeeId, setEmployeeId] = useState<number | null>(null);

  return (
    <div>
      {/* 1단계: λΆ€μ„œ 선택 */}
      <div>
        <label>Department</label>
        <IdAsyncSelect
          config={DepartmentAsyncIdConfig}
          subset="A"
          value={departmentId}
          onValueChange={(id) => {
            setDepartmentId(id);
            setEmployeeId(null); // λΆ€μ„œ λ³€κ²½ μ‹œ 직원 μ΄ˆκΈ°ν™”
          }}
          displayField="name"
        />
      </div>

      {/* 2단계: 직원 선택 (λΆ€μ„œ 선택 ν›„μ—λ§Œ) */}
      {departmentId && (
        <div>
          <label>Employee</label>
          <IdAsyncSelect
            config={EmployeeAsyncIdConfig}
            subset="A"
            value={employeeId}
            onValueChange={setEmployeeId}
            displayField="employee_number"
            // μ„ νƒλœ λΆ€μ„œμ— μ†ν•œ μ§μ›λ§Œ 쑰회
            baseListParams={{ department_id: departmentId }}
          />
        </div>
      )}
    </div>
  );
}

폼과 ν•¨κ»˜ μ‚¬μš©

Sonamu의 useFormκ³Ό ν•¨κ»˜ μ‚¬μš©ν•˜λŠ” μ˜ˆμ œμž…λ‹ˆλ‹€.
import { useForm } from "@sonamu-kit/react-components";
import { CompanyAsyncIdConfig } from "@/services/services.generated";

function CompanySelectForm() {
  const form = useForm({
    defaultValues: { company_id: null as number | null },
  });

  return (
    <IdAsyncSelect
      config={CompanyAsyncIdConfig}
      subset="A"
      displayField="name"
      {...form.register("company_id")}
      placeholder="νšŒμ‚¬λ₯Ό κ²€μƒ‰ν•˜μ„Έμš”"
    />
  );
}

Props 레퍼런슀

Propνƒ€μž…ν•„μˆ˜μ„€λͺ…
configAsyncIdConfigβœ“Entity의 AsyncIdConfig 객체
subsetstringβœ“μ‘°νšŒν•  Subset ν‚€
valueTValue | TValue[] | nullμ„ νƒλœ κ°’
onValueChange(value) => voidκ°’ λ³€κ²½ 콜백
onRowChange(row) => voidRow 데이터 λ³€κ²½ 콜백
displayFieldstring | ((row) => string)ν‘œμ‹œ ν•„λ“œ (μƒλž΅ μ‹œ μžλ™ 탐지)
valueFieldstringκ°’ ν•„λ“œ (κΈ°λ³Έ: β€œid”)
baseListParamsobject검색 μ‹œ μΆ”κ°€ νŒŒλΌλ―Έν„°
multipleboolean닀쀑 선택 λͺ¨λ“œ
placeholderstringν”Œλ ˆμ΄μŠ€ν™€λ” ν…μŠ€νŠΈ
clearableboolean선택 ν•΄μ œ κ°€λŠ₯ μ—¬λΆ€
disabledbooleanλΉ„ν™œμ„±ν™” μ—¬λΆ€
classNamestringμΆ”κ°€ CSS 클래슀

μ£Όμ˜μ‚¬ν•­

ID Async Select μ‚¬μš© μ‹œ μ£Όμ˜μ‚¬ν•­:
  1. configλŠ” services.generated.tsμ—μ„œ μžλ™ μƒμ„±λœ 객체 μ‚¬μš©
  2. subset은 Entity에 μ •μ˜λœ μœ νš¨ν•œ Subset ν‚€μ—¬μ•Ό 함
  3. 닀쀑 선택 μ‹œ λ°°μ—΄ νƒ€μž… 확인
  4. displayField둜 μ§€μ •ν•œ ν•„λ“œλŠ” ν•΄λ‹Ή Subset에 ν¬ν•¨λ˜μ–΄ μžˆμ–΄μ•Ό 함
  5. μ΄ˆκΈ°κ°’ λ‘œλ”© μ‹€νŒ¨ μ‹œ μ—λŸ¬ 처리 ν•„μˆ˜

λ‹€μŒ 단계