๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
OrderBySelect๋Š” ๋ชฉ๋ก ํŽ˜์ด์ง€์—์„œ ์ •๋ ฌ ๊ธฐ์ค€์„ ์„ ํƒํ•˜๋Š” ์ž๋™ ์ƒ์„ฑ ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. ๋ฐฑ์—”๋“œ์˜ OrderBy enum์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์ƒ์„ฑ๋˜๋ฉฐ, ํ•œ๊ธ€ ๋ ˆ์ด๋ธ”์ด ์ž๋™์œผ๋กœ ๋งคํ•‘๋ฉ๋‹ˆ๋‹ค.

ํ•ต์‹ฌ ๊ธฐ๋Šฅ

์ž๋™ ์ƒ์„ฑ

Entity ์ •์˜์—์„œ ์ž๋™ ์ƒ์„ฑOrderBy enum ๊ธฐ๋ฐ˜

ํ•œ๊ธ€ ๋ ˆ์ด๋ธ”

์ž๋™ ๋ ˆ์ด๋ธ” ๋งคํ•‘โ€œID์ตœ์‹ ์ˆœโ€, โ€œ์ƒ์„ฑ์ผ์˜ค๋ฆ„์ฐจ์ˆœโ€ ๋“ฑ

useListParams ํ†ตํ•ฉ

register ํŒจํ„ด ์ง€์›URL ๋™๊ธฐํ™” ์ž๋™

์ปค์Šคํ„ฐ๋งˆ์ด์ง•

์Šค์บํด๋”ฉ ํŒŒ์ผ์ž์œ ๋กœ์šด ์ˆ˜์ • ๊ฐ€๋Šฅ

์ž๋™ ์ƒ์„ฑ ์กฐ๊ฑด

OrderBySelect๋Š” ๋ฐฑ์—”๋“œ์— OrderBy enum์ด ์ •์˜๋˜๋ฉด ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.

๋ฐฑ์—”๋“œ ์ •์˜

// user.types.ts (๋ฐฑ์—”๋“œ)
import { z } from "zod";

export const UserOrderBy = z.enum([
  "id-desc",
  "id-asc",
  "created_at-desc",
  "created_at-asc",
  "username-asc",
  "username-desc",
]);

export const UserListParams = UserBaseListParams.extend({
  orderBy: UserOrderBy.optional(),
});

์ž๋™ ์ƒ์„ฑ๋˜๋Š” ํŒŒ์ผ

์œ„์น˜: web/src/components/user/UserOrderBySelect.tsx ์ž๋™ ์ƒ์„ฑ๋˜๋Š” ๋ ˆ์ด๋ธ”: web/src/services/sonamu.generated.ts
export const UserOrderByLabel = {
  "id-desc": "ID์ตœ์‹ ์ˆœ",
  "id-asc": "ID์˜ค๋ž˜๋œ์ˆœ",
  "created_at-desc": "์ƒ์„ฑ์ผ์ตœ์‹ ์ˆœ",
  "created_at-asc": "์ƒ์„ฑ์ผ์˜ค๋ž˜๋œ์ˆœ",
  "username-asc": "์ด๋ฆ„์˜ค๋ฆ„์ฐจ์ˆœ",
  "username-desc": "์ด๋ฆ„๋‚ด๋ฆผ์ฐจ์ˆœ",
};
๋ ˆ์ด๋ธ” ์ƒ์„ฑ ๊ทœ์น™
  • id-desc โ†’ โ€œID์ตœ์‹ ์ˆœโ€
  • created_at-asc โ†’ โ€œ์ƒ์„ฑ์ผ์˜ค๋ž˜๋œ์ˆœโ€
  • username-desc โ†’ โ€œ์ด๋ฆ„๋‚ด๋ฆผ์ฐจ์ˆœโ€
ํ•„๋“œ๋ช…๊ณผ ์ •๋ ฌ ๋ฐฉํ–ฅ(asc/desc)์„ ์กฐํ•ฉํ•˜์—ฌ ์ž๋™ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.

๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

useListParams์™€ ํ•จ๊ป˜ ์‚ฌ์šฉ

import { useListParams } from "@sonamu-kit/react-components";
import { UserOrderBySelect } from "@/components/user/UserOrderBySelect";
import { UserOrderBy } from "@/services/sonamu.generated";

