사람들은 살면서 "아 저거 꼭 해보고 싶었던 건데..." 하면서 후회하는 것이 있을 것이라 생각합니다.
저는 그중 하나가 웹소켓을 이용해 실시간 채팅 서비스를 만들어보는 것이었습니다.
여태 뭐하다가 왜 이제 하냐? 그것은 설날이기 때문이다... (본가에 와서 노트북 뚝딱거리기)
웹소켓을 이용해 실시간 채팅 서비스를 구현하면서 처음 접하는 sockJS, STOMP, 메시지브로커, 브로드캐스팅 등등... 어려운 용어와 개념을 익히는 데 시간이 걸렸기에 블로그에 정리해 놓고 많은 사람들이 쉽게 이 개념을 습득해서 자신의 서비스, 프로젝트에 이용해봤으면 하는 바람에 글을 적어봅니다.
웹소켓이란 무엇일까?
이번의 글의 핵심 주제입니다.
많이들 들어봤을 내용은 "웹소켓을 이용해 실시간 채팅 서비스를 구현해봤습니다!", "웹소켓으로 실시간 통신을 진행했습니다!"와 같이 실시간에 초점을 맞춰서 말을 많이 하곤 합니다.
웹소켓에 대해 알기 전에 먼저 Polling, Long Polling 방식에 대해 알고 넘어가면 웹소켓의 필요성을 더 느끼고 쉽게 이해할 수 있겠다 싶어 해당 개념부터 먼저 살펴보겠습니다.
브라우저 통신에서 많이 사용하는 프로토콜인 HTTP에서는 요청-응답 모델을 default로 사용하고 있습니다.
(요청을 보내고 응답을 주고 받는 방식)
그렇기에 폴링(Polling) 방식에 매우 적합하다고 볼 수 있습니다.
폴링(Polling)
주기적으로 요청을 보내서 필요한 데이터를 처리하는 방식을 의미합니다.
폴링 방식을 이용해서 채팅을 구현했다고 상상해 봅시다.
오른쪽에 있는 5초는 폴링 타이머입니다.
즉 5초마다 서버에 요청해서 해당 채팅 서버로 들어온 메시지를 전부 뿌려주도록 로직을 구성했다고 가정해 봅시다.
5초 뒤...
A는 5초 뒤에 모든 메시지를 한 번에 받기 때문에 이런 불상사가 발생하게 됩니다.
(이렇게 A의 짝은 또 떠나가고 말았습니다.)
그러면 폴링 방식은 실시간성을 띈다고 볼 수 있을까요?
일정 주기마다 요청을 보내는 방식이기에 실시간성과는 거리가 멀다고 볼 수 있습니다.
"그러면 서버로 요청 주기를 짧게 해서 자주 보내면 실시간처럼 보여서 위와 같은 문제는 해결되지 않을까요?"
위 말을 서버 개발자 번역기로 돌리면 아래와 같은 내용이 나올 것입니다.
그렇다.
실시간성을 챙기자고 서버에 0.1초로 폴링 주기를 잡고 보내게 되면 1초에 10번 서버에 부하테스트를 거는 겪이나 마찬가지가 됩니다.
클라이언트가 10명만 되어도 1분에 6천 번의 요청이 날아오게 됩니다.
이쯤 보면 폴링 방식으로는 실시간성을 챙기는 채팅을 구현할 수 없다는 생각이 들게 됩니다.
그럼 폴링 다음으로 생각해 볼 수 있는 방법은 롱폴링(Long Polling) 방식입니다.
롱 폴링(Long Polling)
폴링 방식과 유사하지만 이름 그대로 뭔가가 Long 합니다.
롱 폴링 방식은 클라이언트가 한번 요청을 보내면 서버에서는 응답 데이터가 있을 때까지 or 대기 시간이 끝날 때까지 기다렸다가 데이터를 보냅니다.
클라이언트는 데이터를 받으면 다시 백엔드로 동일하게 요청을 보냅니다.(주기적인 요청은 같은 개념)
그러면 롱폴링으로는 실시간성을 챙길 수 있을까요?
하지만 롱폴링도 문제점을 가지고 있습니다.
롱 폴링 방식은 요청을 보낸 후 응답을 받는 HTTP 방식입니다. 메시지를 실시간처럼 받으려면 응답을 받은 후에 서버로 다시 요청을 보내게 되는데, 다시 말하면 서버로 엄청 자주 HTTP 요청을 보내게 되는 문제가 발생하게 됩니다.
실시간성을 챙겼지만 그로 인해 부하를 얻게 되는 Trade-off가 발생하게 됩니다.
여기까지 서론이 길었네요
Polling으로는 한계가 있던 실시간성을 챙기고 효율적으로 데이터를 주고받기 위해 사용하는 방법이 웹소켓입니다.
웹소켓
- 웹소켓은 실시간 통신에 이용되는 기술
- 클라이언트와 서버의 최초 연결이 이루어지면 이후에는 양방향 통신을 지속적으로 진행
핵심은 웹소켓을 이용하면 첫 연결 이후에는 ws 프로토콜을 통해 데이터를 주고받게 된다.
위 그림은 최소한의 표현으로 웹소켓 통신 과정을 보여주고 있습니다.
먼저 클라이언트는 서버로 웹소켓 연결 요청을 보냅니다.
이때 HTTP를 사용해서 요청을 보내게 되는데 프로토콜 업그레이드 요청을 보냅니다.
위 사진은 실제로 리액트에서 스프링부트로 웹소켓 연결을 진행할 때 나오는 네트워크 탭입니다.
Request URL을 보면 ws://localhost:8080/ws/670/...으로 보내는 것을 확인할 수 있는데 이 단계가 서버에 웹소켓 연결을 요청하는 첫 시작입니다.
근데 왜 http가 아닌 ws로 요청을 보내는 거지?
-> 이건 실제로는 http로 보내는데 브라우저에서는 ws로 나오는 것이라 추측합니다.
그래도 조금 더 확실하게 확인해 보고자 와이어샤크 툴을 이용해서 패킷을 확인해 봤습니다.
101을 반환받는 부분인데 위 Request URI는 http로 시작하는 것을 확인할 수 있습니다.
(브라우저 표출의 문제인가 보다)
정상적으로 101, 즉 프로토콜 업그레이드 응답을 받았다면 클라이언트와 서버는 웹소켓 연결 상태를 유지하게 됩니다.
이후 메시지를 주고받을 때 웹소켓 프레임이라는 단위를 이용해서 데이터를 주고받습니다.
(간단하게 요약하면 기존의 HTTP 요청-응답에 비해 불필요한 데이터가 없어서 오버헤드가 적다)
해당 웹소켓 프레임에 대한 내용은 아래 블로그에서 그림과 함께 자세하게 정리가 되어 있습니다.
https://alnova2.tistory.com/915
웹소켓 헤더, 프레임 분석 (websocket header, frame)
웹 소켓은 HTML 상에서 말 그대로 소켓 연결을 하여 서버와 실시간으로 데이터를 주고받게 해주는 것이다. 웹소켓은 HTTP 의 반이중적인 통신을 보완하기 위해서 TCP 처럼 전이중적인 통신을 지원
alnova2.tistory.com
그러면 이제 아래와 같은 의문이 생기게 됩니다.
"그래. 클라이언트와 서버의 웹소켓 연결까지는 이해했어! 서로 메시지 주고받을 공간을 열어놓는다는 뜻이잖아?
근데 서버로 어떻게 메시지를 전달하고, 그 메시지를 어떻게 클라이언트한테 뿌리는 거지?"
위 의문을 해결하면 웹소켓의 90%는 이해했다고 볼 수 있습니다.
저 의문을 파헤치기 전에 알아야 하는 개념이 몇 가지 존재합니다.
Subscribe, Publish, 메시지 브로커
줄여서 pub(펍), sub(섭)이라고 많이 부릅니다.
발행(pub) - 구독(sub) 패턴으로 이름이 알려져 있기도 합니다.
sub은 특정 채팅방을 구독하는 행위, pub은 메시지를 보내는 행위라고 생각하면 이해하기 수월합니다.
3개의 채팅방이 존재하는데 A라는 채팅방에 들어간다고 가정해 봅시다.
먼저 박기현이라는 닉네임을 가진 사람이 A 채팅방에 구독(sub)을 날립니다.
이후에 최규호, 김소연이라는 닉네임을 가진 사람도 A 채팅방에 구독(sub)을 날립니다.
현재까지 A 채팅방에는 총 3명의 유저가 존재합니다.
여기서 박기현이 채팅방에 메시지를 날리면 어떻게 될까요?
이 메시지를 날리는 행위를 pub이라고 합니다.
박기현이 날린 메시지는 서버의 메시지 브로커가 수신하게 됩니다.
여기서 메시지 브로커란 "메시지를 중앙에서 관리하고 전달하는 중개자"입니다.
사용자의 메시지를 한 곳에서 수집하고, 그에 맞는 곳으로 해당 메시지를 전달해 주는 역할을 담당합니다.
(단일 서버의 경우는 문제가 없지만, 서버를 수평 확장하는 경우 외부 시스템을 이용하여 중앙 관리형으로 사용한다. 대표적으로 Redis, 카프카, RabbitMQ 등이 있다.)
메시지 브로커는 해당 채팅방을 구독한 모든 구독자에게 수신받은 메시지를 전달합니다.
그러면 해당 구독자들은 메시지를 수신하게 되는 것입니다. 여기까지가 실시간 채팅을 구성하는 과정입니다.
STOMP
Simple Text Oriented Messaging Protocol의 약어로 메시징 시스템에서 클라이언트와 서버(메시지 브로커)간에 메시지를 주고받기 위한 간단한 텍스트 기반의 통신 규약입니다.
주목할 점은 간단한 텍스트입니다.
간단한 텍스트로 이루어져 있기에 메시지 파싱이 단순하고, 다른 언어에서도 쉽게 구현할 수 있다는 게 가장 큰 장점입니다.
STOMP는 아래 6개의 명령어를 사용해서 연결, 종료, 메시지 송수신을 진행합니다.
- CONNECT : 클라이언트가 서버와 연결할 때
- SEND : 클라이언트가 서버로 메시지를 전송할 때
- SUBSCRIBE : 특정 주제(topic)를 구독할 때
- MESSAGE : 서버가 클라이언트에게 메시지를 보낼 때
- ACK/NACK : 메시지 수신 확인 또는 부정확한 수신 시
- DISCONNECT : 연결을 종료할 때
(위 사진은 실제로 vscode에서 console.log로 찍은 데이터이다. COMMAND로 각각의 행동에 맞는 명령어가 들어가는 것을 확인할 수 있다.)
그러면 실습으로 넘어가서 실제로 메시지를 잘 주고받는지 확인해보려고 합니다.
SpringBoot는 3.4.2, React는 18.3.18을 이용했으며 React는 Vite를 이용해서 프로젝트를 생성했습니다.
(정말 최소한의 코드만 이용하려고 노력했습니다.)
실습
스프링부트
웹소켓을 사용하기에 dependencies를 넣어줘야 합니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
runtimeOnly 'mysql:mysql-connector-java:8.0.21'
}
2번째 줄의 starter-websocket이 웹소켓 관련 의존성입니다.
(추가적인 dependencies는 환경에 맞게 추가해 주면 됩니다.)
WebSocketConfig
package com.example.chat.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
// 엔드포인트 등록을 위한 설정
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("http://localhost:5173")
.withSockJS();
}
// prefix로 sub이 붙으면 구독, pub이 붙으면 메시지 송신
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/sub");
registry.setApplicationDestinationPrefixes("/pub");
}
}
웹소켓을 사용하기 위해 환경설정을 진행해 주는 Config 클래스.
WebSocketMessageBrokerConfigurer를 구현하고 있으며 내부 메서드 2개를 구현하면 됩니다.
1.registerStompEndpoints
웹소켓과 STOMP를 사용하기 위해 엔드포인트를 등록하는 부분입니다.
/ws로 설정하면 처음 웹소켓을 연결할 때 http://서버주소/ws로 웹소켓 연결을 요청하게 됩니다.
setAllowedOriginPatterns을 통해 CORS 설정도 진행해줘야 합니다. 해당 메서드는 웹소켓의 CORS이기에 기존에 사용하던 CORSConfig가 있어도 별개로 설정해주어야 합니다.
withSockJS()는 간단하게 말하면 웹소켓을 지원하지 않는 브라우저에서도 웹소켓과 유사한 방식으로 실시간 통신을 진행할 수 있게 해주는 자바스크립트 라이브러리입니다.
(SockJS가 자동으로 HTTP 폴링, XHR 스트리밍 등의 방법으로 연결을 시도한다고 합니다.)
2. configureMessageBroker
파라미터로 넣어준 값으로 sub, pub을 구분합니다. prefix로 구분하기에 '/sub/어쩌고'로 보내면 구독,
'/pub/저쩌고'로 보내면 발행으로 판별합니다.
ChatController
@RestController
@RequiredArgsConstructor
@Slf4j
public class ChatController {
private ChatService chatService;
@MessageMapping("/send")
@SendTo("/sub/messages")
public String sendMessage(String inputMessage) {
log.info("메시지 들어옴 : {}", inputMessage);
return inputMessage;
}
}
여기서는 처음 보는 어노테이션들이 등장하게 됩니다.
@MessageMapping
클라이언트가 특정 엔드포인트로 전송한 메시지를 서버의 해당 메서드가 처리하도록 매핑을 진행해 줍니다.
'pub/send' 경로로 메시지를 보내면 설정에 따라 pub은 prefix로 제거하고 남은 경로인 send를 기반으로 어떤 메서드가 처리할지 결정하게 됩니다.
@SendTo
@MessageMapping으로 처리한 후 메서드의 반환값을 특정 목적지로 보내도록 지정합니다.
위 메서드에서는 '/sub/messages'로 메시지를 그대로 전달해주고 있습니다. '/sub/messages'를 구독한 모든 클라이언트에게 해당 메시지를 전달하게 됩니다.
리액트
리액트에서는 2개의 추가 라이브러리를 사용했습니다.
npm install sockjs-client stompjs
sockjs-client와 stompjs 라이브러리를 설치해 줍니다.
App.jsx
import { useState, useEffect } from "react";
import "./App.css";
import SockJS from "sockjs-client";
import { over } from "stompjs";
function App() {
const [stompClient, setStompClient] = useState(null);
// 수신된 모든 메시지를 저장할 배열
const [receivedMessages, setReceivedMessages] = useState([]);
// 전송할 메시지 내용을 저장할 문자열
const [inputMessage, setInputMessage] = useState("");
useEffect(() => {
const socket = new SockJS("http://localhost:8080/ws");
const client = over(socket);
console.log("웹소켓 연결 시도");
client.connect(
{},
() => {
console.log("웹소켓 연결 성공");
setStompClient(client);
client.subscribe("/sub/messages", (message) => {
console.log(message);
setReceivedMessages((prev) => [...prev, message.body]);
});
},
(error) => {
console.log("웹소켓 연결 실패", error);
}
);
}, []);
const sendMessage = () => {
if (stompClient && inputMessage.trim()) {
stompClient.send("/pub/send", {}, inputMessage);
setInputMessage("");
}
};
const endConnection = () => {
if (stompClient) {
stompClient.disconnect(() => {
console.log("웹소켓 연결 종료");
setStompClient(null);
});
}
};
return (
<div>
<h1>웹소켓 채팅</h1>
<div>
<input
type="text"
placeholder="메시지"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
></input>
<button onClick={sendMessage}>메시지 전송</button>
<button onClick={endConnection}>연결 종료</button>
</div>
<ul>
{receivedMessages.map((msg, idx) => (
<li key={idx}>{msg}</li>
))}
</ul>
</div>
);
}
export default App;
리액트 코드가 긴 관계로 부분 부분 나눠서 설명하겠습니다.
const socket = new SockJS("http://localhost:8080/ws");
const client = over(socket);
먼저 useEffect로 서버에 웹소켓 연결을 요청합니다. 이때 SockJS로 연결 요청할 URI를 넣은 뒤 stompjs로 한번 감싸줍니다.
SockJS로 웹소켓을 지원하지 않는 환경에서도 이용 가능하게 만든 다음에, STOMP 프로토콜을 사용하는 클라이언트로 한번 감싸는 방식입니다.
client.connect(
{},
() => {
console.log("웹소켓 연결 성공");
setStompClient(client);
client.subscribe("/sub/messages", (message) => {
console.log(message);
setReceivedMessages((prev) => [...prev, message.body]);
});
},
(error) => {
console.log("웹소켓 연결 실패", error);
}
);
이후 stompjs의 메서드인 connect로 웹소켓 연결을 진행합니다.
(이 과정에서 ws로 프로토콜 변경을 진행하게 됩니다. 101 반환)
이후 "sub/messages"라는 채널로 구독을 진행합니다. 이렇게 되면 서버 측에서 "sub/messages"로 메시지를 전송하면 콜백 이 실행되어 클라이언트가 message로 메시지를 전달받게 됩니다.
const sendMessage = () => {
if (stompClient && inputMessage.trim()) {
stompClient.send("/pub/send", {}, inputMessage);
setInputMessage("");
}
};
sendMessage는 '메시지 전송' 버튼을 누르면 동작하는 함수입니다.
"/pub/send"로 입력 메시지를 전송하는데 서버에서는 prefix인 pub은 제거하고 @MessageMappin에 send를 가지고 있는 메서드를 동작시킵니다.
이후 서버에서 추가적인 처리를 각자 서비스에 맞게 진행한 후 @SendTo의 경로인 "/sub/messages"로 메시지를 전송하면 해당 구독자들이 메시지를 수신하게 됩니다.
여기까지 웹소켓의 대한 간단한 개념, Sub, Pub, 메시지 브로커를 알아보고 스프링부트와 리액트를 이용해서 추가적인 실습까지 진행했습니다.
이틀 안으로 가볍게 끝내려고 했는데 블로그를 작성하다 보니 1주일이라는 시간 동안 붙잡고 있었습니다. 이 글을 통해 웹소켓의 대한 전반적인 개념 정리가 되었으면 좋겠습니다.
(저도 이번에 처음 웹소켓을 학습하고 적용했기에 잘못된 개념이 존재할 수 있습니다. docs, gpt랑 크로스 체킹을 진행하며 학습했지만 혹여나 오개념이 존재한다면 댓글로 깊은 태클 부탁드립니다.)
'기타' 카테고리의 다른 글
크롬 다중 세션을 쉽게 이용해보자(feat 인간 수동 테스트) (1) | 2025.01.25 |
---|---|
특정 사용자의 IP만 허용시키기 위한 IP 필터 제작하기 (0) | 2024.11.06 |
마이바티스 H2를 이용한 통합 테스트 환경 구축 방법 (1) | 2024.10.03 |
PK를 설정하는 방법(AutoIncrement, UUID) (2) | 2024.09.29 |