프로젝트

Project algo - websocket 기술검토

GoF9490 2023. 6. 25. 14:13

우선 Project algo는 한국에서 '다빈치코드' 라고 불리는 보드게임 algo를

웹상에서 플레이할 수 있도록 구현하려는 프로젝트이다.

 

게임 서버쪽은 Spring(이하 스프링) 프레임워크로 작성하고,

게임 클라이언트쪽은 게임개발자 지망 시절에 다루어봤던 Unity3D(이하 유니티) 엔진으로 만들 예정이다.

(유니티는 다양한 플랫폼에 대해 자동으로 포팅하는 기능으로 유명하며, 그 중 웹도 포함된다.)

 

이 과정에서 Unity3D 클라이언트와 스프링서버가 통신이 원할하게 이루어져야 하는데,

이 부분에 대하 http 프로토콜은 'UnityWebRequest' 라는 유니티의 고유 클래스로 쉽게 통신할 수 있다.

문제는 구상 과정에서 웹소켓 기술의 사용이 필요해보였고, 이 부분에 대해 파악이 덜되었기에 기술검토를 해보려 한다.

 

1. 유니티

 

스프링은 웹소켓 기술에 대해 기본적으로 SockJS 기술을 지원하고 있다.

SockJS를 클라이언트로 사용하고, 스프링 프레임워크에서 SockJS 기술을 사용해 기능을 만들면,

웹소켓을 지원하지 않는 브라우저에 대해서도 유사한 환경을 제공하게끔 작동하게 된다고 한다.

 

SockJs는 기본적으로 ws 프로토콜이 아닌 http, https 프로토콜을 통해 핸드셰이크 후 ws 주소를 취득해 오는 것으로 파악하고 있다.

그렇기에 그에 알맞는 클라이언트 라이브러리가 따로 필요로하는데, 문제는 Unity3D는 SockJS 클라이언트를 구현하는 외부 라이브러리를 찾을 수 없었다.

(딱 하나 찾았는데 15년도에서 업데이트가 멈춰있고, 더이상 Unity에서 지원하지 않는 부분이 있는지 컴파일에러가 뜨기에 사용을 포기했다.)

 

그렇기에 스프링에서 SockJS가 아닌 저수준의 웹소켓 기능을 사용하여야 유니티와의 원할한 웹소켓 기능을 사용할 수 있을것 같다.

해당부분에 대해 유니티에서는 websocekt-sharp 라는 외부 라이브러리를 사용할 예정이다.

해당 라이브러리의 기능에 대해서는 자세하게 설명하지는 않고,

필요하다면 아래 링크를 통해서 자세한 설명을 확인할 수 있다.

 

https://github.com/sta/websocket-sharp

 

GitHub - sta/websocket-sharp: A C# implementation of the WebSocket protocol client and server

A C# implementation of the WebSocket protocol client and server - GitHub - sta/websocket-sharp: A C# implementation of the WebSocket protocol client and server

github.com

 

2. 스프링

 

스프링에서는 스프링에서 제공되는 기능들을 사용해

ws 프로토콜을 호출하기위한 엔드포인트 지정 및 기능구현이 목표이다.

 

그것을 위해 스프링 프레임워크의 웹소켓과 관련된 여러 클래스들을 나름대로 분석해보았다.

 

WebSocketConfigurer


인터페이스이며, @EnableWebSocket 어노테이션이 붙은 클레스에 구현한다.

registerWebSocketHandlers 메서드를 오버라이드 해서 WebSocketHandler를 원하는 엔드포인트에 설정.

살펴보면 크게 기능이 없기에 위에서 설정하는 핸들러가 중요한 역할을 하는것으로 보인다.


WebSocketHandler


WebSocketConfigurer에 필요한 인터페이스이다.

총 5개의 메서드로 이루어져있다.

공식문서에 따르면 

void afterConnectionClosed(WebsocketSession session, CloseStatus closeStatus)
한쪽에서 WebSocket 연결을 닫은 후 또는 전송오 류가 발생한 후에 호출됩니다.

void afterConnectionEstablished(WebSocketSession session)
WebSocket 협상이 성공하고 WebSocket 연결이 열리고 사용할 준비가 된 후에 호출합니다.

void handleMessage*WebSocketSession session, WebSocketMessage<?> message)
새 WebSocket 메시지가 도착하면 호출됩니다.

void handleTransportError(WebSocketSession session, Throwable exception)
기본 WebSocketHandler가 부분메시지를 처리하는지 여부입니다.

boolean supportsPartialMessages()
WebSocketHandler가 부분메시지를 처리하는지 여부입니다.

구현 클래스는

 

  • AbstractWebSocketHandler
  • BinaryWebSocketHandler
  • ExceptionWebSocketHandlerDecorator
  • LoggingWebSocketHandlerDecorator
  • PerConnectionWebSocketHandle
  • SockJsWebSocketHandler
  • SubProtocolWebSocketHandler
  • TextWebSocketHandle
  • WebSocketHandlerDecorator


로 공식문서에 작성되어있으며,
이중에 TextWebSokcetHandler, BinaryWebSocketHandler 두개는 AbstractWebSocketHandler의 구현 클래스다.

Decorator를 제외하고 handler만 봤을경우

 

  • AbstractWebSocketHandler,
  • PerConnectionWebSocketHandler,
  • SockJsWebSocketHandler,
  • SubProtocolWebSocketHandler


네가지 방법 중 하나를 고르면 될듯하다.

우선 SockJs는 아쉽게도 Unity에 SockJs 클라이언트를 구현한 외부 라이브러리를 찾지못했다.
만약 SockJs 클라이언트 외부 라이브러리가 있었으면 두비두밥처럼 MessageBroker로 해결했지, 이렇게 기술검토를 하고있지 않을것이다.
탈락.

PerConnectionWebSocketHandler 같은경우 파악이 아직 덜되었다.
공식문서를 봐도 잘 모르겠고, bing AI에 검색해봐도 

PerConnectionWebSocketHandler는 WebSocketHandler 및 BeanFactoryAware 인터페이스를 구현하는 Spring Framework의 클래스입니다. 
각 WebSocket 연결에 대한 WebSocketHandler 인스턴스를 초기화 및 파괴하고 다른 모든 메서드를 여기에 위임합니다.

라는 무시무시한 소리를 뱉는다.
구글에 검색해봐도 몇개 안나오는것을 보면 그렇게 많이쓰이는 녀석은 아닌것같다.
탈락.

SubProtocolWebSocketHandler 이녀석은 이름으로 유추하자면 STOMP 같은 서브 프로토콜을 핸들링하는데 쓰이는 녀석같다.
허나, 구현 클래스를 봐도 공식문서를 봐도 사용하기 너무 까다로워 보인다.
STOMP 프로토콜은 한번 써봐서 긍정적으로 생각하는데, 설정단계에서부터 막힐것같다.
추후에 기회가 된다면 써보는걸로 해야겠다.
긍정적 검토.

AbstractWebSocketHandler 추상 클래스와 하위 두개의 구현 클래스들은 메서드들도 간단하고,
공식문서에서도 기본을 강조하며, 구글링으로도 정보가 많이 나오는것으로 봐서는 가볍게 쓰기에 알맞은듯 하다.
일단 채택.


AbstractWebSocketHandler


추상클래스이며 클래스를 살펴보면 handleMessage를 구현하고 있고,
supportsPartialMessages 메서드에 false를 리턴하고 있으며,
handleTextMessage, handleBinaryMessage, handlePongMessage 메서드가 빈 메서드로 구현되어있다.

	@Override
	public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
		if (message instanceof TextMessage) {
			handleTextMessage(session, (TextMessage) message);
		}
		else if (message instanceof BinaryMessage) {
			handleBinaryMessage(session, (BinaryMessage) message);
		}
		else if (message instanceof PongMessage) {
			handlePongMessage(session, (PongMessage) message);
		}
		else {
			throw new IllegalStateException("Unexpected WebSocket message type: " + message);
		}
	}



handleMessage는 단순히 타입에 따라 알맞은 메서드가 호출되도록 핸들링하는 녀석이다.


TextWebSocketHandler


handleBinaryMessage 메서드만 구현되어있다.
반대로 BinaryWebSocketHandler는 handleTextMessage가 구현되어있다.
뭐지? 왜 클래스랑 메서드랑 네이밍이 반대지? 라고 맨 처음 의문이 들었다가 구현코드가,

	@Override
	protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) {
		try {
			session.close(CloseStatus.NOT_ACCEPTABLE.withReason("Binary messages not supported"));
		}
		catch (IOException ex) {
			// ignore
		}
	}



바로 쳐내는 별거없는 코드.
생각해보니 TextWebSocketHandler 의 handleTextMessage 메서드는 개발자가 알맞게 따로 구현해야하는 메서드다.
보아하니 핸들러는 역할에 맞게 핸들링을 할 뿐, 작업은 파라미터로 받는 WebSocketSession 이라는 클래스가 하는 듯 하다.
이러면 WebSocketSession 이라는 클래스도 봐야 할것같다.

