AA · 확장 후 — 애플리케이션 구조
갱신일 2026-08-12 · 기준: 확장 개요 · 현재 판 AA 이 문서가 갖는 것: 서비스 경계, 의존 방향과 그 강제 장치, 모듈 배치, 회의 종료 파이프라인의 상태 기계, 계약 사슬. 여기 없는 것: 화면(IA) · 프로토콜과 인증(SA) · AWS 리소스(CA) · 저장소 소유권과 삭제 정책(DA) · 물리 스키마(ERD) · 그래프 스키마(온톨로지) · 실시간 게이트(실시간).
⚠︎ 이건 계획이지 현황이 아니다. 지금 서 있는 것은 현재 판 AA가 갖는다.
1. 서비스 다섯 — 왜 이 선에서 갈랐나
경계를 가르는 기준은 도메인이 아니라 "따로 껐다 켰다 배포할 이유가 있나" 하나다.
| 서비스 | 가른 이유 | 그래서 지는 제약 |
|---|---|---|
| web | 브라우저 능력(마이크·AudioWorklet)이 필요하다 | 영속 저장소를 두지 않는다 |
| server | 권한 판정과 도메인 상태가 한 곳에 있어야 감사와 잠금이 성립한다 | public edge · 오디오 릴레이 · 파이프라인 오케스트레이션을 혼자 진다 |
| ai | 초 단위로 느리고 실패율이 다르다 | 202로 끊고 callback. 무상태(D13) |
| speech | GPU · 분 단위 배치 · 평소 0대. 수명이 다른 넷과 다르다 | 도메인을 모른다. 오디오 in, 구간·임베딩 out |
| mcp | 기계가 부른다. 호출 패턴·인증·폭주 양상이 사람과 다르다 | DB에 안 붙는다. server REST만 쓴다 |
| 버린 대안 | 왜 버렸나 |
|---|---|
| speech를 ai에 넣기 | ai는 상시 떠 있는 IO 대기, speech는 간헐 GPU 점유다. 합치면 GPU 값을 24시간 낸다. 그리고 45분 오디오 배치 하나가 같은 프로세스의 채팅을 굶긴다 — ai는 초 단위 SLO, speech는 분 단위다 |
| mcp를 server에 넣기 | 기계 클라이언트는 루프를 돈다. 같은 프로세스면 MCP 폭주가 회의 중인 사람의 WebSocket을 끊는다. 격리가 이 서비스의 존재 이유라서 격리를 빼면 서비스도 뺀 것과 같다 |
전사 릴레이는 아직 server 안에 둔다
오디오가 브라우저 → server → Soniox 로 흐른다(D1). 릴레이를 지금 떼지 않는 이유는 하나다 — 1만 구간에서는 인스턴스 수로 흡수된다. 동시 50 스트림 = 3.2MB/s, IO 바운드다.
분리 조건을 수치로 박아둔다. 하나라도 걸리면 heymoa-transcription 을 뗀다(heymoa-transcription-worker 저장소가 이미 있다).
| # | 조건 | 수치 | 왜 이 수치 |
|---|---|---|---|
| 1 | 인스턴스당 동시 스트림 | 50 초과 | sendBufferSize 4MB × 50 = 200MB. 버퍼가 도메인 작업량보다 커지는 지점 |
| 2 | 릴레이가 REST를 민다 | REST p99가 릴레이 없는 시각 대비 2배, 5분 지속 | 한 프로세스에 둔 대가가 사용자에게 보이기 시작하는 선 |
| 3 | egress | 월 3TB 초과 | 1만(0.95TB)과 10만(9.5TB) 사이. 릴레이 노드를 대역폭 기준으로 따로 고를 값어치가 생긴다 |
세 값 다 추정이다. 1번은 확장 개요 14번(릴레이 실측)이 들어오면 갈아엎는다. 조건을 수치로 안 적으면 "언젠가"가 된다.
2. 의존 방향 — 규칙 다섯
문서로 지키는 규칙은 6개월 안에 깨진다. 규칙마다 설정·크레덴셜·스키마 중 하나로 강제한다. 셋 다 안 되면 규칙을 포기하고 포기했다고 적는다.
| # | 규칙 | 강제 장치 | 종류 |
|---|---|---|---|
| 1 | web은 /internal/** 를 모른다 | openapi3-server.yml 미러에서 /internal 경로를 빌드 태스크가 지운다. 지금은 수작업이다(현재 판 AA §7) | 설정 |
| 2 | ai는 refresh token을 갖지 않는다 | ai 태스크 role에 토큰 저장소 접근이 없다. runtime context로 단기 토큰만 내려간다 | 크레덴셜 |
| 3 | speech는 도메인을 모른다 | openapi3-speech.yml 요청 스키마에 noteId·workspaceId·userId 필드가 없다. jobId · 오디오 URL · sampleRate 뿐 | 스키마 |
| 4 | mcp는 DB에 직접 붙지 않는다 | mcp 태스크에 DB 시크릿을 주지 않고, 보안그룹에서 RDS·Neo4j 경로를 막는다 | 크레덴셜 + 네트워크 |
| 5 | ai는 Neo4j를 읽기만 한다 | Neo4j에 ai 전용 읽기 전용 롤 계정을 만든다 | 크레덴셜 |
3번이 제일 강하다. 필드가 계약에 없으면 코드가 못 쓴다. 그리고 새 규칙 넷은 인프라 층에서 막히므로 CI가 없어도 성립한다 — heymoa-server의 CI가 과금 문제로 멈춰 있는 상태에서 이게 중요하다. 현재 판의 비대칭(ai만 import-linter, 나머지는 문서와 리뷰)이 여기서 뒤집힌다.
3. 회의 종료 파이프라인 — 단일 잡에서 상태 기계로
지금 분석은 한 방이다: 잡 생성 → ai 디스패치 → callback 영속화 → 워치독. 단계가 다섯이 되면(D10)이 패턴이 안 늘어난다.
3.1 상태값
단계 행 하나에 상태 하나. 파이프라인 전체 상태는 저장하지 않고 단계 행에서 파생한다 — 두 곳에 쓰면 반드시 어긋난다. 단계 코드와 실행 주체는 3.3에 있다.
상태는 다섯뿐이다. CANCELED 를 따로 두지 않고 SKIPPED + skipReason 으로 쓴다 — 노트 삭제(S6)도 "남은 단계 SKIPPED(note_deleted)" 다. enum 하나보다 사유 문자열 하나가 싸다.
3.2 멱등 키 — 재시도와 늦은 callback을 같은 장치로 막는다
| 무엇 | 키 | 막는 것 |
|---|---|---|
| 디스패치 | PgSingleFlight advisory lock (noteId, stage) — 새 장치가 아니다. 지금 쓰는 것의 키만 늘린다 | 두 인스턴스가 같은 단계를 동시에 큐에 넣는 것 |
| callback 수용 | stageRunId — 시도마다 새로 발급하는 TSID | 죽은 시도가 뒤늦게 보낸 callback. 현재 행의 값과 다르면 버린다 |
| ① 오디오 | S3 객체 키 audio/{noteId}/{sessionId}.pcm | 멀티파트 완료를 두 번 불러도 같은 객체 |
| ② 결과 쓰기 | (noteId, stageRunId) 로 기존 화자 구간을 지우고 다시 쓴다 | 재시도가 구간을 두 배로 만드는 것 |
| ⑤ 투영 | noteId 기준 MERGE | 그래프 중복 노드 |
3.3 단계별 재시도와 타임아웃
| 단계 | 코드 | 주체 | 시도 상한 | 백오프 |
|---|---|---|---|---|
| ① 오디오 확정 | AUDIO_FINALIZE | server | 3 | 10s · 30s |
| ② 화자 분리 | DIARIZE | speech (GPU) | 3 | 30s · 2m · 8m |
| ③ 화자 매칭 | SPEAKER_MATCH | server | 3 | 5s 고정 |
| ④ 요약 | SUMMARIZE | ai | 3 | 30s · 2m |
| ⑤ 그래프 투영 | GRAPH_PROJECT | server | 5 | 1m · 5m · 15m · 1h |
타임아웃 값은 이 표에 없다. 단계별 타임아웃은 시간 상수라 SA §8이 갖는다. 여기는 시도 상한과 백오프만 갖는다. ② 만 값이 아니라 식(오디오 길이 × 계수)인 이유는 하나다 — 고정값은 2시간 회의에서 무조건 틀린다. 계수는 미확인(§10)이다.
⑤ 의 상한이 제일 후하고 백오프가 제일 긴 이유: 파생이라 늦어도 손해가 없고, 재시도 폭풍이 정본 쓰기를 밀면 안 된다. ④ 는 사용자가 화면에서 기다리므로 빨리 포기하고 실패를 보여주는 편이 낫다.
Spot 중단은 시도 횟수에 세지 않는다. 우리 잡의 결함이 아니다. 재큐는 SQS가 하고(§4), attempts 는 그대로 둔 채 interrupt_count 만 올린다(ERD §4). 무한 루프만 막으려고 중단 재시도에 별도 상한 5회를 둔다. 상한값은 추정이다.
DLQ로 간 메시지도 단계 행을 내린다. 아무도 안 내리면 행이 RUNNING 에 남아 워치독 타임아웃까지 방치되고, 그동안 화면은 "누가 말했는지 정리 중"을 계속 보여준다.
maxReceiveCount 5(CA §7)와 여기 시도 상한 3은 다른 축이다 | 앞은 인프라 재전달 횟수, 뒤는 잡 실패 횟수다. 같은 수로 맞추지 않는다 — 맞추면 Spot 세 번이 잡 실패 세 번으로 보인다 |
| DLQ 소비자를 둔다 | 해당 단계를 FAILED(dlq) 로 내리고 last_error_code 에 receiveCount 를 남긴다. 인프라가 몇 번 다시 줬는지가 사후에 필요한 유일한 숫자다 |
| 후속 단계는 3.4를 그대로 탄다 | ② 가 FAILED(dlq) 면 ③ 은 SKIPPED(no_segments), ④ 는 화자 없이 돈다. DLQ 전용 분기를 따로 만들지 않는다 |
3.4 건너뛰기 — 의존 하나만 약하다
| 선행 → 후속 | 강도 | 실패 시 |
|---|---|---|
| ① → ② | 강함 | ①FAILED → ②③ SKIPPED(no_audio) |
| ② → ③ | 강함 | ②FAILED → ③ SKIPPED(no_segments) |
| ②③ → ④ | 약함 | 화자 없는 요약이 요약 없는 것보다 낫다 |
| ④ → ⑤ | 강함 | ④FAILED → ⑤ SKIPPED(no_items) |
약한 의존에는 대기 상한이 붙는다. ④ 는 ②③ 을 최대 15분(추정) 기다리고, 넘으면 화자 없이 시작하면서 ②③ 을 SKIPPED(wait_timeout) 로 내린다. ②③ 이 늦게 끝나도 ④ 를 다시 돌리지 않는다 — 재요약은 사용자가 버튼으로 명시할 때만. 사람이 이미 읽은 요약이 소리 없이 바뀌는 것은 기능이 아니라 버그로 신고된다.
조건부 건너뛰기 하나 더: 조직에 voice print 등록자가 0명이면 ③ 을 호출하지 않고 SKIPPED(no_voiceprint). 대조할 대상이 없는데 대조를 부르지 않는다.
3.5 부분 커밋 — 결과와 단계 상태를 같은 트랜잭션에 쓴다
| 커밋 지점 | 쓰는 것 | 화면에 나타나는 것 |
|---|---|---|
| ① 후 | 오디오 객체 키 · 길이 | — |
| ② 후 | 화자 구간 | 화자 1~4 가 전사에 붙는다 |
| ③ 후 | 세그먼트의 화자 라벨 (APP-233이 여는 축) | 이름이 붙는다 |
| ④ 후 | 요약 · 결정 · 액션 아이템 (정본) | 요약 카드 |
| ⑤ 후 | 그래프 투영 (파생) | 연결된 과거 |
결과만 커밋되고 상태가 RUNNING 에 남으면 워치독이 다시 돌리고, 그러면 3.2의 멱등 키가 유일한 방어선이 된다. 방어선을 하나로 두지 않는다.
3.6 S1을 관통시키면
2026-08-12 10:45, 민손 팀 / PRO-1 실시간 STT 회의가 끝난다.
| 시각 | 단계 | 일어나는 일 |
|---|---|---|
| 10:45:02 | ① SUCCEEDED | 릴레이가 이미 멀티파트로 쌓고 있었으므로 완료 호출만. 45분 · 약 86MB(Int16 PCM · CA §8) |
| 10:45:03 | ② PENDING | 큐 적재. GPU 태스크 기동 대기 |
| 10:49 | ② SUCCEEDED | 화자 4명 검출 → 화면에 화자 1~4 |
| 10:49 | ③ SUCCEEDED | 김민수·이서연·박준호 매칭. 최유진은 후보 없음 → 화자 4 유지 |
| 10:52 | ④ SUCCEEDED | 요약 · 결정 2건 · 액션 아이템 3건 |
| 10:52 | ⑤ SUCCEEDED | Neo4j 투영. SUPERSEDES 후보 1건(미확인) |
| 11:10 | — | 김민수가 화자 4 → 최유진 교정. 파이프라인을 다시 돌리지 않는다 |
마지막 줄이 설계다. 교정은 speaker 모듈의 UPDATE 이지 파이프라인 재실행이 아니다. 교정 하나가 GPU 잡을 부르면 사람이 교정을 안 하게 된다.
오케스트레이터는 server 다. speech·ai가 다음 단계를 부르면 파이프라인 지식이 세 저장소에 흩어지고, 순서를 한 번 바꾸는 데 세 저장소를 고쳐야 한다. speech·ai는 자기 일만 하고 callback으로 돌려준다 — 지금 analysis 가 하는 모양 그대로이고 단계 수만 늘었다. 워크플로 엔진(Temporal·Step Functions)은 들이지 않는다. 단계가 열을 넘거나 사람 승인이 중간에 끼면 그때 다시 본다.
4. S4 — Spot 중단을 파이프라인으로 추적한다
10:45 정각, 12개 회의가 동시 종료(1만 규모 피크). 그중 7번 잡이 Spot 중단을 맞는다.
| 시각 | 7번 잡 | 무슨 일 |
|---|---|---|
| 10:45:03 | ② PENDING | 12개 전부 큐에. speech 태스크 0대 |
| 10:45:10 | ② PENDING | 태스크 2개 기동. 콜드스타트 4분 |
| 10:49:20 | ② RUNNING · 시도 1 · stageRunId=A | 태스크 B가 집어감 |
| 10:51:40 | ② RUNNING | Spot 2분 경고. speech가 SIGTERM을 받는다 |
| 10:51:41 | ② PENDING · 시도 1 유지 | speech가 처리 중 메시지에 ChangeMessageVisibility(0) 을 걸어 큐로 즉시 돌려보내고 죽는다(CA §7). INTERRUPTED callback도 같이 보내지만 그건 계측·화면용이고, 재큐는 그걸 안 기다린다 |
| 10:51:45 | ② RUNNING · stageRunId=B | 살아 있는 다른 태스크가 집어간다. 콜드스타트를 다시 겪지 않는다 |
| 10:55 | ② SUCCEEDED → ③ | 사용자 화면은 그동안 "누가 말했는지 정리 중" |
| 10:58 | ④ SUCCEEDED | 대기 상한 15분 안(10:45+15). 요약은 예정대로 나온다 |
여기서 실제로 일하는 장치는 셋이다. stageRunId — 태스크 B가 죽기 직전 결과를 늦게 보내도 현재 행은 stageRunId=B 라 A의 callback이 버려진다. Spot 중단을 시도로 안 세는 것 — 안 그러면 Spot 세 번에 사용자 잘못 없이 FAILED 가 난다. ④ 의 15분 대기 상한 — 재시도 한 번은 흡수하고 두 번 넘게 밀리면 화자 없이 요약을 내보낸다.
ChangeMessageVisibility(0) 조차 못 부르고 죽을 수 있다. 2분 경고 없이 사라지거나 네트워크가 먼저 끊기는 경우다. 그러면 visibility timeout이 만료되며 SQS가 같은 메시지를 다시 주고, 그것도 안 되면 워치독이 타임아웃에서 잡는다. 느린 경로가 있으므로 빠른 경로는 최적화이지 정확성 조건이 아니다. INTERRUPTED callback은 어느 경로에도 안 낀다 — 화면에 "재시도 중"을 띄우고 interrupt_count 를 세는 용도다.
5. 워치독 — 인스턴스마다 도는 문제
AnalysisWatchdogScheduler·ToolApprovalWatchdogScheduler 는 1분 주기다. 앱이 2대가 되는 순간 둘 다 돈다. 잡 중복은 PgSingleFlight 가 막지만 틱 자체가 중복이고, 대가는 셋이다 — DB 스캔이 인스턴스 수만큼, 타임아웃 판정이 경합, 실패 알림이 N 배.
해법: 틱 진입에 pg_try_advisory_lock(<스케줄러별 고정 키>). 잡으면 돌고 못 잡으면 그 틱을 건너뛴다. 끝나면 푼다.
리더를 뽑지 않고 틱마다 경합시킨다. 리더 상태를 저장하지 않으므로 임차 갱신도 만료 처리도 없고, 상태가 없으면 틀릴 것도 없다. 락을 쥐고 있지 않으므로 리더 태스크가 좀비여도 아무도 못 도는 상태가 안 된다. 세션 락이라 태스크가 죽으면 커넥션과 함께 풀린다 — 별도 정리가 없고 새 장치도 없다.
S5 — 14:00 배포 중이면: 리더였던 태스크가 종료되면서 락이 풀리고 다음 틱(≤60초)에 다른 태스크가 잡는다. 최악 60초 공백이고 워치독 타임아웃은 분 단위라 영향이 없다. 진행 중 회의 3건의 스트림은 워치독과 무관하다.
| 버린 대안 | 왜 |
|---|---|
| ShedLock 도입 | advisory lock이 이미 있는데 의존을 하나 더 늘린다 |
| 스케줄러 전용 인스턴스 1대 | 배포 형상이 갈리고 그 1대가 SPOF가 된다 |
| EventBridge Scheduler → ECS task | 인프라로 나가면 로컬에서 안 돈다. 스케줄이 코드에서 사라진다 |
6. 모듈 — 어디에 무엇이 사나
| heymoa-server | 신규 | 소유하는 것 |
|---|---|---|
transcription | 전사 세션 수명주기 · 세그먼트 · 오디오 릴레이 + S3 멀티파트 기록 | |
pipeline (← analysis) | 개명 | 단계 상태 기계 · 디스패치 · callback 수용 · 워치독 (§3) |
speaker | 🆕 | 화자 구간 · 매칭 판정 · 사람 교정 · 노트 스코프 이름표 · voice print 등록/삭제 |
graph | 🆕 | Postgres → Neo4j 투영과 재생성. 그래프 쓰기는 여기서만 — 온톨로지 |
apitoken | 🆕 | PAT 발급 · 범위 · 만료 · 폐기 · 사용량 집계 |
agentchat | 채팅 영속화 · ai SSE passthrough · 도구 승인 상태 기계 | |
realtimeagent | 🆕 | 30초 틱 · 싼 게이트 · ai 호출 · 개입 카드 영속화 |
transcription 과 pipeline 이 갈리는 선은 회의가 끝나는 시각이다. 그 전은 스트림, 그 후는 배치다. 재시도 모델도 지연 예산도 다르다.
| heymoa-ai | 소유 | heymoa-speech 🆕 | 소유 | heymoa-mcp 🆕 | 소유 |
|---|---|---|---|---|---|
analysis | 요약·추출. 화자를 입력으로 받는다 | diarize | pyannote 실행 — 구간만 | tools | 도구 정의 · 응답 모양 변환 |
chat | 채팅 그래프 | embed | 구간별 화자 임베딩 | auth | PAT 검증(server 위임) · 범위 강제 |
retrieval 🆕 | 벡터 검색 + 그래프 순회를 한 곳에서 | — | 둘뿐이다. 대조 모듈을 두지 않는다 | limit | 토큰별 분당·일당 상한 |
retrieval 을 쪼개지 않는 이유: 벡터 경로와 그래프 경로가 흩어지면 같은 질문에 답의 근거가 갈린다.
매칭 임계값을 GPU 워커에 넣지 않는 이유
③ 대조는 server의 speaker 가 한다. speech가 주는 것은 구간별 임베딩뿐이다. speaker 가 그 임베딩을 조직 스코프 voice print와 대조해 { segmentId, candidates: [{ voicePrintId, score }] } 를 자기 안에서 만들고, 누구인지도 거기서 정한다. voice print를 읽는 경로는 그 하나뿐이다(DA §3).
| 왜 | |
|---|---|
| 임계값은 제품 판단이다 | "틀린 이름을 붙이는 것"과 "이름을 안 붙이는 것" 중 무엇이 덜 나쁜가는 신호 처리 문제가 아니다 |
| 배포 단위가 다르다 | 값이 GPU 이미지 안에 있으면 숫자 하나 바꾸는 데 이미지를 다시 굽는다. 배포가 필요한 값은 굳는다 |
| 규칙 3이 깨진다 | 판정하려면 조직·사용자를 알아야 하고, 그 순간 speech가 도메인을 안다 |
speaker 는 임계값 둘을 프로퍼티로 갖는다 — τ_auto(넘으면 자동 지정), τ_suggest(넘으면 후보 제시, 사람이 고름). 둘 다 못 넘으면 화자 N 으로 남는다. 값은 미확인이다(§10).
- S1 — 최유진은 voice print 미등록이라 후보 자체가 안 나온다.
- S2 — 게스트 2명도 후보가 없다. 김민수가
화자 5 = 이정훈(외부)를 지정하면speaker가 노트 스코프 이름표로 쓴다. voice print를 만들지 않으므로 다음 회의에 따라오지 않는다. - S7 — 최유진이 voice print를 지우면
speaker가 임베딩을 즉시 지우고 이후 ③ 에서 후보로 내지 않는다. 과거 노트의 라벨은 남는다 — 그건 파이프라인 산출물이지 voice print가 아니다. 저장소별 삭제 사슬은 DA가 갖는다.
실시간 에이전트가 server에 붙는 이유
D13 — ai는 무상태를 유지한다. 회의 세션 상태(최근 창·마지막 틱 시각·이미 띄운 카드)를 ai 프로세스에 두면 인스턴스 고정이 필요해지고 재시작이 안전하다는 성질이 깨진다. 세그먼트는 이미 server에 있으므로 30초 틱이 새 스트림 소비자를 만들지 않고, 카드는 회의 후 요약이 회수하는 도메인 데이터다.
| 버린 대안 | 왜 |
|---|---|
| ai에 회의 세션 액터를 둔다 | 인스턴스 고정 필요. 재시작에 카드와 창이 날아간다. D13 위반 |
heymoa-agent 서비스를 판다 | 세그먼트를 다시 스트리밍해야 하고 경계 하나에 계약 하나가 붙는다. 여섯 번째 서비스를 만들 이유가 없다 |
| web에서 틱을 돈다 | 클라이언트가 LLM 게이트를 정하면 원가와 개입 정책이 브라우저에서 위조된다 |
게이트 정밀도와 비용은 실시간이 갖는다.
운영 API 넷 — pipeline 이 갖는다
자동으로 못 낫는 경로가 넷 있다. 콘솔과 psql로 하면 누가 무엇을 되돌렸는지가 안 남으므로 pipeline 모듈에 API로 박는다.
| 경로 | 무엇을 한다 |
|---|---|
POST /internal/v1/ops/pipeline/{noteId}/retry?stage= | 단계 하나를 PENDING 으로 되돌린다. 새 stageRunId 를 발급하므로 옛 시도의 callback은 버려진다 |
POST /internal/v1/ops/dlq/redrive | DLQ 메시지를 원 큐로 되돌린다 |
POST /internal/v1/ops/outbox/{id}/reprocess | 세워둔(parked) 투영 outbox 행 하나를 다시 태운다 |
POST /internal/v1/ops/graph/reproject?workspaceId= | 워크스페이스 단위 재투영(온톨로지 §6) |
전부 ADMIN + 감사 이벤트다. 그리고 화면을 안 만든다 — 운영자 화면을 만들면 권한 모델이 하나 더 생기고, 분기에 몇 번 누르는 버튼에 그 값을 낼 이유가 없다. 언제 무엇을 누르는지는 런북(CA §12)이 갖는다.
기능 플래그 — 축 셋과 평가 시점
세 문서가 이미 존재를 전제로 쓰여 있다 — 실시간 §10 "기능을 조직 단위로 끌 수 있어야 한다", IA §8 조직 정책 화면, CA §16 원가와 요금제. 소유는 여기다.
| 축 | 누가 끄나 | 왜 있어야 하나 |
|---|---|---|
| 전역 kill | 우리 | LLM 공급자가 죽거나 원가가 튀면 즉시 끈다 |
| organization 정책 | 조직 관리자 (/o/{org}/policy) | 생체정보와 국외 이전을 조직이 거부할 수 있어야 한다 |
| 워크스페이스 코호트 | 우리 | 새 기능을 몇 팀에만 먼저 켠다 |
대상은 다섯이다 — voice_print_matching · realtime_agent · graph_projection · mcp_edge · summary_pii_names. 더 늘리지 않는다. 스무 개가 되면 어떤 조합이 실제로 도는지 아무도 모르게 되고, §8이 검증할 수 있는 형상도 아니게 된다.
평가는 회의 시작 시 1회, 그 회의 내내 고정한다. 중간에 값이 바뀌면 앞 20분은 카드가 뜨고 뒤 25분은 안 뜬 회의가 생긴다. 그 회의의 요약 입력이 반쪽이 되고, 나중에 "왜 이 회의만 카드가 없나"에 답할 방법이 없다. 그래서 회의 시작 시점의 값을 노트에 박아 둔다.
저장은 Postgres(ERD), 캐시 30초. LaunchDarkly 류를 들이지 않는다 — 플래그 다섯에 외부 의존과 월 구독을 붙이는 값이 아니고, 그 서비스가 느려지면 회의 시작이 같이 느려진다.
7. 계약 사슬 — 서비스가 늘면 처음으로 하위호환이 필요해진다
경계마다 계약이 하나씩 붙는다.
| 경계 | 계약 | 생성/수기 | 원본 소유 |
|---|---|---|---|
| web ↔ server (REST) | openapi3-server.yml | 생성 (restdocs-api-spec) | server 코드 |
| web ↔ server (STOMP) | asyncapi-web-server.yml | 생성 + 일부 수기 | 전사·토픽은 server / 채팅 SSE는 이 파일 |
| server ↔ ai | openapi3-ai.yml · asyncapi-server-ai.yml | 수기 | ai |
| server ↔ speech 🆕 | openapi3-speech.yml | 수기 — 작고 안 변한다 | speech |
| server ↔ Soniox | 공급자 스펙 | — | 우리가 아니다 |
| mcp ↔ server 🆕 | 기존 REST 재사용 | — | 새로 만들지 않는다 |
| mcp ↔ 외부 🆕 | MCP 스펙 + 도구 스키마 | 수기 | 프로토콜은 우리가 아니고 도구 스키마는 우리다 |
mcp가 server 전용 API를 파지 않는 이유
S3 — 박준호가 IDE에서 heymoa.search_decisions(query="STOMP 브로커") 를 부른다. mcp는 PAT를 붙여 웹이 쓰는 그 검색 REST 를 부르고 응답을 도구 모양으로 바꿔 돌려준다. 결과는 Decision 인스턴스 1대 전제로 SimpleBroker 유지, 근거 2026-07-22 회의 31:07.
| 왜 | |
|---|---|
| 권한 판정이 한 벌 | 전용 API를 파면 워크스페이스 스코프 체크가 두 곳에 생긴다. 한쪽만 고치면 §2 규칙 4가 문서 안에서만 살아 있게 된다 |
| 응답 모양은 mcp가 바꾼다 | 어댑팅은 소비자 쪽에서 한다. server에 도구 전용 필드를 넣지 않는다 |
| 감사와 사용량이 한 곳 | 사람 호출과 기계 호출이 같은 경로를 지난다 |
예외는 하나: 도구 하나가 REST를 5번 왕복해야 하면 server에 조회를 추가한다. 단 MCP 전용이 아니라 일반 API 로 만든다. 웹이 쓸 수 없는 모양이면 만들지 않는다.
지금은 동시 배포로 회피하고 있다
계약이 바뀌면 세 저장소를 같이 올린다. 인스턴스가 1대라 옛 판과 새 판이 공존하지도 않는다. 서비스가 다섯이 되면 이 회피가 세 곳에서 동시에 깨진다.
| 깨지는 곳 | 왜 |
|---|---|
| 롤링 배포 중 같은 서비스의 두 판이 동시에 산다 | S5 그대로다. 옛 태스크가 진행 중 회의를 붙든 동안 새 태스크가 새 회의를 받는다. 지금은 1대라 이 문제 자체가 없었다 |
| 비동기 경계가 늘어난다 | ② 잡을 옛 판이 보내고 callback을 새 판이 받는다. S4의 Spot 재시도면 간격이 10분 이상 벌어진다 |
| mcp 클라이언트는 우리가 배포하지 못한다 | S3의 PAT는 90일 만료다. 도구 스키마를 바꿔도 그 90일 동안 옛 스키마로 부르는 IDE가 살아 있다 |
세 번째가 결정적이다. 앞의 둘은 배포창을 짧게 잡으면 완화되지만, 90일짜리 토큰을 든 외부 클라이언트는 완화할 수단이 없다.
필요한 것은 버전 번호가 아니라 하위호환 규칙 셋이다.
| # | 규칙 | 어기면 |
|---|---|---|
| 1 | 필드 추가는 optional로 시작한다 | 처음부터 필수로 넣으면 옛 발신자가 그날로 400을 맞는다 |
| 2 | 필드 제거는 두 판에 걸쳐서 — 안 쓰기 → 한 판 뒤 지우기 | 한 판에 지우면 아직 그 필드를 읽는 옛 판이 살아 있는 동안 값이 사라진다 |
| 3 | 수신 측이 모르는 enum 값에 예외를 던지지 않는다 | 새 값 하나가 옛 판 전체를 500으로 만든다. server의 ExposedEnumContractTest 가 이미 이 방향이다 |
/v2 를 파는 것은 마지막 수단이다. 경로를 가르면 두 벌을 굴려야 하고, 두 벌을 굴리면 권한 판정도 두 벌이 된다. 하위호환으로 못 가는 변경이 나오면 REST가 아니라 MCP 도구 이름에만 버전을 붙인다 — 그쪽만 우리가 통제 못 하는 클라이언트를 갖는다.
8. 무엇으로 검증하나
§7이 하위호환 규칙 셋을 정해놓고 무엇이 그 규칙을 깨뜨려 보는지가 없었다. heymoa-server의 CI는 과금 문제로 멈춰 있다(§2). 그래서 검증을 다섯으로 못박는다 — 이 다섯이 안 도는 상태로는 서비스를 다섯으로 늘리지 않는다.
| # | 무엇을 | 어떻게 | 없으면 |
|---|---|---|---|
| ① 계약 | 미러가 원본과 갈라지는 것 | openapi3-server.yml 미러 diff를 빌드 태스크가 깨뜨린다. MCP 도구 스키마는 90일 전 스키마 픽스처로 회귀 검사한다 | §7 세 번째 깨짐(90일 PAT를 든 IDE)은 여기서만 잡힌다. 배포 창을 아무리 좁혀도 안 잡힌다 |
| ② 멱등 | 같은 일이 두 번 와도 한 번 | 같은 stageRunId callback 2회 · 죽은 stageRunId callback 1회 · outbox 워커 2회 실행이 각각 no-op | 3.2의 멱등 키가 문서에만 있게 된다. 재시도가 화자 구간을 두 배로 만드는 것은 사람이 보고서야 안다 |
| ③ 단계 상태 기계 | 건너뛰기가 3.4 표대로 | ① 실패 · ② 실패 · ②③ 15분 초과 · 노트 삭제 네 조합을 표 그대로 돌린다 | 한 조합만 어긋나도 행이 RUNNING 에 남고 화면이 영원히 "정리 중"에 선다 |
| ④ 그래프 | 파생이 정본과 같은지 | 재투영 후 노드·관계 수 = decisions + action_items 집계 | D7이 무너진다. 갈라진 것을 아무도 모르는 상태가 제일 나쁘다 |
| ⑤ 게이트 정밀도 | 회의 중 개입이 맞는지 | 전사 30건 골드셋으로 intervene:true 정밀도를 잰다 | 원가와 오탐이 같이 튈 때 어느 쪽이 원인인지 못 가른다. 실시간 §13의 "싼 게이트 정밀도"가 이걸로 채워진다 |
①~④ 는 빌드에서 돌고 ⑤ 는 골드셋이라 사람이 돌린다. 정각 버스트 부하 테스트와 게임데이(Spot 강제 중단·Neo4j kill·브로커 kill) 는 환경 소유자인 CA §15가 갖는다.
9. 설계 전제
- 오케스트레이션은 server 하나가 진다. 파이프라인 지식이 흩어지면 순서 하나 바꾸는 데 세 저장소를 고쳐야 한다.
- 파이프라인 전체 상태를 저장하지 않는다. 단계 행에서 파생한다.
- 약한 의존에는 대기 상한을, 강한 의존에는 건너뛰기를 준다. 약한 것은 ②③ → ④ 하나뿐이고, 그래서 화자 없는 요약이 나갈 수 있다.
- 자동 재요약을 하지 않는다. 사람이 읽은 문서가 소리 없이 바뀌는 것은 버그다.
- 규칙은 설정·크레덴셜·스키마로 강제한다. 리뷰로 지키는 규칙은 규칙이 아니라 희망이다.
- speech와 mcp는 도메인을 모른다. 하나는 신호 처리기, 하나는 프로토콜 어댑터다. 둘 다 권한을 판정하지 않는다.
- 제품 판단은 인프라 워커에 넣지 않는다. 바꾸는 데 배포가 필요해지는 순간 그 값은 굳는다.
- mcp 전용 API를 만들지 않는다. 권한 판정을 두 벌로 만드는 가장 빠른 길이다.
10. 미확인
| 무엇 | 지금 아는 것 | 무엇이 걸려 있나 |
|---|---|---|
| pyannote 처리 시간 | 없다 | ② 의 타임아웃 계수(SA §8)와 ④ 의 15분 대기 상한. 둘 다 지금은 추정 |
| DLQ 소비자를 큐마다 두나 하나로 두나 | 큐는 단계별로 나눈다(CA §7) | 하나로 두면 메시지에서 단계 코드를 읽어야 하고, 큐마다 두면 워커가 큐 수만큼 는다 |
voice print 임계값 τ_auto·τ_suggest | 없다 | §6 매칭 판정. 틀린 이름 vs 이름 없음의 교환비가 정한다 |
| 인스턴스당 동시 스트림 상한 | 시간당 230MB 라는 것까지 | §1 분리 조건 1번의 "50" 이 근거 없는 수다 |
| REST p99 기준선 | 없다 | §1 분리 조건 2번을 판정할 방법이 없다 |
| Spot 중단 빈도 (서울 g5·g6) | 없다 | §4의 중단 재시도 상한 5회가 충분한지 |
| Neo4j 재생성 소요 | 없다 | ⑤ 가 진짜 파생인지, 실질적 정본인지가 갈린다 |
| 결정·액션 아이템 추출 정확도 | 도메인 모델이 MVP2·3로 잡아 뒀다 | 나쁘면 ⑤ 앞에 사람 승인 단계가 붙고, 그때 워크플로 엔진 판단을 다시 한다 |
SUPERSEDES 후보를 ④ 가 내나 ⑤ 가 내나 | 정해져 있지 않다 | ④ 면 LLM이, ⑤ 면 그래프 질의가 정한다. 원가와 정확도가 다르다 |
| MCP로 내보낼 범위 | 없다 | 전사 전문인지 요약만인지가 §7 도구 스키마를 정한다 |