본문으로 건너뛰기

이벤트 스트림

DJ 가 방송 중인 동안 일어나는 일을 Server-Sent Events(SSE) 로 실시간으로 받아요. 연결 하나로 채팅 · 입장 · 하트 · 후원을 함께 받고, 동의받은 권한에 해당하는 이벤트만 흘러요.

메서드 · 경로GET /v1/live/events
필요한 권한events.chat · events.presence · events.like · events.donation 중 하나 이상
성공 응답200 (text/event-stream)
방송 중이 아닐 때404 (OAPI_MNGR_0301)
export SPOON_BASE_URL="https://kr-openapi.spooncast.net"
export SPOON_ACCESS_TOKEN="{발급받은 access token}"

일본 지역이면 https://jp-openapi.spooncast.net 이에요 — 지역별 주소.

무슨 일이 일어나나요​

토큰 발급 ─▶ 스트림 연결 ─▶ 방송에 입장 ─▶ 이벤트 수신
│ │
└─ 404: 방송 중이 아님 └─ end: 스트림 종료
백오프 후 다시 다시 연결로

토큰이 방송을 정해요​

경로에도 쿼리에도 DJ 를 적는 자리가 없어요. 토큰 하나가 동의한 DJ 한 명이라, 어느 토큰을 쓰느냐가 곧 어느 방송을 다루느냐예요. 봇이 남의 방송을 지목할 수 없어요.

연결하면 봇이 그 방송에 입장해요​

단순 구독이 아니에요. 연결하는 순간 봇 계정이 청취자로 방에 들어가요. 세 가지가 따라와요.

  • 청취자 목록에 봇 자신이 보여요. 추첨 봇이 자기를 뽑을 수 있어요 — id 로 걸러내야 해요. 이벤트 스트림은 봇 자기 발화를 빼주지만 목록 API 는 빼주지 않아요. 두 API 의 규칙이 달라요.
  • DJ 의 통제를 그대로 받아요. 강퇴당하면 이벤트가 끊기고, 채팅 금지·얼림이면 발화가 막혀요.
  • 받는 범위가 봇의 방 권한을 따라요. 매니저로 지정되면 매니저 등급 메시지까지 와요.

연결이 끊겨도 입장은 유지돼요​

네트워크나 배포로 스트림이 끊겨도 봇은 방에 남아 있어요. 재연결하면 그대로 이어지고, 입장 기록은 일정 시간 뒤 만료돼요.

끝날 때는 end 로 사유를 알리고 닫아요​

end 프레임 뒤로는 아무것도 오지 않아요. 사유마다 봇이 할 일이 달라요 — 방송이 끝난 것인지, 토큰이 만료된 것인지, 서버가 이 연결만 닫은 것인지를 reason 이 알려줘요.

연결하면 청취자 고지가 봇 이름으로 나가요​

연결해 있는 동안 방송에 봇 계정 이름으로 청취자 고지가 올라가요. 끌 수 없고 봇 코드에서 할 일은 없어요 — 언제, 누구에게 나가는지는 청취자 고지에 있어요.

요청​

GET /v1/live/events

헤더​

이름필수설명
Authorization✔Bearer {access_token} 형식이에요.
Accept✔text/event-stream 이에요.

경로 파라미터 · 쿼리 파라미터 · 요청 바디가 없어요.

필요한 권한: events. 로 시작하는 네 권한 중 하나라도 있으면 연결이 열려요. 여러 개 있으면 그 이벤트들이 한 연결로 함께 와요. 동의받지 않은 이벤트는 걸러져서, 연결은 정상인데 그 이벤트만 오지 않아요. 하나도 없으면 403 이에요.

요청 예시​

curl -N \
-H "Authorization: Bearer $SPOON_ACCESS_TOKEN" \
-H "Accept: text/event-stream" \
"$SPOON_BASE_URL/v1/live/events"

응답​

200 으로 스트림이 열리고, 그 뒤로 SSE 프레임이 이어져요.

:heartbeat

id:1788328046114-0
event:chat
data:{"user":{"id":"Ab1Cd2Ef3Gh4Ij5Kl6Mn7O","nickname":"스푼청취자"},"isDj":false,"message":"안녕하세요","sentTime":"2026-09-02T05:47:26.114Z"}

