1. 들어가며
실시간 음성 인식 서비스를 만든다고 했을 때, 가장 먼저 떠오르는 건 “STT API”일지도 모른다. 하지만 정말 중요한 건 API를 언제 어떻게 호출하고, 사용자 음성을 실시간으로 전달하고, 그 결과를 사용자에게 다시 어떻게 돌려주는가 하는 데이터 흐름의 구조다.
이 글은 내가 발음 교정 서비스 SpeekSee에서 Google Streaming STT API를 Spring WebSocket으로 연결하면서 설계한 구조를 정리한 것이다.
특히 WebSocket 세션 관리, STT 비동기 응답 처리, 세션 context 구조화, 그리고 실제 서비스에서 발생할 수 있는 문제 해결 전략까지 기록해두었다.
나중에 비슷한 시스템을 다시 만들거나 확장할 때, 스스로에게 명확한 레퍼런스를 남기기 위한 글이기도 하다.
2. 전체 구조 개요: WebSocket + Google STT Streaming 연동 흐름
🧩 기본 아이디어
사용자의 음성을 실시간으로 인식하려면,
- 클라이언트가 오디오 스트림을 서버에 전송하고,
- 서버는 이를 Google STT의 스트리밍 API로 전달한 뒤,
- 실시간 인식 결과를 다시 클라이언트에게 WebSocket으로 전송해야 한다.
이건 단순한 API 호출이 아니라, 상태가 유지되는 양방향 데이터 흐름 구조가 필요하다는 뜻이다.
---
🔄 전체 흐름도
[Client Mic Input]
↓
[WebSocket 연결 (STT 전용)]
↓
[Spring WebSocket Server]
┌────────────────────────────┐
│ SttSessionContext (세션별) │
└────────────────────────────┘
↓
[Google STT Streaming API (gRPC)]
↓
[비동기 응답 수신 (ResponseObserver)]
↓
[WebSocketSession.sendMessage()]
↓
[Client 실시간 피드백 렌더링]
---
📦 각 구성요소 설명
구성 요소설명
| WebSocketSession | 사용자의 브라우저에서 연결된 WebSocket 객체. 음성 chunk 수신 및 피드백 전송 담당 |
| SttSessionContext | 사용자별 세션 상태를 담는 객체. scriptId, memberId, streamController 등을 포함 |
| GoogleStreamingSttClient | Google Cloud Speech-to-Text API와의 스트리밍 통신을 담당하는 클래스 |
| ResponseObserver | STT 결과를 비동기로 수신하여, WebSocketSession에 전송하는 콜백 객체 |
| SttSessionManager | 전체 WebSocket 세션을 관리하는 매니저. 세션 생성/조회/삭제 책임 |
---
💬 구조 설계 시 고려했던 점
- WebSocket은 상태 기반 → 사용자별 context 분리 필수
- Google STT API는 비동기 → 응답과 WebSocket 간 스레드 동기화 고려
- 예외/오류 상황에서 Stream 및 세션 정리 필요
- 동시에 여러 사용자가 접속할 수 있으므로 동시성 안전성 확보 (e.g. ConcurrentHashMap)
---
🔐 인증은 어떻게?
WebSocket은 기본적으로 HTTP 필터를 타지 않기 때문에, Sec-WebSocket-Protocol 헤더를 사용해 JWT 토큰을 전달하고, HandshakeInterceptor에서 직접 토큰을 검증한다.
java
복사편집
String jwt = request.getHeader(“Sec-WebSocket-Protocol”); // 검증 후 사용자 정보 세션 context에 저장
이 방식은 WebSocket 표준을 지키면서도 보안을 유지할 수 있는 방법이다
3. WebSocket 서버 구성 및 주요 클래스 설계
Spring Boot에서 WebSocket 서버를 구성하는 일 자체는 어렵지 않다. 중요한 건 “단순한 연결”이 아닌, 실시간 STT를 위한 세션 관리, 인증, 에러 복구 등을 설계하는 것이다.
---
🧱 구성 클래스 개요
com._ithon.speeksee.domain.voicefeedback.streaming
├── controller
│ └── VoiceFeedbackWebSocketHandler.java ← WebSocket 진입점
├── infra.session
│ └── SttSessionManager.java ← 세션 등록/삭제/조회 관리
├── model
│ └── SttSessionContext.java ← 사용자별 세션 상태 (context) 저장
├── infra
│ └── GoogleStreamingSttClient.java ← Google STT 연결 및 스트리밍 처리
│ └── GoogleSttResponseObserver.java ← STT 결과 수신 및 피드백 전송
---
1️⃣ VoiceFeedbackWebSocketHandler
Spring WebSocket에서 TextWebSocketHandler 혹은 BinaryWebSocketHandler를 상속받아 사용한다. 우리는 실시간 오디오 스트림을 처리해야 하므로 BinaryMessage 기반 처리가 필요했다.
public class VoiceFeedbackWebSocketHandler extends BinaryWebSocketHandler {
@Override
public void afterConnectionEstablished(WebSocketSession session) {
// 세션 context 생성 및 STT stream 시작
}
@Override
public void handleBinaryMessage(WebSocketSession session, BinaryMessage message) {
// 수신된 음성 chunk → Google STT API로 전송
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
// 세션 및 stream 정리
}
}
이 핸들러는 실제 오디오 스트림과 Google STT API를 연결하는 관문(Gateway) 역할을 한다.
---
2️⃣ SttSessionContext
사용자마다 서로 다른 script를 읽고, 다른 타이밍에 말하며, 다른 세션 상태를 가진다. 따라서 아래와 같은 정보들을 담는 Context 객체를 따로 관리해야 했다:
public class SttSessionContext {
private final Long memberId;
private final Long scriptId;
private final WebSocketSession session;
private final GoogleStreamingSttClient sttClient;
private StreamController streamController;
private boolean finished;
...
}
✅ 이 context는 WebSocketSession ID를 기준으로 SttSessionManager가 관리한다.
---
3️⃣ SttSessionManager
모든 WebSocket 연결을 추적하고 관리하는 중앙 관리자.
@Component
public class SttSessionManager {
private final Map<String, SttSessionContext> sessionMap = new ConcurrentHashMap<>();
public SttSessionContext startSession(WebSocketSession session, Long memberId, Long scriptId) {
...
}
public void removeSession(WebSocketSession session) {
...
}
public SttSessionContext getSession(WebSocketSession session) {
...
}
}
이 구조는 세 가지 중요한 문제를 해결한다:
- 여러 사용자 동시 접속 → ConcurrentHashMap 기반 격리
- 세션 유실 or 연결 종료 → afterConnectionClosed에서 안전 정리
- Context 불일치 문제 → WebSocketSession ID 기반 식별
---
4️⃣ GoogleStreamingSttClient & GoogleSttResponseObserver
- GoogleStreamingSttClient: Google Cloud의 StreamingRecognizeRequest를 보내는 역할
- GoogleSttResponseObserver: Google에서 보내주는 인식 결과(STT)를 받아 FE에 보내는 역할
class GoogleSttResponseObserver implements ResponseObserver<StreamingRecognizeResponse> {
@Override
public void onResponse(StreamingRecognizeResponse response) {
// transcript → 분석 → 피드백 전송
}
@Override
public void onError(Throwable t) {
// WebSocket 오류 메시지 전송, 세션 종료
}
@Override
public void onComplete() {
// 학습 종료 알림 전송
}
}
🔒 이 두 클래스는 STT와 WebSocket을 느슨하게 분리해, 테스트와 디버깅을 용이하게 만든다.
4. 세션 관리의 핵심 – SttSessionContext와 SttSessionManager 설계 원칙
WebSocket은 본질적으로 상태가 있는 프로토콜이다. STT 스트리밍처럼 사용자의 컨텍스트를 유지해야 하는 기능에서는 세션별로 정확한 상태를 보존하고, 동시에 충돌 없이 처리하는 게 핵심 과제였다.
---
🎯 목표
- 사용자마다 독립적인 세션 컨텍스트 유지
- 중복 연결 방지 및 세션 중복 사용 방지
- 세션 종료 시 리소스 누수 없이 정리
- Google STT와의 연결 상태를 WebSocket 세션과 동기화
---
🧱 SttSessionContext: 세션의 모든 상태를 캡슐화
public class SttSessionContext {
private final Long memberId;
private final Long scriptId;
private final WebSocketSession session;
private final GoogleStreamingSttClient sttClient;
private final Long sessionStartTime;
private StreamController streamController;
private boolean finished;
// 기타 분석 상태들, 타이밍 정보 등
}
🔑 설계 원칙
원칙설명
| 단일 책임 | STT 한 세션의 상태를 추상화 (scriptId, memberId, 진행상태 등) |
| 쓰레드 안정성 | streamController나 finished는 동시 접근 고려해 volatile 또는 동기화 필요 |
| 외부 의존 최소화 | 오직 하나의 WebSocketSession과 하나의 GoogleStreamingSttClient만 포함 |
---
📦 SttSessionManager: 모든 세션의 생명주기 총괄
@Component
public class SttSessionManager {
private final Map<String, SttSessionContext> sessionMap = new ConcurrentHashMap<>();
public SttSessionContext startSession(WebSocketSession session, Long memberId, Long scriptId) {
// 세션 중복 여부 확인
// context 생성 및 등록
}
public void removeSession(WebSocketSession session) {
// streamController 정리 + context 제거
}
public Optional<SttSessionContext> getSession(WebSocketSession session) {
return Optional.ofNullable(sessionMap.get(session.getId()));
}
}
✅ 유의한 구현 디테일
- ConcurrentHashMap으로 동시성 제어
- 세션 ID는 session.getId() 기준
- Google STT 연결 종료 시 → removeSession() 내부에서 onComplete() 또는 onError() 호출
- 비정상 종료 감지: afterConnectionClosed 또는 WebSocketHandlerDecorator 활용 가능
---
🧨 트러블슈팅: 세션 충돌 / 중복 문제
사례 1. 브라우저 새로고침 시 세션 중복
- 증상: 이전 세션이 정리되지 않아 stream already open 오류 발생
- 해결: afterConnectionClosed()에서 removeSession() 강제 호출
사례 2. 응답 스레드에서 session.sendMessage() 시 예외
- 증상: IllegalStateException: TextMessage is not allowed in this state
- 원인: Google STT 응답이 비동기 스레드에서 발생 → WebSocketSession이 이미 닫힘
- 해결: SttSessionContext에 finished 플래그 추가 → 상태 체크 후 전송 시도
---
🧠 회고
이 구조는 마치 “WebSocket 기반 STT 세션 가상머신”을 하나씩 띄우는 것과 같다. 세션 하나는 음성 데이터를 Google STT로 중계하고, 결과를 클라이언트로 리턴하며, 정해진 흐름 안에서 시작되고 종료되어야 한다.
결국 핵심은 **상태 관리(State Management)**였다. 언제 시작하고, 언제 종료되며, 언제 예외로 종료되는지를 명확하게 설계해야만 실시간 STT 시스템은 안정적으로 동작할 수 있다.