메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
SonamuλŠ” Entity μ •μ˜ ν•˜λ‚˜λ‘œ ν΄λΌμ΄μ–ΈνŠΈλΆ€ν„° μ„œλ²„, λ°μ΄ν„°λ² μ΄μŠ€κΉŒμ§€ μ™„λ²½ν•œ νƒ€μž… μ•ˆμ „μ„±μ„ μ œκ³΅ν•©λ‹ˆλ‹€. 이 λ¬Έμ„œλŠ” νƒ€μž…μ΄ μ–΄λ–»κ²Œ 전체 μŠ€νƒμ„ κ΄€ν†΅ν•˜λŠ”μ§€ μ„€λͺ…ν•©λ‹ˆλ‹€.

νƒ€μž… 흐름 κ°œμš”

컴파일 νƒ€μž„

TypeScript둜 νƒ€μž… 체크 개발 쀑 μ—λŸ¬ λ°©μ§€

λŸ°νƒ€μž„

Zod둜 μ‹€μ œ 데이터 검증 잘λͺ»λœ 데이터 차단

λ°μ΄ν„°λ² μ΄μŠ€

PostgreSQL μ œμ•½μ‘°κ±΄ 데이터 무결성 보μž₯

API 경계

Request/Response 검증 μ•ˆμ „ν•œ 데이터 κ΅ν™˜

λ ˆμ΄μ–΄λ³„ νƒ€μž… μ•ˆμ „μ„±

1. Entity β†’ TypeScript

Entity μ •μ˜κ°€ TypeScript νƒ€μž…μœΌλ‘œ λ³€ν™˜λ©λ‹ˆλ‹€.
{
  "id": "User",
  "props": [
    { "name": "email", "type": "string", "length": 100 },
    { "name": "age", "type": "integer", "nullable": false },
    { "name": "role", "type": "enum", "id": "UserRole" }
  ],
  "enums": {
    "UserRole": {
      "admin": "κ΄€λ¦¬μž",
      "normal": "일반 μ‚¬μš©μž"
    }
  }
}
νƒ€μž… μ•ˆμ „μ„± 보μž₯:
// βœ… 컴파일 성곡
const user: User = {
  email: "[email protected]",
  age: 30,
  role: "admin",
};

// ❌ 컴파일 μ—λŸ¬
const user: User = {
  email: "[email protected]",
  age: "30", // Error: Type 'string' is not assignable to type 'number'
  role: "guest", // Error: Type '"guest"' is not assignable to type 'UserRole'
};

2. Entity β†’ Zod μŠ€ν‚€λ§ˆ

Entity μ •μ˜κ°€ Zod μŠ€ν‚€λ§ˆλ‘œ λ³€ν™˜λ˜μ–΄ λŸ°νƒ€μž„ 검증을 μ œκ³΅ν•©λ‹ˆλ‹€.
export const User = z.object({
  email: z.string().max(100),
  age: z.number().int(),
  role: UserRole,
});

3. Entity β†’ PostgreSQL

Entity μ •μ˜κ°€ PostgreSQL ν…Œμ΄λΈ” μŠ€ν‚€λ§ˆλ‘œ λ³€ν™˜λ©λ‹ˆλ‹€.
{
  "props": [
    { "name": "email", "type": "string", "length": 100 },
    { "name": "age", "type": "integer", "nullable": false }
  ],
  "indexes": [
    { "type": "unique", "columns": [{ "name": "email" }] }
  ]
}
λ°μ΄ν„°λ² μ΄μŠ€ μ œμ•½μ‘°κ±΄:
-- βœ… μ‚½μž… 성곡
INSERT INTO users (email, age) VALUES ('[email protected]', 30);

-- ❌ μ‹€νŒ¨: NULL λΆˆκ°€
INSERT INTO users (email, age) VALUES ('[email protected]', NULL);

-- ❌ μ‹€νŒ¨: UNIQUE μœ„λ°˜
INSERT INTO users (email, age) VALUES ('[email protected]', 25);

API λ ˆμ΄μ–΄ νƒ€μž… μ•ˆμ „μ„±

Model β†’ API Client

Model의 λ©”μ„œλ“œκ°€ νƒ€μž… μ•ˆμ „ν•œ API ν΄λΌμ΄μ–ΈνŠΈλ‘œ λ³€ν™˜λ©λ‹ˆλ‹€.
@api({ httpMethod: "POST" })
async save(params: UserSaveParams[]): Promise<number[]> {
  // κ΅¬ν˜„...
}

Request 검증

