본문으로 건너뛰기

공통 인터페이스 규약

갱신일 2026-07-26 · 기준: ../contracts/ 5개 파일 + ai@60ee59a · server@bf192cc · web@87e0834 파일 목록: INDEX.md · 스키마 정본: ../contracts/

세 서비스가 모두 지키는 규칙만 담는다. 한 경계에만 해당하는 것은 server-web.md·server-ai.md에 있다. 값과 필드는 여기 복사하지 않는다 — 형식이 궁금하면 계약 파일을 연다.


확인한 사실

1. 버전

버전은 세 층이고 서로 독립이다. 하나를 올린다고 나머지가 따라 오르지 않는다.

무엇지금 값바뀌면
경로 버전URL의 /v1v1 (public·internal 공통)호환 불가 변경. MVP1에서 올린 적 없다
문서 버전각 계약 파일 info.versionserver REST 1.0.0 · web↔server 비동기 2.1.0 · server↔ai SSE 1.1.0 · ai 내부 REST 1.1.0계약 파일을 고칠 때마다
스펙 버전OpenAPI/AsyncAPI 자체OpenAPI 3.0.1(server 생성물) · 3.1.0(ai 수기) · AsyncAPI 3.0.0(둘 다)툴체인 교체 시

server REST의 3.0.1은 선택이 아니라 생성기 산출값이다. restdocs-api-spec이 그 버전으로 뱉는다. 수기 파일(openapi3-ai.yml)만 3.1.0을 쓴다 — 두 파일의 스펙 버전이 다른 것은 의도이며, 하나로 맞추려면 생성기를 바꿔야 한다.

2. 경로

