
1. 요구 사항
- 프로젝트 목표: 청각 장애인과 일반 사용자가 음성으로 통화할 수 있다.
"들리담"은 일반 사용자와 청각 장애인이 실시간으로 음성으로 채팅할 수 있는 플랫폼입니다. 우선, 최초 회원가입 시에 청각 장애인의 목소리를 학습합니다.
이후에 청각 장애인이 일반 사용자와 음성 채팅을 할 때, 청각 장애인에게는 채팅 창이 표시됩니다. 청각 장애인에게 보내는 일반 사용자의 음성은 STT(Speech-To-Text) 과정을 거쳐 텍스트로 변환되어 청각 장애인의 채팅창에 표시됩니다. 청각 장애인은 발화 내용을 채팅창에 입력을 하면 발화 내용이 미리 학습된 본인의 목소리로 변조가 되어 상대방에게 전달됩니다.
이 글에서는 청각 장애인과 일반 사용자의 실시간 통신을 백엔드에서 어떻게 구축하였는지 알아보겠습니다.
2. 구현 개요

"들리담"은 위와 같이 프론트는 React를, 백엔드는 스프링 프레임워크를, 사용자 음성 변조는 FastAPI를 사용하여 구현했습니다. 실시간 통신 파이프라인을 구축하기 위해 React와 Spring은 Socket.IO를 사용하여 연결되며, Spring과 FastAPI는 WebSocket을 통해 연결됩니다. 즉, Spring 서버는 React와 FastAPI 사이를 프록시해주는 역할을 하면서 필요한 데이터를 DB에 저장합니다.
- 일반 사용자
- 일반 사용자가 단말기에 발화
- 프론트(리액트)에서 "음성"이 "텍스트"로 변환되어 서버(스프링)로 전달됨
- "텍스트"가 DB에 저장됨
- "텍스트"가 Socket.IO로 연결이 된 상대방(청각 장애인)에게 전달됨
- 청각 장애인 사용자
- 청각 장애인 사용자가 채팅방에 “텍스트” 입력
- “텍스트”가 서버(스프링)로 전달됨
- "텍스트"가 DB에 저장됨
- 서버(Spring)에서 “텍스트”를 FastAPI로 전달
- FastAPI에서 "텍스트"를 사용자 목소리로 변조하여 “음성 파일”을 서버로 전달
- 서버(Spring)에서 “음성 파일”을 프론트(React)로 전달
프론트와 백엔드 사이에 웹소켓 연결이 생성되었다는 가정하에 일반 사용자와 청각 장애인이 음성 채팅 시, 위와 같은 플로우로 진행됩니다.
3. 기술 스택
1. WebSocket

WebSocket은 클라이언트와 서버 간에 양방향 통신을 가능하게 해주는 프로토콜입니다. WebSocket은 HTTP 프로토콜과 달리 실시간으로 데이터를 주고받을 수 있는 지속적인 연결을 제공해 줍니다.
초기에 HTTP Handshake 요청을 통해 연결을 설정하며, 연결이 설정되면 WebSocket 프로토콜로 업그레이드 됩니다.업그레이드됩니다. 구체적으로 서버가 WebSocket 연결을 수락하면 101 Switching Protocols 응답을 반환하고, 이후 HTTP 연결은 WebSocket으로 업그레이드됩니다.
WebSocket의 주요 특징으로는 다음과 같습니다.
- 양방향 통신 (Full-Duplex) : 클라이언트는 요청을 보낼 수 있고, 서버도 별도의 요청 없이 클라이언트에게 데이터를 푸시할 수 있음
- 지속적인 연결: HTTP처럼 요청-응답 주기를 반복하지 않아도 양측에서 언제든지 데이터를 주고받을 수 있음 (실시간성)
- 낮은 오버헤드: WebSocket은 연결이 수립된 이후에 HTTP와 달리 요청-응답마다 헤더와 기타 정보들이 포함되지 않음
- 단일 TCP 연결: WebSocket은 단일 TCP 연결을 유지하며, 이를 통해 다중 메시지를 전달할 수 있음
2. Socket.IO
Socket.IO 웹소켓을 지원하면서도 브라우저 호환성 및 다양한 네트워크 환경에 맞게 폴백(fallback) 기술을 포함한 JavaScript 라이브러리입니다. 보통 Spring에서는 STOMP와 SockJS를 사용하여 웹소켓 연결을 구현하는 것이 일반적이지만 다음과 같은 특징 때문에 Socket.IO를 채택했습니다. STOMP를 사용하여 실시간 일대일 채팅을 구현한 내용은 다음 글에서 다루도록 하겠습니다.
- 자동 재연결: 네트워크가 불안정할 때 자동으로 재연결 시도
- 풀백 지원: WebSocket이 지원되지 않는 환경(Safari, Firefox, Opera 등)에서는 Long Polling 등의 폴백 메커니즘을 제공
- 이벤트 기반 통신: 채팅과 같은 실시간 이벤트 처리에 유리
- 네임스페이스 및 룸: 특정 사용자 그룹에게만 메시지를 전송하는 기능이 쉽게 구현됨
Spring에서 Socket.IO를 사용하려면 Netty와 같은 Socket.IO 서버 라이브러리를 통합하는 방식으로 구현해야 합니다. Socket.IO 서버를 구현하기 위한 설정은 뒤에서 바로 알아보도록 하겠습니다.
4. 실습 및 구현
- 의존성 추가
implementation 'com.corundumstudio.socketio:netty-socketio:2.0.3'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.java-websocket:Java-WebSocket:1.5.3'
Socket.IO와 WebSocket을 사용하기 위한 의존성 추가
- SocketIOServer 생성