API μš”μ²­μ€ Zod μŠ€ν‚€λ§ˆλ‘œ μžλ™ κ²€μ¦λ©λ‹ˆλ‹€.
Fastify λΌμš°ν„°μ—μ„œ
// Sonamuκ°€ μžλ™μœΌλ‘œ 생성
fastify.post("/user/save", async (request, reply) => {
  // μžλ™ 검증
  const params = UserSaveParams.array().parse(request.body.params);

  // κ²€μ¦λœ νƒ€μž…μœΌλ‘œ μ•ˆμ „ν•˜κ²Œ μ‚¬μš©
  const result = await UserModel.save(params);
  return result;
});
검증 μ‹€νŒ¨ μ‹œ:
// Request
POST /user/save
{
  "params": [
    { "email": "invalid-email", "age": "30" }
  ]
}

// Response (400 Bad Request)
{
  "error": "Validation failed",
  "issues": [
    {
      "path": ["params", 0, "email"],
      "message": "Invalid email"
    },
    {
      "path": ["params", 0, "age"],
      "message": "Expected number, received string"
    }
  ]
}

Response νƒ€μž…

API 응닡도 νƒ€μž… μ•ˆμ „ν•©λ‹ˆλ‹€.
@api({ httpMethod: "GET" })
async findById(id: number): Promise<User> {
  return this.findById("A", id);
}

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

TanStack Query hooks도 μ™„λ²½ν•œ νƒ€μž… μ•ˆμ „μ„±μ„ μ œκ³΅ν•©λ‹ˆλ‹€.
export function useUserById(id: number) {
  return useQuery({
    queryKey: ["User", "findById", id],
    queryFn: () => findUserById(id),
  });
}

export function useSaveUser() {
  return useMutation({
    mutationFn: (params: UserSaveParams[]) => saveUser(params),
  });
}

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

Subset 쿼리도 μ™„λ²½ν•œ νƒ€μž… 좔둠을 μ œκ³΅ν•©λ‹ˆλ‹€.
async findMany<T extends UserSubsetKey>(
  subset: T,
  params?: UserListParams
): Promise<ListResult<UserListParams, UserSubsetMapping[T]>> {
  const { qb } = this.getSubsetQueries(subset);
  
  // subset에 따라 νƒ€μž…μ΄ μžλ™μœΌλ‘œ 결정됨
  return this.executeSubsetQuery({ subset, qb, params });
}

전체 μŠ€νƒ νƒ€μž… 흐름 μ˜ˆμ‹œ

μ‹€μ œ CRUD μž‘μ—…μ—μ„œ νƒ€μž…μ΄ μ–΄λ–»κ²Œ 흐λ₯΄λŠ”μ§€ λ³΄μ—¬μ€λ‹ˆλ‹€.
1

1. Entity μ •μ˜

user.entity.json
{
  "id": "User",
  "props": [
    { "name": "email", "type": "string", "length": 100 },
    { "name": "age", "type": "integer" },
    { "name": "role", "type": "enum", "id": "UserRole" }
  ],
  "subsets": {
    "A": ["id", "email", "age", "role"]
  }
}
생성: TypeScript νƒ€μž…, Zod μŠ€ν‚€λ§ˆ, PostgreSQL μŠ€ν‚€λ§ˆ
2

2. Model μž‘μ„±

user.model.ts
class UserModelClass extends BaseModelClass<...> {
  @api({ httpMethod: "POST" })
  async save(params: UserSaveParams[]): Promise<number[]> {
    // νƒ€μž… μ•ˆμ „ν•œ κ΅¬ν˜„
    params.forEach(p => wdb.ubRegister("users", p));
    return wdb.ubUpsert("users");
  }
}
생성: API ν΄λΌμ΄μ–ΈνŠΈ, TanStack Query hooks
3

3. ν”„λ‘ νŠΈμ—”λ“œ μ‚¬μš©

UserForm.tsx
import { useSaveUser } from "@/services/services.generated";
import { UserSaveParams } from "@/services/user/user.types";

function UserForm() {
const { mutate } = useSaveUser();

const handleSubmit = (values: UserSaveParams) => {
mutate([values]); // βœ… νƒ€μž… 체크됨
};

return <form>...</form>;
}

검증: 컴파일 νƒ€μž„ (TypeScript) + λŸ°νƒ€μž„ (Zod)
4

4. API μš”μ²­

// Request (μžλ™ 직렬화)
POST /user/save
{
  "params": [
    { "email": "[email protected]", "age": 30, "role": "admin" }
  ]
}

// Zod μžλ™ 검증
const params = UserSaveParams.array().parse(request.body.params);
검증: Zod μŠ€ν‚€λ§ˆλ‘œ λŸ°νƒ€μž„ 검증
5

5. λ°μ΄ν„°λ² μ΄μŠ€ μ €μž₯

-- μžλ™ μƒμ„±λœ 쿼리
INSERT INTO users (email, age, role)
VALUES ('[email protected]', 30, 'admin')
RETURNING id;

-- PostgreSQL μ œμ•½μ‘°κ±΄ 체크
-- - email: VARCHAR(100) NOT NULL
-- - age: INTEGER NOT NULL
-- - role: CHECK (role IN ('admin', 'normal'))

검증: PostgreSQL μ œμ•½μ‘°κ±΄
6

6. 응닡 λ°˜ν™˜

// Response
{ "data": [1] }  // number[]

// ν”„λ‘ νŠΈμ—”λ“œμ—μ„œ νƒ€μž… μΆ”λ‘ 
const ids = await saveUser([...]);
// ids: number[]
νƒ€μž… μΆ”λ‘ : API ν΄λΌμ΄μ–ΈνŠΈκ°€ λ°˜ν™˜ νƒ€μž… 보μž₯

νƒ€μž… 뢈일치 λ°©μ§€

Sonamu의 E2E νƒ€μž… μ•ˆμ „μ„±μ€ ν”ν•œ νƒ€μž… 뢈일치 문제λ₯Ό λ°©μ§€ν•©λ‹ˆλ‹€.
문제: APIκ°€ μ˜ˆμƒκ³Ό λ‹€λ₯Έ νƒ€μž… λ°˜ν™˜Sonamu ν•΄κ²°:
// Modelμ—μ„œ λ°˜ν™˜ νƒ€μž… λͺ…μ‹œ
@api()
async findById(id: number): Promise<User> {
  return this.findById("A", id);
}

// API ν΄λΌμ΄μ–ΈνŠΈλ„ 같은 νƒ€μž…
export async function findUserById(id: number): Promise<User> {
  // ...
}

// νƒ€μž… 뢈일치 μ‹œ 컴파일 μ—λŸ¬
문제: ν΄λΌμ΄μ–ΈνŠΈκ°€ 잘λͺ»λœ νŒŒλΌλ―Έν„° 전솑Sonamu ν•΄κ²°:
// λ™μΌν•œ νƒ€μž… μ •μ˜ 곡유
export const UserSaveParams = z.object({...});

// Modelμ—μ„œ μ‚¬μš©
async save(params: UserSaveParams[]): Promise<number[]>

// API ν΄λΌμ΄μ–ΈνŠΈμ—μ„œ μ‚¬μš©
export async function saveUser(params: UserSaveParams[]): Promise<number[]>

// μ„œλ²„μ—μ„œ μžλ™ 검증
const validated = UserSaveParams.array().parse(request.body.params);
문제: ν•˜λ“œμ½”λ”©λœ λ¬Έμžμ—΄μ΄ Enumκ³Ό 뢈일치Sonamu ν•΄κ²°:
// ❌ 잘λͺ»λœ 방법
const role = "guest";  // μ»΄νŒŒμΌμ€ λ˜μ§€λ§Œ λŸ°νƒ€μž„ μ—λŸ¬

// βœ… μ˜¬λ°”λ₯Έ 방법
import { UserRole } from "./sonamu.generated";
const role: UserRole = "admin";  // νƒ€μž… 체크됨
문제: nullable ν•„λ“œλ₯Ό null 체크 없이 μ‚¬μš©Sonamu ν•΄κ²°:
type User = {
  bio: string | null;  // nullable λͺ…μ‹œ
};

// ❌ 컴파일 μ—λŸ¬
const length = user.bio.length;

// βœ… null 체크 κ°•μ œ
const length = user.bio?.length ?? 0;

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

νƒ€μž… μ•ˆμ „μ„±μ΄ μ œλŒ€λ‘œ μž‘λ™ν•˜λŠ”μ§€ ν™•μΈν•˜λŠ” λ°©λ²•μž…λ‹ˆλ‹€.

컴파일 νƒ€μž„ 검증

// νƒ€μž… ν…ŒμŠ€νŠΈ 파일 생성
import { expectType } from "tsd";
import type { User, UserSaveParams } from "./user.types";

// νƒ€μž… 체크
expectType<User>({
  id: 1,
  email: "[email protected]",
  age: 30,
  role: "admin",
});

// ❌ 이 μ½”λ“œλŠ” 컴파일 μ—λŸ¬λ₯Ό λ°œμƒμ‹œμΌœμ•Ό 함
expectType<User>({
  id: 1,
  email: "[email protected]",
  age: "30", // Type error
});

λŸ°νƒ€μž„ 검증

import { describe, it, expect } from "vitest";
import { User, UserSaveParams } from "./user.types";

describe("User type validation", () => {
  it("should validate correct user", () => {
    const result = User.safeParse({
      email: "[email protected]",
      age: 30,
      role: "admin",
    });

    expect(result.success).toBe(true);
  });

  it("should reject invalid user", () => {
    const result = User.safeParse({
      email: "invalid-email",
      age: "30",
      role: "guest",
    });

    expect(result.success).toBe(false);
  });
});

λ‹€μŒ 단계