๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
TypeScript๋Š” ๊ฐ•๋ ฅํ•œ ํƒ€์ž… ์‹œ์Šคํ…œ์„ ๊ฐ€์ง€๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๋งŽ์€ ํ”„๋ ˆ์ž„์›Œํฌ๋“ค์ด ์ด ๊ฐ•์ ์„ ์˜จ์ „ํžˆ ํ™œ์šฉํ•˜์ง€ ๋ชปํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. Sonamu๋Š” TypeScript์˜ ํƒ€์ž… ์‹œ์Šคํ…œ์„ ์ค‘์‹ฌ์— ๋‘๊ณ  ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

๋ˆ„๊ตฌ๋ฅผ ์œ„ํ•œ ํ”„๋ ˆ์ž„์›Œํฌ์ธ๊ฐ€

์ƒ์‚ฐ์„ฑ์„ ์ค‘์‹œํ•˜๋Š” ๊ฐœ๋ฐœ์ž

๋ฐ˜๋ณต ์ž‘์—…์— ์‹œ๊ฐ„์„ ๋‚ญ๋น„ํ•˜์ง€ ์•Š๊ณ , ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์— ์ง‘์ค‘ํ•˜๊ณ  ์‹ถ์€ ๋ถ„

ํƒ€์ž… ์•ˆ์ „์„ฑ์„ ์‹ ๋ขฐํ•˜๋Š” ํŒ€

๋Ÿฐํƒ€์ž„ ์—๋Ÿฌ๋ณด๋‹ค ์ปดํŒŒ์ผ ์—๋Ÿฌ๋ฅผ ์„ ํ˜ธํ•˜๊ณ , IDE์˜ ๋„์›€์„ ์ตœ๋Œ€ํ•œ ํ™œ์šฉํ•˜๋Š” ํŒ€

ํ”„๋ก ํŠธ์—”๋“œ์™€ ํ˜‘์—…ํ•˜๋Š” ๋ฐฑ์—”๋“œ

API ์ŠคํŽ™ ๋ฌธ์„œํ™”์™€ ํด๋ผ์ด์–ธํŠธ ์ž‘์„ฑ์˜ ์ด์ค‘ ์ž‘์—…์—์„œ ๋ฒ—์–ด๋‚˜๊ณ  ์‹ถ์€ ๋ถ„

๋น ๋ฅธ ํ”„๋กœํ† ํƒ€์ดํ•‘์ด ํ•„์š”ํ•œ ์Šคํƒ€ํŠธ์—…

MVP๋ฅผ ๋น ๋ฅด๊ฒŒ ๋งŒ๋“ค๋ฉด์„œ๋„ ๊ธฐ์ˆ  ๋ถ€์ฑ„๋ฅผ ์ตœ์†Œํ™”ํ•˜๊ณ  ์‹ถ์€ ํŒ€

ํ•ต์‹ฌ ์ฒ ํ•™: ๋‹จ์ผ ์ง„์‹ค ๊ณต๊ธ‰์›

Entity ํ•˜๋‚˜๋ฅผ ์ •์˜ํ•˜๋ฉด, ๋‚˜๋จธ์ง€๋Š” TypeScript๊ฐ€ ์•Œ์•„์„œ ํ•ฉ๋‹ˆ๋‹ค.
Entity๋ฅผ ํ•œ ๊ณณ์— ์ •์˜ํ•˜๋ฉด, ์‹œ์Šคํ…œ ์ „์ฒด๊ฐ€ ๋™๊ธฐํ™”๋ฉ๋‹ˆ๋‹ค. ์ˆ˜๋™์œผ๋กœ ํƒ€์ž…์„ ๋งž์ถ”๊ฑฐ๋‚˜, API ์ŠคํŽ™์„ ๋ฌธ์„œํ™”ํ•˜๊ฑฐ๋‚˜, ํ”„๋ก ํŠธ์—”๋“œ ํด๋ผ์ด์–ธํŠธ๋ฅผ ์ž‘์„ฑํ•  ํ•„์š”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

๋ฐฑ์—”๋“œ์™€ ํ”„๋ก ํŠธ์—”๋“œ์˜ ํƒ€์ž… ๋™๊ธฐํ™”

