콘텐츠로 이동

이벤트 스트림

Backend.AI GO에서 오래 걸리는 작업은 모두 진행 상황을 이벤트로 알린다. 스쿼드 실행, 에이전트 채팅 턴, 토론방, 데이터 수집과 폴더 가져오기, 임베딩 실행, 메모리 정리가 모두 여기 해당한다. Management API는 이 이벤트를 하나의 버스에 발행하고, 두 가지 전송 방식으로 내보낸다.

전송 방식 경로 스코프 이어받기와 지연 보고 이럴 때 쓴다
Server-Sent Events GET /api/v1/events admin 지원 읽기만 하면 되고 클라이언트가 EventSource일 때
WebSocket GET /api/v1/events/ws admin 지원 연결된 상태에서 필터를 바꿔야 하거나, 끊긴 뒤 이어받아야 할 때
WebSocket GET /api/v1/squads/{id}/ws agent_read 지원 끊겨도 이어가야 하는 스크립트에서 스쿼드 하나를 지켜볼 때
Server-Sent Events GET /api/v1/squads/{id}/events agent_read 미지원 브라우저에서 스쿼드 하나를 지켜보고, 끊길 때 이벤트를 잃어도 괜찮을 때
Server-Sent Events GET /api/v1/squads/events agent_read 지원 모든 스쿼드를 한꺼번에 보거나, 아직 id를 모를 때
WebSocket GET /api/v1/squads/ws agent_read 지원 연결한 채로 필터를 바꾸며 모든 스쿼드를 지켜볼 때
Server-Sent Events GET /api/v1/schedules/events agent_read 지원 자동화 실행과 설정 변경을 따라갈 때
WebSocket GET /api/v1/schedules/ws agent_read 지원 연결한 채로 필터를 바꾸며 자동화를 따라갈 때
Server-Sent Events GET /api/v1/memory/events memory_read 지원 폴링 없이 메모리 화면을 갱신할 때
WebSocket GET /api/v1/memory/ws memory_read 지원 연결한 채로 필터를 바꾸며 메모리 변경을 따라갈 때
Server-Sent Events GET /api/v1/data/events data_read 지원 수집·폴더 가져오기·임베딩 진행을 따라갈 때
WebSocket GET /api/v1/data/ws data_read 지원 연결한 채로 필터를 바꾸며 데이터 허브 작업을 따라갈 때

앞의 두 전역 경로는 버스의 모든 이벤트를 실어 나르므로 admin 스코프가 필요하다. 나머지 경로는 도메인 하나로 미리 걸러진 스트림이라 그 도메인의 읽기 스코프를 쓴다. 더 좁은 스코프가 안전한 근거는 필터에 있다. 도메인 스트림은 이어받기든 실시간이든 자기 계열 밖의 이름을 전달하지 않으며, ?types=는 계열 안에서만 좁힐 수 있고 밖의 이름을 지정하면 400으로 거절한다.

GET /api/v1/squads/{id}/events는 이 기능보다 먼저 있던 경로라 아직 Last-Event-ID?since=를 무시하고, 소비자가 뒤처지면 이벤트를 조용히 버린다. 이것이 중요하면 GET /api/v1/squads/{id}/ws나 스쿼드 전체를 다루는 두 경로 중 하나를 쓴다. 아래에서 다루는 이어받기, 유실, 지연은 표에서 지원이라고 적힌 경로들에 해당한다.

이벤트 형태

두 전송 방식이 나르는 이벤트는 같다. WebSocket에서는 JSON 텍스트 프레임 하나로 도착한다.

{
  "id": 4213,
  "type": "squad:task-completed",
  "payload": { "squadId": "8f2c...", "taskId": "t-19" },
  "timestamp": "2026-08-22T10:00:00Z"
}

SSE에서는 같은 세 가지 정보가 표준 필드로 도착한다. EventSource 클라이언트가 봉투를 따로 파싱하지 않아도 되도록 한 것이다.

event: squad:task-completed
data: {"squadId":"8f2c...","taskId":"t-19"}
id: 4213

id는 이벤트가 발행될 때마다 1씩 증가하며, 이어받기 커서로 쓴다. type은 이벤트 이름이고, 도메인별 이름 목록은 각 도메인 문서에 있다.

끊긴 뒤 이어받기

서버는 최근 2048개 이벤트를 보관한다. 다시 연결하면서 마지막으로 받은 id를 넘기면 그 뒤의 이벤트를 모두 받고, 이어서 실시간 스트림으로 넘어간다. 중복도 빠짐도 없다.

이어받기 지점은 두 가지 방법으로 넘긴다.

  • 요청 헤더 Last-Event-ID: 4213. 브라우저 EventSource는 이 헤더를 알아서 다시 보낸다.
  • 쿼리 파라미터 ?since=4213. 헤더를 지정할 수 없는 클라이언트용이다.

둘 다 있으면 헤더가 우선한다.

curl -N -H "Authorization: Bearer $KEY" \
     -H "Last-Event-ID: 4213" \
     http://127.0.0.1:8001/api/v1/events

