๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
@api ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋Š” Model ๋ฉ”์„œ๋“œ๋ฅผ HTTP API ์—”๋“œํฌ์ธํŠธ๋กœ ์ž๋™ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ๋ฉ”์„œ๋“œ์— ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด ๋ผ์šฐํŒ…, ํƒ€์ž… ๊ฒ€์ฆ, ํด๋ผ์ด์–ธํŠธ ์ฝ”๋“œ๊ฐ€ ์ž๋™ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.

๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

import { api } from "sonamu";

class UserModelClass extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async findById(id: number): Promise<User> {
    return this.getPuri("r").where("id", id).first();
  }
}
์ƒ์„ฑ๋˜๋Š” ๊ฒƒ๋“ค:
  • HTTP ์—”๋“œํฌ์ธํŠธ: GET /user/findById?id=1
  • TypeScript ํด๋ผ์ด์–ธํŠธ ํ•จ์ˆ˜
  • TanStack Query hooks (์„ ํƒ ์‹œ)
  • API ๋ฌธ์„œ

๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ์˜ต์…˜

์ „์ฒด ์˜ต์…˜

@api({
  httpMethod: "GET",                    // HTTP ๋ฉ”์„œ๋“œ
  contentType: "application/json",      // Content-Type
  clients: ["axios", "tanstack-query"], // ์ƒ์„ฑํ•  ํด๋ผ์ด์–ธํŠธ
  path: "/custom/path",                 // ์ปค์Šคํ…€ ๊ฒฝ๋กœ
  resourceName: "Users",                // ๋ฆฌ์†Œ์Šค ์ด๋ฆ„
  guards: ["admin", "user"],            // ์ธ์ฆ/๊ถŒํ•œ ๊ฐ€๋“œ
  description: "์‚ฌ์šฉ์ž ์กฐํšŒ API",       // API ์„ค๋ช…
  timeout: 5000,                        // ํƒ€์ž„์•„์›ƒ (ms)
  cacheControl: { maxAge: 60 },         // ์บ์‹œ ์„ค์ •
  compress: false,                      // ์‘๋‹ต ์••์ถ• ๋น„ํ™œ์„ฑํ™”
})

httpMethod

HTTP ๋ฉ”์„œ๋“œ๋ฅผ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.
๋ฉ”์„œ๋“œ์šฉ๋„์˜ˆ์‹œ
GET๋ฐ์ดํ„ฐ ์กฐํšŒfindById, findMany
POST๋ฐ์ดํ„ฐ ์ƒ์„ฑ/์ˆ˜์ •save, login
PUT๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธupdate
DELETE๋ฐ์ดํ„ฐ ์‚ญ์ œdel, remove
PATCH๋ถ€๋ถ„ ์—…๋ฐ์ดํŠธupdateProfile
@api({ httpMethod: "GET" })
async findById(id: number): Promise<User> {
  // ...
}
// ์—”๋“œํฌ์ธํŠธ: GET /user/findById?id=1
๊ธฐ๋ณธ๊ฐ’: httpMethod๋ฅผ ์ƒ๋žตํ•˜๋ฉด GET์ด ๊ธฐ๋ณธ๊ฐ’์ž…๋‹ˆ๋‹ค.
@api()  // httpMethod: "GET"
async findById(id: number) { }

clients

์ƒ์„ฑํ•  ํด๋ผ์ด์–ธํŠธ ์ฝ”๋“œ ํƒ€์ž…์„ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.
Client์„ค๋ช…์‚ฌ์šฉ ์˜ˆ์‹œ
axiosAxios ๊ธฐ๋ฐ˜ ํ•จ์ˆ˜์ผ๋ฐ˜ API ํ˜ธ์ถœ
axios-multipartํŒŒ์ผ ์—…๋กœ๋“œ์šฉ Axios์ด๋ฏธ์ง€ ์—…๋กœ๋“œ
tanstack-queryQuery hook๋ฐ์ดํ„ฐ ์กฐํšŒ
tanstack-mutationMutation hook๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ
window-fetchFetch API๋ธŒ๋ผ์šฐ์ € ๋„ค์ดํ‹ฐ๋ธŒ
@api({
  httpMethod: "GET",
  clients: ["axios", "tanstack-query"],
})
async findById(id: number): Promise<User> {
  // ...
}
์ƒ์„ฑ๋˜๋Š” ํด๋ผ์ด์–ธํŠธ ์ฝ”๋“œ:
// services/user.service.ts
export async function findUserById(id: number): Promise<User> {
  const { data } = await axios.get("/user/findById", { params: { id } });
  return data;
}
๊ธฐ๋ณธ๊ฐ’: clients๋ฅผ ์ƒ๋žตํ•˜๋ฉด ["axios"]๊ฐ€ ๊ธฐ๋ณธ๊ฐ’์ž…๋‹ˆ๋‹ค.

