메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
Sonamuκ°€ λ°±μ—”λ“œ API의 νƒ€μž…μ„ ν”„λ‘ νŠΈμ—”λ“œμ—μ„œ μžλ™μœΌλ‘œ μΆ”λ‘ ν•˜μ—¬ μ™„μ „ν•œ νƒ€μž… μ•ˆμ „μ„±μ„ μ œκ³΅ν•˜λŠ” 방법을 μ•Œμ•„λ΄…λ‹ˆλ‹€.

API νƒ€μž… μΆ”λ‘  κ°œμš”

μžλ™ νƒ€μž… 생성

λ°±μ—”λ“œ β†’ ν”„λ‘ νŠΈμ—”λ“œμˆ˜λ™ μž‘μ—… λΆˆν•„μš”

μ™„μ „ν•œ μΆ”λ‘ 

νŒŒλΌλ―Έν„°λΆ€ν„° μ‘λ‹΅κΉŒμ§€λͺ¨λ“  νƒ€μž… 보μž₯

μ‹€μ‹œκ°„ 동기화

API λ³€κ²½ μ‹œμžλ™ μ—…λ°μ΄νŠΈ

IDE 지원

μžλ™ μ™„μ„±νƒ€μž… 힌트

νƒ€μž… μΆ”λ‘ μ΄λž€?

문제: μˆ˜λ™ νƒ€μž… μ •μ˜μ˜ 어렀움

전톡적인 κ°œλ°œμ—μ„œλŠ” λ°±μ—”λ“œμ™€ ν”„λ‘ νŠΈμ—”λ“œμ˜ νƒ€μž…μ„ 각각 μˆ˜λ™μœΌλ‘œ μ •μ˜ν•΄μ•Ό ν•©λ‹ˆλ‹€.
// ❌ λ°±μ—”λ“œ (Node.js + Express)
app.get("/api/user/:id", async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json({ user });
});

// ❌ ν”„λ‘ νŠΈμ—”λ“œ (μˆ˜λ™ νƒ€μž… μ •μ˜)
interface User {
  id: number;
  username: string;
  email: string;
  // ν•„λ“œ μΆ”κ°€/λ³€κ²½ μ‹œ μˆ˜λ™ 동기화 ν•„μš”
}

async function getUser(id: number): Promise<{ user: User }> {
  const response = await fetch(`/api/user/${id}`);
  return response.json();
}
문제점:
  1. 쀑볡 μž‘μ—…: λ°±μ—”λ“œμ™€ ν”„λ‘ νŠΈμ—”λ“œμ—μ„œ 같은 νƒ€μž… 두 번 μ •μ˜
  2. 동기화 λˆ„λ½: λ°±μ—”λ“œ λ³€κ²½ μ‹œ ν”„λ‘ νŠΈμ—”λ“œ νƒ€μž… μ—…λ°μ΄νŠΈ λˆ„λ½
  3. λŸ°νƒ€μž„ μ—λŸ¬: νƒ€μž… 뢈일치λ₯Ό λŸ°νƒ€μž„μ—λ§Œ 발견
  4. μœ μ§€λ³΄μˆ˜ 어렀움: νƒ€μž…μ΄ λ§Žμ•„μ§ˆμˆ˜λ‘ 관리 λ³΅μž‘λ„ 증가

ν•΄κ²°: μžλ™ νƒ€μž… μΆ”λ‘ 

SonamuλŠ” λ°±μ—”λ“œ νƒ€μž…μ„ μžλ™μœΌλ‘œ μΆ”λ‘ ν•˜μ—¬ ν”„λ‘ νŠΈμ—”λ“œμ— μ „λ‹¬ν•©λ‹ˆλ‹€.
// βœ… λ°±μ—”λ“œ (Sonamu)
@api({ httpMethod: "GET" })
async getUser(userId: number): Promise<{
  user: {
    id: number;
    username: string;
    email: string;
  };
}> {
  const user = await this.findById(userId);
  return { user };
}

// βœ… ν”„λ‘ νŠΈμ—”λ“œ (μžλ™ 생성)
export namespace UserService {
  export async function getUser(
    userId: number
  ): Promise<{
    user: {
      id: number;
      username: string;
      email: string;
    };
  }> {
    return fetch({
      method: "GET",
      url: `/api/user/getUser?${qs.stringify({ userId })}`,
    });
  }
}
μž₯점:
  • λ°±μ—”λ“œ νƒ€μž…μ΄ ν”„λ‘ νŠΈμ—”λ“œμ— μžλ™μœΌλ‘œ 볡사됨
  • API λ³€κ²½ μ‹œ μžλ™μœΌλ‘œ 동기화됨
  • 단일 μ§„μ‹€ 곡급원 (λ°±μ—”λ“œκ°€ μœ μΌν•œ νƒ€μž… μ†ŒμŠ€)

