๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
Model์€ Sonamu์˜ ํ•ต์‹ฌ ๋ ˆ์ด์–ด๋กœ, ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง๊ณผ ๋ฐ์ดํ„ฐ ์•ก์„ธ์Šค๋ฅผ ๋‹ด๋‹นํ•ฉ๋‹ˆ๋‹ค. Entity ์ •์˜๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ฟผ๋ฆฌ, API ์—”๋“œํฌ์ธํŠธ, ํƒ€์ž… ์•ˆ์ „ํ•œ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

Model์˜ ์—ญํ• 

๋ฐ์ดํ„ฐ ์•ก์„ธ์Šค

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค CRUD ์ž‘์—…Puri ์ฟผ๋ฆฌ ๋นŒ๋”๋ฅผ ํ†ตํ•œ ํƒ€์ž… ์•ˆ์ „ํ•œ ์ฟผ๋ฆฌ

๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง

๋„๋ฉ”์ธ ๊ทœ์น™ ๊ตฌํ˜„๋ฐ์ดํ„ฐ ๊ฒ€์ฆ, ๋ณ€ํ™˜, ๊ณ„์‚ฐ

API ์—”๋“œํฌ์ธํŠธ

HTTP API ์ž๋™ ์ƒ์„ฑ@api ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋กœ REST API ์ œ๊ณต

ํƒ€์ž… ์•ˆ์ „์„ฑ

์ปดํŒŒ์ผ ํƒ€์ž„ ํƒ€์ž… ์ฒดํฌEntity ๊ธฐ๋ฐ˜ ์ž๋™ ํƒ€์ž… ์ƒ์„ฑ

Sonamu ์•„ํ‚คํ…์ฒ˜์—์„œ์˜ ์œ„์น˜

๋ ˆ์ด์–ด ๊ตฌ์กฐ:
  1. Client - ํ”„๋ก ํŠธ์—”๋“œ (React, Vue ๋“ฑ)
  2. API Layer - HTTP ์—”๋“œํฌ์ธํŠธ (@api ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ)
  3. Model Layer - ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง โญ
  4. Database - PostgreSQL
Model์€ API Layer์™€ Database ์‚ฌ์ด์˜ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๋ ˆ์ด์–ด์ž…๋‹ˆ๋‹ค. ๋ชจ๋“  ๋ฐ์ดํ„ฐ ์ ‘๊ทผ๊ณผ ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™์€ Model์—์„œ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค.

Model ํด๋ž˜์Šค ๊ตฌ์กฐ

๊ธฐ๋ณธ ๊ตฌ์กฐ

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

class UserModelClass extends BaseModelClass<
  UserSubsetKey,              // "A" | "P" | "SS"
  UserSubsetMapping,          // ๊ฐ subset์˜ ํƒ€์ž…
  typeof userSubsetQueries,   // subset ์ฟผ๋ฆฌ ํ•จ์ˆ˜๋“ค
  typeof userLoaderQueries    // loader ์ฟผ๋ฆฌ ํ•จ์ˆ˜๋“ค
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }

  @api({ httpMethod: "GET" })
  async findById(id: number): Promise<UserSubsetMapping["A"]> {
    // ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๊ตฌํ˜„
  }
}

export const UserModel = new UserModelClass();

์ œ๋„ค๋ฆญ ํƒ€์ž… ํŒŒ๋ผ๋ฏธํ„ฐ

ํŒŒ๋ผ๋ฏธํ„ฐ์„ค๋ช…์˜ˆ์‹œ
TSubsetKeySubset ํ‚ค์˜ Union ํƒ€์ž…"A" | "P" | "SS"
TSubsetMappingSubset๋ณ„ ๊ฒฐ๊ณผ ํƒ€์ž… ๋งคํ•‘{ A: UserA, P: UserP }
TSubsetQueriesSubset ์ฟผ๋ฆฌ ํ•จ์ˆ˜ ํƒ€์ž…์ž๋™ ์ƒ์„ฑ
TLoaderQueriesLoader ์ฟผ๋ฆฌ ํƒ€์ž…์ž๋™ ์ƒ์„ฑ
์ด ์ œ๋„ค๋ฆญ ํƒ€์ž…๋“ค์€ sonamu.generated.ts์™€ sonamu.generated.sso.ts์—์„œ ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋˜๋ฏ€๋กœ ์ง์ ‘ ์ž‘์„ฑํ•  ํ•„์š”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

Model vs Entity vs Types