path

์ปค์Šคํ…€ API ๊ฒฝ๋กœ๋ฅผ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.
// ๊ธฐ๋ณธ ๊ฒฝ๋กœ
@api({ httpMethod: "GET" })
async findById(id: number) { }
// ๊ฒฝ๋กœ: /user/findById

// ์ปค์Šคํ…€ ๊ฒฝ๋กœ
@api({ httpMethod: "GET", path: "/api/v1/users/:id" })
async findById(id: number) { }
// ๊ฒฝ๋กœ: /api/v1/users/:id
๊ฒฝ๋กœ ํŒŒ๋ผ๋ฏธํ„ฐ:
@api({ httpMethod: "GET", path: "/posts/:postId/comments/:commentId" })
async findComment(postId: number, commentId: number): Promise<Comment> {
  // ...
}
// ํ˜ธ์ถœ: GET /posts/123/comments/456
๊ฒฝ๋กœ๋ฅผ ์ƒ๋žตํ•˜๋ฉด /{model}/{method} ํ˜•์‹์œผ๋กœ ์ž๋™ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.
  • Model: UserModel โ†’ user
  • Method: findById โ†’ findById
  • ๊ฒฐ๊ณผ: /user/findById

resourceName

API ๋ฆฌ์†Œ์Šค ์ด๋ฆ„์„ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค. TanStack Query์˜ queryKey์— ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.
@api({
  httpMethod: "GET",
  resourceName: "Users",  // ๋ณต์ˆ˜ํ˜•
  clients: ["tanstack-query"],
})
async findMany(): Promise<User[]> {
  // ...
}

// ์ƒ์„ฑ๋˜๋Š” Query Hook
export function useUsers() {
  return useQuery({
    queryKey: ["Users", "findMany"],  // resourceName ์‚ฌ์šฉ
    queryFn: () => findManyUsers(),
  });
}
๋„ค์ด๋ฐ ๊ฐ€์ด๋“œ:
API ํƒ€์ž…resourceName์˜ˆ์‹œ
๋‹จ์ผ ์กฐํšŒ๋‹จ์ˆ˜ํ˜•User
๋ชฉ๋ก ์กฐํšŒ๋ณต์ˆ˜ํ˜•Users
์ƒ์„ฑ/์ˆ˜์ •๋‹จ์ˆ˜ํ˜•User
์‚ญ์ œ๋ณต์ˆ˜ํ˜•Users

guards

์ธ์ฆ ๋ฐ ๊ถŒํ•œ ๊ฒ€์‚ฌ๋ฅผ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.
// ์ธ์ฆ๋งŒ ํ•„์š”
@api({ guards: ["user"] })
async getMyProfile(): Promise<User> {
  // ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ
}

// ๊ด€๋ฆฌ์ž ๊ถŒํ•œ ํ•„์š”
@api({ guards: ["admin"] })
async deleteUser(id: number): Promise<void> {
  // ๊ด€๋ฆฌ์ž๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ
}

