23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
우리는 다양한 상황에서 음성으로 AI와 대화하고 있습니다. 대부분의 음성 챗봇은 사용자의 입력이 끝나야 다음 단계가 진행되는 순차 처리 방식입니다.
STT (Speech-to-Text): 사용자의 음성을 텍스트로 변환
LLM (Large Language Model): 변환된 텍스트를 기반으로 AI 응답 생성
TTS (Text-to-Speech): 생성된 텍스트 응답을 다시 음성으로 합성
이 구조는 음성을 → 텍스트로 → 생각하고 → 다시 음성으로 바꾸는 방식으로 동작하고 있습니다.
그런데 이 구조에는 두 가지 한계를 가지고 있습니다.
음성 응답 중 말하기 불가
챗봇이 음성으로 응답하는 동안에는 사용자가 말을 시작할 수 없습니다.
즉, 사람이 대화 중 끼어들 듯 말하는 barge-in 기능이 지원되지 않아, 실제 대화처럼 자연스럽게 주고받는 흐름이 어렵습니다.
누적되는 지연 시간
음성 입력 → 텍스트 변환(STT) → 응답 생성(LLM) → 음성 합성(TTS) 과정을 순차적으로 거치기 때문에, 각 단계의 처리 시간이 누적되며 전체 응답 속도에 영향을 줍니다.
사람과의 자연스러운 대화처럼, 말이 겹치고 바로 반응하는 진짜 실시간 대화란 어떤 모습일지에 대해 고민해보게 되었습니다.
여기서 말하는 ‘실시간’은 단순히 빠른 응답이 아니라, 사용자와 챗봇이 동시에 말하고, 끊고, 반응하는 자연스러운 상호작용을 의미합니다.
이러한 경험이 가능할지 탐색하는 과정에서, 우리는 OpenAI의 Realtime API를 테스트해보며 그 가능성을 살펴보게 되었습니다.
OpenAI Realtime API는 멀티모달 모델(GPT-4o 및 mini) 을 기반으로 한 초저지연(ultra low-latency) 양방향 인터페이스입니다.
WebSocket 또는 WebRTC 연결로 텍스트와 오디오를 동시에 스트리밍하고, 실시간 음성 대화를 구현할 수 있게 만들어졌습니다. (Speech-to-Speech)
기능 | 설명 |
|---|---|
실시간 STT | 오디오 스트림을 실시간 텍스트로 변환 (GPT-4o Transcribe) |
LLM | 입력 텍스트 기반 GPT-4o/mini 모델의 실시간 응답 생성 |
실시간 TTS | 응답 텍스트를 자연스러운 음성으로 합성 |
Voice Activity Detection (VAD) | 사용자의 발화 시작, 종료 시점을 자동 감지 응답 도중 사용자가 끼어들면 바로 입력 전환 |
OpenAI Realtime API는 다음 두가지 연결 방식을 지원합니다.
방법 | 용도 |
|---|---|
WebRTC | 클라이언트에서 바로 오디오를 보내고 싶은 경우, 별도 백엔드 구축 없이 빠르게 데모 구현할 때
|
WebSocket | 백엔드에서 OpenAI Realtime API를 붙이는 경우
|
우리는 기존의 백엔드 시스템에서 관리할 수 있도록 WebSocket을 활용하여 서버 - 서버 연동을 테스트해보았습니다.
클라이언트에서 실시간으로 음성 데이터를 서버로 전송하면, 서버는 이를 OpenAI Realtime API에 전달하고, 응답 결과(음성 또는 텍스트)를 다시 실시간으로 클라이언트에 전달하는 구조입니다.
서버는 이 과정에서 중계와 함께 DB에서 프롬프트 설정을 조회하거나, 로그를 저장하는 등 비즈니스 로직도 함께 처리할 수 있습니다.
그리고 OpenAI Realtime API와는 WebSocket 연결을 유지한 채, 스트림 기반으로 상시 통신하고 있습니다.
우리가 목표로 했던 것은 WebSocket 기반 OpenAI Realtime API와의 실시간 양방향 통신을 안정적이고 명시적으로 다루는 것이었습니다.
WebSocket을 사용하면 다음과 같은 이유로 구현이 복잡해질 수 있는데요.
하나의 스트림에서 동시에 송수신이 일어나야 합니다. (별도의 스레드, 비동기 핸들링 구조 필요)
연결 종료, 네트워크 오류, 타임아웃 등 다양한 실패 케이스가 송수신 양쪽에서 다르게 발생하므로 예외 처리가 분산될 수 있습니다.
연결 상태를 mocking하거나 제어하기 어렵습니다.
이를 위해 Go에서 제공하는 채널(channel) 과 고루틴(goroutine) 을 활용했습니다.
Go의 chan(채널)은 Goroutine 간 데이터를 주고받을 수 있는 안전한 통신 수단입니다.
간단히 말하면, 두 개의 흐름 사이에 데이터를 흘려보내는 파이프 같은 개념입니다.
ch := make(chan string)
go func() {
ch <- "Hello" // 채널에 메시지 전송
}()
msg := <-ch // 채널에서 메시지 수신
fmt.Println(msg) // "Hello"goroutine Go의 초경량 스레드로, 함수 하나를 동시에 실행시키는 비동기 작업 단위입니다.
수천 개를 띄워도 메모리 부담이 거의 없기 때문에, WebSocket처럼 동시에 여러 작업이 일어나는 환경에서 특히 유용합니다.
go handleWebSocket() // 이 함수는 메인과 별도로 실행됨그래서 우리는 Go의 chan과 goroutine 조합을 사용해 읽기와 쓰기, 에러 처리, 종료 흐름을 명확히 분리하고 구조화했습니다.
그 결과, 내부 로직은
채널에 밀어 넣고
채널에서 꺼내는
단순한 구조로 구성할 수 있었습니다.
이처럼 데이터 흐름을 명확히 분리한 덕분에 WebSocket의 읽기/쓰기 처리뿐 아니라 에러 핸들링과 종료 처리까지도 직관적으로 구현할 수 있었고, 테스트 작성 역시 수월해졌습니다
type RTSession struct {
Conn *websocket.Conn
In chan []byte // Backend → Realtime
Out chan []byte // WebSocket → Realtime
}
func (s *RTSession) Listen(ctx context.Context) {
for {
select {
case <-ctx.Done():
logger.Info().Msg("Listen: context done")
return
default:
_, msg, err := s.Conn.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
logger.Info().Msg("Listen: WS closed normally")
return
}
logger.Error().Err(err).Msg("Listen: read error")
continue
}
s.Out <- msg // Realtime으로부터 메시지를 받음
}
}
}
func (s *RTSession) Write(ctx context.Context) {
for {
select {
case <-ctx.Done():
logger.Info().Msg("Write: context done")
return
case msg := <-s.In: // Realtimed으로 메시지를 전송
if err := s.Conn.WriteMessage(websocket.BinaryMessage, msg); err != nil {
logger.Error().Err(err).Msg("Write: write error")
continue
}
}
}
}
// Listen: Realtime에서 받은 메시지를 session.Out으로 계속 밀어 넣음
go session.Listen(ctx)
// Write: session.In에 들어온 메시지를 Realtime으로 계속 보냄
go session.Write(ctx)이후 서비스 로직에서는 session의 In과 Out 채널을 통해 메시지를 주고받기만 하면 되므로, WebSocket 처리에 직접 관여하지 않고도 실시간 흐름을 간결하고 편리하게 구현할 수 있습니다.
// 1. 클라이언트 오디오 입력을 Realtime API로 전송
go func() {
for {
session.In <- clientAudioStream
}
}()
// 2. Realtime API 응답을 클라이언트에 전송
go func() {
for msg := range session.Out {
sendToClient(msg)
}
}()이번 글에서는 OpenAI Realtime API가 무엇인지, 그리고 어떤 상황에서 활용할 수 있는지부터 이를 Go의 goroutine과 channel을 활용해 실시간으로 연결하는 구조까지 함께 살펴보았습니다.
복잡할 수 있는 WebSocket 스트림도, Go의 채널을 활용하면 직관적인 흐름으로 추상화할 수 있다는 것을 확인할 수 있었고,
이를 통해 실시간 음성 응답 흐름을 안정적이고 효율적으로 구현할 수 있었습니다.
Realtime API는 현재 베타(Beta) 단계로, 지속적으로 발전 중인 기술입니다.
실제 서비스에 안정적으로 적용하기 위해서는 프롬프트 설정, 보이스 옵션, 그외 여러 파라미터 등 다양한 기능들을 상황에 맞게 테스트하고, 최적의 조합을 찾아가는 과정이 필요했습니다.
하지만 그만큼 앞으로 더 많은 기능이 추가되고, 더 나은 성능과 커스터마이징 옵션들이 제공될 것이라는 기대도 큽니다.
Go의 channel과 goroutine은 이러한 실시간 스트리밍 구조에 매우 강력한 도구입니다.
이번 예제를 바탕으로, 여러분의 프로젝트에서도 채널 기반의 구조를 다양하게 시도해 보시길 추천드립니다. 😄
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.