๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
@websocket ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋Š” ํด๋ผ์ด์–ธํŠธ์™€ ์„œ๋ฒ„ ์‚ฌ์ด์— ์–‘๋ฐฉํ–ฅ ๋ฉ”์‹œ์ง€ ์ฑ„๋„์„ ๋งŒ๋“œ๋Š” WebSocket API๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. SSE ์ „์šฉ์ธ @stream๊ณผ ๋‹ฌ๋ฆฌ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋ณด๋‚ด๋Š” ์ด๋ฒคํŠธ(inEvents)๋„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๊ณ , ๊ฐ™์€ namespace/room์— ๋ฌถ์ธ ๋‹ค๋ฅธ ์—ฐ๊ฒฐ๋กœ ๋ฉ”์‹œ์ง€๋ฅผ ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

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

import { BaseFrameClass, Sonamu, websocket, type WebSocketContext } from "sonamu";
import { z } from "zod";

const ChatOutEvents = z.object({
  newMessage: z.object({
    id: z.number(),
    content: z.string(),
    userId: z.string(),
  }),
  typingUsers: z.array(z.object({ id: z.string(), name: z.string() })),
});

const ChatInEvents = z.object({
  send: z.object({ content: z.string() }),
  typing: z.object({ active: z.boolean() }),
});

class ChatFrameClass extends BaseFrameClass {
  @websocket({
    namespace: "chat",
    heartbeat: 30_000,
    guards: ["user"],
    outEvents: ChatOutEvents,
    inEvents: ChatInEvents,
  })
  async subscribeChat(
    ctx: WebSocketContext<typeof ChatOutEvents.shape, typeof ChatInEvents.shape>,
  ): Promise<void> {
    const user = ctx.user;
    if (!user) throw new Error("unauthenticated");

    ctx.ws.join("global");
    ctx.ws.setUserId(user.id);

    ctx.ws.onMessage("send", (data) => {
      Sonamu.websocketRuntime.publishToRoom(
        "global",
        "newMessage",
        { id: Date.now(), content: data.content, userId: user.id },
        "chat",
      );
    });

    ctx.ws.onClose(() => {
      ctx.ws.leave("global");
    });

    await ctx.ws.waitForClose();
  }
}

์˜ต์…˜

outEvents

์„œ๋ฒ„ โ†’ ํด๋ผ์ด์–ธํŠธ๋กœ ๋ณด๋‚ผ ์ด๋ฒคํŠธ์˜ ํƒ€์ž…์„ Zod ์Šคํ‚ค๋งˆ๋กœ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. ํ•„์ˆ˜ ์˜ต์…˜
import { z } from "zod";

const OutEvents = z.object({
  ready: z.object({ history: z.array(z.string()) }),
  newMessage: z.object({ id: z.number(), content: z.string() }),
});

inEvents

ํด๋ผ์ด์–ธํŠธ โ†’ ์„œ๋ฒ„๋กœ ๋“ค์–ด์˜ฌ ์ด๋ฒคํŠธ์˜ ํƒ€์ž…์„ Zod ์Šคํ‚ค๋งˆ๋กœ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. ํ•„์ˆ˜ ์˜ต์…˜
const InEvents = z.object({
  send: z.object({ content: z.string() }),
  setPeriod: z.object({ period: z.enum(["7", "30", "all"]) }),
});
๋“ค์–ด์˜จ ์ด๋ฒคํŠธ๋Š” envelope์„ ๊ฑฐ์ณ ์ž๋™ ๊ฒ€์ฆ๋ฉ๋‹ˆ๋‹ค. ์Šคํ‚ค๋งˆ์— ์—†๋Š” ์ด๋ฒคํŠธ๋‚˜ ํ˜•์‹์ด ๋งž์ง€ ์•Š๋Š” ํŽ˜์ด๋กœ๋“œ๋Š” ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š๊ณ  ์ •์ฑ… ์œ„๋ฐ˜์œผ๋กœ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค.

path

