OpenAI Realtime API WebSocket 써보기

유진·2025년 11월 30일

Android

목록 보기
10/16
post-thumbnail

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

0. 이런 기능을 만들고 싶었다

AI 전화처럼 가상 대화하는 기능을 만들려고 한다.

1. 왜 Realtime API인가

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

  1. 사용자가 말하기 종료
  2. m4a 파일을 서버로 업로드 (Multipart)
  3. 서버에서 STT + LLM 호출 → 텍스트 응답 생성
  4. 응답 텍스트를 다시 TTS로 변환해 m4a 반환
  5. 클라이언트에서 재생

이 과정에서

  1. LLM 응답 생성 ≒ 8초
  2. TTS 파일 생성 및 전송 ≒ 2초
    정도 걸리면서, 한 턴의 대화가 10초 이상 딜레이가 발생했다. 대화 연습용 “전화” 인터랙션으로 쓰기엔 너무 느린 속도였다.

기존에도 OpenAI의 Assistants API을 활용해서 기능을 구현했었는데, OPEN AI에서 WebSocket으로 실시간 소통을 할 수 있는 API가 새로 나왔다고 해서 당장 써봤다

OpenAI Realtime API는 WebSocket을 통해 음성·텍스트를 저지연으로 주고받는 인터페이스를 제공한다.
이걸 이용하면 음성 종료 후 서버 응답까지의 체감 딜레이를 3초 정도까지 줄일 수 있다.


2. 전체 구조 개요

이번 구현은 “모바일에서 직접 OpenAI에 WebSocket으로 붙되, 인증은 백엔드에서 처리”하는 구조다.

  1. 백엔드

    • OpenAI Realtime Session 생성 REST API 호출
    • 응답으로 내려오는 client_secret.value(ephemeral key)를 모바일에 내려줌 (OpenAI Platform)
  2. 안드로이드

    • GET /realtime/token 같은 엔드포인트로 client_secret 요청
    • 이 토큰을 Authorization 헤더에 넣고 wss://api.openai.com/v1/realtime?...로 WebSocket 연결
    • 마이크 입력을 WebSocket으로 보내고, 서버에서 오는 오디오를 받아 재생

레이어 별로 보면 Saegil-Android PR 구조랑 비슷하게 나뉜다.

  • data: RealTimeService, RealTimeServiceImpl, RealTimeRepositoryImpl
  • domain: RealTimeRepository, StartRealtimeChatUseCase, EndRealtimeChatUseCase, GetRealTimeTokenUsecase
  • presentation: AiConversationViewModel, AiConversationScreen

3. 백엔드에서 client_secret 발급받기

Realtime API는 바로 API 키를 클라이언트에 노출하지 않고, 짧은 수명의 “client_secret(=ephemeral key)”를 발급해 사용하는 방식을 권장한다. (OpenAI Platform)

백엔드에서는 대략 다음 흐름으로 동작한다.

  1. 서버에서 OpenAI Realtime Session API 호출
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"
}
  1. 응답에서 client_secret.value를 꺼냄
{
  "id": "session_...",
  "client_secret": {
    "value": "ek_1234567890...",
    "expires_at": 1738015688
  }
}
  1. value를 모바일에 내려주는 간단한 API 구현
// 예시 응답
{
  "clientSecret": "ek_1234567890..."
}

Saegil PR에서는 이 응답을 GetRealTimeApiTokenResponse 같은 DTO로 파싱하고, AssistantServiceImpl / RealTimeRepositoryImpl에서 사용하도록 분리해뒀다.


4. 안드로이드: 토큰 가져오기(REST)

안드로이드 쪽에서는 먼저 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에서 호출하기 쉽게 정리한다.


5. 안드로이드: WebSocket 연결하기

OpenAI Realtime WebSocket 엔드포인트는 대략 다음과 같은 형태다. (OpenAI Platform)

wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17

여기에 아까 받은 client_secret를 Authorization 헤더에 실어 연결한다.

5-1. OkHttp WebSocket 예시

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 형태로 이벤트를 올려주는 구조를 사용했다.


6. 음성 입력 보내기

음성 통화 느낌을 내려면, 마이크에서 입력을 받아 Realtime API로 보내야 한다.

  1. AudioRecord로 PCM 데이터 수집
  2. 적당한 크기(예: 20~40ms 단위)의 버퍼로 잘라 WebSocket 바이너리 프레임으로 전송
  3. 녹음이 끝났다는 이벤트도 Realtime API 프로토콜에 맞게 JSON으로 보내야 한다

예시 코드 흐름은 다음과 같다.

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)


7. 음성 응답 재생하기

서버에서 오는 오디오는 WebSocket의 onMessage(bytes: ByteString)에서 받는다. 바로 재생하면 버퍼가 꼬이거나, 여러 응답이 겹쳐서 들리기 쉬우므로 Saegil-Android에서는 “재생 큐”를 두고 한 번에 하나씩 재생하는 구조로 풀었다.

대략적인 흐름은 다음과 같다.

  1. BlockingQueue<ByteArray> 혹은 Channel<ByteArray>로 오디오 조각을 쌓는다
  2. 별도의 재생 쓰레드 / 코루틴에서 큐를 소비하면서 AudioTrack으로 재생
  3. 끊김을 줄이기 위해 재생 버퍼 사이에 약간의 sleep 또는 버퍼 사이즈 조절
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())를 호출하는 식으로 연결하면 된다.


8. ViewModel과 UI에서의 사용

presentation 레이어에서는 크게 두 가지를 관리한다.

  1. 통화 상태

    • 연결 중인지
    • 상대가 말하고 있는지
    • 내가 말하고 있는지
  2. 에러/로딩 상태

    • 토큰 요청 실패
    • WebSocket 연결 실패
    • 네트워크 끊김

예시 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를 구독하며 통화 버튼, 타이머, 파형 애니메이션 등을 표시한다.


9. REST 대비 체감 성능 개선

실제 프로젝트에서 REST + TTS 방식과 Realtime WebSocket 방식의 속도를 비교했을 때:

  • 기존 방식: 음성 종료 후 응답 재생까지 약 10~11초
  • Realtime WebSocket: 음성 종료 후 응답 재생까지 약 3초

대략 70% 이상의 딜레이를 줄일 수 있었고, 전화 대화처럼 “말하면 곧바로 답이 오는” 느낌에 훨씬 가까워졌다.


10. 정리 및 도입 시 팁

정리하면, Android에서 OpenAI Realtime API(WebSocket)를 붙일 때 핵심은 세 가지다.

  1. 백엔드에서 client_secret(ephemeral key)을 발급해주는 얇은 API를 만든다.
  2. 안드로이드에서는 이 토큰을 사용해 WebSocket을 열고, 마이크·스피커 처리는 AudioRecord/AudioTrack으로 관리한다.
  3. WebSocket 이벤트를 ViewModel로 올리고, UI는 단순히 상태와 이벤트만 구독하도록 분리한다.

도입할 때 생각해 볼 포인트:

  • 토큰 만료(기본 30분) 시 재발급/재연결 전략 (OpenAI Platform)
  • 네트워크 끊김/재연결 처리
  • 녹음 권한, 블루투스/이어폰 오디오 라우팅
  • 벤치마크(프레임 타이밍, 지연 시간)로 실제 성능 측정

구현 PR

profile
안드로이드... 좋아하세요?

0개의 댓글