새길 프로젝트에서 AI 대화 연습 기능을 구현하면서, 기존 REST + TTS 방식 대신 OpenAI Realtime API(WebSocket)를 붙여 대화 사용자 발화 이후 AI의 응답 지연 속도를 11초대 → 3초 수준으로 줄였다. 이 글에서는 그 과정에서 사용한 구조와 안드로이드 코드 구성을 정리한다.
AI 전화처럼 가상 대화하는 기능을 만들려고 한다.

처음에는 다음과 같은 구조였다.

이 과정에서

기존에도 OpenAI의 Assistants API을 활용해서 기능을 구현했었는데, OPEN AI에서 WebSocket으로 실시간 소통을 할 수 있는 API가 새로 나왔다고 해서 당장 써봤다
OpenAI Realtime API는 WebSocket을 통해 음성·텍스트를 저지연으로 주고받는 인터페이스를 제공한다.
이걸 이용하면 음성 종료 후 서버 응답까지의 체감 딜레이를 3초 정도까지 줄일 수 있다.
이번 구현은 “모바일에서 직접 OpenAI에 WebSocket으로 붙되, 인증은 백엔드에서 처리”하는 구조다.
백엔드
client_secret.value(ephemeral key)를 모바일에 내려줌 (OpenAI Platform)안드로이드
GET /realtime/token 같은 엔드포인트로 client_secret 요청wss://api.openai.com/v1/realtime?...로 WebSocket 연결레이어 별로 보면 Saegil-Android PR 구조랑 비슷하게 나뉜다.
RealTimeService, RealTimeServiceImpl, RealTimeRepositoryImplRealTimeRepository, StartRealtimeChatUseCase, EndRealtimeChatUseCase, GetRealTimeTokenUsecaseAiConversationViewModel, AiConversationScreen 등Realtime API는 바로 API 키를 클라이언트에 노출하지 않고, 짧은 수명의 “client_secret(=ephemeral key)”를 발급해 사용하는 방식을 권장한다. (OpenAI Platform)
백엔드에서는 대략 다음 흐름으로 동작한다.
POST https://api.openai.com/v1/realtime/sessions
Authorization: Bearer {SERVER_SIDE_OPENAI_API_KEY}
Content-Type: application/json
{
"model": "gpt-4o-realtime-preview-2024-12-17",
"voice": "alloy"
}
client_secret.value를 꺼냄{
"id": "session_...",
"client_secret": {
"value": "ek_1234567890...",
"expires_at": 1738015688
}
}
value를 모바일에 내려주는 간단한 API 구현// 예시 응답
{
"clientSecret": "ek_1234567890..."
}
Saegil PR에서는 이 응답을 GetRealTimeApiTokenResponse 같은 DTO로 파싱하고, AssistantServiceImpl / RealTimeRepositoryImpl에서 사용하도록 분리해뒀다.
안드로이드 쪽에서는 먼저 Retrofit으로 토큰을 받아오는 부분을 만든다. (패키지 구조는 data/domain/presentation 그대로 유지)
// data/remote/AssistantService.kt
interface AssistantService {
@GET("/realtime/token")
suspend fun getRealTimeToken(): GetRealTimeApiTokenResponse
}
// data/model/GetRealTimeApiTokenResponse.kt
data class GetRealTimeApiTokenResponse(
@SerializedName("clientSecret")
val clientSecret: String
)
// data/repository/RealTimeRepositoryImpl.kt
class RealTimeRepositoryImpl(
private val assistantService: AssistantService,
private val realTimeService: RealTimeService
) : RealTimeRepository {
override suspend fun startRealtimeChat(): Result<Unit> {
return runCatching {
val tokenResponse = assistantService.getRealTimeToken()
realTimeService.connect(tokenResponse.clientSecret)
}
}
override suspend fun endRealtimeChat(): Result<Unit> {
return runCatching {
realTimeService.disconnect()
}
}
}
domain 레이어에서는 StartRealtimeChatUseCase, EndRealtimeChatUseCase, GetRealTimeTokenUsecase 등으로 감싸 ViewModel에서 호출하기 쉽게 정리한다.
OpenAI Realtime WebSocket 엔드포인트는 대략 다음과 같은 형태다. (OpenAI Platform)
wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17
여기에 아까 받은 client_secret를 Authorization 헤더에 실어 연결한다.
Saegil-Android에서는 Ktor/멀티모듈을 쓰고 있지만, 블로그에서는 OkHttp 기반 예시로 정리해보자.
class RealTimeServiceImpl(
private val okHttpClient: OkHttpClient
) : RealTimeService {
private var webSocket: WebSocket? = null
override fun connect(clientSecret: String) {
val request = Request.Builder()
.url("wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17")
.addHeader("Authorization", "Bearer $clientSecret")
.build()
webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
// 연결 성공
}
override fun onMessage(webSocket: WebSocket, text: String) {
// JSON 이벤트 처리 (텍스트 응답 등)
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
// 바이너리 프레임 처리 (오디오 응답 등)
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?
) {
// 에러 처리
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String
) {
// 종료 처리
}
})
}
override fun disconnect() {
webSocket?.close(1000, "user closed")
webSocket = null
}
}
PR에서는 이 WebSocket을 직접 쓰는 대신, RealTimeService 인터페이스로 감싸고, 상위 계층에는 콜백/Flow 형태로 이벤트를 올려주는 구조를 사용했다.
음성 통화 느낌을 내려면, 마이크에서 입력을 받아 Realtime API로 보내야 한다.
AudioRecord로 PCM 데이터 수집예시 코드 흐름은 다음과 같다.
fun sendAudioStream() {
val minBufferSize = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
CHANNEL_CONFIG,
AUDIO_FORMAT
)
val audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE,
CHANNEL_CONFIG,
AUDIO_FORMAT,
minBufferSize
)
val buffer = ByteArray(minBufferSize)
audioRecord.startRecording()
while (isRecording) {
val read = audioRecord.read(buffer, 0, buffer.size)
if (read > 0) {
// 이 부분에서 OpenAI Realtime 프로토콜에 맞게 래핑 후 전송
webSocket?.send(ByteString.of(buffer, 0, read))
}
}
audioRecord.stop()
audioRecord.release()
}
실제 PR에서는 OpenAI의 이벤트 규격에 맞는 JSON을 보내고, 오디오 프레임을 특정 이벤트 타입에 맞춰서 전송하는 로직이 들어간다. 이 부분은 Realtime API 공식 문서의 이벤트 포맷을 참고해 구현하면 된다. (OpenAI Platform)
서버에서 오는 오디오는 WebSocket의 onMessage(bytes: ByteString)에서 받는다. 바로 재생하면 버퍼가 꼬이거나, 여러 응답이 겹쳐서 들리기 쉬우므로 Saegil-Android에서는 “재생 큐”를 두고 한 번에 하나씩 재생하는 구조로 풀었다.
대략적인 흐름은 다음과 같다.
BlockingQueue<ByteArray> 혹은 Channel<ByteArray>로 오디오 조각을 쌓는다AudioTrack으로 재생class AudioPlayer {
private val audioTrack = AudioTrack(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build(),
BUFFER_SIZE,
AudioTrack.MODE_STREAM,
AudioManager.AUDIO_SESSION_ID_GENERATE
)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val queue = Channel<ByteArray>(Channel.UNLIMITED)
init {
scope.launch {
audioTrack.play()
for (chunk in queue) {
audioTrack.write(chunk, 0, chunk.size)
}
}
}
fun enqueue(bytes: ByteArray) {
scope.launch {
queue.send(bytes)
}
}
fun stop() {
scope.cancel()
audioTrack.stop()
audioTrack.release()
}
}
WebSocket 쪽에서는 onMessage(bytes: ByteString)에서 audioPlayer.enqueue(bytes.toByteArray())를 호출하는 식으로 연결하면 된다.
presentation 레이어에서는 크게 두 가지를 관리한다.
통화 상태
에러/로딩 상태
예시 ViewModel 흐름:
@HiltViewModel
class AiConversationViewModel @Inject constructor(
private val startRealtimeChatUseCase: StartRealtimeChatUseCase,
private val endRealtimeChatUseCase: EndRealtimeChatUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow(AiConversationUiState())
val uiState = _uiState.asStateFlow()
fun startCall() {
viewModelScope.launch {
_uiState.update { it.copy(isConnecting = true) }
startRealtimeChatUseCase()
.onSuccess {
_uiState.update { it.copy(isConnecting = false, isTalking = true) }
}
.onFailure { e ->
_uiState.update {
it.copy(isConnecting = false, errorMessage = e.message ?: "연결 실패")
}
}
}
}
fun endCall() {
viewModelScope.launch {
endRealtimeChatUseCase()
_uiState.update { it.copy(isTalking = false) }
}
}
}
Compose 화면(AiConversationScreen)에서는 이 uiState를 구독하며 통화 버튼, 타이머, 파형 애니메이션 등을 표시한다.
실제 프로젝트에서 REST + TTS 방식과 Realtime WebSocket 방식의 속도를 비교했을 때:
대략 70% 이상의 딜레이를 줄일 수 있었고, 전화 대화처럼 “말하면 곧바로 답이 오는” 느낌에 훨씬 가까워졌다.
정리하면, Android에서 OpenAI Realtime API(WebSocket)를 붙일 때 핵심은 세 가지다.
도입할 때 생각해 볼 포인트: