메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
@stream λ°μ½”λ ˆμ΄ν„°λŠ” Server-Sent Events(SSE)λ₯Ό μ‚¬μš©ν•œ μ‹€μ‹œκ°„ 슀트리밍 APIλ₯Ό μƒμ„±ν•©λ‹ˆλ‹€.

κΈ°λ³Έ μ‚¬μš©λ²•

import { BaseModelClass, stream } from "sonamu";
import { z } from "zod";

const NotificationEventsSchema = z.object({
  message: z.object({
    id: z.number(),
    text: z.string(),
    timestamp: z.date(),
  }),
  status: z.object({
    type: z.enum(["connected", "disconnected"]),
    userId: z.number(),
  }),
});

class NotificationModelClass extends BaseModelClass {
  @stream({
    type: "sse",
    events: NotificationEventsSchema,
  })
  async subscribe(userId: number) {
    const sse = Sonamu.getContext().createSSE(NotificationEventsSchema);

    // μ—°κ²° μ‹œμž‘
    sse.publish("status", {
      type: "connected",
      userId,
    });

    // 주기적으둜 이벀트 전솑
    const interval = setInterval(() => {
      sse.publish("message", {
        id: Date.now(),
        text: "New notification",
        timestamp: new Date(),
      });
    }, 5000);

    // 5λΆ„ ν›„ μ—°κ²° μ’…λ£Œ
    setTimeout(async () => {
      clearInterval(interval);
      sse.publish("status", {
        type: "disconnected",
        userId,
      });
      await sse.end();
    }, 300000);

    return sse;
  }
}

μ˜΅μ…˜

type

슀트리밍 νƒ€μž…μ„ μ§€μ •ν•©λ‹ˆλ‹€. ν•„μˆ˜ μ˜΅μ…˜
type: "sse"; // Server-Sent Events (ν˜„μž¬ μœ μΌν•œ μ˜΅μ…˜)
WebSocket은 λ³„λ„μ˜ @websocket λ°μ½”λ ˆμ΄ν„°λ‘œ μ§€μ›λ©λ‹ˆλ‹€. @stream은 SSE μ „μš©μ΄λ©° type μ˜΅μ…˜μ€ "sse"만 λ°›μŠ΅λ‹ˆλ‹€.

events

전솑할 이벀트의 νƒ€μž…μ„ Zod μŠ€ν‚€λ§ˆλ‘œ μ •μ˜ν•©λ‹ˆλ‹€. ν•„μˆ˜ μ˜΅μ…˜
import { z } from "zod";

const EventsSchema = z.object({
  // 이벀트 이름: 이벀트 데이터 μŠ€ν‚€λ§ˆ
  progress: z.object({
    percent: z.number(),
    message: z.string()
  }),
  complete: z.object({
    result: z.string()
  }),
  error: z.object({
    message: z.string(),
    code: z.string()
  })
});

@stream({
  type: "sse",
  events: EventsSchema
})
async process() {
  const sse = Sonamu.getContext().createSSE(EventsSchema);

  // 이벀트 전솑
  sse.publish("progress", { percent: 50, message: "Processing..." });
  sse.publish("complete", { result: "Success" });

  // 슀트림 μ’…λ£Œ
  await sse.end();

  return sse;
}

path

슀트리밍 μ—”λ“œν¬μΈνŠΈ 경둜λ₯Ό μ§€μ •ν•©λ‹ˆλ‹€. κΈ°λ³Έκ°’: /{modelName}/{methodName} (camelCase)
@stream({
  type: "sse",
  events: EventsSchema,
  path: "/stream/notifications"
})
async subscribe() {
  // /stream/notifications둜 μ ‘κ·Ό
}

guards

μ ‘κ·Ό κΆŒν•œμ„ μ§€μ •ν•©λ‹ˆλ‹€.
type GuardKey = "query" | "admin" | "user";
@stream({
  type: "sse",
  events: EventsSchema,
  guards: ["user"]
})
async subscribe() {
  // λ‘œκ·ΈμΈν•œ μ‚¬μš©μžλ§Œ μ ‘κ·Ό κ°€λŠ₯
}

description

슀트리밍 API μ„€λͺ…을 μΆ”κ°€ν•©λ‹ˆλ‹€.
@stream({
  type: "sse",
  events: EventsSchema,
  description: "μ‹€μ‹œκ°„ μ•Œλ¦Όμ„ μŠ€νŠΈλ¦¬λ°ν•©λ‹ˆλ‹€."
})
async notifications() {
  // ...
}

resourceName

μƒμ„±λ˜λŠ” μ„œλΉ„μŠ€ 파일의 λ¦¬μ†ŒμŠ€ 이름을 μ§€μ •ν•©λ‹ˆλ‹€.
@stream({
  type: "sse",
  events: EventsSchema,
  resourceName: "Notifications"
})
async subscribe() {
  // NotificationsService.ts νŒŒμΌμ— 포함
}

SSE 객체 μ‚¬μš©λ²•

createSSE

Contextμ—μ„œ SSE 객체λ₯Ό μƒμ„±ν•©λ‹ˆλ‹€.
const sse = Sonamu.getContext().createSSE(EventsSchema);