export function UserListPage() {
  const { register } = useListParams(UserListParams, {
    num: 24,
    page: 1,
    orderBy: UserOrderBy.options[0],  // ๊ธฐ๋ณธ๊ฐ’: "id-desc"
  });

  return (
    <div className="flex gap-2">
      <UserOrderBySelect {...register("orderBy")} />
    </div>
  );
}
๋™์ž‘:
  1. ์‚ฌ์šฉ์ž๊ฐ€ ์ •๋ ฌ ์˜ต์…˜ ์„ ํƒ
  2. register("orderBy")๊ฐ€ ์ž๋™์œผ๋กœ URL ์—…๋ฐ์ดํŠธ
  3. API ํ˜ธ์ถœ ์‹œ listParams.orderBy๊ฐ€ ์ „๋‹ฌ๋จ
  4. ๋ฐฑ์—”๋“œ์—์„œ ์ •๋ ฌ ์ ์šฉ

Props

export type UserOrderBySelectProps = {
  value?: string;
  onValueChange?: (value: string | null | undefined) => void;
  placeholder?: string;
  textPrefix?: string;
  clearable?: boolean;
  disabled?: boolean;
  className?: string;
};

๊ธฐ๋ณธ ์‚ฌ์šฉ (register ์—†์ด)

import { useState } from "react";
import { UserOrderBySelect } from "@/components/user/UserOrderBySelect";

export function UserListPage() {
  const [orderBy, setOrderBy] = useState<string>("id-desc");

  return (
    <UserOrderBySelect
      value={orderBy}
      onValueChange={(value) => setOrderBy(value ?? "id-desc")}
      placeholder="์ •๋ ฌ ๊ธฐ์ค€"
    />
  );
}

Props ์ƒ์„ธ

placeholder

์„ ํƒ๋˜์ง€ ์•Š์•˜์„ ๋•Œ ํ‘œ์‹œ๋˜๋Š” ํ…์ŠคํŠธ์ž…๋‹ˆ๋‹ค.
<UserOrderBySelect
  {...register("orderBy")}
  placeholder="์ •๋ ฌ ์„ ํƒ"  // ๊ธฐ๋ณธ๊ฐ’: "์ •๋ ฌ"
/>

textPrefix

๊ฐ ์˜ต์…˜ ์•ž์— ๋ถ™๋Š” ์ ‘๋‘์‚ฌ์ž…๋‹ˆ๋‹ค.
<UserOrderBySelect
  {...register("orderBy")}
  textPrefix="์ •๋ ฌ: "
/>

// ๋ Œ๋”๋ง ๊ฒฐ๊ณผ:
// - ์ •๋ ฌ: ID์ตœ์‹ ์ˆœ
// - ์ •๋ ฌ: ID์˜ค๋ž˜๋œ์ˆœ
// - ์ •๋ ฌ: ์ƒ์„ฑ์ผ์ตœ์‹ ์ˆœ
textPrefix ํ™œ์šฉ ์˜ˆ์‹œ๋ชฉ๋ก ํŽ˜์ด์ง€ ์ƒ๋‹จ์— ํ˜„์žฌ ์ •๋ ฌ์„ ๋ช…ํ™•ํžˆ ํ‘œ์‹œํ•˜๊ณ  ์‹ถ์„ ๋•Œ ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค:
[์ •๋ ฌ: ID์ตœ์‹ ์ˆœ โ–ผ]

clearable

โ€œ์ „์ฒดโ€ ์˜ต์…˜์„ ์ถ”๊ฐ€ํ•˜์—ฌ ์ •๋ ฌ์„ ์ดˆ๊ธฐํ™”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
<UserOrderBySelect
  {...register("orderBy")}
  clearable
/>

// ๋ Œ๋”๋ง ๊ฒฐ๊ณผ:
// - ์ „์ฒด           โ† clearable๋กœ ์ถ”๊ฐ€๋จ
// - ID์ตœ์‹ ์ˆœ
// - ID์˜ค๋ž˜๋œ์ˆœ
// - ...
์‚ฌ์šฉ ์‹œ๋‚˜๋ฆฌ์˜ค:
  • ๊ธฐ๋ณธ ์ •๋ ฌ๋กœ ๋Œ์•„๊ฐ€๊ณ  ์‹ถ์„ ๋•Œ
  • ์ •๋ ฌ ์—†์ด ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ˆœ์„œ๋Œ€๋กœ ๋ณด๊ณ  ์‹ถ์„ ๋•Œ

