๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
Model์€ Entity ์ •์˜ ํ›„ Scaffold๋ฅผ ํ†ตํ•ด ์ž๋™ ์ƒ์„ฑํ•˜๊ฑฐ๋‚˜ ์ˆ˜๋™์œผ๋กœ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. Scaffold๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๊ธฐ๋ณธ ๊ตฌ์กฐ์™€ CRUD ๋ฉ”์„œ๋“œ๊ฐ€ ํฌํ•จ๋œ Model ํ…œํ”Œ๋ฆฟ์ด ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.

Model ์ƒ์„ฑ ๊ณผ์ •

1

Entity ์ •์˜

๋จผ์ € Entity๋ฅผ ์ •์˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
user.entity.json
{
  "id": "User",
  "table": "users",
  "props": [
    { "name": "id", "type": "integer" },
    { "name": "email", "type": "string", "length": 255 },
    { "name": "username", "type": "string", "length": 255 }
  ],
  "subsets": {
    "A": ["id", "email", "username"],
    "SS": ["id", "email"]
  }
}
2

์ž๋™ ํƒ€์ž… ์ƒ์„ฑ

Entity ์ €์žฅ ์‹œ ์ž๋™์œผ๋กœ ํƒ€์ž… ํŒŒ์ผ์ด ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.์ƒ์„ฑ๋˜๋Š” ํŒŒ์ผ:
  • user.types.ts - TypeScript ํƒ€์ž…๊ณผ Zod ์Šคํ‚ค๋งˆ
  • sonamu.generated.ts - Base ์Šคํ‚ค๋งˆ์™€ Enum
  • sonamu.generated.sso.ts - Subset ์ฟผ๋ฆฌ ํ•จ์ˆ˜
3

Model Scaffold ์ƒ์„ฑ

Sonamu CLI๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ Model ํ…œํ”Œ๋ฆฟ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
pnpm sonamu generate model --entity User
๋˜๋Š” Sonamu UI์˜ Scaffolding ํƒญ์—์„œ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
4

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

์ƒ์„ฑ๋œ Model์— ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
// ์ƒ์„ฑ๋œ ํ…œํ”Œ๋ฆฟ์— ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€
@api({ httpMethod: "POST" })
async login(params: LoginParams) {
  // ๋กœ๊ทธ์ธ ๋กœ์ง ๊ตฌํ˜„
}

Scaffold๋กœ Model ์ƒ์„ฑํ•˜๊ธฐ

CLI ์‚ฌ์šฉ

# Entity ์ด๋ฆ„ ์ง€์ •
pnpm sonamu generate model --entity User

# ์—ฌ๋Ÿฌ Entity ํ•œ๋ฒˆ์— ์ƒ์„ฑ
pnpm sonamu generate model --entity User,Post,Comment

Sonamu UI ์‚ฌ์šฉ

1

Scaffolding ํƒญ ์—ด๊ธฐ

