๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
์ƒ์„ฑ๋œ Namespace Service๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํƒ€์ž… ์•ˆ์ „ํ•˜๊ฒŒ API๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ์•„๋ด…๋‹ˆ๋‹ค.

Service ์‚ฌ์šฉ ๊ฐœ์š”

Namespace ํ˜ธ์ถœ

UserService.getUser()๊ฐ„๊ฒฐํ•œ ๋ฌธ๋ฒ•

ํƒ€์ž… ์•ˆ์ „

์ž๋™ ์™„์„ฑ์ปดํŒŒ์ผ ๊ฒ€์ฆ

Subset ์ง€์›

ํ•„์š”ํ•œ ํ•„๋“œ๋งŒ์„ฑ๋Šฅ ์ตœ์ ํ™”

TanStack Query

useUser Hook์ž๋™ ์บ์‹ฑ

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

Service Import

์ƒ์„ฑ๋œ Service๋Š” services.generated.ts์—์„œ Namespace๋กœ export๋ฉ๋‹ˆ๋‹ค.
// โœ… ์˜ฌ๋ฐ”๋ฅธ import ๋ฐฉ์‹
import { UserService, PostService } from "@/services/services.generated";
๋‹จ์ผ ํŒŒ์ผ import์˜ ์žฅ์ :
  1. ์ผ๊ด€์„ฑ: ๋ชจ๋“  Service๊ฐ€ ํ•œ ๊ณณ์—์„œ ๊ด€๋ฆฌ๋จ
  2. ๊ฐ„ํŽธํ•œ import: ํŒŒ์ผ ๊ฒฝ๋กœ ์ฐพ์„ ํ•„์š” ์—†์Œ
  3. ์ž๋™ ์—…๋ฐ์ดํŠธ: pnpm generate ์‹œ ์ž๋™์œผ๋กœ ๋™๊ธฐํ™”
  4. ๋ช…ํ™•ํ•œ ๋„ค์ด๋ฐ: Namespace๋กœ ๊ทธ๋ฃนํ™”๋˜์–ด ์ถฉ๋Œ ์—†์Œ

๊ธฐ๋ณธ API ํ˜ธ์ถœ

Service์˜ ์ •์  ํ•จ์ˆ˜๋ฅผ ์ง์ ‘ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.
import { UserService } from "@/services/services.generated";

// ์‚ฌ์šฉ์ž ์กฐํšŒ (Subset "A"๋กœ ๊ธฐ๋ณธ ์ •๋ณด๋งŒ)
const user = await UserService.getUser("A", 123);
console.log(user.username); // ํƒ€์ž… ์•ˆ์ „!

// ์‚ฌ์šฉ์ž ์ˆ˜์ •
await UserService.updateProfile({
  username: "newname",
  bio: "Hello, World!",
});
ํŠน์ง•:
  • await์œผ๋กœ ๋น„๋™๊ธฐ ํ˜ธ์ถœ
  • ์‘๋‹ต์€ ์ž๋™์œผ๋กœ ํŒŒ์‹ฑ๋จ (fetch ํ•จ์ˆ˜ ๋‚ด๋ถ€ ์ฒ˜๋ฆฌ)
  • ํƒ€์ž…์ด ์™„์ „ํžˆ ๋ณด์žฅ๋จ
  • Subset ํŒŒ๋ผ๋ฏธํ„ฐ ํ•„์ˆ˜ (getUser ๋“ฑ)
Namespace ๊ธฐ๋ฐ˜ ๊ตฌ์กฐ:
  • โŒ ํด๋ž˜์Šค ์ธ์Šคํ„ด์Šค: new UserService() ๋ถˆํ•„์š”
  • โœ… ์ •์  ํ•จ์ˆ˜: UserService.getUser() ์ง์ ‘ ํ˜ธ์ถœ
  • ๋ชจ๋“  ํ•จ์ˆ˜๊ฐ€ ๋…๋ฆฝ์ ์œผ๋กœ ๋™์ž‘

Subset ์‹œ์Šคํ…œ ์ดํ•ดํ•˜๊ธฐ

Sonamu์˜ ํ•ต์‹ฌ ๊ธฐ๋Šฅ์ธ Subset ์‹œ์Šคํ…œ์ž…๋‹ˆ๋‹ค.
// Subset "A": ๊ธฐ๋ณธ ์ •๋ณด
const basicUser = await UserService.getUser("A", 123);
console.log(basicUser.id);       // โœ… OK
console.log(basicUser.username); // โœ… OK
console.log(basicUser.bio);      // โŒ ์ปดํŒŒ์ผ ์—๋Ÿฌ (Subset A์— ์—†์Œ)

// Subset "B": bio ํฌํ•จ
const userWithBio = await UserService.getUser("B", 123);
console.log(userWithBio.bio);    // โœ… OK

