본문 바로가기
넓고 얕은 웹 지식

직접 만들어 보면서 이해하는 웹소켓 (2) - 크롬 확장프로그램을 이용하여 실시간 채팅방 구현하기

by 황원용 2024. 5. 12.
728x90

지난 글에 이어 채팅방을 구현해 보자.

  • 프론트엔드 영역은 웹소켓 공부에 포커싱 하고자 웹소켓 확장 프로그램으로 대신했다.

 

👨🏻‍🔬 실습 진행

🛠️ 확장 프로그램 설치 - WebSocket King Client

  • 구글 크롬의 확장 프로그램에 WebSocket이라고 검색하면 웹소켓에 관련된 여러 테스팅 & 디버깅 툴이 나온다.
  • 그중에 현재 기준 가장 인기 있는 툴을 사용했다.
WebSocket king is a tool designed to assist in developing and debugging WebSocket connections.
  • 툴의 개요를 보면 웹소켓 연결에 대한 개발과 디버딩을 도와주는 툴이라고 적혀있다.

  • 프로그램 설치 후 들어가 보면 위와 같은 화면이 나온다.

 

☘️ 스프링부트 프로젝트 생성

🖋️ build.gradle

dependencies {
	...
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
}
  • spring에서 제공하는 websocket 의존성을 추가한다.

 

📝 WebSocketConfig

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.*;

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry
                .addHandler(signalingSocketHandler(), "/ws") 
                .setAllowedOrigins("*");
    }

    @Bean
    public WebSocketHandler signalingSocketHandler() {

        return new WebSocketHandler();
    }
}
  • WebSocketConfigurer를 구현한 WebSocketConfig 클래스를 만든다.

@EnableWebSocket

  • 이 애너테이션으로 웹소켓 메시지를 다룰 수 있게 한다.

.addHandler(signalingSocketHandler(), "/ws")

  • 웹소켓 서버의 엔드포인트는 {url}:{port}/ws로 설정한다.

.setAllowedOrigins("*");

  • 모든 클라이언트의 요청을 수용한다.

 

📝 WebSocketHandler

import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.util.Map;
import java.util.StringTokenizer;
import java.util.concurrent.ConcurrentHashMap;


public class WebSocketHandler extends TextWebSocketHandler {

    private final Map<String, WebSocketSession> sessionMap = new ConcurrentHashMap<>();
    private static StringTokenizer st;

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {

        // 세션의 ID는 서버가 클라이언트와의 WebSocket 연결을 수립할 때 자동으로 생성
        String sessionId = session.getId();

        // 세션 저장
        sessionMap.put(sessionId, session);

        sessionMap.values().forEach(s -> {
            try {
                s.sendMessage(new TextMessage(sessionId + "님이 대화방에 들어오셨습니다."));

            } catch (IOException e) {
                throw new RuntimeException("message 전송 실패!!!");
            }
        });

    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage textMessage) throws Exception {
        String sessionId = session.getId();
        String[] sessionIdStrings = sessionId.split("-");

        // 대화창에 표시할 보낸 사람 아이디
        String senderId = sessionIdStrings[0];

        // 메시지 내용 파싱
        String textMessagePayload = textMessage.getPayload();

        // /귓속말:(sessionId 앞 부분) (내용) <- 개인 메시지 양식
        if (textMessagePayload.contains("/귓속말")) {
            st = new StringTokenizer(textMessagePayload, " ");
            String partialReceiverSessionId = st.nextToken().replaceAll("/귓속말:", "");

            // 세션 id의 일부분만을 가지고 전체 세션 id를 찾는 코드
            // 중복 가능성이 있지만 보다 짧은 세션 id 사용을 위해 적용
            String receiverSessionId = "";
            for (Map.Entry<String, WebSocketSession> entry : sessionMap.entrySet()) {
                if (entry.getKey().contains(partialReceiverSessionId)) {
                    receiverSessionId = entry.getKey();
                }
            }

            // 특정 개인에게 메시지 전달
            WebSocketSession receiver = sessionMap.get(receiverSessionId);

            if (receiver != null && receiver.isOpen()) {
                receiver.sendMessage(new TextMessage(senderId + " : " + st.nextToken()));
            }
        } else {
            // 대화방 전체에 메시지 전달
            sessionMap.values().forEach(s -> {
                try {
                    // 자신을 제외한 참가자들에게 메시지 전달
                    if (!sessionId.equals(s.getId())) {
                        s.sendMessage(new TextMessage(senderId + " : " + textMessagePayload));
                    }

                } catch (IOException e) {
                    throw new RuntimeException("message 전송 실패!!!");
                }
            });
        }
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        super.handleTransportError(session, exception);
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        String sessionId = session.getId();

        sessionMap.remove(sessionId);

        sessionMap.values().forEach(s -> {
            try {
                s.sendMessage(new TextMessage(sessionId + "님이 대화방을 나가셨습니다."));
            } catch (IOException e) {
                throw new RuntimeException("message 전송 실패!!!");
            }
        });
    }
}