/v1/** public — 브라우저가 부른다. 쿠키 인증
/internal/v1/** 내부 — 서비스끼리만. 브라우저에 절대 노출하지 않는다
/ws/transcriptions STOMP 핸드셰이크 (버전 세그먼트 없음)
  • /internal은 양방향 대칭이다. server가 ai에 거는 요청도, ai가 server에 거는 요청도 /internal/v1/**이다. 방향은 경로가 아니라 누가 호출자인가로 갈린다.
  • public 스펙에 /internal 3경로가 함께 들어 있다openapi3-server.yml은 server가 받는 모든 경로의 생성물이라 ai가 부르는 경로도 포함한다(37경로 중 3개). web은 이 3경로를 지운 사본으로 클라이언트를 만든다 → server-web.md.
  • STOMP 목적지에는 /v1이 없다. 요청 prefix /app, 브로커 /queue, user prefix /user이며 버전은 AsyncAPI 문서 버전으로만 관리한다.

3. 식별자 — TSID와 네 가지 예외

외부에 노출되는 도메인 식별자는 전부 13자리 TSID 문자열이다. 패턴 ^[0-9A-HJKMNP-TV-Z]{13}$ (Crockford Base32 — I·L·O·U 없음). 숫자로 직렬화하지 않고 auto-increment 내부 id를 계약에 노출하지 않는다.

적용 대상: workspaceId projectId noteId sessionId chatId analysisId approvalId invitationId notificationId.

예외가 넷 있고, 넷 다 이유가 다르다.

예외형식누가 만드나왜 TSID가 아닌가
replyId (STOMP)UUID브라우저 — connect SEND의 reply-id 헤더서버가 발급하기 전에 구독 목적지가 필요하다. SUBSCRIBE가 connect보다 먼저 등록돼야 해서 client가 정한다
messageId (SSE)uuid4 hexheymoa-aimessage_start에서 발급도메인 엔티티가 아니라 스트림 내부 id다. server가 저장하는 메시지 row의 id와 별개이고, 계약도 type: string으로만 두었다(chatId와 달리 TSID 스키마를 $ref하지 않는다)
toolCallId (SSE)불투명 문자열 (call_01 등)heymoa-ai — LLM 도구 호출 id마찬가지로 스트림 내부의 짝 맞추기 키다. server는 이 값으로 tool_call_result를 시작 이벤트에 귀속시키기만 한다
provider (경로)enum LINEAR GITHUB계약이 고정식별자가 아니라 값 집합이다

messageIdtoolCallId는 같은 이유로 예외다 — 둘 다 ai가 만들고 스트림이 끝나면 쓸모가 없다. 반대로 approvalId는 ai가 만들지만 server가 row로 영속화하고 web이 승인 API 경로에 실어 보내므로 도메인 식별자이고 TSID여야 한다(바로 아래).

approvalId는 예외가 아니다 — ai가 발급하지만 13자 TSID여야 한다. 형식이 다르면 server가 승인 row 등록을 건너뛰고, web에는 카드가 뜨는데 승인 API가 404가 되어 300초 대기 후 REJECTED로 끝난다. 조용히 깨지는 경로라 계약이 명시로 못박아 두었다(asyncapi-server-ai.yml receiveAgentChatEvents).

toolCallId가 시작 이벤트와 결과 이벤트에서 어긋나면 도구 이름이 unknown으로 남는다 — 실패하지 않고 기록만 상한다.

4. 인증 — 경계마다 다르고, 한 곳은 비어 있다

구간방식성격
web → server (REST·STOMP·SSE 전부)access_token / refresh_token HttpOnly 쿠키Authorization 헤더를 쓰지 않는다. 세 프로토콜이 같은 쿠키를 탄다
ai → server /internal/**X-Internal-Token 공유 시크릿필터가 경로 단위·기본 차단이라 새 internal 경로가 자동 보호된다 (APP-122)
server → ai없음네트워크 격리에 의존. 비대칭이며 계약이 스스로 미결로 등재했다 → server-ai.md

공유 시크릿은 양쪽에 같은 값이어야 한다. server의 INTERNAL_API_TOKEN과 ai의 INTERNAL_TOKEN이 같은 값을 가리키며, 한쪽만 회전시키면 ai→server 호출이 전부 401이 되고 분석 결과가 조용히 저장되지 않는다.

5. 오류

봉투가 경계마다 다르다. 하나로 통일되어 있지 않으니 옮겨 쓸 때 변환이 필요하다.

경계봉투정본
web ↔ serverAppResponse{success, data, error{code, message, details}}. 실패도 이 형태이며 success: falseopenapi3-server.yml
server ↔ ai (REST){code, message} 단순 형태openapi3-ai.yml ErrorResponse
SSE 스트림 안상태 코드가 아니라 이벤트로 알린다 (아래)asyncapi-web-server.yml

세 가지 규칙:

  1. 사용자에게 보일 한국어 문구는 서버가 소유한다. error.message가 그 문구이고, 클라이언트가 코드별 문구를 다시 만들면 서버가 바뀔 때 갈라진다. 코드로 분기할 때만 error.code를 본다.
  2. SSE 안에서는 상태 코드를 쓸 수 없다. 헤더가 이미 나갔기 때문이다. 그래서 실패가 두 성격으로 갈린다 — 도구 실패는 tool_call_result{status: error}로 나가고 스트림이 계속되며, 치명 오류만 error 이벤트로 스트림을 끝낸다. 이 구분이 유일한 신호다.
  3. ai → server 승인 재개는 상태 코드 셋만 의미가 있다. server는 2xx를 성공, 404를 "대기 중인 승인 없음"(만료 포함)으로 해석하고 그 외 전부를 500으로 바꾼다. 재개 불가 상황은 반드시 404로 표현한다 — 400을 돌려줘도 사용자는 500을 본다.

6. 멱등성과 동시성

대상키 · 장치누가 지키나
분석 요청analysisId — 같은 값 재요청에 ai는 LLM을 다시 돌리지 않고 보관 결과를 재발송, server는 완료 잡의 중복 callback을 무시양쪽
분석 잡 유실워치독 1분 주기 / 10분 타임아웃 → 재디스패치 → 재실패 시 FAILEDserver 단독
도구 승인PENDING → RESOLVING 원자적 claim. 이 전이에 성공한 요청만 ai를 호출한다 (더블 클릭·중계 중 만료 배제). 확정은 스트림의 tool_approval_resolved가 한다server
승인 재개이미 확정된 approvalId 재호출은 새 실행을 만들지 않는다. 대기 중이 아니면 404ai
공유 챗 동시 입력입력 잠금 1명. 회의 ACTIVE 재검증 + 승인 claim을 노트 행 락 하나의 트랜잭션으로 묶는다. ai 호출은 그 락 밖 — 외부 HTTP를 락 안에 두면 노트가 그만큼 잠긴다server

"접수했다"와 "확정됐다"를 섞지 않는다. 분석의 202, 승인 API의 204는 둘 다 중계했다는 뜻이다. 확정의 단일 출처는 각각 callback과 스트림 이벤트다.

7. 비밀정보

  • toolCredentials는 요청 스코프 메모리에만 존재한다. DB·파일·checkpoint·로그 저장 금지이며 server·ai 양쪽이 로그를 마스킹한다.
  • ai는 이를 LangGraph runtime context로만 주입한다. graph state는 물론 configurable도 금지 — 후자는 스칼라여도 checkpoint metadata로 복사돼 영속화된다.
  • 장기 크리덴셜(OAuth refresh token)은 server에만 있다. 암호화해 보관하고 요청마다 단기 access token만 발급해 넘긴다. 토큰 재발급 내부 API는 만들지 않았다 — 도구가 401을 받으면 그 호출만 실패로 처리하고, 다음 메시지 때 새 토큰이 가면 자연 복구된다.
  • 계약 파일에는 비밀값도 실제 도메인도 적지 않는다. 예시 서버는 placeholder이고 운영값은 각 서비스의 환경변수다.

8. 시간과 SSE 종료

모든 시간 상수는 한 축에 정렬돼 있다. 짧은 쪽이 긴 쪽보다 먼저 끊으면 안 된다.

상수구간깨지면
ai keepalive comment≤ 15초ai → serverserver 유휴 60초에 걸려 승인 대기 중 스트림이 끊긴다
server keepalive comment≤ 20초 (구현 10초)server → web프록시 유휴 타임아웃이 승인 대기보다 먼저 브라우저 연결을 끊는다
upstream 유휴 read timeout60초 (행 간격 기준)server가 ai 스트림에 건다— 정상 수명 제어의 축
승인 대기 상한300초ai의 interrupt만료되면 REJECTED로 스트림 정상 종료
SSE 절대 상한30분server → web emitter좀비 방지용. 300초보다 충분히 커야 한다
분석 워치독1분 주기 / 10분 타임아웃serverRUNNING 초과 잡을 재디스패치
승인 재개 read timeout10초server → aiai는 도구 실행을 기다리지 말고 즉시 응답해야 한다

두 구간의 keepalive는 독립이다. server는 upstream 입력과 무관하게 자기 타이머로 발행하고, ai의 comment는 server가 수신 시점에 소비하므로 web으로 흘러가지 않는다. upstream이 20초 넘게 조용해도 브라우저 연결은 산다.

종료 이벤트는 둘, 종료 경로는 셋이다.

  • ai → server 구간에서는 세 번째가 계약 위반이다. ai는 반드시 message_end 또는 error로 끝내야 하고, 그렇지 않으면 server가 실패로 처리한다. message_end.content가 빈 문자열이어도 실패다.
  • server → web 구간에서는 세 번째가 정상 시나리오다. 동시 스트림 상한 초과(즉시 거부), upstream 유휴 60초 초과, 입력 잠금 상실 때 일어나며 web은 이 경우를 반드시 처리해야 한다. 처리하지 않으면 화면이 영원히 로딩이다.
  • 부분 응답은 어느 경로에서도 저장되지 않는다. 절반 쓰다 만 답변이 히스토리에 남지 않는다.
  • server는 종료 이벤트를 처리한 즉시 읽기를 멈춘다 — ai가 message_end 뒤 연결을 열어 둬도 무방하다.

미결

여기부터는 확인한 사실이 아니라 아직 정해지지 않은 것이다.

  1. server → ai 인증 공백 — 코드와 계약이 일치하지만(양쪽 다 "헤더 없음") 계약 파일 스스로 미결로 표시했다. 대칭으로 갈지 네트워크 격리로 남길지 정하고, 양쪽을 같은 변경으로 맞춰야 한다. 서버만 먼저 고치면 모든 호출이 401이 된다. 상세: server-ai.md.
  2. chatKind 필드가 계약에는 있고 요청에는 없다openapi3-ai.yml이 "현재 heymoa-server는 이 필드를 보내지 않는다(요청 DTO에 없다)"고 적어 두었고, ai가 받는 값은 항상 기본값 personal이다. 공유 챗봇 profile이 선택되지 않는다는 뜻이며 APP-172로 MVP2에 이월됐다. 원장: pm/2-product/open-issues.md.
  3. keepalive 간격 표기가 문서마다 다르다agent-chat-flow.md의 "스트림 수명" 행이 keepalive(30초 이하)로 적혀 있으나, 같은 문서의 "heartbeat" 행과 asyncapi-server-ai.yml15초 이하다. 이 문서는 15초를 따랐다. 계약 파일 쪽 표기를 정정하는 편이 낫다.
  4. 오류 봉투가 두 종류인 것이 의도인지 미확인 — web↔server는 AppResponse, server↔ai는 {code, message}다. 내부 경계를 단순하게 둔 선택으로 보이나 근거가 문서화돼 있지 않다.

이어서 볼 것어디
web ↔ server 경계 상세server-web.md
server ↔ ai 경계 상세server-ai.md
계약 파일을 고치기 전에api-registry.md
스키마·필드·enum 값../contracts/