publish

이벀트λ₯Ό ν΄λΌμ΄μ–ΈνŠΈλ‘œ μ „μ†‘ν•©λ‹ˆλ‹€.
sse.publish("eventName", data);
νƒ€μž… μ•ˆμ •μ„±:
  • 이벀트 이름은 μŠ€ν‚€λ§ˆμ— μ •μ˜λœ κ²ƒλ§Œ μ‚¬μš© κ°€λŠ₯
  • λ°μ΄ν„°λŠ” μŠ€ν‚€λ§ˆμ— 맞좰 μžλ™ 검증
const EventsSchema = z.object({
  message: z.object({
    text: z.string(),
  }),
});

const sse = context.createSSE(EventsSchema);

// βœ… μ˜¬λ°”λ₯Έ μ‚¬μš©
sse.publish("message", { text: "Hello" });

// ❌ νƒ€μž… μ—λŸ¬
sse.publish("unknown", { text: "Hello" }); // 'unknown'은 μŠ€ν‚€λ§ˆμ— μ—†μŒ
sse.publish("message", { text: 123 }); // textλŠ” string이어야 함

end

슀트림 연결을 μ’…λ£Œν•©λ‹ˆλ‹€.
await sse.end();
end()λŠ” ν΄λΌμ΄μ–ΈνŠΈμ— β€œend” 이벀트λ₯Ό μ „μ†‘ν•˜κ³  200ms λŒ€κΈ° ν›„ 연결을 λ‹«μŠ΅λ‹ˆλ‹€.

전체 μ˜ˆμ‹œ

import { BaseModelClass, stream } from "sonamu";
import { z } from "zod";

const LogEventsSchema = z.object({
  log: z.object({
    level: z.enum(["info", "warn", "error"]),
    message: z.string(),
    timestamp: z.string(),
  }),
  progress: z.object({
    current: z.number(),
    total: z.number(),
  }),
  complete: z.object({
    success: z.boolean(),
    duration: z.number(),
  }),
});

class TaskModelClass extends BaseModelClass {
  @stream({
    type: "sse",
    events: LogEventsSchema,
    path: "/tasks/logs",
    guards: ["admin"],
    description: "μž‘μ—… μ‹€ν–‰ 둜그λ₯Ό μ‹€μ‹œκ°„μœΌλ‘œ μŠ€νŠΈλ¦¬λ°ν•©λ‹ˆλ‹€.",
  })
  async streamLogs(taskId: number) {
    const sse = Sonamu.getContext().createSSE(LogEventsSchema);
    const startTime = Date.now();

    // μž‘μ—… μ‹œμž‘
    sse.publish("log", {
      level: "info",
      message: "Task started",
      timestamp: new Date().toISOString(),
    });

    // μž‘μ—… μ‹€ν–‰
    const total = 100;
    for (let i = 0; i <= total; i++) {
      await this.processItem(i);

      // μ§„ν–‰λ₯  전솑
      sse.publish("progress", {
        current: i,
        total,
      });

      // 둜그 전솑
      if (i % 10 === 0) {
        sse.publish("log", {
          level: "info",
          message: `Processed ${i}/${total} items`,
          timestamp: new Date().toISOString(),
        });
      }
    }

    // μž‘μ—… μ™„λ£Œ
    sse.publish("complete", {
      success: true,
      duration: Date.now() - startTime,
    });

    // μ—°κ²° μ’…λ£Œ
    await sse.end();

    return sse;
  }

  private async processItem(index: number) {
    // μž‘μ—… 처리
    await new Promise((resolve) => setTimeout(resolve, 50));
  }
}

λ‹€λ₯Έ λ°μ½”λ ˆμ΄ν„°μ™€ ν•¨κ»˜ μ‚¬μš©

@transactional

@stream({ type: "sse", events: EventsSchema })
@transactional()
async process() {
  const sse = Sonamu.getContext().createSSE(EventsSchema);
  const wdb = this.getDB("w");

  // νŠΈλžœμž­μ…˜ λ‚΄μ—μ„œ μ‹€ν–‰
  // SSE 전솑과 DB μž‘μ—… ν•¨κ»˜ μˆ˜ν–‰

  await sse.end();
  return sse;
}
@stream을 λ¨Όμ €, @transactional을 λ‚˜μ€‘μ— μž‘μ„±ν•˜μ„Έμš”.

μ œμ•½μ‚¬ν•­

1. λ‹€λ₯Έ λΌμš°νŒ… λ°μ½”λ ˆμ΄ν„°(@api, @websocket, @upload)와 쀑볡 μ‚¬μš© λΆˆκ°€

같은 λ©”μ„œλ“œμ— @api, @stream, @websocket, @upload 쀑 λ‘˜ 이상을 ν•¨κ»˜ μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€.
// ❌ μ—λŸ¬ λ°œμƒ
@api()
@stream({ type: "sse", events: EventsSchema })
async subscribe() {}
μ—λŸ¬ λ©”μ‹œμ§€:
@stream decorator can only be used once on NotificationModel.subscribe.
You can use only one of @api, @stream, @websocket, or @upload decorator on the same method.

