Skip to main content
POST
https://sajuapi.dev
/
api
/
daily-fortune-stream
운세 스트리밍 (SSE)
curl --request POST \
  --url https://sajuapi.dev/api/daily-fortune-stream \
  --header 'Content-Type: application/json' \
  --header 'X-API-Key: <api-key>' \
  --data '
{
  "saju": {},
  "userName": "<string>",
  "model": "<string>"
}
'

개요

Server-Sent Events (SSE)를 통해 운세 데이터를 점진적으로 스트리밍합니다. 각 섹션이 생성되는 즉시 표시하여 체감 지연 시간을 줄입니다.
스트리밍은 각 섹션(성격, 성향, 운세)별 로딩 상태를 표시하는 UI에 적합합니다.

요청

일일 운세와 동일한 파라미터입니다.
saju
object
required
사용자의 사주팔자
userName
string
required
사용자 이름
model
string
default:"haiku"
AI 모델: haiku, sonnet, gpt4o

응답 형식

응답은 Server-Sent Events 스트림입니다:
event: character
data: {"dayMaster":"병화","emoji":"☀️",...}

event: tendency
data: {"scores":{"riskTolerance":65,...},...}

event: fortune
data: {"score":78,"actionItems":[...],...}

event: complete
data: {"cached":false,"cost":0.013}

이벤트 타입

이벤트설명타이밍
character성격 분석 완료~1초
tendency투자 성향 완료~2초
fortune일일 운세 완료~3초
complete모든 데이터 수신 완료~3-5초
error에러 발생상황에 따름

클라이언트 구현

JavaScript (EventSource)

function streamFortune(saju, userName, onUpdate) {
  return new Promise((resolve, reject) => {
    const result = {};

    // 참고: EventSource는 GET만 지원하므로 fetch와 ReadableStream 사용
    fetch('https://api.sajuapi.dev/api/daily-fortune-stream', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': 'bs_live_xxx',
        'Accept': 'text/event-stream'
      },
      body: JSON.stringify({ saju, userName, model: 'haiku' })
    }).then(response => {
      const reader = response.body.getReader();
      const decoder = new TextDecoder();

      function read() {
        reader.read().then(({ done, value }) => {
          if (done) {
            resolve(result);
            return;
          }

          const text = decoder.decode(value);
          const lines = text.split('\n');

          for (const line of lines) {
            if (line.startsWith('event: ')) {
              const eventType = line.slice(7);
              // 다음 줄이 데이터
            }
            if (line.startsWith('data: ')) {
              const data = JSON.parse(line.slice(6));
              result[eventType] = data;
              onUpdate(eventType, data);
            }
          }

          read();
        });
      }

      read();
    }).catch(reject);
  });
}

// 사용법
streamFortune(saju, '홍길동', (type, data) => {
  switch (type) {
    case 'character':
      setCharacter(data);
      break;
    case 'tendency':
      setTendency(data);
      break;
    case 'fortune':
      setFortune(data);
      break;
    case 'complete':
      setLoading(false);
      break;
  }
});

React Hook

import { useState, useEffect } from 'react';

function useFortuneStream(saju: Saju, userName: string) {
  const [character, setCharacter] = useState(null);
  const [tendency, setTendency] = useState(null);
  const [fortune, setFortune] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function stream() {
      try {
        const response = await fetch('/api/daily-fortune-stream', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Accept': 'text/event-stream'
          },
          body: JSON.stringify({ saju, userName }),
          signal: controller.signal
        });

        const reader = response.body!.getReader();
        const decoder = new TextDecoder();
        let eventType = '';

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          const text = decoder.decode(value);
          for (const line of text.split('\n')) {
            if (line.startsWith('event: ')) {
              eventType = line.slice(7);
            } else if (line.startsWith('data: ') && eventType) {
              const data = JSON.parse(line.slice(6));
              switch (eventType) {
                case 'character': setCharacter(data); break;
                case 'tendency': setTendency(data); break;
                case 'fortune': setFortune(data); break;
                case 'complete': setIsLoading(false); break;
                case 'error': setError(data.message); break;
              }
            }
          }
        }
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      }
    }

    stream();
    return () => controller.abort();
  }, [saju, userName]);

  return { character, tendency, fortune, isLoading, error };
}

점진적 UI 예시

function FortuneCard() {
  const { character, tendency, fortune, isLoading } = useFortuneStream(saju, userName);

  return (
    <div>
      {/* 성격 섹션 - 먼저 표시 */}
      <Section title="나의 투자 성격">
        {character ? (
          <CharacterDisplay data={character} />
        ) : (
          <Skeleton height={100} />
        )}
      </Section>

      {/* 성향 섹션 - 두 번째로 표시 */}
      <Section title="투자 성향">
        {tendency ? (
          <TendencyChart data={tendency} />
        ) : (
          <Skeleton height={150} />
        )}
      </Section>

      {/* 운세 섹션 - 마지막으로 표시 */}
      <Section title="오늘의 운세">
        {fortune ? (
          <FortuneDisplay data={fortune} />
        ) : (
          <Skeleton height={200} />
        )}
      </Section>
    </div>
  );
}

에러 처리

에러는 SSE 이벤트로 전송됩니다:
event: error
data: {"code":"ai_provider_error","message":"AI 서비스 일시적 불가"}
항상 error 이벤트를 처리하세요:
if (eventType === 'error') {
  const { code, message } = JSON.parse(data);
  // 일반 엔드포인트로 폴백
  const fortune = await fetchRegularFortune(saju, userName);
}

스트리밍 vs 일반 비교

항목일반스트리밍
첫 바이트까지 시간~3초~1초
전체 응답~3초~3초
체감 속도느림빠름
복잡도간단중간
캐싱전체 응답섹션별
요청 제한60회/분30회/분