들어가며
이번 개인 프로젝트에서는 Python 기반 Streamlit UI에서 GPT Realtime API(WebSocket 기반)를 직접 연동하는 것이 목표였다.
겉보기에는 단순해 보였지만, 실제로는 이벤트 루프 모델의 충돌 이라는 꽤 까다로운 문제에 부딪혀 이러한 문제들과 어떻게 해결했는지를 정리해보려 한다.
이 글에서는
- Streamlit 환경에서 WebSocket 기반 SDK(OpenAI Realtime)를 직접 쓰기 어려웠던 이유
- 왜 단순한 “스레드 분리”로는 해결되지 않았는지
- 결국 메시지 큐 + 워커 스레드 구조로 문제를 풀게 된 과정 을 중심으로, 내가 실제로 겪은 고민과 선택을 정리해본다.
시작하기 전에..
그 전에 gpt-realtime API에 대해 이해를 돕기 위해 간단한 설명을 붙인다.
GPT-Realtime은 voice-to-voice에서 GPT API를 사용하는 과정에서 가지는 개발자들의 문제를 해결하기 위해 개발된 API이다.
gpt api가 가졌던 기존의 문제라고 함은 기존의 음성 에이전트 구축 방식은 여러 개의 개별 모델을 연결하는 파이프라인 형태로 이루어졌다는 것이 문제였다.
이러한 파이프라인은
텍스트 → STT로 텍스트 변환 → 해당 텍스트를 LLM에 전달 → 모델의 텍스트 출력 → TTS로 음성으로 변환 → 음성 와 같은 형태로 voice-to-voice를 구현했다. 이전에 진행했던 꼬꼬면 프로젝트의 경우도 이와 같은 방식으로 음성의 면접관을 구현했다.
하지만 이러한 일련의 데이터 처리 과정을 거치는 파이프라인은 레이턴시가 길어질 수밖에 없었다. 이떄문에 인간처럼 즉각적이고 자연스럽게 반응하는 상호작용형 대화에는 맞지 않았다는 이야기가 많았다. gpt-Realtime은 이러한 문제를 해결하기 위해 나온 API이다.
gpt-realtime API의 특징
-
오디오 입력 → gpt-realtime → 오디오 출력의 형태로 출력된다. 따라서 기존의 파이프라인을 전부 벗겨내고 하나의 connection으로 대체할 수 있는 것이다. 가지는 connection은 무조건 음성이 아닌, text와 음성 모두를 지원하기 때문에 여러 상황에 대처할 수 있다.

-
주로 Speech-to-speech interactions에 특화되어 있다
- 그렇기 때문에 오디오 스트리밍을 위한 저지연이 필요하고, 이때문에 WebRTC를 사용하여 지연을 최대한으로 줄이는 방식을 추천한다.
-
mutlimodal inputs(audio, image, text)를 지원한다
문제 상황: Streamlit과 Websocket 기반 gpt-realtime의 부조회
OpenAI SDK의 WebSocket의 이벤트 루프
GPT Realtime API는 내부적으로 WebSocket + 이벤트 루프를 사용한다.
문제는 이 이벤트 루프를 사용자가 세밀하게 제어할 수 없다는 점이다.
즉,
- SDK 내부에서 이벤트 루프를 돌리고
- 메시지 수신, partial response, done 이벤트 등을 처리하지만
- 이 흐름을 내가 원하는 타이밍에 끼워 넣거나, UI 렌더링과 직접 엮기 어렵다 라는 구조였다.
Streamlit도 자체적인 실행 모델을 가진다
Streamlit은 일반적인 웹 프레임워크와 다르게, 스크립트 전체를 위에서 아래로 다시 실행(re-run) 하는 구조이고, UI 변경은 이벤트 핸들러가 아니라 상태(state) 변경 → 재실행을 통해 이루어진다. 이러한 일련의 과정은 개별적인 이벤트 루프를 통해 조작한다. 즉, 메인 스레드에서만 UI 조작이 가능하다는 의미인데, 문제는 이벤트루프를 이미 가지고 있기 때문에 websocket의 이벤트 루프와 함께 돌리면서 처리하는 구조가 매우 복잡하다는 것이다.
처음 시도: WebSocket을 워커 스레드로 빼면 되지 않을까?
가장 먼저 떠오른 접근은 단순했다.
“그럼 WebSocket은 워커 스레드에서 돌리고, 메인 스레드는 UI만 그리면 되지 않나?”
하지만 바로 벽에 부딪혔다.
문제 1. 워커 스레드에서는 Streamlit UI를 건드릴 수 없다
Streamlit은 메인 스레드 외부에서 UI를 직접 조작하는 것을 허용하지 않는다.
워커 스레드에서 메시지를 받는 건 가능하지만 st.write, st.markdown, st.session_state 변경 등을 직접 호출하면 예외가 나거나, 아무 일도 일어나지 않았다.
문제 2. 이벤트 기반 UI 갱신이 불가능하다
WebSocket은 기본적으로 이런 흐름이다.
on_message → partial response
on_message → partial response
on_message → done하지만 Streamlit에는 “on_message 이벤트에 맞춰 UI를 즉시 갱신”하는 구조가 없다. 스트레스..
결국 이 말은 워커 스레드에서 받은 이벤트를 메인 스레드가 어떤 방식으로든 polling 해서 처리해야 한다는 의미기도 하다.
Decision: 메시지 큐를 중심에 둔 구조로 전환
결국 선택한 구조는 아래와 같다.