νƒ€μž… μΆ”λ‘  κ³Όμ •

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

TypeScript νƒ€μž…μ„ ν¬ν•¨ν•˜μ—¬ 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;
      username: string;
      email: string;
      role: "admin" | "user";
      createdAt: Date;
    };
    stats: {
      postCount: number;
      followerCount: number;
    };
  }> {
    const user = await this.findById(userId);
    const stats = await this.getStats(userId);
    
    return { user, stats };
  }
}
TypeScript νƒ€μž… μ‹œμŠ€ν…œ ν™œμš©:
  • λ°˜ν™˜ νƒ€μž…μ„ λͺ…μ‹œμ μœΌλ‘œ μ„ μ–Έ
  • 쀑첩 객체, μœ λ‹ˆμ˜¨ νƒ€μž…, λ¦¬ν„°λŸ΄ νƒ€μž… λͺ¨λ‘ 지원
  • TypeScript의 λͺ¨λ“  νƒ€μž… κΈ°λŠ₯ ν™œμš© κ°€λŠ₯

2단계: AST νŒŒμ‹±

SonamuλŠ” TypeScript 컴파일러 APIλ₯Ό μ‚¬μš©ν•˜μ—¬ μ½”λ“œλ₯Ό λΆ„μ„ν•©λ‹ˆλ‹€.
// Sonamu λ‚΄λΆ€ λ™μž‘ (μ˜μ‚¬ μ½”λ“œ)
import * as ts from "typescript";

function extractApiType(methodNode: ts.MethodDeclaration) {
  // 1. λ°˜ν™˜ νƒ€μž… μΆ”μΆœ
  const returnType = typeChecker.getTypeAtLocation(methodNode.type);
  
  // 2. νƒ€μž… 정보λ₯Ό TypeScript μ½”λ“œλ‘œ λ³€ν™˜
  const typeString = typeChecker.typeToString(returnType);
  
  // 3. νŒŒλΌλ―Έν„° νƒ€μž… μΆ”μΆœ
  const parameters = methodNode.parameters.map(param => ({
    name: param.name.getText(),
    type: typeChecker.getTypeAtLocation(param.type),
  }));
  
  return {
    returnType: typeString,
    parameters,
  };
}
AST (Abstract Syntax Tree):
  • TypeScript μ½”λ“œλ₯Ό 트리 ꡬ쑰둜 ν‘œν˜„
  • νƒ€μž… 정보λ₯Ό ν”„λ‘œκ·Έλž˜λ° λ°©μ‹μœΌλ‘œ μΆ”μΆœ κ°€λŠ₯
  • 100% μ •ν™•ν•œ νƒ€μž… 정보 νšλ“

3단계: Service νƒ€μž… 생성

μΆ”μΆœν•œ νƒ€μž…μ„ Service μ½”λ“œμ— μ‚½μž…ν•©λ‹ˆλ‹€.
// services.generated.ts (μžλ™ 생성)
export namespace UserService {
  // νŒŒλΌλ―Έν„° νƒ€μž…λ„ μ •ν™•νžˆ μΆ”λ‘ 
  export async function getProfile(
    userId: number  // ← λ°±μ—”λ“œμ™€ λ™μΌν•œ νƒ€μž…
  ): Promise<{
    user: {
      id: number;
      username: string;
      email: string;
      role: "admin" | "user";  // ← λ¦¬ν„°λŸ΄ νƒ€μž…λ„ 보쑴
      createdAt: Date;
    };
    stats: {
      postCount: number;
      followerCount: number;
    };
  }> {
    return fetch({
      method: "GET",
      url: `/api/user/getProfile?${qs.stringify({ userId })}`,
    });
  }
}
νƒ€μž… 보쑴:
  • μœ λ‹ˆμ˜¨ νƒ€μž… ("admin" | "user")
  • 쀑첩 객체
  • λ°°μ—΄ νƒ€μž…
  • Date, null, undefined λ“± λͺ¨λ“  TypeScript νƒ€μž…

4단계: TanStack Query Hook 생성

React Hook에도 νƒ€μž…μ΄ μ •ν™•νžˆ μ „λ‹¬λ©λ‹ˆλ‹€.
// services.generated.ts (계속)
export namespace UserService {
  export const getProfileQueryOptions = (userId: number) =>
    queryOptions({
      queryKey: ["User", "getProfile", userId],
      queryFn: () => getProfile(userId),
    });
  
  export const useProfile = (
    userId: number,
    options?: { enabled?: boolean }
  ) =>
    useQuery({
      ...getProfileQueryOptions(userId),
      ...options,
    });
}
νƒ€μž… 체인:
Backend Method
  β†’ TypeScript AST
  β†’ Service Function
  β†’ Query Options
  β†’ React Hook
  β†’ Component