id:1788328052169-1
event:presence
data:{"user":{"id":"Pq8Rs9Tu0Vw1Xy2Za3Bc4D","nickname":"라디오팬"},"type":"JOIN","fanRank":1,"isManager":false,"favoriteTemperature":36.5,"time":"2026-09-02T05:47:32.169Z"}

id:1788328061044-0
event:like
data:{"user":{"id":"Pq8Rs9Tu0Vw1Xy2Za3Bc4D","nickname":"라디오팬"},"type":"PAID","totalAmount":30,"amount":10,"extraAmount":5,"combo":2,"time":"2026-09-02T05:47:41.044Z"}

id:1788328070512-0
event:donation
data:{"user":{"id":"Ab1Cd2Ef3Gh4Ij5Kl6Mn7O","nickname":"스푼청취자"},"amount":300,"message":"응원해요","time":"2026-09-02T05:47:50.512Z"}

event:end
data:{"reason":"LIVE_ENDED"}
이벤트필요한 권한언제 오나요페이로드
chatevents.chat청취자나 DJ 가 채팅을 쳤을 때chat
presenceevents.presence청취자가 방송에 입장했을 때presence
likeevents.like청취자가 하트를 보냈을 때like
donationevents.donation청취자가 후원했을 때donation
end없음스트림이 끝날 때. 이 프레임 뒤에는 아무것도 오지 않아요end

id: 는 {epochMillis}-{offset} 형식이에요. 지금은 재개에 쓰이지 않지만, 나중에 Last-Event-ID 재개를 열 때 계약이 깨지지 않도록 처음부터 붙어 있어요.

하트비트​

:heartbeat

: 로 시작하는 SSE comment 줄이 기본 15초마다 와요. 채팅이 없어도 연결이 살아 있게 하는 것이 유일한 목적이에요.

표준 EventSource 는 이 줄을 알아서 무시하니 신경 쓰지 않아도 돼요. 직접 파서를 짤 때만 data: 가 없는 프레임으로 걸러 주세요.

이벤트 받기​

아래 예제를 저장해 실행하면 네 이벤트가 모두 콘솔에 찍혀요. 각 이벤트 절의 코드는 이 handlers 에 넣어요.

// stream.ts — node --experimental-strip-types stream.ts

interface EventUser {
id: string;
nickname: string | null;
}

interface ChatEvent {
user: EventUser;
isDj: boolean;
message: string;
sentTime: string;
}

interface PresenceEvent {
user: EventUser;
type: 'JOIN';
fanRank: number | null;
isManager: boolean;
favoriteTemperature: number | null;
time: string;
}

interface LikeEvent {
user: EventUser;
type: 'FREE' | 'PAID';
totalAmount: number;
amount: number;
extraAmount: number;
combo: number | null;
time: string;
}

interface DonationEvent {
user: EventUser;
amount: number;
message: string | null;
time: string;
}

interface StreamEndEvent {
reason: 'LIVE_ENDED' | 'TOKEN_EXPIRED' | 'RECONNECT';
}

const res = await fetch(`${process.env.SPOON_BASE_URL}/v1/live/events`, {
headers: {
Authorization: `Bearer ${process.env.SPOON_ACCESS_TOKEN}`,
Accept: 'text/event-stream',
},
});
if (!res.ok || !res.body) throw new Error(`스트림을 열지 못했어요: ${res.status}`);

const handlers: Record<string, (d: any) => void> = {
chat: (d: ChatEvent) => console.log(`${d.user.nickname ?? '익명'}: ${d.message}`),
presence: (d: PresenceEvent) => console.log(`${d.user.nickname ?? '익명'} 입장`),
like: (d: LikeEvent) => console.log(`하트 ${d.totalAmount}개 (${d.type})`),
donation: (d: DonationEvent) => console.log(`후원 ${d.amount} 스푼`),
end: (d: StreamEndEvent) => console.log(`스트림 종료: ${d.reason}`),
};

const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';

for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;

const frames = buffer.split('\n\n');
buffer = frames.pop() ?? '';

for (const frame of frames) {
const lines = frame.split('\n');
const event = lines.find((l) => l.startsWith('event:'))?.slice(6);
const data = lines.find((l) => l.startsWith('data:'))?.slice(5);
if (!event || !data) continue; // `:heartbeat` 에는 data 가 없어요
handlers[event]?.(JSON.parse(data));
}
}
node --experimental-strip-types stream.ts

브라우저 EventSource 로는 연결할 수 없어요. 토큰을 Authorization 헤더로 보내는데 EventSource 는 헤더를 붙이지 못해요. 서버에서 위 코드를 돌리고 결과만 브라우저로 중계해요.

이벤트 페이로드​

모든 이벤트의 사용자 정보는 같은 모양이에요.

필드타입null설명
user.idstring아니오그 사람을 가리키는 값이에요. 암호화한 값이에요.
user.nicknamestring예닉네임이에요. 빈 문자열이 아니라 null 이 와요.
id 로 사람을 식별해요

id 는 이 사람의 고유 식별자예요. 같은 사람이면 항상 같은 값이라 출석 · 누적 · 랭킹의 키로 쓰면 돼요. nickname 은 바뀌고 중복될 수 있으니 식별에 쓰지 마세요.

값의 범위는 내 봇과 그 DJ 조합이에요. DJ 가 연동을 끊었다 다시 동의해도 유지돼요.

이 id 는 청취자 목록 · 팬 랭킹 의 id 와 같은 값이에요.

chat — 채팅​

청취자 채팅과 DJ 퀵메시지를 실시간으로 받아요. 필요한 권한: events.chat

청취자 발화:

{
"user": { "id": "Ab1Cd2Ef3Gh4Ij5Kl6Mn7O", "nickname": "스푼청취자" },
"isDj": false,
"message": "안녕하세요",
"sentTime": "2026-09-02T05:47:26.114Z"
}

DJ 발화 — isDj 가 true 예요:

{
"user": { "id": "Uv4Wx5Yz6Ab7Cd8Ef9Gh0I", "nickname": "스푼DJ" },
"isDj": true,
"message": "사연 보내주세요!",
"sentTime": "2026-09-02T05:47:31.802Z"
}
필드타입null설명
user.idstring아니오발화한 사람이에요. 암호화한 값이에요.
user.nicknamestring예닉네임이에요.
isDjboolean아니오이 발화가 DJ 본인의 것인지 알려줘요. user 안이 아니라 최상위에 있어요.
messagestring아니오채팅 원문이에요.
sentTimestring아니오보낸 시각이에요. ISO-8601 UTC(Z) 예요.
// stream.ts — handlers 에 `chat: onChat` 으로 넣어요
const counts = new Map();

const onChat = (d: ChatEvent) => {
if (d.isDj) return; // DJ 는 집계에서 뺀다
const n = (counts.get(d.user.id) ?? 0) + 1;
counts.set(d.user.id, n); // 키는 nickname 이 아니라 id
console.log(`${d.user.nickname ?? '익명'}: ${d.message} (${n}번째)`);
};

isDj 를 안 보면 DJ 가 자기 방송에서 점수를 벌어요. DJ 도 채팅을 치고 퀵메시지를 보내요. 출석·랭킹 집계에서 빼지 않으면 DJ 가 자기 랭킹 1위에 올라요. 암호화된 id 로는 누가 DJ 인지 알 수 없고 닉네임 비교도 답이 아니라서, 이 필드로 갈라야 해요.

isDj 는 chat 에만 있어요. user 안이 아니라 이벤트 최상위예요. 다른 이벤트에는 이 필드가 아예 없어요 — 원천이 주지 않아서, 항상 false 인 값을 만들어 내보내지 않아요.

nickname 이 null 일 수 있어요. 빈 문자열이 아니라 null 이에요. d.user.nickname ?? '익명' 처럼 받아요.

message 가 JSON 처럼 생겨도 문자열이에요. 청취자가 {"a":1} 을 칠 수 있어요. 파싱하지 마세요.

채팅이 금지된 청취자는 발화 자체가 막혀요. 그래서 그 청취자의 chat 이벤트는 애초에 생기지 않아요 — 우리가 걸러내는 게 아니라 만들어질 것이 없어요. 금지는 그 방송 안에서만 유효해서 다음 방송에는 이어지지 않고, 그때는 같은 사람의 채팅이 다시 흘러요.