Socket.IO 서버는 Netty 서버를 사용해서 만들어진 기술이기에 위와 같이 Socket.IO 서버를 별도로 띄워야 합니다. Spring Boot 내에서 HTTP 요청을 처리하는 8080 포트의 Tomcat 서버와 실시간 웹소켓 통신을 처리하는 9092 포트의 Netty Socket.io 서버가 나뉘어서 구동된 구조입니다.
@Configuration
public class SocketIOConfig {
@Bean
public SocketIOServer socketIOServer(ConfigUtil configUtil) {
com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration();
config.setHostname(configUtil.getHost()); // Socket.IO 서버의 호스트
config.setPort(Integer.parseInt(configUtil.getSocketPort())); // Socket.IO 서버가 사용할 포트를 설정
config.setOrigin("*"); // 모든 도메인에서의 요청을 허용하도록 설정
config.setAllowCustomRequests(true); // 사용자 정의 요청을 허용
config.setTransports(Transport.WEBSOCKET, Transport.POLLING);
return new SocketIOServer(config);
}
}
위 코드를 통해 SocketIOServer를 스프링 부트에서 별도의 빈으로 띄울 수 있습니다.
- SocketIOCommandLineRunner.java
@Slf4j
@Component
public class SocketIOCommandLineRunner implements CommandLineRunner {
private final SocketIOServer server;
@Autowired
public SocketIOCommandLineRunner(SocketIOServer server) {
log.info("[SocketIOCommandLineRunner] SocketIOServer Initialized");
this.server = server;
}
@Override
public void run(String... args) throws Exception {
log.info("[SocketIOCommandLineRunner] SocketIOServer Running");
server.start();
}
}
SocketIOCommandLineRunner은 스프링 부트 애플리케이션에서 Socket.IO 서버를 시작하기 위한 구현체입니다. 이 구현체를 통해 스프링 애플리케이션이 시작될 때, Socket.IO 서버가 자동으로 실행됩니다.

- WebSocketUtil.java
@Slf4j
public class WebSocketUtil extends WebSocketClient {
private final SocketIOClient socketIOClient;
public WebSocketUtil(URI serverUri, Draft protocolDraft, SocketIOClient socketIOClient) {
super(serverUri, protocolDraft);
this.socketIOClient = socketIOClient;
}
public interface OnMessageCallback {
void onMessage(byte[] audioData);
}
public OnMessageCallback onMessageCallback;
@Override
public void onMessage(ByteBuffer bytes) {
log.info("[WebSocketUtil]-[onMessage] Received audio data from FastAPI");
// 콜백을 통해서 Spring 서버로 전송
if (onMessageCallback != null) {
onMessageCallback.onMessage(bytes.array());
}
}
@Override
public void onMessage(String message) {
log.info("[WebSocketUtil]-[onMessage] Received message {}", message);
}
@Override
public void onOpen(ServerHandshake handshake) {
log.info("[WebSocketUtil] WebSocket connection opened");
}
@Override
public void onClose(int code, String reason, boolean remote) {
log.info("[WebSocketUtil] WebSocket connection closed: {}", reason);
}
@Override
public void onError(Exception ex) {
log.error("[WebSocketUtil] WebSocket error occurred", ex);
}
}
WebSocketUtil 클래스는 FastAPI 서버와의 WebSocket 연결을 관리하고, 수신된 메시지를 처리하는 기능을 제공합니다. WebSocket을 통해 수신된 바이너리 데이터와 문자열 메시지를 처리하고 수신된 데이터를 외부로 전달하기 위한 콜백 메커니즘을 제공합니다.
- WebSocketProxy.java
@Slf4j
@Component
public class WebSocketProxy {
@Autowired
private FFmpegConfig ffmpegConfig;
private final String fastApiEndpoint;
private final SocketIOServer server;
private final SocketIONamespace namespace;
private WebSocketUtil fastAPIWebSocket;
private Timer timer;
private final AudioConverter audioConverter;
private final ChatMessageService chatMessageService;
private final UserService userService;
@Autowired
public WebSocketProxy(
SocketIOServer server,
ConfigUtil configUtil,
ChatMessageService chatMessageService,
UserService userService
) {
this.server = server;
this.fastApiEndpoint = configUtil.getFastApiEndpoint();
this.namespace = server.addNamespace("/websocket");
this.namespace.addConnectListener(onConnected());
this.namespace.addDisconnectListener(onDisconnected());
this.namespace.addEventListener("textMessage", String.class, textMessageListener());
this.chatMessageService = chatMessageService;
this.userService = userService;
this.audioConverter = new AudioConverter(ffmpegConfig);
}
private void connectFastAPI(Timer timer, SocketIOClient client){
timer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
try {
if(fastAPIWebSocket == null || fastAPIWebSocket.isClosed()) {
fastAPIWebSocket = new WebSocketUtil(
new URI(fastApiEndpoint),
new Draft_6455(),
client
);
fastAPIWebSocket.connectBlocking();
}
} catch (Exception e) {
log.error("[WebRTCProxy]-[connectFastAPI] WebSocket Connection Failed");
}
}
}, 0, 60);
}
private ConnectListener onConnected() {
return client -> {
HandshakeData handshakeData = client.getHandshakeData();
log.info("[WebRTCProxy]-[Socketio]-[{}] Connected to WebRTCProxy Socketio through '{}'",
client.getSessionId().toString(),
handshakeData.getUrl());
// 클라이언트가 연결 시에 데이터베이스의 채팅방 ID로 WebSocket 룸에 참가
String chatRoomId = client.getHandshakeData().getSingleUrlParam("chatRoomId");
if(chatRoomId == null) {
log.error("chatRoomId is null. Cannot join the room.");
return;
}
log.info("String chatRoomId: {}", chatRoomId);
client.joinRoom(chatRoomId);
timer = new Timer();
connectFastAPI(timer, client);
};
}
private DisconnectListener onDisconnected() {
return client -> {
log.info("[WebRTCProxy]-[Socketio]-[{}] Disconnected from WebSocketProxy Socketio Module",
client.getSessionId().toString());
if(timer != null) {
timer.cancel();
timer.purge();
}
if(fastAPIWebSocket != null) {
fastAPIWebSocket.close();
}
};
}
private DataListener<String> textMessageListener() {
return (client, messagePayload, ackSender) -> {
try {
ObjectMapper objectMapper = new ObjectMapper();
ChatMessageRequestDTO chatMessageRequestDTO = objectMapper.readValue(messagePayload, ChatMessageRequestDTO.class);
User sender = userService.findUserByCustomId(chatMessageRequestDTO.getSenderId());
chatMessageService.save(chatMessageRequestDTO, sender.getName());
if(!sender.isDisabled()){ // 비장애인 사용자
namespace.getRoomOperations(chatMessageRequestDTO.getChatRoomId().toString())
.sendEvent("messageData", chatMessageRequestDTO);
}
else { // 청각 장애인 사용자
if (fastAPIWebSocket != null && fastAPIWebSocket.isOpen()) {
// FastAPI 서버에 문자열 메시지 전송
fastAPIWebSocket.send(chatMessageRequestDTO.getMessage());
// FastAPI 서버로부터 응답 대기 및 오디오 데이터 수신
fastAPIWebSocket.onMessageCallback = audioData -> {
// 클라이언트로 오디오 데이터 전송
namespace.getRoomOperations(chatMessageRequestDTO.getChatRoomId().toString())
.sendEvent("audioData", Base64.getEncoder().encodeToString(audioData));
log.info("[WebRTCProxy]-[Socketio] Sent audio data to client: {}", client.getSessionId().toString());
};
} else {
log.error("[WebRTCProxy]-[Socketio] FastAPI WebSocket is not connected");
}
}
} catch (Exception ex) {
log.error("[WebRTCProxy]-[Socketio] Exception while processing text message", ex);
}
};
}
}
이 클래스는 Socket.IO를 이용해 클라이언트와의 실시간 통신을 처리하고, FastAPI 서버와 WebSocket을 통해 오디오 데이터를 주고받는 역할을 담당하는 Spring Component입니다. 이 클래스는 클라이언트로부터 메시지를 수신하고, 이를 FastAPI 서버로 전달하며, 받은 오디오 데이터를 클라이언트에 다시 전송하는 과정을 담당합니다.
우선 onConnected()에서 , 채팅방 개념을 적용해 메시지를 처리합니다. 클라이언트의 세션 정보를 로깅하고, chatRoomId를 받아와 해당 채팅방에 클라이언트를 추가합니다. 또한, FastAPI 서버와의 WebSocket 연결을 설정하기 위한 타이머를 시작합니다.
textMessageListener()는 클라이언트로부터 메시지를 수신했을 때 호출됩니다. 수신된 메시지를 DTO로 변환한 후, 유저 정보에 따라 적절한 로직을 수행합니다. 비장애인 사용자의 경우 메시지를 해당 채팅방에 전송하고, 청각 장애인 사용자의 경우 FastAPI 서버에 메시지를 전송하고, FastAPI로부터 받은 오디오 데이터를 클라이언트에게 전달합니다.
5. 트러블 슈팅
1. 로컬 환경에서는 웹소켓에 접속(onConnect)이 되지만 배포 환경에서는 접속이 안 되는 문제