// Subset "C": ์ „์ฒด ํ•„๋“œ
const fullUser = await UserService.getUser("C", 123);
console.log(fullUser.createdAt); // โœ… OK
Subset์„ ์‚ฌ์šฉํ•˜๋Š” ์ด์œ :
  1. ์„ฑ๋Šฅ: ํ•„์š”ํ•œ ํ•„๋“œ๋งŒ ์กฐํšŒํ•˜์—ฌ ๋„คํŠธ์›Œํฌ ๋น„์šฉ ์ ˆ๊ฐ
  2. ํƒ€์ž… ์•ˆ์ „: ๊ฐ Subset๋งˆ๋‹ค ์ •ํ™•ํ•œ ํƒ€์ž… ๋ฐ˜ํ™˜
  3. ๋ช…์‹œ์„ฑ: ์–ด๋–ค ๋ฐ์ดํ„ฐ๊ฐ€ ํ•„์š”ํ•œ์ง€ ์ฝ”๋“œ์—์„œ ๋ช…ํ™•ํžˆ ํ‘œํ˜„

React์—์„œ ์‚ฌ์šฉํ•˜๊ธฐ

TanStack Query Hook (๊ถŒ์žฅ)

๊ฐ€์žฅ ๊ฐ„ํŽธํ•œ ๋ฐฉ๋ฒ•์€ ์ž๋™ ์ƒ์„ฑ๋œ TanStack Query Hook์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.
import { UserService } from "@/services/services.generated";

function UserProfile({ userId }: { userId: number }) {
  // ์ž๋™ ์ƒ์„ฑ๋œ Hook ์‚ฌ์šฉ
  const { data: user, error, isLoading } = 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>
  );
}
TanStack Query Hook์˜ ์žฅ์ :
  • ์ž๋™ ์บ์‹ฑ (๊ฐ™์€ userId ์žฌ์‚ฌ์šฉ)
  • ์ž๋™ ์žฌ๊ฒ€์ฆ (ํฌ์ปค์Šค ์‹œ, ์žฌ์—ฐ๊ฒฐ ์‹œ)
  • ๋กœ๋”ฉ/์—๋Ÿฌ ์ƒํƒœ ์ž๋™ ๊ด€๋ฆฌ
  • ์ค‘๋ณต ์š”์ฒญ ์ž๋™ ์ œ๊ฑฐ

ํ•จ์ˆ˜ ์ปดํฌ๋„ŒํŠธ (useEffect)

Hook์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ  ์ง์ ‘ ํ˜ธ์ถœํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.
import { useState, useEffect } from "react";
import { UserService } from "@/services/services.generated";
import type { User } from "@/services/services.generated";

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  
  useEffect(() => {
    async function fetchUser() {
      try {
        setLoading(true);
        // Subset "C"๋กœ ์ „์ฒด ํ•„๋“œ ์กฐํšŒ
        const userData = await UserService.getUser("C", userId);
        setUser(userData);
      } catch (err) {
        setError(err instanceof Error ? err.message : "Failed to load user");
      } finally {
        setLoading(false);
      }
    }
    
    fetchUser();
  }, [userId]);
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>User not found</div>;
  
  return (
    <div>
      <h1>{user.username}</h1>
      <p>{user.email}</p>
    </div>
  );
}
๊ถŒ์žฅ: ๊ฐ€๋Šฅํ•˜๋ฉด TanStack Query Hook์„ ์‚ฌ์šฉํ•˜์„ธ์š”. ์ž๋™ ์บ์‹ฑ, ์žฌ๊ฒ€์ฆ, ์ƒํƒœ ๊ด€๋ฆฌ๋ฅผ ๋ชจ๋‘ ์ œ๊ณตํ•˜์—ฌ ์ฝ”๋“œ๊ฐ€ ํ›จ์”ฌ ๊ฐ„๊ฒฐํ•ด์ง‘๋‹ˆ๋‹ค.

์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ

์‚ฌ์šฉ์ž ์•ก์…˜์— ๋”ฐ๋ผ API๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.
import { useState } from "react";
import { UserService } from "@/services/services.generated";

function EditProfile({ userId }: { userId: number }) {
  const [username, setUsername] = useState("");
  const [bio, setBio] = useState("");
  const [saving, setSaving] = useState(false);
  
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    
    try {
      setSaving(true);
      
      await UserService.updateProfile({
        username,
        bio,
      });
      
      alert("Profile updated!");
    } catch (error) {
      alert("Failed to update profile");
      console.error(error);
    } finally {
      setSaving(false);
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
        placeholder="Username"
      />
      
      <textarea
        value={bio}
        onChange={(e) => setBio(e.target.value)}
        placeholder="Bio"
      />
      
      <button type="submit" disabled={saving}>
        {saving ? "Saving..." : "Save"}
      </button>
    </form>
  );
}