후원 메시지 · 운영자 공지 · 다른 시스템 발화는 오지 않아요. 봇 자신의 발화도 돌아오지 않아요.

presence — 입장​

청취자가 방송에 입장하면 받아요. 필요한 권한: events.presence

{
"user": { "id": "Pq8Rs9Tu0Vw1Xy2Za3Bc4D", "nickname": "라디오팬" },
"type": "JOIN",
"fanRank": 1,
"isManager": false,
"favoriteTemperature": 36.5,
"time": "2026-09-02T05:47:32.169Z"
}
필드타입null설명
user.idstring아니오입장한 사람이에요. 암호화한 값이에요.
user.nicknamestring예닉네임이에요.
typestring아니오지금은 JOIN 만 와요.
fanRanknumber예이 방송의 후원 랭킹 순위예요. 후원 이력이 없으면 null 이에요.
isManagerboolean아니오DJ 가 이 청취자를 매니저로 지정했는지 알려줘요.
favoriteTemperaturenumber예애청 온도예요. 방송이 바뀌어도 이어지는 누적 관계 지표라, 단골을 가릴 때 써요.
timestring아니오입장 시각이에요. ISO-8601 UTC(Z) 예요.

입장 시점에 랭킹·매니저·애청 온도가 함께 와요. "1위 팬 입장!" 이나 "단골 인사" 를 만들 때 팬 랭킹 을 따로 부르지 않아도 돼요.

// stream.ts — handlers 에 `presence: onPresence` 로 넣어요
const greeted = new Set();

const onPresence = (d: PresenceEvent) => {
if (greeted.has(d.user.id)) return; // 재입장에 두 번 인사하지 않는다
greeted.add(d.user.id);

const name = d.user.nickname ?? '익명';
if (d.fanRank === 1) console.log(`${name}님, 1위 팬 입장!`);
else if (d.isManager) console.log(`매니저 ${name}님 어서 오세요!`);
else if (d.favoriteTemperature !== null && d.favoriteTemperature >= 36.5)
console.log(`단골 ${name}님 오랜만이에요!`);
else console.log(`${name}님 어서 오세요!`);
};

DJ 가 봇을 매니저로 지정해야 이 이벤트가 흘러요. 지정하지 않으면 오류가 나지 않아요 — 스트림은 200 으로 열리고 chat 도 잘 오는데 presence 만 한 건도 오지 않아요. 지정 방법은 아래 에 있고, 봇의 등급은 연결하는 순간 정해지니 방송 도중 지정했다면 봇이 다시 연결해야 해요.

애청 온도 기준값은 봇이 정해요. 서버가 "몇 도부터 단골" 을 정해주지 않아요. null 이면 판정하지 않는 게 맞아요.

퇴장은 오지 않아요. type 은 지금 JOIN 뿐이에요. "지금 방에 누가 있는지" 가 필요하면 청취자 목록 으로 조회해요.

같은 사람이 나갔다 들어오면 또 와요. 위 코드처럼 id 로 중복을 걸러요.

like — 하트​

청취자가 하트를 보내면 받아요. 필요한 권한: events.like

{
"user": { "id": "Pq8Rs9Tu0Vw1Xy2Za3Bc4D", "nickname": "라디오팬" },
"type": "PAID",
"totalAmount": 30,
"amount": 10,
"extraAmount": 5,
"combo": 2,
"time": "2026-09-03T02:59:03.249Z"
}
필드타입null설명
user.idstring아니오보낸 사람이에요. 암호화한 값이에요.
user.nicknamestring예닉네임이에요.
typestring아니오돈이 들었는지를 알려줘요. PAID 는 스푼을 쓴 아이템 하트, FREE 는 그 밖이에요.
totalAmountnumber아니오이번에 방송에 적립된 하트 총 수예요. (amount + extraAmount) × combo 예요.
amountnumber아니오아이템 하나가 주는 하트 수예요.
extraAmountnumber아니오부스트 아이템으로 더해진 하트 수예요. 없으면 0 이에요.
combonumber예겹쳐서 쓴 횟수예요. 무료 하트는 null 이에요.
timestring아니오보낸 시각이에요. ISO-8601 UTC(Z) 예요.

