System Architecture — HeyMoa
갱신일 2026-08-10 · 기준:
interfaces/+ Linear 운영 문서 + 세 저장소 배포 설정 이 문서가 갖는 것: 액터, 컨테이너, 외부 시스템, 경계별 프로토콜·인증, 주요 런타임 흐름, 확장 천장. 여기 없는 것: 화면(information-architecture.md) · 내부 모듈(application-architecture.md) · AWS 리소스(cloud-architecture.md).
1. 논리 구성
한 장에 다 그리면 32개 노드가 엉켜서 아무것도 안 보인다. 줌 레벨을 넷으로 나눈다 — 전체 조감 → 서비스 안 → 외부 소유권. 각 그림의 노드를 클릭하면 화살표를 따라 흐름이 퍼진다.
1.1 전체 조감 — 무엇이 무엇과 붙어 있나
①~⑤ 는 §4 경계 다섯의 번호와 같다. ④ 만 인증이 비어 있고, 지금은 보안그룹 하나가 막고 있다.
1.2 heymoa-server 안 — 도메인과 스케줄러
public edge 전부를 혼자 진다. 인증·권한·도메인 상태가 한 곳에 모여야 감사와 잠금이 성립한다.
BRK 가 확장 천장이다. enableSimpleBroker 는 JVM 내부 브로커라 스케일아웃하면 팬아웃이 조용히 반쪽 난다 → §10.
1.3 heymoa-ai 안 — 두 개의 그래프
LLM 호출은 초 단위로 느리고 실패율이 다르다. 같은 프로세스면 요청 스레드를 묶으므로 202로 끊고 callback 으로 되돌려준다.
단기 토큰은 요청 스코프 메모리에만 산다. X 로 나가는 쓰기는 server 가 그때그때 발급한 토큰으로 실행되고, ai 는 refresh token 을 갖지 않는다 → §11.
1.4 외부 시스템 — 소유자는 하나씩만
두 서비스가 같은 외부 시스템에 붙으면 크리덴셜도 둘로 늘어난다. 겹치는 것이 없다.
| 소유자 | 외부 시스템 |
|---|---|
| server | 음성(ElevenLabs) · OAuth(Google·Linear·GitHub) · 메일(SES) |
| ai | LLM(OpenAI) · 프롬프트 관측(Langfuse) |
| 둘 다 | Grafana Cloud — 텔레메트리는 수신 전용이라 크리덴셜이 갈라져도 무해하다 |
Linear·GitHub 는 연결은 server 가, 실행은 ai 가 한다. refresh token 은 server 의 tool_connection 에 암호화돼 있고, ai 는 요청마다 단기 토큰만 받아 쓴다.
2. 액터
| 누구 | 화면 | |
|---|---|---|
| 진행자 | 마이크를 켠 사람. 1명 | 녹음 조작 + 회의 종료 |
| 참여자 | 회의에 이름이 올라간 사람 | 뷰어와 같다 |
| 접속자 | 지금 노트를 열어둔 사람 | 표시하지 않는다 — presence를 만들지 않았다 |
| ADMIN | 팀 운영자 | + 초대·멤버·연동 |
presence를 만들지 않는 이유: 오프라인 회의에서 아바타 3개는 "3명이 회의 중"으로 읽히는데 실제로는 8명이 앉아 있다. 정보가 아니라 틀린 정보다.
3. 컨테이너가 넷인 이유
| 컨테이너 | 왜 따로 있나 | 그래서 갖는 제약 |
|---|---|---|
| heymoa-web | 브라우저 능력(마이크·AudioWorklet)이 필요하고 화면 수명주기가 서버와 다르다 | 영속 저장소를 갖지 않는다 |
| heymoa-server | 인증·권한·도메인 상태가 한 곳에 모여야 감사와 잠금이 성립한다 | public edge 전부를 혼자 진다 |
| heymoa-ai | LLM 호출은 초 단위로 느리고 실패율이 다르다. 같은 프로세스면 요청 스레드를 묶는다 | 202로 끊고 callback으로 되돌려준다 |
| DB 둘 | 파생 데이터가 원본과 같은 database에 있으면 "지워도 되는 것"을 구분할 수 없다 | cross-database 쿼리 금지 |
외부 시스템의 소유자는 하나씩만 — 음성(ElevenLabs)·OAuth(Google/Linear/GitHub)·메일(SES)은 server, LLM(OpenAI)·프롬프트 관측(Langfuse)은 ai. 두 서비스가 같은 외부 시스템에 붙으면 크리덴셜도 둘로 늘어난다.
4. 경계 다섯 — 프로토콜과 인증
| # | 구간 | 프로토콜 | 인증 |
|---|---|---|---|
| ① | web → server | REST /v1/** | access_token HttpOnly 쿠키 (Secure · SameSite=Lax · Domain=.heymoa.app) |
| ② | web ↔ server | STOMP over WebSocket | 쿠키 (핸드셰이크 시점) |
| ③ | web ← server | SSE (POST 응답이 text/event-stream) | 쿠키 |
| ④ | server → ai | REST /internal/v1/** + SSE 소비 | 없음 ⚠️ |
| ⑤ | ai → server | REST /internal/v1/** | X-Internal-Token |
/internal은 양방향 대칭이다. 방향은 경로가 아니라 누가 호출자인가로 갈린다.
브라우저는 heymoa-ai에 도달할 수단이 없다 — web의 생성 API 클라이언트에 /internal/**이 아예 없고, ai에는 CORS 미들웨어도 인증도 없다.
⚠️ 같은 시크릿을 양쪽이 다른 이름으로 읽는다 — server INTERNAL_API_TOKEN, ai INTERNAL_TOKEN. 회전할 때 두 이름을 같이 봐야 한다. 한쪽만 돌리면 ai→server 호출이 전부 401이 되고 분석 결과가 조용히 저장되지 않는다.
프로토콜 선택 기준 (셋은 서로 대체하지 않는다)
| 프로토콜 | 언제 |
|---|---|
| REST | 요청 하나에 응답 하나. 계약에서 클라이언트를 생성할 수 있는 유일한 구간 |
| STOMP | 클라이언트가 계속 보낸다(오디오) + 서버가 노트 멤버에게 fanout |
| SSE | 서버가 계속 보낸다. 요청 본문이 필요해 GET 기반 EventSource를 쓸 수 없다 |
오류 봉투가 경계마다 다르다
| 경계 | 봉투 |
|---|---|
| web ↔ server | AppResponse — {success, data, error{code, message, details}} (실패도 같은 형태) |
| server ↔ ai (REST) | {code, message} |
| SSE 스트림 안 | 상태 코드가 아니라 이벤트로 (헤더가 이미 나갔다) |
사용자에게 보일 한국어 문구는 서버가 소유한다. 클라이언트가 코드별 문구를 다시 만들면 갈라진다.
5. 흐름 A — 실시간 전사
| 항목 | 값 |
|---|---|
| 핸드셰이크 | /ws/transcriptions (버전 세그먼트 없음) |
| 오디오 | PCM16 · 24kHz mono = 48 bytes/ms, 배치 40–100ms 강제 |
| STOMP heartbeat | 10초 / 10초 · WebSocket 유휴 60초 · 프레임 상한 1 MiB + 16 KiB |
| 백프레셔 | STOMP 백로그 96KB 초과 시 전송 조임 |
| terminal 이벤트 | completed · error 중 정확히 하나만, 최대 한 번 |
계약이 보장하지 않는 것 3개 — commit이 final을 보장하지 않는다 / partial 없이 final만 오는 발화가 있다 / 바인딩 전에 보낸 audio·commit·stop은 오류 이벤트 없이 조용히 버려진다.
노트 토픽 /topic/notes/{noteId} — 뷰어 실시간
이벤트 9종: transcript.partial · transcript.final · meeting.started · meeting.ended · recording.started · recording.stopped · chat.token · chat.message_end · chat.lock
| 결정 | 왜 |
|---|---|
| 이벤트는 payload가 아니라 invalidate 신호다 | 예외는 transcript.partial과 chat.token뿐 (영속되지 않으므로) |
| 토픽은 별도 STOMP 연결이다 | 녹음 연결에 얹으면 구독 인가 실패의 ERROR 프레임이 연결을 끊어 진행 중인 녹음을 죽인다 |
순서는 구독 → 버퍼 → REST → segmentId로 접기 | REST → 구독이면 그 사이 final이 영구히 사라진다 (SimpleBroker에 보존 없음) |
chat.lock을 함께 싣는다 | message_end 없이 잠금 해제가 오면 = 중단. 승인 3종은 싣지 않는다 — 입력자 전용 경계를 UI 구조로 유지 |
| 인가는 default-deny | /topic은 주소만 알면 누구나 구독한다. SUBSCRIBE 시점 + 30초마다 멤버십 재검증, 노트당 구독 상한 100 |
정렬키는 (sessionStartedAt, sessionId, sequence) | sequence는 노트 스코프가 아니라 세션마다 1로 리셋된다 |
6. 흐름 B — 분석 (202 + callback)
브로커가 없다. DB row가 큐다.
경계 규칙 5개
202는 접수이지 완료가 아니다. 같은analysisId재요청은 새 작업을 만들지 않고 callback을 재발송한다- 멱등성 키는
analysisId이고 server가 발급한다 callbackUrl은 payload가 실어 보내고 ai가 자기 설정값과 origin을 대조한다- 전사는 payload에 통째로 push (회의가 끝난 확정 전사이므로)
- 재시도·워치독은 server 단독 소유. ai 큐는 인메모리라 프로세스가 죽으면 접수분이 사라지지만 워치독이 덮는다 — ai는 무상태 재시작이 가능해야 한다
7. 흐름 C — 채팅 SSE passthrough
SSE 이벤트 8종 — message_start · token · tool_call_start · tool_approval_request · tool_approval_resolved · tool_call_result · message_end · error
server가 tee하는 것 셋
| tee | 언제 | 회의 ACTIVE 게이트 |
|---|---|---|
| USER 메시지 | 요청 시 | 걸린다 |
message_end.content (ASSISTANT 전문) | 정상 종료 시 | 걸린다 |
| 승인·도구 실행 기록 (TOOL) | 승인 확정·도구 결과 수신 시 | 걸리지 않는다 |
TOOL tee에 게이트를 태우면 안 되는 이유: 승인 직후 회의가 끝나면 외부 시스템은 바뀌었는데 히스토리에 흔적이 없어 감사가 불가능해진다.
승인 경계
- 쓰기 도구만 승인, 조회 도구는 자동 실행
- 승인 주체는 해당 메시지 입력자 본인 (관전자 403)
- server는 재개 직전 현재 멤버십을 다시 검증한다. ai는 권한을 재검증하지 않는다
- 단절 시 run이 죽는다 — server가 SSE를 끊으면 ai는 진행 중 run을 취소하고 interrupt를 폐기한다. 이후 재개 요청은 404. "스트림이 끝난 뒤 도구만 실행돼 이력 없이 외부가 바뀌는 창"은 존재하지 않는다
approvalId는 ai가 발급하지만 13자 TSID여야 한다. 형식이 다르면 server가 승인 row 등록을 건너뛰고 카드는 뜨는데 API가 404 → 300초 뒤 REJECTED. 조용히 깨지는 경로
SSE 종료 — 이벤트 2, 경로 3
| 종료 | ai → server 구간 | server → web 구간 |
|---|---|---|
message_end | 정상 | 정상 |
error | 치명 — 부분 응답 저장 안 함 | 동일 |
| 종료 이벤트 없이 끊김 | 계약 위반 | 정상 시나리오 (동시 스트림 상한, upstream 유휴 60초, 입력 잠금 상실) — web은 반드시 처리해야 한다 |
8. 시간 상수 — 한 축에 정렬돼 있다
짧은 쪽이 긴 쪽보다 먼저 끊으면 안 된다.
| 상수 | 값 | 구간 | 깨지면 |
|---|---|---|---|
| ai keepalive comment | ≤ 15초 | ai → server | server 유휴 60초에 걸려 승인 대기 중 스트림이 끊긴다 |
| server keepalive comment | ≤ 20초 (구현 10초) | server → web | 프록시 유휴 타임아웃이 승인 대기보다 먼저 끊는다 |
| upstream 유휴 read timeout | 60초 (행 간격 기준) | server ← ai | 정상 수명 제어의 축 |
| 승인 대기 상한 | 300초 | ai interrupt | 만료 시 REJECTED로 스트림 정상 종료 |
| SSE 절대 상한 | 30분 | server → web | 좀비 방지 |
| 승인 재개 read timeout | 10초 | server → ai | ai는 도구 실행을 기다리지 말고 즉시 응답 |
| 분석 워치독 | 1분 주기 / 10분 타임아웃 | server | RUNNING 초과 잡 재디스패치 |
| STOMP heartbeat / WS 유휴 | 10초 / 60초 | web ↔ server | |
nginx proxy_read_timeout (/ws/) | 75초 |
두 구간의 keepalive는 독립이다. server는 upstream 입력과 무관하게 자기 타이머로 발행한다.
⚠️ 실제로 깨진 적 있다 — web의
IDLE_TIMEOUT_MS40초 타이머가 SSE comment 라인을 걸러내서 리셋되지 않았다 (APP-235). keepalive가 오는데도 stalled로 판정한다.
9. 실측된 실패 사례 — 경계가 어떻게 깨졌나
| # | 무엇 | 원인 | 교훈 |
|---|---|---|---|
| 1 | 분석 요청 본문이 통째로 사라짐 | Spring JdkClientHttpRequestFactory의 기본 HTTP/2 h2c 업그레이드 vs uvicorn(h11) | ai 전용 factory를 HTTP/1.1로 고정. MockRestServiceServer는 소켓을 안 타서 못 잡는다 → MockWebServer로 회귀 테스트 |
| 2 | 콜백이 전부 400 | server가 보내는 origin(사설 IP)과 ai의 SERVER_BASE_URL(공개 도메인)이 어긋남 | same_origin 가드는 정상 작동했고 설정이 낡았던 것. 두 값은 scheme·host·port가 정확히 같아야 한다 |
| 3 | 챗봇 스트리밍이 뭉쳐서 나옴 | nginx location /에 proxy_buffering off 없음 | X-Accel-Buffering: no가 nginx가 없는 구간(ai→server)에 붙어 있었고 필요한 구간(server→브라우저)엔 없었다 |
| 4 | 10시간 동안 분석 성공 0건인데 아무도 몰랐다 | AnalysisDispatchHandler가 실패를 삼키고 WARN만 남긴다 (잡 유실 방지 목적의 의도된 설계) | 실패율 100%가 조용히 유지되는 구조. 관측이 유일한 방어선 |
| 5 | 전사가 1시간마다 끊김 | ElevenLabs 세션 시간 상한 3,600초 (close 1006, Close 프레임 없음) | 맥북 로컬에서 3,599,962ms로 재현 — 우리 인프라 무관 확정 |
| 6 | 전사가 임의로 끊김 (미확정) | queue_overflow — 미확정 오디오가 쌓여 배출이 멈춤. 전송배율은 6건 전부 1.000x라 과전송이 아니다 | "연속 발화 20초 / 무음 20초" 가설도 반증(54초 발화·90초 무음 모두 생존). 원인 미확정 |
10. 확장 천장 — 지금 구조가 못 넘는 선
| 천장 | 내용 | 넘는 방법 |
|---|---|---|
| 앱 인스턴스 1개 | enableSimpleBroker는 JVM 내부 브로커다. /topic이 생긴 순간 "인스턴스 1개"가 기능 정확성의 조건이 됐다 — 스케일아웃·블루그린이면 팬아웃이 조용히 반쪽 난다 | Redis / RabbitMQ STOMP 릴레이 |
| 연결 1 = 녹음자 1 | sendBufferSize 4MB · sendTimeLimit 10s가 그 전제로 잡힌 값 | 실측 후 재산정 |
| 서버 재기동 | 기동 시 전역 전사 세션 중단 — 이중화하면 타 인스턴스의 라이브 세션을 끊는다 (APP-189) | 스케일아웃 전에 처리 |
| 토큰 수명 | access token TTL 30분. 2시간 회의면 마지막 90분은 만료 토큰으로 열린 WebSocket이 유지된다 | 별개 보안 공백으로 기록됨 |
| 브로커 없음 | 비동기는 DB row + HTTP callback | 병목이 실측될 때 도입 |
| 도구 왕복 | 60 | 왕복 축소가 지연의 본체 |
11. 비밀정보의 자리
| 무엇 | 어디에 있나 | 어디에 없나 |
|---|---|---|
| OAuth refresh token (Linear·GitHub) | server tool_connection (암호화) | ai는 갖지 않는다 |
| 단기 access token | 요청 스코프 메모리뿐 | DB·파일·로그 금지. ai는 LangGraph runtime context로만 — state는 물론 configurable도 금지(스칼라여도 checkpoint metadata로 복사된다) |
| DB 비밀번호 | AWS Secrets Manager (RDS 관리형, 7일 회전) | 저장소에 없다 |
토큰 재발급 내부 API는 만들지 않았다 — 도구가 401을 받으면 그 호출만 실패하고 스트림은 계속된다. 다음 메시지 때 새 토큰이 가면 자연 복구된다.
12. 미확인
| 무엇 | 지금 아는 것 |
|---|---|
| server → ai 인증 공백(④) | 코드와 계약이 일치한다(양쪽 다 없음). 계약이 스스로 미결로 등재. 결정 주체가 지정돼 있지 않다. 대칭으로 바꾸려면 양쪽을 같은 변경으로 — server 세 클라이언트가 헤더를 실기 전에 ai에서 검증을 켜면 분석과 채팅이 전부 401로 죽는다 |
| 실제 네트워크 격리 수단 | 보안그룹으로 막고 있다(→ cloud-architecture.md §4). 애플리케이션 레벨 인증은 여전히 없다 |
| 분석 완료 push | 채널이 없다. web 3초 폴링이 유일 |
ANALYSIS_WORKERS 적정값 | 동시 LLM 요청이 이 값의 3배까지 간다는 것만 코드로 확인. provider rate limit 미측정 |
| 전사 길이 상한 | 정한 적 없다. 실제 전사가 몇 토큰인지 측정한 적도 없다 |
| 파생 데이터 보존 기간 | 삭제 정책이 없다 |
| 화자 분리 | ElevenLabs는 제공하지 않는다. transcript_segments에 speaker 컬럼 자체가 없다 — 파이프라인 어디에도 화자 데이터가 없다 (APP-233) |