유실과 지연은 조용히 넘어가지 않는다

예약된 이벤트 이름 세 개는 제품 이벤트가 아니라 스트림 상태를 나른다. 구독 필터는 이 이름들을 절대 걸러내지 않는다. 이벤트 종류 하나만 신청한 클라이언트라도 이벤트를 놓쳤다는 사실은 들어야 하기 때문이다. 다만 도메인이 정해진 경로에서는 ?types=나 구독 프레임에 이 이름들을 적지 않는다. 어느 도메인에도 속하지 않는 이름이라 다른 도메인 밖 이름과 똑같이 거절되며, 어차피 오는 것을 신청해서 얻을 것도 없다.

이름 의미 페이로드 최상위 id
stream:ready WebSocket 업그레이드 직후, 재생분과 실시간 이벤트보다 먼저 한 번 보낸다. {"subscribed": [...] 또는 null, "lastId": 4213} 이어받을 때는 요청한 커서, 새 연결에서는 현재 버스 앵커
stream:gap 요청한 이어받기 지점을 그대로 지킬 수 없다. {"droppedBefore": 2170} null
stream:lagged 이 연결이 뒤처져서 이벤트가 버려졌다. {"dropped": 37} null

어떤 예약 프레임도 클라이언트가 아직 받아야 할 이벤트 너머로 커서를 밀지 않는다. 이어받는 연결에서 stream:ready는 서버의 최신 id가 아니라 요청한 이어받기 지점을 싣는다. 밀린 이벤트가 이 프레임 뒤에 오기 때문이다. 새 연결에는 밀린 이벤트가 없으므로 현재 버스 id를 안전한 앵커로 싣는다. 이 앵커를 기록하면 첫 제품 이벤트가 오기 전에 소켓이 끊기는 틈도 막을 수 있다. 최신 id는 항상 페이로드의 lastId에도 들어 있다.

stream:gap이 나오는 경우는 둘이다. 흔한 쪽은 이어받기 지점이 버퍼에 남은 가장 오래된 이벤트보다 앞서서 그 사이 이벤트가 밀려난 경우다. 다른 쪽은 이어받기 지점이 서버가 발행한 어떤 이벤트보다 뒤인 경우인데, 서버가 재시작하면 이벤트 id가 0부터 다시 시작하므로 재시작 전에 저장해 둔 커서가 이렇게 보인다. 이 경우 서버는 새 프로세스의 버퍼에 남은 이벤트를 모두 재생하고, 첫 실제 이벤트가 클라이언트의 새 커서 기준을 세운다. stream:readylastId가 요청한 커서보다 작다면 같은 신호를 한 프레임 먼저 본 것이다.

재생 버퍼는 연결이 읽는 브로드캐스트 채널보다 많은 이벤트를 담는다(2048 대 1024). 의도한 비율이다. stream:lagged를 받은 클라이언트가 마지막으로 본 id로 다시 연결하면 버려진 이벤트를 버퍼에서 되찾을 수 있다.

연결한 채로 구독 바꾸기

WebSocket 클라이언트는 제어 프레임을 JSON 텍스트로 보낸다. SSE 클라이언트는 그럴 수 없다. EventSource의 필터는 연결 시점의 ?types=로 고정된다.

프레임 효과
{"op":"subscribe","types":["squad:task-completed"]} 필터에 이름을 더한다. 빈 목록은 이 소켓 범위의 모든 이벤트를 뜻한다.
{"op":"unsubscribe","types":["squad:task-completed"]} 필터에서 이름을 뺀다.
{"op":"set","types":["squad:task-completed"]} 필터를 통째로 바꾼다. 빈 목록은 이 소켓 범위의 모든 이벤트를 뜻한다. 필터 없는 스트림을 좁힐 때 쓰는 방법이다.
{"op":"ping"} {"op":"pong"}으로 답한다.

받아들인 구독 변경은 결과 필터를 담은 {"op":"subscribed","types":[...] 또는 null}로 확인해 준다. 클라이언트가 언제 반영됐는지 추측하지 않아도 된다. 모르는 op이거나 형식이 잘못된 프레임에는 {"op":"error","message":"..."}로 답하고 연결은 유지한다.

subscribe는 더하기만 한다. 좁히지는 못한다. 필터가 없는 스트림(?types=도 없고 set도 하지 않은 상태)에서 subscribe는 더할 것이 없고, unsubscribe는 조용히 좁히는 대신 오류로 거절한다. 목록을 명시하려면 set을 쓴다.

소켓의 도메인 밖 종류를 담은 프레임도 같은 방식으로 거절한다. 거절된 이름을 적은 {"op":"error"}가 오고 필터는 그대로 남는다. 두 스쿼드 소켓은 squad:*, 스케줄 소켓은 schedule:*, 메모리 소켓은 memory:*, 데이터 허브 소켓은 data:*만 받는다. 도메인 밖 이름을 ?types=로 넘기면 그보다 먼저 업그레이드 단계에서 400으로 거절하므로 소켓 자체가 만들어지지 않는다. GET /api/v1/events/ws는 버스 전체를 실어 나르므로 그쪽에는 도메인 밖 이름이 없다.

이 소켓은 수신과 구독 전용이다. 제품의 무언가를 시작하거나 멈추거나 바꾸는 제어 프레임은 없다. 그런 동작은 모두 인가된 REST 호출로 남는다.

제한

항목
클라이언트 제어 프레임 16 KiB, 넘으면 코드 1009로 연결을 닫는다
구독 하나에 담을 이벤트 종류 256개
이벤트 이름 길이 128바이트
재생 버퍼 이벤트 2048개
서버 핑 주기 15초, 두 번 응답이 없으면 코드 1001로 닫는다

서버는 멈출 때도 1001로 닫는다. 계획된 중단인지 네트워크 장애인지 클라이언트가 구분할 수 있다. 설정 변경으로 내장 Management API가 재시작하는 경우도 여기 해당하니, 1001을 오류로 보지 말고 다시 연결한다.

모든 도메인 소켓에서 id와 lastId는 도메인별 일련번호가 아니라 서버 전역 이벤트 카운터다. 그래서 한 도메인의 연속된 이벤트라도 id는 보통 연속하지 않는다. 이어받기는 이 전역 커서로 동작하며, 숫자를 무언가의 개수로 읽으면 안 된다.

인증

업그레이드 요청은 평범한 보호 요청이라, 다른 모든 경로와 똑같은 인증과 스코프 검사를 거친다. 인가는 소켓이 생기기 전인 업그레이드 시점에 끝난다. 스코프가 없는 호출자는 401이나 403을 받고 소켓은 만들어지지 않는다.

스크립트와 CLI는 키를 헤더로 보낸다.

websocat -H "Authorization: Bearer $KEY" \
  "ws://127.0.0.1:8001/api/v1/events/ws?types=squad:task-completed"

브라우저는 WebSocket 업그레이드에 헤더를 붙일 수 없으므로, 키를 서브프로토콜 목록에 실어 보낸다. 키를 담은 토큰과 서버가 되돌려줄 표식 토큰을 함께 제시한다.

const socket = new WebSocket(
  "ws://127.0.0.1:8001/api/v1/events/ws",
  [`aigo-key.${key}`, "aigo-key"],
);

서버는 클라이언트가 제시한 둘 중 하나인 aigo-key를 고른다. RFC 6455가 요구하는 대로다. 키를 담은 토큰은 Upgrade: websocket이 실제로 붙은 요청에서만 읽으므로, 평범한 REST 호출을 인증하는 두 번째 경로가 되지 않는다.

쿼리 문자열의 키는 절대 받지 않는다. 쿼리 문자열은 접근 로그, 리버스 프록시, 브라우저 방문 기록에 남는다. 그래서 ?key=, ?token=을 비롯한 모든 철자를 무시하고 401로 거절한다. 헤더나 서브프로토콜을 쓴다.

소켓은 업그레이드 시점에 한 번 인가된다. 그 뒤 클라이언트가 무엇을 보내도 받는 범위가 넓어지지 않는다. 구독 프레임은 종류 필터만 조정하고, /squads/{id}/ws의 스쿼드 조건은 경로에서 오는 값이라 어떤 프레임으로도 건드릴 수 없다. since 재생도 실시간 스트림과 똑같이 걸러진다. 반대 방향도 마찬가지다. 연결 도중 폐기된 키는 소켓이 닫힐 때까지 스트림을 유지한다. 지금의 SSE 스트림과 같은 동작이다. 접근을 즉시 끊으려면 소켓을 닫아야 한다.

유닉스 소켓 전송

Management API는 같은 라우터를 유닉스 도메인 소켓으로도 서비스하므로, 업그레이드가 그대로 동작한다. 로컬 스크립트라면 보통 이쪽을 쓴다. 포트가 필요 없고 소켓 파일의 권한을 그대로 물려받기 때문이다.

websocat --unix-socket "$SOCKET" \
  -H "Authorization: Bearer $KEY" \
  "ws://localhost/api/v1/events/ws"

aigo CLI에서 사용하기

CLI가 이 경로들을 직접 사용하므로, 스크립트가 두 전송 방식을 직접 구현할 필요가 없다. aigo events는 전역 버스를, aigo squad events, aigo schedule events, aigo memory events, aigo data events는 각각 도메인 하나를 따라간다. follow 모드(aigo squad execute --follow, aigo squad execution --follow, aigo squad message --wait, aigo squad discussion watch, aigo data ... --follow)도 같은 스트림을 사용하되 런 하나만 보고한다.

모두 --types, --since, --raw, --transport를 받고 기본은 SSE다. 재연결, 재개 커서, stream:gap / stream:lagged 보고는 클라이언트가 처리한다. CLI 레퍼런스를 참고하라.

도메인별 이벤트 이름

각 도메인 문서에 그 도메인이 발행하는 이벤트와 페이로드가 정리돼 있다.