FREE 는 두 가지예요 — 청취자가 탭으로 보내는 무료 하트, 그리고 하트가 0개라 잔액도 차감되지 않는 아이템이에요. 탭으로 보내는 무료 하트는 combo 가 null 이에요.

FREE 도 하트는 적립돼요. type 은 반응 강도를 가르는 값이지, 집계에서 빼는 값이 아니에요.

// stream.ts — handlers 에 `like: onLike` 로 넣어요
let hearts = 0;

const onLike = (d: LikeEvent) => {
hearts += d.totalAmount; // 집계는 totalAmount — amount 만 더하면 콤보·부스트가 빠진다
if (d.type === 'PAID') {
console.log(`${d.user.nickname ?? '익명'}님 하트 ${d.totalAmount}개 감사합니다!`);
}
// FREE 는 반응하지 않고 세기만 한다
};

FREE 에 일일이 반응하면 방송이 시끄러워져요. 탭으로 보내는 무료 하트는 계속 쏟아지고, PAID 는 청취자가 아이템을 쓴 거라 무게가 달라요. 보통은 PAID 에만 반응하고 FREE 는 집계만 해요. FREE 에도 반응한다면 쿨다운을 두세요.

집계에는 totalAmount 를 써요. amount 만 더하면 콤보와 부스트가 빠져 실제보다 적게 세요. totalAmount 는 라이브 서버가 방송 하트 수에 더하는 값과 같아요.

like.amount 는 하트 수이고 donation.amount 는 스푼이에요. 같은 이름인데 단위가 달라요. 곱셈식도 달라서, 하트는 (amount + extraAmount) × combo 인 totalAmount 를 쓰고 후원의 amount 는 이미 총 스푼이라 다시 곱하면 안 돼요.

combo 는 무료 하트에서 null 이에요. 곱셈에 그대로 넣으면 터져요.

donation — 후원​

청취자가 후원하면 받아요. 필요한 권한: events.donation

{
"user": { "id": "Ab1Cd2Ef3Gh4Ij5Kl6Mn7O", "nickname": "스푼청취자" },
"amount": 300,
"message": "응원해요",
"time": "2026-09-02T05:47:50.512Z"
}
필드타입null설명
user.idstring아니오후원한 사람이에요. 암호화한 값이에요.
user.nicknamestring예닉네임이에요.
amountnumber아니오이번 후원의 총 스푼이에요. 이미 곱해진 값이에요.
messagestring예함께 온 한마디예요. 없는 경우가 더 많아요.
timestring아니오후원 시각이에요. ISO-8601 UTC(Z) 예요.
// stream.ts — handlers 에 `donation: onDonation` 으로 넣어요
const spoons = new Map();

const onDonation = (d: DonationEvent) => {
const total = (spoons.get(d.user.id) ?? 0) + d.amount;
spoons.set(d.user.id, total);
const note = d.message ?? ''; // null 이 기본이라 ?? 로 받는다
console.log(`${d.user.nickname ?? '익명'}님 ${d.amount} 스푼 감사합니다! ${note}`);
};

amount 는 이미 곱해진 총 스푼이에요. 개당 값이 아니라서 개수를 다시 곱하면 안 돼요. like.amount 는 스푼이 아니라 하트 수예요 — 같은 이름이지만 단위가 달라요.

message 는 null 인 경우가 더 많아요. 빈 문자열이 아니에요. d.message.trim() 은 터져요.

방송 전체의 누적 후원 랭킹은 여기서 직접 쌓아요. 팬 랭킹 은 지금 방에 있는 사람 기준이라, 후원하고 나간 사람은 빠져요.

end — 스트림 종료​

스트림이 왜 끝났는지 알리고 닫아요. 이 프레임 뒤에는 아무것도 오지 않아요. 권한이 필요 없어요 — 스코프가 좁은 봇도 연결이 왜 끝났는지는 알 수 있어야 해요.

{ "reason": "LIVE_ENDED" }
필드타입null설명
reasonstring아니오LIVE_ENDED · TOKEN_EXPIRED · RECONNECT 셋 중 하나예요.
reason무슨 일인가요어떻게 하나요
LIVE_ENDED방송이 끝났어요.재연결하지 않아요. 다음 방송을 기다렸다 연결해요.
TOKEN_EXPIREDaccess token 이 만료됐어요.토큰을 갱신하고 곧바로 다시 연결해요.
RECONNECT서버가 이 연결만 닫았어요. 방송은 계속되고 있어요.백오프 없이 곧바로 다시 연결해요. 토큰은 그대로 써요.

