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;
}
μ΄ λ°©μμ λ¬Έμ μ :
- νμ
μμ μ± λΆμ¬:
any νμ
λ¨λ°, λ°νμ μλ¬ λ°μ
- λ°±μλ λ³κ²½ μΆμ λΆκ°: APIκ° λ³κ²½λμ΄λ νλ‘ νΈμλλ λͺ¨λ¦
- μ€λ³΅ μ½λ: λͺ¨λ APIλ§λ€ λΉμ·ν μ½λ λ°λ³΅
- μ€μ κ°λ₯μ±: URL μ€ν, μλͺ»λ νλΌλ―Έν° λ±
- μ μ§λ³΄μ μ΄λ €μ: 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 },
});
}
}
μ₯μ :
- β¨ μμ ν νμ
μμ μ±: λ°±μλ νμ
μ΄ νλ‘ νΈμλμ κ·Έλλ‘ λ°μ
- β¨ μ¦κ°μ μΈ μλ¬ λ°κ²¬: μ»΄νμΌ νμμ API λ³κ²½ κ°μ§
- β¨ μλ μμ±: IDEκ° API νλΌλ―Έν°μ μλ΅ νμ
μλ μ μ
- β¨ Namespace κΈ°λ°: κΉλν ꡬ쑰, import κ°νΈ
- β¨ λ¨μΌ μ§μ€ 곡κΈμ: λ°±μλκ° 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μ μ₯μ :
- μ±λ₯: νμν νλλ§ μ‘°ννμ¬ λ€νΈμν¬ λΉμ© μ κ°
- νμ
μμ : κ° Subsetλ§λ€ μ νν νμ
λ°ν
- λͺ
μμ±: μ΄λ€ λ°μ΄ν°κ° νμνμ§ μ½λμμ λͺ
νν νν
- λ°μ΄ν°λ² μ΄μ€ μ΅μ ν: 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 μ¬μ© μ μ£Όμμ¬ν:
- μμ±λ Service νμΌ(
services.generated.ts)μ μ λ μλ μμ κΈμ§
- Subset νλΌλ―Έν° νμ:
getUser("A", id) μ²λΌ subset μ§μ νμ
- Namespaceμ΄λ―λ‘ new λΆνμ:
UserService.getUser() μ§μ νΈμΆ
- TanStack Query Hookμ μ»΄ν¬λνΈ λ΄λΆμμλ§ νΈμΆ
- μλ¬ μ²λ¦¬ μ
isSonamuError() νμ
κ°λ μ¬μ©
qs.stringify()λ 볡μ‘ν κ°μ²΄ μ§λ ¬ν μ μ¬μ©
λ€μ λ¨κ³