본문으로 건너뛰기

시스템 아키텍처 (SA)

2026-08-17 기록. 현재 문서와 내용이 다를 수 있다. 아래 내용은 당시 상태를 보존하며 수정하지 않는다.

실행 중인 시스템들이 어떻게 협력하는가?

돌고 있는 것들 사이에 무엇이 어떤 방식으로 오가는지 그린다. AA 가 정적인 책임 분할이라면 여기는 움직이는 그림이다.

  • 집중하는 것 — Runtime Component 와 통신
  • 담는 것 — Client, Server, DB, Queue, STT, AI, 외부 API, WS/SSE/HTTP
  • 빼는 것 — 코드 클래스, AWS 상세 설정
  • 구체성 — 중간~구체적

세는 단위는 따로 재기동되는 것이다. 합치거나 쪼개면 이 문서가 틀린다. 기술 이름은 적되 배치는 적지 않는다 — PostgreSQL 은 여기, db.r6g.largeCA다.

시간 순서는 여기 없다. 아래 그림들은 구조를 그린다 — 누가 무엇에 어떤 방식으로 붙나. 무엇이 먼저 오고 실패하면 어디로 가는지는 핵심 흐름도가 진다. SA 는 경계를, 흐름 문서는 순서를 진다.

그림답하는 질문
§1무엇이 따로 돌고 무엇에 붙나
§2오디오와 전사가 어떤 연결을 타고 오가나
§3질의와 응답 스트림이 어디를 지나나
§4종료 다음의 일이 어떻게 넘어가나

1. 전체 — 무엇이 따로 돌고 무엇에 붙나

10초 안에 읽혀야 하는 것 — 따로 재기동되는 것이 넷이고, ALB 뒤에 두 개만 있고, 비동기로 갈린 자리가 하나 있다는 것.

그림이 말하는 것무엇
ALB 뒤가 둘뿐이다조립 함수는 인바운드가 0개이고 ai 는 타깃 그룹이 없다. 브라우저에서 그 둘에 도달할 길이 없다
조립 함수만 밖에 안 붙는다저장소 중 S3 하나만 본다. DB 도 벤더도 안 부른다 — 그래야 VPC 밖에 둘 수 있다
넷이 다 저장소에 붙는다다만 붙는 대상이 다르다. 어느 실행 단위가 무엇에 붙는지는 §5가 진다 — 거기서 빠진 선은 보안그룹에서도 빠진다
realtime 만 프록시를 안 지난다오디오를 Cloudflare 로 흘리지 않기로 했다. 프록시는 호스트 단위라 경로로 못 나눈다
realtime 만 상태를 갖는다오디오 버퍼와 확정 전 토큰이 그 인스턴스 메모리에 산다

실행 단위를 왜 이렇게 갈랐나

컨테이너무엇으로 도나왜 따로 있나상태를 갖나
heymoa-webNext.js · Vercel마이크와 오디오 처리는 브라우저에서만 된다
apiSpring Kotlin인증·권한·업무 상태가 한 곳에 모여야 감사와 잠금이 성립한다
realtimeSpring Kotlin연결 수명이 REST 와 다르다. 나눈 목적은 배포다 — api 를 하루 세 번 올려도 진행 중인 회의가 안 끊긴다⚠️ 오디오 버퍼와 확정 전 토큰을 든다
조립 함수AWS Lambda · 컨테이너 이미지(ffmpeg)뾰족하고 짧고 순수하다. 회의 종료가 정각에 몰리는데 나머지 시간엔 논다
heymoa-aiFastAPI · LangGraphLLM 호출은 초 단위로 느리고 실패율이 다르다⚠️ 큐를 프로세스 안에 들고 있다. 밖으로 빼야 한다

api · realtime 은 한 저장소 안의 boot 모듈 둘로 갈린다. 스프링 프로필로 한 덩어리를 여러 가지로 기동하던 것을 빌드에서 가른다.

조립을 왜 Spring boot 모듈이 아니라 함수로 두나

포맷을 Opus 로 바꾸면서 이 일의 성질이 달라졌다. 예전에는 PCM 864 MB 를 받아 인코딩하는 일이었고, 지금은 세그먼트 72 MB 를 받아 컨테이너만 다시 쓰는(ffmpeg -c copy) 일이다. 디코딩도 인코딩도 없다.

5시간 회의옛 설계 (PCM)지금 (Opus)
내려받을 양864 MB72 MB
ffmpegOpus 인코딩remux 뿐
실제 병목CPUS3 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 는 그 아래 줄이다.

⚠️ 매핑이 둘이고 사람은 그중 하나에만 끼어든다.

무엇누가언제
기계 매핑화자 구간 ↔ 전사 세그먼트 (겹침 최장)apiwebhook 받자마자, 같은 트랜잭션
사람 매핑SPEAKER_01 ↔ 실제 사람사용자화면에서

