카테고리 없음

Google Speech-to-text API를 활용한 실시간 음성 인식 기능 구현

ehdbs7908 2025. 12. 6. 08:39

– 캡스톤 프로젝트에서 실시간 음성 인식 기능을 구현한 방법 –

 

우리가 캡스톤 프로젝트를 진행함에 있어 선택한 주요 기능 중 하나는
사용자의 음성을 실시간으로 받아서 텍스트로 변환해야 하는 것이었다.

 

정적 음성 파일(STT)이 아니라 마이크 입력을 실시간으로 스트리밍 처리해야 했기 때문에,
단순한 REST API 방식으로는 구현할 수 없었다.

 

이때 선택한 기술이 바로 Google Cloud Speech-to-Text의 양방향 스트리밍 API이다.

 

이 기능을 사용하면 클라이언트로부터 실시간 오디오를 받아 구글 서버에 스트리밍으로 전송하고,
바뀌는 음성을 즉시 텍스트로 분석해 다시 반환할 수 있다.

 

이번 글에서는 우리가 캡스톤 프로젝트에서 어떻게 Google Speech-to-Text Streaming API를 적용했는지
그 구현 과정을 정리한다.

 

 

  • Google Speech-to-Text Streaming API를 선택한 이유

1. 실시간 처리 성능

사용자가 말한 내용을 거의 즉시 텍스트로 변환해준다.
대부분의 STT 엔진이 수 초의 지연을 가지는 반면, Google STT는 초 단위 이하의 빠른 반응 속도를 제공한다.

 

2. 인식률

한국어 인식률이 매우 높은 편이며, 특히 긴 문장에서도 문맥을 반영한 정확한 변환이 가능했다.

 

3. 스트리밍 API 제공

일반적인 STT API는 파일 단위 처리지만, Google STT는 “StreamingRecognize” 방식으로
음성을 실시간으로 보내고 실시간으로 결과를 받을 수 있다.

 

4. 자동 구두점(.,?) 지원

문장 단위 정리, 요약, 키워드 추출 등 후처리에 매우 유리했다.

 

 

 

 

  • 전체 구조 요약

우리가 구현한 실시간 STT 흐름은 다음과 같다.

[클라이언트 브라우저]
   ↓  (Socket 전송: ByteString 오디오)
[Spring 서버 StreamingSpeechService]
   ↓  (Google에 스트리밍 전송)
[Google Cloud Speech-to-Text]
   ↓
실시간 텍스트 응답 (INTERIM, FINAL)
   ↓
[웹소켓으로 클라이언트 전달]

그리고 최종 결과가 나오면 우리가 만든 KOMORAN 키워드 추출 서비스로 전달되어
문장의 핵심 키워드를 분석하는 데 사용된다.

 

 

 

 

  • 핵심 기능 요약

1. 구글 STT와 양방향 스트리밍 연결 생성

2. 브라우저에서 보내는 오디오 ByteString을 지속적으로 구글에 전송

3. 결과가 들어올 때마다 WebSocket으로 즉시 클라이언트에게 전달

4. STREAMING API의 305초 타임아웃 문제 해결 (자동 스트림 재생성)

5. 스트림 종료 신호(END_MARKER)를 통해 안전하게 종료

6. 정확도 순으로 정렬된 transcript를 REAL-TIME으로 제공

 

실제로 실시간 회의 기록, 음성 명령, 자동 요약 등 다양한 기능 확장에 사용 가능하다.

 

 

 

 

  • 실제 구현 코드 (StreamingSpeechService)

아래 코드는 우리가 캡스톤 프로젝트에서 실제로 작성한 완성된 Streaming STT 서비스 코드이다.

package com.example.innobyte.service;

import com.google.api.gax.rpc.ClientStream;
import com.google.api.gax.rpc.ResponseObserver;
import com.google.api.gax.rpc.StreamController;
import com.google.cloud.speech.v1.*;
import com.google.protobuf.ByteString;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.TextMessage;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
public class StreamingSpeechService {

    private final SpeechSettings speechSettings;
    private static final long MAX_STREAM_DURATION_MS = 300_000; // 5분 (305초 타임아웃 제한 대비)
    public static final ByteString END_MARKER = ByteString.copyFromUtf8("END");