WebSocket ์—”๋“œํฌ์ธํŠธ ๊ฒฝ๋กœ๋ฅผ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค. ๊ธฐ๋ณธ๊ฐ’: /{modelName}/{methodName} (camelCase)
@websocket({
  outEvents,
  inEvents,
  path: "/realtime/chat",
})
async subscribeChat(ctx: WebSocketContext) {}

namespace

๊ฐ™์€ ์„œ๋ฒ„ ์•ˆ์—์„œ ๋ผ์šฐํŒ…ยท๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ์˜ ๋ฒ”์œ„๋ฅผ ๊ฐ€๋ฅด๋Š” ๋…ผ๋ฆฌ์  ๊ทธ๋ฃน์ž…๋‹ˆ๋‹ค. Sonamu.websocketRuntime.publishToRoom(roomId, event, data, namespace)๋กœ ๋ณด๋‚ผ ๋•Œ ๊ฐ™์€ namespace์˜ ์—ฐ๊ฒฐ์—๋งŒ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค. ๊ธฐ๋ณธ๊ฐ’: "default"
@websocket({ outEvents, inEvents, namespace: "chat" })

heartbeat

์—ฐ๊ฒฐ ์œ ์ง€๋ฅผ ์œ„ํ•œ ping ๊ฐ„๊ฒฉ(๋ฐ€๋ฆฌ์ดˆ)์ž…๋‹ˆ๋‹ค. ๊ธฐ๋ณธ๊ฐ’: 30000 (30์ดˆ)
@websocket({ outEvents, inEvents, heartbeat: 15_000 })

guards

์ ‘๊ทผ ๊ถŒํ•œ์„ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.
type GuardKey = "query" | "admin" | "user";
@websocket({ outEvents, inEvents, guards: ["user"] })
๊ฐ€๋“œ ์‹คํŒจ๋Š” ํ•ธ๋“œ์…ฐ์ดํฌ ๋‹จ๊ณ„์—์„œ ์ฐจ๋‹จ๋˜์–ด 1008 Policy Violation์œผ๋กœ close๋ฉ๋‹ˆ๋‹ค.

description

WebSocket API ์„ค๋ช…์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
@websocket({
  outEvents,
  inEvents,
  description: "์ „์—ญ ์ฑ„ํŒ… ์ฑ„๋„์„ ๊ตฌ๋…ํ•ฉ๋‹ˆ๋‹ค.",
})

resourceName

์ƒ์„ฑ๋˜๋Š” ์„œ๋น„์Šค ํŒŒ์ผ์˜ ๋ฆฌ์†Œ์Šค ์ด๋ฆ„์„ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.
@websocket({ outEvents, inEvents, resourceName: "Chat" })

WebSocketContext

๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๊ฐ€ ์ ์šฉ๋œ ๋ฉ”์„œ๋“œ๋Š” ๋งˆ์ง€๋ง‰ ์ธ์ž๋กœ WebSocketContext<TOut, TIn>์„ ๋ฐ›์Šต๋‹ˆ๋‹ค.
type WebSocketContext<TOut, TIn> = BaseContext & {
  transport: "ws";
  ws: WebSocketConnection<TOut, TIn>;
};

ctx.ws.publish

ํ•ด๋‹น ์—ฐ๊ฒฐ๋กœ๋งŒ ์ด๋ฒคํŠธ๋ฅผ ์ „์†กํ•ฉ๋‹ˆ๋‹ค.
ctx.ws.publish("ready", { history: ["hello", "world"] });

ctx.ws.onMessage

ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋ณด๋‚ธ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
ctx.ws.onMessage("send", async (data) => {
  // data๋Š” inEvents.send ์Šคํ‚ค๋งˆ๋กœ ๊ฒ€์ฆ๋˜์–ด ๋“ค์–ด์˜ด
});

ctx.ws.onClose

์—ฐ๊ฒฐ์ด ๋‹ซํž ๋•Œ ์ •๋ฆฌ ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.
ctx.ws.onClose(() => {
  ctx.ws.leave("global");
});

ctx.ws.waitForClose

