개발/프로젝트

Springboot로 Web Socket 서버 구축하기

선우. 2024. 5. 2. 00:02

개요

 일반적인 채팅 애플리케이션은 상대방이 나에게 메시지를 전송하면, 내 화면에 즉시 메시지가 표시됩니다. HTTP 통신은 클라이언트와 서버 간 요청과 응답이 완료되면 연결을 끊어버리기 때문에, 상대방이 메시지를 전송했는지 안 했는지 알기 위해서는 새로고침을 해야만 합니다.

 

 하지만 나에게 신규 메시지가 전송될 때마다 새로고침 하는 것은 우리가 원하는 방법이 아닐 겁니다. 이 문제를 해결하기 위해 사용할 수 있는 프로토콜은 바로 Web Socket인데요, 왜 그런지 살펴보도록 하겠습니다.

 

Web Socket 특징 2가지

 HTTP 통신과 비교했을 때 WebSocket 통신에서 두드러지는 특징이 2가지가 있습니다. 첫째, 클라이언트와 서버가 TCP 3-way 핸드셰이크로 연결을 체결한 후, 해당 연결을 끊지 않습니다. 둘째, 클라이언트의 요청 없이도 서버에서 클라이언트로 데이터 전송이 가능합니다. 즉, 양방향 통신(클라 -> 서버, 서버-> 클라)이 가능합니다.

 

 웹소켓 통신으로 클라이언트는 서버와 지속적으로 연결을 유지함과 동시에 서버에서 클라이언트로 데이터 전송이 가능하게 됩니다. 만약 상대방이 채팅 서버에 메시지를 전송할 경우, 서버는 지속적으로 연결을 유지하고 있는 나에게 실시간으로 메시지를 전송할 수 있습니다.

 

 이제 웹소켓 프로토콜을 채팅 애플리케이션 개발을 통해 직접 체험해 보도록 하겠습니다.

 

채팅 애플리케이션 개발

프로젝트 개발환경

  • Java 17
  • Springboot 3.2.5
  • dependency implementation 'org.springframework.boot:spring-boot-starter-websocket'

 

WebSocketConfig

 WebSocketConfigurer 인터페이스 구현으로 Spring이 제공하는 웹소켓 환경설정을 할 수 있습니다. WebSocketConfigurer 인터페이스의 registerWebSocketHandlers 메서드를 오버라이드 해야 합니다. 

@Configuration
@EnableWebSocket
public class SocketConfig implements WebSocketConfigurer {
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(myHandler(), "/ws") // 특정 url 경로에 웹소켓 핸들러를 등록한다.
                .addHandler(myHandler2(), "/ws/v2") // 핸들러를 여러개 등록할 수 있다.
                .setAllowedOrigins("*");
    }

    @Bean
    public WebSocketHandler myHandler() { // 메시지를 처리할 핸들러
        return new MyHandler();
    }
    
    @Bean
    public WebSocketHanlder myHandler2() {
    	return new MyHandler2();
    }
}

addHandler 메서드는 특정 url에 매핑되는 메시지 처리 핸들러를 등록합니다. 위 예제에서는 ws://localhost:8080/ws url 경로에 myHandler가 매핑됩니다. 추가적으로  다른 경로에도 핸들러를 등록할 경우 주석과 같이 작성할 수 있습니다.

 

WebSocketHandler

 WebSocketHandler는 TextWebSocketHandler 또는 BinaryWebSocketHandler를 extends 하면 됩니다. 아래 예제 코드는 마샤와곰님의 포스팅을 참고했습니다.

public class MyHandler2 extends TextWebSocketHandler {
    private static final ConcurrentHashMap<String, WebSocketSession> CLIENTS = new ConcurrentHashMap<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        CLIENTS.put(session.getId(), session);
    }

    @Override
    public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String id = session.getId();
        TextMessage msg = new TextMessage("Handler 2: " + message.getPayload());

        CLIENTS.entrySet().forEach( arg -> {
            if(!arg.getKey().equals(id)) {
                try {
                    arg.getValue().sendMessage(msg);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        });
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        CLIENTS.remove(session.getId());
    }
}

CLIENTS 컨커런트 해시맵은 현재 연결이 체결되어 있는 세션들을 관리합니다. afterConnectionEstablished 메서드에서 해시맵에 웹소켓 세션 id와 세션을 넣는 것을 확인할 수 있습니다.

 

handleTextMessage 메서드에는 message를 어떻게 처리할지 작성합니다. 해시맵에서 메시지를 보낸 당사자의 세션 id를 제외한 나머지 세션에 메시지를 send 합니다.

 

마지막으로 웹소켓 연결을 종료할 때 afterConnectionClosed 메서드는 해시맵에서 세션을 제거해 연결 정보를 제거합니다.

 

테스트

 저는 웹소켓 연결 테스트로 Edge 확장 프로그램 Apic을 사용했습니다. 포스트맨에서도 ws 테스트를 진행하실 수 있습니다.

세번째 탭에서 Hi 메시지를 전송

우선, 두번째 탭과 세 번째 탭에서 "ws://localhost:8080/ws/v2" url로 웹소켓 연결을 체결했습니다. CLIENTS 컨커런트 해시맵에 두 탭의 WebSocketSession이 등록되어 연결정보가 저장됩니다.

 

두번째 탭에서 Hi 메시지를 전송 받았음

세 번째 탭에서 text를 서버로 send 하면, MyHandler2에 의해 두 번째 탭으로 text가 전송됩니다. 위 사진과 같이 잘 전달된 모습을 확인할 수 있습니다.

 

단일 WebSocket의 한계

 현재 위 예제는 특정 url과 WebSocketHandler를 매핑하고, 각 핸들러에서 웹소켓 세션(브라우저 연결정보)을 관리합니다. 만약 채팅방이 100개가 된다면 "/chatroom/1"과 같은 url과 매핑될 WebSocketHandler를 100개 만들어 세션을 관리해야 할 것입니다. 또한 클라이언트와 서버 간 어떤 형식으로 데이터를 주고받을지 규칙을 정하지 않아 데이터 처리가 곤란합니다.

 

 이때 메시지 브로커를 도입하면 간단히 해결할 수 있습니다. 스프링은 웹소켓 상에서 STOMP 메시징 프로토콜을 사용하는 in-memory 메시지 브로커를 지원합니다. 다음 포스팅에서는 스프링의 내장 메시지 브로커를 사용해 채널링을 구현해 보도록 하겠습니다.

 

Reference