useTypeForm์œผ๋กœ ํผ ์ฒ˜๋ฆฌํ•˜๊ธฐ (๊ถŒ์žฅ)

@sonamu-kit/react-components์˜ useTypeForm์„ ์‚ฌ์šฉํ•˜๋ฉด ํƒ€์ž… ์•ˆ์ „ํ•œ ํผ์„ ๋” ๊ฐ„ํŽธํ•˜๊ฒŒ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
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() {
  // Zod ์Šคํ‚ค๋งˆ ๊ธฐ๋ฐ˜ ํƒ€์ž… ์•ˆ์ „ ํผ
  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); // โœ… ํƒ€์ž… ์•ˆ์ „
        },
        onError: (error) => {
          console.error("Registration failed:", error);
        },
      }
    );
  });

  return (
    <div>
      <Input placeholder="์ด๋ฉ”์ผ" {...register("email")} />
      <Input placeholder="์ด๋ฆ„" {...register("username")} />
      <Input type="password" placeholder="๋น„๋ฐ€๋ฒˆํ˜ธ" {...register("password")} />
      <Button onClick={handleSubmit} disabled={saveMutation.isPending}>
        {saveMutation.isPending ? "๋“ฑ๋ก ์ค‘..." : "๋“ฑ๋ก"}
      </Button>
    </div>
  );
}
useTypeForm์˜ ์žฅ์ :
  • โœ… ํƒ€์ž… ์•ˆ์ „: Zod ์Šคํ‚ค๋งˆ์—์„œ ํƒ€์ž… ์ž๋™ ์ถ”๋ก 
  • โœ… ์ž๋™ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ: ์Šคํ‚ค๋งˆ ๊ทœ์น™ ์ž๋™ ์ ์šฉ
  • โœ… ๊ฐ„๊ฒฐํ•œ ์ฝ”๋“œ: useState + ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ ๋ถˆํ•„์š”
  • โœ… ์—๋Ÿฌ ์ฒ˜๋ฆฌ: ํ•„๋“œ๋ณ„ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ์ž๋™ ๊ด€๋ฆฌ
  • โœ… ํ†ตํ•ฉ ์ปดํฌ๋„ŒํŠธ: Input, Button ๋“ฑ ์ฆ‰์‹œ ์‚ฌ์šฉ ๊ฐ€๋Šฅ
๊ถŒ์žฅ: ์ƒˆ๋กœ์šด ํผ์„ ์ž‘์„ฑํ•  ๋•Œ๋Š” useTypeForm์„ ์‚ฌ์šฉํ•˜์„ธ์š”. ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ ์ฝ”๋“œ๋ฅผ ํฌ๊ฒŒ ์ค„์ด๊ณ  ํƒ€์ž… ์•ˆ์ „์„ฑ์„ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค.
์—๋Ÿฌ ํ‘œ์‹œ ์˜ˆ์ œ:
function RegisterFormWithErrors() {
  const { register, submit, errors } = useTypeForm(UserSaveParams, {
    email: "",
    username: "",
    password: "",
  });

  const saveMutation = UserService.useSaveMutation();

  const handleSubmit = submit(async (form) => {
    saveMutation.mutate({ spa: [form] });
  });

  return (
    <div>
      <div>
        <Input placeholder="์ด๋ฉ”์ผ" {...register("email")} />
        {errors.email && <span className="error">{errors.email.message}</span>}
      </div>

      <div>
        <Input placeholder="์ด๋ฆ„" {...register("username")} />
        {errors.username && <span className="error">{errors.username.message}</span>}
      </div>

      <div>
        <Input type="password" placeholder="๋น„๋ฐ€๋ฒˆํ˜ธ" {...register("password")} />
        {errors.password && <span className="error">{errors.password.message}</span>}
      </div>

      <Button onClick={handleSubmit}>๋“ฑ๋ก</Button>
    </div>
  );
}
ํŒจํ„ด ๋น„๊ต:
ํŒจํ„ด์ฝ”๋“œ๋Ÿ‰ํƒ€์ž… ์•ˆ์ „์œ ํšจ์„ฑ ๊ฒ€์‚ฌ๊ถŒ์žฅ๋„
useTypeFormโญ๏ธโญ๏ธโญ๏ธ ์ ์Œโœ… ์™„๋ฒฝโœ… ์ž๋™โญ๏ธโญ๏ธโญ๏ธ ์ตœ๊ณ 
useEffect + useStateโญ๏ธ ๋งŽ์Œโš ๏ธ ์ˆ˜๋™โŒ ์ˆ˜๋™โญ๏ธ ๋‚ฎ์Œ
์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌโญ๏ธโญ๏ธ ๋ณดํ†ตโš ๏ธ ์ˆ˜๋™โŒ ์ˆ˜๋™โญ๏ธโญ๏ธ ๋ณดํ†ต
@sonamu-kit/react-components๋Š” Material-UI, Ant Design ๋“ฑ ๋‹ค๋ฅธ UI ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์™€๋„ ํ•จ๊ป˜ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

