middlefitting

Java Spring Boot에서 Socket.IO 사용하기 본문

Spring

Java Spring Boot에서 Socket.IO 사용하기

middlefitting 2024. 5. 23. 13:43

최근 진행한 프로젝트에서 Socket.IO를 적용해야 할 일이 있었습니다. 처음에는 JavaScript로 탄생하였지만 Java, Python, Rust 등 다른 언어에서도 이를 지원하는 라이브러리들이 감사하게도 만들어습니다. 덕분에 Java 기반의 Spring 프로젝트에서도 사용할 수 있습니다. 관련된 한글 자료가 많지는 않은 것 같아서 SocketIOServer를 띄우는 것에 초점을 맞추어서 글을 작성해 보려고 합니다. 다른 언어로 적용이 필요하시다면 아래의 공식 문서를 참고하시길 바랍니다.

https://socket.io/docs/v4/

 

Introduction | Socket.IO

If you are new to Socket.IO, we recommend checking out our tutorial.

socket.io

 

 

Socket.IO 프로토콜

 

Socket.IO는 실시간 양방향 통신을 제공하기 위한 프로토콜인데요, WebSocket 혹은 HTTP long-polling 를 기반으로 작동합니다. 실시간 통신을 지원하는 WebSocket, 혹은 클라이언트가 HTTP 요청으로 GET 요청을 계속 보내고 서버는 바로 응답을 보내는 것이 아니라 데이터를 보낼 일이 있을때비동기적으로 응답하는 것입니다. 응답 이후에는 클라이언트가 다시 GET 요청을 보내는 것으로 반복됩니다.

 

Socket.IO는 Namespace를 통해 멀티플렉싱 기능을 제공할 수 있으며, Room이라는 기능을 통해 특정 집합의 클라이언트들에게만 요청을 브로드캐스팅하는 등, 편리한 기능을 많이 제공합니다. 이벤트는 전송하는 emit, 수신하는 Listen 이벤트가 있습니다. 이는 클라이언트 서버 모두 해당됩니다.

 

 

Netty Socket.io

 

Spring은 해당 기술을 공식적으로 지원하지는 않아서 별도의 라이브러리를 사용해야 합니다. Socket.IO 공식문서를 보면 Java 기반으로 netty-socketio, socket.io-server-java 두개가 있는 것을 확인하실 수 있습니다. socket.io-server-java는 최종 버전이 21년도이고, netty-socketio는 지속해서 버전업이 진행되고 있기 때문에 저는 해당 기술을 사용했습니다.

 

 

의존성 추가

 

지금부터 Socket.IO 기능을 지원하도록 Spring Boot 서버를 구성하겠습니다.

기본 서버는 내장형 Tomcat 이고 Gradle, Java 17, SpringBoot 3.24 버전을 사용했습니다. 

 

먼저 관련 의존성을 추가합니다.

// netty-socketio 추가
implementation 'com.corundumstudio.socketio:netty-socketio:2.0.9'

 

 

SocketIOServer 생성  

 

다음으로는 Socket.IO 서버를 만들어야 합니다. netty-socketio는 이름값처럼 Netty 서버를 사용해서 만들어진 기술이기 때문에 서버를 별도로 띄워야 하기 때문입니다. Netty 기반의 Webflux를 사용하면 해당 러닝커브를 감당해야 하고, 이게 또 스프링 공식 라이브러리가 아니어서 netty-socketio의 SocketIOServer를 별도로 띄우는게 아니라 프로젝트에 통합하려면 정말 깊은 이해가 필요할 것입니다. 이러한 점에서 스프링 부트에서 별도의 SocketIOServer를 빈으로 띄워보도록 하겠습니다. 구조를 그려보면 아래와 같습니다.

 

포트는 구성한 것에 따라 다르겠지만, 이렇게 스프링 부트에서 내장형 Tomcat을 제공하는 것과 별개로 새로운 서버를 띄운다고 생각하시면 됩니다. 포트 자체가 달라서 요청은 구분되어야 하지만 같은 스프링 애플리케이션 컨텍스트를 사용하게 됩니다.

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.corundumstudio.socketio.SocketIOServer;

/**
 * SocketIoConfig.
 */
@Configuration
public class SocketIoConfig {

   @Value("${socketio.server.hostname}")
   private String hostname;

   @Value("${socketio.server.port}")
   private int port;

   /**
    * Tomcat 서버와 별도로 돌아가는 netty 서버를 생성
    */
   @Bean
   public SocketIOServer socketIoServer() {
      com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration();
      config.setHostname(hostname);
      config.setPort(port);
      return new SocketIOServer(config);
   }
}