afterConnectionEstablished(WebSocketSession session)

  • 새로운 클라이언트의 웹소켓 연결이 성공적으로 수립되었을 때 호출된다.
  • 클라이언트의 세션 ID를 통해 해당 세션을 sessionMap에 저장한다.
    • 세션 id는 웹소켓 연결이 되면 자동으로 생성된다. 
    • WebSocketSession의 구현체인 StandardWebSocketSession 클래스의 생성자 메서드를 보면 확인 가능하다.
      • this.id = idGenerator.generateId().toString()에서 만들어진다.
  • forEach 메서드를 이용하여 새로운 참가자가 대화방에 들어온 것을 모든 클라이언트에게 알리는 메시지를 보낸다.
    • sessionId로 참가자를 구분한다.

handleTextMessage(WebSocketSession session, TextMessage textMessage)

  • 다른 참가자로부터 텍스트 메시지를 받았을 때 호출된다. 
  • 받은 메시지가 "/귓속말" 명령을 포함하는 경우에는 명령어 다음에 오는 세션 ID의 일부분을 사용하여 특정 참가자에게 개인 메시지를 보낼 수 있게 만들었다.
    • 세션 ID의 일부만 사용하기 때문에 중복 가능성이 있지만 간단한 예시이므로 짧은 세션 ID를 사용하기 위해 이 방법을 사용했다. 
  • "/귓속말" 명령이 아닌 경우에는 메시지를 보낸 자신을 제외한 모든 참가자에게 메시지를 전달한다.

handleTransportError

  • 웹소켓 세션에서 통신 에러가 발생했을 때 호출된다. 
  • 이 메서드는 기본적으로 상위 클래스의 같은 메서드를 호출한다. 
  • 에러 처리 로직이 필요한 경우에 이 메서드를 오버라이드하여 구현할 수 있다.

afterConnectionClosed

  • 클라이언트(참가자)의 웹소켓 연결이 닫혔을 때 호출된다. 
  • 연결이 닫힌 클라이언트를 sessionMap에서 제거한다. 
  • forEach 메서드를 이용하여 대화방을 나갔음을 다른 모든 참가자에게 알리는 메시지를 보낸다.
    • sessionId로 참가자를 구분한다.

 

🖋️ application.yml

# Service Post config
server:
  port: 9091
  • 서버 포트는 9091로 설정했다.(의미는 없음)

 

⚙️ 스프링 부트 실행