SSR์—์„œ Service ์‚ฌ์šฉํ•˜๊ธฐ

Sonamu๋Š” Vite + React ๊ธฐ๋ฐ˜์˜ SSR์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. SSR์—์„œ๋Š” queries.generated.ts์— ์ž๋™ ์ƒ์„ฑ๋œ SSR ์ „์šฉ Service๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

SSR Service๋ž€?

ํ”„๋ก ํŠธ์—”๋“œ Service(services.generated.ts)์™€๋Š” ๋ณ„๊ฐœ๋กœ, ๋ฐฑ์—”๋“œ์— SSR ์ „์šฉ Service๊ฐ€ ์ž๋™ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.
// api/src/application/queries.generated.ts (์ž๋™ ์ƒ์„ฑ)
import type { SSRQuery } from "sonamu/ssr";

export namespace UserService {
  // SSRQuery ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ (HTTP ์š”์ฒญํ•˜์ง€ ์•Š์Œ)
  export const getUser = <T extends UserSubsetKey>(subset: T, id: number): SSRQuery =>
    createSSRQuery("UserModel", "findById", [subset, id], ["User", "getUser"]);

  export const me = (): SSRQuery =>
    createSSRQuery("UserModel", "me", [], ["User", "me"]);
}

registerSSR์—์„œ ์‚ฌ์šฉ

SSR ๋ผ์šฐํŠธ๋ฅผ ๋“ฑ๋กํ•  ๋•Œ ์ด Service๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
// api/src/ssr/routes.ts
import { registerSSR } from "sonamu/ssr";
import { UserService, CompanyService } from "../application/queries.generated";

// ํšŒ์‚ฌ ์ƒ์„ธ ํŽ˜์ด์ง€ SSR
registerSSR({
  path: "/companies/:companyId",
  preload: (params) => [
    // Service ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ โ†’ SSRQuery ๊ฐ์ฒด ๋ฐ˜ํ™˜
    UserService.me(),
    CompanyService.getCompany("A", Number(params.companyId)),
  ],
});

ํ”„๋ก ํŠธ์—”๋“œ Service vs SSR Service

ํ•ญ๋ชฉํ”„๋ก ํŠธ์—”๋“œ ServiceSSR Service
ํŒŒ์ผ ์œ„์น˜web/src/services/services.generated.tsapi/src/application/queries.generated.ts
๋ฐ˜ํ™˜๊ฐ’Promise<Data> (HTTP ์š”์ฒญ)SSRQuery (๊ฐ์ฒด)
์‚ฌ์šฉ ์œ„์น˜๋ธŒ๋ผ์šฐ์ € (React ์ปดํฌ๋„ŒํŠธ)๋ฐฑ์—”๋“œ (registerSSR)
HTTP ์š”์ฒญโœ… ์‹ค์ œ API ํ˜ธ์ถœโŒ ๋ฐฑ์—”๋“œ Model ์ง์ ‘ ์‹คํ–‰
// ํ”„๋ก ํŠธ์—”๋“œ Service (๋ธŒ๋ผ์šฐ์ €)
const user = await UserService.getUser("A", 123);  // HTTP GET /api/user/findById

// SSR Service (๋ฐฑ์—”๋“œ)
const query = UserService.getUser("A", 123);  // SSRQuery ๊ฐ์ฒด ๋ฐ˜ํ™˜
// โ†’ Sonamu๊ฐ€ UserModel.findById("A", 123) ์ง์ ‘ ์‹คํ–‰
ํ•ต์‹ฌ ์ฐจ์ด์ : SSR Service๋Š” HTTP ์š”์ฒญ ์—†์ด ๋ฐฑ์—”๋“œ Model ๋ฉ”์„œ๋“œ๋ฅผ ์ง์ ‘ ์‹คํ–‰ํ•˜๋ฏ€๋กœ ๋„คํŠธ์›Œํฌ ์˜ค๋ฒ„ํ—ค๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

๋” ์•Œ์•„๋ณด๊ธฐ

SSR์— ๋Œ€ํ•œ ์ž์„ธํ•œ ๋‚ด์šฉ์€ ๋‹ค์Œ ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”.

Service ์ƒ์„ฑ ๋ฉ”์ปค๋‹ˆ์ฆ˜ ์ดํ•ดํ•˜๊ธฐ

Service๋Š” Namespace ๊ธฐ๋ฐ˜์˜ ์ •์  ํ•จ์ˆ˜ ๋ชจ์Œ์ž…๋‹ˆ๋‹ค.

Service๋Š” ํด๋ž˜์Šค๊ฐ€ ์•„๋‹Œ Namespace