disabled

์ปดํฌ๋„ŒํŠธ๋ฅผ ๋น„ํ™œ์„ฑํ™”ํ•ฉ๋‹ˆ๋‹ค.
<UserOrderBySelect
  {...register("orderBy")}
  disabled={isLoading}  // ๋กœ๋”ฉ ์ค‘์—๋Š” ๋ณ€๊ฒฝ ๋ถˆ๊ฐ€
/>

className

Tailwind CSS ํด๋ž˜์Šค๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
<UserOrderBySelect
  {...register("orderBy")}
  className="w-[200px] h-8 bg-white border-gray-300 text-xs"
/>

์‹ค์ „ ์˜ˆ์ œ

์™„์ „ํ•œ ๋ชฉ๋ก ํ•„ํ„ฐ

import { useListParams } from "@sonamu-kit/react-components";
import { Input } from "@sonamu-kit/react-components/components";
import { UserService } from "@/services/services.generated";
import { UserOrderBySelect } from "@/components/user/UserOrderBySelect";
import { UserSearchFieldSelect } from "@/components/user/UserSearchFieldSelect";
import { UserRoleSelect } from "@/components/user/UserRoleSelect";

export function UserListPage() {
  const { listParams, register } = useListParams(UserListParams, {
    num: 24,
    page: 1,
    search: "id" as const,
    keyword: "",
    orderBy: "id-desc" as const,
    role: undefined,
  });

  const { data } = UserService.useUsers("A", listParams);

  return (
    <div>
      {/* ํ•„ํ„ฐ ์„น์…˜ */}
      <div className="flex gap-2 mb-4">
        {/* ๊ฒ€์ƒ‰ */}
        <UserSearchFieldSelect
          {...register("search")}
          className="w-32"
        />
        <Input
          {...register("keyword")}
          placeholder="๊ฒ€์ƒ‰์–ด"
          className="w-64"
        />

        {/* ์—ญํ•  ํ•„ํ„ฐ */}
        <UserRoleSelect
          {...register("role")}
          clearable
        />

        {/* ์ •๋ ฌ */}
        <UserOrderBySelect
          {...register("orderBy")}
          textPrefix="์ •๋ ฌ: "
          className="w-[200px]"
        />
      </div>

      {/* ํ…Œ์ด๋ธ” */}
      <table>
        {/* ... */}
      </table>
    </div>
  );
}

๋ชจ๋ฐ”์ผ ๋ฐ˜์‘ํ˜•

<div className="flex flex-col sm:flex-row gap-2">
  <UserOrderBySelect
    {...register("orderBy")}
    className="w-full sm:w-[200px]"  // ๋ชจ๋ฐ”์ผ: ์ „์ฒด ๋„ˆ๋น„, ๋ฐ์Šคํฌํƒ‘: 200px
  />
</div>

์ •๋ ฌ ์˜ต์…˜ ์ˆ˜ ํ‘œ์‹œ

import { UserOrderBy } from "@/services/sonamu.generated";

<div className="flex items-center gap-2">
  <span className="text-sm text-gray-500">
    {UserOrderBy.options.length}๊ฐœ ์ •๋ ฌ ์˜ต์…˜
  </span>
  <UserOrderBySelect {...register("orderBy")} />
</div>

์ปค์Šคํ„ฐ๋งˆ์ด์ง•

OrderBySelect๋Š” ์Šค์บํด๋”ฉ ํŒŒ์ผ์ด๋ฏ€๋กœ ์ž์œ ๋กญ๊ฒŒ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋ ˆ์ด๋ธ” ๋ณ€๊ฒฝ

// UserOrderBySelect.tsx ์ˆ˜์ •
import { UserOrderByLabel } from "@/services/sonamu.generated";