우선 클래스를 상세히 보기전에 구현했었던 Config 클래스를 확인했다.
WebSocketSession 클래스는 파라미터로 받는 역할만 하고있고, 컨픽도, 핸들러도 따로 구현하고있지는 않다.
아마도 컨픽 클래스의 @EnableWebSocket 어노테이션에 따라서 
스프링이 적절한 구현체를 만들어서 클라이언트의 요청에 알맞게 자동으로 잡아주는 듯 하다.

브레이크 포인트를 찍고 디버그모드를 돌려서 어떤 구현체가 있는지 확인해보기로 했다.

 

 

StandardWebSocketSession 이라는 녀석이다.

일단은 WebSocketSession 인터페이스로 메서드를 호출하는 상황이기에 
공식문서로 WebSocketSession 메서드의 용도만 파악하면 사용이 가능할 것 같다.


WebSocketSession


공식문서에서 17개의 메서드를 소개해주고 있다.

void close() / void close(CloseStatus status)
상태 1000으로 / CloseStatus로 웹소켓 연결을 닫습니다.

String getAcceptedProtocol()
협상된 하위 프로토콜을 반환합니다.

Map<String, Object> getAttributes()
웹소켓 세션과 관련된 속성이 있는 맵을 반환합니다.

int getBinaryMessageSizeLimit()
들어오는 바이너리 메시지에 대해 구성된 최대 크기를 가져옵니다.

List<WebSocketExtension> getExtensions()
협상된 확장을 결정합니다.
(WebSocketExtension 클래스는 String name 과 Map<String, String> parameters 필드값을 가진 객체.)


HttpHeaders getHandshakeHeaders()
핸드셰이크 요청에 사용된 헤더를 반환합니다.

String getId()
고유한 세션 식별자를 반환합니다.

InetSocketAddress getLocalAddress()
요청을 받은 주소를 반환합니다.

Principal getPrincipal()
Principal 인증된 사용자의 이름을 포함하는 인스턴스를 반환합니다.
(당장에는 스프링 시큐리티가 생각난다. 맞는지는 모르겠지만.)

InetSocketAddress getRemoteAddress()
원격 클라이언트의 주소를 반환합니다.

int getTextMessageSizeLimit()
수신 문자메세지에 대해 구성된 최대 크기를 가져옵니다.

URI getUri()
웹소켓 연결을 여는데 사용되는 URL을 반환합니다. (컨픽에서 설정하는 엔드포인트 말하는듯.)

boolean isOpen()
기본 연결이 열려있는지 여부입니다.

void sendMessage(WebSocketMessage<?> message)
웹소켓 메세지 보내기 (TextMessage 또는 BinaryMessage)

void setBinaryMessageSizeLimit(int messageSizeLimit)
수신 바이너리 메시지의 최대 크기를 구성합니다.

void setTextMessageSizeLimit(int messageSizeLimit)
수신 문자의 최대 크기를 구성합니다.

메서드는 많지만 대부분이 조회성 메서드이고, 
직접 호출해서 사용할만한 메서드는 극히 일부로 보인다.
그 중 가장 핵심처럼 보이는것이 sendMessage 메서드.

대충 파악이 끝나고나니, WebSocketSession은 클라이언트쪽의 정보를 저장해놓고 사용하는 용도의 클래스인듯하다.

 

결론

 

클라이언트에서 연결된 엔드포인트로 전송하면 
WebSocketSession에 클라이언트의 정보를 
(TextWebSocketHandler 기준)TextMessage에 내용을 넣어서
WebSocketHandler의 알맞는 메서드를 호출,
이후 개발자에 의해 작성된 메서드에 따라 WebSocketSession에 응답을 주는 흐름인듯 하다.

별 문제가 없다면 TextWebSocketHandler를 사용할 생각이기에,
handleTextMessage 메서드를 필두로 웹소켓으로 주고받는 데이터에 어떤식으로 비즈니스 로직을 구분하는 규칙을 줄것인지 고민해봐야 할듯하다.

당장 생각나는건 핸들러에서 비즈니스 로직에대한 구분을 나누고 Json형식의 데이터를 주고받으면 될것같은데,

일단 해보면서 파악해봐야 할것같다.

 

STOMP 프로토콜을 사용할때는 엔드포인트에 따라 기능이 호출되었는데, 이부분도 좀 생각해보면 좋을듯.