웹 사이트에서 채팅 기능을 구현하기 위해서는 채팅을 하는 모든 사용자가 실시간으로 양방향 통신을 해야한다. 따라서 이를 위한 웹소켓 기술에 대해 알아보고 스프링 기반 웹소켓 채팅 서버를 구현해보려고 한다.
목차
- Websocket 개요
- 웹소켓 배경
- 웹소켓 개념
- Spring Websocket
- 웹 소켓 서버 구축
- Postman으로 웹소켓 통신 테스트
- 채팅 고도화 - 채팅방 구현
- Postman으로 채팅방 입장 및 채팅 전송 테스트
- 회고
Websocket 개요
1. 웹소켓 배경
HTTP의 한계
- URL을 통한 요청: 인터넷이 나오고 HTTP를 통해 서버로부터 데이터를 가져오는 유일한 방법
- 따라서 HTTP는 사용자가 URL을 요청할 때에만 서버에서 해당 페이지를 꺼내는 방식
- 사용자는 서버로부터 새로운 정보를 받기 위해서는 반드시 새로운 URL을 요청해야 하는 문제가 존재함
Ajax 등장
- HTTP를 효과적으로 이용하는 기술로, 서버와 소통하기 위한 기술(약속 X)
- 클라이언트에서 XMLHttpRequest 객체를 이용하여 서버에 요청을 보내면 서버가 응답하는 방식
- 작동 방식
- 사용자의 이벤트로부터 Javascript는 사용자가 작성한 값이 쓰여진 DOM을 읽기
- XMLHttpRequest 객체를 통해 웹 서버에 해당 값을 전송
- 웹 서버는 요청을 처리하고 XML, Test, JSON 등을 이용하여 XMLHttpRequest에 전송
- Javascript가 해당 응답 정보를 DOM에 씀
- 장점
- 페이지 요청이 아닌 데이터 요청이라 부분적으로 정보를 갱신할 수 있게 됨
- 유저는 새로운 HTML을 서버로부터 받는 것이 아닌 동일한 페이지 내에서 DOM의 일부를 수정할 수 있게 됨
- HTML 페이지 전체를 바구는 것이 아닌 부분만 변경할 수 있게 됨
- 사용자 입장에서는 페이지 이동이 발생되지 않고, 페이지 내부 변화만 일어나게 해주어 그만큼이 자원과 시간을 아낄 수 있음
- 페이지 요청이 아닌 데이터 요청이라 부분적으로 정보를 갱신할 수 있게 됨
- 문제점
- Ajax도 결국 HTTP로 서버와 통신하기 때문에 요청을 보내야 응답을 받을 수 있음
- 변경된 데이터를 가져오기 위해 버튼을 누르거나 일정 시간 주기로 요청(폴링 방식)을 보내게 되면 번거롭고 자원 낭비가 발생함
- 이러한 문제점을 해결하기 위해 웹 소켓이 탄생함
2. 웹 소켓이란?
HTML5은 순수 웹 환경에서 실시간 양방향 통신이 가능해지게 만들었는데, 이 스펙의 명칭이 웹 소켓(Web Socket)
- HTML5 표준 기술로, 클라이언트와 서버를 연결하고 실시간으로 통신이 가능하도록 하는 통신 프로토콜
- Socket Connection을 유지한 채로 실시간 양방향 통신 혹은 데이터 전송이 가능한 프로토콜
- 특징
- 양방향 통신(Full-Duplex)
- 데이터 송수신을 동시에 처리할 수 있는 통신 방법
- 클라이언트와 서버가 원할 때 데이터를 주고받을 수 있음
- 실시간 네트워킹(Real Time-Networking)
- 웹 환경에서 연속된 데이터를 빠르게 노출함
- 양방향 통신(Full-Duplex)
- 동작 과정
- 웹 소켓은 HTTP로 Handshake로 초기 통신을 시작한 후, 웹 소켓 프로토콜로 변환하여 데이터를 전송함
- 먼저 클라이언트가 서버에 HTTP 프로토콜로 Handshake 요청하면, 서버에서 응답 코드를 101로 응답 해주고 통신을 시작함
- 웹 소켓 테스트 도구
- Postman
- 크롬 Simple WebSocket Client → STOMP 테스트는 지원하지 않음
- wscat → 서버 도는 터미널에서 사용하기 좋음
- 등등
Spring Websocket
웹 소켓 서버 구축
build.gradle
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-websocket'
...
}
- 웹소켓 의존성 추가
WebSocketConfig
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(signalingSocketHandler(), "/ws/chat")
.setAllowedOrigins("*");
}
@Bean
public WebSocketHandler signalingSocketHandler() {
return new WebSocketHandler();
}
}
- WebSocketConfigurer 인터페이스를 구현한다.
- 웹소켓 서버를 사용하도록 활성화(
@EnableWebSocket) - WebSocketHandler 클래스를 웹 소켓 핸들러로 정의함(
addHandler()) - 웹소켓 서버에 접속하기 위한 엔드포인트 설정(
/ws/chat) - 클라이언트에서 웹소켓 서버에 요청 시 모든 요청을 수용함(CORS)
- 도메인이 다른 서버에도 접속 가능하도록 설정함(
setAllowedOrigins("*"))
- 도메인이 다른 서버에도 접속 가능하도록 설정함(
- 웹소켓 서버를 사용하도록 활성화(
Message
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Message {
private String type;
private String sender;
private String receiver;
private Object data;
public void setSender(String sender) {
this.sender = sender;
}
public void newConnect() {
this.type = "new";
}
public void closeConnect() {
this.type = "close";
}
@Override
public String toString() {
return "Message{" +
"type='" + type + '\'' +
", sender='" + sender + '\'' +
", receiver='" + receiver + '\'' +
", data=" + data +
'}';
}
}
- 웹 소켓 통신 시, 사용할 메시지 스펙이다.
WebSocketHandler
@Slf4j
@Component
public class WebSocketHandler extends TextWebSocketHandler {
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
/**
* 웹소켓 연결
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
log.info("[afterConnectionEstablished] " + session.getId());
var sessionId = session.getId();
sessions.put(sessionId, session); // 세션에 저장
Message message = Message.builder().sender(sessionId).receiver("all").build();
message.newConnect();
sessions.values().forEach(s -> { // 모든 세션에 알림
try {
if(!s.getId().equals(sessionId)) {
s.sendMessage(new TextMessage(message.toString()));
}
}
catch (Exception e) {
// TODO: throw
}
});
}
/**
* 양방향 데이터 통신
*/
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage textMessage) throws Exception {
Message message = getObject(textMessage.getPayload());
message.setSender(session.getId());
WebSocketSession receiver = sessions.get(message.getReceiver()); // 메시지를 받을 타켓을 찾음
if(receiver != null && receiver.isOpen()) { // 타켓이 존재하고, 연결된 상태라면 메세지 전송
receiver.sendMessage(new TextMessage(message.toString()));
}
}
private Message getObject(String textMessage){
Gson gson = new Gson();
Message message = gson.fromJson(textMessage, Message.class);
return message;
}
/**
* 소켓 연결 종료
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
var sessionId = session.getId();
sessions.remove(sessionId);
final Message message = new Message();
message.closeConnect();
message.setSender(sessionId);
sessions.values().forEach(s -> {
try {
s.sendMessage(new TextMessage(message.toString()));
}
catch (Exception e) {
// TODO: throw
}
});
}
/**
* 소켓 통신 에러
*/
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
super.handleTransportError(session, exception);
}
}
- 소켓 통신은 서버와 클라이언트가 1:N 관계를 맺기 때문에 한 서버에 여러 클라이언트가 접속할 수 있다. 따라서 서버는 여러 클라이언트가 발송한 메시지를 받아 처리해줄 핸들러가 필요하다.
- 웹소켓을 통해 message를 전달하기 위해
WebSocketHandler인터페이스를 상속받은TextWebSocketHandler를 상속받아야 한다.afterConnectionEstablished: 웹소켓 연결- 최초로 웹소켓 서버에 연결하면, 웹소켓 서버에 연결된 다른 사용자에게 접속 여부를 전달해준다.
- Map으로 세션 id를 key로, 세션을 value로 저장하고 있는 Map 자료구조를 이용한다.
- 본인을 제외한 나머지 세션에게 메시지를 보낸다. (채팅방에 이미 들어와있던 사용자에게 신규 사용자가 들어왔다는 것을 알려주는 것)
handleTextMessage: 웹소켓을 통해서 받은 메세지를 처리하는 메소드(양방향 데이터 통신)- 메시지를 보낼 때는 메시지를 받을 타겟 사용자의 세션 아이디가 있어야 한다. 메시지 스펙에 맞게 JSON으로 메시지를 전송한다.
- sessions 저장소에서 전달할 사용자를 찾고, 연결된 상태라면 메시지를 전송한다.
afterConnectionClosed: 웹 소켓 연결 종료- 첫번째 사용자가 웹소켓을 종료하게 되면, 함께 통신하던 두번째 사용자에게 첫번째 사용자가 접속을 종료하였다는 메시지를 전송한다.
handleTransportError: 웹소켓 통신 에러
Postman으로 웹소켓 통신 테스트
- 왼쪽 사용자가 먼저 연결되고, 오른쪽 사용자가 연결이 되면 왼쪽 사용자에게 오른쪽 사용자가 연결되었다는 메시지를 전달한다.
Message{type='new', sender='2b04e4e8-8f82-ef73-e6c2-ce05eb33a337', receiver='all', data=null}
- 이 접속 정보에는 오른쪽 사용자의 세션 아이디가 저장되어 있다. 이를 이용해서 왼쪽 사용자가 오른쪽 사용자에게 메시지를 보낸다.
{"type"="offer", "sender"="uuid", "receiver"="2b04e4e8-8f82-ef73-e6c2-ce05eb33a337", "data"="hello"}
- 오른쪽 사용자는 왼쪽 사용자가 보낸 메시지를 받게 된다.
Message{type='offer', sender='f72ada59-99f2-2ff3-dd9d-b6105082ba5d', receiver='2b04e4e8-8f82-ef73-e6c2-ce05eb33a337', data=hello}
- 이후 오른쪽 사용자가 연결을 끊으면, 오른쪽 사용자는 왼쪽 사용자가 연결이 종료되었다는 메시지를 받는다.
Message{type='close', sender='2b04e4e8-8f82-ef73-e6c2-ce05eb33a337', receiver='null', data=null}
채팅 고도화 - 채팅방 구현
현재 웹소켓 통신은ws://localhost:8080/ws/chat에 연결된 클라이언트끼리만 통신이 가능하다. 채팅방이 하나 밖에 존재하지 않기 때문에 여러 개의 채팅방을 만들어서 각 채팅방에 입장하고 채팅을 할 수 있도록 만들 것이다.
ChatMessage
@Getter
@NoArgsConstructor
public class ChatMessage {
private MessageType type;
private String sender;
private String roomId;
private String message;
public void setEnterMessage() {
this.message = "[ " + MessageType.ENTER + " ] " + this.sender;
}
}
Message에서receiver를 없애고 메시지를 보낼 채팅방 id(roomId)를 추가한다.
ChatRoom
@Getter
public class ChatRoom {
private String roomId;
private String name;
private Set<WebSocketSession> sessions = new HashSet<>();
@Builder
public ChatRoom(String roomId, String name) {
this.roomId = roomId;
this.name = name;
}
public void addSession(WebSocketSession session) {
sessions.add(session);
}
}
- 채팅방은 입장한 클라이언트의 세션을 가지고 있어야 한다.
ChatService
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatService {
private final ObjectMapper objectMapper;
private Map<String, ChatRoom> chatRooms;
@PostConstruct
private void init() {
chatRooms = new LinkedHashMap<>();
}
public ChatRoom createRoom(String roomName) {
String roomId = UUID.randomUUID().toString();
ChatRoom chatRoom = ChatRoom.builder()
.roomId(roomId)
.name(roomName)
.build();
chatRooms.put(roomId, chatRoom);
return chatRoom;
}
public <T> void sendMessage(WebSocketSession receiver, ChatMessage message) {
try {
if(receiver != null && receiver.isOpen()) { // 타켓이 존재하고, 연결된 상태라면 메세지 전송
receiver.sendMessage(new TextMessage(objectMapper.writeValueAsString(message)));
}
}
catch (IOException e) {
log.error(e.getMessage(), e);
}
}
public List<ChatRoom> findAllRoom() {
return new ArrayList<>(chatRooms.values());
}
public ChatRoom findRoomById(String roomId) {
return chatRooms.get(roomId);
}
}
- 채팅방을 생성하고, 조회하며 한 세션에 메시지를 발송하는 기능을 가지고 있다.
createRoom(): 채팅방 생성
- 채팅방 id는 UUID로 생성하고, 채팅방을 모아둔 Map 자료구조인
chatRooms에 저장한다.
- 채팅방 id는 UUID로 생성하고, 채팅방을 모아둔 Map 자료구조인
sendMessage(): 메시지 발송- 보낼 메시지와 이 메시지를 받을 웹소켓 세션을 받아 메시지를 전송한다.
WebSocketHandler
@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketHandler extends TextWebSocketHandler {
private final ObjectMapper objectMapper;
private final ChatService chatService;
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
/**
* 웹소켓 연결
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
log.info("[연결 완료] " + session.getId());
var sessionId = session.getId();
sessions.put(sessionId, session); // 세션에 저장
}
/**
* 양방향 데이터 통신
*/
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage textMessage) throws Exception {
ChatMessage chatMessage = objectMapper.readValue(textMessage.getPayload(), ChatMessage.class);
ChatRoom chatRoom = chatService.findRoomById(chatMessage.getRoomId());
if(chatRoom != null) {
if (chatMessage.getType().equals(MessageType.ENTER)) {
chatRoom.addSession(session);
chatMessage.setEnterMessage();
}
chatRoom.getSessions().parallelStream().forEach(s -> {
if(!s.getId().equals(session.getId())) {
chatService.sendMessage(s, chatMessage);
}
});
}
}
/**
* 소켓 연결 종료
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
log.info("[연결 완료] " + session.getId());
var sessionId = session.getId();
sessions.remove(sessionId);
}
/**
* 소켓 통신 에러
*/
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
super.handleTransportError(session, exception);
}
}
- 클라이언트로부터 채팅방 id와 메시지가 담겨있는 ChatMessage를 전달 받는다.
- ChatMessage에 담긴 채팅방 Id로 메시지를 발송할 ChatRoom을 찾고, 해당 채팅방에 입장하고 있는 클라이언트에게 메시지를 전송한다. (본인 제외)
Postman으로 채팅방 입장 및 채팅 전송 테스트
채팅방 생성
두 클라이언트 채팅방 입장 및 채팅 전송
- 생성된 채팅방 id로 클라이언트가 채팅방에 입장한다.
{"type":"ENTER", "roomId": "6958755d-Oead-4£3b-81ee-06497eb56844", "sender":"tom" }
- 이후 해당 채팅방으로 메시지를 전송한다.
{"type":"TALK", "roomId": "6958755d-Oead-4£3b-81ee-06497eb56844", "sender":"tom", "message":"hello ~~"}
- 채팅이 가고, 오는 것을 확인할 수 있다.
회고
학부 때 네트워크 프로그래밍에서 소켓 통신을 공부한 적이 있었는데 당시에는 TCP/IP 소켓을 배웠었다. 그때 당시에.. 연결하고 데이터를 주고 받기까지 엄청 힘들었었던 기억이 있다...^&^... listen(), connet(), accept() 등등... 연결 과정도 굉장히 복잡했었기 때문에 스프링 웹소켓 모듈에게 굉장히 고마운 마음이 들었다. ㅎ
현재는 Websocket만을 이용해서 채팅을 구현하였다. 여기서 메시지를 직접 정의하였는데 STOMP 프로토콜을 사용하면 메시지 형식을 직접 개발 필요 없다고 한다. 메시지 형식이나 유형, 내용 등 규격을 갖춘 메시지를 보낼 수 있다고 하는데 이후에 좀 더 알아보고 적용해보려고 한다.
채팅을 전부터 계속 구현해보고 싶었는데 드디어 구현을 해본다! 하는김에 기초부터 차근차근 쌓아서 프로젝트에도 잘 적용해봐야 겠다.
참고
'Web > Spring, JPA' 카테고리의 다른 글
[Spring Websocket] 채팅 서버 구축 (3) - Redis의 Pub/Sub (0) | 2023.11.24 |
---|---|
[Spring Websocket] 채팅 서버 구축 (2) - STOMP (0) | 2023.11.23 |
[SpringSecurity] 스프링 시큐리티 이해(구조, 필터) (0) | 2023.09.17 |
[JPA] Entity 생성 방법 (0) | 2023.08.29 |
[아키텍쳐] 스프링 패키지 구조(계층형과 도메인형) (0) | 2023.08.22 |