본문으로 건너뛰기

web ↔ server 인터페이스

갱신일 2026-07-29 · 기준: server@5d7305d · web contract@a45b063 · web lifecycle sync@fad1d73 + openapi3-server.yml · asyncapi-web-server.yml 공통 규칙(인증·오류·식별자·시간)은 common-conventions.md에 있다. 여기서 반복하지 않는다. 스키마·필드·enum 값은 위 계약 파일이 정본이다. 이 문서는 방향과 경계만 적는다.


확인한 사실

1. 프로토콜 셋 — 방향과 선택 기준

#프로토콜방향무엇에 쓰나왜 이것이어야 하나
REST /v1/**web → server (응답 1건)상태 조회·변경 전부기본값. 계약에서 클라이언트를 생성할 수 있는 유일한 구간이다
STOMP over WebSocket양방향 지속실시간 전사 + 노트 토픽오디오를 지속해서 올리고, 서버는 노트 멤버에게 실시간 변경 신호를 fanout한다
SSE (POST 응답)server → web (단방향 스트림)agent 채팅 응답 2종에만응답이 토큰 단위로 길게 흐른다. 요청 본문이 필요해 GET 기반 EventSource를 쓸 수 없다

셋은 서로 대체하지 않는다. 새 기능이 어디에 붙을지는 "요청 하나에 응답 하나인가 / 클라이언트가 계속 보내는가 / 서버가 계속 보내는가"로 갈린다.

2. ① REST

  • 규모: 계약 정본 openapi3-server.yml37경로·48 operation·48 schema이고, 그중 /internal/** 3경로·3 operation과 전용 schema·의존성을 뺀 34경로·45 operation·43 schema가 web이 쓰는 표면이다.
  • 인증: 세 프로토콜 모두 같은 HttpOnly 쿠키를 탄다. web은 401을 받으면 /v1/auth/refresh1회만 재시도하고, SSR 경로는 렌더 전에 미들웨어가 갱신한다.
  • 봉투: 성공·실패 모두 AppResponse. 값은 계약 파일이 정본이다.

회의 상태·시간 snapshot

GET /v1/projects/{projectId}/notes의 목록 항목과 GET /v1/notes/{noteId} 상세는 같은 서버 계산 필드 세트를 준다.

필드의미
meetingStatus서버 권위 네 상태 NOT_STARTED → IN_PROGRESS ⇄ PAUSED → ENDED
meetingStartedAt최초 ACTIVE 전사 세션 시작 시각. 한 번도 ACTIVE가 아니면 null
recordedDurationMs종료된 ACTIVE 구간만 합산한 누적 녹음 시간. READY·진행 중 구간은 제외
activeSessionStartedAt현재 ACTIVE 세션 시작 시각. READY 또는 활성 세션이 없으면 null

web은 상태를 로컬 녹음 객체나 경과 시간으로 추론하지 않는다. READY 연결 대기에는 meetingStatus=IN_PROGRESS여도 activeSessionStartedAt=null일 수 있으므로 기존 recordedDurationMs에 시간을 더하지 않는다. PAUSED·ENDED도 누적값을 그대로 표시한다.

3. ② STOMP — 전사 + 노트 토픽

항목
핸드셰이크/ws/transcriptions (버전 세그먼트 없음)
prefix요청 /app · 브로커 /queue · user /user
이벤트 목적지/user/queue/transcription-sessions/{replyId}/events
명령 4종connect · audio(PCM 바이너리) · commit · stop
이벤트 5종connected · partial · final · completed · error

경계상 중요한 셋:

  • {replyId}는 브라우저가 만든 UUID다. 서버 발급 id가 아니므로 SUBSCRIBE가 connect보다 먼저 등록돼야 한다. 순서가 뒤집히면 이벤트가 갈 곳이 없다.
  • STOMP session별 queue를 쓴다. 같은 사용자의 다른 탭 연결과 이벤트가 섞이지 않게 하기 위함이다.
  • completederror는 terminal이다. 둘 중 정확히 하나만, 최대 한 번 온다. 받으면 client는 연결을 deactivate한다.

오디오 인코딩 파라미터(샘플레이트·배치 길이·백로그 상한)는 계약이 고정한 값이 아니라 web 클라이언트가 소유한다 → web/README.md.

노트 멤버 fanout은 /topic/notes/{noteId} 한 채널을 쓴다. docs 합본은 전사·노트 토픽 6채널·6 operation과 채팅 SSE 2채널·2 operation을 합친 8채널·8 operation이다. 기존 recording.started·recording.stopped payload에는 transcriptionSessionId만 있고 회의 상태·시간은 없다. 따라서 둘은 invalidate-only 신호다. 현재 web은 회의 상태·시간 동기화 대상으로 노트 상세 query와 캐시된 프로젝트 노트 목록 query를 invalidate한다 (web@fad1d73, recording.stopped는 전사 query도 invalidate). 활성 query는 즉시 REST snapshot을 다시 읽고, 비활성 목록은 stale로 표시되어 다음 사용 시 다시 읽는다. 재연결 catch-up도 노트 상세를 다시 읽는다. 별도 pause/resume REST나 meeting.paused 이벤트는 없다.

4. ③ SSE — agent 채팅 2종

진입 URL만 다르고 이벤트 계층은 완전히 같다.

개인 챗봇공유 챗봇
진입점POST /v1/agent-chats/{chatId}/messagesPOST /v1/notes/{noteId}/chat/messages
게이트없음노트 meetingStatus=IN_PROGRESS에만 쓰기 + 입력 잠금(한 번에 한 명)
관전자없음있음 — 스트림을 받지 않는다
  • activeSessionStartedAt은 공유 챗 gate가 아니다. READY에는 meetingStatus=IN_PROGRESS여도 activeSessionStartedAt=null일 수 있으며 이때도 server의 IN_PROGRESS 판정을 따른다.
  • 이벤트는 8종이다message_start token tool_call_start tool_approval_request tool_approval_resolved tool_call_result message_end error. 실제 생산자는 heymoa-ai이고 server는 변환 없이 통과시킨다 → server-ai.md.
  • 관전자는 폴링으로만 안다. 다른 멤버가 입력 중인 것도, 승인 대기 중인 것도 히스토리 조회 응답의 lock 필드가 유일한 근거다. 스트림이 없으니 실시간 신호도 없다.
  • 승인 API의 204는 확정이 아니다. 확정의 단일 출처는 스트림의 tool_approval_resolved다.
  • 종료 규약과 keepalive는 common-conventions.md §8에 있다. web은 "종료 이벤트 없이 끊김"을 반드시 처리해야 한다 — 이 구간에서는 계약 위반이 아니라 정상 시나리오다.

5. web 생성물 경계 — 무엇이 생성되고 무엇이 수기인가

규칙 셋:

  1. lib/api/generated/**는 손으로 고치지 않는다. 바꿔야 하면 계약이나 생성 설정을 고치고 다시 생성한다.
  2. AsyncAPI는 코드를 생성하지 않는다. 검증에만 쓰고, 프로토콜 파싱은 수기 zod 스키마가 구현한다. 계약과 구현이 갈라질 수 있는 유일한 지점이라 파싱 실패를 에러로 다룬다.
  3. 미러를 손으로 고치지 않는다. web의 openapi3.yml은 계약 정본에서 /internal/**을 지운 사본이다. 만드는 방법은 api-registry.md.

생성 훅이 있어도 쓸 수 없는 operation이 셋 있다. 계약 자체의 성질 때문이며, 계약이 바뀌지 않는 한 사라지지 않는다.

operation생성은 되는가왜 못 쓰나대신
POST /v1/agent-chats/{chatId}/messages응답이 text/event-stream인데 생성 클라이언트가 본문을 한 덩어리로 읽는다수기 SSE 리더
POST /v1/notes/{noteId}/chat/messages위와 같음수기 SSE 리더
GET /v1/workspaces/{workspaceId}/integrations/{provider}/authorize302 리다이렉트 — 본문이 HTML이라 JSON 파싱이 깨진다. 실제 서버도 외부 제공자로 브라우저를 보낸다브라우저 최상위 이동

네이티브 EventSource를 못 쓰는 이유는 별개다 — GET 전용이라 요청 본문을 실을 수 없다. 두 제약이 겹쳐 SSE 두 경로가 수기 구현이 됐다.

6. 계약이 web 화면에 강제하는 것

계약의 응답 형태가 화면 구조를 정하는 지점들이다. 값이 아니라 관계라서 여기 적는다.

계약 사실화면에 강제되는 것
회의 시작·재개와 중지·종료가 403 시작자 아님을 낸다시작자가 아니면 버튼이 화면에 없어야 한다. 403은 최후 방어선이지 UX가 아니다
공유 챗 쓰기는 IN_PROGRESS에서만 되고 나머지 세 상태는 409NOT_STARTED·PAUSED·ENDED에서는 히스토리만 읽고 composer를 잠근다
최신 분석 조회가 404를 낸다404 = "아직 분석 전"이지 오류가 아니다. 빈 상태로 그린다
활성 개인 챗 조회가 null을 낸다null = 활성 세션 없음이지 오류가 아니다
요약 3종이 markdown 문자열이고 미완료 시 비어 있다web이 markdown으로 렌더한다. 원문 출력은 #·-를 사용자에게 노출한다
미연동 provider도 목록에 온다"연결하기" 버튼의 근거가 목록 자체다
알림은 초대가 취소돼도 남고 상태만 바뀐다PENDING이 아닌 알림은 버튼 대신 상태 라벨을 보여야 한다

미결

  1. /internal 3경로가 public 스펙에 함께 들어 있다. server가 받는 모든 경로의 생성물이라 구조상 불가피하지만, web이 매번 3경로·3 operation과 전용 schema·의존성을 지운 사본을 유지해야 한다. 생성 단계에서 public/internal을 분리해 뱉으면 사본 관리가 사라진다 — 생성기 설정 변경이 필요해 확인하지 못했다.
  2. AsyncAPI 구간에는 계약↔구현 자동 검증이 없다. REST는 생성으로 강제되지만 STOMP·SSE는 수기 스키마라 계약이 바뀌어도 빌드가 깨지지 않는다. 계약 예시를 파싱하는 테스트가 web에 일부 있으나 전 이벤트를 덮는지는 확인하지 않았다.
  3. 알림 폴링 주기를 계약이 정하지 않는다. push 채널이 없어 web이 주기를 고르는데, 그 값이 어느 문서에도 규약으로 적혀 있지 않다.

이어서 볼 것어디
인증·오류·식별자·시간 공통 규칙common-conventions.md
이 SSE 이벤트를 실제로 만드는 쪽server-ai.md
스키마·필드·enum 값openapi3-server.yml · asyncapi-web-server.yml
계약 파일 갱신 방법api-registry.md
web 내부 구조web/README.md