    public List<String> streamingRecognize(BlockingQueue<ByteString> audioQueue, WebSocketSession session) throws IOException {
        List<String> results = new ArrayList<>();

        // 결과 수신용
        ResponseObserver<StreamingRecognizeResponse> responseObserver = new ResponseObserver<StreamingRecognizeResponse>() {

            // 스트리밍 시작
            @Override
            public void onStart(StreamController controller) {
                System.out.println("Streaming Recognize Start! session ID: " + session.getId());
            }

            // Google이 인식한 결과를 보낼 때마다 인식
            @Override
            public void onResponse(StreamingRecognizeResponse response) {
                for (StreamingRecognitionResult result : response.getResultsList()) {
                    // 정확도 점수가 가장 높은 해석을 선택해서 가져오기 (정확도가 높을 순서로 정렬)
                    SpeechRecognitionAlternative alternative = result.getAlternativesList().get(0);
                    // 가장 정확한 텍스트를 가져오기
                    String transcript = alternative.getTranscript();

                    try {
                        if (session.isOpen()) {
                            // 구글로부터 받은 결과를 클라이언트에게 전달
                            // 중간 결과:INTERIM: message
                            // 결과: FINAL: message
                            String messagePrefix = result.getIsFinal() ? "FINAL: " : "INTERIM: ";
                            session.sendMessage(new TextMessage(messagePrefix + transcript));
                        }

                        // 최종적으로 STT변환이 완료되면 List에 텍스트 저장
                        if (result.getIsFinal()) {
                            results.add(transcript);
                        }

                    } catch (IOException e) {
                        System.err.println("Failed to send WebSocket messages: " + e.getMessage());
                    }
                }
            }

            // 오류 발생 시
            @Override
            public void onError(Throwable t) {
                System.err.println("session ID: " + session.getId());
                System.err.println("Streaming Recognize error: " + t.getMessage());
            }

            // 스트림 종료 시
            @Override
            public void onComplete() {
                System.out.println("Streaming Recognize Complete! session ID: " + session.getId());
            }
        };

        // Google Cloud STT 서버와 양방향 스트리밍 연결을 생성
        try (SpeechClient client = SpeechClient.create(speechSettings)) {

            // 설정 전송 (오디오 형식, 언어 등을 지정)
            RecognitionConfig recognitionConfig = RecognitionConfig.newBuilder()
                    .setEncoding(RecognitionConfig.AudioEncoding.LINEAR16)      // PCM 인코딩
                    .setLanguageCode("ko-KR")                                   // 언어 설정
                    .setSampleRateHertz(16000)                                  // 샘플 레이트 설정
                    .setAudioChannelCount(1)                                    // 채널 : 모노(1), 스테레오(2)
                    .setEnableAutomaticPunctuation(true)                        // 자동 구두점 생성(마침표, 쉼표 삽입 여부)
                    .build();

            // 스트리밍 설정
            StreamingRecognitionConfig streamingConfig = StreamingRecognitionConfig.newBuilder()
                    .setConfig(recognitionConfig)       // 오디오 형식 지정
                    .setInterimResults(true)            // 인식 중간에도 계속 결과를 보내는 설정(실시간)
                    .build();

            // ClientStream 생성 (오디오 전송용)
            ClientStream<StreamingRecognizeRequest> clientStream = createNewStream(client, responseObserver, streamingConfig);
            long streamStartTime = System.currentTimeMillis();

            while(true) {

                // 타임 아웃이 발생하면 새로운 스트림을 만들어서 다시 사용
                if (System.currentTimeMillis() - streamStartTime > MAX_STREAM_DURATION_MS) {
                    System.out.println("Restarting streaming session to avoid 305s timeout...");

                    // 기존 스트림 종료
                    clientStream.closeSend();

                    // 새 스트림 생성
                    clientStream = createNewStream(client, responseObserver, streamingConfig);
                    streamStartTime = System.currentTimeMillis();
                }

                // 오디오 데이터 가져오기(2초 동안 대기)
                ByteString audioData = audioQueue.poll(2, TimeUnit.SECONDS);

                // 오디오 데이터가 비어 있으면 다시 대기
                if (audioData == null) {
                    continue;
                }

                // 클라이언트로 부터 종료 신호가 왔을 때
                if (audioData.equals(StreamingSpeechService.END_MARKER)) {
                    break;
                }
                StreamingRecognizeRequest audioRequest = StreamingRecognizeRequest.newBuilder()
                        .setAudioContent(audioData)
                        .build();
                clientStream.send(audioRequest);
            }

            // 스트림 종료
            clientStream.closeSend();
            Thread.sleep(1000); // 최종 결과 수신을 위한 대기

        } catch (Exception e) {
            throw new IOException("Streaming Failed: " + e.getMessage(), e);
        }

        return results;
    }