์—ฐ๊ฒฐ์ด ๋๋‚  ๋•Œ๊นŒ์ง€ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์‚ด์•„์žˆ๊ฒŒ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค. WebSocket ๋ฉ”์„œ๋“œ๋Š” ๋ณดํ†ต ์ด promise๋ฅผ awaitํ•˜๋ฉฐ ๋งˆ๋ฌด๋ฆฌ๋ฉ๋‹ˆ๋‹ค.
await ctx.ws.waitForClose();

ctx.ws.join / ctx.ws.leave

๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ ๋‹จ์œ„์ธ room์— ์—ฐ๊ฒฐ์„ ๊ฐ€์ž…/ํƒˆํ‡ด์‹œํ‚ต๋‹ˆ๋‹ค.
ctx.ws.join("room-1");
ctx.ws.leave("room-1");

ctx.ws.setUserId / ctx.ws.clearUserId

์—ฐ๊ฒฐ์— ์‚ฌ์šฉ์ž ์‹๋ณ„์ž๋ฅผ ๋ถ™์—ฌ publishToUser์˜ ํƒ€๊ฒŸ์ด ๋˜๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค.
ctx.ws.setUserId(user.id);

๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ

ํ•ธ๋“ค๋Ÿฌ ๋ฐ–์—์„œ๋„ Sonamu.websocketRuntime์„ ํ†ตํ•ด ๊ฐ™์€ ์ฑ„๋„์˜ ๋‹ค๋ฅธ ์—ฐ๊ฒฐ์— ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด๋‚ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
import { Sonamu } from "sonamu";

Sonamu.websocketRuntime.publishToRoom(
  "global",
  "newMessage",
  { id: 1, content: "hello" },
  "chat", // namespace
);

Sonamu.websocketRuntime.publishToUser(
  userId,
  "notify",
  { kind: "mention" },
  "chat",
);

๋‹ค๋ฅธ ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ์™€ ํ•จ๊ป˜ ์‚ฌ์šฉ

@websocket์€ @api, @stream, @upload์™€ ๊ฐ™์€ ๋ฉ”์„œ๋“œ์— ๋™์‹œ์— ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. WebSocket์œผ๋กœ ๊ตฌ๋…ํ•˜๋ฉด์„œ ๋™์ผ ๋ฐ์ดํ„ฐ๋ฅผ ์ผ๋ฐ˜ HTTP๋กœ๋„ ์กฐํšŒํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด, ๋ฉ”์„œ๋“œ๋ฅผ ๋ถ„๋ฆฌํ•ด ํ•œ์ชฝ์€ @api๋กœ, ๋‹ค๋ฅธ ํ•œ์ชฝ์€ @websocket์œผ๋กœ ๋…ธ์ถœํ•˜์„ธ์š”.
class DashboardFrameClass extends BaseFrameClass {
  @api({ httpMethod: "GET" })
  async getRecentActivity(period: ActivityPeriod = "7"): Promise<ActivityGroup[]> {
    // ...
  }

  @websocket({ outEvents, inEvents })
  async getRecentActivityStream(
    initialPeriod: ActivityPeriod = "7",
    ctx: WebSocketContext<typeof outEvents.shape, typeof inEvents.shape>,
  ): Promise<void> {
    // ...
  }
}

์ œ์•ฝ์‚ฌํ•ญ

1. @api / @stream / @upload์™€ ์ค‘๋ณต ์‚ฌ์šฉ ๋ถˆ๊ฐ€

@websocket decorator can only be used once on Chat.subscribe.
You can use only one of @api, @stream, @websocket, or @upload decorator on the same method.

2. httpMethod๋Š” ํ•ญ์ƒ GET

@websocket์€ ์ž๋™์œผ๋กœ httpMethod: "GET"์„ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. WebSocket upgrade๋Š” GET ์š”์ฒญ์—์„œ ์‹œ์ž‘๋˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

3. createSSE / reply์— ์˜์กดํ•˜๋Š” contextProvider ์‚ฌ์šฉ ์‹œ ์ฃผ์˜