RECONNECT 는 방송도 토큰도 멀쩡해요. 토큰을 갱신할 필요가 없고, 백오프를 두면 그만큼 이벤트를 놓쳐요.

네트워크로 끊길 때만 이 프레임이 오지 않아요. 그때는 백오프를 두고 다시 연결하면 돼요.

// stream.ts — handlers 에 `end: onEnd` 로 넣어요.
// 세 사유의 대응이 달라요 — 여기서는 다음에 무엇을 할지만 정하고, 재연결 루프는 바깥에서 돌려요
let next = null;

const onEnd = (d: StreamEndEvent) => {
if (d.reason === 'LIVE_ENDED') next = 'wait'; // 방송이 끝났어요. 백오프 후 다시 연결
else if (d.reason === 'TOKEN_EXPIRED') next = 'refresh'; // 토큰을 갱신하고 곧바로 연결
else next = 'now'; // RECONNECT — 백오프 없이 곧바로 연결
console.log(`스트림 종료: ${d.reason} → ${next}`);
};

봇을 매니저로 지정하는 방법​

매니저 지정은 DJ 가 스푼 앱에서 직접 해요. 봇이 API 로 요청할 수는 없어요.

  1. 봇을 먼저 방송에 들여보내요. 방송 중인 DJ 에게 봇이 이벤트 스트림으로 연결하면 봇 계정이 청취자로 입장해요. 방에 있는 사람만 매니저로 지정할 수 있어요.
  2. DJ 가 방송 화면에서 청취자 목록을 열고 봇 계정을 골라요.
  3. 프로필에서 [매니저 지정] 을 눌러요. 한 방송에 매니저는 3명까지 지정할 수 있어요.
  4. 봇이 스트림을 끊고 다시 연결해요. 봇의 등급은 연결하는 순간에 정해져요. 방송 도중에 매니저로 지정해도 그 연결에는 반영되지 않고, 다음 연결부터 적용돼요.

지정이 필요한 것은 presence 이벤트예요.

무엇이 오지 않나요​

이 스트림은 문서에 적힌 이벤트만 내보내요. 방송 서버에 새 이벤트가 생겨도 자동으로 흘러나오지 않아요.

개인 메시지(DM)특정 청취자에게만 가는 메시지예요. 절대 오지 않아요.
매니저 등급 이상 메시지봇이 매니저가 아니면 오지 않아요. 스푼 앱과 같은 규칙이에요.
봇 자신의 발화·입장자기가 보낸 것은 자기에게 돌아오지 않아요. 에코 루프를 막기 위해서예요. 같은 방송의 다른 봇은 보여요.
시스템 이벤트방송 메타 갱신, 스튜디오 알림 등은 오지 않아요.

받는 범위는 봇의 방 권한을 따라요. 일반 멤버로 입장했으면 청취자가 앱 화면에서 보는 것까지 오고, DJ 가 매니저로 지정했으면 매니저 등급 메시지까지 와요. 개인 메시지(DM)는 어느 경우에도 오지 않아요.

연결 루프​

방송 켜짐을 따로 확인하지 않아도 돼요. 스트림 엔드포인트가 방송 중이 아니면 연결 전에 404 를 줘요. 봇의 루프는 이것 하나면 돼요.

스트림 연결 시도
├ 404 (OAPI_MNGR_0301) → 방송 중이 아니에요. 백오프 후 다시 시도
└ 200 → 이벤트 수신 … end{LIVE_ENDED} → 백오프 후 다시 연결 시도로
end{RECONNECT} → 백오프 없이 곧바로 연결 시도로

현재 방송 조회 로 먼저 확인하고 연결하면 왕복이 두 번이고, 확인과 연결 사이에 방송이 꺼지면 어차피 404 를 받아요. live.read 없이 events.chat 만 동의받은 봇도 이 루프가 그대로 돌아요.

404 마다 즉시 재시도하지 마세요. 몇 초 간격을 두세요 — 재시도마다 토큰 검증이 도는데 이 엔드포인트에는 호출 한도가 걸려 있지 않아요.