- 문제 상황: EC2에 웹소켓 연결 요청이 들어가지 않음
- 원인: docker container 기동 시 포트를 열어주지 않아 요청이 컨테이너 내부로 들어가지 못함
- 해결: docker-compose-blue.yml과 docker-compose-green.yml 파일에 9092 포트 명시하여 Spring Boot Application의 포트를 열어줌
2. blue, green 나누어서 배포하여 blue, green에서 모두 9092 사용 시 포트 충돌 문제

- 문제 상황: EC2에 웹소켓 연결 요청이 들어가지만 새로운 버전을 배포할 때, 포트 충돌이 생김
- 원인: 새로운 버전으로 배포(blue -> green)할 때, 포트 충돌이 발생하여 정상적으로 배포 파이프라인이 돌아가지 않음
- 해결: blue는 9092 포트로 green은 9093 포트로 SocketIO 서버를 실행시키도록 명시
3. "/websocket"으로 들어오는 요청은 nginx를 거치지 않는 문제

- 문제 상황: spring이 실행되고 있는 컨테이너에 직접 접속(3.34.121.34:9092/websocket)하면 연결이 되지만, nginx를 거치도록(3.34.121.34/websocket) 요청을 보내면 404 NOT FOUND ERROR가 발생하는 문제
- 원인: 클라이언트가 ws://3.34.121.34/websocket?chatRoomId=1 URL을 사용하여 서버에 연결을 시도하면, Socket.IO는 내부적으로 이 요청을 처리하여 ws://3.34.121.34/socket.io/?EIO=4&transport=websocket&chatRoomId=1와 같은 URL로 요청을 변환합니다. 즉, 실제로는 /socket.io 경로를 통해 통신합니다. 클라이언트가 요청한 URL에서 /websocket 부분은 네임스페이스를 설정하기 위한 것이고, 내부적으로 Socket.IO는 이를 /socket.io 경로와 연관 지어 처리
- 해결: nginx의 설정 파일을 아래와 같이 location을 “/websocket” 에서 “/socket.io”로 변경 후 해결됨
location /socket.io {
proxy_pass http://{private-ip}:$websocket_port;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
프로젝트 깃허브
- https://github.com/DliDAM/backend-spring