Sonamu์—์„œ ์ž์ฃผ ํ˜ผ๋™๋˜๋Š” ๊ฐœ๋…๋“ค์„ ์ •๋ฆฌํ•ด๋ด…์‹œ๋‹ค:
๊ตฌ๋ถ„EntityTypesModel
ํŒŒ์ผ{entity}.entity.json{entity}.types.ts{entity}.model.ts
์—ญํ• ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ ์ •์˜ํƒ€์ž… ์ •์˜๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง
์ƒ์„ฑ์ˆ˜๋™ (Sonamu UI)์ž๋™ ์ƒ์„ฑ์ˆ˜๋™ (Scaffold)
๋‚ด์šฉํ…Œ์ด๋ธ”, ์ปฌ๋Ÿผ, ๊ด€๊ณ„TypeScript ํƒ€์ž…, Zod๋ฉ”์„œ๋“œ, API, ์ฟผ๋ฆฌ
๋ณ€๊ฒฝ ๋นˆ๋„๋‚ฎ์Œ์ž๋™ ์žฌ์ƒ์„ฑ๋†’์Œ
์˜ˆ์‹œ:
{
  "id": "User",
  "table": "users",
  "props": [
    { "name": "id", "type": "integer" },
    { "name": "email", "type": "string" }
  ]
}

Model์ด ์ œ๊ณตํ•˜๋Š” ๊ธฐ๋Šฅ

1. ํƒ€์ž… ์•ˆ์ „ํ•œ ๋ฐ์ดํ„ฐ ์•ก์„ธ์Šค

// โœ… ํƒ€์ž… ์•ˆ์ „: subset ๊ธฐ๋ฐ˜ ํƒ€์ž… ์ถ”๋ก 
const user = await UserModel.findById("A", 1);
// user์˜ ํƒ€์ž…: UserSubsetMapping["A"]

// โœ… ์ปดํŒŒ์ผ ์—๋Ÿฌ: ์ž˜๋ชป๋œ subset
const user = await UserModel.findById("INVALID", 1);
//                                      ^^^^^^^^^ 
// Error: Argument of type '"INVALID"' is not assignable

2. Subset ๊ธฐ๋ฐ˜ ์ฟผ๋ฆฌ

async findMany<T extends UserSubsetKey>(
  subset: T,
  params: UserListParams
): Promise<ListResult<UserSubsetMapping[T]>> {
  const { qb } = this.getSubsetQueries(subset);
  
  // subset์— ๋งž๋Š” ํ•„๋“œ๋งŒ ์ž๋™ SELECT
  qb.where("id", params.id);
  
  return this.executeSubsetQuery({ subset, qb, params });
}
Subset์€ API ์‘๋‹ต์— ํ•„์š”ํ•œ ํ•„๋“œ๋งŒ ์„ ํƒํ•˜์—ฌ ๋„คํŠธ์›Œํฌ ํŠธ๋ž˜ํ”ฝ์„ ์ค„์ด๊ณ  ์„ฑ๋Šฅ์„ ํ–ฅ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค.

3. ์ž๋™ API ์ƒ์„ฑ

@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
async findById(id: number): Promise<User> {
  // ๋ฉ”์„œ๋“œ ๊ตฌํ˜„
}
์ž๋™ ์ƒ์„ฑ๋˜๋Š” ๊ฒƒ๋“ค:
  • HTTP ์—”๋“œํฌ์ธํŠธ: GET /user/findById?id=1
  • TypeScript ํด๋ผ์ด์–ธํŠธ ์ฝ”๋“œ
  • TanStack Query hooks
  • API ๋ฌธ์„œ

4. ํŠธ๋žœ์žญ์…˜ ๊ด€๋ฆฌ

@transactional()
async updateUserAndProfile(
  userId: number,
  userData: UserData,
  profileData: ProfileData
): Promise<void> {
  // ๋ชจ๋“  ์ž‘์—…์ด ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์‹คํ–‰๋จ
  await this.save([userData]);
  await ProfileModel.save([profileData]);
  // ์ž๋™ ์ปค๋ฐ‹ ๋˜๋Š” ๋กค๋ฐฑ
}

5. ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ๊ณผ ๋ณ€ํ™˜

async save(params: UserSaveParams[]): Promise<number[]> {
  // ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ ๊ฒ€์ฆ
  for (const param of params) {
    if (param.age && param.age < 18) {
      throw new BadRequestException("18์„ธ ๋ฏธ๋งŒ์€ ๊ฐ€์ž…ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค");
    }
  }
  
  // ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜
  const hashedParams = params.map(p => ({
    ...p,
    password: bcrypt.hashSync(p.password, 10)
  }));
  
  // ์ €์žฅ
  return this.getPuri("w").ubUpsert("users", hashedParams);
}

Model ์ž‘์„ฑ ํŒจํ„ด

CRUD ๋ฉ”์„œ๋“œ

์ผ๋ฐ˜์ ์ธ Model์€ ๋‹ค์Œ CRUD ๋ฉ”์„œ๋“œ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค:
class UserModelClass extends BaseModelClass {
  // Create
  @api({ httpMethod: "POST" })
  async save(params: UserSaveParams[]): Promise<number[]> {
    // ...
  }
  