o.e.w.WebSocketTestApplication           : Starting WebSocketTestApplication using Java 17.0.4.1 with PID 39357 (/Users/wonyong/study/test/webSocketTest/build/classes/java/main started by wonyong in ...)
o.e.w.WebSocketTestApplication           : No active profile set, falling back to 1 default profile: "default"
o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port 9091 (http)
o.apache.catalina.core.StandardService   : Starting service [Tomcat]
o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/10.1.18]
o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 446 ms
o.s.b.a.w.s.WelcomePageHandlerMapping    : Adding welcome page: class path resource [static/index.html]
o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port 9091 (http) with context path ''
o.s.m.s.b.SimpleBrokerMessageHandler     : Starting...
o.s.m.s.b.SimpleBrokerMessageHandler     : BrokerAvailabilityEvent[available=true, SimpleBrokerMessageHandler [org.springframework.messaging.simp.broker.DefaultSubscriptionRegistry@5e85c21b]]
o.s.m.s.b.SimpleBrokerMessageHandler     : Started.
o.e.w.WebSocketTestApplication           : Started WebSocketTestApplication in 1.097 seconds (process running for 1.305)
  • 스프링 부트를 실행하여 서버를 킨다.

 

⚙️ WebSocket King Client

  • ws://localhost:9091/ws 위 주소로 Connect 한다.
  • /ws는 위의 config에서 설정한 엔드포인트이다.

 

💡 서버와 연결하기

<주소 입력>
<연결 성공 시 화면>

  • 연결과 함께 자동으로 부여된 세션 id가 출력됨을 확인할 수 있다.
  • 이대로 세 개의 참가자가 있는 채팅방 환경을 만들어보자.

 

  • + 버튼을 누르면 커넥션을 늘릴 수 있다.

 

  • 세 명의 참가자가 참가하고 있는 상황을 만들었다.
  • 첫 번째 참가자는 자신의 세션 id를 포함하여 참가자 모두의 연결을 실시간으로 확인할 수 있다.
  • 두 번째 참가자부터는 채팅방에 참가한 이후, 자신을 포함한 새로운 참가자가 있을 때 실시간으로 확인할 수 있다.

 

⌨️ 채팅해보기

  • 첫 번째 참가자가 메시지를 보내면 첫번째 참가자는 자신이 보낸 메시지만 출력되고(확장 프로그램 자체 기능), 다른 참가자들은 첫번째 참가자의 세션 id 중 일부가 메시지와 함께 전달된다.

 

👂귓속말 보내기

  • 귓속말을 보내기 위해서는 "/귓속말:(sessionid 앞부분) (공백 없이 내용 입력)"과 같은 양식으로 보내야 한다.
  • 첫 번째 사용자가 세 번째 사용자에게 보냈는데 정확하게 전달되었음을 확인할 수 있다.
    • 두 번째 사용자는 메시지를 받지 못했다.

 

📜 결론

  • 첫 번째 사용자가 메시지를 보낼 때 실시간으로 정확하게 메시지가 전달됨을 확인할 수 있었다.
  • 만약 Polling 등 HTTP 통신만을 사용하여 구현했다면 메시지를 받는 쪽에서 서버에 매번 요청을 보내 메시지를 확인해야 했을 것이다.
    • 참가자가 늘어날수록 서버의 부하가 심해졌을 것이고, 완벽한 실시간을 기대하기는 어려웠을 것이다.
  • 실제 채팅 프로그램과 어느 정도 차이가 있지만 양방향성을 가진 전이중 통신에 대해 직접 만들어보면서 충분히 이해할 수 있는 시간이었다.

 

🤔 생각해 볼 거리

  • 단순히 웹소켓을 이해함에 있어서는 부족함이 없었지만 채팅 기능만을 놓고 본다면 유저의 id를 랜덤으로 부여하는 세션 id를 그대로 사용한다든가, 귓속말을 보낼 때 특정 양식이 정해져 있다든가 여러 부분에서 부족한 모습이 보인다.
    • 웹소켓만을 사용하면 메시지 송수신에 대한 데이터 포맷이 정해져 있지 않아 개발자가 직접 구현해야 하며 이에 따른 유연한 관리에 어려움이 있다.
    • 이를 해결하여 줄 메시징 프로토콜로 STOMP가 있다.

 

다음 글에서는 웹소켓과 STOMP에 대해 알아보고 STOMP를 통해 채팅방을 좀 더 그럴싸하게 구현해 보겠다.

 

 

 

참고

뤼튼

https://brunch.co.kr/@springboot/695

728x90