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.yml은 37경로·48 operation·48 schema이고, 그중/internal/**3경로·3 operation과 전용 schema·의존성을 뺀 34경로·45 operation·43 schema가 web이 쓰는 표면이다. - 인증: 세 프로토콜 모두 같은 HttpOnly 쿠키를 탄다. web은 401을 받으면
/v1/auth/refresh후 1회만 재시도하고, 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를 쓴다. 같은 사용자의 다른 탭 연결과 이벤트가 섞이지 않게 하기 위함이다.
completed와error는 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}/messages | POST /v1/notes/{noteId}/chat/messages |
| 게이트 | 없음 | 노트 meetingStatus=IN_PROGRESS에만 쓰기 + 입력 잠금(한 번에 한 명) |
| 관전자 | 없음 | 있음 — 스트림을 받지 않는다 |
activeSessionStartedAt은 공유 챗 gate가 아니다. READY에는meetingStatus=IN_PROGRESS여도activeSessionStartedAt=null일 수 있으며 이때도 server의 IN_PROGRESS 판정을 따른다.- 이벤트는 8종이다 —
message_starttokentool_call_starttool_approval_requesttool_approval_resolvedtool_call_resultmessage_enderror. 실제 생산자는 heymoa-ai이고 server는 변환 없이 통과시킨다 → server-ai.md. - 관전자는 폴링으로만 안다. 다른 멤버가 입력 중인 것도, 승인 대기 중인 것도 히스토리 조회 응답의
lock필드가 유일한 근거다. 스트림이 없으니 실시간 신호도 없다. - 승인 API의
204는 확정이 아니다. 확정의 단일 출처는 스트림의tool_approval_resolved다. - 종료 규약과 keepalive는 common-conventions.md §8에 있다. web은 "종료 이벤트 없이 끊김"을 반드시 처리해야 한다 — 이 구간에서는 계약 위반이 아니라 정상 시나리오다.
5. web 생성물 경계 — 무엇이 생성되고 무엇이 수기인가
규칙 셋:
lib/api/generated/**는 손으로 고치지 않는다. 바꿔야 하면 계약이나 생성 설정을 고치고 다시 생성한다.- AsyncAPI는 코드를 생성하지 않는다. 검증에만 쓰고, 프로토콜 파싱은 수기 zod 스키마가 구현한다. 계약과 구현이 갈라질 수 있는 유일한 지점이라 파싱 실패를 에러로 다룬다.
- 미러를 손으로 고치지 않는다. 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}/authorize | 됨 | 302 리다이렉트 — 본문이 HTML이라 JSON 파싱이 깨진다. 실제 서버도 외부 제공자로 브라우저를 보낸다 | 브라우저 최상위 이동 |
네이티브 EventSource를 못 쓰는 이유는 별개다 — GET 전용이라 요청 본문을 실을 수 없다. 두 제약이 겹쳐 SSE 두 경로가 수기 구현이 됐다.
6. 계약이 web 화면에 강제하는 것
계약의 응답 형태가 화면 구조를 정하는 지점들이다. 값이 아니라 관계라서 여기 적는다.
| 계약 사실 | 화면에 강제되는 것 |
|---|---|
회의 시작·재개와 중지·종료가 403 시작자 아님을 낸다 | 시작자가 아니면 버튼이 화면에 없어야 한다. 403은 최후 방어선이지 UX가 아니다 |
공유 챗 쓰기는 IN_PROGRESS에서만 되고 나머지 세 상태는 409다 | NOT_STARTED·PAUSED·ENDED에서는 히스토리만 읽고 composer를 잠근다 |
최신 분석 조회가 404를 낸다 | 404 = "아직 분석 전"이지 오류가 아니다. 빈 상태로 그린다 |
활성 개인 챗 조회가 null을 낸다 | null = 활성 세션 없음이지 오류가 아니다 |
| 요약 3종이 markdown 문자열이고 미완료 시 비어 있다 | web이 markdown으로 렌더한다. 원문 출력은 #·-를 사용자에게 노출한다 |
| 미연동 provider도 목록에 온다 | "연결하기" 버튼의 근거가 목록 자체다 |
| 알림은 초대가 취소돼도 남고 상태만 바뀐다 | PENDING이 아닌 알림은 버튼 대신 상태 라벨을 보여야 한다 |
미결
/internal3경로가 public 스펙에 함께 들어 있다. server가 받는 모든 경로의 생성물이라 구조상 불가피하지만, web이 매번 3경로·3 operation과 전용 schema·의존성을 지운 사본을 유지해야 한다. 생성 단계에서 public/internal을 분리해 뱉으면 사본 관리가 사라진다 — 생성기 설정 변경이 필요해 확인하지 못했다.- AsyncAPI 구간에는 계약↔구현 자동 검증이 없다. REST는 생성으로 강제되지만 STOMP·SSE는 수기 스키마라 계약이 바뀌어도 빌드가 깨지지 않는다. 계약 예시를 파싱하는 테스트가 web에 일부 있으나 전 이벤트를 덮는지는 확인하지 않았다.
- 알림 폴링 주기를 계약이 정하지 않는다. push 채널이 없어 web이 주기를 고르는데, 그 값이 어느 문서에도 규약으로 적혀 있지 않다.
| 이어서 볼 것 | 어디 |
|---|---|
| 인증·오류·식별자·시간 공통 규칙 | common-conventions.md |
| 이 SSE 이벤트를 실제로 만드는 쪽 | server-ai.md |
| 스키마·필드·enum 값 | openapi3-server.yml · asyncapi-web-server.yml |
| 계약 파일 갱신 방법 | api-registry.md |
| web 내부 구조 | web/README.md |