본문으로 건너뛰기

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)
aiLLM(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-aiLLM 호출은 초 단위로 느리고 실패율이 다르다. 같은 프로세스면 요청 스레드를 묶는다202로 끊고 callback으로 되돌려준다
DB 둘파생 데이터가 원본과 같은 database에 있으면 "지워도 되는 것"을 구분할 수 없다cross-database 쿼리 금지

외부 시스템의 소유자는 하나씩만 — 음성(ElevenLabs)·OAuth(Google/Linear/GitHub)·메일(SES)은 server, LLM(OpenAI)·프롬프트 관측(Langfuse)은 ai. 두 서비스가 같은 외부 시스템에 붙으면 크리덴셜도 둘로 늘어난다.


4. 경계 다섯 — 프로토콜과 인증

#구간프로토콜인증
web → serverREST /v1/**access_token HttpOnly 쿠키 (Secure · SameSite=Lax · Domain=.heymoa.app)
web ↔ serverSTOMP over WebSocket쿠키 (핸드셰이크 시점)
web ← serverSSE (POST 응답이 text/event-stream)쿠키
server → aiREST /internal/v1/** + SSE 소비없음 ⚠️
ai → serverREST /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 ↔ serverAppResponse{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 heartbeat10초 / 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.partialchat.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개

  1. 202접수이지 완료가 아니다. 같은 analysisId 재요청은 새 작업을 만들지 않고 callback을 재발송한다
  2. 멱등성 키는 analysisId이고 server가 발급한다
  3. callbackUrl은 payload가 실어 보내고 ai가 자기 설정값과 origin을 대조한다
  4. 전사는 payload에 통째로 push (회의가 끝난 확정 전사이므로)
  5. 재시도·워치독은 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 comment15초ai → serverserver 유휴 60초에 걸려 승인 대기 중 스트림이 끊긴다
server keepalive comment≤ 20초 (구현 10초)server → web프록시 유휴 타임아웃이 승인 대기보다 먼저 끊는다
upstream 유휴 read timeout60초 (행 간격 기준)server ← ai정상 수명 제어의 축
승인 대기 상한300초ai interrupt만료 시 REJECTED로 스트림 정상 종료
SSE 절대 상한30분server → web좀비 방지
승인 재개 read timeout10초server → aiai는 도구 실행을 기다리지 말고 즉시 응답
분석 워치독1분 주기 / 10분 타임아웃serverRUNNING 초과 잡 재디스패치
STOMP heartbeat / WS 유휴10초 / 60초web ↔ server
nginx proxy_read_timeout (/ws/)75초

두 구간의 keepalive는 독립이다. server는 upstream 입력과 무관하게 자기 타이머로 발행한다.

⚠️ 실제로 깨진 적 있다 — web의 IDLE_TIMEOUT_MS 40초 타이머가 SSE comment 라인을 걸러내서 리셋되지 않았다 (APP-235). keepalive가 오는데도 stalled로 판정한다.


9. 실측된 실패 사례 — 경계가 어떻게 깨졌나

#무엇원인교훈
1분석 요청 본문이 통째로 사라짐Spring JdkClientHttpRequestFactory의 기본 HTTP/2 h2c 업그레이드 vs uvicorn(h11)ai 전용 factory를 HTTP/1.1로 고정. MockRestServiceServer는 소켓을 안 타서 못 잡는다 → MockWebServer로 회귀 테스트
2콜백이 전부 400server가 보내는 origin(사설 IP)과 ai의 SERVER_BASE_URL(공개 도메인)이 어긋남same_origin 가드는 정상 작동했고 설정이 낡았던 것. 두 값은 scheme·host·port가 정확히 같아야 한다
3챗봇 스트리밍이 뭉쳐서 나옴nginx location /proxy_buffering off 없음X-Accel-Buffering: nonginx가 없는 구간(ai→server)에 붙어 있었고 필요한 구간(server→브라우저)엔 없었다
410시간 동안 분석 성공 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 = 녹음자 1sendBufferSize 4MB · sendTimeLimit 10s가 그 전제로 잡힌 값실측 후 재산정
서버 재기동기동 시 전역 전사 세션 중단 — 이중화하면 타 인스턴스의 라이브 세션을 끊는다 (APP-189)스케일아웃 전에 처리
토큰 수명access token TTL 30분. 2시간 회의면 마지막 90분은 만료 토큰으로 열린 WebSocket이 유지된다별개 보안 공백으로 기록됨
브로커 없음비동기는 DB row + HTTP callback병목이 실측될 때 도입
도구 왕복60135초 걸린 턴은 **전부 도구 27회 왕복**, TTFT는 총 소요의 0.3~14%뿐왕복 축소가 지연의 본체

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_segmentsspeaker 컬럼 자체가 없다 — 파이프라인 어디에도 화자 데이터가 없다 (APP-233)