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

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

Entity ํ•˜๋‚˜๋ฅผ ์ •์˜ํ•˜๋ฉด, ๋‚˜๋จธ์ง€๋Š” TypeScript๊ฐ€ ์•Œ์•„์„œ ํ•ฉ๋‹ˆ๋‹ค.

๐ŸŽฌ Entity ์ƒ์„ฑ โ†’ Migration ์‹คํ–‰ โ†’ ํƒ€์ž…/์Šคํ‚ค๋งˆ/API ์ž๋™ ์ƒ์„ฑ โ†’ ํ”„๋ก ํŠธ์—”๋“œ Service ์ƒ์„ฑ๊นŒ์ง€ ์ „์ฒด ํ”Œ๋กœ์šฐ

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 ๋ฐฉ์‹ ์ปดํŒŒ์ผ ์—๋Ÿฌ

๐Ÿ“ธ Sonamu ๋ฐฉ์‹์—์„œ ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์†์„ฑ์— ์ ‘๊ทผํ•˜๋ฉด ์ปดํŒŒ์ผ ์—๋Ÿฌ ํ‘œ์‹œ


Frontend Integration

๋ฐฑ์—”๋“œ API๋ฅผ ์ •์˜ํ•˜๋ฉด, ํ”„๋ก ํŠธ์—”๋“œ Service์™€ TanStack Query Hook์ด ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.

๐ŸŽฌ ๋ฐฑ์—”๋“œ @api ๋ฉ”์„œ๋“œ ์ž‘์„ฑ โ†’ ํ”„๋ก ํŠธ์—”๋“œ Service ์ž๋™ ์ƒ์„ฑ โ†’ useQuery ์‚ฌ์šฉ

// ๋ฐฑ์—”๋“œ: @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, ... }
ํ•ต์‹ฌ ๊ธฐ๋Šฅ:
  • โœ… ๋ฐฑ์—”๋“œ ๋ณ€๊ฒฝ ์‹œ ์ปดํŒŒ์ผ ์—๋Ÿฌ๋กœ ์ฆ‰์‹œ ๊ฐ์ง€
  • โœ… Namespace ๊ธฐ๋ฐ˜์œผ๋กœ ๊น”๋”ํ•œ ๊ตฌ์กฐ
  • โœ… TanStack Query ์ž๋™ ํ†ตํ•ฉ (์บ์‹ฑ, ์žฌ๊ฒ€์ฆ, ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ)
  • โœ… Subset๋ณ„ ์ •ํ™•ํ•œ ํƒ€์ž… ์ถ”๋ก 

Testing

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

Fixture ์‹œ์Šคํ…œ

ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ์‰ฝ๊ฒŒ ์ƒ์„ฑํ•˜๊ณ  ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
// Fixture ์ •์˜
export const UserFixtures = {
  admin: {
    email: "[email protected]",
    username: "admin",
    role: "admin",
  },
  user: {
    email: "[email protected]",
    username: "user",
    role: "user",
  },
};

// ํ…Œ์ŠคํŠธ์—์„œ ์‚ฌ์šฉ
test("๊ด€๋ฆฌ์ž๋Š” ๋ชจ๋“  ์‚ฌ์šฉ์ž๋ฅผ ๋ณผ ์ˆ˜ ์žˆ๋‹ค", async () => {
  const admin = await createUser(UserFixtures.admin);
  // ...
});

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

ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์ค‘ ๋ชจ๋“  ๋ฐ์ดํ„ฐ ๋ณ€ํ™”๋ฅผ ์ถ”์ ํ•˜๊ณ  ์‹œ๊ฐํ™”ํ•ฉ๋‹ˆ๋‹ค. ๋””๋ฒ„๊น…์ด ํ›จ์”ฌ ์‰ฌ์›Œ์ง‘๋‹ˆ๋‹ค.

๐Ÿ“ธ Naite Viewer ํ™”๋ฉด - ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์ค‘ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ณ€ํ™”๋ฅผ ์‹œ๊ฐ์ ์œผ๋กœ ๋ณด์—ฌ์ฃผ๋Š” UI

test("์‚ฌ์šฉ์ž ์ƒ์„ฑ ํ”Œ๋กœ์šฐ", async () => {
  Naite.t("์ดˆ๊ธฐ ์ƒํƒœ", { userCount: 0 });

  const user = await UserModel.create({
    email: "[email protected]",
    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: "[email protected]", ... });
  // ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ํ›„ ์ž๋™ ๋กค๋ฐฑ โ†’ DB๋Š” ๊นจ๋—ํ•œ ์ƒํƒœ ์œ ์ง€
});

test("๋‹ค์Œ ํ…Œ์ŠคํŠธ๋Š” ๊นจ๋—ํ•œ DB์—์„œ ์‹œ์ž‘", async () => {
  const users = await UserModel.findMany();
  expect(users).toHaveLength(0); // โœ… ์ด์ „ ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์—†์Œ
});

Context ๊ธฐ๋ฐ˜ ๊ถŒํ•œ ํ…Œ์ŠคํŠธ

ํŠน์ • ์‚ฌ์šฉ์ž๋กœ ์ธ์ฆ๋œ ์ƒํƒœ์—์„œ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
testAs(
  { id: 1, role: "admin" },
  "๊ด€๋ฆฌ์ž๋Š” ๋ชจ๋“  ๊ฒŒ์‹œ๊ธ€์„ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค",
  async () => {
    await PostModel.delete(123);
    // Context.user๊ฐ€ admin์œผ๋กœ ์„ค์ •๋จ
  }
);