현재 방송 조회 는 방송 정보가 필요할 때 불러요 — 제목 · 카테고리 · 청취자 수 · isChatFrozen 같은 것들이요.

재연결 전략​

스트림은 오래 살지만 영원하지 않아요. 배포로 파드가 재시작하면 열려 있던 연결이 전부 닫히니, 재연결은 선택이 아니라 필수예요.

end(LIVE_ENDED) → 방송이 끝났어요. 백오프 후 연결을 다시 시도해요
end(TOKEN_EXPIRED)→ 토큰을 갱신하고 곧바로 다시 연결해요
end(RECONNECT) → 서버가 연결만 닫았어요. 백오프 없이 곧바로 다시 연결해요
사유 없이 끊김 → 지수 백오프(1초 → 2초 → 4초 → … 최대 30초) + jitter 로 재연결해요
404 → 방송 중이 아니에요. 오류가 아니라 상태이니 백오프 후 다시 연결해요
401 → 재연동이 필요해요. 사람이 개입해야 하니 무한 재시도하지 않아요
403 (detailCode 없음) → 권한이 모자라요. 재동의만 받으면 되고 연동은 살아 있어요
403 OAPI_MNGR_0209 → DJ 가 봇을 차단했어요. 재시도로 풀리지 않으니 멈추고 DJ 에게 안내해요

401 과 403 을 다르게 처리해요. 401 은 DJ 가 다시 연동해야 풀리고, 권한 부족 403 은 권한만 추가로 받으면 돼요. 뭉뚱그리면 멀쩡한 연동을 끊게 돼요. 그리고 401 에 무한 재시도하면 영원히 실패하면서 호출 한도만 소진해요.

재연결하면 끊긴 동안의 채팅은 받지 못해요. Last-Event-ID 재개는 아직 열려 있지 않아요.

자주 틀리는 지점​

: 로 시작하는 줄을 파싱하려 하지 마세요. 하트비트예요. data: 가 없는 프레임은 건너뛰어요.

시각의 소수부 자릿수를 고정하지 마세요. ISO-8601 파서를 쓰고, %Y-%m-%dT%H:%M:%S.%fZ 처럼 자릿수를 가정한 형식 문자열은 피해요.

압축을 기대하지 마세요. Accept-Encoding: gzip 을 보내도 이 엔드포인트는 압축하지 않아요. 버퍼에 모였다 나가면 실시간이 아니게 되기 때문이에요.

스트림이 끝났다고 방송이 끝난 것은 아니에요. end 프레임의 reason 을 보세요.

에러​

HTTPdetailCode무슨 일인가요어떻게 하나요
401없음토큰이 없거나 무효해요. 폐기·만료·연동 해제·앱 정지가 모두 여기예요.DJ 에게 재연동을 안내해요.
403없음events. 로 시작하는 권한이 하나도 없어요.권한을 추가해 재동의를 받아요.
403OAPI_MNGR_0209방송에 입장할 수 없어요. DJ 가 봇을 차단했을 수 있어요.재시도로 풀리지 않아요. DJ 에게 안내해요.
404OAPI_MNGR_0301방송 중이 아니에요.백오프 후 다시 연결해요.
502OAPI_MNGR_2302방송 서버 오류예요.백오프 후 재시도해요.

스트림이 열리기 전 오류는 SSE 프레임으로 와요. 이 엔드포인트는 text/event-stream 으로 응답하기 때문에, 연결에 실패해도 본문이 data: 로 시작해요. 본문을 그대로 JSON 파싱하면 터져요. HTTP 상태 코드로 판별하고, 본문이 필요하면 data: 를 떼고 파싱해요.

HTTP/2 404
content-type: text/event-stream;charset=UTF-8

data:{"status":404,"detailCode":"OAPI_MNGR_0301","message":"The DJ is not broadcasting right now."}

이 표에 없는 상태도 올 수 있어요. 잘못된 메서드·Content-Type(400 · 405 · 415, OAPI_MNGR_0103) · 호출 한도(429, 본문 없음) · 서버 오류(500, OAPI_MNGR_1002)는 모든 엔드포인트에 공통이에요 — 에러 코드에 있어요.

관련 문서​