그 화면이 IA에 아직 없다.

── 실선동기. 부르는 쪽이 답을 기다린다
┄┄ 점선비동기. 큐에 넣거나 되불러온다

선 굵기로 오디오를 표시하지 않는다. 굵기는 눈에 먼저 띄는데 그렇게 강조된 것이 정작 이 층이 답해야 하는 것이 아니다. 오디오가 흐르는 구간은 ② ④ ⑤ 와 조립 함수 ↔ S3 넷이고, 그건 아래 표와 CA의 비용 표가 진다. 넷 다 Opus 라 같은 선이 12배 얇아졌다.


5. 무엇이 무엇에 붙나

묶어서 적지 않는다. 보안그룹은 연결 하나마다 규칙 하나이므로 여기서 빠진 선은 거기서도 빠진다.

실행 단위붙는 것무엇을 하나
apiheymoa DB · Redis · heymoa-ai · pyannote · SES · Google OAuth2 · 잡 발행과 소비업무 데이터 쓰기 · 티켓 발급 · 승인 알림 · 관계 갱신 · 분리 제출과 결과 수용 · 기계 매핑
realtimeheymoa DB · Redis · Soniox · S3 · heymoa-ai세션과 세그먼트 쓰기 · 티켓 소비 · 팬아웃 · 오디오 중계와 적재 · 채팅 중계
조립 함수S3 · 잡 소비와 발행그게 전부다. 세그먼트를 읽어 합본을 쓰고 완료를 알린다
heymoa-aiheymoa_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 → apiRESTaccess_token HttpOnly 쿠키JSON401 → 재발급 → 실패하면 재로그인토큰 30분
web ↔ realtimeSTOMP over WebSocket티켓 (api 발급, Redis 일회용)WebM/Opus 청크 + 캡처 오프셋 + STOMP 프레임티켓 무효면 연결 거부. 재연결은 티켓 재발급부터티켓 30초 · 유휴 60초
web ← realtimeSSE티켓전사·상태 이벤트 JSON끊기면 재연결. 확정 전 문장은 유실 허용절대 상한 30분
realtime → SonioxWebSocket · 바이너리 프레임벤더 API 키받은 청크를 그대로 통과(audio_format: auto)재연결이 run 경계가 아니다.stt_stream 이 열리고 오프셋이 이어진다세션 상한 미정
realtime → S3PutObject — 세그먼트 하나에 오브젝트 하나태스크 IAM 역할세그먼트 = 30초실패하면 그 세그먼트만 잃는다. 앞뒤는 이미 오브젝트다
api → aiREST 202 + 콜백공유 토큰분석 요청 JSON잡 상태로 남고 워치독이 다시 던진다유휴 60초
ai → apiREST 내부 경로내부 토큰 헤더콜백 결과 · 맥락 조회 JSON미정 — 콜백이 계속 실패하면 무엇을 하나
api → pyannoteREST + webhookpresigned URL · 서명 검증합본 URL → 화자 구간화자 없이 검토 대기까지 간다 · 워치독이 필요하다제출 후 상한 미정
realtime → aiREST + 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. 오디오 포맷 체인

경계마다 형식이 다르고 어디서 압축하느냐가 유실 노출과 벤더 한도를 동시에 가른다.

구간형식
브라우저 → realtimeWebM / Opus 32 kbps monoMediaRecorder("audio/webm;codecs=opus")
④ realtime → Soniox받은 것 그대로Soniox 가 webm 을 auto 로 감지한다. 서버가 디코딩하지 않는다
⑤ realtime → S3받은 것 그대로, 30초 오브젝트변환이 없다
조립 함수 → S3.ogg (Opus 그대로)ffmpeg -f concat **-c copy**. 디코딩도 인코딩도 없다
⑧ S3 → pyannote.oggpyannote 가 받고 압축을 권장한다. 5시간이 72 MB 로 1 GiB 한도에 여유가 크다

왜 PCM 에서 Opus 로 뒤집었나 — 대역폭이 아니라 신뢰성 때문이다

녹음을 깨뜨리는 것은 CPU 도 디스크도 아니고 업링크다.

지속 업로드
옛 판 PCM16 24 kHz mono384 kbps
Opus 32 kbps32 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으로 돌아간다
segment30초 타이머 · 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시간제품
티켓 TTL30초, 일회용web → realtime
세션 heartbeat / 만료10초 / 60초realtime → RDS
STOMP heartbeat / WS 유휴10초 / 60초web ↔ realtime
ai keepalive15초 이내ai → api
upstream 유휴 read timeout60초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 과금 단위 · 동시 제출 한도 · 서명 규약
  • 그래프에 무엇을 투영하나
  • 언어 — 한국어 전용인가 자동 감지인가