- **WebSocket은 워커 스레드에서 처리
- UI 갱신에 필요한 정보만 메시지 큐로 넘기기
- 메인 스레드는 while 루프로 큐를 소비하면서 UI를 갱신한다.**
이러한 구조에 대해서 더 자세히 알아보자.
1. 워커 스레드의 역할
워커 스레드는 오직 WebSocket 처리만 담당한다.
- GPT Realtime API 연결
- 메시지 수신
- partial response / done 이벤트 파싱
- UI와 무관한 로직만 수행
그리고 중요한 점은,
UI에 반영해야 할 정보만 메시지 큐에 넣는다
queue.put({
"type": "delta",
"text": "생성 중인 문장 일부"
})와 같은 형식의 데이터만 메시지 큐에 넣고, 메인 스레드가 이러한 메시지 큐를 보면서 차례대로 뺴내어 UI 업데이트를 실행하는 것이다.
2. 메시지 큐 포맷
메시지는 명확한 타입을 갖도록 설계했다
delta: 스트리밍 중인 텍스트done: 응답 완료 신호
{ "type": "done", "text": "최종 완성 문장" }이 done 메시지는 굉장히 중요하다. 메인 스레드가 while 루프를 빠져나갈 수 있는 유일한 종료 조건이기 때문이다.
3. 메인 스레드: while 루프로 큐를 소비
Streamlit 메인 스레드에서는 다음과 같은 흐름을 갖는다.
- 사용자가 요청을 보낸 후 워커 스레드를 시작시킨다.
- 메시지 큐가 빌 때까지 while 루프 실행한다.
- 큐에서 메시지를 하나씩 꺼내 UI 상태 업데이트
{ type: "done" }을 받으면 루프 종료 의 흐름을 통해서 스트리밍을 처리한다.
while True:
msg = queue.get()
if msg["type"] == "delta":
st.session_state.text += msg["text"]
st.rerun()
elif msg["type"] == "done":
break이 방식으로 한 번의 요청 → 스트리밍 응답 → 완료 를
메시지 큐 하나로 깔끔하게 관리할 수 있었다.
Consequences: 이 구조의 대가
장점
- Streamlit의 실행 모델을 억지로 바꾸지 않아도 된다
- WebSocket 이벤트를 UI 이벤트처럼 “번역”할 수 있다
- Realtime API의 스트리밍 특성을 비교적 자연스럽게 표현 가능하다
단점
while루프를 계속 도는 구조라 오버헤드가 신경 쓰인다- 사실상 polling 기반 아키텍처다
- Streamlit이 이벤트 루프를 공식적으로 지원하지 않는 이상, 더 우아한 해법은 찾기 어려웠다. 아무래도 streamlit이라는 UI 라이브러리 자체가 데아터 사이언티스트들이 데이터를 쉽게 표현하기 위한 도구로 만들어졌다보니 더 저수준으로 처리하기에는 난이도가 굉장히 높아졌다…
하지만 현실적으로는,Streamlit + WebSocket SDK + 실시간 스트리밍 UI 의 조합을 동시에 만족시키는 방법은 거의 없었고, 이 방식이 가장 예측 가능하고 안정적인 선택이었다고 생각한다..
마치며
이번 작업을 하면서 느낀 점은 분명했다. Streamlit은 “이벤트 기반 실시간 앱”보다는 상태 기반 선언적 UI에 훨씬 가까운 도구라는 점이다. 이걸 간과하고 해당 라이브러리를 사용하면서 피눈물을 흘렸다..
그래서 WebSocket 같은 강한 이벤트 드리븐 모델을 붙이려면, 중간에서 그 성격을 완충해주는 계층이 필요하다고 생각했다. 이번 경우에는 그 역할을 메시지 큐가 맡은 것이다.
솔직히 완벽하게 우아한 구조는 아니지만, 문제를 이해하고, 도구의 한계를 받아들인 상태에서 선택한 합리적인 타협이라고 생각한다.
비슷하게 Streamlit에서 실시간 스트리밍이나 WebSocket 연동을 고민하고 있다면,
이 경험이 하나의 참고 사례가 되었으면 좋겠다.