이렇게 Config 파일을 만들어서 netty-socketio 라이브러리에서 제공하는 SocketIOServer를 빈으로 등록합니다. 호스트명과 포트는 필수적으로 적용되어야 합니다. 저는 yml에서 해당 값을 가져오는 구조로 작성했는데 편한 방식으로 작성하시면 됩니다.

 

 

생명주기 관리

 

SocketIOServer 빈을 만들었으니 이제는 생명주기를 관리해야 합니다. 컨텍스트 생명주기로 이를 관리하면 되는데요, 다음과 같이 컴포넌트를 하나 추가합니다.

package com.rmf.apiserverjava.config;

import org.springframework.stereotype.Component;

import com.corundumstudio.socketio.SocketIOServer;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;

/**
 * SocketIoServerLifeCycle.
 *
 * <p>
 *   SocketIoServer의 생명주기를 관리
 * </p>
 */
@Component
public class SocketIoServerLifeCycle {
   private final SocketIOServer server;

   public SocketIoServerLifeCycle(SocketIOServer server) {
      this.server = server;
   }

   /**
    * SocketIo 서버 시작
    */
   @PostConstruct
   public void start() {
      server.start();
   }

   /**
    * SocketIo 서버 종료
    */
   @PreDestroy
   public void stop() {
      server.stop();
   }
}

이 친구는 방금 만든 SocketIOServer의 빈을 의존성 주입받아서 생성자 호출 이후 SocketIOServer를 시작하고, 컨텍스트가 내려갈때 서버를 종료시킵니다. 서버를 종료하지 않으면 프로세스가 게속 남아있게 되니 꼭 처리를 해줘야 합니다.

 

 

이벤트 핸들러 등록

 

다음으로는 emit, listen 이벤트를 처리할 컨트롤러(이벤트 핸들러)를 등록해보겠습니다. 사실 connect, disconnect의 경우에는 기본적으로 제공되는 것이기 때문에 따로 이벤트 리스너를 등록하지 않아도 동작합니다. 다만 서버 내부 구현으로 connect, disconnect에 따른 별도의 동작을 제공하고 싶은 경우 이렇게 리스너를 추가해주면 됩니다.

package com.rmf.apiserverjava.controller.impl;

import java.util.List;
import java.util.Map;

import org.springframework.stereotype.Component;

import com.corundumstudio.socketio.SocketIOServer;
import com.corundumstudio.socketio.listener.ConnectListener;
import com.corundumstudio.socketio.listener.DisconnectListener;

import lombok.extern.slf4j.Slf4j;

/**
 * SocketIOController.
 */
@Component
@Slf4j
public class SocketIOController {
   private final SocketIOServer server;

   /**
    * 소켓 이벤트 리스너 등록
    */
   public SocketIOController(SocketIOServer server) {
      this.server = server;

      // 소켓 이벤트 리스너 등록
      server.addConnectListener(listenConnected());
      server.addDisconnectListener(listenDisconnected());
   }

   /**
    * 클라이언트 연결 리스너
    */
   public ConnectListener listenConnected() {
      return (client) -> {
         Map<String, List<String>> params = client.getHandshakeData().getUrlParams();
         log.info("connect:" + params.toString());
      };
   }

   /**
    * 클라이언트 연결 해제 리스너
    */
   public DisconnectListener listenDisconnected() {
      return client -> {
         String sessionId = client.getSessionId().toString();
         log.info("disconnect: " + sessionId);
         client.disconnect();
      };
   }
}

현재는 연결 및 해제가 이루어졌을때 로깅만 진행하는데요, 추가적인 기능은 커스텀하셔서 작성하시면 됩니다. 

 

 

연결 테스트

 

Postman에서는 HTTP 뿐만 아니라 Socket.IO 연결을 지원합니다.

 

연결을 수행해보면 다음과 같이 연결이 이루어지는 것을 확인할 수 있습니다.

 

서버 로그에서는 다음과 같이 WebSocket을 사용해서 Socket.IO 연결이 이루어지고 연결 해제에 따른 로깅도 잘 적용되는걸 확인할 수 있습니다.

 

 

 

이걸로 SpringBoot에 SocketIOServer를 띄우는 과정은 마무리 되었습니다. 현재로서는 기능이 존재하는 건 아니라서 namespace, room을 통해 요청을 분리하고 emit, listen 이벤트를 적절히 등록해서 서비스를 만들어나가면 되는데요, 관련된 내용은 다른 글에서 다루도록 하겠습니다.

'Spring' 카테고리의 다른 글

TestContainer, 도커 기반의 테스트  (2) 2024.01.05