// ์—ฌ๋Ÿฌ ๊ฐ€๋“œ ์กฐํ•ฉ
@api({ guards: ["user", "admin"] })
async someAdminAction(): Promise<void> {
  // user AND admin ๋ชจ๋‘ ๋งŒ์กฑํ•ด์•ผ ํ•จ
}
Guard ์ข…๋ฅ˜:
Guard์„ค๋ช…ํ™•์ธ ๋‚ด์šฉ
user๋กœ๊ทธ์ธ ํ•„์š”context.user ์กด์žฌ ์—ฌ๋ถ€
admin๊ด€๋ฆฌ์ž ๊ถŒํ•œcontext.user.role === "admin"
query์ปค์Šคํ…€ ๊ฒ€์‚ฌ์‚ฌ์šฉ์ž ์ •์˜ ๋กœ์ง
Guard ๋กœ์ง์€ sonamu.config.ts์˜ guardHandler์—์„œ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค.
sonamu.config.ts
export default {
  guardHandler: (guard, request, api) => {
    if (guard === "user" && !request.user) {
      throw new UnauthorizedException("๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");
    }
    if (guard === "admin" && request.user?.role !== "admin") {
      throw new ForbiddenException("๊ด€๋ฆฌ์ž ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");
    }
  },
};

contentType

์‘๋‹ต์˜ Content-Type์„ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.
// JSON (๊ธฐ๋ณธ๊ฐ’)
@api({ contentType: "application/json" })
async getUser(): Promise<User> {
  return { id: 1, name: "John" };
}

// HTML
@api({ contentType: "text/html" })
async renderProfile(): Promise<string> {
  return "<html><body>Profile</body></html>";
}

// Plain Text
@api({ contentType: "text/plain" })
async getLog(): Promise<string> {
  return "Log content...";
}

// Binary (ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ)
@api({ contentType: "application/octet-stream" })
async downloadFile(): Promise<Buffer> {
  return fileBuffer;
}

timeout

API ํƒ€์ž„์•„์›ƒ์„ ๋ฐ€๋ฆฌ์ดˆ ๋‹จ์œ„๋กœ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.
@api({ 
  httpMethod: "GET",
  timeout: 5000,  // 5์ดˆ
})
async longRunningQuery(): Promise<Result> {
  // 5์ดˆ ์ด์ƒ ๊ฑธ๋ฆฌ๋ฉด ํƒ€์ž„์•„์›ƒ
}
ํƒ€์ž„์•„์›ƒ์€ ํด๋ผ์ด์–ธํŠธ ์ธก ์„ค์ •์ž…๋‹ˆ๋‹ค. ์„œ๋ฒ„์—์„œ๋Š” ๊ณ„์† ์‹คํ–‰๋  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ, ์„œ๋ฒ„ ์ธก ํƒ€์ž„์•„์›ƒ๋„ ๋ณ„๋„๋กœ ์„ค์ •ํ•ด์•ผ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

cacheControl

HTTP Cache-Control ํ—ค๋”๋ฅผ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.
// 1๋ถ„ ์บ์‹ฑ
@api({
  cacheControl: {
    maxAge: 60,  // seconds
  },
})
async getStaticData(): Promise<Data> {
  // ...
}

// ์บ์‹ฑ ๋น„ํ™œ์„ฑํ™”
@api({
  cacheControl: {
    maxAge: 0,
    noCache: true,
  },
})
async getDynamicData(): Promise<Data> {
  // ...
}
CacheControl ์˜ต์…˜:
์˜ต์…˜ํƒ€์ž…์„ค๋ช…์˜ˆ์‹œ
maxAgenumber์ตœ๋Œ€ ์บ์‹œ ์‹œ๊ฐ„ (์ดˆ)60
noCacheboolean์บ์‹œ ์‚ฌ์šฉ ์•ˆํ•จtrue
noStoreboolean์ €์žฅ ์•ˆํ•จtrue
privateboolean์‚ฌ์šฉ์ž๋ณ„ ์บ์‹œtrue
publicboolean๊ณต์šฉ ์บ์‹œtrue

compress

์‘๋‹ต ์••์ถ• ์„ค์ •์„ ์ œ์–ดํ•ฉ๋‹ˆ๋‹ค.
// ์••์ถ• ํ™œ์„ฑํ™” (๊ธฐ๋ณธ๊ฐ’)
@api({ compress: true })
async getData(): Promise<LargeData> {
  // ์‘๋‹ต์ด gzip์œผ๋กœ ์••์ถ•๋จ
}

// ์••์ถ• ๋น„ํ™œ์„ฑํ™”
@api({ compress: false })
async streamData(): Promise<StreamData> {
  // ์••์ถ• ์—†์ด ์ „์†ก (์ŠคํŠธ๋ฆฌ๋ฐ์— ์ ํ•ฉ)
}