// services.generated.ts (์ž๋™ ์ƒ์„ฑ)

// โŒ ํด๋ž˜์Šค๊ฐ€ ์•„๋‹˜
// class UserService { ... }

// โœ… Namespace์ž„
export namespace UserService {
  // ์ •์  ํ•จ์ˆ˜๋“ค
  export async function getUser<T extends UserSubsetKey>(
    subset: T,
    id: number
  ): Promise<UserSubsetMapping[T]> {
    return fetch({
      method: "GET",
      url: `/api/user/getUser?${qs.stringify({ subset, id })}`
    });
  }

  export async function save(spa: UserSaveParams[]): Promise<number[]> {
    return fetch({
      method: "POST",
      url: `/api/user/save`,
      data: { spa }
    });
  }

  // TanStack Query Hook
  export const useUser = <T extends UserSubsetKey>(
    subset: T,
    id: number
  ) => useQuery({
    queryKey: ["User", "getUser", subset, id],
    queryFn: () => getUser(subset, id)
  });
}
Namespace์˜ ํŠน์ง•:
  • ๋ฌด์ƒํƒœ (Stateless): ์ธ์Šคํ„ด์Šค๋ฅผ ์ƒ์„ฑํ•˜์ง€ ์•Š์Œ
  • ์ •์  ํ˜ธ์ถœ: UserService.getUser() ์ง์ ‘ ํ˜ธ์ถœ
  • ํƒ€์ž… ์•ˆ์ „: TypeScript Namespace๋กœ ์™„๋ฒฝํ•œ ํƒ€์ž… ์ถ”๋ก 
  • ํŠธ๋ฆฌ ์…ฐ์ดํ‚น: ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ํ•จ์ˆ˜๋Š” ๋ฒˆ๋“ค์— ํฌํ•จ ์•ˆ๋จ

Service๋Š” ์˜์กด์„ฑ ์ฃผ์ž…(DI)์ด ์—†์Œ

Service๋Š” ์ˆœ์ˆ˜ ํ•จ์ˆ˜์ด๋ฏ€๋กœ ์˜์กด์„ฑ์ด ์ฃผ์ž…๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค:
// โŒ DI๊ฐ€ ์—†์Œ (ํด๋ž˜์Šค๊ฐ€ ์•„๋‹ˆ๋ฏ€๋กœ)
// new UserService(dependencies)

// โœ… ์ง์ ‘ ํ˜ธ์ถœ
const user = await UserService.getUser("A", 123);
Context๋Š” ์–ด๋–ป๊ฒŒ ์ „๋‹ฌ๋˜๋‚˜์š”? Service๋Š” HTTP ์š”์ฒญ์œผ๋กœ Context๋ฅผ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค:
// Service ๋‚ด๋ถ€ (๊ฐ„๋žตํ™”)
export async function getUser(subset: string, id: number) {
  return fetch({
    method: "GET",
    url: `/api/user/getUser?subset=${subset}&id=${id}`,
    // Context๋Š” HTTP ํ—ค๋”๋กœ ์ „๋‹ฌ๋จ
    headers: {
      "Authorization": `Bearer ${getToken()}`,
      "Cookie": document.cookie,  // ์„ธ์…˜ ์ •๋ณด
    }
  });
}
๋ฐฑ์—”๋“œ์—์„œ๋Š” HTTP ์š”์ฒญ์—์„œ Context๋ฅผ ์ž๋™ ์ถ”์ถœ:
// Backend Model
class UserModelClass extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async getUser(subset: string, id: number) {
    // Context๋Š” HTTP ์š”์ฒญ์—์„œ ์ž๋™ ํŒŒ์‹ฑ๋จ
    const context = Sonamu.getContext();

    console.log(context.user);      // ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž
    console.log(context.session);   // ์„ธ์…˜ ์ •๋ณด
    console.log(context.ip);        // ํด๋ผ์ด์–ธํŠธ IP

    // ๊ถŒํ•œ ์ฒดํฌ ๋“ฑ ๊ฐ€๋Šฅ
    if (!context.user) {
      throw new UnauthorizedError();
    }

    return this.findById(id, [subset]);
  }
}

Service ์ƒ์„ฑ ๊ณผ์ •

์ƒ์„ฑ ๋‹จ๊ณ„:
  1. ๋ฐฑ์—”๋“œ: Model์— @api ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ์ž‘์„ฑ
  2. Sonamu Sync: pnpm sonamu sync ์‹คํ–‰
  3. ์ž๋™ ์ƒ์„ฑ: services.generated.ts ํŒŒ์ผ ์ƒ์„ฑ
  4. Namespace ํ•จ์ˆ˜: ๊ฐ API ๋ฉ”์„œ๋“œ๊ฐ€ Namespace ํ•จ์ˆ˜๋กœ ๋ณ€ํ™˜
  5. Hook ์ƒ์„ฑ: GET ๋ฉ”์„œ๋“œ๋Š” useQuery Hook ์ž๋™ ์ƒ์„ฑ

Model Instance๋Š” ์–ด๋””์— ์žˆ๋‚˜์š”?

Server Components์—์„œ ์ง์ ‘ ์ƒ์„ฑ:
// Server Component
export default async function UserPage() {
  // โœ… ์„œ๋ฒ„์—์„œ Model์„ ์ง์ ‘ ์ธ์Šคํ„ด์Šคํ™”
  const userModel = new UserModel();
  const user = await userModel.findById(1, ["A"]);

  return <div>{user.username}</div>;
}
์™œ new UserModel()์„ ํ•ด๋„ ๋˜๋‚˜์š”?
  • Model์€ ๋ฌด์ƒํƒœ์ด๋ฏ€๋กœ ๋งค๋ฒˆ ์ƒˆ๋กœ ์ƒ์„ฑํ•ด๋„ ์•ˆ์ „
  • Constructor์—์„œ ์ดˆ๊ธฐํ™”ํ•˜๋Š” ๊ฒƒ์€ Subset ์ฟผ๋ฆฌ๋ฟ
  • Context๋Š” Sonamu.getContext()๋กœ ๋™์ ์œผ๋กœ ๊ฐ€์ ธ์˜ด
// Model ๋‚ด๋ถ€
class UserModelClass extends BaseModelClass {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
    // ์ƒํƒœ๋ฅผ ์ €์žฅํ•˜์ง€ ์•Š์Œ
  }

  async findById(id: number, subsets: string[]) {
    // Context๋Š” ์—ฌ๊ธฐ์„œ ๋™์ ์œผ๋กœ ๊ฐ€์ ธ์˜ด
    const context = Sonamu.getContext();

    // DB ์ฟผ๋ฆฌ ์‹คํ–‰
    return this.getPuri("r", subsets).where("id", id).first();
  }
}

Service vs Model ๋น„๊ต

ํ•ญ๋ชฉService (Frontend)Model (Backend)
ํƒ€์ž…NamespaceClass Instance
ํ˜ธ์ถœ ๋ฐฉ์‹UserService.getUser()userModel.findById()
์ƒํƒœ๋ฌด์ƒํƒœ (Stateless)๋ฌด์ƒํƒœ (Stateless)
์˜์กด์„ฑ์—†์ŒBaseModelClass
ContextHTTP ํ—ค๋”๋กœ ์ „๋‹ฌSonamu.getContext()
์‚ฌ์šฉ ์œ„์น˜Client ComponentServer Component / API
์ƒ์„ฑ ๋ฐฉ๋ฒ•์ž๋™ ์ƒ์„ฑ๊ฐœ๋ฐœ์ž ์ž‘์„ฑ
Model ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ ์‹œ ์ฃผ์˜์‚ฌํ•ญ:
  1. Server Components์—์„œ๋งŒ: Client Component์—์„œ๋Š” Model์„ importํ•  ์ˆ˜ ์—†์Œ
  2. ๋งค๋ฒˆ ์ƒ์„ฑ: ์‹ฑ๊ธ€ํ†ค ํŒจํ„ด ์‚ฌ์šฉ ๋ถˆํ•„์š” (๋ฌด์ƒํƒœ์ด๋ฏ€๋กœ)
  3. Context ์ ‘๊ทผ: Sonamu.getContext()๋Š” ์š”์ฒญ ์ปจํ…์ŠคํŠธ์—์„œ ์ž๋™์œผ๋กœ ๊ฐ€์ ธ์˜ด
๊ถŒ์žฅ ํŒจํ„ด:
  • Client Component: Service ์‚ฌ์šฉ
  • Server Component: Model ์ง์ ‘ ์‚ฌ์šฉ
  • API ์—”๋“œํฌ์ธํŠธ: Model์— @api ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ

Client Components

Client Components์—์„œ๋Š” Service๋ฅผ ์ž์œ ๋กญ๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
// app/users/[id]/edit-button.tsx
"use client";

import { useState } from "react";
import { UserService } from "@/services/services.generated";

export function EditButton({ userId }: { userId: number }) {
  const [editing, setEditing] = useState(false);
  
  async function handleEdit() {
    setEditing(true);
    
    try {
      await UserService.updateProfile({
        username: "newname",
      });
      
      alert("Updated!");
    } catch (error) {
      alert("Failed!");
    } finally {
      setEditing(false);
    }
  }
  
  return (
    <button onClick={handleEdit} disabled={editing}>
      {editing ? "Saving..." : "Edit"}
    </button>
  );
}

์—๋Ÿฌ ์ฒ˜๋ฆฌ

SonamuError ์ฒ˜๋ฆฌ