testAs(
  { id: 2, role: "user" },
  "์ผ๋ฐ˜ ์‚ฌ์šฉ์ž๋Š” ์ž์‹ ์˜ ๊ฒŒ์‹œ๊ธ€๋งŒ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค",
  async () => {
    await expect(PostModel.delete(999)).rejects.toThrow("๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค");
  }
);
ํ•ต์‹ฌ ๊ธฐ๋Šฅ:
  • โœ… Fixture๋กœ ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์žฌ์‚ฌ์šฉ
  • โœ… Naite๋กœ ๋ฐ์ดํ„ฐ ๋ณ€ํ™” ์‹œ๊ฐํ™”
  • โœ… Transaction ์ž๋™ ๋กค๋ฐฑ์œผ๋กœ ๊ฒฉ๋ฆฌ๋œ ํ…Œ์ŠคํŠธ
  • โœ… testAs๋กœ ๊ถŒํ•œ ํ…Œ์ŠคํŠธ ๊ฐ„ํŽธํ™”

AI Ready

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

Vector Search (pgvector)

AI ์ž„๋ฒ ๋”ฉ ๊ฒ€์ƒ‰์„ Entity ํ•„๋“œ๋กœ ์ •์˜ํ•˜๋ฉด ๋์ž…๋‹ˆ๋‹ค. pgvector๋ฅผ ๋„ค์ดํ‹ฐ๋ธŒ๋กœ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

๐ŸŽฌ Entity์— vector ํ•„๋“œ ์ •์˜ โ†’ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ž๋™ ์ƒ์„ฑ โ†’ ๋ฒกํ„ฐ ๊ฒ€์ƒ‰ API ๊ตฌํ˜„

// Entity์— vector ํ•„๋“œ ์ •์˜
{
  "name": "embedding",
  "type": "vector",
  "dimensions": 1536
}

// ๋ฒกํ„ฐ ๊ฒ€์ƒ‰ (raw SQL ์‚ฌ์šฉ)
const results = await DocumentModel.getPuri("r").raw(`
  SELECT
    id, title, content,
    1 - (embedding <=> ?) AS similarity
  FROM documents
  WHERE embedding IS NOT NULL
  ORDER BY embedding <=> ?
  LIMIT 10
`, [
  JSON.stringify(queryEmbedding),
  JSON.stringify(queryEmbedding)
]);

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)

์‹ค์‹œ๊ฐ„ ์ŠคํŠธ๋ฆฌ๋ฐ์„ ์œ„ํ•œ SSE๋ฅผ ๊ฐ„ํŽธํ•˜๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
import { Sonamu } from "sonamu";
import { z } from "zod";

@api({ httpMethod: "GET" })
async streamEvents() {
  const { createSSE } = Sonamu.getContext();

  const events = z.object({
    type: z.literal("message"),
    data: z.string(),
  });

  return createSSE(events)(async (emit) => {
    for (let i = 0; i < 10; i++) {
      await emit({ type: "message", data: `Event ${i}` });
      await sleep(1000);
    }
  });
}

AI Agent ํ†ตํ•ฉ

Sonamu UI์˜ AI ์ฑ„ํŒ…์œผ๋กœ Entity๋ฅผ ์ž์—ฐ์–ด๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐ŸŽฌ Sonamu UI์—์„œ AI์—๊ฒŒ '์ด์ปค๋จธ์Šค ์ฃผ๋ฌธ ์‹œ์Šคํ…œ ๋งŒ๋“ค์–ด์ค˜' ์ž…๋ ฅ โ†’ Entity 4๊ฐœ ์ž๋™ ์ƒ์„ฑ

๐Ÿ’ฌ "์ด์ปค๋จธ์Šค ์ฃผ๋ฌธ ์‹œ์Šคํ…œ์„ ๋งŒ๋“ค์–ด์ค˜. ์‚ฌ์šฉ์ž, ์ƒํ’ˆ, ์ฃผ๋ฌธ, ์ฃผ๋ฌธ ์•„์ดํ…œ ํ…Œ์ด๋ธ”์ด ํ•„์š”ํ•ด."

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

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 ๋ฌธ์ œ ์ž๋™ ํ•ด๊ฒฐ, ์ฟผ๋ฆฌ ์ตœ์ ํ™”, HMR(Hot Module Replacement)์ด ๊ธฐ๋ณธ์œผ๋กœ ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค.
// 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 ์ •์˜

๐ŸŽฌ Sonamu UI์—์„œ User 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 { UserService } from "@/services/services.generated";

function RegisterForm() {
  const registerMutation = useMutation({
    mutationFn: UserService.register,
  });

  const handleSubmit = async (e: React.FormEvent) => {
    const { user } = await registerMutation.mutateAsync({
      email: formData.get("email") as string,
      username: formData.get("username") as string,
      password: formData.get("password") as string,
    });

    console.log("Registered:", user.username); // โœ… ํƒ€์ž… ์•ˆ์ „
  };

  return <form onSubmit={handleSubmit}>...</form>;
}

๐ŸŽฌ Entity ์ •์˜ โ†’ Migration โ†’ Model ์ž‘์„ฑ โ†’ React์—์„œ ์‚ฌ์šฉ๊นŒ์ง€ ์ „์ฒด ํ”Œ๋กœ์šฐ


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

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

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

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

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

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

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

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

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

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

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

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

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

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

GitHub

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

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