웹 기술 중 하나로, 양방향 통신을 지원하는 프로토콜입니다.
웹 브라우저와 서버간의 지속적인 연결을 통해 실시간 데이터를 주고받을 수 있게 해주는 기술입니다.
웹소켓은 HTTP 프로토콜과는 다르게 단일 연결을 통해 계속적인 데이터 교환을 할 수 있어서, 실시간성이 중요한 애플리케이션에서 유용하게 사용됩니다.
웹소켓은 실시간 채팅 애플리케이션, 주식 시세 업데이트, 온라인 게임 등 실시간 정보 전달이 필요한 많은 영역에서 사용됩니다. 대부분의 주요 웹 브라우저와 서버 플랫폼은 웹소켓을 지원하며, 여러 프로그래밍 언어와 라이브러리에서 웹소켓을 쉽게 구현할 수 있도록 도와줍니다.
간단한 이론을 봤으며 이제 간단히 Spring Boot와 WebSocket을 통해 채팅을 구현해보자
블로그를 보고 공부했으며 참조한 블로그를 링크로 남기겠습니다.
//websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:sockjs-client:1.1.2'
implementation 'org.webjars:stomp-websocket:2.3.3-1'
//view
implementation 'org.springframework.boot:spring-boot-starter-freemarker'
implementation 'org.springframework.boot:spring-boot-devtools'
implementation 'org.webjars.bower:bootstrap:4.3.1'
implementation 'org.webjars.bower:vue:2.5.16'
implementation 'org.webjars.bower:axios:0.17.1'
implementation 'com.google.code.gson:gson:2.8.0'
위처럼 WebSocket 의존성을 추가해주었습니다.
웹소켓을 Spring Boot 프로젝트에서 사용할 때, 웹소켓 라이브러리 외에도 웹 애플리케이션의 프론트엔드 부분에서도 관련 라이브러리 및 리소스를 사용해야 할 수 있습니다. 이때 웹 프론트엔드 라이브러리를 관리하기 위해 웹자들(WebJars)을 사용합니다.
소켓 통신은 서버와 클라이언트의 1:N 관계를 맺습니다.
따라서 여러 클라이언트가 발송한 메시지를 받아 처리해줄 핸들러가 필요합니다.
TextWebSocketHadnler를 상속받습니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketHandler extends TextWebSocketHandler {
private final ChatService chatService;
private final ObjectMapper objectMapper;
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
log.info("payload : {}", payload);
Message msg = objectMapper.readValue(payload, Message.class);
ChatRoom chatRoom = chatService.findById(msg.getRoomId());
chatRoom.handleActions(session, msg, chatService);
}
}
서비스와 메시지를 DTO로 매핑을 위해 ObjectMapper를 주입받습니다.
그 후, TextWebSocketHandler의 메서드를 Override하여 재작성하겠습니다.
위에서 작성한 Handler를 이용해서 WebSocket을 활성화하기 위한 Config 파일을 작성합니다.
@Slf4j
@Configuration
@RequiredArgsConstructor
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final WebSocketHandler webSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketHandler, "/ws/chat").setAllowedOriginPatterns("*");
}
}
채팅 메세지를 주고받기 위한 DTO
@Getter
@Setter
public class Message {
public enum MessageType {
ENTER, TALK
}
private MessageType messageType;
private String roomId;
private String sender;
private String message;
}
채팅방 클래스
@Getter
@Setter
public class ChatRoom {
private String roomId;
private Set<WebSocketSession> sessions = new HashSet<>();
@Builder
public ChatRoom(String roomId) {
this.roomId = roomId;
}
public void handleActions(WebSocketSession session, Message message, ChatService chatService) {
if (message.getMessageType().equals(Message.MessageType.ENTER)) {
sessions.add(session);
message.setMessage(message.getSender() + "님이 입장했습니다.");
}
sendMessage(message, chatService);
}
public <T> void sendMessage(T message, ChatService chatService) {
sessions.parallelStream().forEach(session -> chatService.sendMessage(session, message));
}
}
public <T> void sendMessage(T message, ChatService chatService) {
}
여기서 <T>는 제네릭(Generic) 타입을 나타내는 표현입니다. 제네릭은 클래스나 메서드를 선언할 때 타입을 매개변수화하여 여러 종류의 타입에 대해 동작하도록 만들 수 있는 기능입니다. 제네릭을 사용함으로써 코드의 재사용성과 유연성을 높일 수 있습니다.
여기서의 <T>는 메서드 sendMessage의 매개변수인 message의 타입을 지칭합니다. 실제로 메서드가 호출될 때, T는 실제 타입으로 대체됩니다. 즉, sendMessage 메서드를 호출할 때 어떤 타입의 message가 넘어오더라도 해당 타입에 맞게 메서드가 작동하게 됩니다.
예를 들어, 만약 sendMessage("Hello", chatService)를 호출한다면 T는 String 타입으로 대체되어 메시지의 타입이 String이 되고, sendMessage(42, chatService)를 호출한다면 T는 Integer 타입으로 대체되어 메시지의 타입이 Integer가 됩니다.
이렇게 제네릭을 사용하면 같은 기능을 하는 코드를 다양한 타입에 대해 재사용할 수 있으며, 컴파일 시 타입 안정성을 보장받을 수 있습니다.
채티방 생성, 조회, 메세지 발송을 하는 서비스 클래스입니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatService {
private final ObjectMapper objectMapper;
private Map<String, ChatRoom> chatRoomMap;
@PostConstruct
private void init() {
chatRoomMap = new LinkedHashMap<>();
}
public List<ChatRoom> findAllRoom() {
return new ArrayList<>(chatRoomMap.values());
}
public ChatRoom findById(String roomId) {
return chatRoomMap.get(roomId);
}
public ChatRoom createRoom(String name) {
ChatRoom chatRoom = ChatRoom.builder().roomId(name).build();
chatRoomMap.put(name, chatRoom);
return chatRoom;
}
public <T> void sendMessage(WebSocketSession session, T message) {
try {
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message)));
} catch (IOException e) {
log.error(e.getMessage(), e);
}
}
}
Rest API로 구현하였습니다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/chat")
public class ChatController {
private final ChatService chatService;
@PostMapping
public ChatRoom createRoom(@RequestParam String name) {
return chatService.createRoom(name);
}
@GetMapping
public List<ChatRoom> findAllRoom() {
return chatService.findAllRoom();
}
}
채팅방 생성과 조회만 할 수 있도록 했습니다.
클라이언트로부터 메세지 받음 →
WebSocketHandler에서 메세지 처리 →
메세지를 Message 클래스로 매핑 →
채팅방 ID를 통해 해당 채팅방을 찾음 →
해당 채티방의 handleActions를 통해 입장인지, 통신인지 분기 처리 →
그 후 메세지를 채팅방에 저장되어있는 Session(클라이언트들)에게 모두 전송 → ChatService의 SendMessage 메서드 사용
이런 순서로 동작을 합니다.
https://chrome.google.com/webstore/detail/simple-websocket-client/pfdhoblngboilpfeibdedpjgfnlcodoo/related?hl=ko
링크를 통해 Websocket 테스트를 할 수 있도록 추가해줍니다.
우선 포스트맨을 통해 채팅방을 생성합니다.
아까 작성한 엔드포인트를 작성해준 후,
{
"messageType":"ENTER",
"roomId":"chatRoom1",
"sender":"sender1",
"message":"hi"
}
요청에 위와 같이 작성한 후, Send를 누르면 서버로 보내집니다.
주황색 글씨는 클라이언트가 보낸 요청이고, 검은 글씨는 서버에서 보내준 응답 메세지입니다.
MessageType을 ENTER로 했기때문에 hi라는 메세지가 아닌 입장 메세지를 응답 받았습니다.
Type을 TALK으로 변경하고 다시 보내면 위와 같이 응답한다.
이제 창을 두 개 띄어 채팅방에서 이야기를 나눠보자
위 사진처럼 채팅을 할 수 있다.