Service ํ˜ธ์ถœ ์‹œ ๋ฐœ์ƒํ•˜๋Š” ์—๋Ÿฌ๋ฅผ ํƒ€์ž… ์•ˆ์ „ํ•˜๊ฒŒ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
import { UserService } from "@/services/services.generated";
import { isSonamuError } from "@/lib/sonamu.shared";

async function updateUser() {
  try {
    await UserService.updateProfile({
      username: "newname",
    });
  } catch (error) {
    if (isSonamuError(error)) {
      // Sonamu ์—๋Ÿฌ (ํƒ€์ž… ์•ˆ์ „)
      console.log("Status:", error.code);
      console.log("Message:", error.message);
      
      // Zod ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์—๋Ÿฌ
      error.issues.forEach((issue) => {
        console.log(`${issue.path.join(".")}: ${issue.message}`);
      });
      
      // HTTP ์ƒํƒœ ์ฝ”๋“œ๋ณ„ ์ฒ˜๋ฆฌ
      if (error.code === 401) {
        // ์ธ์ฆ ์—๋Ÿฌ
        console.log("Please login");
      } else if (error.code === 403) {
        // ๊ถŒํ•œ ์—๋Ÿฌ
        console.log("Permission denied");
      } else if (error.code === 422) {
        // Validation ์—๋Ÿฌ
        console.log("Invalid data:", error.issues);
      }
    } else {
      // ์ผ๋ฐ˜ ์—๋Ÿฌ
      console.log("Network error:", error);
    }
  }
}

React์—์„œ ์—๋Ÿฌ ์ฒ˜๋ฆฌ

import { isSonamuError } from "@/lib/sonamu.shared";

function EditProfile({ userId }: { userId: number }) {
  const [error, setError] = useState<string | null>(null);
  const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
  
  async function handleSubmit(data: any) {
    setError(null);
    setValidationErrors({});
    
    try {
      await UserService.updateProfile(data);
    } catch (err) {
      if (isSonamuError(err)) {
        setError(err.message);
        
        // Zod ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์—๋Ÿฌ๋ฅผ ํ•„๋“œ๋ณ„๋กœ ๋งคํ•‘
        const fieldErrors: Record<string, string> = {};
        err.issues.forEach((issue) => {
          const field = issue.path.join(".");
          fieldErrors[field] = issue.message;
        });
        setValidationErrors(fieldErrors);
      } else {
        setError("An unexpected error occurred");
      }
    }
  }
  
  return (
    <div>
      {error && <div className="error-message">{error}</div>}
      
      <form onSubmit={(e) => {
        e.preventDefault();
        handleSubmit({/* data */});
      }}>
        <div>
          <input name="username" />
          {validationErrors.username && (
            <span className="error">{validationErrors.username}</span>
          )}
        </div>
      </form>
    </div>
  );
}

๊ณ ๊ธ‰ ํŒจํ„ด

๋ณ‘๋ ฌ ์š”์ฒญ

์—ฌ๋Ÿฌ API๋ฅผ ๋™์‹œ์— ํ˜ธ์ถœํ•˜์—ฌ ์„ฑ๋Šฅ์„ ํ–ฅ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค.
import { UserService, PostService } from "@/services/services.generated";

async function loadUserDashboard(userId: number) {
  // โŒ ์ˆœ์ฐจ ์‹คํ–‰ (๋А๋ฆผ)
  const user = await UserService.getUser("A", userId);
  const posts = await PostService.getPostsByUser(userId);
  const comments = await PostService.getCommentsByUser(userId);
  
  return { user, posts, comments };
}

async function loadUserDashboardFast(userId: number) {
  // โœ… ๋ณ‘๋ ฌ ์‹คํ–‰ (๋น ๋ฆ„)
  const [user, posts, comments] = await Promise.all([
    UserService.getUser("A", userId),
    PostService.getPostsByUser(userId),
    PostService.getCommentsByUser(userId),
  ]);
  
  return { user, posts, comments };
}
์„ฑ๋Šฅ ๋น„๊ต:
  • ์ˆœ์ฐจ: 300ms + 200ms + 150ms = 650ms
  • ๋ณ‘๋ ฌ: max(300ms, 200ms, 150ms) = 300ms

Subset ํ™œ์šฉ ์ตœ์ ํ™”

์ƒํ™ฉ์— ๋”ฐ๋ผ ์ ์ ˆํ•œ Subset์„ ์„ ํƒํ•ฉ๋‹ˆ๋‹ค.
// ๋ชฉ๋ก ํ™”๋ฉด: ๊ธฐ๋ณธ ์ •๋ณด๋งŒ
const users = await UserService.getUsers("A");

users.map((user) => (
  <div key={user.id}>
    {user.username} - {user.email}
  </div>
));

// ์ƒ์„ธ ํ™”๋ฉด: ์ „์ฒด ์ •๋ณด
const fullUser = await UserService.getUser("C", userId);