์ผ๋ฐ˜์ ์œผ๋กœ ๋ฐฑ์—”๋“œ API๋ฅผ ๋งŒ๋“ค๊ณ  ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ์‚ฌ์šฉํ•  ๋•Œ๋Š” ํƒ€์ž… ๋™๊ธฐํ™”๊ฐ€ ์–ด๋ ต์Šต๋‹ˆ๋‹ค. Sonamu๋Š” ๋ฐฑ์—”๋“œ ๋ณ€๊ฒฝ์ด ์ฆ‰์‹œ ํ”„๋ก ํŠธ์—”๋“œ์— ๋ฐ˜์˜๋˜์–ด ์ปดํŒŒ์ผ ํƒ€์ž„์— ์—๋Ÿฌ๋ฅผ ๊ฐ์ง€ํ•ฉ๋‹ˆ๋‹ค.
// ๋ฐฑ์—”๋“œ API
router.get('/api/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json(user);
});

// ํ”„๋ก ํŠธ์—”๋“œ ํด๋ผ์ด์–ธํŠธ (์ˆ˜๋™์œผ๋กœ ์ž‘์„ฑ)
async function getUser(id: number): Promise<any> {  // โ† any!
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

const user = await getUser(123);
console.log(user.username);  // ๋Ÿฐํƒ€์ž„ ์—๋Ÿฌ ๊ฐ€๋Šฅ์„ฑ
Sonamu ๋ฐฉ์‹ ์ปดํŒŒ์ผ ์—๋Ÿฌ

Fully Type-safe Query Builder: Puri

Sonamu๋Š” Knex ๊ธฐ๋ฐ˜์˜ ํƒ€์ž… ์•ˆ์ „ํ•œ ์ฟผ๋ฆฌ ๋นŒ๋” Puri๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ํ…Œ์ด๋ธ”๋ช…, ์นผ๋Ÿผ๋ช…, ๊ด€๊ณ„๊นŒ์ง€ ๋ชจ๋‘ ํƒ€์ž… ์ฒดํฌ๋ฉ๋‹ˆ๋‹ค.
// ํƒ€์ž… ์•ˆ์ „ํ•œ ์ฟผ๋ฆฌ
const users = await this.getPuri("r")
  .table("users")           // โœ… ํ…Œ์ด๋ธ”๋ช… ์ž๋™์™„์„ฑ
  .select("id", "email")    // โœ… ์นผ๋Ÿผ๋ช… ํƒ€์ž… ์ฒดํฌ
  .where("role", "admin")   // โœ… ์นผ๋Ÿผ๋ช… + ๊ฐ’ ํƒ€์ž… ์ฒดํฌ
  .orderBy("created_at", "desc");

// Relation ์กฐ์ธ
const posts = await this.getPuri("r")
  .table("posts")
  .join("users", "posts.author_id", "users.id")  // โœ… ๊ด€๊ณ„ ํƒ€์ž… ์ฒดํฌ
  .select("posts.*", "users.username as author_name");
ํ•ต์‹ฌ ๊ธฐ๋Šฅ:
  • โœ… Entity ์ •์˜์—์„œ ํƒ€์ž… ์ž๋™ ์ถ”์ถœ
  • โœ… ํ…Œ์ด๋ธ”๋ช…, ์นผ๋Ÿผ๋ช…, ๊ด€๊ณ„ ๋ชจ๋‘ ์ž๋™์™„์„ฑ
  • โœ… ์ž˜๋ชป๋œ ์นผ๋Ÿผ๋ช…์€ ์ปดํŒŒ์ผ ์—๋Ÿฌ
  • โœ… Knex์˜ ๋ชจ๋“  ๊ธฐ๋Šฅ ์ง€์›

Puri ๊ฐ€์ด๋“œ

ํƒ€์ž… ์•ˆ์ „ํ•œ ์ฟผ๋ฆฌ ์ž‘์„ฑํ•˜๊ธฐ

Frontend Integration

๋ฐฑ์—”๋“œ API๋ฅผ ์ •์˜ํ•˜๋ฉด, ํ”„๋ก ํŠธ์—”๋“œ Service์™€ TanStack Query Hook์ด ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.
// ๋ฐฑ์—”๋“œ: @api ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋กœ API ์ •์˜
@api({ httpMethod: "GET" })
async getProfile(userId: number): Promise<User> {
  return this.getPuri("r").table("users").where("id", userId).first();
}

// ํ”„๋ก ํŠธ์—”๋“œ: ์ž๋™ ์ƒ์„ฑ๋œ Hook ์‚ฌ์šฉ
function UserProfile({ userId }: { userId: number }) {
  const { data: user, isLoading } = UserService.useUser("A", userId);

  if (isLoading) return <div>Loading...</div>;
  return <div>{user.username}</div>; // โœ… ์™„๋ฒฝํ•œ ํƒ€์ž… ์•ˆ์ „์„ฑ
}

Subset์œผ๋กœ ์ตœ์ ํ™”

ํ•„์š”ํ•œ ํ•„๋“œ๋งŒ ์กฐํšŒํ•˜์—ฌ ๋„คํŠธ์›Œํฌ ๋น„์šฉ์„ ์ค„์ด๊ณ  ์„ฑ๋Šฅ์„ ๋†’์ž…๋‹ˆ๋‹ค. ๊ฐ Subset๋งˆ๋‹ค ์ •ํ™•ํ•œ TypeScript ํƒ€์ž…์ด ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.
// ๋ชฉ๋ก ํ™”๋ฉด: ์ตœ์†Œ ์ •๋ณด๋งŒ
const users = await UserService.findMany("A", { page: 1 });
// { id, email, username }

// ์ƒ์„ธ ํ™”๋ฉด: ์ „์ฒด ์ •๋ณด
const user = await UserService.findById("C", userId);
// { id, email, username, bio, createdAt, updatedAt, ... }

SSR

Vite + React ๊ธฐ๋ฐ˜์˜ ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ๋ Œ๋”๋ง์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. registerSSR๋กœ SSRํ•  ๋ผ์šฐํŠธ๋ฅผ ๋“ฑ๋กํ•˜๊ณ , preload ์ฝœ๋ฐฑ์œผ๋กœ ์„œ๋ฒ„์—์„œ ํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฏธ๋ฆฌ ๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค.
// api/src/ssr/routes.ts
import { registerSSR } from "sonamu/ssr";
import { UserService } from "../application/queries.generated";

// ์‚ฌ์šฉ์ž ์ƒ์„ธ ํŽ˜์ด์ง€ SSR ๋“ฑ๋ก
registerSSR({
  path: "/users/:id",
  preload: (params) => [
    // Service ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ โ†’ SSRQuery ๋ฐ˜ํ™˜
    UserService.getUser("C", parseInt(params.id)),
  ],
});
๋“ฑ๋ก๋œ ๋ผ์šฐํŠธ๋Š” ์„œ๋ฒ„์—์„œ ๋ Œ๋”๋ง๋˜๊ณ , ํด๋ผ์ด์–ธํŠธ์—์„œ๋Š” ์ž๋™์œผ๋กœ hydration๋ฉ๋‹ˆ๋‹ค. SEO ์ตœ์ ํ™”์™€ ์ดˆ๊ธฐ ๋กœ๋”ฉ ์†๋„๋ฅผ ๊ฐœ์„ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•ต์‹ฌ ๊ธฐ๋Šฅ:
  • โœ… ๋ฐฑ์—”๋“œ ๋ณ€๊ฒฝ ์‹œ ์ปดํŒŒ์ผ ์—๋Ÿฌ๋กœ ์ฆ‰์‹œ ๊ฐ์ง€
  • โœ… Namespace ๊ธฐ๋ฐ˜์œผ๋กœ ๊น”๋”ํ•œ ๊ตฌ์กฐ
  • โœ… TanStack Query ์ž๋™ ํ†ตํ•ฉ (์บ์‹ฑ, ์žฌ๊ฒ€์ฆ, ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ)
  • โœ… Subset๋ณ„ ์ •ํ™•ํ•œ ํƒ€์ž… ์ถ”๋ก 
  • โœ… SSR ์ง€์›์œผ๋กœ SEO ์ตœ์ ํ™”

Testing

Sonamu๋Š” ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ๊ฐ•๋ ฅํ•œ ๋„๊ตฌ๋ฅผ ๋‚ด์žฅํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

Fixture ์‹œ์Šคํ…œ

Fixture DB์— ๋ฏธ๋ฆฌ ์„ธํŒ…๋œ ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ๋ฅผ createFixtureLoader๋กœ ์‰ฝ๊ฒŒ ๋กœ๋“œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
// fixture.ts
import { createFixtureLoader } from "sonamu/test";
import { UserModel } from "../application/user/user.model";

export const loadFixtures = createFixtureLoader({
  adminUser: async () => UserModel.findById("A", 1),
  normalUser: async () => UserModel.findById("A", 2),
});

// ํ…Œ์ŠคํŠธ์—์„œ ์‚ฌ์šฉ
import { loadFixtures } from "./fixture";

test("๊ด€๋ฆฌ์ž๋Š” ๋ชจ๋“  ์‚ฌ์šฉ์ž๋ฅผ ๋ณผ ์ˆ˜ ์žˆ๋‹ค", async () => {
  const f = await loadFixtures(["adminUser"]);

  expect(f.adminUser.role).toBe("admin");
});

Naite: ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์ถ”์  & ์‹œ๊ฐํ™”

ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์ค‘ ๋ชจ๋“  ๋ฐ์ดํ„ฐ ๋ณ€ํ™”๋ฅผ ์ถ”์ ํ•˜๊ณ  ์‹œ๊ฐํ™”ํ•ฉ๋‹ˆ๋‹ค. ๋””๋ฒ„๊น…์ด ํ›จ์”ฌ ์‰ฌ์›Œ์ง‘๋‹ˆ๋‹ค.
test("์‚ฌ์šฉ์ž ์ƒ์„ฑ ํ”Œ๋กœ์šฐ", async () => {
  Naite.t("์ดˆ๊ธฐ ์ƒํƒœ", { userCount: 0 });

  const user = await UserModel.create({
    email: "test@test.com",
    username: "testuser",
  });

  Naite.t("์‚ฌ์šฉ์ž ์ƒ์„ฑ ํ›„", { userCount: 1, userId: user.id });

  await UserModel.delete(user.id);

  Naite.t("์‚ญ์ œ ํ›„", { userCount: 0 });
});

Transaction ์ž๋™ ๋กค๋ฐฑ

๊ฐ ํ…Œ์ŠคํŠธ๋Š” ๋…๋ฆฝ๋œ Transaction์—์„œ ์‹คํ–‰๋˜๊ณ  ์ž๋™์œผ๋กœ ๋กค๋ฐฑ๋ฉ๋‹ˆ๋‹ค. ํ…Œ์ŠคํŠธ ๊ฐ„ ๊ฒฉ๋ฆฌ๊ฐ€ ๋ณด์žฅ๋˜๋ฉฐ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ๊ฐ€ ํ•„์š” ์—†์Šต๋‹ˆ๋‹ค.
test("์‚ฌ์šฉ์ž ์ƒ์„ฑ", async () => {
  await UserModel.create({ email: "test@test.com", ... });
  // ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ํ›„ ์ž๋™ ๋กค๋ฐฑ โ†’ DB๋Š” ๊นจ๋—ํ•œ ์ƒํƒœ ์œ ์ง€
});

test("๋‹ค์Œ ํ…Œ์ŠคํŠธ๋Š” ๊นจ๋—ํ•œ DB์—์„œ ์‹œ์ž‘", async () => {
  const users = await UserModel.findMany();
  expect(users).toHaveLength(0); // โœ… ์ด์ „ ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์—†์Œ
});
ํ•ต์‹ฌ ๊ธฐ๋Šฅ:
  • โœ… Fixture๋กœ ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์žฌ์‚ฌ์šฉ
  • โœ… Naite๋กœ ๋ฐ์ดํ„ฐ ๋ณ€ํ™” ์‹œ๊ฐํ™”
  • โœ… Transaction ์ž๋™ ๋กค๋ฐฑ์œผ๋กœ ๊ฒฉ๋ฆฌ๋œ ํ…Œ์ŠคํŠธ

AI Ready

Sonamu๋Š” AI ์‹œ๋Œ€์— ๋งž๋Š” ๊ธฐ๋Šฅ๋“ค์„ ๋„ค์ดํ‹ฐ๋ธŒ๋กœ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

Vector Search (pgvector)

AI ์ž„๋ฒ ๋”ฉ ๊ฒ€์ƒ‰์„ Entity ํ•„๋“œ๋กœ ์ •์˜ํ•˜๋ฉด ๋์ž…๋‹ˆ๋‹ค. pgvector๋ฅผ ๋„ค์ดํ‹ฐ๋ธŒ๋กœ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.
// Entity์— vector ํ•„๋“œ ์ •์˜
{
  "name": "embedding",
  "type": "vector",
  "dimensions": 1536
}

// vectorSimilarity๋กœ ๋ฒกํ„ฐ ๊ฒ€์ƒ‰
const results = await this.getPuri("r")
  .table("documents")
  .select("id", "title", "content")
  .vectorSimilarity("embedding", queryEmbedding, "similarity")
  .orderBy("similarity", "desc")
  .limit(10);

AI SDK ํ†ตํ•ฉ

Vercel AI SDK์™€ ์™„๋ฒฝํ•˜๊ฒŒ ํ†ตํ•ฉ๋ฉ๋‹ˆ๋‹ค. ์ŠคํŠธ๋ฆฌ๋ฐ ์‘๋‹ต๋„ ๊ฐ„๋‹จํ•ฉ๋‹ˆ๋‹ค.
@api({ httpMethod: "POST" })
async chat(message: string): Promise<StreamingResponse> {
  const result = await streamText({
    model: openai("gpt-4"),
    messages: [{ role: "user", content: message }],
  });

  return result.toTextStreamResponse();
}

SSE (Server-Sent Events)

@stream ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋กœ ์‹ค์‹œ๊ฐ„ ์ŠคํŠธ๋ฆฌ๋ฐ์„ ๊ฐ„ํŽธํ•˜๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
import { stream } from "sonamu";
import { z } from "zod";

const ChatEventSchema = z.object({
  type: z.enum(["message", "done"]),
  content: z.string().optional(),
});

@stream({ eventSchema: ChatEventSchema })
async chat(message: string) {
  return async (emit) => {
    for (const chunk of await generateResponse(message)) {
      await emit({ type: "message", content: chunk });
    }
    await emit({ type: "done" });
  };
}

AI Agent ํ†ตํ•ฉ

Sonamu UI์˜ AI ์ฑ„ํŒ…์œผ๋กœ Entity๋ฅผ ์ž์—ฐ์–ด๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
๐Ÿ’ฌ "์ด์ปค๋จธ์Šค ์ฃผ๋ฌธ ์‹œ์Šคํ…œ์„ ๋งŒ๋“ค์–ด์ค˜. ์‚ฌ์šฉ์ž, ์ƒํ’ˆ, ์ฃผ๋ฌธ, ์ฃผ๋ฌธ ์•„์ดํ…œ ํ…Œ์ด๋ธ”์ด ํ•„์š”ํ•ด."

โ†’ Entity 4๊ฐœ๊ฐ€ ๊ด€๊ณ„๊นŒ์ง€ ํฌํ•จํ•ด์„œ ์ž๋™ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.
ํ•ต์‹ฌ ๊ธฐ๋Šฅ:
  • โœ… pgvector ๋„ค์ดํ‹ฐ๋ธŒ ์ง€์› (๋ฒกํ„ฐ ๊ฒ€์ƒ‰)
  • โœ… AI SDK ์™„๋ฒฝ ํ†ตํ•ฉ (์ŠคํŠธ๋ฆฌ๋ฐ)
  • โœ… SSE๋กœ ์‹ค์‹œ๊ฐ„ ์ด๋ฒคํŠธ
  • โœ… AI Agent๋กœ Entity ์ž๋™ ์ƒ์„ฑ

Seamless DX

Sonamu๋Š” ๊ฐœ๋ฐœ์ž ๊ฒฝํ—˜์„ ์ตœ์šฐ์„ ์œผ๋กœ ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

HMR (Hot Module Replacement)

์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜๋ฉด ์„œ๋ฒ„ ์žฌ์‹œ์ž‘ ์—†์ด ์ฆ‰์‹œ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค. Entity, Model, API ์–ด๋””๋ฅผ ์ˆ˜์ •ํ•˜๋“  2์ดˆ๋ฉด ์ถฉ๋ถ„ํ•ฉ๋‹ˆ๋‹ค.
# ์ „ํ†ต์  ๋ฐฉ์‹: Entity ์ˆ˜์ • โ†’ ์„œ๋ฒ„ ์žฌ์‹œ์ž‘ โ†’ 30์ดˆ ๋Œ€๊ธฐ
# Sonamu ๋ฐฉ์‹: Entity ์ˆ˜์ • โ†’ ์ €์žฅ โ†’ 2์ดˆ ํ›„ ๋ฐ˜์˜ โœ…
# ์ฝ˜์†” ์ถœ๋ ฅ ์˜ˆ์‹œ
๐Ÿ”„ Invalidated:
- src/application/user/user.model.ts (with 8 APIs)

โœ… All files are synced!
ํ•˜๋ฃจ์— 50๋ฒˆ ์ˆ˜์ •ํ•œ๋‹ค๋ฉด? ์ „ํ†ต์  ๋ฐฉ์‹์€ 25๋ถ„ ๋‚ญ๋น„, Sonamu๋Š” 1.7๋ถ„์ž…๋‹ˆ๋‹ค.

Sonamu UI

Entity ์ •์˜, Subset ๊ด€๋ฆฌ, Migration ์‹คํ–‰์„ ์›น UI์—์„œ ์ง์ ‘ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. JSON์„ ์ง์ ‘ ํŽธ์ง‘ํ•˜์ง€ ์•Š์•„๋„ ๋ฉ๋‹ˆ๋‹ค.

๋น ๋ฅธ ํ”ผ๋“œ๋ฐฑ ๋ฃจํ”„

Entity ๋ณ€๊ฒฝ โ†’ ํƒ€์ž…/์Šคํ‚ค๋งˆ ์ž๋™ ์ƒ์„ฑ โ†’ API ์žฌ๋“ฑ๋ก โ†’ ํ”„๋ก ํŠธ์—”๋“œ Service ์—…๋ฐ์ดํŠธ๊นŒ์ง€ ๋ชจ๋“  ๊ฒƒ์ด ์ž๋™์ž…๋‹ˆ๋‹ค. ํ•ต์‹ฌ ๊ธฐ๋Šฅ:
  • โœ… HMR๋กœ ์„œ๋ฒ„ ์žฌ์‹œ์ž‘ ์—†์ด ๊ฐœ๋ฐœ
  • โœ… Sonamu UI๋กœ ์‹œ๊ฐ์  Entity ๊ด€๋ฆฌ
  • โœ… Syncer๊ฐ€ ๋ชจ๋“  ์ฝ”๋“œ ์ž๋™ ๋™๊ธฐํ™”
  • โœ… ๋ณ€๊ฒฝ ์ฆ‰์‹œ ํƒ€์ž… ์—๋Ÿฌ ๊ฐ์ง€

Production Ready

Sonamu๋Š” ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์„ ์œ„ํ•œ ๊ฐ•๋ ฅํ•œ ๊ธฐ๋Šฅ๋“ค์„ ๋‚ด์žฅํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

BentoCache: ๋‹ค์ธต ์บ์‹ฑ

๋ฉ”๋ชจ๋ฆฌ + Redis ๋‹ค์ธต ์บ์‹ฑ์„ ๊ฐ„๋‹จํ•˜๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
import { Sonamu } from "sonamu";

// L1 (๋ฉ”๋ชจ๋ฆฌ) + L2 (Redis) ์บ์‹ฑ
const user = await Sonamu.cache.getOrSet(
  `user:${userId}`,
  async () => {
    return await UserModel.findById("C", userId);
  },
  { ttl: "5m" } // 5๋ถ„ ์บ์‹ฑ
);

Cache-Control: ์‘๋‹ต ์บ์‹ฑ

HTTP ์‘๋‹ต ์บ์‹ฑ ์ „๋žต์„ ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋กœ ๊ฐ„๋‹จํžˆ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
@api({
  httpMethod: "GET",
  cacheControl: { maxAge: 3600, sMaxAge: 7200 }
})
async getPublicData(): Promise<PublicData[]> {
  return await this.getPuri("r")
    .table("public_data")
    .where("public", true);
}

i18n: ํƒ€์ž… ์•ˆ์ „ํ•œ ๋‹ค๊ตญ์–ด ์ง€์›

Entity์˜ title, prop.desc, enumLabels๊ฐ€ ์ž๋™์œผ๋กœ ๋”•์…”๋„ˆ๋ฆฌ์— ์ถ”์ถœ๋ฉ๋‹ˆ๋‹ค. ํƒ€์ž… ์•ˆ์ „ํ•œ SD() ํ•จ์ˆ˜๋กœ ๋ฒˆ์—ญ์„ ๊ด€๋ฆฌํ•˜๊ณ , ์กด์žฌํ•˜์ง€ ์•Š๋Š” ํ‚ค๋Š” ์ปดํŒŒ์ผ ํƒ€์ž„์— ์—๋Ÿฌ๋กœ ์žก์•„์ค๋‹ˆ๋‹ค.
import { SD } from "../i18n/sd.generated";

SD("common.save")         // "์ €์žฅ" (ko) / "Save" (en)
SD("error.typo")          // โŒ ์ปดํŒŒ์ผ ์—๋Ÿฌ: ํ‚ค ์—†์Œ

SD("entity.User")         // "์‚ฌ์šฉ์ž" - Entity title ์ž๋™ ์ถ”์ถœ
SD("entity.User.email")   // "์ด๋ฉ”์ผ" - prop.desc ์ž๋™ ์ถ”์ถœ
SD.enumLabels("UserRole")["admin"]  // "๊ด€๋ฆฌ์ž"

Workflow: ๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๊ด€๋ฆฌ

์—ฌ๋Ÿฌ ๋‹จ๊ณ„๋กœ ์ด๋ฃจ์–ด์ง„ ๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์ฒด๊ณ„์ ์œผ๋กœ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
import { workflow } from "sonamu";

export const processOrder = workflow(
  {
    name: "process-order",
    version: "1.0",
  },
  async ({ input, step }) => {
    // Step 1: ๊ฒ€์ฆ
    await step
      .define({ name: "validate" }, async () => validateOrder(input))
      .run();

    // Step 2: ๊ฒฐ์ œ
    const payment = await step
      .define({ name: "charge" }, async () => chargePayment(input))
      .run();

    // Step 3: ์ฃผ๋ฌธ ์ƒ์„ฑ
    const order = await step
      .define({ name: "create-order" }, async () =>
        createOrderRecord(input, payment)
      )
      .run();

    // Step 4: ์ด๋ฉ”์ผ ์ „์†ก
    await step
      .define({ name: "send-email" }, async () => sendConfirmationEmail(order))
      .run();

    return order;
  }
);

// ์‹คํ–‰
await Sonamu.workflows.run(processOrder, orderData);

์„ฑ๋Šฅ ์ตœ์ ํ™” ๋‚ด์žฅ

N+1 ๋ฌธ์ œ ์ž๋™ ํ•ด๊ฒฐ๊ณผ ์ฟผ๋ฆฌ ์ตœ์ ํ™”๊ฐ€ ๊ธฐ๋ณธ์œผ๋กœ ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค.
// Relation ์ฟผ๋ฆฌ์—์„œ N+1 ์ž๋™ ํ•ด๊ฒฐ
const posts = await PostModel.findMany("A", {
  relations: ["author", "tags"], // DataLoader ํŒจํ„ด์œผ๋กœ ์ตœ์ ํ™”
});
ํ•ต์‹ฌ ๊ธฐ๋Šฅ:
  • โœ… BentoCache๋กœ ๋‹ค์ธต ์บ์‹ฑ (๋ฉ”๋ชจ๋ฆฌ + Redis)
  • โœ… Cache-Control๋กœ HTTP ์‘๋‹ต ์บ์‹ฑ
  • โœ… i18n์œผ๋กœ ํƒ€์ž… ์•ˆ์ „ํ•œ ๋‹ค๊ตญ์–ด ์ง€์›
  • โœ… Workflow๋กœ ๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๊ด€๋ฆฌ
  • โœ… N+1 ๋ฌธ์ œ ์ž๋™ ํ•ด๊ฒฐ

๊ฐ„๋‹จํ•œ ์˜ˆ์‹œ

Entity๋ฅผ ์ •์˜ํ•˜๊ณ  ์‚ฌ์šฉํ•˜๊ธฐ๊นŒ์ง€์˜ ์ „์ฒด ํ”Œ๋กœ์šฐ์ž…๋‹ˆ๋‹ค.
์ฐธ๊ณ : ์ž๋™ ์ƒ์„ฑ๋˜๋Š” ํŒŒ์ผ๋“ค
  • api/src/application/sonamu.generated.ts - ๋ชจ๋“  ํƒ€์ž…, ์Šคํ‚ค๋งˆ, Enum
  • web/src/services/sonamu.generated.ts - ํ”„๋ก ํŠธ์—”๋“œ ํƒ€์ž… (๋™๊ธฐํ™”๋จ)
  • web/src/services/services.generated.ts - ๋ชจ๋“  Service ๋ฉ”์„œ๋“œ
  • web/src/services/{entity}/{entity}.types.ts - ์ปค์Šคํ…€ ํƒ€์ž… (์ˆ˜๋™ ์ž‘์„ฑ)

1. Entity ์ •์˜

{
  "id": "User",
  "table": "users",
  "props": [
    { "name": "email", "type": "string", "length": 255 },
    { "name": "username", "type": "string", "length": 100 },
    { "name": "role", "type": "enum", "id": "UserRole" }
  ]
}

2. Migration ์‹คํ–‰ ๋ฐ ๋™๊ธฐํ™”

Entity ์ €์žฅ โ†’ Migration ์‹คํ–‰ โ†’ pnpm sync: โœ… TypeScript ํƒ€์ž… (sonamu.generated.ts)
โœ… Zod ์Šคํ‚ค๋งˆ (BaseSchema, BaseListParams)
โœ… DB ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜
โœ… ํ”„๋ก ํŠธ์—”๋“œ Service (services.generated.ts)
โœ… TanStack Query Hook

3. Model์—์„œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์ž‘์„ฑ

@api({ httpMethod: "POST" })
@transactional()
async register(params: {
  email: string;
  username: string;
  password: string;
}): Promise<{ user: User }> {
  const wdb = this.getPuri("w");

  wdb.ubRegister("users", {
    email: params.email,
    username: params.username,
    password: await bcrypt.hash(params.password, 10),
    role: "user",
  });

  const [userId] = await wdb.ubUpsert("users");
  const user = await this.findById("C", userId);

  return { user };
}

4. React์—์„œ ์ฆ‰์‹œ ์‚ฌ์šฉ

import { useTypeForm } from "@sonamu-kit/react-components/lib";
import { Input, Button } from "@sonamu-kit/react-components/components";
import { UserService } from "@/services/services.generated";
import { UserSaveParams } from "@/services/user/user.types";

function RegisterForm() {
  const { register, submit } = useTypeForm(UserSaveParams, {
    email: "",
    username: "",
    password: "",
  });

  const saveMutation = UserService.useSaveMutation();

  const handleSubmit = submit(async (form) => {
    saveMutation.mutate(
      { spa: [form] },
      {
        onSuccess: ([userId]) => {
          console.log("Registered:", userId); // โœ… ํƒ€์ž… ์•ˆ์ „
        },
      },
    );
  });

  return (
    <div>
      <Input placeholder="์ด๋ฉ”์ผ" {...register("email")} />
      <Input placeholder="์ด๋ฆ„" {...register("username")} />
      <Input type="password" placeholder="๋น„๋ฐ€๋ฒˆํ˜ธ" {...register("password")} />
      <Button onClick={handleSubmit}>๋“ฑ๋ก</Button>
    </div>
  );
}

์‹œ์ž‘ํ•˜๊ธฐ

3๋ถ„์ด๋ฉด ์ถฉ๋ถ„ํ•ฉ๋‹ˆ๋‹ค.
# ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ
pnpm create sonamu my-project

# ๊ฐœ๋ฐœ ์„œ๋ฒ„ ์‹œ์ž‘
cd my-project/api
pnpm dev

# Sonamu UI ์—ด๊ธฐ
open http://localhost:1028/sonamu-ui

์ปค๋ฎค๋‹ˆํ‹ฐ

GitHub

์ด์Šˆ ๋ฆฌํฌํŠธ์™€ ๊ธฐ์—ฌ

Sonamu์™€ ํ•จ๊ป˜๋ผ๋ฉด, TypeScript๋กœ ๊ฐœ๋ฐœํ•˜๋Š” ๊ฒƒ์ด ์ฆ๊ฑฐ์›Œ์ง‘๋‹ˆ๋‹ค.