본문으로 바로가기
웹 사이트에서 채팅 기능을 구현하기 위해서는 채팅을 하는 모든 사용자가 실시간으로 양방향 통신을 해야한다. 따라서 이를 위한 웹소켓 기술에 대해 알아보고 스프링 기반 웹소켓 채팅 서버를 구현해보려고 한다. 

 

목차

  • Websocket 개요
    • 웹소켓 배경
    • 웹소켓 개념
  • Spring Websocket
    • 웹 소켓 서버 구축
    • Postman으로 웹소켓 통신 테스트
    • 채팅 고도화 - 채팅방 구현
    • Postman으로 채팅방 입장 및 채팅 전송 테스트
  • 회고

 

Websocket 개요

1. 웹소켓 배경

 

HTTP의 한계

  • URL을 통한 요청: 인터넷이 나오고 HTTP를 통해 서버로부터 데이터를 가져오는 유일한 방법
    • 따라서 HTTP는 사용자가 URL을 요청할 때에만 서버에서 해당 페이지를 꺼내는 방식
  • 사용자는 서버로부터 새로운 정보를 받기 위해서는 반드시 새로운 URL을 요청해야 하는 문제가 존재함

 

Ajax 등장

  • HTTP를 효과적으로 이용하는 기술로, 서버와 소통하기 위한 기술(약속 X)
  • 클라이언트에서 XMLHttpRequest 객체를 이용하여 서버에 요청을 보내면 서버가 응답하는 방식
  • 작동 방식
    1. 사용자의 이벤트로부터 Javascript는 사용자가 작성한 값이 쓰여진 DOM을 읽기
    2. XMLHttpRequest 객체를 통해 웹 서버에 해당 값을 전송
    3. 웹 서버는 요청을 처리하고 XML, Test, JSON 등을 이용하여 XMLHttpRequest에 전송
    4. Javascript가 해당 응답 정보를 DOM에 씀
  • 장점
    • 페이지 요청이 아닌 데이터 요청이라 부분적으로 정보를 갱신할 수 있게 됨
      • 유저는 새로운 HTML을 서버로부터 받는 것이 아닌 동일한 페이지 내에서 DOM의 일부를 수정할 수 있게 됨
      • HTML 페이지 전체를 바구는 것이 아닌 부분만 변경할 수 있게 됨
    • 사용자 입장에서는 페이지 이동이 발생되지 않고, 페이지 내부 변화만 일어나게 해주어 그만큼이 자원과 시간을 아낄 수 있음
  • 문제점
    • Ajax도 결국 HTTP로 서버와 통신하기 때문에 요청을 보내야 응답을 받을 수 있음
    • 변경된 데이터를 가져오기 위해 버튼을 누르거나 일정 시간 주기로 요청(폴링 방식)을 보내게 되면 번거롭고 자원 낭비가 발생함
    • 이러한 문제점을 해결하기 위해 웹 소켓이 탄생함

 

2. 웹 소켓이란?

HTML5은 순수 웹 환경에서 실시간 양방향 통신이 가능해지게 만들었는데, 이 스펙의 명칭이 웹 소켓(Web Socket)
  • HTML5 표준 기술로, 클라이언트와 서버를 연결하고 실시간으로 통신이 가능하도록 하는 통신 프로토콜
  • Socket Connection을 유지한 채로 실시간 양방향 통신 혹은 데이터 전송이 가능한 프로토콜
  • 특징
    • 양방향 통신(Full-Duplex)
      • 데이터 송수신을 동시에 처리할 수 있는 통신 방법
      • 클라이언트와 서버가 원할 때 데이터를 주고받을 수 있음
    • 실시간 네트워킹(Real Time-Networking)
      • 웹 환경에서 연속된 데이터를 빠르게 노출함
  • 동작 과정
    • 웹 소켓은 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에 저장한다.
    • 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 프로토콜을 사용하면 메시지 형식을 직접 개발 필요 없다고 한다. 메시지 형식이나 유형, 내용 등 규격을 갖춘 메시지를 보낼 수 있다고 하는데 이후에 좀 더 알아보고 적용해보려고 한다. 

 채팅을 전부터 계속 구현해보고 싶었는데 드디어 구현을 해본다! 하는김에 기초부터 차근차근 쌓아서 프로젝트에도 잘 적용해봐야 겠다. 

 


참고