이벤트 스트림
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"}
| 이벤트 | 필요한 권한 | 언제 오나요 | 페이로드 |
|---|---|---|---|
chat | events.chat | 청취자나 DJ 가 채팅을 쳤을 때 | chat |
presence | events.presence | 청취자가 방송에 입장했을 때 | presence |
like | events.like | 청취자가 하트를 보냈을 때 | like |
donation | events.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.id | string | 아니오 | 그 사람을 가리키는 값이에요. 암호화한 값이에요. |
user.nickname | string | 예 | 닉네임이에요. 빈 문자열이 아니라 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.id | string | 아니오 | 발화한 사람이에요. 암호화한 값이에요. |
user.nickname | string | 예 | 닉네임이에요. |
isDj | boolean | 아니오 | 이 발화가 DJ 본인의 것인지 알려줘요. user 안이 아니라 최상위에 있어요. |
message | string | 아니오 | 채팅 원문이에요. |
sentTime | string | 아니오 | 보낸 시각이에요. 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.id | string | 아니오 | 입장한 사람이에요. 암호화한 값이에요. |
user.nickname | string | 예 | 닉네임이에요. |
type | string | 아니오 | 지금은 JOIN 만 와요. |
fanRank | number | 예 | 이 방송의 후원 랭킹 순위예요. 후원 이력이 없으면 null 이에요. |
isManager | boolean | 아니오 | DJ 가 이 청취자를 매니저로 지정했는지 알려줘요. |
favoriteTemperature | number | 예 | 애청 온도예요. 방송이 바뀌어도 이어지는 누적 관계 지표라, 단골을 가릴 때 써요. |
time | string | 아니오 | 입장 시각이에요. 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.id | string | 아니오 | 보낸 사람이에요. 암호화한 값이에요. |
user.nickname | string | 예 | 닉네임이에요. |
type | string | 아니오 | 돈이 들었는지를 알려줘요. PAID 는 스푼을 쓴 아이템 하트, FREE 는 그 밖이에요. |
totalAmount | number | 아니오 | 이번에 방송에 적립된 하트 총 수예요. (amount + extraAmount) × combo 예요. |
amount | number | 아니오 | 아이템 하나가 주는 하트 수예요. |
extraAmount | number | 아니오 | 부스트 아이템으로 더해진 하트 수예요. 없으면 0 이에요. |
combo | number | 예 | 겹쳐서 쓴 횟수예요. 무료 하트는 null 이에요. |
time | string | 아니오 | 보낸 시각이에요. 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.id | string | 아니오 | 후원한 사람이에요. 암호화한 값이에요. |
user.nickname | string | 예 | 닉네임이에요. |
amount | number | 아니오 | 이번 후원의 총 스푼이에요. 이미 곱해진 값이에요. |
message | string | 예 | 함께 온 한마디예요. 없는 경우가 더 많아요. |
time | string | 아니오 | 후원 시각이에요. 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 | 설명 |
|---|---|---|---|
reason | string | 아니오 | LIVE_ENDED · TOKEN_EXPIRED · RECONNECT 셋 중 하나예요. |
reason | 무슨 일인가요 | 어떻게 하나요 |
|---|---|---|
LIVE_ENDED | 방송이 끝났어요. | 재연결하지 않아요. 다음 방송을 기다렸다 연결해요. |
TOKEN_EXPIRED | access 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 로 요청할 수는 없어요.
- 봇을 먼저 방송에 들여보내요. 방송 중인 DJ 에게 봇이 이벤트 스트림으로 연결하면 봇 계정이 청취자로 입장해요. 방에 있는 사람만 매니저로 지정할 수 있어요.
- DJ 가 방송 화면에서 청취자 목록을 열고 봇 계정을 골라요.
- 프로필에서 [매니저 지정] 을 눌러요. 한 방송에 매니저는 3명까지 지정할 수 있어요.
- 봇이 스트림을 끊고 다시 연결해요. 봇의 등급은 연결하는 순간에 정해져요. 방송 도중에 매니저로 지정해도 그 연결에는 반영되지 않고, 다음 연결부터 적용돼요.
지정이 필요한 것은 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 을 보세요.
에러
| HTTP | detailCode | 무슨 일인가요 | 어떻게 하나요 |
|---|---|---|---|
401 | 없음 | 토큰이 없거나 무효해요. 폐기·만료·연동 해제·앱 정지가 모두 여기예요. | DJ 에게 재연동을 안내해요. |
403 | 없음 | events. 로 시작하는 권한이 하나도 없어요. | 권한을 추가해 재동의를 받아요. |
403 | OAPI_MNGR_0209 | 방송에 입장할 수 없어요. DJ 가 봇을 차단했을 수 있어요. | 재시도로 풀리지 않아요. DJ 에게 안내해요. |
404 | OAPI_MNGR_0301 | 방송 중이 아니에요. | 백오프 후 다시 연결해요. |
502 | OAPI_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)는
모든 엔드포인트에 공통이에요 — 에러 코드에 있어요.