병목 현상을 줄이기 위한 실시간 아키텍처 설계
— SpeakNote의 설계 선택과 그 이유
https://ye-seul0-0.tistory.com/185
[CS | 실시간 시스템] 병목이 발생하는 이유
실시간 시스템에서 병목이 발생하는 이유실시간 시스템을 처음 설계할 때 가장 많이 착각하는 부분 중 하나는“성능을 충분히 높이면 병목은 없앨 수 있다”는 생각입니다.저 역시 SpeakNote 프로
ye-seul0-0.tistory.com
이전 글에서는 실시간 시스템에서 병목 현상이 왜 필연적으로 발생하는지, 그리고 병목이 단순한 성능 문제가 아니라 구조적인 문제라는 점을 정리했습니다.
이번 글에서는 그 연장선에서, SpeakNote에서는 병목을 줄이고, 병목이 전체 시스템으로 확산되는 것을 막기 위해 어떤 아키텍처를 선택했는지를 실제 설계를 기준으로 설명하고자 합니다.
SpeakNote는 실시간 음성 입력 → STT → AI 요약 → 주석 렌더링이라는 여러 단계가 연속적으로 연결된 시스템입니다.
이 구조에서 병목은 특정 코드의 문제가 아니라, 입력 속도와 처리 속도가 다른 여러 단계가 동시에 연결되면서 자연스럽게 발생합니다.
따라서 이 시스템의 핵심 설계 목표는 다음과 같았습니다.
- 병목이 발생해도 전체 흐름이 무너지지 않을 것
- 한 사용자의 지연이 다른 사용자에게 전파되지 않을 것
- 실시간성을 “즉시 반영”이 아니라 “체감 가능한 지연 범위”로 정의할 것
병목을 없애는 것이 아니라,
병목이 발생하더라도 시스템이 무너지지 않도록 만든다.
SpeakNote 전체 아키텍처 개요

(Java Backend – Python Backend – Google STT Streaming 구조)
SpeakNote는 크게 세 계층으로 나뉩니다.
- 클라이언트 (Browser)
실시간 오디오 캡처 및 주석 렌더링 - 애플리케이션 계층 (Spring Boot)
WebSocket 기반 실시간 처리 + REST 기반 비실시간 처리 - AI 처리 계층 (Python / FastAPI)
문서 파싱, 요약, 주석 생성 등 고비용 연산
이 중 병목 관리의 핵심은 Java Backend 내부의 세션 단위 실시간 처리 구조에 있습니다.
1. WebSocket 입력 단계: 병목은 ‘속도’가 아니라 ‘유입 제어 불가’에서 시작된다
사용자의 음성 입력은 WebSocket을 통해 Java 서버로 전달됩니다.
WebSocket은 연결 유지형 프로토콜이기 때문에, 서버가 클라이언트의 입력 속도를 직접 제어하기 어렵습니다.
즉, 사용자가 빠르게 말할수록 오디오 청크는 밀리초 단위로 계속 유입됩니다.
이 단계에서 발생할 수 있는 병목은 다음과 같습니다.
- 서버가 다음 단계(STT 전송)를 처리하지 못해도
- 오디오 입력은 계속 들어오고
- 결과적으로 메모리 누적 또는 지연 전파가 발생
이를 해결하기 위해 “즉시 처리” 대신 “즉시 큐잉” 구조를 선택했습니다.
2. 세션 단위 Inbound Queue: 병목을 분리하는 첫 번째 장치
각 WebSocket 연결은 서버 내부에서 독립된 SessionState로 관리됩니다.
그리고 각 세션마다 다음 구성 요소를 가지게 구성했습니다.
- InboundAudioQueue
- STT Text Buffer
- OutboundQueue
- 세션 전용 워커 스레드
- 세션 전용 Google STT gRPC 스트림

