시스템 아키텍처 (SA)
갱신: 2026-08-17
사용자의 요청은 웹, 업무 API, 실시간 중계 서버, AI 서비스와 오디오 조립 함수를 지난다. 같은 저장소의 코드라도 배포 수명과 확장 기준이 다르면 별도 실행 단위로 분리했다. AWS 자원 배치는 클라우드 아키텍처, 회의 상태 전이는 회의 처리 흐름으로 이어진다.
전체 구성
외부 요청은 ALB에서 짧은 REST 요청과 오래 유지되는 WebSocket·SSE 연결로 갈라진다. api는 업무 상태와 승인을 맡고, realtime은 오디오와 응답 스트림을 중계한다. heymoa-ai는 두 서비스의 내부 요청만 받는다. 회의가 끝난 뒤에는 SQS가 조립 함수와 AI 분석을 각 실행 단위로 연결한다.
PostgreSQL은 업무 데이터와 AI 상태를, Redis는 연결 중에 필요한 티켓과 알림을, S3는 오디오를 저장한다. Soniox, pyannote, OpenAI, Linear, GitHub, Google OAuth2, SES는 처리 시점과 실패 조건이 달라 각각 독립된 cloud node로 표시했다. 아래에서는 같은 구성을 실행 단위와 처리 경로별로 나누어 설명한다.
실행 단위와 연결 관계
전체 구성 그림에서 실행 단위(api, realtime, heymoa-ai)를 누르면 그 단위의 연결만 남는다.
| 실행 단위 | 기술 | 맡은 일 | 프로세스에 남는 상태 |
|---|---|---|---|
heymoa-web | Next.js, Vercel | 화면, 마이크 캡처 | 없음 |
api | Spring Boot, Kotlin | 인증, 권한, 업무 상태, 외부 결과 수신 | 없음 |
realtime | Spring Boot, Kotlin | 오디오 수신, STT 중계, 전사·채팅 스트림 | 오디오 버퍼, 확정 전 토큰 |
audio-assembly | AWS Lambda, ffmpeg 컨테이너 이미지 | 오디오 조각을 Opus 합본으로 연결 | 없음 |
heymoa-ai | FastAPI, LangGraph | Agent 실행, 회의 분석, 외부 도구 호출 | 프로세스 내부 작업 큐 |
api와 realtime은 같은 서버 저장소에 있지만 독립된 Spring Boot 애플리케이션이다. 짧은 요청을 처리하는 API와 회의 내내 연결을 유지하는 realtime을 함께 확장하면 한쪽의 부하가 다른 쪽의 배포와 용량 계획을 흔든다. ALB는 두 애플리케이션만 외부 요청 대상으로 등록한다. AI 서비스와 조립 함수에는 브라우저가 직접 접근하지 않는다.
조립 함수의 입출력도 좁게 유지한다. 회의 식별자와 순서가 정해진 세그먼트 키, 무음 삽입 위치를 받아 {meetingId}/merged.ogg와 durationMs를 돌려준다. 데이터베이스와 Redis, pyannote에는 연결하지 않는다.
5시간 분량의 예상 오디오 크기는 PCM 설계에서 864 MB, 현재 Opus 전제에서 72 MB다. 운영 측정값이 아니라 codec 설정으로 계산한 값이다. 조립 함수는 다시 인코딩하지 않고 ffmpeg -c copy로 컨테이너만 Ogg로 바꾼다.
오디오가 전사와 저장소로 갈라지는 지점
브라우저가 보낸 WebM/Opus 청크는 realtime에서 두 방향으로 갈라진다. Soniox에는 받은 청크를 이어 보내 전사 결과를 받고, S3에는 30초 단위 오브젝트로 저장한다. 어느 한쪽의 결과를 다른 쪽 입력으로 사용하지 않으므로 전사 지연이 오디오 저장을 막지 않는다.
연결 전에 API가 일회용 티켓을 발급해 Redis에 저장한다. realtime은 티켓을 한 번 소비한 뒤 회의 세션을 연다. 사용자 인증과 권한 판단은 API가 맡고 realtime은 이미 검증된 연결만 유지한다.
Soniox 연결이 끊기면 새 stt_stream을 열되 같은 회의 run을 계속 사용한다. S3 업로드 실패는 해당 세그먼트 단위로 다룬다. 두 경로에서 생긴 시간 차이를 다시 맞추는 규칙은 아래 ‘오디오 시간 좌표’에 정리했다.
Agent 응답이 브라우저에 도달하는 과정
전체 구성 그림에서 heymoa-ai 를 누르면 Agent 응답이 오가는 경로만 남는다.
브라우저는 채팅 메시지를 API에 보내고 응답 스트림은 realtime에서 받는다. API가 대화와 승인을 소유하고, realtime은 AI가 보내는 SSE를 브라우저 연결로 중계한다. AI 서비스는 필요한 회의 맥락을 API에서 읽고 벡터 검색에는 자신이 소유한 PostgreSQL 데이터베이스를 사용한다.
Linear와 GitHub 쓰기 작업은 AI가 곧바로 실행하지 않는다. 승인 요청이 realtime을 거쳐 브라우저에 도착하고, 사용자의 결정이 API를 통해 AI에 돌아온 뒤에야 도구를 호출한다. Agent 내부 분기와 상태 저장은 HeyMoa AI Agent에 적었다.
realtime의 AI 스트림 소비 코드는 blocking I/O를 사용한다. 스트림 하나가 응답이 끝날 때까지 스레드 하나를 점유한다.
회의를 끝낸 뒤의 비동기 처리
사용자가 종료했든 watchdog이 종료했든 이후 경로는 같다. API는 열린 run이 없는지 확인하고 오디오 조립 작업을 SQS에 넣는다. 조립 완료 메시지를 받은 뒤 pyannote에 화자 분리를 요청하며, 사용자가 화자 매핑을 확정하면 AI 분석 작업을 발행한다.
큐는 실행 단위를 넘는 세 곳에만 둔다.
- API에서 조립 함수로 넘어가는 오디오 조립
- 조립 완료 뒤 API가 시작하는 pyannote 제출
- 화자 매핑 확정 뒤 AI가 시작하는 회의 분석
관계 갱신, 기계 화자 매핑, 승인 만료처럼 API 안에서 끝나는 작업은 데이터베이스 상태로 관리한다.
실행 단위별 외부 연결
| 출발 | 연결 대상 | 사용 목적 |
|---|---|---|
api | heymoa DB, Redis, AI, pyannote, SES, Google OAuth2, SQS | 업무 쓰기, 티켓, 승인, 외부 요청과 결과 수신 |
realtime | heymoa DB, Redis, Soniox, S3, AI | 회의 세션, 전사·오디오·채팅 중계 |
| 조립 함수 | S3, SQS | 세그먼트 읽기, 합본 쓰기, 완료 통지 |
heymoa-ai | heymoa_ai DB, API, OpenAI, Linear, GitHub, SQS | 분석, 맥락 조회, 도구 실행 |
| 전체 서비스 | Grafana Cloud, Langfuse | metric, trace, log |
AI 서비스는 Redis, S3, SES에 연결하지 않는다. 조립 함수도 데이터베이스, Redis, 외부 분석 업체에 연결하지 않는다. 연결 목록은 보안 그룹과 IAM 권한을 검토할 때 허용 목록으로 사용한다.
통신 규약과 실패 경계
| 구간 | 통신과 인증 | 오가는 데이터 | 실패 처리와 상한 |
|---|---|---|---|
| web → api | REST, HttpOnly 접근 토큰 | JSON | 토큰 30분, 재발급 실패 시 재로그인 |
| web ↔ realtime | STOMP over WebSocket, 일회용 티켓 | WebM/Opus 청크, 캡처 오프셋 | 티켓 30초, idle 60초 |
| web ← realtime | SSE, 티켓 | 전사와 상태 이벤트 | 확정 전 문장 유실 허용, 절대 상한 30분 |
| realtime → Soniox | WebSocket 바이너리, 서비스 키 | 받은 오디오 청크 | 재연결 시 새 stt_stream |
| realtime → S3 | 세그먼트별 PutObject, IAM | 30초 Opus 세그먼트 | 해당 세그먼트만 재시도 또는 유실 |
| api → AI | REST 202와 콜백, 내부 토큰 | 분석 요청 | 유휴 제한 60초, watchdog 재발행 |
| AI → api | 내부 REST, 내부 토큰 | 콜백과 업무 맥락 | watchdog 재발행 |
| api → pyannote | REST와 webhook, presigned URL | 합본 URL과 화자 구간 | watchdog 재제출 |
| realtime → AI | REST 요청, SSE 소비 | 응답 token과 승인 카드 | 승인 대기 300초 |
| 종료 → 조립 | SQS 표준 큐, IAM | 회의 ID와 세그먼트 목록 | 적어도 한 번 전달 |
| 조립 완료 → 제출 | SQS 표준 큐, IAM | 합본 key와 길이 | 적어도 한 번 전달 |
| 매핑 확정 → 분석 | SQS 표준 큐, IAM | 전사와 확정 화자 | 적어도 한 번 전달 |
| 처리 실패 | DLQ | 처리하지 못한 작업 | DLQ로 격리 |
오디오 시간 좌표
브라우저부터 S3까지 WebM/Opus 32 kbps mono를 유지한다. Soniox에도 같은 청크를 보내며, 조립 함수는 decode나 재인코딩 없이 Ogg 컨테이너로 합친다. PCM16 24 kHz mono의 384 kbps보다 네트워크와 저장량은 줄지만 손실 압축 전 원본은 남지 않는다.
meeting
└─ run 1..N 중지·재개, task 교체, client crash
└─ stt_stream 1..M Soniox reconnect
└─ segment 1..K 30초 object
회의 전체 좌표는 soniox.start_ms + stt_stream.offset_ms + run.offset_ms로 계산한다. run 안에서 잃은 구간은 무음으로 채워 뒤쪽 좌표를 보존한다. 중지와 재개 사이의 시간은 오디오에 넣지 않는다. run.offset_ms는 앞선 run의 duration_ms 합이며 벽시계로 흐른 시간이 아니다.
실제 시각은 run.started_at + (합본 위치 - run.offset_ms)로 되돌린다. 브라우저 캡처 오프셋과 Soniox total_audio_proc_ms의 차이를 함께 기록해야 전송 중 빠진 구간을 찾을 수 있다.
운영 상한
| 값 | 적용 구간 |
|---|---|
| 회의 최대 5시간 | 제품 제한 |
| ticket 30초, 1회용 | web → realtime |
| heartbeat 10초, 세션 만료 60초 | realtime 세션 |
| STOMP heartbeat 10초, WebSocket idle 60초 | web ↔ realtime |
| AI keepalive 15초 이내, upstream 유휴 제한 60초 | AI 스트림 |
| 승인 300초 | 도구 승인 |
| SSE 30분 | 브라우저 스트림 |
| ALB 연결 종료 유예 300초 | realtime |
| segment 30초 | S3 저장 |
5시간 회의는 ALB 종료 유예만으로 보호할 수 없다. 재연결과 run 오프셋이 함께 동작해야 한다. realtime은 종료할 태스크를 선택할 수 없는 자동 scale-in을 사용하지 않는다.