GPT-Live 동시 음성 인식 및 생성 — 작동 원리와 Java 통합 예제
1. 개요
“GPT-Live”는 Speech-to-Text → LLM → Text-to-Speech를 순차적으로 실행하는 대신, 하나의 음성 스트리밍 채널을 통해 동시에 듣고 말할 수 있는 LLM 모델을 지칭하는 일반적인 용어입니다. 이 글에서는 프로토콜 계층에서 이 full-duplex 메커니즘이 어떻게 작동하는지 설명하고, WebSocket을 연결하고 오디오를 송수신하며 사용자가 말을 끝내기 전에 응답하는 상황(barge-in)을 처리하는 Spring Boot 예제를 구축합니다. 이 글을 읽고 나면, 실시간 음성 API를 호출하는 Java 클라이언트를 직접 작성할 수 있고 프로덕션에 이 기능을 배포할 때의 주의점을 알게 될 것입니다.
2. 기존 파이프라인(STT → LLM → TTS)이 “라이브”가 아닌 이유
이전에는 음성 어시스턴트가 보통 3개의 분리된 모델을 조합했습니다:
Mic → [Speech-to-Text] → text → [LLM] → text → [Text-to-Speech] → Speaker
각 단계는 이전 단계가 완료될 때까지 기다립니다. 지연이 누적되어 보통 2-5초가 되고, 사용자는 시스템이 말하는 도중에 “듣지” 않기 때문에 응답 중간에 말을 끝낼 수 없습니다.
GPT-Live는 세 가지 역할을 모두 하나의 멀티모달 모델로 통합하여 오디오 프레임을 직접 수신하고 생성하며, 지속적인 양방향(duplex) 연결을 통해 이를 수행합니다. “문장이 끝날 때까지 기다리는” 단계가 없습니다 — 모델은 각 작은 오디오 청크(chunk)가 도착하는 즉시 처리하며, 의미론적 신뢰도가 충분하다면 사용자가 문장을 완전히 마치기 전에 응답 음성을 생성하기 시작할 수 있습니다.
주의: 여기서 “라이브”는 지연이 없다는 의미가 아니라, 지연이 파이프라인 끝에 누적되는 대신 각 청크(보통 20-100ms)로 분산된다는 의미입니다.
3. Full-duplex 아키텍처: WebSocket을 통한 이벤트 스트림
REST API(일회성 request-response)와 달리, 실시간 API는 WebSocket을 사용하여 연결을 열린 상태로 유지하고 JSON 형식의 이벤트를 주고받습니다. 두 개의 데이터 스트림이 동시에 실행됩니다:
- Client → server: 오디오 청크(base64 PCM16), 제어 이벤트(음성 시작/종료).
- Server → client: 응답 오디오 청크, 부분 텍스트 기록, “모델이 당신이 말하는 것을 감지했습니다” 이벤트(출력을 자동으로 중지하기 위해).
Client Server (GPT-Live)
|--- audio.append (chunk1) -->|
|--- audio.append (chunk2) -->|
| |-- transcript.delta (partial) -->
|--- audio.append (chunk3) -->|
|<------ response.audio.delta -| (모델이 응답 생성을 시작했습니다)
|--- audio.append (말 끼어들기)-->|
|<------ response.cancelled ---| (서버가 진행 중인 부분을 취소했습니다)
두 오디오 스트림이 단일 소켓에서 동시에 실행되는 것이 바로 “동시에 듣고 말하는” 본질입니다.
4. Spring Boot에서 WebSocket 연결 설정
Spring Boot는 spring-websocket을 통해 WebSocket 클라이언트를 지원합니다. 의존성을 추가합니다:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
실시간 엔드포인트에 연결하기 위한 설정을 만듭니다(예: wss://api.gptlive.example/v1/voice):
@Configuration
public class VoiceSessionConfig {
@Bean
public StandardWebSocketClient webSocketClient() {
return new StandardWebSocketClient();
}
}
StandardWebSocketClient는 Spring의 표준 구현이며, 서블릿 컨테이너가 필요하지 않은 클라이언트 측에 충분합니다.
5. 오디오 청크 전송 및 스트리밍 응답 수신
핵심 아이디어: 마이크가 오디오 세그먼트를 기록할 때마다(예: 20ms), 전체 문장을 기다리지 않고 즉시 서버에 audio.append 이벤트를 보냅니다.
public class VoiceSessionHandler extends TextWebSocketHandler {
private final ObjectMapper mapper = new ObjectMapper();
public void sendAudioChunk(WebSocketSession session, byte[] pcmChunk) throws IOException {
String base64Audio = Base64.getEncoder().encodeToString(pcmChunk);
Map<String, Object> event = Map.of(
"type", "input_audio.append",
"audio", base64Audio
);
session.sendMessage(new TextMessage(mapper.writeValueAsString(event)));
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
JsonNode event = mapper.readTree(message.getPayload());
String type = event.get("type").asText();
switch (type) {
case "response.audio.delta" -> playAudioChunk(event.get("audio").asText());
case "response.text.delta" -> System.out.print(event.get("delta").asText());
case "input_audio.speech_started" -> cancelOngoingPlayback();
default -> { /* 처리할 필요가 없는 이벤트는 무시 */ }
}
}
private void playAudioChunk(String base64Audio) {
byte[] pcm = Base64.getDecoder().decode(base64Audio);
// pcm을 스피커의 오디오 출력 버퍼로 전송
}
private void cancelOngoingPlayback() {
// 현재 재생 중인 오디오 중지 — 이것이 barge-in 메커니즘
}
}
설명:
sendAudioChunk은 마이크 녹음 스레드에서 지속적으로 호출되며(예: 20ms마다) 낮은 지연을 유지하기 위해 배치로 묶지 않습니다.handleTextMessage은 서버에서 이벤트를 수신하는 콜백입니다.response.audio.delta는 모델이 말하고 있다는 뜻이고,input_audio.speech_started는 서버가 사용자가 말하기 시작했음을 감지했다는 뜻입니다 — 이 경우 클라이언트는 즉시 스피커를 중지해야 합니다.event.get("type").asText()는 Jackson의JsonNode를 사용하여 고정된 DTO 정의 없이type필드를 읽습니다 — 이벤트 스키마가 새로운 유형으로 확장될 수 있으므로 유용합니다.
6. Barge-in 메커니즘: 누가 언제 말할 것인가?
Barge-in은 사용자가 AI 응답 중간에 말을 끝낼 수 있는 기능입니다. 이를 구현하려면 서버가 입력 오디오 스트림에서 지속적으로 실행되는 음성 활동 감지기(Voice Activity Detection — VAD)를 가져야 하며, 이는 서버가 오디오를 재생하고 있는지 여부와 무관합니다.
public class TurnStateMachine {
public enum State { IDLE, USER_SPEAKING, MODEL_SPEAKING }
private volatile State state = State.IDLE;
public void onServerEvent(String eventType) {
state = switch (eventType) {
case "input_audio.speech_started" -> {
// 모델이 말하는 중에 사용자가 말함 → 말 끼어들기
if (state == State.MODEL_SPEAKING) {
stopPlaybackImmediately();
}
yield State.USER_SPEAKING;
}
case "input_audio.speech_stopped" -> State.IDLE;
case "response.audio.delta" -> State.MODEL_SPEAKING;
case "response.done" -> State.IDLE;
default -> state;
};
}
private void stopPlaybackImmediately() {
System.out.println("오디오 재생 중지, 서버에 response.cancel 전송");
}
}
설명: TurnStateMachine은 3가지 상태로 누가 “마이크를 잡고 있는지” 추적합니다. input_audio.speech_started가 MODEL_SPEAKING 중에 도착하면, 사용자가 말을 끼어든다는 뜻입니다 — 클라이언트는 즉시 스피커를 중지하고 서버에 진행 중인 응답을 취소하라고 알려야 합니다(response.cancel 이벤트 전송), 이전 오디오와 새 오디오가 겹치는 것을 방지합니다.
주의: Barge-in을 처리하지 않으면 AI가 사용자 위에서 말할 것입니다 — 음성 어시스턴트의 가장 불쾌한 경험입니다. 이는 빠른 데모에서 쉽게 간과되지만 프로덕션에서는 필수입니다.
7. 음성 대화에서 세션 및 컨텍스트 관리
연결이 stateful이므로(통화 내내 유지됨), 트랜스포트 로직과 분리하여 대화 기록과 음성 구성을 저장하기 위한 세션 객체가 필요합니다.
public class VoiceSession {
private final String sessionId;
private final List<ConversationTurn> history = new ArrayList<>();
private final String voice; // 예: "alloy", "verse"
public VoiceSession(String sessionId, String voice) {
this.sessionId = sessionId;
this.voice = voice;
}
public void appendTurn(String role, String transcript) {
history.add(new ConversationTurn(role, transcript, Instant.now()));
}
public record ConversationTurn(String role, String transcript, Instant at) {}
}
세션을 초기화할 때, 오디오 녹음을 시작하기 전에 session.update 이벤트를 보내 음성 음색과 시스템 지침을 설정합니다:
Map<String, Object> sessionUpdate = Map.of(
"type", "session.update",
"session", Map.of(
"voice", "alloy",
"instructions", "당신은 병원 예약을 도와주는 어시스턴트이며, 간단하게 답변하세요.",
"turn_detection", Map.of("type", "server_vad")
)
);
turn_detection: server_vad는 사용자가 문장을 완료할 시기를 감지하는 것을 서버의 VAD로 처리하도록 하며, 클라이언트가 침묵으로 추측하는 대신입니다 — 서버가 의미론적 신호를 사용할 수 있으므로(예: 문법적으로 불완전한 문장은 여전히 진행 중으로 간주) 이것이 더 정확합니다.
8. JUnit으로 turn-taking 로직 테스트하기
Transport 부분(실제 WebSocket)은 단위 테스트하기 어렵지만, turn-taking을 제어하는 상태 머신은 완벽하게 독립적으로 테스트할 수 있습니다.
class TurnStateMachineTest {
@Test
void modelDangNoiDuLucUserBatDauNoi_thiPhaiDungPhatAudio() {
TurnStateMachine machine = new TurnStateMachine();
machine.onServerEvent("response.audio.delta");
assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.MODEL_SPEAKING);
machine.onServerEvent("input_audio.speech_started");
assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.USER_SPEAKING);
}
@Test
void khongAiDangNoi_thiTrangThaiLaIdle() {
TurnStateMachine machine = new TurnStateMachine();
machine.onServerEvent("response.done");
assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.IDLE);
}
}
(TurnStateMachine에 getter getState()를 추가했다고 가정합니다.) 첫 번째 테스트는 말을 끼어들 때 상태 전환이 올바른 순서로 진행되는지 확인합니다. 두 번째 테스트는 응답이 완료된 후 시스템이 IDLE로 돌아가는지 확인합니다 — 이는 다음 대화 루프가 깨끗하게 시작되기 위한 조건입니다.
9. 일반적인 오류 및 사용하지 말아야 할 경우
- 큰 배치로 오디오 전송(예: 2초마다): 스트리밍의 모든 이점을 무효화하고 지연이 다시 이전 파이프라인처럼 됩니다. 항상 작은 청크를 보냅니다(20-100ms).
response.cancel이벤트를 처리하지 않기: 클라이언트에서만 오디오 재생을 중지하고 서버에 취소를 알리지 않으면 서버는 계속 추가 오디오를 생성하여 비용을 낭비하고 세션 상태를 불일치시킬 수 있습니다.- 클라이언트 측 VAD를 사용하여 사용자가 문장을 완료한 시점을 결정하기: 시끄러운 환경에서 문장을 너무 일찍 또는 너무 늦게 자르기 쉽습니다. API가 지원하면 서버 VAD 처리에 맡기는 것이 좋으며, 오프라인 작동이 필요한 경우가 아니면 그렇게 하세요.
- 실시간 상호작용이 필요하지 않은 용도에 적용하기(예: 보고서 생성, 긴 문서 요약): 실시간 음성 모델은 기존 STT/LLM/TTS 파이프라인보다 훨씬 비싸고 복잡하며, 필요 없는 낮은 지연을 위해 그 비용을 감수할 가치가 없습니다.
- UI의 주 스레드에서 WebSocket 클라이언트 실행(오디오 녹음/재생 스레드를 분리하지 않음): JSON 파싱이 오디오 스트림을 차단할 때 끊김 및 지연 문제가 발생하기 쉽습니다.
주의: Full-duplex 음성은 텍스트 토큰으로만 계산하지 않고 통화 내내 지속적으로 대역폭과 API 비용을 소비합니다. 세션 시간 제한 또는 비활성 타임아웃을 고려하세요.
10. 결론
GPT-Live는 열린 상태로 유지되는 WebSocket 연결, 양방향 오디오 청크 전송, 그리고 누가 발언권을 가지고 있는지 결정하기 위해 VAD를 사용하는 것에 기반을 두고 있습니다 — 이것은 기존 STT/LLM/TTS 파이프라인처럼 순차적으로 기다리는 대신 “동시에 듣고 말하는” 것을 가능하게 하는 메커니즘입니다. Java/Spring Boot 애플리케이션에 통합할 때, 주요 작업은 turn-taking 상태 머신을 관리하고, barge-in을 올바르게 처리하며, 세션 로직을 트랜스포트에서 분리하여 테스트를 쉽게 만드는 것입니다. 사용 사례에 실시간 응답이 필요하지 않다면, 기존 파이프라인은 여전히 더 간단하고 저렴한 선택입니다.