-
WebSocket을 이용한 실시간 채팅 구현하기 1: 서버Today I Learned 2022. 12. 18. 23:54
WebSocket을 이용한 실시간 채팅 구현 기록은 다음의 네 단계에 걸쳐 작성할 예정이다.
- WebSocket을 이용한 실시간 채팅 구현 1: 서버
- WebSocket을 이용한 실시간 채팅 구현 2: 클라이언트
- WebSocket을 이용한 실시간 채팅 구현 3: STOMP
- WebSocket을 이용한 실시간 채팅 구현 4: 프로젝트에 적용하기
특정 운동 모집 게시글에 참가하는 사용자들이 사용할 수 있는 각 게시글별 실시간 채팅 기능을 구현하기 위한 WebSocket 사용법을 학습하고 있다. 오늘은 Spring에서 WebSocket을 통해 특정 클라이언트가 서버에 메시지를 전송하면 서버와 연결되어 있는 다른 모든 클라이언트들에게 해당 메시지가 전송되는 서버를 구축하는 것을 실험했다.
전체 소스코드는 다음의 링크를 참조할 수 있다.
아직 클라이언트를 구현하지 않았으므로 클라이언트로는 구글 확장으로 제공되는 Simple WebSocket Client를 사용했다.
기본
TextWebSocketHandler
클라이언트가 전송한 텍스트 요청을 처리할 TextWebSocketHandler를 상속받은 socketTextHandler를 작성했다.
// utils/SocketTextHandler.java public class SocketTextHandler extends TextWebSocketHandler { private final Set<WebSocketSession> sessions = new HashSet<>() @Override public void afterConnectionEstablished(WebSocketSession session) { sessions.add(session); System.out.println("새 클라이언트와 연결되었습니다."); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException { System.out.println(message.getPayload()); for (WebSocketSession connectedSession : sessions) { connectedSession.sendMessage(message); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { sessions.remove(session); System.out.println("특정 클라이언트와의 연결이 해제되었습니다."); } }
- 클라이언트가 서버와 연결을 수립하기 위한 afterConnectionEstablished, 연결을 해제하기 위한 afterConnectionClosed, 텍스트 데이터를 주고받기 위한 handleTextMessage 메서드를 오버라이딩했다.
- 클라이언트가 서버에 연결을 수립하거나 해제하기 위한 요청을 보내면, 서버는 WebSocket이 연결될 때 생성되는 정보를 가진 객체 WebSocketSession 객체를 Handler가 관리하는 Set에 추가하거나 제거한다.
- 연결된 클라이언트가 서버에 데이터를 전송하면, Handler는 Set에 등록되어 있는 모든 session들을 순회하면서 전달받은 데이터를 각 session에 전송한다.
WebSocketConfigurer
Spring 웹 서버에서 WebSocket을 사용하도록 Configuration을 추가해주는 작업을 진행했다.
// config/WebSocketConfig.java @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(socketTextHandler(), "/chat") .setAllowedOrigins("*"); } @Bean public WebSocketHandler socketTextHandler() { return new SocketTextHandler(); } }
- @EnableWebSocket 어노테이션을 추가해 서버가 WebSocket 요청을 처리할 수 있도록 했다.
- 앞서 생성한 socketTextHandler를 @Bean으로 추가하고, 해당 Handler를 WebSocketHandlerRegistry에 추가했다. 이때 핸드 쉐이크를 수행하기 위해 요청을 보내야 할 path를 함께 지정했다. ('/chat')
- CORS 설정을 추가했다.
여기까지 수행하면 application.properties에 설정해놓은 port 번호에 path를 포함시킨 주소로 (ex. ws://localhost:8000/chat) 클라이언트에서 핸드 쉐이크 요청을 보내 서버와 연결을 수립할 수 있고, 특정 클라이언트에서 서버로 데이터를 전송할 경우 서버와 연결된 모든 클라이언트들에 데이터가 다시 전송되는 것을 확인할 수 있다.
응용
프로젝트에서는 게시글마다 하나의 채팅방이 존재하므로 채팅방이 여러 개 존재할 수 있는데, 각 채팅방을 구분할 수 있을지 고민이 되었다.
REST API 요청에서 path 상에서 달라지는 값을 식별하는 PathVariable처럼 핸드 쉐이크 요청을 보내는 주소에 각기 다른 식별자를 포함해 요청할 경우 해당 값을 식별하는 방법이 있는지 찾아보았다. 그 결과 registry에 handler를 추가할 때 Interceptor를 추가할 수 있다는 사실을 확인해 소스코드에 적용을 시도했다.
HandshakeInterceptor
핸드 쉐이킹 동작을 하기 전에, HandshakeInterceptor를 implement해 핸드 쉐이크 요청 주소에서 식별자 값을 attribute로 구별해놓는 동작을 수행하는 ChattingHandshakeInterceptor를 작성했다.
// interceptors/ChattingHandshakeInterceptor.java public class ChattingHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception { String path = request.getURI().getPath(); String roomId = path.substring(path.lastIndexOf('/') + 1); attributes.put("roomId", roomId); return true; } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // do nothing } }
WebSocketConfig는 다음과 같이 interceptor가 추가되고, 핸드 쉐이크 요청 주소가 변경되었다.
// config/WebSocketConfig.java @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(socketTextHandler(), "/chat/rooms/*") .addInterceptors(handshakeInterceptor()) .setAllowedOrigins("*"); } @Bean public HandshakeInterceptor handshakeInterceptor() { return new ChattingHandshakeInterceptor(); }
Room, RoomRepository
간단한 채팅방 객체인 Room과 채팅방들을 저장하는 컬렉션인 RoomRepository를 정의했다. 일단은 RoomRepository에 3개의 채팅방이 들어있도록 했다.
// models/Room.java public class Room { private Long id; private String name; private final Set<WebSocketSession> sessions = new HashSet<>(); public static Room create(String name) { Room room = new Room(); room.id = RoomIdGenerator.createId(); room.name = name; return room; } // getters }
// repositories/RoomRepository.java @Repository public class RoomRepository { private final Map<Long, Room> rooms; public RoomRepository() { rooms = Stream.of( Room.create("1번 채팅방"), Room.create("2번 채팅방"), Room.create("3번 채팅방") ).collect(Collectors.toMap( Room::id, room -> room )); } public Room room(Long id) { return rooms.get(id); } }
// utils/RoomIdGenerator.java public class RoomIdGenerator { private static Long id = 0L; public static Long createId() { id += 1; return id; } }
각 Room에 WebSocketSession 컬렉션이 존재하게 되었으므로, SocketTextHandler에 존재하던 컬렉션을 삭제하고, handler에서는 RoomRepository를 @Autowired로 의존성을 주입받아 Room 컬렉션에 접근할 수 있도록 했다.
// utils/SocketTextHandler.java public class SocketTextHandler extends TextWebSocketHandler { @Autowired private RoomRepository roomRepository; @Override public void afterConnectionEstablished(WebSocketSession session) { Long roomId = getRoomId(session); roomRepository.room(roomId).sessions().add(session); System.out.println("새 클라이언트와 연결되었습니다."); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException { Long roomId = getRoomId(session); Room room = roomRepository.room(roomId); System.out.println(message.getPayload()); for (WebSocketSession connectedSession : room.sessions()) { connectedSession.sendMessage(message); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { Long roomId = getRoomId(session); roomRepository.room(roomId).sessions().remove(session); System.out.println("특정 클라이언트와의 연결이 해제되었습니다."); } private Long getRoomId(WebSocketSession session) { return Long.parseLong( session.getAttributes() .get("roomId") .toString() ); } }
여기까지 구현된 내용들을 바탕으로 동작하는 화면은 다음과 같다.
References
- https://www.youtube.com/watch?v=rvss-_t6gzg
- https://supawer0728.github.io/2018/03/30/spring-websocket/
'Today I Learned' 카테고리의 다른 글
Parcel로 React 프로젝트 빌드 후 실행 시 src 디렉터리를 참조하지 못하는 문제 해결 과정 (0) 2023.01.02 WebSocket을 이용한 실시간 채팅 구현하기 2: 클라이언트 (0) 2022.12.21 Web Socket과 STOMP 이해하기 (1) 2022.12.17 Polling 방식을 이용해 간단한 실시간(?) 채팅 구현하기 (0) 2022.12.15 중첩된 DTO을 Validation할 때는 DTO 필드에 @Valid 어노테이션을 붙이기 (1) 2022.12.14