WebSocket ๊ฒฝ๋กœ์—์„œ๋Š” SSE ํ—ฌํผ๋‚˜ FastifyReply ์ ‘๊ทผ์ด ์˜๋ฏธ๊ฐ€ ์—†์–ด ์ ‘๊ทผํ•˜๋ฉด ์ฆ‰์‹œ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. ์ปจํ…์ŠคํŠธ๊ฐ€ SSE/reply์— ์˜์กดํ•œ๋‹ค๋ฉด websocketContextProvider๋ฅผ ๋ณ„๋„๋กœ ์ •์˜ํ•˜์„ธ์š”.

ํด๋ผ์ด์–ธํŠธ ์‚ฌ์šฉ (Web)

@websocket์ด ๋ถ™์€ ๋ฉ”์„œ๋“œ์— ๋Œ€ํ•ด Sonamu๋Š” useWebSocketChannel ํ›…์„ ์ž๋™ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
import { ChatService } from "@/services/ChatService";

function ChatRoom() {
  const channel = ChatService.useSubscribeChat(
    {},
    {
      newMessage: (msg) => {
        // outEvents.newMessage ๋ฐ์ดํ„ฐ
      },
      typingUsers: (users) => {
        // outEvents.typingUsers ๋ฐ์ดํ„ฐ
      },
    },
  );

  return (
    <div>
      <div>{channel.isConnected ? "connected" : "disconnected"}</div>
      <button onClick={() => channel.send("send", { content: "hi" })}>send</button>
    </div>
  );
}
useWebSocketChannel์ด ๋ฐ˜ํ™˜ํ•˜๋Š” ์ƒํƒœ:
type WebSocketChannelState<TSend> = {
  isConnected: boolean;
  error: string | null;
  retryCount: number;
  readyState: number;
  send<K extends keyof TSend>(event: K, data: TSend[K]): void;
  close(code?: number, reason?: string): void;
};
์˜ต์…˜:
type WebSocketChannelOptions = {
  enabled?: boolean;
  retry?: number;
  retryInterval?: number;
  protocols?: string | string[];
  traceProvider?: () => string | undefined;
};

๋กœ๊น…

@websocket ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋Š” ํ˜ธ์ถœ ์‹œ ์ž๋™์œผ๋กœ ๋””๋ฒ„๊ทธ ๋กœ๊ทธ๋ฅผ ๋‚จ๊น๋‹ˆ๋‹ค.
[DEBUG] websocket: ChatFrame.subscribeChat

@stream๊ณผ์˜ ์ฐจ์ด

ํ•ญ๋ชฉ@stream (SSE)@websocket
๋ฐฉํ–ฅ์„œ๋ฒ„ โ†’ ํด๋ผ์ด์–ธํŠธ (๋‹จ๋ฐฉํ–ฅ)์–‘๋ฐฉํ–ฅ
ํด๋ผ์ด์–ธํŠธ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ๋ถˆ๊ฐ€inEvents ์Šคํ‚ค๋งˆ๋กœ ์ˆ˜์‹ 
๋ธŒ๋กœ๋“œ์บ์ŠคํŠธSSE ์—ฐ๊ฒฐ๋งˆ๋‹ค ์ˆ˜๋™ ๋ฐœํ–‰room / userId ๊ธฐ๋ฐ˜ ๋ผ์šฐํŒ… ์ง€์›
HTTP ๋ฉ”์„œ๋“œGET (์ž๋™)GET (์ž๋™, upgrade)
์ž๋™ ์ƒ์„ฑ ํด๋ผ์ด์–ธํŠธeventSource ํ—ฌํผuseWebSocketChannel ํ›…

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

@stream

๋‹จ๋ฐฉํ–ฅ SSE ์ŠคํŠธ๋ฆฌ๋ฐ์ด ํ•„์š”ํ•  ๋•Œ

@api

์ผ๋ฐ˜ REST API ๋งŒ๋“ค๊ธฐ

@transactional

ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์ผ๊ด€์„ฑ ๋ณด์žฅ

@cache

๊ฒฐ๊ณผ ์บ์‹ฑ์œผ๋กœ ์„ฑ๋Šฅ ํ–ฅ์ƒ