시스템 아키텍처 (SA)
2026-08-17 기록. 현재 문서와 내용이 다를 수 있다. 아래 내용은 당시 상태를 보존하며 수정하지 않는다.
실행 중인 시스템들이 어떻게 협력하는가?
돌고 있는 것들 사이에 무엇이 어떤 방식으로 오가는지 그린다. AA 가 정적인 책임 분할이라면 여기는 움직이는 그림이다.
- 집중하는 것 — Runtime Component 와 통신
- 담는 것 — Client, Server, DB, Queue, STT, AI, 외부 API, WS/SSE/HTTP
- 빼는 것 — 코드 클래스, AWS 상세 설정
- 구체성 — 중간~구체적
세는 단위는 따로 재기동되는 것이다. 합치거나 쪼개면 이 문서가 틀린다. 기술 이름은 적되 배치는 적지 않는다 — PostgreSQL 은 여기, db.r6g.large 는 CA다.
시간 순서는 여기 없다. 아래 그림들은 구조를 그린다 — 누가 무엇에 어떤 방식으로 붙나. 무엇이 먼저 오고 실패하면 어디로 가는지는 핵심 흐름도가 진다. SA 는 경계를, 흐름 문서는 순서를 진다.
| 그림 | 답하는 질문 |
|---|---|
| §1 | 무엇이 따로 돌고 무엇에 붙나 |
| §2 | 오디오와 전사가 어떤 연결을 타고 오가나 |
| §3 | 질의와 응답 스트림이 어디를 지나나 |
| §4 | 종료 다음의 일이 어떻게 넘어가나 |
1. 전체 — 무엇이 따로 돌고 무엇에 붙나
10초 안에 읽혀야 하는 것 — 따로 재기동되는 것이 넷이고, ALB 뒤에 두 개만 있고, 비동기로 갈린 자리가 하나 있다는 것.
| 그림이 말하는 것 | 무엇 |
|---|---|
| ALB 뒤가 둘뿐이다 | 조립 함수는 인바운드가 0개이고 ai 는 타깃 그룹이 없다. 브라우저에서 그 둘에 도달할 길이 없다 |
| 조립 함수만 밖에 안 붙는다 | 저장소 중 S3 하나만 본다. DB 도 벤더도 안 부른다 — 그래야 VPC 밖에 둘 수 있다 |
| 넷이 다 저장소에 붙는다 | 다만 붙는 대상이 다르다. 어느 실행 단위가 무엇에 붙는지는 §5가 진다 — 거기서 빠진 선은 보안그룹에서도 빠진다 |
| realtime 만 프록시를 안 지난다 | 오디오를 Cloudflare 로 흘리지 않기로 했다. 프록시는 호스트 단위라 경로로 못 나눈다 |
| realtime 만 상태를 갖는다 | 오디오 버퍼와 확정 전 토큰이 그 인스턴스 메모리에 산다 |
실행 단위를 왜 이렇게 갈랐나
| 컨테이너 | 무엇으로 도나 | 왜 따로 있나 | 상태를 갖나 |
|---|---|---|---|
| heymoa-web | Next.js · Vercel | 마이크와 오디오 처리는 브라우저에서만 된다 | ❌ |
| api | Spring Kotlin | 인증·권한·업무 상태가 한 곳에 모여야 감사와 잠금이 성립한다 | ❌ |
| realtime | Spring Kotlin | 연결 수명이 REST 와 다르다. 나눈 목적은 배포다 — api 를 하루 세 번 올려도 진행 중인 회의가 안 끊긴다 | ⚠️ 오디오 버퍼와 확정 전 토큰을 든다 |
| 조립 함수 | AWS Lambda · 컨테이너 이미지(ffmpeg) | 뾰족하고 짧고 순수하다. 회의 종료가 정각에 몰리는데 나머지 시간엔 논다 | ❌ |
| heymoa-ai | FastAPI · LangGraph | LLM 호출은 초 단위로 느리고 실패율이 다르다 | ⚠️ 큐를 프로세스 안에 들고 있다. 밖으로 빼야 한다 |
api · realtime 은 한 저장소 안의 boot 모듈 둘로 갈린다. 스프링 프로필로 한 덩어리를 여러 가지로 기동하던 것을 빌드에서 가른다.
조립을 왜 Spring boot 모듈이 아니라 함수로 두나
포맷을 Opus 로 바꾸면서 이 일의 성질이 달라졌다. 예전에는 PCM 864 MB 를 받아 인코딩하는 일이었고, 지금은 세그먼트 72 MB 를 받아 컨테이너만 다시 쓰는(ffmpeg -c copy) 일이다. 디코딩도 인코딩도 없다.
| 5시간 회의 | 옛 설계 (PCM) | 지금 (Opus) |
|---|---|---|
| 내려받을 양 | 864 MB | 72 MB |
| ffmpeg | Opus 인코딩 | remux 뿐 |
| 실제 병목 | CPU | S3 GET 수백 번 |
「인코딩이 api 의 CPU 를 나눠 쓰면 안 된다」가 worker 를 두던 이유였는데 그 CPU 가 없어졌다. 상시 태스크를 띄울 이유도 같이 없어진다.
대신 하나를 못 박는다 — 조립 함수는 DB 도 벤더도 모른다.
입력 { meetingId, [세그먼트 키 순서대로], [무음 삽입 위치] }
출력 { mergedKey, durationMs } ← 「조립 완료 → 제출」 큐로
- RDS 를 안 부른다 → VPC 에 넣을 이유가 없다 → NAT·ENI·커넥션 풀이 이 함수에서 전부 빠진다
- pyannote 를 안 부른다 → 벤더 키를 안 갖는다. 제출과 webhook 수신을 api 한쪽에 모은다
- 출력 키가 결정적이다(
{meetingId}/merged.ogg) → 두 번 돌아도 같은 바이트를 덮는다
realtime 이 상태를 갖는다는 것이 이 구조의 핵심 제약이다. 오디오 버퍼와 아직 문장이 되지 못한 토큰이 그 인스턴스 메모리에 산다. 배포·축소·크래시가 전부 여기서 갈린다.
2. 실시간 전사
같은 오디오가 두 갈래로 나간다 — ④는 전사하려고 Soniox 로, ⑤는 보관하려고 S3 로. 둘 다 월 TB 단위다.
티켓이 있는 이유는 realtime 에 인증 코드를 안 두려는 것이다 — api 가 발급하고 Redis 에 일회용으로 둔다.
3. 에이전트 상호작용
⚠️ 스트림을 realtime 이 문다. 이 구간이 스레드를 점유해 동시 32가 관측됐고, 원인은 프로토콜이 아니라 블로킹으로 읽는 방식이다.
4. 회의 종료 뒤
큐를 넣는 조건은 하나다 — 생산자와 소비자가 같은 프로세스 안에 있는가. 셋 다 런타임 경계를 넘으므로 큐가 필요하다. 같은 프로세스 안의 일에는 큐를 끼우지 않는다 — 관계 갱신, 기계 매핑, 승인 만료 정리는 api 안에서 끝난다.
이 그림에서 읽어야 하는 것 넷.
| 무엇 | 왜 그렇게 그렸나 |
|---|---|
| 트리거가 둘인데 그 뒤는 하나다 | 종료 버튼과 워치독이 같은 자리로 들어온다. 버튼만 트리거면 배포 중에 회의하던 사람은 회의록을 못 받는다 |
| 발행 앞에 게이트가 있다 | 열린 run 이 0개인지 확인하고 발행한다. 없으면 뒷부분이 통째로 빠진 오디오가 에러 없이 나온다 |
| 조립 함수가 벤더를 안 부른다 | 제출은 api 가 한다. 그래야 함수가 DB 도 벤더 키도 인터넷도 모르는 채로 남는다 |
| 큐가 셋이다 | 그 대가가 「조립 완료 → 제출」 큐 하나다. 홉 하나를 사고 격리를 산다 |
경계 이름에는 수단을 안 박는다. 상자 이름은 「종료 → 조립」이고 SQS 는 그 아래 줄이다.
⚠️ 매핑이 둘이고 사람은 그중 하나에만 끼어든다.
| 무엇 | 누가 | 언제 | |
|---|---|---|---|
| 기계 매핑 | 화자 구간 ↔ 전사 세그먼트 (겹침 최장) | api | webhook 받자마자, 같은 트랜잭션 |
| 사람 매핑 | SPEAKER_01 ↔ 실제 사람 | 사용자 | 화면에서 |
그 화면이 IA에 아직 없다.
| 선 | 뜻 |
|---|---|
| ── 실선 | 동기. 부르는 쪽이 답을 기다린다 |
| ┄┄ 점선 | 비동기. 큐에 넣거나 되불러온다 |
선 굵기로 오디오를 표시하지 않는다. 굵기는 눈에 먼저 띄는데 그렇게 강조된 것이 정작 이 층이 답해야 하는 것이 아니다. 오디오가 흐르는 구간은 ② ④ ⑤ 와 조립 함수 ↔ S3 넷이고, 그건 아래 표와 CA의 비용 표가 진다. 넷 다 Opus 라 같은 선이 12배 얇아졌다.
5. 무엇이 무엇에 붙나
묶어서 적지 않는다. 보안그룹은 연결 하나마다 규칙 하나이므로 여기서 빠진 선은 거기서도 빠진다.
| 실행 단위 | 붙는 것 | 무엇을 하나 |
|---|---|---|
| api | heymoa DB · Redis · heymoa-ai · pyannote · SES · Google OAuth2 · 잡 발행과 소비 | 업무 데이터 쓰기 · 티켓 발급 · 승인 알림 · 관계 갱신 · 분리 제출과 결과 수용 · 기계 매핑 |
| realtime | heymoa DB · Redis · Soniox · S3 · heymoa-ai | 세션과 세그먼트 쓰기 · 티켓 소비 · 팬아웃 · 오디오 중계와 적재 · 채팅 중계 |
| 조립 함수 | S3 · 잡 소비와 발행 | 그게 전부다. 세그먼트를 읽어 합본을 쓰고 완료를 알린다 |
| heymoa-ai | heymoa_ai DB · api · OpenAI · Linear · GitHub · 잡 소비 | 자기 스키마를 직접 읽고 쓴다 · 맥락 조회와 결과 반환 · 도구 실행 |
| 전부 | Grafana Cloud · Langfuse | 메트릭 · 트레이스 · 로그 |
없다고 확인한 것도 적는다 — heymoa-ai 는 Redis·S3·SES 를 쓰지 않고, 조립 함수는 DB·Redis·벤더 어느 것도 안 쓰며, 브라우저는 heymoa-ai 에 도달할 수단이 없다. 안 적으면 다음에 또 묻게 된다.
pyannote 가 worker 에서 api 로 옮겨 왔다. 보내는 쪽과 받는 쪽이 갈려 있던 것이 한쪽으로 모였다 — 옛 판의 ⑧ 은 "보내는 것은 worker 인데 결과는 api 가 받는다" 였고 그 비대칭이 없어졌다.
⚠️ heymoa-ai 가 데이터베이스에 직접 붙는다. 같은 인스턴스 안의 다른 데이터베이스이고 자기 마이그레이션과 벡터 테이블을 갖는다. 이 선을 빠뜨리면 ai 가 기동하자마자 죽는다.
6. 경계
구현할 때 필요한 것은 다섯 칸이다 — 무엇으로 말하나 · 누구인지 어떻게 아나 · 무엇이 오가나 · 깨지면 어떻게 되나 · 얼마나 기다리나.
| # | 구간 | 프로토콜 | 인증 | 무엇이 오가나 | 깨지면 | 시간 |
|---|---|---|---|---|---|---|
| ① | web → api | REST | access_token HttpOnly 쿠키 | JSON | 401 → 재발급 → 실패하면 재로그인 | 토큰 30분 |
| ② | web ↔ realtime | STOMP over WebSocket | 티켓 (api 발급, Redis 일회용) | WebM/Opus 청크 + 캡처 오프셋 + STOMP 프레임 | 티켓 무효면 연결 거부. 재연결은 티켓 재발급부터 | 티켓 30초 · 유휴 60초 |
| ③ | web ← realtime | SSE | 티켓 | 전사·상태 이벤트 JSON | 끊기면 재연결. 확정 전 문장은 유실 허용 | 절대 상한 30분 |
| ④ | realtime → Soniox | WebSocket · 바이너리 프레임 | 벤더 API 키 | 받은 청크를 그대로 통과(audio_format: auto) | 재연결이 run 경계가 아니다. 새 stt_stream 이 열리고 오프셋이 이어진다 | 세션 상한 미정 |
| ⑤ | realtime → S3 | PutObject — 세그먼트 하나에 오브젝트 하나 | 태스크 IAM 역할 | 세그먼트 = 30초 | 실패하면 그 세그먼트만 잃는다. 앞뒤는 이미 오브젝트다 | — |
| ⑥ | api → ai | REST 202 + 콜백 | 공유 토큰 | 분석 요청 JSON | 잡 상태로 남고 워치독이 다시 던진다 | 유휴 60초 |
| ⑦ | ai → api | REST 내부 경로 | 내부 토큰 헤더 | 콜백 결과 · 맥락 조회 JSON | 미정 — 콜백이 계속 실패하면 무엇을 하나 | — |
| ⑧ | api → pyannote | REST + webhook | presigned URL · 서명 검증 | 합본 URL → 화자 구간 | 화자 없이 검토 대기까지 간다 · 워치독이 필요하다 | 제출 후 상한 미정 |
| ⑨ | realtime → ai | REST + SSE 소비 | ⑥과 같은 토큰 | 채팅 토큰 · 승인 카드 | 미정 — 중간에 끊기면 사용자에게 무엇을 보이나 | 승인 대기 300초 |
| ⑩ | 종료 → 조립 | SQS 표준 큐 | 태스크 IAM 역할 | 회의 식별자 · 세그먼트 키 목록 · 무음 삽입 위치 | 적어도 한 번 — 출력 키가 결정적이라 덮어쓰기로 흡수된다 | 가시성 타임아웃 미정 |
| ⑪ | 조립 완료 → 제출 | 〃 | 함수 실행 역할 | 합본 키 · 길이 | 〃 | 〃 |
| ⑫ | 매핑 확정 → 분석 | 〃 | 태스크 IAM 역할 | 전사 + 확정된 화자 | 〃 | 〃 |
| ⑬ | 처리 실패의 종착 | DLQ | — | 죽은 잡 | 버려지는 자리가 있어야 한다. 아무도 안 보면 회의 하나가 영영 처리되지 않는다 | — |
⑤가 멀티파트에서 오브젝트로 바뀌었다. 이유는 하나다 — 멀티파트는 파트 최소 5 MiB 때문에 유실 창이 코덱 비트레이트에 끌려간다.
| 포맷 | 5 MiB 를 채우는 데 |
|---|---|
| 옛 판 PCM16 24 kHz (48 kB/s) | 1분 49초 |
| Opus 32 kbps (4 kB/s) | 21분 50초 |
압축을 잘할수록 더 오래 위험해지는, 방향이 거꾸로 된 결합이다. 세그먼트로 가면 유실 창이 우리가 고르는 값이 되고, uploadId 라는 숨은 상태가 없어져 태스크가 바뀌어도 넘길 것이 0개다.
⚠️ flush 는 크기가 아니라 타이머로 건다. 바이트 임계면 조용한 회의가 영영 안 밀린다.
⚠️ 가시성 타임아웃이 조립보다 짧으면 같은 회의를 두 번 조립한다. 다만 출력 키가 결정적이라 같은 바이트를 덮을 뿐이라 예전만큼 위험하지 않다. 함수 호출값만 두 배로 낸다.
⚠️ ⑧에 워치독이 없다. webhook 이 안 오면 화자 분리 중 이 영영 남는다.
티켓의 값은 도메인 분리가 아니다 — ① 연결 수명(최대 5시간)이 토큰 수명(30분)보다 긴 문제를 정면으로 푸는 것, ② realtime 에서 인증 코드를 걷어내는 것이다.
브라우저가 S3 에 직접 올리지 않는다. 오디오가 서버를 지나야 벤더 중계·버퍼링·세션 기록이 한 자리에서 일어난다. 그 대가로 ⑤ 구간의 대역폭을 우리가 낸다.
7. 오디오 포맷 체인
경계마다 형식이 다르고 어디서 압축하느냐가 유실 노출과 벤더 한도를 동시에 가른다.
| 구간 | 형식 | 왜 |
|---|---|---|
| 브라우저 → realtime | WebM / Opus 32 kbps mono | MediaRecorder("audio/webm;codecs=opus") |
| ④ realtime → Soniox | 받은 것 그대로 | Soniox 가 webm 을 auto 로 감지한다. 서버가 디코딩하지 않는다 |
| ⑤ realtime → S3 | 받은 것 그대로, 30초 오브젝트 | 변환이 없다 |
| 조립 함수 → S3 | .ogg (Opus 그대로) | ffmpeg -f concat **-c copy**. 디코딩도 인코딩도 없다 |
| ⑧ S3 → pyannote | .ogg | pyannote 가 받고 압축을 권장한다. 5시간이 72 MB 로 1 GiB 한도에 여유가 크다 |
왜 PCM 에서 Opus 로 뒤집었나 — 대역폭이 아니라 신뢰성 때문이다
녹음을 깨뜨리는 것은 CPU 도 디스크도 아니고 업링크다.
| 지속 업로드 | |
|---|---|
| 옛 판 PCM16 24 kHz mono | 384 kbps |
| Opus 32 kbps | 32 kbps |
호텔 와이파이·테더링·붐비는 회의실 업링크가 1~2 Mbps 인 것은 흔하다. 384 kbps 를 다섯 시간 붙들고 있는 것과 32 kbps 는 혼잡 확률이 다른 게임이고, 혼잡하면 브라우저가 프레임을 버린다. 버린 소리는 회의가 끝나고 나서야 안다.
옛 판이 PCM 을 지킨 두 논거가 둘 다 무너졌다.
| 옛 논거 | 왜 안 서나 |
|---|---|
| "STT 에 손실 압축을 먹이지 않는다" | 세상의 회의 전사가 이미 대부분 Opus 위에서 돈다 — WebRTC 의 오디오가 Opus 다 |
| "압축하면 파트가 21분이 되어 노출 창이 12배" | 그건 멀티파트의 대가였다. ⑤를 세그먼트로 바꾸면 사라진다 |
그리고 조립이 오히려 싸졌다. PCM 은 이어붙이기가 공짜인 대신 pyannote 가 raw 를 못 받아 변환이 필수였다. Opus 는 변환이 필요 없고 리먹싱만 든다.
WAV 를 쓰지 않는다. 헤더의 전체 길이 필드는 회의가 끝나야 아는 값이다. 화자 분리 전에 16 kHz WAV 로 정규화하지도 않는다 — 그건 자체 호스팅 pyannote.audio 모델이 받는 형식이고, 호스팅 API 는 정규화를 자기 쪽에서 한다.
되돌리기 어려운 것은 이 하나뿐이다 — 손실 압축이라 원본이 안 남는다. 뒤집으려면 같은 회의를 PCM 과 Opus 로 전사해 정확도를 비교한 실측이 있어야 한다.
7.2 시간 좌표계 — 층이 셋이다
회의 하나가 오디오 조각 여럿으로 갈리는 경로가 셋이고, 각각 다른 것을 리셋한다.
meeting 사용자가 보는 것. 최종 합본 1개 · 화자 분리 1회
└─ run 1..N 연속 캡처 구간. 중지 · 태스크 교체 · 크래시로 닫힌다
└─ stt_stream 1..M Soniox WS 세션. 재연결하면 새로 열린다
└─ segment 1..K 30초마다 S3 오브젝트 1개
| 층 | 무엇이 이걸 새로 만드나 | 무엇이 리셋되나 |
|---|---|---|
| run | 중지·재개 · 태스크 교체(배포·스케일인) · 클라이언트 크래시 | 오디오 스트림이 끊긴다 |
| stt_stream | 벤더 재연결 · 세션 시간 상한 | Soniox start_ms 가 0으로 돌아간다 |
| segment | 30초 타이머 · run 종료 | — |
최종 좌표 = soniox.start_ms + stt_stream.offset_ms + run.offset_ms
| 규칙 | 내용 |
|---|---|
| run 안의 구멍은 무음으로 채운다 | 안 채우면 그 뒤 전사가 전부 당겨진다 |
| run 사이는 안 채운다 | 점심시간 1시간이 무음 1시간이 되면 pyannote 과금과 모델 입력을 둘 다 버린다 |
run.offset_ms 는 앞선 run 들의 duration_ms 합 | 벽시계 경과가 아니다 |
실제 시각은 못을 박아 얻는다
실제 시각 = run.started_at + (합본 안의 위치 − run.offset_ms)
미디어 시간을 벽시계로 재지 않는다. 벽시계는 NTP 보정으로 뒤로 갈 수 있고 절전에서 멈춘다. 그리고 pyannote 가 돌려주는 화자 구간은 파일의 초라서, 전사에 벽시계를 찍으면 두 결과를 겹칠 수가 없다. 자가 하나면 동기화할 것이 없다.
| 시계 | 무엇 | 역할 |
|---|---|---|
| 브라우저 캡처 오프셋 | 청크마다 실어 보낸다 (②) | "내가 잡은 소리" |
Soniox total_audio_proc_ms | 벤더가 실제로 처리한 오디오 길이 | 합본 좌표의 정본. 세그먼트 duration_ms 가 여기서 나온다 |
run.started_at | 서버 Instant | 벽시계로 옮기는 못 |
앞의 둘을 빼면 전송 중 사라진 양이 나온다. 이 값을 세그먼트마다 남기지 않으면 시간축이 조용히 당겨지고 당겨진 줄도 모른다.
8. 시간 상수
짧은 쪽이 긴 쪽보다 먼저 끊으면 안 된다. 이 표는 그 순서가 지켜지는지 확인하려고 있다.
| 상수 | 값 | 구간 |
|---|---|---|
| 회의 최대 길이 | 5시간 | 제품 |
| 티켓 TTL | 30초, 일회용 | web → realtime |
| 세션 heartbeat / 만료 | 10초 / 60초 | realtime → RDS |
| STOMP heartbeat / WS 유휴 | 10초 / 60초 | web ↔ realtime |
| ai keepalive | 15초 이내 | ai → api |
| upstream 유휴 read timeout | 60초 | api ← ai |
| 승인 대기 상한 | 300초 | 도구 승인 |
| SSE 절대 상한 | 30분 | api → web |
| ALB 드레이닝 | 기본 300초 · 최댓값 미정 | ALB → realtime |
| 세그먼트 주기 | 30초 | ⑤ |
| run 타임아웃 → 파이널라이즈 | 미정 — 세션 만료 60초와 다른 값이다. 저건 「세션이 죽었나」, 이건 「사람이 돌아올까」 | 워치독 |
| 분리 제출 후 상한 | 미정 — 넘으면 화자 분리 중 이 영영 남는다 | ⑧ |
| 가시성 타임아웃 | 미정 — 조립 실측보다 길어야 한다 | ⑩ ⑪ ⑫ |
| 몇 번 실패하면 포기하나 | 미정 | ⑬ |
⚠️ maxReceiveCount 는 실패 횟수가 아니라 「받은 횟수」다. 예약 동시성에 막혀 실행조차 안 된 메시지도 이 값을 소진한다. 그래서 종료 몰림이 그대로 DLQ 유입으로 보인다.
9. 이 구조가 감당하지 못하는 것
| 무엇 | 내용 |
|---|---|
| 드레이닝으로 회의를 덮을 수 없다 | 회의가 최대 5시간인데 드레이닝은 그보다 짧다. 재연결 흡수가 필수다 |
| realtime 축소 | ECS 가 어느 태스크를 죽일지 고르게 해주지 않는다. 축소가 곧 회의 강제 종료다 |
| 온라인 회의 | 에코 캔슬레이션이 상대방 목소리를 지운다. 지금은 오프라인 전용 |
| RDS 장애 전환 중 쓰기 | 1~2분 공백. 프록시를 넣어도 없어지지 않는다 |
| Soniox 동시 스트림 한도 | 문의로 늘릴 수 있다고 전제하고 있다. 이게 틀리면 목표 동시 회의 수가 성립하지 않는다 |
10. 아직 안 정한 것
- 팬아웃 수단 — Redis Pub/Sub 인가 브로커인가
- SQS 재시도 횟수 · 가시성 타임아웃 · DLQ 경보 임계
- Soniox 동시 한도 · 세션 시간 상한
- pyannote 과금 단위 · 동시 제출 한도 · 서명 규약
- 그래프에 무엇을 투영하나
- 언어 — 한국어 전용인가 자동 감지인가