이번 프로젝트가 줌과 비슷한 웹 어플리케이션을 만드는 프로젝트이다보니, WebSocket, WebRTC에 대한 지식이 필요하게 되었다. 스프링에선 WebSocket을 어떻게 구현하는지 찾아 보던 중, 정말 구현을 잘해 놓은 글을 보게 되었고 해당 코드를 따라 치며 이해해보기로 하였다.
아래의 모든 코드는 링크 에서 가져왔으며, 본인이 추가 설명만 붙인 것임.
우선 WebSocket에 대해서 알아보자.
WebSocket은 클라이언트와 서버를 연결하고 실시간으로 통신이 가능하게 하는 프로토콜이다.
기존 Http 통신은 요청을 보내야만 요청을 받는 단방향 통신이고, Stateless(상태를 저장하지 않는) 방식이였다. 하지만 WebSocket은 양방향 통신으로 연결이 이루어지면 클라이언트가 별도의 요청을 보내지 않아도 데이터를 송신할 수 있다. 또한 상태를 유지하는 Stateful 프로토콜이다.
기존 HTTP 같이 양쪽 방향으로 송수신이 가능한 양방향 통신이지만 한 번에 하나의 전송만 이루어지도록 설정된 것을 반이중 통신(Half Duplex)이라하고, WebSocket 같이 데이터를 동시에 양방향으로 송수신 할 수 있는 것을 전이중 통신(Full Duplex)라고 한다.
만약 Notion, Google Docs 같이 여러 사용자가 동시에 한 문서를 편집하면 새로고침을 누르지 않아도 실시간으로 다른 사용자들이 편집한 부분이 자동적으로 적용되는 모습도 WebSocket을 이용한 기술이다.
WebSocket은 최초 연결 요청 시 HTTP를 통해 웹 서버에 요청(HandShake)한다. 이후 HandShake 성공 시, 통신 프로토콜이 WebSocket(ws)로 변경된다.
@RequiredArgsConstructor
@Configuration
@EnableWebSocket //WebSocket 활성화
public class WebSocketConfig implements WebSocketConfigurer {
private final WebSocketHandler webSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketHandler, "ws/chat").setAllowedOrigins("*");
}
}
WebConfigurer는 WebSocket을 활성화 하고, WebSocketHandler를 등록할 수 있게 해준다.
registerWebSocketHandlers 메서드를 통해 WebSocketHandler를 등록할 수 있다.
또한 registerWebSocketHandlers에 연결할 WebSocket 엔드 포인트("ws/chat")을 등록할 수 있다.
setAllowedOrigin은 지정한 Origin에서 오는 요청만 허용한다. *은 모든 요청을 허용한다.
위 코드에선 WebSocketConfigurer를 구현하고 WebSocketHandler, 엔드포인트를 "ws/chat"으로 등록하고 모든 Origin에서 오는 요청을 허용하도록 설정하였다.
@Slf4j
@RequiredArgsConstructor
@Component
public class WebSocketHandler extends TextWebSocketHandler {
private final ObjectMapper objectMapper;
//payload를 ChatMessage 객체로 만들어 주기 위한 objectMapper
private final ChatService chatService;
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload(); //메세지를 가져오기
log.info("{}", payload); //log 출력
ChatMessage chatMessage = objectMapper.readValue(payload, ChatMessage.class);
//payload를 ChatMessage 객체로 만들어주기
ChatRoom chatRoom = chatService.findRoomById(chatMessage.getRoomId());
//ChatMessage 객체에서 roomId를 가져와 일치하는 room 주입
chatRoom.handlerActions(session, chatMessage, chatService);
}
}
WebSocket 연결에 대한 로직처리를 담당한다.
클라이언트나 서버에서 메세지를 보내면, WebSocket에서 처리 후 다시 메세지를 보내는 역할을 담당한다.
대표 메서드
WebSession : WebSocket 연결에 대한 세션 정보 제공
TextMessage를 사용하니 TextWebSocketHandler를 구현하였고, handleTextMessage 메서드로 받은 메서드를 처리한다. chatRoom의 handlerActions 메서드는 추후에 나온다.
@Getter
@Setter
public class ChatMessage {
public enum MessageType{
ENTER, TALK
//처음 입장인지 아닌지 구별하는 Enum
}
//단순 DTO
private MessageType type;
private String roomId;
private String sender;
private String message;
}
DTO를 위한 ChatMessage 클래스이다. 만약 처음 입장하면 상태가 ENTER로 되어 있고, 이미 입장해 있으면 상태가 TALK으로 되어 있다.
@Getter
public class ChatRoom {
private String roomId;
private String name;
private Set<WebSocketSession> sessions = new HashSet<>();
@Builder //객체 생성에서 주입하는 것에 대한 방식 - Builder Pattern
public ChatRoom(String roomId, String name) {
this.roomId = roomId;
this.name = name;
}
public void handlerActions(WebSocketSession session, ChatMessage chatMessage, ChatService chatService) {
if (chatMessage.getType().equals(ChatMessage.MessageType.ENTER)) {
//방에 처음 들어왔을때
sessions.add(session);
chatMessage.setMessage(chatMessage.getSender() + "님이 입장했습니다.");
}
sendMessage(chatMessage, chatService);
//메세지 전송
}
private <T> void sendMessage(T message, ChatService chatService) {
sessions.parallelStream()
.forEach(session -> chatService.sendMessage(session, message));
//채팅방에 입장해 있는 모든 클라이언트에게 메세지 전송
}
}
WebSocketHandler에서 hanldeTextMessage에서 chatRoom.handlerActions를 호출하면 메세지 바디에 있는 type(ENTER, TALK)을 확인한다.
만약 ENTER 일 시, sessions에 세션을 추가하고 메세지 바디를 "XXX님이 입장했습니다." 로 설정하고, TALK 일 시, 바로 sendMessage 메서드를 통해 같은 방에 있는 모든 사람들에게 병렬 스트림을 이용하여 메세지를 전송한다.
@Slf4j
@RequiredArgsConstructor
@Service
public class ChatService {
private final ObjectMapper objectMapper;
private Map<String, ChatRoom> chatRooms;
//roomId를 key로 가지고 chatRoom을 value로 가짐
@PostConstruct //Bean 의존성 주입이 완료되고 실행되어야 하는 메서드에 사용
private void init() {
chatRooms = new LinkedHashMap<>();
}
//모든 방을 찾는 메서드
public List<ChatRoom> findAllRoom() {
return new ArrayList<>(chatRooms.values());
}
//id로 방을 찾고 결과로 ChatRoom 객체 반환
public ChatRoom findRoomById(String roomId) {
return chatRooms.get(roomId);
}
//방 생성 메서드
public ChatRoom createRoom(String name) {
String randomId = UUID.randomUUID().toString();
//랜덤 roomId 생성
ChatRoom chatRoom = ChatRoom.builder() //builder로 변수 세팅
.roomId(randomId)
.name(name)
.build();
chatRooms.put(randomId, 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);
}
}
}
ChatService 클래스에서는 방 목록을 저장하는 chatRooms, 모든 방 목록 조회, roomId로 방 조회, 사용자 이름으로 방 생성 메서드들이 있다.
@RequiredArgsConstructor
@RestController //HTTP 바디에 Json으로 써야하기 때문에 RestController 사용
@RequestMapping("/chat") //URL 매핑
public class ChatController {
private final ChatService chatService;
@PostMapping
public ChatRoom createRoom(@RequestBody String name) {
return chatService.createRoom(name);
//Post 요청이 들어올 시, Json에서 name 값을 받아 방을 생성한다.
}
@GetMapping
public List<ChatRoom> findAllRoom() {
return chatService.findAllRoom();
//Get 요청이 들어올 시, 모든 방 목록을 조회한다.
}
}
Post로 요청이 올 시 방을 생성하고 Get으로 요청이 올 시 모든 방을 조회한다.
다음과 같이 Postman을 이용하여 localhost:8080/chat에 Post로 이름을 담아 보내면 roomId를 반환한다.
이후 WebSocket Test Client에서 위에서 얻은 roomId를 통해 방에 입장할 수 있다.
{
"sender":"이건희",
"type":"ENTER",
"roomId":"e8f0769b-4fe1-48f9-9809-eb1f7bec09a4",
"message":"asd"
}
웹 소켓은 위에서 말했듯이 첫 연결때만 HTTP를 사용하고 이후에는 ws로 프로토콜이 변경되므로 URL 주소를 ws로 해야한다.
이후 새 창을 웹 소켓에 연결하고 type을 "ENTER"가 아닌 "TALK"로 변경하고 메세지를 입력하면 메세지를 주고 받을 수 있다.
좋은 글이네요. 공유해주셔서 감사합니다.