<div>
  <h1>{fullUser.username}</h1>
  <p>{fullUser.bio}</p>
  <p>Created: {fullUser.createdAt}</p>
</div>

TanStack Query ํ™œ์šฉ

์กฐ๊ฑด๋ถ€ ํŽ˜์นญ

function UserProfile({ userId }: { userId: number | null }) {
  const { data: user } = UserService.useUser(
    "A",
    userId!,
    { enabled: userId !== null } // userId๊ฐ€ null์ด๋ฉด ํ˜ธ์ถœ ์•ˆํ•จ
  );
  
  if (!userId) return <div>Please select a user</div>;
  if (!user) return <div>Loading...</div>;
  
  return <div>{user.username}</div>;
}

์บ์‹œ ๋ฌดํšจํ™”

import { useQueryClient } from "@tanstack/react-query";
import { UserService } from "@/services/services.generated";

function EditProfile({ userId }: { userId: number }) {
  const queryClient = useQueryClient();
  
  async function handleUpdate(data: any) {
    await UserService.updateProfile(data);
    
    // ํŠน์ • ์ฟผ๋ฆฌ ๋ฌดํšจํ™”
    queryClient.invalidateQueries(
      UserService.getUserQueryOptions("A", userId)
    );
    
    // ๋˜๋Š” ๋ชจ๋“  User ์ฟผ๋ฆฌ ๋ฌดํšจํ™”
    queryClient.invalidateQueries({
      queryKey: ["User"],
    });
  }
}

Prefetching

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>
  );
}

์‹ค์ „ ์˜ˆ์ œ

์ „์ฒด CRUD ํ”Œ๋กœ์šฐ

import { useState } from "react";
import { UserService } from "@/services/services.generated";
import { isSonamuError } from "@/lib/sonamu.shared";

function UserManagement() {
  // ๋ชฉ๋ก ์กฐํšŒ (Subset A)
  const { data: users, refetch } = UserService.useUsers("A");
  
  // ์ƒ์„ฑ
  async function handleCreate(data: { username: string; email: string }) {
    try {
      await UserService.create(data);
      refetch(); // ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ
      alert("Created!");
    } catch (error) {
      if (isSonamuError(error)) {
        alert(error.message);
      }
    }
  }
  
  // ์ˆ˜์ •
  async function handleUpdate(id: number, data: { username: string }) {
    try {
      await UserService.update(id, data);
      refetch();
      alert("Updated!");
    } catch (error) {
      if (isSonamuError(error)) {
        alert(error.message);
      }
    }
  }
  
  // ์‚ญ์ œ
  async function handleDelete(id: number) {
    if (!confirm("Are you sure?")) return;
    
    try {
      await UserService.delete(id);
      refetch();
      alert("Deleted!");
    } catch (error) {
      if (isSonamuError(error)) {
        alert(error.message);
      }
    }
  }
  
  return (
    <div>
      <h1>Users</h1>
      {users?.map((user) => (
        <div key={user.id}>
          {user.username}
          <button onClick={() => handleUpdate(user.id, { username: "new" })}>
            Edit
          </button>
          <button onClick={() => handleDelete(user.id)}>
            Delete
          </button>
        </div>
      ))}
    </div>
  );
}

์ฃผ์˜์‚ฌํ•ญ

Service ์‚ฌ์šฉ ์‹œ ์ฃผ์˜์‚ฌํ•ญ:
  1. ์ƒ์„ฑ๋œ Service ํŒŒ์ผ(services.generated.ts)์€ ์ ˆ๋Œ€ ์ˆ˜๋™ ์ˆ˜์ • ๊ธˆ์ง€
  2. Subset ํŒŒ๋ผ๋ฏธํ„ฐ ํ•„์ˆ˜: getUser ๋“ฑ์—์„œ subset ์ง€์ • ํ•„์š”
  3. Server Components์—์„œ๋Š” Service ๋Œ€์‹  ๋ฐฑ์—”๋“œ ๋ชจ๋ธ ์ง์ ‘ ํ˜ธ์ถœ
  4. ์—๋Ÿฌ ์ฒ˜๋ฆฌ ์‹œ isSonamuError() ํƒ€์ž… ๊ฐ€๋“œ ์‚ฌ์šฉ
  5. TanStack Query Hook์€ ์ปดํฌ๋„ŒํŠธ ๋‚ด๋ถ€์—์„œ๋งŒ ํ˜ธ์ถœ
  6. await ํ‚ค์›Œ๋“œ ํ•„์ˆ˜ (๋ชจ๋“  Service ํ•จ์ˆ˜๋Š” async)
  7. Namespace์ด๋ฏ€๋กœ new ๋ถˆํ•„์š”: UserService.getUser() ์ง์ ‘ ํ˜ธ์ถœ

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