  // Read
  @api({ httpMethod: "GET" })
  async findById(id: number): Promise<User> {
    // ...
  }
  
  @api({ httpMethod: "GET" })
  async findMany(params: UserListParams): Promise<ListResult<User>> {
    // ...
  }
  
  // Update
  @api({ httpMethod: "PUT" })
  async update(id: number, params: UserUpdateParams): Promise<User> {
    // ...
  }
  
  // Delete
  @api({ httpMethod: "DELETE" })
  async del(ids: number[]): Promise<number> {
    // ...
  }
}

๋„๋ฉ”์ธ๋ณ„ ๋ฉ”์„œ๋“œ

๋น„์ฆˆ๋‹ˆ์Šค ๋„๋ฉ”์ธ์— ํŠนํ™”๋œ ๋ฉ”์„œ๋“œ๋„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:
class UserModelClass extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async login(params: LoginParams): Promise<{ user: User }> {
    // ๋กœ๊ทธ์ธ ๋กœ์ง
  }
  
  @api({ httpMethod: "POST" })
  async logout(): Promise<{ message: string }> {
    // ๋กœ๊ทธ์•„์›ƒ ๋กœ์ง
  }
  
  @api({ httpMethod: "GET" })
  async me(): Promise<User | null> {
    // ํ˜„์žฌ ์‚ฌ์šฉ์ž ์ •๋ณด
  }
  
  @api({ httpMethod: "POST" })
  async changePassword(params: ChangePasswordParams): Promise<void> {
    // ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ
  }
}

BaseModel ๋ฉ”์„œ๋“œ

BaseModelClass๋Š” ๋‹ค์Œ ์œ ํ‹ธ๋ฆฌํ‹ฐ ๋ฉ”์„œ๋“œ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค:
๋ฉ”์„œ๋“œ์„ค๋ช…๋ฐ˜ํ™˜ ํƒ€์ž…
getDB(preset)๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ํš๋“Knex
getPuri(preset)Puri ์ฟผ๋ฆฌ ๋นŒ๋” ํš๋“PuriWrapper
getSubsetQueries(subset)Subset ์ฟผ๋ฆฌ ๋นŒ๋” ํš๋“{ qb, onSubset }
executeSubsetQuery(params)Subset ์ฟผ๋ฆฌ ์‹คํ–‰ListResult
createEnhancers(enhancers)Enhancer ์ƒ์„ฑ ํ—ฌํผEnhancerMap
๋” ์•Œ์•„๋ณด๊ธฐ

Model์˜ ์žฅ์ 

Entity ์ •์˜๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ TypeScript ํƒ€์ž…์ด ์ž๋™ ์ƒ์„ฑ๋˜์–ด, ์ปดํŒŒ์ผ ํƒ€์ž„์— ์˜ค๋ฅ˜๋ฅผ ์žก์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
// โœ… ํƒ€์ž… ์ฒดํฌ
const user: UserSubsetMapping["A"] = await UserModel.findById("A", 1);

// โŒ ์ปดํŒŒ์ผ ์—๋Ÿฌ
user.nonExistentField;
๊ณตํ†ต ๋กœ์ง์„ Model ๋ฉ”์„œ๋“œ๋กœ ์ž‘์„ฑํ•˜์—ฌ ์—ฌ๋Ÿฌ API์—์„œ ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
// Model ๋ฉ”์„œ๋“œ
async findActiveUsers(): Promise<User[]> {
  return this.puri().where("is_active", true).many();
}

// ์—ฌ๋Ÿฌ API์—์„œ ์žฌ์‚ฌ์šฉ
@api()
async api1() {
  return this.findActiveUsers();
}

@api()
async api2() {
  const users = await this.findActiveUsers();
  // ์ถ”๊ฐ€ ๋กœ์ง...
}
Model์€ ๋…๋ฆฝ์ ์œผ๋กœ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
describe("UserModel", () => {
  it("should find user by id", async () => {
    const user = await UserModel.findById("A", 1);
    expect(user.id).toBe(1);
  });
});
๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง(Model)๊ณผ HTTP ์ฒ˜๋ฆฌ(Controller/API)๋ฅผ ๋ช…ํ™•ํžˆ ๋ถ„๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
  • Model: ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ, ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™
  • API: HTTP ์š”์ฒญ/์‘๋‹ต, ์ธ์ฆ, ๊ถŒํ•œ

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

Model์˜ ๊ธฐ๋ณธ ๊ฐœ๋…์„ ์ดํ•ดํ–ˆ๋‹ค๋ฉด, ๋‹ค์Œ ์ฃผ์ œ๋ฅผ ํ•™์Šตํ•˜์„ธ์š”: