Spring boot에서 WebSocket을 어떻게 사용할까?
WebSocket이란?
--
--
환경
--
- Spring Boot : 3.4.0
- build : Gradle
- Java : 17
--
SebSocket 의존성 추가
--
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-websocket'
--
DTO (+ Enum)
--
WebSocketMessageDto.java
import com.example.SimpleChat_WebSocket.enumeration.MessageType;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class WebSocketMessageDto {
private MessageType type; // JOIN(입장), LEAVE(나가기), MESSAGE(데이터 전달)
private Long RoomNumber; // 특정 유저들과 소통할 방(공간)의 고유 번호
private String sender; // 해당 메시지를 전달하는 수신자
private String message; // 전달할 메시지
}
MessageType.java
public enum MessageType {
JOIN,
LEAVE,
MESSAGE
;
}
해당 DTO는
웹소켓 통신에서 주고받을 데이터 객체로 사용하게 된다.
type은
관리하기 편하게 Enum을 사용했으며,
채팅방에 입장하는 요청인지, 나가는 요청인지, 메시지를 전달하는 요청인지 등을
구분하여 상황에 따라 로직을 수행하기 위해 사용된다.
RoomNumber는
현재 어떠한 동작을 수행하는 채팅방의 번호(코드)다.
sender는
현재 웹소켓 요청을 보낸 사용자의 이름(닉네임)이다.
message는
현재 채팅방에 전달할 메시지다.
--
WebSocketHandler
--
WebSocketSimpleChatHandler.java
import com.example.SimpleChat_WebSocket.dto.WebSocketMessageDto;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
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.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
@Component
@RequiredArgsConstructor
public class WebSocketSimpleChatHandler extends TextWebSocketHandler {
private final ObjectMapper objectMapper; // JSON 형식으로된 문자열과 객체 간의 변화를 편리하게 도와주는 용도
private final Set<WebSocketSession> sessions = new HashSet<>(); // Set을 통해 웹소켓 세션을 저장하여 관리하는 용도
private final Map<Long, Set<WebSocketSession>> roomSessionMap = new HashMap<>(); // MAP을 통해 방(그룹)과 해당 방에 참여 중인 사용자를 관리하는 용도
// 소켓 연결 확인 메서드 (소켓 연결 성공 시 호출되는 메서드)
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
sessions.add(session); // Set<세션>에 현재 연결된 사용자 세션을 추가
session.sendMessage(new TextMessage("웹소켓 연결"));
}
// 소켓 요청(메시지) 처리 메서드 (소켓 연결 이후 메시지를 수신할 때 호출되는 메서드)
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String data = message.getPayload(); // 클라이언트가 보낸 데이터(JSON 형태의 문자열)를 따로 가져오기
WebSocketMessageDto webSocketMessageDto = objectMapper.readValue(data, WebSocketMessageDto.class); // 위에서 가져온 data를 바로 "WebSocketMessageDto" 객체로 변환
// 메시지에서 type값에 따라 동작 분류 (JOIN = 방 참여, LEAVE = 방 나가기, MESSAGE = 데이터 전달)
switch (webSocketMessageDto.getType()){
case JOIN:
Long roomNumber = webSocketMessageDto.getRoomNumber(); // 방 코드 가져오기
Set<WebSocketSession> sessionRoom = roomSessionMap.get(roomNumber); // Map에 해당 방 코드를 사용하는 방이 있는지 찾아 해당 방과 매핑된 WebSocketSession들을 가져온다.
// 만약 해당 방과 매핑된 WebSocketSession이 존재하지 않는다면? (= 해당 방이 존재하지 않는다면)
if (sessionRoom == null){
sessionRoom = new HashSet<>(); // 빈 Set<WebSocketSession>를 생성
roomSessionMap.put(roomNumber, sessionRoom); // Map에 해당 코드의 방과 Set<WebSocketSession>을 매핑하여 추가
}
sessionRoom.add(session); // 해당 방과 매핑된 WebSocketSession에 사용자 세션(정보)를 추가하여 해당 방에 참여시킨다.
webSocketMessageDto.setMessage("사용자가 입장했습니다."); // 방 입장하는 메시지 담기
break;
case LEAVE:
roomSessionMap.get(webSocketMessageDto.getRoomNumber()).remove(session); // Map에서 현재 사용자의 방 번호인 MAP을 찾고 해당 방과 매핑된 Sessions에서 해당 사용자 세션을 제거
webSocketMessageDto.setMessage("사용자가 나갔습니다."); // 방 나가는 메시지 담기
break;
}
// 데이터(메시지) 전송
Set<WebSocketSession> sessionsInRoom = roomSessionMap.get(webSocketMessageDto.getRoomNumber()); // 특정 방과 매핑된 모든 세션을 가져옴
//만약 해당 방과 매핑된 세션(사용자)가 존재한다면
if(sessionsInRoom != null){
// 해당 세션안에 있는 모든 세션(사용자)을 모두에게 메시지 담기 (같은 방 안에 있는 사람들에게 메시지 전달용)
for (WebSocketSession webSocketSession : sessionsInRoom) {
String messagePayload = objectMapper.writeValueAsString(webSocketMessageDto); // mapper를 이용하여 DTO를 JSON 문자열로 변환
TextMessage textMessage = new TextMessage(messagePayload); // JSON 문자열을 TextMessage에 담는다.
webSocketSession.sendMessage(textMessage); // TextMessage를 세션에 메시지로 전송한다.
}
}
}
// 소켓 연결 종료 처리 메서드 (WebSocket 연결이 종료될 때 호출되는 메서드)
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
sessions.remove(session); // 웹소켓 연결된 모든 사용자를 관리하는 세션에서 해당 사용자 세션을 제거
session.sendMessage(new TextMessage("웹소켓 연결 종료")); // 연결 종료 메시지를 클라이언트에게 전달
}
}
웹소켓 통신에서 사용하는 session은 평소 http 통신할 때 사용하는 session과는 다르다.
웹소켓에서 사용하는 session은
웹소켓 연결될 때 해당 연결 정보를 담고 있는 객체로 보통 사용자를 구분할 때 사용한다.
상속받는 Handler 종류
TextWebSocketHandler는
웹소켓을 통해 텍스트 메시지를 처리하기 위한 클래스로,
연결된 후, 웹소켓 메시지 수신, 연결 종료 등 다양한 이벤트를 처리할 수 있는 기본적인 메서드를 제공받을 수 있다.
- afterConnectionEstablished(WebSocketSession session) 메서드
: 웹소켓 연결 시 호출되는 메서드 - handleTextMessage(WebSocketSession session, TextMessage message) 메서드
: 메세지 수신 시 호출되는 메서드 - afterConnecttionClosed(WebSocketSession session, CloseStatus status) 메서드
: 웹소켓 연결 종료 시 호출되는 메서드 - 이 외에도 예외 발생시 호출되는 메서드 등 여러 메서드를 제공한다.
즉, 해당 핸들러는 텍스트 기반 메시지를 처리하는 채팅, 알림 등에 적합하다.
BinaryWebSocketHandler는
웹소켓을 통해 바이너리 메시지를 처리하기 위한 클래스로,
이미지를 전송하거나, 큰 데이터 덩어리(파일, 비디오 등)를 실시간으로 주고받기 위해 사용된다.
WebSocketHandler는
Spring WebSocket의 기본 인터페이스로,
텍스트와 바이너리 메시지 외에도 다른 요구 사항에 맞는 다양한 메시지 처리를 하기 위해
직접 커스터마이징을 하기 위해 사용된다.
SubProtocolWebSocketHandler는
서브 프로토콜을 처리하는 데 사용되며,
기본적으로 WebSocket은 텍스트 메시지를 주고받지만,
가끔 특정 서브 프로토콜을 정의하여 메시지를 다르게 처리해야 할 때 사용된다.
서브 프로토콜(STOMP, MATT 등)을 사용하여 더 복잡한 메시지 처리 시스템을 구현할 때 사용된다.
채팅방과 사용자 세션을 Entity가 아닌 MAP으로 관리하는 이유
Map은
간단하고 빠르게 데이터를 관리할 수 있는 구조로
채팅방과 사용자는 대부분 서버 메모리에서 실시간으로 관리되어야 때문에,
Map을 사용하면 구현이 간단하고 성능도 좋게 사용할 수 있다.
그리고 Key-Value 구조로 채팅방 코드와 사용자를 기준으로 빠르게 조회도 가능하다.
그리고 HashMap 또는 ConcurrentHashmap은 조회와 삽입 성능이 뛰어나서
채팅과 같이 실시간으로 요청이 많은 경우에 적합하다.
고려해야 하는 경우는
영속성(데이터 저장)을 사용하지 않으므로
서버가 실행 중일 때만 데이터를 유지할 때 사용이 가능하며,
서버가 종료되거나 재시작할 때 상태가 초기화해도 무방한 경우에만 사용하면 좋다.
Entity는
보통 DB와 연동하여 데이터를 저장/조회를 하므로,
채팅방이나 사용자 목록은 실시간 처리가 중요하기 때문에
해당 데이터를 DB에 지속적으로 저장하고 읽는 것은 과도할 I/O 작업을 유발할 수 있으며,
DB에 접근하는 과정이 추가되어 실시간 응답성이 중요한 WebSocket 기반 애플리케이션에서는
성능이 낮아지게 된다.
그래서 Entity는
데이터의 영속성이 필요한 경우에 사용해야 한다.
즉, 기존 채팅 기록을 보존하거나 사용자가 나갔다 다시 들어와서 방 상태를 유지해야 하는 경우에 필요하다.
또는 WebSocket 연결을 여러 서버에 분산될 경우
각 서버는 동일한 상태를 공유해야 하므로 DB에 저장하거나 redis와 같은 공유 메모리 저장소를 사용해야 한다.
추가로 데이터에 대한 분석이나, 복잡한 쿼리가 필요한 경우에도 Entity를 사용하는 것이 적합하다.
한 번에 Map 하나로 관리하지 않고 Set으로 사용자 세션을 또 관리하는 이유
Set과 Map을 나눈 이유는
Set에서는 웹 소켓을 연결한 모든 클라이언트(사용자)의 세션을 저장하는 용도로
현재 웹소켓 서버에 연결된 모든 사용자(세션)를 추적하며,
특정 채팅방에 상관없이 연결된 세션에 대해 작업을 수행할 수 있도록 하기 위해 나눴다.
예시로 시스템 전체의 공통 메시지를 브로드캐스트 하거나,
사용자의 통계를 처리할 때 사용된다.
Map은
특정 채팅방과 해당 채팅방에 연결될 세션들을 관리하기 위한 용도로
key는 채팅방 코드, value는 해당 채팅방에 참가한 클라이언트 세션들이다.
즉, 채팅방과 해당 방에 참여한 사용자 목록을 관리하고,
특정 채팅방에서만 메시지를 보내는 용도로 사용된다.
각 메서드의 매개변수에 전달되는 값
"WebSocketSession session"
클라이언트(사용자)와의 연결 정보를 나타내는 객체로
현재 연결되어 웹소켓을 통한 요청을 보낸 사용자의 세션을 나타낸다.
해당 세션을 통해 사용자를 식별하는 데 사용되며, 메시지 송수신과 같은 작업도 가능하게 해 준다.
주요 정보
- session.getId() : 세션의 고유 식별자
- session.getUri() : 클라이언트가 요청한 WebSocket의 URI
- session.sendMessage() : 해당 클라이언트로 메시지를 전송할 때 사용하는 메서드
- session.isOpen() : 현재 해당 세션의 연결 상태 여부
"TextMessage message"
클라이언트가 보낸 텍스트 메시지로
전송한 데이터를 포함한 객체로, "message.getPlayload()"를 통해 해당 메시지 내용을 문자열로 꺼내올 수 있다.
예시 텍스트 메시지
{
roomNumber: 2838,
type: "JOIN",
message: "Hello!"
}
"CloseStatus status"
웹소켓 연결 종료 상태를 나타내는 정보를 가지고 있으며,
크게 2가지 정보를 제공한다.
- code : 종료 코드 (정수 값, 1000이 기본 종료 코드)
- reason : 종료 사유 (문자열, 선택적)
code 종류
1000 : 정상 종료
1001 : 원격 호스트에서 연결 종료
1002 : 프로토콜 오류
1003 : 메시지 크기 초과
1006 : 비정상 종료
해당 객체는 spring 웹소켓에서 웹소켓 연결이 종료될 때 자동으로 생성하여 해당 메서드에 전달된다.
클라이언트 or 서버 측에서 연결을 종료하는 경우 해당 객체가 생성되어 전송된다.
- 클라이언트가 연결을 종료했을 때
- 서버가 연결을 종료했을 때
- 네트워크 오류, 타임아웃, 비정상적인 종료 등이 발생했을 때
sendMessage()에 TextMessage로 데이터를 전달할 때 텍스트가 아닌 JSON으로 보내기
TextMessage로 데이터를 전달하면 해당 데이터는 문자열 형식으로 클라이언트에게 전달된다.
즉, JSON이 아니라 Text로 데이터를 응답하게 되는데
만약 JSON으로 보내고 싶다면 해당 문자열을 JSON 형태로 전달해줘야 한다.
session.sendMessage(new TextMessage("웹소켓 연결 종료"));
/*
클라이언트가 해당 데이터를 받는 형식
=> "웹소켓 연결 종료"
*/
session.sendMessage(new TextMessage("{\"message\": \"웹소켓 연결 종료\"}"));
/*
클라이언트가 해당 데이터를 받는 형식
=> "{
"message": "웹소켓 연결 종료"
}"
*/
하지만 이것도 문자열이 JSON 형태처럼 생긴 거지 JSON 데이터가 아닌 문자열 데이터는 여전하다.
그래서 클라이언트 측에서 데이터를 받을 때 JSON으로 파싱 하여 받도록 해줘야 한다.
그럼 클라이언트에서 해당 문자열을 JSON으로 파싱하여 받게 되어 결과적으로 JSON 데이터를 받게 된다.
--
웹소켓 설정
--
WebSocketConfig.java
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 // Spring에서 WebSocket 지원을 활성화 (SebSocket 관련 컴포넌트인 WebSocketHandler, WebSocketConfigurer 등을 스캔하고 설정하도록 함)
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
private final WebSocketHandler webSocketHandler;
// WebSocket 연결을 처리하기 위한 "엔드포인트"와 "WebSocket 핸들러"를 설정하는 메서드 (매개변수 WebSocketHandlerRegistry는 WebSocket 핸들러를 등록할 수 있는 API로 엔드포인트와 CORS 설정등을 함께 정의)
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry
// /ws/connect 경로로 WebSocket 연결을 허용
.addHandler(webSocketHandler, "/ws/connect")
// CORS 허용 (어떤 도메인에서든 WebSocket 연결 허용)
.setAllowedOrigins("*");
}
}
--
동작 과정
--
1. 클라이언트가 WebSocket 연결 요청을 서버로 보냄
"ws://localhost:8080/ws/connect"
Spring boot의 웹소켓 설정(WebSocketConfig.java)에서 지정한 경로로 요청 보낸다.
2. 서버의 WebSocketConfig.java파일로 요청을 라우팅 하여 연결이 가능한지 확인 후 연결을 수행하게 된다.
registry.addHandler(webSocketHandler, "/ws/conn").setAllowedOrigins("*");
3. 연결이 성공했다면 WebSocketSimpleChatHandler.java 파일의 "afterConnectionEstablished"메서드를 호출한다.
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
sessions.add(session); // Set<세션>에 현재 연결된 사용자 세션을 추가
session.sendMessage(new TextMessage("웹소켓 연결"));
}
사용자 세션을 관리하는 sessions에 해당 사용자의 세션을 추가하여 저장한다.
즉, 현재 서버와 웹소켓 연결한 사용자 목록에 해당 사용자를 추가한다.
그리고 메시지에 "웹소켓 연결" 문자열을 담아 클라이언트로 응답한다.
4. 연결 이후 클라이언트는 이제 메시지를 웹소켓 통신으로 전송이 가능해진다.
예시
const message = {
messageType: "JOIN", // JOIN, TALK, LEAVE 중 하나
chatRoomId: 1, // 채팅방 ID
sender: "User1", // 메시지 발신자
message: "안녕하세요" // 메시지 내용
};
socket.send(JSON.stringify(message));
5. Spring boot의 WebSocketSimpleChatHandler.java의 "handleTextMessage" 메서드를 호출하게 된다.
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
...
}
여기서는 요청 처리에 대한 로직들이 작성되어 있다.
보통 전달된 데이터를 가져와 DTO로 변환하여 사용한다.
전달된 데이터는 JSON이 아닌 JSON처럼 생긴 문자열이므로
이를 mapper를 이용하여 JSON으로 변환하여 DTO로 변환하게 된다.
그리고 해당 메시지에서 Type을 확인하여 무슨 요청인지 파악하고 해당 로직을 수행하게 된다.
그리고 마지막에 관련된 사용자들에게 메시지(데이터)를 전달한다.
6. 클라이언트가 WebSocket 연결을 종료하자고 요청을 보내면
예시
socket.close();
서버에서는 WebSocketSimpleHandler.java의 "afterConnectionClosed" 메서드를 호출한다.
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
sessions.remove(session);
}
관리 중인 세션 목록에서 해당 사용자 세션을 제거하여 웹소켓으로 연결을 하지 않는다고 명시하고
웹소켓 연결을 종료하게 된다.
--
'Spring Boot' 카테고리의 다른 글
Spring boot에서 STOMP 사용하기 [ 간단한 채팅 용 ] (0) | 2024.12.03 |
---|---|
[IntelliJ] finished with non-zero exit value 1 에러 해결 방법 (0) | 2024.11.26 |
이메일 발송하기 (Google SMTP Server/ Gmail을 통해 발송하기) (0) | 2024.06.01 |
spring security에서 발생하는 예외 처리하기 (0) | 2024.05.27 |
스케줄러(@Scheduled)를 이용하여 특정 로직을 자동으로 동작하게 하기 (0) | 2024.05.03 |