메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
Sonamuκ°€ λ°±μ—”λ“œ APIλ‘œλΆ€ν„° νƒ€μž… μ•ˆμ „ν•œ ν΄λΌμ΄μ–ΈνŠΈ Serviceλ₯Ό μžλ™ μƒμ„±ν•˜λŠ” 방식을 μ΄ν•΄ν•©λ‹ˆλ‹€.

μžλ™ 생성 Service κ°œμš”

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

λ°±μ—”λ“œμ™€ λ™κΈ°ν™”μ»΄νŒŒμΌ νƒ€μž„ 검증

Namespace 기반

정적 ν•¨μˆ˜κ°„κ²°ν•œ 호좜

TanStack Query

React Hook μžλ™ 생성캐싱과 μž¬κ²€μ¦

Subset 지원

ν•„μš”ν•œ ν•„λ“œλ§Œμ„±λŠ₯ μ΅œμ ν™”

μ™œ μžλ™ 생성인가?

문제: μˆ˜λ™ API ν΄λΌμ΄μ–ΈνŠΈμ˜ ν•œκ³„

전톡적인 ν”„λ‘ νŠΈμ—”λ“œ κ°œλ°œμ—μ„œλŠ” λ°±μ—”λ“œ APIλ₯Ό ν˜ΈμΆœν•˜κΈ° μœ„ν•΄ μˆ˜λ™μœΌλ‘œ ν΄λΌμ΄μ–ΈνŠΈ μ½”λ“œλ₯Ό μž‘μ„±ν•©λ‹ˆλ‹€. μˆ˜λ™ ν΄λΌμ΄μ–ΈνŠΈ μ˜ˆμ‹œ:
// ❌ μˆ˜λ™ μž‘μ„± - λ¬Έμ œκ°€ 많음
async function getUser(userId: number) {
  const response = await axios.get(`/api/user/${userId}`);
  return response.data;
}

async function updateUser(userId: number, data: any) {
  const response = await axios.put(`/api/user/${userId}`, data);
  return response.data;
}
이 λ°©μ‹μ˜ 문제점:
  1. νƒ€μž… μ•ˆμ „μ„± λΆ€μž¬: any νƒ€μž… λ‚¨λ°œ, λŸ°νƒ€μž„ μ—λŸ¬ λ°œμƒ
  2. λ°±μ—”λ“œ λ³€κ²½ 좔적 λΆˆκ°€: APIκ°€ λ³€κ²½λ˜μ–΄λ„ ν”„λ‘ νŠΈμ—”λ“œλŠ” λͺ¨λ¦„
  3. 쀑볡 μ½”λ“œ: λͺ¨λ“  APIλ§ˆλ‹€ λΉ„μŠ·ν•œ μ½”λ“œ 반볡
  4. μ‹€μˆ˜ κ°€λŠ₯μ„±: URL μ˜€νƒ€, 잘λͺ»λœ νŒŒλΌλ―Έν„° λ“±
  5. μœ μ§€λ³΄μˆ˜ 어렀움: API λ³€κ²½ μ‹œ λͺ¨λ“  호좜 지점 μˆ˜μ • ν•„μš”

ν•΄κ²°: μžλ™ μƒμ„±μ˜ μž₯점

SonamuλŠ” λ°±μ—”λ“œμ˜ @api λ°μ½”λ ˆμ΄ν„°λ₯Ό λΆ„μ„ν•˜μ—¬ νƒ€μž… μ•ˆμ „ν•œ ν΄λΌμ΄μ–ΈνŠΈλ₯Ό μžλ™ μƒμ„±ν•©λ‹ˆλ‹€. μžλ™ 생성 ν΄λΌμ΄μ–ΈνŠΈ μ˜ˆμ‹œ:
// βœ… μžλ™ 생성 - νƒ€μž… μ•ˆμ „ (Namespace 기반)
export namespace UserService {
  // Subset으둜 ν•„μš”ν•œ ν•„λ“œλ§Œ 쑰회
  export async function getUser<T extends UserSubsetKey>(
    subset: T,
    id: number
  ): Promise<UserSubsetMapping[T]> {
    return fetch({
      method: "GET",
      url: `/api/user/findById?${qs.stringify({ subset, id })}`,
    });
  }
  
  export async function updateUser(
    id: number,
    params: { username?: string; email?: string }
  ): Promise<User> {
    return fetch({
      method: "PUT",
      url: `/api/user/update`,
      data: { id, ...params },
    });
  }
}
μž₯점:
  1. ✨ μ™„μ „ν•œ νƒ€μž… μ•ˆμ „μ„±: λ°±μ—”λ“œ νƒ€μž…μ΄ ν”„λ‘ νŠΈμ—”λ“œμ— κ·ΈλŒ€λ‘œ 반영
  2. ✨ 즉각적인 μ—λŸ¬ 발견: 컴파일 νƒ€μž„μ— API λ³€κ²½ 감지
  3. ✨ μžλ™ μ™„μ„±: IDEκ°€ API νŒŒλΌλ―Έν„°μ™€ 응닡 νƒ€μž… μžλ™ μ œμ•ˆ
  4. ✨ Namespace 기반: κΉ”λ”ν•œ ꡬ쑰, import κ°„νŽΈ
  5. ✨ 단일 μ§„μ‹€ 곡급원: λ°±μ—”λ“œκ°€ API λͺ…μ„Έμ˜ μœ μΌν•œ μ†ŒμŠ€
**단일 μ§„μ‹€ 곡급원 (Single Source of Truth)**μ΄λž€ μ‹œμŠ€ν…œμ˜ λͺ¨λ“  정보가 ν•˜λ‚˜μ˜ μ†ŒμŠ€μ—μ„œ νŒŒμƒλ˜λŠ” μ›μΉ™μž…λ‹ˆλ‹€. Sonamuμ—μ„œλŠ” λ°±μ—”λ“œμ˜ @api λ°μ½”λ ˆμ΄ν„°κ°€ μœ μΌν•œ API λͺ…세이며, ν”„λ‘ νŠΈμ—”λ“œλŠ” 이λ₯Ό λ”°λΌκ°‘λ‹ˆλ‹€.

Service 생성 κ³Όμ •

1단계: λ°±μ—”λ“œ API μ •μ˜

λ°±μ—”λ“œμ—μ„œ @api λ°μ½”λ ˆμ΄ν„°λ‘œ APIλ₯Ό μ •μ˜ν•©λ‹ˆλ‹€.
// backend/models/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,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }
  
  /**
   * μ‚¬μš©μž ν”„λ‘œν•„ 쑰회
   */
  @api({ httpMethod: "GET" })
  async getProfile(userId: number): Promise<{
    user: {
      id: number;
      email: string;
      username: string;
      createdAt: Date;
    };
  }> {
    const rdb = this.getPuri("r");
    const user = await rdb
      .table("users")
      .where("id", userId)
      .first();
    
    return { user };
  }
  
  /**
   * μ‚¬μš©μž ν”„λ‘œν•„ μˆ˜μ •
   */
  @api({ httpMethod: "PUT", guards: ["user"] })
  async updateProfile(params: {
    username?: string;
    bio?: string;
  }): Promise<{
    user: {
      id: number;
      username: string;
      bio: string;
    };
  }> {
    // κ΅¬ν˜„...
  }
}
이 API μ •μ˜κ°€ λͺ¨λ“  κ²ƒμ˜ μ‹œμž‘μ μž…λ‹ˆλ‹€. νƒ€μž… 정보, νŒŒλΌλ―Έν„°, 응닡 ν˜•μ‹ λͺ¨λ‘ 여기에 μ •μ˜λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€.

2단계: TypeScript AST νŒŒμ‹±

SonamuλŠ” TypeScript 컴파일러 APIλ₯Ό μ‚¬μš©ν•˜μ—¬ μ½”λ“œλ₯Ό λΆ„μ„ν•©λ‹ˆλ‹€. 뢄석 κ³Όμ •:
// Sonamu의 λ‚΄λΆ€ λ™μž‘ (μ˜μ‚¬ μ½”λ“œ)
const sourceFile = ts.createSourceFile(
  "user.model.ts",
  fileContent,
  ts.ScriptTarget.Latest
);

// @api λ°μ½”λ ˆμ΄ν„°κ°€ μžˆλŠ” λ©”μ„œλ“œ μ°ΎκΈ°
const apiMethods = findDecorators(sourceFile, "api");

for (const method of apiMethods) {
  const apiInfo = {
    name: method.name.text,                    // "getProfile"
    httpMethod: getDecoratorOption("httpMethod"), // "GET"
    path: `/api/user/${method.name.text}`,    // "/api/user/getProfile"
    parameters: extractParameters(method),     // [{ name: "userId", type: "number" }]
    returnType: extractReturnType(method),     // Promise<{ user: User }>
  };
  
  // Service μ½”λ“œ 생성
  generateServiceMethod(apiInfo);
}
핡심 κ°œλ…:
  • AST (Abstract Syntax Tree): μ½”λ“œλ₯Ό 트리 ꡬ쑰둜 ν‘œν˜„ν•œ 것
  • νƒ€μž… μΆ”μΆœ: TypeScript의 νƒ€μž… μ‹œμŠ€ν…œμ—μ„œ μ •ν™•ν•œ νƒ€μž… 정보 νšλ“
  • 메타데이터 μˆ˜μ§‘: λ°μ½”λ ˆμ΄ν„° μ˜΅μ…˜, Guards, 주석 λ“± λͺ¨λ“  정보 μˆ˜μ§‘

3단계: Namespace Service 생성

μˆ˜μ§‘λœ 정보λ₯Ό λ°”νƒ•μœΌλ‘œ Namespace 기반 Serviceλ₯Ό μƒμ„±ν•©λ‹ˆλ‹€. μƒμ„±λ˜λŠ” μ½”λ“œ (services.generated.ts):
import qs from "qs";

// Subset νƒ€μž… μ •μ˜
export type UserSubsetKey = "A" | "B" | "C";

// Subset 별 νƒ€μž… λ§€ν•‘
export type UserSubsetMapping = {
  A: { id: number; email: string; username: string };           // κΈ°λ³Έ ν•„λ“œ
  B: { id: number; email: string; username: string; bio: string }; // + bio
  C: User; // 전체 ν•„λ“œ (createdAt, updatedAt λ“± 포함)
};

/**
 * User Service Namespace
 * 
 * λͺ¨λ“  User κ΄€λ ¨ API ν˜ΈμΆœμ„ λ‹΄λ‹Ήν•˜λŠ” namespaceμž…λ‹ˆλ‹€.
 */
export namespace UserService {
  /**
   * μ‚¬μš©μž 쑰회 (Subset 지원)
   * 
   * @param subset - μ‘°νšŒν•  ν•„λ“œ λ²”μœ„ ("A" | "B" | "C")
   * @param id - μ‚¬μš©μž ID
   * @returns Subset에 λ”°λ₯Έ νƒ€μž… μ•ˆμ „ν•œ μ‚¬μš©μž 정보
   */
  export async function getUser<T extends UserSubsetKey>(
    subset: T,
    id: number
  ): Promise<UserSubsetMapping[T]> {
    // qs.stringify둜 쿼리 νŒŒλΌλ―Έν„° 직렬화
    return fetch({
      method: "GET",
      url: `/api/user/findById?${qs.stringify({ subset, id })}`,
    });
  }
  
  /**
   * μ‚¬μš©μž ν”„λ‘œν•„ μˆ˜μ •
   * 
   * @param params - μˆ˜μ •ν•  ν•„λ“œ
   * @returns μˆ˜μ •λœ μ‚¬μš©μž 정보
   */
  export async function updateProfile(params: {
    username?: string;
    bio?: string;
  }): Promise<{
    user: {
      id: number;
      username: string;
      bio: string;
    };
  }> {
    return fetch({
      method: "PUT",
      url: "/api/user/updateProfile",
      data: params, // POST/PUT은 body둜 전솑
    });
  }
}
Namespace ꡬ쑰의 μž₯점:
  • 간결함: ν΄λž˜μŠ€λ³΄λ‹€ 간단 (new λΆˆν•„μš”)
  • 정적 λ©”μ„œλ“œ: μƒνƒœ 관리 λΆˆν•„μš”
  • Tree-shaking: μ‚¬μš©ν•˜μ§€ μ•ŠλŠ” ν•¨μˆ˜λŠ” λ²ˆλ“€μ—μ„œ μ œμ™Έ
  • Import κ°„νŽΈ: import { UserService } from "./services.generated"

4단계: TanStack Query Hook 생성

Reactμ—μ„œ λ°”λ‘œ μ‚¬μš©ν•  수 μžˆλŠ” Hook도 μžλ™ μƒμ„±λ©λ‹ˆλ‹€.
// services.generated.ts (계속)
import { useQuery, queryOptions } from "@tanstack/react-query";

export namespace UserService {
  // ... μœ„μ˜ ν•¨μˆ˜λ“€
  
  /**
   * TanStack Query Options
   * 
   * queryKey와 queryFn을 ν¬ν•¨ν•œ μž¬μ‚¬μš© κ°€λŠ₯ν•œ μ˜΅μ…˜μž…λ‹ˆλ‹€.
   */
  export const getUserQueryOptions = <T extends UserSubsetKey>(
    subset: T,
    id: number
  ) =>
    queryOptions({
      queryKey: ["User", "getUser", subset, id],
      queryFn: () => getUser(subset, id),
    });
  
  /**
   * React Hook (TanStack Query)
   * 
   * μžλ™ 캐싱, μž¬κ²€μ¦, λ‘œλ”© μƒνƒœλ₯Ό μ œκ³΅ν•˜λŠ” Hookμž…λ‹ˆλ‹€.
   */
  export const useUser = <T extends UserSubsetKey>(
    subset: T,
    id: number,
    options?: { enabled?: boolean }
  ) =>
    useQuery({
      ...getUserQueryOptions(subset, id),
      ...options,
    });
}
TanStack Query ν†΅ν•©μ˜ μž₯점:
  • μžλ™ 캐싱
  • μžλ™ μž¬κ²€μ¦
  • λ‘œλ”©/μ—λŸ¬ μƒνƒœ μžλ™ 관리
  • 쑰건뢀 페칭 지원
  • 낙관적 μ—…λ°μ΄νŠΈ 지원

μƒμ„±λœ Service의 ꡬ쑰

fetch μœ ν‹Έλ¦¬ν‹° ν•¨μˆ˜

λͺ¨λ“  Serviceκ°€ μ‚¬μš©ν•˜λŠ” 곡톡 fetch ν•¨μˆ˜μž…λ‹ˆλ‹€.
// sonamu.shared.ts
import axios, { AxiosRequestConfig } from "axios";
import { z } from "zod";

/**
 * 곡톡 fetch ν•¨μˆ˜
 * 
 * λͺ¨λ“  API 호좜이 이 ν•¨μˆ˜λ₯Ό 톡해 μ΄λ£¨μ–΄μ§‘λ‹ˆλ‹€.
 * Axiosλ₯Ό λž˜ν•‘ν•˜μ—¬ μ—λŸ¬ μ²˜λ¦¬μ™€ 응닡 λ³€ν™˜μ„ λ‹΄λ‹Ήν•©λ‹ˆλ‹€.
 */
export async function fetch(options: AxiosRequestConfig) {
  try {
    const res = await axios({
      ...options,
    });
    return res.data;
  } catch (e: unknown) {
    // Axios μ—λŸ¬λ₯Ό SonamuError둜 λ³€ν™˜
    if (axios.isAxiosError(e) && e.response && e.response.data) {
      const d = e.response.data as {
        message: string;
        issues: z.ZodIssue[];
      };
      throw new SonamuError(e.response.status, d.message, d.issues);
    }
    throw e;
  }
}

/**
 * Sonamu μ—λŸ¬ 클래슀
 * 
 * HTTP μƒνƒœ μ½”λ“œμ™€ Zod μœ νš¨μ„± 검사 이슈λ₯Ό ν¬ν•¨ν•©λ‹ˆλ‹€.
 */
export class SonamuError extends Error {
  isSonamuError: boolean;

  constructor(
    public code: number,        // HTTP μƒνƒœ μ½”λ“œ (401, 403, 422 λ“±)
    public message: string,     // μ—λŸ¬ λ©”μ‹œμ§€
    public issues: z.ZodIssue[] // Zod μœ νš¨μ„± 검사 이슈
  ) {
    super(message);
    this.isSonamuError = true;
  }
}

/**
 * μ—λŸ¬ νƒ€μž… κ°€λ“œ
 */
export function isSonamuError(e: any): e is SonamuError {
  return e && e.isSonamuError === true;
}
fetch ν•¨μˆ˜μ˜ μ—­ν• :
  • Axios 호좜 λž˜ν•‘: optionsλ₯Ό Axios에 전달
  • μžλ™ 응닡 μΆ”μΆœ: res.dataλ₯Ό λ°”λ‘œ λ°˜ν™˜
  • μ—λŸ¬ λ³€ν™˜: Axios μ—λŸ¬ β†’ SonamuError
  • Zod 이슈 처리: μœ νš¨μ„± 검사 μ—λŸ¬λ₯Ό νƒ€μž… μ•ˆμ „ν•˜κ²Œ 처리
AxiosRequestConfig νŒŒλΌλ―Έν„°:
{
  method: "GET" | "POST" | "PUT" | "DELETE",
  url: string,
  params?: Record<string, any>,  // GET 쿼리 νŒŒλΌλ―Έν„°
  data?: any,                     // POST/PUT body
  headers?: Record<string, string>,
}

Subset μ‹œμŠ€ν…œ

Sonamu의 λ…νŠΉν•œ κΈ°λŠ₯인 Subset μ‹œμŠ€ν…œμž…λ‹ˆλ‹€. Subsetμ΄λž€? μ—”ν‹°ν‹°μ˜ μ—¬λŸ¬ λ³€ν˜•(subset)을 μ •μ˜ν•˜μ—¬ ν•„μš”ν•œ ν•„λ“œλ§Œ μ‘°νšŒν•  수 μžˆλŠ” μ‹œμŠ€ν…œμž…λ‹ˆλ‹€.
// Subset μ •μ˜ (μžλ™ 생성)
export type UserSubsetKey = "A" | "B" | "C";

export type UserSubsetMapping = {
  A: { id: number; email: string; username: string },           // κΈ°λ³Έ 정보
  B: { id: number; email: string; username: string; bio: string }, // + bio
  C: User, // 전체 ν•„λ“œ (createdAt, updatedAt, deletedAt λ“± 포함)
};

// μ‚¬μš© μ˜ˆμ‹œ
const basicUser = await UserService.getUser("A", 123);  
// νƒ€μž…: { id: number; email: string; username: string }

const fullUser = await UserService.getUser("C", 123);   
// νƒ€μž…: User (전체 ν•„λ“œ)
Subset의 μž₯점:
  1. μ„±λŠ₯: ν•„μš”ν•œ ν•„λ“œλ§Œ μ‘°νšŒν•˜μ—¬ λ„€νŠΈμ›Œν¬ λΉ„μš© 절감
  2. νƒ€μž… μ•ˆμ „: 각 Subsetλ§ˆλ‹€ μ •ν™•ν•œ νƒ€μž… λ°˜ν™˜
  3. λͺ…μ‹œμ„±: μ–΄λ–€ 데이터가 ν•„μš”ν•œμ§€ μ½”λ“œμ—μ„œ λͺ…ν™•νžˆ ν‘œν˜„
  4. λ°μ΄ν„°λ² μ΄μŠ€ μ΅œμ ν™”: SELECT μ ˆμ— ν•„μš”ν•œ 컬럼만 포함
Subset 넀이밍 κ·œμΉ™:
  • A: κΈ°λ³Έ ν•„λ“œ (id, 핡심 정보)
  • B: 쀑간 ν•„λ“œ (A + μΆ”κ°€ 정보)
  • C: 전체 ν•„λ“œ (λͺ¨λ“  컬럼, νƒ€μž„μŠ€νƒ¬ν”„ 포함)

νƒ€μž… μ•ˆμ „μ„±μ˜ μ‹€μ œ

컴파일 νƒ€μž„ 검증

λ°±μ—”λ“œ APIκ°€ λ³€κ²½λ˜λ©΄ 컴파일 νƒ€μž„μ— μ¦‰μ‹œ μ—λŸ¬κ°€ λ°œμƒν•©λ‹ˆλ‹€. λ°±μ—”λ“œ λ³€κ²½:
// λ°±μ—”λ“œμ—μ„œ username -> displayName으둜 λ³€κ²½
@api({ httpMethod: "PUT" })
async updateProfile(params: {
  displayName?: string;  // usernameμ—μ„œ 변경됨
  bio?: string;
}): Promise<{ user: User }> {
  // ...
}
ν”„λ‘ νŠΈμ—”λ“œ μ—λŸ¬:
// pnpm generate ν›„ μžλ™μœΌλ‘œ Service νƒ€μž… μ—…λ°μ΄νŠΈλ¨

// ❌ 컴파일 μ—λŸ¬ λ°œμƒ!
await UserService.updateProfile({
  username: "newname",  // Error: 'username' does not exist in type
});

// βœ… μˆ˜μ • ν›„ 정상 λ™μž‘
await UserService.updateProfile({
  displayName: "newname",  // OK
});
μ΄λŠ” λŸ°νƒ€μž„ μ—λŸ¬λ₯Ό 컴파일 νƒ€μž„μœΌλ‘œ λŒμ–΄μ˜¬λ € 버그λ₯Ό 사전에 λ°©μ§€ν•©λ‹ˆλ‹€.

IDE μžλ™ μ™„μ„±

νƒ€μž… 정보 덕뢄에 IDEκ°€ κ°•λ ₯ν•œ μžλ™ 완성을 μ œκ³΅ν•©λ‹ˆλ‹€.
// νƒ€μž…μ„ μž…λ ₯ν•˜λ©΄...
UserService.up  // IDEκ°€ "updateProfile" μžλ™ μ œμ•ˆ

// νŒŒλΌλ―Έν„°λ₯Ό μž…λ ₯ν•˜λ©΄...
await UserService.updateProfile({
  // IDEκ°€ κ°€λŠ₯ν•œ ν•„λ“œλ₯Ό λͺ¨λ‘ μ œμ•ˆ:
  // - displayName?: string
  // - bio?: string
});

// Subset도 μžλ™ μ™„μ„±
await UserService.getUser(
  "A" // IDEκ°€ "A" | "B" | "C" μ œμ•ˆ
  , 123
);

개발 μ›Œν¬ν”Œλ‘œμš°

λ°±μ—”λ“œ μš°μ„  개발

Sonamu의 μžλ™ 생성은 λ°±μ—”λ“œ μš°μ„ (Backend-First) κ°œλ°œμ„ ꢌμž₯ν•©λ‹ˆλ‹€. 일반적인 μ›Œν¬ν”Œλ‘œμš°: μž₯점:
  • λ°±μ—”λ“œμ™€ ν”„λ‘ νŠΈμ—”λ“œμ˜ 계약(Contract)이 λͺ…ν™•
  • νƒ€μž… 뢈일치둜 μΈν•œ 버그 μ›μ²œ 차단
  • API λ¬Έμ„œ μžλ™ 생성 (Serviceκ°€ κ³§ λ¬Έμ„œ)
  • ν˜‘μ—… νš¨μœ¨μ„± ν–₯상

개발 μ‹œ μž¬μƒμ„±

APIκ°€ 변경될 λ•Œλ§ˆλ‹€ Serviceλ₯Ό μž¬μƒμ„±ν•΄μ•Ό ν•©λ‹ˆλ‹€.
# Service μž¬μƒμ„±
pnpm generate

# λ˜λŠ” watch λͺ¨λ“œλ‘œ μžλ™ μž¬μƒμ„±
pnpm generate:watch
μ£Όμ˜μ‚¬ν•­:
  • μƒμ„±λœ Service 파일(services.generated.ts)은 μ ˆλŒ€ μˆ˜λ™ μˆ˜μ • κΈˆμ§€
  • μˆ˜μ •μ΄ ν•„μš”ν•˜λ©΄ λ°±μ—”λ“œμ—μ„œ μˆ˜μ • ν›„ μž¬μƒμ„±
  • 생성 νŒŒμΌμ„ .gitignore에 μΆ”κ°€ν• μ§€ νŒ€μ—μ„œ κ²°μ •
    • μΆ”κ°€ν•˜λ©΄: 각자 λ‘œμ»¬μ—μ„œ 생성
    • μΆ”κ°€ μ•ˆν•˜λ©΄: Git으둜 곡유 (λΉŒλ“œ μ‹œκ°„ 단좕)

μ‹€μ œ μ‚¬μš© μ˜ˆμ‹œ

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

import { UserService } from "@/services/services.generated";

// Subset "A"둜 κΈ°λ³Έ μ •λ³΄λ§Œ 쑰회
const user = await UserService.getUser("A", 123);
console.log(user.username); // OK
console.log(user.bio); // ❌ 컴파일 μ—λŸ¬ (Subset A에 bio μ—†μŒ)

// Subset "B"둜 bio 포함 쑰회
const userWithBio = await UserService.getUser("B", 123);
console.log(userWithBio.bio); // OK

// ν”„λ‘œν•„ μˆ˜μ •
await UserService.updateProfile({
  username: "newname",
  bio: "Hello, World!",
});

Reactμ—μ„œ μ‚¬μš© (TanStack Query Hook)

import { UserService } from "@/services/services.generated";

function UserProfile({ userId }: { userId: number }) {
  // μžλ™ μƒμ„±λœ Hook μ‚¬μš©
  const { data: user, isLoading, error } = UserService.useUser("A", userId);
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return <div>User not found</div>;
  
  return (
    <div>
      <h1>{user.username}</h1>
      <p>{user.email}</p>
    </div>
  );
}

쑰건뢀 페칭

function UserProfile({ userId }: { userId: number | null }) {
  const { data: user } = UserService.useUser(
    "A", 
    userId!, // TypeScript non-null assertion
    { enabled: userId !== null } // userIdκ°€ null이면 호좜 μ•ˆν•¨
  );
  
  if (!userId) return <div>Please select a user</div>;
  
  return <div>{user?.username}</div>;
}

κ³ κΈ‰ κΈ°λŠ₯

qs.stringify μ‚¬μš©

GET μš”μ²­μ˜ 쿼리 νŒŒλΌλ―Έν„°λ₯Ό 직렬화할 λ•Œ qs 라이브러리λ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€.
import qs from "qs";

// λ³΅μž‘ν•œ 객체도 쿼리 슀트링으둜 λ³€ν™˜
const queryString = qs.stringify({ 
  subset: "A", 
  id: 123,
  filters: { status: "active" } 
});
// "subset=A&id=123&filters[status]=active"

// Serviceμ—μ„œ μ‚¬μš©
export async function getUser<T extends UserSubsetKey>(
  subset: T,
  id: number
): Promise<UserSubsetMapping[T]> {
  return fetch({
    method: "GET",
    url: `/api/user/findById?${qs.stringify({ subset, id })}`,
  });
}
qsλ₯Ό μ‚¬μš©ν•˜λŠ” 이유:
  • 쀑첩 객체 지원 (filters[status]=active)
  • λ°°μ—΄ 직렬화 지원 (ids[]=1&ids[]=2)
  • λ°±μ—”λ“œμ˜ νŒŒμ‹± 방식과 일치

μ—λŸ¬ 처리

SonamuErrorλ₯Ό νƒ€μž… μ•ˆμ „ν•˜κ²Œ μ²˜λ¦¬ν•©λ‹ˆλ‹€.
import { UserService } from "@/services/services.generated";
import { isSonamuError } from "@/lib/sonamu.shared";

try {
  await UserService.updateProfile({
    username: "newname",
  });
} catch (error) {
  if (isSonamuError(error)) {
    // Sonamu μ—λŸ¬
    console.log("Status:", error.code);
    console.log("Message:", error.message);
    console.log("Validation Issues:", error.issues);
    
    // Zod μœ νš¨μ„± 검사 μ—λŸ¬ 처리
    error.issues.forEach((issue) => {
      console.log(`${issue.path.join(".")}: ${issue.message}`);
    });
  } else {
    // 일반 μ—λŸ¬
    console.error(error);
  }
}

Query Options μž¬μ‚¬μš©

import { UserService } from "@/services/services.generated";
import { useQueryClient } from "@tanstack/react-query";

function SomeComponent() {
  const queryClient = useQueryClient();
  
  async function handleUpdate() {
    // μˆ˜μ • ν›„ μΊμ‹œ λ¬΄νš¨ν™”
    await UserService.updateProfile({ username: "newname" });
    
    // Query Options둜 νŠΉμ • 쿼리 λ¬΄νš¨ν™”
    queryClient.invalidateQueries(
      UserService.getUserQueryOptions("A", 123)
    );
  }
}

Prefetching

import { UserService } from "@/services/services.generated";
import { useQueryClient } from "@tanstack/react-query";

function UserList({ userIds }: { userIds: number[] }) {
  const queryClient = useQueryClient();
  
  // ν˜Έλ²„ μ‹œ 미리 λ‘œλ“œ
  function handleMouseEnter(userId: number) {
    queryClient.prefetchQuery(
      UserService.getUserQueryOptions("A", userId)
    );
  }
  
  return (
    <ul>
      {userIds.map((id) => (
        <li
          key={id}
          onMouseEnter={() => handleMouseEnter(id)}
        >
          User {id}
        </li>
      ))}
    </ul>
  );
}

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

Service μ‚¬μš© μ‹œ μ£Όμ˜μ‚¬ν•­:
  1. μƒμ„±λœ Service 파일(services.generated.ts)은 μ ˆλŒ€ μˆ˜λ™ μˆ˜μ • κΈˆμ§€
  2. Subset νŒŒλΌλ―Έν„° ν•„μˆ˜: getUser("A", id) 처럼 subset μ§€μ • ν•„μš”
  3. Namespaceμ΄λ―€λ‘œ new λΆˆν•„μš”: UserService.getUser() 직접 호좜
  4. TanStack Query Hook은 μ»΄ν¬λ„ŒνŠΈ λ‚΄λΆ€μ—μ„œλ§Œ 호좜
  5. μ—λŸ¬ 처리 μ‹œ isSonamuError() νƒ€μž… κ°€λ“œ μ‚¬μš©
  6. qs.stringify()λŠ” λ³΅μž‘ν•œ 객체 직렬화 μ‹œ μ‚¬μš©

λ‹€μŒ 단계