λͺ¨λ“  λ‹¨κ³„μ—μ„œ νƒ€μž…μ΄ 100% λ³΄μ‘΄λ©λ‹ˆλ‹€!

κ³ κΈ‰ νƒ€μž… μΆ”λ‘ 

μ œλ„€λ¦­ νƒ€μž…

μ œλ„€λ¦­μ„ μ‚¬μš©ν•˜λŠ” API도 μ •ν™•νžˆ μΆ”λ‘ λ©λ‹ˆλ‹€.
// λ°±μ—”λ“œ
@api({ httpMethod: "GET" })
async getList<T extends "posts" | "comments">(
  entityType: T
): Promise<{
  items: T extends "posts" ? Post[] : Comment[];
}> {
  // κ΅¬ν˜„...
}

// ν”„λ‘ νŠΈμ—”λ“œ (μžλ™ 생성)
export namespace DataService {
  export async function getList<T extends "posts" | "comments">(
    entityType: T
  ): Promise<{
    items: T extends "posts" ? Post[] : Comment[];
  }> {
    return fetch({
      method: "GET",
      url: `/api/data/getList?${qs.stringify({ entityType })}`,
    });
  }
}

// μ‚¬μš©
const { items } = await DataService.getList("posts");
// items의 νƒ€μž…: Post[]

Subset μ‹œμŠ€ν…œ

Subset νƒ€μž…λ„ μžλ™μœΌλ‘œ μƒμ„±λ©λ‹ˆλ‹€.
// Entity μ •μ˜μ—μ„œ Subset μΆ”μΆœ
export type UserSubsetKey = "A" | "B" | "C";

export type UserSubsetMapping = {
  A: { id: number; username: string; email: string };
  B: { id: number; username: string; email: string; bio: string };
  C: User; // 전체 ν•„λ“œ
};