Sonamu UI(http://localhost:1028/sonamu-ui)์—์„œ Scaffolding ํƒญ์„ ํด๋ฆญํ•ฉ๋‹ˆ๋‹ค.
2

Model ํ…œํ”Œ๋ฆฟ ์„ ํƒ

โ€œModelโ€ ํ…œํ”Œ๋ฆฟ์„ ์„ ํƒํ•˜๊ณ  Entity๋ฅผ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.
3

์˜ต์…˜ ์„ค์ •

  • Default Search Field: ๊ธฐ๋ณธ ๊ฒ€์ƒ‰ ํ•„๋“œ (์˜ˆ: email)
  • Default Order By: ๊ธฐ๋ณธ ์ •๋ ฌ (์˜ˆ: id-desc)
  • Overwrite: ๊ธฐ์กด ํŒŒ์ผ ๋ฎ์–ด์“ฐ๊ธฐ ์—ฌ๋ถ€
4

์ƒ์„ฑ ์‹คํ–‰

โ€œGenerateโ€ ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜๋ฉด Model ํŒŒ์ผ์ด ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.

๐Ÿ“ธ ํ•„์š”: Sonamu UI์˜ Scaffolding ํƒญ - Model ์ƒ์„ฑ ํ™”๋ฉด

์ƒ์„ฑ๋˜๋Š” Model ๊ตฌ์กฐ

Scaffold๋กœ ์ƒ์„ฑ๋œ Model์˜ ๊ธฐ๋ณธ ๊ตฌ์กฐ์ž…๋‹ˆ๋‹ค:
user.model.ts
import {
  api,
  asArray,
  BadRequestException,
  BaseModelClass,
  exhaustive,
  type ListResult,
  NotFoundException,
} from "sonamu";
import type { UserSubsetKey, UserSubsetMapping } from "../sonamu.generated";
import { userLoaderQueries, userSubsetQueries } from "../sonamu.generated.sso";
import type { UserListParams, UserSaveParams } from "./user.types";

/*
  User Model
*/

class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }

  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"], resourceName: "User" })
  async findById<T extends UserSubsetKey>(
    subset: T,
    id: number
  ): Promise<UserSubsetMapping[T]> {
    const { rows } = await this.findMany(subset, {
      id,
      num: 1,
      page: 1,
    });
    if (!rows[0]) {
      throw new NotFoundException(`์กด์žฌํ•˜์ง€ ์•Š๋Š” User ID ${id}`);
    }
    return rows[0];
  }

  async findOne<T extends UserSubsetKey>(
    subset: T,
    listParams: UserListParams
  ): Promise<UserSubsetMapping[T] | null> {
    const { rows } = await this.findMany(subset, {
      ...listParams,
      num: 1,
      page: 1,
    });

    return rows[0] ?? null;
  }

  @api({
    httpMethod: "GET",
    clients: ["axios", "tanstack-query"],
    resourceName: "Users",
  })
  async findMany<T extends UserSubsetKey, LP extends UserListParams>(
    subset: T,
    rawParams?: LP
  ): Promise<ListResult<LP, UserSubsetMapping[T]>> {
    // params with defaults
    const params = {
      num: 24,
      page: 1,
      search: "id" as const,
      orderBy: "id-desc" as const,
      ...rawParams,
    };

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

    // id
    if (params.id) {
      qb.whereIn("users.id", asArray(params.id));
    }

    // search-keyword
    if (params.search && params.keyword && params.keyword.length > 0) {
      if (params.search === "id") {
        qb.where("users.id", Number(params.keyword));
      } else {
        exhaustive(params.search);
      }
    }

    // orderBy
    if (params.orderBy) {
      if (params.orderBy === "id-desc") {
        qb.orderBy("users.id", "desc");
      } else {
        exhaustive(params.orderBy);
      }
    }

    const enhancers = this.createEnhancers({
      A: (row) => row,
      SS: (row) => row,
    });

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

  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
  async save(spa: UserSaveParams[]): Promise<number[]> {
    const wdb = this.getPuri("w");

    // register
    spa.forEach((sp) => {
      wdb.ubRegister("users", sp);
    });

    // transaction
    return wdb.transaction(async (trx) => {
      const ids = await trx.ubUpsert("users");
      return ids;
    });
  }

  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"], guards: ["admin"] })
  async del(ids: number[]): Promise<number> {
    const wdb = this.getPuri("w");

    await wdb.transaction(async (trx) => {
      return trx.table("users").whereIn("users.id", ids).delete();
    });

    return ids.length;
  }
}

export const UserModel = new UserModelClass();

์ƒ์„ฑ๋œ ๋ฉ”์„œ๋“œ ์„ค๋ช…

Scaffold๊ฐ€ ์ƒ์„ฑํ•˜๋Š” ๊ธฐ๋ณธ ๋ฉ”์„œ๋“œ๋“ค์ž…๋‹ˆ๋‹ค:

1. findById