WebSocket 핸들러는 오디오 청크를 수신하자마자 바로 InboundAudioQueue에 넣고 반환합니다.
이렇게 함으로써 WebSocket I/O 스레드는 음성 처리 로직에 의해 블로킹되지 않습니다.
이 구조의 핵심은 다음과 같습니다.
- 입력 폭주가 발생해도 큐까지만 영향
- STT 처리 지연이 발생해도 입력 수신은 지속
- 세션 간 큐가 분리되어 한 사용자의 병목이 다른 사용자에게 전파되지 않음
즉, 병목을 “없앤 것”이 아니라 영향 범위를 세션 단위로 가둔 것!!
3. Google STT Streaming: 병목이 발생해도 흐름이 끊기지 않게
WebSocket 단계에서 입력 폭주(backpressure 부재)가 병목의 출발점이라면, 그 다음 단계인 Google STT 스트리밍은 “지연이 발생했을 때 시스템 전체로 전파되는가”를 결정하는 구간이다. 특히 STT는 네트워크 I/O와 외부 API 응답 지연에 크게 영향을 받는 구간이므로, 단일 스트림 또는 공유 스트림 구조로 설계할 경우 특정 사용자의 STT 지연이 곧바로 전체 서버의 지연으로 확산될 위험이 큽니다. SpeakNote는 이 문제를 피하기 위해 세션 단위로 STT 스트림을 완전히 분리하고, STT 처리는 세션 전용 워커가 전담하도록 구성했습니다.

3.1 세션별 STT gRPC 스트림 분리 설계
SpeakNote에서 각 WebSocket 세션(Session A/B/N)은 독립적인 Google STT gRPC 스트림을 가집니다. 이는 “단일 클라이언트 인스턴스 또는 단일 스트림을 여러 세션이 공유”하는 방식에서 자주 발생하는 충돌/경합 문제를 회피하기 위한 선택입니다. 스트리밍 STT는 기본적으로 한 스트림 내에서 오디오 프레임의 순서와 타이밍이 중요하며, 여러 세션의 오디오가 섞이거나 동시 요청이 엉키는 순간 인식 품질 저하뿐 아니라 스트림 정지, 응답 꼬임과 같은 장애로 이어질 수 있는데 따라서 SpeakNote는 세션당 하나의 스트림을 열어 “세션 내 순서 보장”과 “세션 간 격리”를 동시에 만족시키는 방향을 택했습니다.
이 구조의 핵심은 단순히 스트림을 분리하는 것에서 끝나지 않는데, STT 스트림으로 오디오를 전송하는 역할은 WebSocket 핸들러가 아니라 세션 전용 워커(Session Worker)가 담당합니다. 즉, WebSocket I/O 스레드가 STT 호출을 직접 수행하지 않고, 입력 큐에 적재한 뒤 빠르게 반환함으로써 서버의 연결 유지 성능을 확보하는 것.
3.2 세션 전용 워커의 역할과 처리 흐름
세션 전용 워커는 해당 세션의 STT 처리에서 다음 역할만 수행하도록 책임을 제한했습니다.
- InboundAudioQueue에서 오디오 청크를 소비(consume)
WebSocket으로 수신된 오디오 청크는 즉시 InboundAudioQueue에 적재되고, 워커는 이 큐를 FIFO로 소비한다. 이때 큐는 순서를 보장하며, 세션 내 오디오 흐름이 뒤섞이지 않도록 한다. - 해당 세션의 STT gRPC 스트림으로 순서대로 전송
워커는 큐에서 꺼낸 청크를 해당 세션의 gRPC 요청 스트림에 순차적으로 write한다. “오디오 입력 순서가 그대로 STT 스트림 전송 순서”가 되기 때문에, 스트리밍 인식 모델이 기대하는 입력 흐름을 깨뜨리지 않는다. - STT 응답은 비동기 콜백으로 수신하고 TextBuffer에 누적
STT의 partial/final transcript 응답은 비동기 observer 콜백으로 수신된다. 응답이 도착할 때마다 세션의 TextBuffer를 갱신하고, final 결과가 확정되면 해당 문장을 누적하여 이후 요약/주석 생성 트리거의 입력으로 사용한다.
이 설계는 “STT 지연이 발생하더라도 WebSocket 서버가 멈추지 않는 구조”를 만들기 위한 의도적인 분리인데, WebSocket I/O 스레드는 오디오를 받는 역할만 최소 비용으로 수행하고, 실제 STT 전송과 응답 처리는 세션 워커 및 비동기 콜백으로 분산되어 결과적으로 STT가 느려지는 순간에도 WebSocket 서버의 주요 스레드가 외부 호출로 블로킹되지 않습니다.
3.3 STT 지연이 전체 시스템으로 전파되지 않는 이유
이 구조가 병목을 “해결”한다기보다는, 병목이 생겼을 때 영향 범위를 세션 내부로 격리한다는 점이 중요한데, SpeakNote는 STT 지연이 발생해도 다음 조건을 만족하도록 설계했습니다!
STT 응답이 늦어져도 입력 큐는 유지됨.
STT 호출이 느려지면 워커의 소비 속도가 감소하고 InboundAudioQueue에 청크가 쌓이기 시작하지만 그러나 이 누적은 세션 단위 큐에서 발생하며, WebSocket 서버 전체가 멈추는 형태가 아니라 “해당 세션의 처리 지연”으로 국한됩니다.
- 지연은 세션 내부에서 흡수된다(세션 격리)
세션 A의 STT가 느려졌다고 해서 세션 B의 STT 전송이나 응답 처리에 간섭하지 않는다. 스트림, 큐, 워커가 모두 세션 전용이기 때문에 STT 지연은 공유 자원 경합으로 확대되지 않는다. - WebSocket 서버는 계속 연결을 유지한다
WebSocket 핸들러는 오디오를 큐에 넣고 빠르게 반환한다. 즉, STT 응답이 늦어도 서버의 연결 유지 능력은 유지되며, 다른 세션의 오디오 수신/전송이 블로킹되지 않는다.
결과적으로 SpeakNote에서 Google STT Streaming 구간은 “지연이 발생할 수밖에 없는 외부 I/O”라는 현실을 인정하고, 그 지연이 서버 전체 다운/정지로 번지지 않도록 설계한 단계라고 정리할 수 있습니다. 병목이 완전히 사라지는 것은 아니지만, 지연이 발생해도 서비스가 끊기지 않고, 문제 범위를 특정 세션으로 제한하며, 전체 파이프라인을 지속 가능하게 만듭니다.