    private ClientStream<StreamingRecognizeRequest> createNewStream(
            SpeechClient client,
            ResponseObserver<StreamingRecognizeResponse> responseObserver,
            StreamingRecognitionConfig streamingConfig) {

        ClientStream<StreamingRecognizeRequest> stream = client.streamingRecognizeCallable().splitCall(responseObserver);

        // 스트리밍 설정 전송 (항상 첫 번째 요청은 설정)
        StreamingRecognizeRequest configRequest = StreamingRecognizeRequest.newBuilder()
                .setStreamingConfig(streamingConfig)
                .build();
        stream.send(configRequest);

        System.out.println("[Google STT] New stream session started.");
        return stream;
    }
}

 

 

  • 코드의 핵심 포인트 설명

1. ResponseObserver로 실시간 결과 수신

Google STT는 서버 → 클라이언트로 결과를 push 방식으로 보내기 때문에
Observer 패턴을 사용한다.

 

responseObserver.onResponse() 내부에서

 

intermediate 결과 (INTERIM)

최종 결과 (FINAL) 를 구분하여 클라이언트로 전송한다.

 

2. 305초 제한 해결 (스트림 자동 재생성)

Google STT Streaming API는 약 5분(305초) 스트림 제한이 있다.
이를 해결하기 위해:

if (System.currentTimeMillis() - streamStartTime > MAX_STREAM_DURATION_MS) {
    clientStream.closeSend();
    clientStream = createNewStream(client, responseObserver, streamingConfig);
    streamStartTime = System.currentTimeMillis();
}

이 부분에서 스트림을 자동으로 재생성하여 장시간 녹음에도 끊김 없는 STT를 구현했다.

 

3. WebSocket으로 클라이언트에게 실시간 전달

음성이 변환되는 즉시 클라이언트 UI에 결과가 출력되도록 했다.

 

4. 최종 텍스트(FINAL 결과)는 리스트로 저장

이 결과는 이후 “KOMORAN 키워드 분석 서비스”로 전달되어 문서 키워드 추출, 요약, 정리 등에 사용되었다.

 

 

 

 

  • 구현 결과 (캡스톤 관점)

Google STT 스트리밍 적용 후 다음과 같은 결과가 있었다.

 

1. 실시간 음성 인식 정확도 상승

쉬지 않고 말을 해도 빠르게 텍스트로 변환되며, 인터림 결과와 파이널 결과가 구분되어 UI 반응성이 매우 좋았다.

 

2. 프론트엔드와의 연동이 쉬워짐

WebSocket을 사용했기 때문에 브라우저에서 실시간 자막처럼 출력 가능했다.

 

3. 텍스트 기반 후처리를 자동화할 수 있음

 

  • KOMORAN 키워드 추출
  • 문장 요약
  • 회의록 자동 생성

등 확장성이 증가했다.

 

 

 

4. 장시간 STT도 안정적으로 수행

스트림 재생성 기능 덕분에 약 1시간 이상의 연속 녹음도 안정적으로 처리했다.

 

 

  • 마무리

Google Speech-to-Text Streaming API는
캡스톤 프로젝트처럼 기능 구현 시간도 중요하고 정확도도 중요한 상황에서 매우 강력한 기술이었다.

 

특히 실시간 음성 인식을 기반으로 하는 모든 서비스 (키워드 분석 등)에 바로 적용할 수 있다는 점이 큰 장점이다.

 

이번 글에서는 내가 직접 작성한 코드를 기반으로 Streaming STT 구조와 구현 내용을 정리했으며,
이 방식은 어떤 한국어 음성 처리 프로젝트에도 그대로 응용 가능하다.