2. eventsλŠ” ν•„μˆ˜

// ❌ μ—λŸ¬ λ°œμƒ
@stream({ type: "sse" })
async subscribe() {}
// events μ˜΅μ…˜μ΄ ν•„μš”ν•©λ‹ˆλ‹€

3. httpMethodλŠ” 항상 GET

@stream은 μžλ™μœΌλ‘œ httpMethod: "GET"을 μ„€μ •ν•©λ‹ˆλ‹€.
@stream({ type: "sse", events: EventsSchema })
async subscribe() {
  // GET /notification/subscribe둜 λ…ΈμΆœλ¨
}

ν΄λΌμ΄μ–ΈνŠΈ μ‚¬μš© (Web)

SonamuλŠ” μžλ™μœΌλ‘œ SSE ν΄λΌμ΄μ–ΈνŠΈ μ½”λ“œλ₯Ό μƒμ„±ν•©λ‹ˆλ‹€.
// μžλ™ μƒμ„±λœ μ½”λ“œ
import { NotificationService } from "@/services/NotificationService";

// SSE μ—°κ²°
const eventSource = NotificationService.subscribe(userId);

// 이벀트 λ¦¬μŠ€λ„ˆ 등둝
eventSource.addEventListener("message", (event) => {
  const data = JSON.parse(event.data);
  console.log("New message:", data);
});

eventSource.addEventListener("status", (event) => {
  const data = JSON.parse(event.data);
  console.log("Status:", data);
});

// μ—°κ²° μ’…λ£Œ 이벀트
eventSource.addEventListener("end", () => {
  console.log("Stream ended");
  eventSource.close();
});

// μˆ˜λ™ μ—°κ²° μ’…λ£Œ
eventSource.close();

React μ˜ˆμ‹œ

import { useEffect, useState } from "react";

function NotificationList({ userId }: { userId: number }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [status, setStatus] = useState<string>("disconnected");

  useEffect(() => {
    const eventSource = NotificationService.subscribe(userId);

    eventSource.addEventListener("message", (event) => {
      const message = JSON.parse(event.data);
      setMessages(prev => [...prev, message]);
    });

    eventSource.addEventListener("status", (event) => {
      const { type } = JSON.parse(event.data);
      setStatus(type);
    });

    eventSource.addEventListener("end", () => {
      console.log("Stream ended by server");
      setStatus("disconnected");
    });

    return () => {
      eventSource.close();
    };
  }, [userId]);

  return (
    <div>
      <div>Status: {status}</div>
      <ul>
        {messages.map(msg => (
          <li key={msg.id}>{msg.text}</li>
        ))}
      </ul>
    </div>
  );
}

λ‘œκΉ…

@stream λ°μ½”λ ˆμ΄ν„°λŠ” μžλ™μœΌλ‘œ 둜그λ₯Ό λ‚¨κΉλ‹ˆλ‹€:
@stream({ type: "sse", events: EventsSchema })
async subscribe() {
  // μžλ™ 둜그:
  // [DEBUG] stream: NotificationModel.subscribe
}

μ˜ˆμ‹œ λͺ¨μŒ

const ChatEventsSchema = z.object({
  message: z.object({
    id: z.string(),
    userId: z.number(),
    text: z.string(),
    timestamp: z.date()
  }),
  typing: z.object({
    userId: z.number(),
    isTyping: z.boolean()
  }),
  joined: z.object({
    userId: z.number(),
    username: z.string()
  }),
  left: z.object({
    userId: z.number()
  })
});

class ChatModelClass extends BaseModelClass {
  @stream({
    type: "sse",
    events: ChatEventsSchema,
    guards: ["user"]
  })
  async subscribe(roomId: number) {
    const sse = Sonamu.getContext().createSSE(ChatEventsSchema);
    const { user } = Sonamu.getContext();

    // μž…μž₯ μ•Œλ¦Ό
    sse.publish("joined", {
      userId: user.id,
      username: user.name
    });

    // λ©”μ‹œμ§€ μˆ˜μ‹  λ¦¬μŠ€λ„ˆ 등둝
    const unsubscribe = this.onMessage(roomId, (message) => {
      sse.publish("message", message);
    });

    // 1μ‹œκ°„ ν›„ μžλ™ μ’…λ£Œ
    setTimeout(async () => {
      unsubscribe();
      sse.publish("left", { userId: user.id });
      await sse.end();
    }, 3600000);

    return sse;
  }
}

λ‹€μŒ 단계

@api

일반 API μ—”λ“œν¬μΈνŠΈ λ§Œλ“€κΈ°

@transactional

νŠΈλžœμž­μ…˜μœΌλ‘œ 데이터 일관성 보μž₯ν•˜κΈ°

@cache

κ²°κ³Ό μΊμ‹±μœΌλ‘œ μ„±λŠ₯ ν–₯μƒν•˜κΈ°

SSE κ°€μ΄λ“œ

SSE 상세 κ°€μ΄λ“œ 보기