4. AI 요약 단계: 병목을 ‘비동기 분리’로 관리
AI 요약은 전체 파이프라인 중 가장 느린 단계입니다.
따라서 이 단계는 절대 실시간 흐름에 직접 연결되지 않도록 설계함
- STT 텍스트 누적 → 조건 충족 시 요약 요청
- 요약 요청은 비동기 HTTP로 Python 서버에 전달
- 응답은 즉시 WebSocket으로 보내지 않고 OutboundQueue에 적재
이렇게 함으로써,
- 요약이 느려져도 STT·입력 처리는 계속 진행
- 요약 결과 전송 지연은 출력 큐에서 흡수
- 필요 시 오래된 결과를 드롭하여 최신성 유지
즉, 가장 큰 병목 구간을 파이프라인 바깥으로 밀어낸 구조입니다!
4.1 Python 계층에서의 병목 조건 정의
애플리케이션 계층(Java)으로부터 Python 서버는 다음 두 가지 형태의 입력을 지속적으로 수신합니다.
- STT 텍스트 조각(msg): 수 초 간격으로 꾸준히 도착하는 짧은 문자열
- 문서 경로(doc_path): 세션당 한 번 업로드되는 비교적 무거운 전처리 대상
이때 병목이 발생하는 조건은 명확한데,
AI 주석 생성은 단일 요청 처리 시간이 입력 간격보다 길어 예를 들어, 음성 인식 결과가 3초마다 들어오지만, AI 주석 생성에는 10초 이상이 걸릴 수 있다면, 단순한 동기 처리나 즉시 비동기 호출 방식은 필연적으로 요청 누적과 지연 폭증으로 이어집니다. 이 문제를 해결하지 않으면 “처리 자체는 되지만, 시간이 지날수록 응답이 끝없이 밀리는 구조”가 됩니다.
4.2 Task Manager 기반 큐잉: 요청을 즉시 처리하지 않는다
SpeakNote의 Python 계층은 들어오는 모든 요청을 즉시 LLM 호출로 넘기지 않고 대신, Task Manager를 중심으로 한 작업 큐 기반 구조를 도입했습니다. 이 구조의 핵심은 “요청을 바로 처리하지 않고, 처리 가능한 단위로 묶어 관리한다”는 점입니다.
Task Manager는 다음과 같은 책임을 가집니다.