// ์ปค์Šคํ…€ ๋ ˆ์ด๋ธ” ๊ฐ์ฒด ์ƒ์„ฑ
const customLabels = {
  ...UserOrderByLabel,
  "id-desc": "์ตœ์‹  ๋“ฑ๋ก์ˆœ",  // ๊ธฐ๋ณธ: "ID์ตœ์‹ ์ˆœ"
  "created_at-asc": "์˜ค๋ž˜๋œ ์ˆœ",  // ๊ธฐ๋ณธ: "์ƒ์„ฑ์ผ์˜ค๋ž˜๋œ์ˆœ"
};

export function UserOrderBySelect({ ... }) {
  return (
    <Select ...>
      <SelectContent>
        {validOptions.map((key) => (
          <SelectItem key={key} value={key}>
            {(textPrefix ?? "") + customLabels[key]}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

์•„์ด์ฝ˜ ์ถ”๊ฐ€

import ArrowUpIcon from "~icons/lucide/arrow-up";
import ArrowDownIcon from "~icons/lucide/arrow-down";

const getIcon = (key: string) => {
  if (key.endsWith("-asc")) return <ArrowUpIcon className="w-3 h-3" />;
  if (key.endsWith("-desc")) return <ArrowDownIcon className="w-3 h-3" />;
  return null;
};

<SelectItem key={key} value={key}>
  <div className="flex items-center gap-2">
    {getIcon(key)}
    {customLabels[key]}
  </div>
</SelectItem>

ํŠน์ • ์˜ต์…˜ ์ˆจ๊ธฐ๊ธฐ

// "username" ์ •๋ ฌ ์˜ต์…˜๋งŒ ํ‘œ์‹œ
const visibleOptions = validOptions.filter(key =>
  key.includes("username")
);

<SelectContent>
  {visibleOptions.map((key) => (
    <SelectItem key={key} value={key}>
      {UserOrderByLabel[key]}
    </SelectItem>
  ))}
</SelectContent>

๊ทธ๋ฃนํ™”

const groupedOptions = {
  "๊ธฐ๋ณธ": ["id-desc", "id-asc"],
  "๋‚ ์งœ": ["created_at-desc", "created_at-asc"],
  "์ด๋ฆ„": ["username-asc", "username-desc"],
};

<SelectContent>
  {Object.entries(groupedOptions).map(([group, options]) => (
    <Fragment key={group}>
      <SelectLabel>{group}</SelectLabel>
      {options.map((key) => (
        <SelectItem key={key} value={key}>
          {UserOrderByLabel[key]}
        </SelectItem>
      ))}
    </Fragment>
  ))}
</SelectContent>

๋ฐฑ์—”๋“œ ์ฒ˜๋ฆฌ

OrderBySelect์—์„œ ์„ ํƒํ•œ ๊ฐ’์€ ๋ฐฑ์—”๋“œ๋กœ ์ „๋‹ฌ๋˜์–ด ์‹ค์ œ ์ •๋ ฌ์ด ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.

Model์—์„œ ์ฒ˜๋ฆฌ

// user.model.ts (๋ฐฑ์—”๋“œ)
async findMany<T extends UserSubsetKey, LP extends UserListParams>(
  subset: T,
  rawParams?: LP,
): Promise<ListResult<LP, UserSubsetMapping[T]>> {
  const params = {
    num: 24,
    page: 1,
    orderBy: "id-desc" as const,
    ...rawParams,
  };

  const { qb } = this.getSubsetQueries(subset);

  // orderBy ์ฒ˜๋ฆฌ
  if (params.orderBy === "id-desc") {
    qb.orderBy("users.id", "desc");
  } else if (params.orderBy === "id-asc") {
    qb.orderBy("users.id", "asc");
  } else if (params.orderBy === "created_at-desc") {
    qb.orderBy("users.created_at", "desc");
  } else if (params.orderBy === "created_at-asc") {
    qb.orderBy("users.created_at", "asc");
  } else if (params.orderBy === "username-asc") {
    qb.orderBy("users.username", "asc");
  } else if (params.orderBy === "username-desc") {
    qb.orderBy("users.username", "desc");
  } else {
    exhaustive(params.orderBy);  // ํƒ€์ž… ์•ˆ์ „์„ฑ ๋ณด์žฅ
  }

  return this.executeSubsetQuery({ subset, qb, params });
}

exhaustive ํŒจํ„ด

exhaustive()๋Š” ๋ชจ๋“  ๊ฒฝ์šฐ๋ฅผ ์ฒ˜๋ฆฌํ–ˆ๋Š”์ง€ ์ปดํŒŒ์ผ ํƒ€์ž„์— ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค.
import { exhaustive } from "sonamu";

// OrderBy enum์— ์ƒˆ ์˜ต์…˜ "email-asc"๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด
// ์ปดํŒŒ์ผ ์—๋Ÿฌ ๋ฐœ์ƒ โ†’ ๊ฐœ๋ฐœ์ž๊ฐ€ ๋ˆ„๋ฝ์„ ์ฆ‰์‹œ ์•Œ ์ˆ˜ ์žˆ์Œ
else {
  exhaustive(params.orderBy);  // โŒ ํƒ€์ž… ์—๋Ÿฌ: "email-asc" ๋ฏธ์ฒ˜๋ฆฌ
}

์žฌ์ƒ์„ฑ

OrderBySelect๋Š” ์ดˆ๊ธฐ ์ƒ์„ฑ ํ›„ ์ž๋™ ์žฌ์ƒ์„ฑ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

์žฌ์ƒ์„ฑ์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ

  1. ์ปดํฌ๋„ŒํŠธ ํŒŒ์ผ์„ ์‚ญ์ œํ•œ ๊ฒฝ์šฐ
  2. Entity์˜ OrderBy enum์ด ํฌ๊ฒŒ ๋ณ€๊ฒฝ๋œ ๊ฒฝ์šฐ

์žฌ์ƒ์„ฑ ๋ฐฉ๋ฒ•

# ์ปดํฌ๋„ŒํŠธ ์‚ญ์ œ
rm web/src/components/user/UserOrderBySelect.tsx

# ๊ฐœ๋ฐœ ์„œ๋ฒ„ ์žฌ์‹œ์ž‘ (์ž๋™ ์žฌ์ƒ์„ฑ)
pnpm dev
์žฌ์ƒ์„ฑ ์‹œ ์ฃผ์˜์ปค์Šคํ„ฐ๋งˆ์ด์ง•ํ•œ ๋‚ด์šฉ์€ ์žฌ์ƒ์„ฑ ์‹œ ์‚ฌ๋ผ์ง‘๋‹ˆ๋‹ค. ์ค‘์š”ํ•œ ์ˆ˜์ • ์‚ฌํ•ญ์€ ๋ณ„๋„๋กœ ๋ฐฑ์—…ํ•˜๊ฑฐ๋‚˜ ๋ฌธ์„œํ™”ํ•˜์„ธ์š”.

๋ฌธ์ œ ํ•ด๊ฒฐ

OrderBySelect๊ฐ€ ์ƒ์„ฑ๋˜์ง€ ์•Š์Œ

์›์ธ: ๋ฐฑ์—”๋“œ์— OrderBy enum์ด ์ •์˜๋˜์ง€ ์•Š์Œ ํ•ด๊ฒฐ:
// user.types.ts (๋ฐฑ์—”๋“œ)์— ์ถ”๊ฐ€
export const UserOrderBy = z.enum([
  "id-desc",
  "id-asc",
]);

export const UserListParams = UserBaseListParams.extend({
  orderBy: UserOrderBy.optional(),
});

๋ ˆ์ด๋ธ”์ด ํ•œ๊ธ€๋กœ ํ‘œ์‹œ๋˜์ง€ ์•Š์Œ

์›์ธ: sonamu.generated.ts๊ฐ€ ์žฌ์ƒ์„ฑ๋˜์ง€ ์•Š์Œ ํ•ด๊ฒฐ:
# sonamu.lock ์‚ญ์ œ ํ›„ ์žฌ๋™๊ธฐํ™”
rm api/sonamu.lock
pnpm sonamu sync

์„ ํƒํ•ด๋„ ์ •๋ ฌ์ด ์ ์šฉ๋˜์ง€ ์•Š์Œ

์›์ธ: ๋ฐฑ์—”๋“œ Model์—์„œ orderBy ์ฒ˜๋ฆฌ ๋ˆ„๋ฝ ํ•ด๊ฒฐ: user.model.ts์˜ findMany์—์„œ params.orderBy๋ฅผ ์ฒ˜๋ฆฌํ•˜์„ธ์š”.

๊ด€๋ จ ๋ฌธ์„œ