웹 소켓이란?
WebSocket은 웹 앱과 서버 간의 지속적인 연결을 제공하는 프로토콜입니다. 이를 통해 서버와 클라이언트 간에 양방향 통신이 가능해집니다. HTTP와는 달리, WebSocket 연결은 한 번 열린 후 계속 유지되므로, 서버나 클라이언트에서 언제든지 데이터를 전송할 수 있다는 것이 특징입니다. 그렇기에 실시간으로 진행되는 통신에서 적극적으로 사용하고 있습니다.
특징
- 양방향 통신
- 데이터 송수신을 동시에 처리할 수 있는 통신 방법
- 클라이언트와 서버가 서로에게 원할 때 데이터를 주고 받을 수 있따.
- 통상적인 Http 통신은 Client가 요청을 보내는 경우에만 Server가 응답하는 단방향 통신
- 실시간 네트워킹
- 웹 환경에서 연속된 데이터를 빠르게 노출 ex) 채팅, 주식, 비디오 데이터
- 여러 단말기에 빠르게 데이터를 교환
웹 소켓 이전의 비슷한 기술
Polling vs Long polling vs Streaming
- 결과적으로 이 모든 방법이 HTTP를 통해 통신하기 떄문에 Request, Response 둘다 Header가 불필요하게 큼
핸드 쉐이킹
클라이언트 요청 주요 헤더
Get / HTTP/1.1/r/n : 요청 메서드와 요청 파일정보. HTTP 버전을 의미
Host : 서버의 도메인 네임으로 Host 헤더는 반드시 존재해야 한다.
User-Agent : 사용자가 어떤 클라이언트를 통해 요청을 보냈는지 알 수 있다.
Origin: 요청을 보낼 때, 요청이 어느 주소에서 시작되었는지 나타낸다.
Connection: 클라이언트와 서버가 connection에 대한 옵션을 정할 수 있게 알려줌.
Cache-Control: 메시지와 함께 캐시 지시자를 전달하기 위해 사용하는 헤더이다.
Upgrade: websocket이라는 단어를 꼭 사용해야 한다. (이유는 WebSocket 연결 과정에 서술)
Sec-WebSocket-Key: 유효한 요청인지를 확인하기 위한 키로 base64로 인코딩한다.(클라이언트에서 생성)
서버 응답 주요 헤더
Connection: 클라이언트와 서버가 connection에 대한 옵션을 정할 수 있게 알려줌. Upgrade가 반드시 포함되어야 함
Date: HTTP 메시지를 생성한 일시를 나타냄.
Sec-WebSkcoet-Accept: 클라이언트로부터 받은 Sec-WebSocket-Key를 사용하여 계산된 값
Server: 서버 소프트웨어 정보를 나타냄.
Access-Control-Allow-Credentials:클라이언트 요청이 쿠키를 통해서 자격 증명을 해야 하는 경우에 true를 응답받은 클라이언트는 실제 요청 시 서버에서 정의된 규격의 인증값이 담긴 쿠키를 함께 보내야 한다.
Access-Control-Allow-Headers: 요청을 허용하는 헤더.
Upgrade: websocket이라는 단어를 꼭 사용해야함. (이유는 WebSocket 연결 과정 서술)
WebSocket 핸드셰이크 과정에서 Sec-WebSocket-Key 검증 원리
- 클라이언트는 핸드셰이크 요청 헤더에 Sec-WebSocket-Key를 포함합니다.
예:
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== - 서버는 다음 과정을 통해 클라이언트의 요청을 검증합니다:
- 클라이언트가 제공한 Sec-WebSocket-Key 값에 RFC 6455에서 정의된 고정 문자열 258EAFA5-E914-47DA-95CA-C5AB0DC85B11을 결합합니다.
- 이 결합된 문자열을 SHA-1 알고리즘으로 해시한 후, 그 결과를 Base64로 인코딩합니다.
- 생성된 결과가 서버 응답 헤더의 Sec-WebSocket-Accept에 포함됩니다.
- 클라이언트는 서버로부터 응답을 수신한 후, Sec-WebSocket-Accept 값을 확인하여 핸드셰이크가 성공적으로 완료되었는지 검증합니다.
이 후 웹 소켓이 열리게 되면 메시지를 전송할 수 있음
프레임 헤더 구조
126bytes이하일 경우
127 byte이상, 65535 bytes 보다 작은 경우
65535 bytes 이상일 경우
FIN : 이 프레임이 전체 메시지의 끝인지 나타내는 플래그
RSV1, RSV2, RSV3 : WebSocket 확장을 위해 예약된 필드
OPCODE :
MSK : 마스킹 필드로 1로 세팅되어 있으면 Payload 데이터에 마스크로 4바이트가 설정된다. 보통 프레임마다 랜덤하게 만들어지고 XOR 로 적용한다. 마스킹을 하는 이유는 cache-poisoning attack을 방지하기 위함이다.
LENGTH : 이 프레임에 포함된 데이터의 총 길이를 나타내는 단위
웹 소켓 프로토콜 특징
- 최초 접속에서만 http 프로토콜 위에서 handshaking을 하기 때문에 http header를 사용한다.
- 웹소켓을 위한 별도의 포트는 없으며, 기존 포트(http-80, https-443)
- 프레임을 구성된 메시지라는 논리적 단위로 송수신
- 메시지에 포함될 수 있는 교환 가능한 메시지는 텍스트와 바이너리
웹소켓 한계
- HTML 5 이후에 나온기술(이전에는 Socket.io, SockJs 활용해야함)
즉, 브라우저와 웹 서버의 종류와 버전을 파악하여 가장 적합한 기술을 선택하여 사용해야 함 - 형식이 정해져 있기 않음
→ sub-portocols를 사용해서 주고 받는 메시지의 형태를 약속
→ STOMP(Simple Text Oriented Message Protocol)
채팅 통신을 하기 위한 형식을 정의
HTTP와 유사하게 간단히 정의되어 해석하기 편한 프로토콜 - 모든 것은 Server에 달렸다!
당신이 채팅방에 입장하게 되면 당신은 친구들과 연결된 거시 아니라 모두가 같은 Socket Server에 연결된 것이다.
그렇기 때문에, 누군가가 메세지를 보낸다면 그것은 다른 친구한테 보낸게 아니라 서버에 전달하게 된 것이고 서버는 이를 다른 다른 사용자들에게 전달만 할 뿐이다.
여기서 문제가 발생하는데, WebSocket Server는 모든 통신을 추척하기 위한 "메모리 파워"가 중요하다. 이 말은 유저가 많아질수록, 더 많은 메모리가 필요하고, 메모리가 많이 필요할 수록 서버에 더 많은 돈을 써야함을 의미한다. 또한, 서버를 빠르게 유지해야하는데 이미 수많은 통신이 서버를 통해 이루어지고 있다면 이는 지연(Latency)를 유발할 수 있다. 그리고 이는 최악의 유저경험이 되는 것이다. 또한 서버에 문제가 생기면 누구도 대화 할 수 없다는 문제도 있다.
이러한 문제를 해결하기 위해 Socket Server가 아닌 브라우저끼리 연결하여 데이터를 주고 받는 것이 "WebRTC" 이다.
Spring Boot 환경에서의 WebSocket
요구사항 : 특정 게임방이 있고 각각의 플레이어 게임방에 입장하면 방장은 입장한 플레이어를 실시간으로 확인할 수 있게 구현해야하는 상황
- build.gradle 설정
implementation 'org.springframework.boot:spring-boot-starter-websocket'
- @Configuration이용한 WebSocketConfigurer 구현
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new RoomWebSocketHandler(new WebSocketRoomManager()), "/ws/room").setAllowedOrigins("*"); // 클라이언트 도메인을 설정 (CORS)
}
}
- TextWebSocketHandler 구현
package com.dodam.dicegame.dicegame.sockethandler;
import com.dodam.dicegame.dicegame.vo.SocketPayloadVO;
import com.google.gson.Gson;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import com.dodam.dicegame.dicegame.manager.WebSocketRoomManager;
import java.io.IOException;
import java.util.Set;
@Slf4j
@RequiredArgsConstructor
public class RoomWebSocketHandler extends TextWebSocketHandler {
private final WebSocketRoomManager roomManager;
private final Gson gson = new Gson();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
log.info("WebSocket connection established: {}", session.getId());
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
log.info("Received message from {}: {}", session.getId(), message.getPayload());
SocketPayloadVO socketPayloadVO = gson.fromJson(message.getPayload(), SocketPayloadVO.class);
if ("joinRoom".equals(socketPayloadVO.getAction())) {
roomManager.addSessionToRoom(socketPayloadVO.getRoomId(), session.getId(), session);
broadcastToRoom(socketPayloadVO.getRoomId(), session.getId(), socketPayloadVO.getNickName() + "님이 입장하였습니다.");
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
log.info("WebSocket connection closed: {}", session.getId());
roomManager.removeSessionById(session.getId());
}
private void broadcastToRoom(String roomId, String selfSessionId, String message) {
Set<String> sessionIds = roomManager.getSessionsInRoom(roomId); // 방에 있는 세션 목록 조회
if (sessionIds == null) {
log.warn("No sessions found for room {}", roomId);
return;
}
sessionIds.forEach(sessionId -> {
WebSocketSession session = roomManager.getSessionById(sessionId);
if (isSkipSession(session, selfSessionId, sessionId))
return;
try {
session.sendMessage(new TextMessage(message));
} catch (IOException e) {
throw new RuntimeException("Failed to send message", e);
}
});
}
private boolean isSkipSession(WebSocketSession session, String selfSessionId, String sessionId) {
return session == null || !session.isOpen() || selfSessionId.equals(sessionId);
}
}
@Slf4j
@Component
@Getter
@Setter
@NoArgsConstructor
public class WebSocketRoomManager {
public final Map<String, Set<String>> roomSessionIdMap = new ConcurrentHashMap<>(); //roomId,sessionId
public final Map<String, WebSocketSession> sessionMap = new ConcurrentHashMap<>(); //sessionId,WebSocketSession
public void addSessionToRoom(String roomId, String sessionId, WebSocketSession session) {
roomSessionIdMap.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet()).add(sessionId);
sessionMap.put(sessionId, session);
}
public WebSocketSession getSessionById(String sessionId) {
return sessionMap.get(sessionId);
}
public void removeSessionById(String sessionId) {
sessionMap.remove(sessionId);
roomSessionIdMap.values().forEach(sessions -> sessions.remove(sessionId));
}
public Set<String> getSessionsInRoom(String roomId) {
return roomSessionIdMap.getOrDefault(roomId, ConcurrentHashMap.newKeySet());
}
}
- Client 접속
https통신이면 wss지원
- 크롬 개발자 도구 확인
)
독스토리에서 활용
- 차단 알람을 대시보드에서 실시간으로 표시 가능함.
- SuperAdmin이 정책 변경 시 접속 되어 있는 monitor계정에게 알람을 줄 수 있음
- 대용량 엑셀 데이터 다운로드 시 클라이언트에게 처리중이라는 상태 리턴 후, 서버에 생성이 완료되면 접속되어있는 클라이언트에게 Noti 할 수 있음.
참고자료 및 출처
https://www.youtube.com/watch?v=MPQHvwPxDUw&list=PLgXGHBqgT2TvpJ_p9L_yZKPifgdBOzdVH&index=98
'[개발관련] > Web' 카테고리의 다른 글
[Web] JMeter summary report 항목 정보 (0) | 2023.06.10 |
---|---|
우분투에서 Jenkins 설치 (0) | 2021.03.28 |
JSP에서 shell script 실행 코드 (0) | 2021.02.01 |
[링크] REST API 설계 가이드 (0) | 2020.02.06 |
이클립스 안쓰는 최근 workspace목록 삭제하기 (0) | 2019.03.27 |