- 들어오는 요청을 Chat Task / Context Task로 분리
- 요청을 Max_process_num 단위의 묶음(batch)으로 구성
- 일정 시간(patient time) 동안 요청이 충분히 모이지 않더라도 강제로 flush
- 큐에 들어간 작업만 백그라운드에서 비동기 실행
이 방식은 단순한 비동기 호출과 다른데, 단순히 asyncio.create_task()를 요청마다 호출하면, 요청 폭주 시 태스크 수가 무제한으로 늘어나 오히려 시스템이 불안정해지지만 반면 Task Manager는 병렬 처리의 상한을 명시적으로 제한하고, 작업 생성 시점을 제어함으로써 병목이 “지연”으로만 나타나고 “장애”로 확대되지 않도록 하는 것입니다.

4.3 세션 단위 격리와 순서 보장

Python 계층에서 중요한 또 하나의 설계 원칙은 세션 단위 순서 보장입니다. SpeakNote는 단일 사용자의 발화 흐름이 의미적으로 연결되어 있다는 점을 전제로 해야하기 때문에 따라서 동일 세션에서 발생한 STT 텍스트들은 순서를 유지한 채 처리되어야 하며, 동시에 다른 세션의 요청과는 병렬로 처리될 수 있어야 합니다.
이를 위해 Chat Task는 내부적으로 세션 ID를 기준으로 관리되며, 같은 세션의 요청은 논리적으로 연결된 작업 흐름을 유지합니다. 반면 서로 다른 세션의 Chat Task들은 Task Manager에 의해 병렬로 실행될 수 있습니다. 이 구조 덕분에 특정 사용자의 요청이 느리게 처리되더라도, 다른 사용자의 요청이 그 영향을 받지 않습니다.
4.4 문서 전처리 병렬화: 무거운 작업을 한 번에 끝내지 않는다
문서 전처리는 AI 계층에서 가장 무거운 작업 중 하나
SpeakNote는 문서 내 텍스트뿐 아니라 도표, 차트, 수식과 같은 시각 정보를 LLM을 통해 자연어로 변환하는 과정을 포함하는데 이 과정은 필연적으로 긴 지연을 발생시키며, 단일 스레드로 처리할 경우 사용자 체감 대기 시간이 급격히 늘어납니다.
이를 완화하기 위해 문서 전처리는 내부적으로 카테고리 단위 비동기 병렬 처리로 설계되었습니다.

문서에서 figure, chart, equation과 같은 시각 정보들을 먼저 수집한 뒤, 각 카테고리에 대해 비동기 함수로 전처리를 수행, 모든 카테고리에 대한 전처리가 완료되면 결과를 하나의 Context 객체로 통합합니다.

이 구조는 문서 전처리 자체를 빠르게 만드는 것이 목적이 아니며 핵심은 “문서 전처리로 인한 지연이 전체 시스템을 막지 않도록 분리”하는 데 있습니다. 전처리는 Context Task로 분리되어 백그라운드에서 처리되며, 채팅 기반 주석 생성(Chat Task)과는 독립적으로 관리합니다.
4.5 느린 AI 연산을 실시간 흐름에 맞추는 방식
SpeakNote의 Python 계층은 단일 요청 기준으로 보면 결코 빠르지 않습니다. 오히려 의도적으로 “느릴 수밖에 없는 구조”를 인정하고, 그 느림이 사용자 경험에 직접적으로 누적되지 않도록 설계했습니다. 첫 응답은 반드시 지연이 발생하지만, 이후에는 Task Manager와 비동기 병렬 구조를 통해 요청 간 간격보다 빠른 속도로 응답을 지속적으로 반환할 수 있습니다.
즉, 이 계층에서의 병목은 제거의 대상이 아니라 관리의 대상
병목을 숨기지 않고, 병목이 발생해도 시스템이 안정적으로 동작하도록 구조적으로 흡수하기 때문에 그 결과 SpeakNote는 다중 사용자 환경에서도, 단일 사용자 환경에서도 “응답이 밀리다 멈추는 시스템”이 아니라 “느리더라도 끊기지 않는 시스템”으로 동작할 수 있습니다.