웹 소켓이란 클라이언트와 서버 간의 연결을 길게 유지하고, 이 연결을 통해서 양방향으로 데이터를 전송할 수 있게 해주는 통신 규약이다. 그리고 실시간성을 보장한다. 따라서 채팅, 알림, 실시간 주식 거래 사이트 등의 기능을 제공할 때 사용된다.
그런데, HTTP에서도 Polling, Long Polling, Streaming와 같이 실시간성을 보장하는 기법들이 존재하는데 웹 소켓을 사용하는 이유는 무엇일까? 웹 소켓과 HTTP의 차이점은 다음과 같다.
비 연결성: 따라서 매번 연결을 맺고 끊는 과정에서 비용이 발생한다.
Request - Response 구조: 일반적인 HTTP 통신은 클라이언트의 요청에서 통신이 시작된다. 따라서 채팅이나 알림 서비스와 같이 서버 측에서 발생한 데이터를 사용자에게 전달하는 기능을 만들기는 어렵다.
매 요청과 응답을 보낼 때마다 많은 양의 데이터를 함께 보내야 한다. 따라서 요청과 응답이 많은 실시간성을 요하는 사이트의 경우, 많은 양의 데이터를 주고 받는것이 부담이 될 수 있다.
한 번 연결을 맺은 뒤 유지한다. 따라서 매번 연결을 맺고 끊는 비용이 발생하지 않는다.
양방향 통신
처음에 핸드쉐이크를 하는 과정에서는 HTTP 프로토콜 하에서 이뤄지기 때문에 많은 양의 데이터를 주고 받지만, 한 번 연결이 된 후에는 간단한 메시지 정도만 오고간다. 따라서 HTTP에 비해서 통신에 오가는 비용을 줄일 수 있다.
양방향 통신(Full-Duplex)
데이터 송수신을 동시에 처리할 수 있는 양방향 통신 방법이다.
클라이언트와 서버가 서로에게 원할 때 데이터를 주고받을 수 있다.
웹 서버와 달리 HTTP 통신은 클라이언트가 요청을 보내는 경우에만 서버가 응답할 수 있는 단방향 통신이다.
실시간 네트워킹(Real Time-Networking)
웹 환경에서 연속된 데이터를 빠르게 노출시킬 수 있다.
따라서 채팅, 주식 등의 서비스에서 사용할 수 있다.
웹 소켓 클라이언트에서 핸드쉐이크 요청(HTTP Upgrade)을 전송하고 이에 대한 응답으로 핸드쉐이크 응답을 받는데, 이때 응답 코드는 101(Switching Protocol)이다. 101은 프로토콜 전환을 서버가 승인했음을 의미한다.
이 과정에서 요청과 응답 헤더를 살펴보면 다음과 같다.
GET /chat HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://localhost:9000
GET /chat HTTP/1.1
연결 수립 과정에서는 HTTP 프로토콜을 사용하고, GET 메소드를 사용한다.
Host: localhost:8080
웹 소켓 서버의 주소이다.
Upgrade: websocket
프로토콜을 전환하기 위해 사용하는 헤더이다. 웹 소켓 요청시에는 'websocket'이라는 값을 가진다.
Connection: Upgrade
현재의 전송이 완료된 후에 네트워크 접속을 유지할 것인가에 대한 정보이다. 웹 소켓 요청시에는 'Upgrade'라는 값을 가진다.
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
유효한 요청인지 확인하기 위해 사용하는 키 값으로, 길이가 16바이트인 임의로 선택된 숫자를 base64로 인코딩한 값이다.
Sec-WebSocket-Protocol: chat, superchat
사용하고자 하는 하나 이상의 웹 소켓 프로토콜을 지정한다.
Sec-WebSocket-Version: 13
클라이언트가 사용하고자 하는 웹 소켓 프로토콜 버전이다.
핸드쉐이크를 통해 웹 소켓 연결이 수립되면, HTTP 프로토콜에서 ws 프로토콜로 변경되고, 데이터 전송 파트가 시작된다.
여기에서 클라이언트와 서버는 'message'라는 개념으로 데이터를 주고 받는다. 이때 message는 한 개 이상의 frame으로 구성되어 있다.
message: 여러 frame이 모여서 구성되는 하나의 논리적 메시지 단위
frame: 커뮤니케이션에서 가장 작은 단위의 데이터로, 작은 헤더와 payload로 구성된다.
클라이언트와 서버 모두 커넥션을 종료하기 위한 close frame을 전송할 수 있다.
위 그림에서는 서버가 커넥션을 종료한다는 frame을 보내고, 클라이언트가 이에 대한 응답으로 close frame을 전송한다. 그러면 웹소켓 연결이 종료된다.
최초 접속시에 HTTP 프로토콜 위에서 핸드쉐이킹을 하기 때문에 HTTP Header를 사용한다.
웹 소켓을 위한 별도의 포트는 없으며 기존 포트(HTTP-80, HTTPS-443)를 사용한다.
frame으로 구성된 message라는 논리적 단위로 송수신을 한다.
메시지에 포함될 수 있는 교환 가능한 메시지는 텍스트와 바이너리뿐이다.
웹 소켓은 문자열들을 주고 받을 수 있게 해줄 뿐, 그 이상의 일은 하지 않는다. 즉 주고 받는 문자열의 해독은 온전히 어플리케이션에게 맡긴다. HTTP는 형식이 정해져 있기 때문에 해석이 쉽지만, 웹 소켓은 형식이 정해져 있지 않아 어플리케이션에서 해석하기가 어렵다. 따라서 웹 소켓은 서브 프로토콜을 사용해서 주고 받는 메시지의 형태를 약속하는 경우가 많다.
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
private final SimpleChatHandler simpleChatHandler;
// WebSocketHandler 객체를 등록하기 위한 메소드
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(simpleChatHandler, "ws/chat")
.setAllowedOrigins("*") // CORS 관련 설정
.withSockJS(); // 웹 소켓을 지원하지 않는 브라우저에서 SockJS 라이브러리가 사용된다.
}
}
@EnableWebSocket
어노테이션을 붙인다.
WebSocketConfigurer
을 구현한다.
addhandler()
메소드를 통해 클라이언트와의 통신을 처리할 핸들러를 등록한다. 이때 두번째 인자로는 웹 소켓 연결 주소를 전달한다.
setAllowedOrigins()
메소드는 CORS 관련 설정이다.
withSockJS()
메소드를 사용하면, 웹 소켓을 지원하지 않는 브라우저에서는 SockJS가 사용된다.
@Component
@Slf4j
public class SimpleChatHandler extends TextWebSocketHandler {
// 현재 연결되어 있는 클라이언트(WebSocketSession)를 관리하기 위한 리스트
private final List<WebSocketSession> sessions = new ArrayList<>();
@Override
// WebSocket 최초 연결시 실행된다.
public void afterConnectionEstablished(WebSocketSession session) {
sessions.add(session); // 연결이 되면 세션을 관리하도록 컬렉션에 저장
log.info("connected with session id = {}, total sessions = {}", session.getId(), sessions.size());
}
// 메시지를 받으면 실행된다.
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
log.info("receive = {}", payload);
for(WebSocketSession connected : sessions) {
connected.sendMessage(message);
}
}
// WebSocket 연결이 종료되었을 때 실행된다.
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
log.info("connection with [} closed", session.getId());
sessions.remove(session); // 더이상 세션 객체를 보유하지 않도록 제거
}
// 컨트롤러에서 해당 메소드를 호출해서 모든 사용자에게 메시지를 전달 가능하다.
public void broadcast(String message) throws IOException {
for(WebSocketSession connected : sessions) {
connected.sendMessage(new TextMessage(message));
}
}
}
웹 소켓은 텍스트와 바이너리 타입을 지원하는데 필요에 따라서 TextWebSocketHandler
혹은 BinaryWebSocketHandler
를 상속받아서 핸들러를 정의하면 된다.
WebSocketSession
은 웹 소켓이 연결될 때 생기는 연결 정보를 담고 있는 객체이다. 그리고 핸들러에서 웹 소켓 통신에 대한 처리를 하기 위해서 이 세션들을 컬렉션 형태로 보관하는 경우가 많다.
afterConnectionEstablished()
메소드는 웹 소켓 최초 연결시에 실행된다.
sessions.add(session)
: 웹 소켓이 연결되면 전달받은 세션을 컬렉션에 저장한다. handleTextMessage()
메소드는 메시지를 받으면 실행된다.
sendMessage()
메소드를 통해 세션들에게 입력 받은 메시지를 전달한다. afterConnectionClosed()
메소드는 웹 소켓 연결이 종료될 때 실행된다.
sessions.remove(session)
: 웹 소켓이 종료되면 컬렉션에서 세션을 제거하여 더이상 해당 세션을 관리하지 않도록 한다. broadcast()
메소드와 같이 관리하는 모든 세션에게 메시지를 전달하는 메소드를 만들면, 모든 사용자에게 메시지를 전달하는 기능을 구현할 수 있다.
STOMP: Streaming Text Oriented Messaging Protocol
단순히 데이터만 보내는 통신 규약인 웹 소켓에서, HTTP와 같은 구조로 메시지 형식을 갖추어 보내도록 하는 통신 규약이다. STOMP를 활용해 웹 소켓 통신을 진행하면 HTTP 요청과 비슷하게 Method 역할을 하는 Command, 부수적인 정보를 위한 Header, 실제 데이터를 나타내는 Payload 등을 정의할 수 있다.
메시지 브로커를 활용해서 Pub-Sub 방식으로 클라이언트와 서버가 쉽게 메시지를 주고 받을 수 있는 프로토콜이다. 이때 Pub-Sub(발행-구독)은 발신자가 어떤 경로와 같은 범주로 메시지를 발행하면, 이 경로를 구독하고 있는 수신자가 메시지를 받게되는 방식이다. 즉 하나의 URL에 연결된 모든 세션을 조정할 필요 없이, 특정 세션에게만 메시지를 보내도록 할 수도 있다.
웹 소켓 위에 얹어서 함께 사용할 수 있는 하위(서브) 프로토콜이다.
웹 소켓만 사용해도 되는 것을 왜 STOMP를 함께 사용해야 할까? 웹 소켓은 텍스트와 바이너리 타입의 메시지를 양방향으로 주고 받을 수 있는 프로토콜이다. 그러나 그 메시지를 어떤 형식으로 주고 받을지는 정해진 형식이 없다. 따라서 프로젝트가 커지는 상황에서 '클라이언트와 서버가 어떤 형식으로 메시지를 주고 받을지', '주고 받는 메시지의 타입은 어떤 것일지', '그 메시지의 본문과 설정 정보와 같은 데이터는 어떻게 구분할 것인지' 등을 정의해야 하고, 이들을 파싱하는 코드도 따로 구현해야 할 것이다.
이런 상황에서 STOMP를 사용하면 그런 형식을 정의할 필요도, 파싱하는 코드도 구현할 필요도 없다. STOMP는 프레임 단위의 프로토콜로 Command, Header, Payload로 구성된다.
왼쪽을 보면 메시지를 보내는 발신자와, 메시지를 받고자하는 구독자가 있다. 발신자는 이 구독자들에게 메시지를 보내고 싶어하고, 구독자들은 "/topic" 이라는 경로를 구독하고 있다고 가정해보자.
이 상황에서 발신자는 바로 "/topic" 이라는 헤더를 destination 헤더로 넣어서 메시지를 송신할 수도 있겠지만, 서버 내에서의 어떤 처리 혹은 가공이 필요하다면 이 "/app" 이라는 주소로 메시지를 송신하여 Message Handler를 타게 한다. 그리고 서버가 이를 모두 마쳤다면 가공되거나 처리된 메시지를 "/topic" 이라는 경로로 전송하면, 이 메시지가 Message Broker에게 전달되고, 그 다음에 이 Message Broker는 전달받은 메시지를 "/topic" 을 구독하고 있는 구독자들에 최종적으로 전달한다.
반면, 서버에서 메시지를 처리 혹은 가공할 필요가 없다면 바로 Message Broker를 통해 메시지가 구독자들에게 보낼 수 있다.
STOMP가 정의를 해주고 있기 때문에 하위 프로토콜 혹은 컨벤션을 따로 정의할 필요가 없다.
연결 주소마다 새로 핸들러를 구현하고 설정해줄 필요가 없다. 이때 핸들러는 @Controller
어노테이션을 사용하는 등 굉장히 익숙한 방식으로 사용된다.
스프링이 기본으로 제공해주는 내장 메시지 브로커 외에도 외부 Messaging Queue도 사용할 수 있다. (RabbitMQ, 카프카 ...)
Spring Security를 사용하여 오고가는 메시지들을 보안할 수 있다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/chatting")
.withSockJS();
}
}
@EnableWebSocketMessageBroker
어노테이션을 붙인다.
WebSocketMessageBrokerConfigurer
을 구현한다.
configureMessageBroker()
: STOMP에서는 메시지 브로커를 사용하는데, 이 메시지 브로커를 설정하는 메소드이다.
enableSimpleBroker()
: 스프링에서 제공하는 내장 브로커를 사용한다는 설정이다. 인자로 전달된 값이 prefix로 붙은 메시지가 송신되었을 때 그 메시지를 메시지 브로커가 처리하겠다는 의미이다.
즉 위 예제에서는 '/topic'이 앞에 붙은 메시지가 발신되고, SimpleBroker가 메시지를 구독자들에게 전달해준다.
setApplicationDestinationPrefixes()
: 바로 메시지 브로커가 메시지를 처리하는 것이 아니라, 서버 내에서 메시지의 가공 혹은 처리가 필요할 때 Message Handler를 타게 하도록 하는 설정이다.
즉 위 예제에서는 '/app'이 앞에 붙은 메시지가 발신되면, 해당 경로를 처리하는 핸들러에게 메시지가 전달된다.
registerStompEndpoints()
: 인자로 전달된 경로는 웹 소켓 연결 주소이다. STOMP를 사용하 웹 소켓만 사용했을 때와 다르게 핸들러를 따로 설정해줄 필요가 없다. 컨트롤러 방식이기 때문이다.
@Controller
public class GreetingController {
@MessageMapping("/hello")
@SendTo("/topic/greeting")
public Message greeting(Message message) throws Exception{
Thread.sleep(5000);
return new Message(message.getData());
}
}
웹 소켓만 사용했을 때와 달리 상속받을 필요 없이 @Controller
어노테이션을 통해 사용할 수 있다.
@MessageMapping
: STOMP 웹 소켓 통신을 통해 메시지가 들어왔을 때, 메시지의 destination 헤더와 MessageMapping의 경로가 일치하는 핸들러가 실행된다.
즉 위 예제에서는 Config에서 설정해둔 '/app'이라는 prefix와 합쳐져서 '/app/hello'라는 destination을 가진 메시지들이 이 핸들러를 거치게 된다.
@SendTo
: 핸들러에서 처리를 마친 뒤 반환값을 해당 경로로 보낸다는 의미이다.
즉 위 예제에서는 처리를 마치고 반환된 Message 객체가 '/topic/greeting' 경로로 다시 보내진다. 이때 앞에 '/topic'이 붙었기 때문에 SimpleBroker에게 전달된다.
Reference
https://kellis.tistory.com/65
https://www.youtube.com/watch?v=MPQHvwPxDUw
https://www.youtube.com/watch?v=rvss-_t6gzg