// Serviceμ—μ„œ μ‚¬μš©
export namespace UserService {
  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 })}`,
    });
  }
}

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

const fullUser = await UserService.getUser("C", 123);
// νƒ€μž…: User
Mapped Types:
  • UserSubsetMapping[T]둜 Subset에 λ”°λ₯Έ μ •ν™•ν•œ νƒ€μž… λ°˜ν™˜
  • TypeScript의 쑰건뢀 νƒ€μž… ν™œμš©

λ³΅μž‘ν•œ 쀑첩 ꡬ쑰

깊게 μ€‘μ²©λœ νƒ€μž…λ„ μ •ν™•νžˆ μΆ”λ‘ λ©λ‹ˆλ‹€.
// λ°±μ—”λ“œ
@api({ httpMethod: "GET" })
async getDashboard(): Promise<{
  user: {
    profile: {
      name: string;
      avatar: string;
    };
    settings: {
      notifications: {
        email: boolean;
        push: boolean;
      };
      privacy: {
        profileVisibility: "public" | "private";
      };
    };
  };
  stats: {
    posts: { count: number; latest: Post[] };
    followers: { count: number; recent: User[] };
  };
}> {
  // κ΅¬ν˜„...
}

// ν”„λ‘ νŠΈμ—”λ“œ (μžλ™ 생성)
export namespace DashboardService {
  export async function getDashboard(): Promise<{
    user: {
      profile: {
        name: string;
        avatar: string;
      };
      settings: {
        notifications: {
          email: boolean;
          push: boolean;
        };
        privacy: {
          profileVisibility: "public" | "private";
        };
      };
    };
    stats: {
      posts: { count: number; latest: Post[] };
      followers: { count: number; recent: User[] };
    };
  }> {
    return fetch({
      method: "GET",
      url: "/api/dashboard/getDashboard",
    });
  }
}

// μ‚¬μš© (μ™„μ „ν•œ νƒ€μž… μ•ˆμ „μ„±)
const dashboard = await DashboardService.getDashboard();
console.log(dashboard.user.settings.notifications.email); // βœ… boolean
console.log(dashboard.stats.posts.latest[0].title); // βœ… string

μ‹€μ „ ν™œμš©

React μ»΄ν¬λ„ŒνŠΈ

νƒ€μž…μ΄ μžλ™μœΌλ‘œ μΆ”λ‘ λ˜μ–΄ IDE 지원을 λ°›μŠ΅λ‹ˆλ‹€.
import { UserService } from "@/services/services.generated";

function UserProfile({ userId }: { userId: number }) {
  const { data, isLoading } = UserService.useProfile(userId);
  
  if (isLoading) return <div>Loading...</div>;
  
  // data의 νƒ€μž…μ΄ μžλ™μœΌλ‘œ 좔둠됨
  return (
    <div>
      <h1>{data.user.username}</h1>
      <p>Role: {data.user.role}</p>
      <p>Posts: {data.stats.postCount}</p>
      <p>Followers: {data.stats.followerCount}</p>
    </div>
  );
}
IDE μžλ™ μ™„μ„±:
  • data. μž…λ ₯ μ‹œ user, stats μžλ™ μ œμ•ˆ
  • data.user. μž…λ ₯ μ‹œ λͺ¨λ“  ν•„λ“œ μžλ™ μ œμ•ˆ
  • 잘λͺ»λœ ν•„λ“œ μ ‘κ·Ό μ‹œ μ¦‰μ‹œ μ—λŸ¬ ν‘œμ‹œ

νƒ€μž… μž¬μ‚¬μš©

μƒμ„±λœ νƒ€μž…μ„ λ‹€λ₯Έ κ³³μ—μ„œ μž¬μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
import type { UserService } from "@/services/services.generated";

// Service ν•¨μˆ˜μ˜ λ°˜ν™˜ νƒ€μž… μΆ”μΆœ
type UserProfile = Awaited<ReturnType<typeof UserService.getProfile>>;

// νƒ€μž… μ‚¬μš©
function processProfile(profile: UserProfile) {
  console.log(profile.user.username);
  console.log(profile.stats.postCount);
}

폼 데이터 νƒ€μž…

API νŒŒλΌλ―Έν„° νƒ€μž…λ„ μΆ”λ‘ λ©λ‹ˆλ‹€.
// λ°±μ—”λ“œ
@api({ httpMethod: "POST" })
async createPost(params: {
  title: string;
  content: string;
  tags: string[];
  published: boolean;
}): Promise<{ post: Post }> {
  // κ΅¬ν˜„...
}

// ν”„λ‘ νŠΈμ—”λ“œ (μžλ™ 생성)
export namespace PostService {
  export async function createPost(params: {
    title: string;
    content: string;
    tags: string[];
    published: boolean;
  }): Promise<{ post: Post }> {
    return fetch({
      method: "POST",
      url: "/api/post/createPost",
      data: params,
    });
  }
}

// μ‚¬μš© (νŒŒλΌλ―Έν„° νƒ€μž… 검증)
function CreatePostForm() {
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    
    await PostService.createPost({
      title: "Hello",
      content: "World",
      tags: ["typescript", "sonamu"],
      published: true,
      // author: "John" // ❌ 컴파일 μ—λŸ¬: μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ν•„λ“œ
    });
  }
}

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

1. νŒŒλΌλ―Έν„° 검증

잘λͺ»λœ νŒŒλΌλ―Έν„°λŠ” 컴파일 νƒ€μž„μ— κ°μ§€λ©λ‹ˆλ‹€.
// ❌ 컴파일 μ—λŸ¬
await UserService.getProfile("123"); // string λŒ€μ‹  number ν•„μš”

// βœ… 정상
await UserService.getProfile(123);

2. 응닡 νƒ€μž… 보μž₯

API μ‘λ‹΅μ˜ νƒ€μž…μ΄ 보μž₯λ©λ‹ˆλ‹€.
const { user } = await UserService.getProfile(123);

console.log(user.username); // βœ… string
console.log(user.age); // ❌ 컴파일 μ—λŸ¬: age ν•„λ“œ μ—†μŒ

3. null/undefined 처리

μ˜΅μ…”λ„ ν•„λ“œλ„ μ •ν™•νžˆ ν‘œν˜„λ©λ‹ˆλ‹€.
// λ°±μ—”λ“œ
@api({ httpMethod: "GET" })
async getUser(): Promise<{
  user: {
    id: number;
    bio?: string; // μ˜΅μ…”λ„
  };
}> {
  // κ΅¬ν˜„...
}

// ν”„λ‘ νŠΈμ—”λ“œ
const { user } = await UserService.getUser();
console.log(user.bio?.length); // βœ… μ˜΅μ…”λ„ 체이닝 ν•„μˆ˜

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

API νƒ€μž… μΆ”λ‘  μ‚¬μš© μ‹œ μ£Όμ˜μ‚¬ν•­:
  1. λ°±μ—”λ“œ API에 λͺ…μ‹œμ  νƒ€μž… μ„ μ–Έ ν•„μˆ˜
  2. any νƒ€μž… μ‚¬μš© κΈˆμ§€ (νƒ€μž… μΆ”λ‘  λΆˆκ°€)
  3. pnpm generate μ‹€ν–‰ν•΄μ•Ό νƒ€μž… μ—…λ°μ΄νŠΈ
  4. μƒμ„±λœ νƒ€μž… 파일 μˆ˜λ™ μˆ˜μ • κΈˆμ§€
  5. λ³΅μž‘ν•œ νƒ€μž…μ€ 별도 interface둜 뢄리 ꢌμž₯

λ‹€μŒ 단계