웹 소켓은 웹 앱과 서버 간의 지속적인 연결을 제공하는 프로토콜로, 이를 통해 서버와 클라이언트간에 양방향 통신이 가능해진다.
HTTP와는 달리, 웹 소켓 연결은 한 번 열린 후 계속 유지되므로, 서버나 클라이언트에서 언제든지 데이터를 전송할 수 있어서, 실시간 통신에 적극적으로 사용되고 있다.
초기 인터넷 통신 방식은 주로 HTTP를 이용한 클라이언트(요청) - 서버(응답) 모델을 통해 진행되었다.
즉, 클라이언트가 서버에 요청을 보내고(Request), 서버가 이에 응답(Response)하는 반이중 통신 방식을 따른다.
이 방식은, 서버에게 요청하지 않는 이상 서버는 클라이언트에게 먼저 데이터를 보낼수 없는데, 따라서 클라이언트는 항상 새로운 데이터가 있는지 확인을 하기 위해 서버에 지속적으로 요청을 보내야한다.
이는 트래픽을 불필요하게 증가시키고, 이로 인해 서버의 비용이 증가될 뿐더러 요청과 응답사이의 지연시간이 있기 때문에 실시간 통신의 효율성을 저하시킨다.

TCP를 기반으로 하는 웹 소켓은 신뢰성 있는 데이터 전송을 보장하며, 메시지 경계를 존중하고, 순서가 보장된 양방향 통신을 제공한다.
HTTP와 다르게 클라이언트와 서버 간에 최초 연결이 이루어지면, 이 연결을 통해 양방향 통신을 지속적으로 할 수 있다. 즉, 전화 통화와 같이 양쪽 모두에서 정보를 주고받는게 가능해짐
이 지속적 연결을 통해 서버는 클라이언트에게 실시간으로 데이터를 보낼 수 있으며, 반대도 가능하다.
이때 데이터는 패킷(Packet) 형태로 전달되며 전송은 연결 중단과 추가 HTTP 요청 없이 양방향으로 이뤄진다
그리고 통신시에 지정되는 URL은 http://www.sample.com/ 과 같은 형식이 아니라 ws://www.sample.com/ 과 같은 형식이 된다.
implementation 'org.springframework.boot:spring-boot-starter-websocket'
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
private final WebSocketHandler webSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// endpoint 설정 : /api/v1/chat/{postId}
// 이를 통해서 ws://localhost:9090/ws/chat 으로 요청이 들어오면 websocket 통신을 진행한다.
// setAllowedOrigins("*")는 모든 ip에서 접속 가능하도록 해줌
registry.addHandler(webSocketHandler, "/ws/chat").setAllowedOrigins("*");
}
}
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sunkyuj.douner.chat.model.ChatMessageDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
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 java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/*
* WebSocket Handler 작성
* 소켓 통신은 서버와 클라이언트가 1:n으로 관계를 맺는다. 따라서 한 서버에 여러 클라이언트 접속 가능
* 서버에는 여러 클라이언트가 발송한 메세지를 받아 처리해줄 핸들러가 필요
* TextWebSocketHandler를 상속받아 핸들러 작성
* 클라이언트로 받은 메세지를 log로 출력하고 클라이언트로 환영 메세지를 보내줌
* */
@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketChatHandler extends TextWebSocketHandler {
private final ObjectMapper mapper;
// 현재 연결된 세션들
private final Set<WebSocketSession> sessions = new HashSet<>();
// chatRoomId: {session1, session2}
private final Map<Long,Set<WebSocketSession>> chatRoomSessionMap = new HashMap<>();
// 소켓 연결 확인
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// TODO Auto-generated method stub
log.info("{} 연결됨", session.getId());
sessions.add(session);
}
// 소켓 통신 시 메세지의 전송을 다루는 부분
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
log.info("payload {}", payload);
// 페이로드 -> chatMessageDto로 변환
ChatMessageDto chatMessageDto = mapper.readValue(payload, ChatMessageDto.class);
log.info("session {}", chatMessageDto.toString());
Long chatRoomId = chatMessageDto.getChatRoomId();
// 메모리 상에 채팅방에 대한 세션 없으면 만들어줌
if(!chatRoomSessionMap.containsKey(chatRoomId)){
chatRoomSessionMap.put(chatRoomId,new HashSet<>());
}
Set<WebSocketSession> chatRoomSession = chatRoomSessionMap.get(chatRoomId);
// message 에 담긴 타입을 확인한다.
// 이때 message 에서 getType 으로 가져온 내용이
// ChatDTO 의 열거형인 MessageType 안에 있는 ENTER 과 동일한 값이라면
if (chatMessageDto.getMessageType().equals(ChatMessageDto.MessageType.ENTER)) {
// sessions 에 넘어온 session 을 담고,
chatRoomSession.add(session);
}
if (chatRoomSession.size()>=3) {
removeClosedSession(chatRoomSession);
}
sendMessageToChatRoom(chatMessageDto, chatRoomSession);
}
// 소켓 종료 확인
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
// TODO Auto-generated method stub
log.info("{} 연결 끊김", session.getId());
sessions.remove(session);
}
// ====== 채팅 관련 메소드 ======
private void removeClosedSession(Set<WebSocketSession> chatRoomSession) {
chatRoomSession.removeIf(sess -> !sessions.contains(sess));
}
private void sendMessageToChatRoom(ChatMessageDto chatMessageDto, Set<WebSocketSession> chatRoomSession) {
chatRoomSession.parallelStream().forEach(sess -> sendMessage(sess, chatMessageDto));//2
}
public <T> void sendMessage(WebSocketSession session, T message) {
try{
session.sendMessage(new TextMessage(mapper.writeValueAsString(message)));
} catch (IOException e) {
log.error(e.getMessage(), e);
}
}
}
import lombok.*;
@Builder
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class ChatMessageDto {
// 메시지 타입 : 입장, 채팅
public enum MessageType{
ENTER, TALK
}
private MessageType messageType; // 메시지 타입
private Long chatRoomId; // 방 번호
private Long senderId; // 채팅을 보낸 사람
private String message; // 메시지
}
{
"messageType":"TALK", // ENTER, TALK
"chatRoomId":1, // 채팅방 번호
"senderId":100, // 메세지 전송자의 UserId
"message":"hello" // 메세지 내용
}