๋‹จ์ผ ๋ ˆ์ฝ”๋“œ๋ฅผ ID๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.
async findById<T extends UserSubsetKey>(
  subset: T,
  id: number
): Promise<UserSubsetMapping[T]>
ํŠน์ง•:
  • Subset์„ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐ›์•„ ํ•„์š”ํ•œ ํ•„๋“œ๋งŒ ์กฐํšŒ
  • ๋ ˆ์ฝ”๋“œ๊ฐ€ ์—†์œผ๋ฉด NotFoundException ๋ฐœ์ƒ
  • @api ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋กœ HTTP ์—”๋“œํฌ์ธํŠธ ์ƒ์„ฑ

2. findOne

์กฐ๊ฑด์— ๋งž๋Š” ์ฒซ ๋ฒˆ์งธ ๋ ˆ์ฝ”๋“œ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.
async findOne<T extends UserSubsetKey>(
  subset: T,
  listParams: UserListParams
): Promise<UserSubsetMapping[T] | null>
ํŠน์ง•:
  • ๋ ˆ์ฝ”๋“œ๊ฐ€ ์—†์œผ๋ฉด null ๋ฐ˜ํ™˜ (์˜ˆ์™ธ ๋ฐœ์ƒ ์•ˆํ•จ)
  • ๋‚ด๋ถ€์ ์œผ๋กœ findMany๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ num: 1, page: 1 ์‚ฌ์šฉ

3. findMany

์—ฌ๋Ÿฌ ๋ ˆ์ฝ”๋“œ๋ฅผ ์กฐ๊ฑด์— ๋”ฐ๋ผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.
async findMany<T extends UserSubsetKey, LP extends UserListParams>(
  subset: T,
  rawParams?: LP
): Promise<ListResult<LP, UserSubsetMapping[T]>>
ํŠน์ง•:
  • ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ง€์› (num, page)
  • ๊ฒ€์ƒ‰/ํ•„ํ„ฐ๋ง (search, keyword)
  • ์ •๋ ฌ (orderBy)
  • ListResult ํƒ€์ž…์œผ๋กœ { rows, total } ๋ฐ˜ํ™˜

4. save

๋ ˆ์ฝ”๋“œ๋ฅผ ์ƒ์„ฑํ•˜๊ฑฐ๋‚˜ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.
async save(spa: UserSaveParams[]): Promise<number[]>
ํŠน์ง•:
  • Upsert Builder ์‚ฌ์šฉ (Insert or Update)
  • ๋ฐฐ์—ด๋กœ ์—ฌ๋Ÿฌ ๋ ˆ์ฝ”๋“œ ํ•œ๋ฒˆ์— ์ฒ˜๋ฆฌ
  • ํŠธ๋žœ์žญ์…˜์œผ๋กœ ๋ฌถ์–ด์„œ ์‹คํ–‰
  • ์ƒ์„ฑ/์ˆ˜์ •๋œ ID ๋ฐฐ์—ด ๋ฐ˜ํ™˜

5. del

๋ ˆ์ฝ”๋“œ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.
async del(ids: number[]): Promise<number>
ํŠน์ง•:
  • ์—ฌ๋Ÿฌ ID๋ฅผ ๋ฐฐ์—ด๋กœ ๋ฐ›์Œ
  • ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์‹คํ–‰
  • guards: ["admin"]์œผ๋กœ ๊ด€๋ฆฌ์ž๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ
  • ์‚ญ์ œ๋œ ๋ ˆ์ฝ”๋“œ ์ˆ˜ ๋ฐ˜ํ™˜

์ˆ˜๋™์œผ๋กœ Model ์ƒ์„ฑํ•˜๊ธฐ

Scaffold๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ  ์ง์ ‘ ์ž‘์„ฑํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.

์ตœ์†Œ ๊ตฌ์กฐ

import { BaseModelClass } from "sonamu";
import type { UserSubsetKey, UserSubsetMapping } from "../sonamu.generated";
import { userLoaderQueries, userSubsetQueries } from "../sonamu.generated.sso";

class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }

  // ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€...
}

export const UserModel = new UserModelClass();

๋„ค์ด๋ฐ ๊ทœ์น™

๊ตฌ๋ถ„ํŒจํ„ด์˜ˆ์‹œ
ํด๋ž˜์Šค๋ช…{Entity}ModelClassUserModelClass
Export๋ช…{Entity}ModelUserModel
ํŒŒ์ผ๋ช…{entity}.model.tsuser.model.ts
ํด๋ž˜์Šค๋ช…์€ ๋ฐ˜๋“œ์‹œ ModelClass๋กœ ๋๋‚˜์•ผ ํ•ฉ๋‹ˆ๋‹ค. Export๋Š” Model๋กœ ๋๋‚˜์•ผ ํ•ฉ๋‹ˆ๋‹ค.
// โœ… ์˜ฌ๋ฐ”๋ฅธ ๋„ค์ด๋ฐ
class UserModelClass extends BaseModelClass { }
export const UserModel = new UserModelClass();

// โŒ ์ž˜๋ชป๋œ ๋„ค์ด๋ฐ
class UserModel extends BaseModelClass { }  // X
export const User = new UserModelClass();   // X

Model ํŒŒ์ผ ์œ„์น˜

Model ํŒŒ์ผ์€ Entity์™€ ๊ฐ™์€ ๋””๋ ‰ํ† ๋ฆฌ์— ์œ„์น˜ํ•ฉ๋‹ˆ๋‹ค:

Types ํŒŒ์ผ ์ž‘์„ฑ

Model๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•  ํŒŒ๋ผ๋ฏธํ„ฐ ํƒ€์ž…์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค:
user.types.ts
import { z } from "zod";
import { UserOrderBy, UserSearchField } from "../sonamu.generated";

// List ํŒŒ๋ผ๋ฏธํ„ฐ
export const UserListParams = z.object({
  num: z.number().optional(),
  page: z.number().optional(),
  search: UserSearchField.optional(),
  keyword: z.string().optional(),
  orderBy: UserOrderBy.optional(),
  id: z.union([z.number(), z.array(z.number())]).optional(),
});
export type UserListParams = z.infer<typeof UserListParams>;

// Save ํŒŒ๋ผ๋ฏธํ„ฐ
export const UserSaveParams = z.object({
  id: z.number().optional(),
  email: z.string().email(),
  username: z.string().min(2).max(50),
  password: z.string().min(8),
});
export type UserSaveParams = z.infer<typeof UserSaveParams>;

// Login ํŒŒ๋ผ๋ฏธํ„ฐ
export const UserLoginParams = z.object({
  email: z.string().email(),
  password: z.string(),
});
export type UserLoginParams = z.infer<typeof UserLoginParams>;
Types ํŒŒ์ผ ํŒจํ„ด:
  • {Entity}ListParams: ๋ชฉ๋ก ์กฐํšŒ์šฉ
  • {Entity}SaveParams: ์ƒ์„ฑ/์ˆ˜์ •์šฉ
  • {Entity}{Action}Params: ํŠน์ • ์•ก์…˜์šฉ (์˜ˆ: LoginParams)

Model ๋“ฑ๋ก ํ™•์ธ

์ƒ์„ฑ๋œ Model์ด ์ œ๋Œ€๋กœ ๋กœ๋“œ๋˜๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:
api/src/application/sonamu.generated.ts
// Model imports (์ž๋™ ์ƒ์„ฑ)
export * from "./user/user.model";

// ๋ชจ๋“  Model์ด ์—ฌ๊ธฐ์— export๋จ
sonamu.generated.ts๋Š” Entity ์ €์žฅ ์‹œ ์ž๋™์œผ๋กœ ๊ฐฑ์‹ ๋ฉ๋‹ˆ๋‹ค. Model์„ ์ถ”๊ฐ€ํ•˜๋ฉด ์ด ํŒŒ์ผ์—๋„ ์ž๋™์œผ๋กœ ์ถ”๊ฐ€๋ฉ๋‹ˆ๋‹ค.

๋‹ค์Œ ๋‹จ๊ณ„

Model์„ ์ƒ์„ฑํ–ˆ๋‹ค๋ฉด, ๋‹ค์Œ ์ฃผ์ œ๋ฅผ ํ•™์Šตํ•˜์„ธ์š”: