๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
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()๋Š” ๋ณต์žกํ•œ ๊ฐ์ฒด ์ง๋ ฌํ™” ์‹œ ์‚ฌ์šฉ

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