์—ฌ๋Ÿฌ ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ์กฐํ•ฉ

@api + @transactional

ํŠธ๋žœ์žญ์…˜ ๋‚ด์—์„œ API๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.
@api({ httpMethod: "POST" })
@transactional()
async updateUserAndProfile(
  userId: number,
  userData: UserData,
  profileData: ProfileData
): Promise<void> {
  await this.save([userData]);
  await ProfileModel.save([profileData]);
  // ์ž๋™ ์ปค๋ฐ‹ ๋˜๋Š” ๋กค๋ฐฑ
}

@api + @upload

ํŒŒ์ผ ์—…๋กœ๋“œ API๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค.
@api({
  httpMethod: "POST",
  clients: ["axios-multipart"],
})
@upload({ mode: "single" })
async uploadAvatar(): Promise<{ url: string }> {
  const { file } = Sonamu.getUploadContext();
  
  if (!file) {
    throw new BadRequestException("ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค");
  }
  
  // ํŒŒ์ผ ์ฒ˜๋ฆฌ...
  const url = await saveToS3(file);
  
  return { url };
}
Upload ์˜ต์…˜:
์˜ต์…˜์„ค๋ช…์‚ฌ์šฉ ์˜ˆ์‹œ
mode: "single"๋‹จ์ผ ํŒŒ์ผํ”„๋กœํ•„ ์ด๋ฏธ์ง€
mode: "multiple"์—ฌ๋Ÿฌ ํŒŒ์ผ๊ฐค๋Ÿฌ๋ฆฌ ์—…๋กœ๋“œ

API ๊ฒฝ๋กœ ๊ทœ์น™

๊ธฐ๋ณธ ๊ฒฝ๋กœ๋Š” ๋‹ค์Œ ๊ทœ์น™์œผ๋กœ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค:
/{modelName}/{methodName}
๋ณ€ํ™˜ ๊ทœ์น™:
  • Model ์ด๋ฆ„: PascalCase โ†’ camelCase
    • UserModel โ†’ user
    • BlogPostModel โ†’ blogPost
  • Method ์ด๋ฆ„: ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ
    • findById โ†’ findById
์˜ˆ์‹œ:
ModelMethod๊ฒฝ๋กœ
UserModelfindById/user/findById
BlogPostModelfindMany/blogPost/findMany
CommentModelsave/comment/save

์‹ค์ „ ์˜ˆ์ œ

๊ธฐ๋ณธ CRUD API

@api({
  httpMethod: "GET",
  clients: ["axios", "tanstack-query"],
  resourceName: "User",
})
async findById(id: number): Promise<User> {
  const user = await this.getPuri("r")
    .where("id", id)
    .first();
    
  if (!user) {
    throw new NotFoundException(`User not found: ${id}`);
  }
  
  return user;
}

์ธ์ฆ API

@api({
  httpMethod: "POST",
  clients: ["axios", "tanstack-mutation"],
})
async login(params: LoginParams): Promise<{ user: User; token: string }> {
  const { email, password } = params;
  
  // ์‚ฌ์šฉ์ž ์กฐํšŒ
  const user = await this.getPuri("r")
    .where("email", email)
    .first();
    
  if (!user) {
    throw new UnauthorizedException("์ด๋ฉ”์ผ ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค");
  }
  
  // ๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ
  const isValid = await bcrypt.compare(password, user.password);
  if (!isValid) {
    throw new UnauthorizedException("์ด๋ฉ”์ผ ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค");
  }
  
  // ์„ธ์…˜ ์ƒ์„ฑ
  const context = Sonamu.getContext();
  await context.passport.login(user);
  
  // ํ† ํฐ ์ƒ์„ฑ (์„ ํƒ)
  const token = jwt.sign({ userId: user.id }, SECRET_KEY);
  
  return { user, token };
}

@api({
  httpMethod: "GET",
  clients: ["axios", "tanstack-query"],
  guards: ["user"],
})
async me(): Promise<User | null> {
  const context = Sonamu.getContext();
  
  if (!context.user) {
    return null;
  }
  
  return this.findById(context.user.id);
}

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