글을 쓰게된 배경
Spring Websocket을 공부하기 위해 간단한 채팅서버를 만들기로 결정했다. 처음에 생각했던 것과 다르게 직접 찾아보니, 너무나 쉽게 채팅서버를 만들 수 있었다. (물론 간단한 채팅서버다.)
채팅서버를 만드는 과정은 Spring 공식 문서와 블로그 글들을 참고했다. 만드는 과정은 간단했지만, 채팅서버가 내가 원하는대로 동작하지 않았다. 문제를 해결하기 위해 공식 문서와 코드를 참고했고, 원인을 찾아 문제를 해결했다. 이 과정에서 배운점들을 기록하고 공유하고 싶어 글로 남기게 됐다.
만난 문제
Spring 공식문서를 참고하면 정말 몇 줄 안되는 코드로 간단한 채팅서버를 만들 수 있다.
추가로 블로그 글도 많은 도움이 되었다.
위 링크의 내용을 간략하게 요약하면 다음과 같다.
1. 스프링 프로젝트 세팅
2. 핸들러 구현
3. 핸들러 등록
1번 과정은 spring initializer를 이용하면 쉽게 세팅할 수 있다.
build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "3.1.0"
id("io.spring.dependency-management") version "1.1.0"
kotlin("jvm") version "1.8.21"
kotlin("plugin.spring") version "1.8.21"
}
group = "elvis.chat"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_17
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-websocket")
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("org.jetbrains.kotlin:kotlin-reflect")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "17"
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
2번 과정은 TextWebSocketHandler를 상속받아 나만의 Handler를 구현하면 된다.
private val log = KotlinLogging.logger { }
@Component
class WebSocketChatHandler : TextWebSocketHandler() {
override fun handleBinaryMessage(session: WebSocketSession, message: BinaryMessage) {
val payload = message.payload
log.info { "payload : $payload" }
val textMessage = TextMessage("Welcome My First Websocket chatting server. :)")
session.sendMessage(textMessage)
}
}
(미리 말하자면, 이곳에서 치명적인 실수를 했다..)
3번 과정은 내가 만든 핸들러를 등록하고, @EnableWebSocket 애노테이션을 추가하면 된다.
@EnableWebSocket
Add this annotation to an @Configuration class to configure processing WebSocket requests
@Configuration
@EnableWebSocket
class WebSocketConfig: WebSocketConfigurer {
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
registry.addHandler(myHandler(), "/ws/chat").setAllowedOrigins("*")
}
@Bean
fun myHandler(): WebSocketHandler = WebSocketChatHandler()
}
생각보다 별거 없네라는 생각과 함께 코드 작성을 맞추고 테스트 해봤다.
(테스트 도구는 구글 확장 도구인 'Simple Websocket Client'를 사용하면 편리하다.)
하지만 내가 원하는데로 동작하지 않았다...
예상한 결과 : Client가 'hello~' 메시지를 보내면 서버에 메시지 내용을 콘솔에 남기고, Client에게 환영 인사를 보낸다.
실제 결과 : 메시지 내용이 콘솔에 남지 않았고, Client에게 환영 인사도 가지 않았다.
내가 작성한 코드가 별로 없었기에 무엇이 문제인지 추측하기 어려웠다. 또한 로그도 남지 않아 어디부터 확인해야할지 감이 오지 않았다.
문제 해결 과정
내가 가장 먼저 해결해야 할 부분은 문제의 범위를 좁히는 것이었다. 이를 위해 로깅 레밸을 Debug로 낮추었다.
로그를 통해 Tomcat Websocket에서는 Message를 제대로 읽었지만, 핸들러 부분이 동작하지 않는다는걸 알 수 있었다. 이를 기반으로 핸들러 등록에 문제가 있을 수 있음을 가설로 세웠고 디버거를 통해 확인했다.
디버깅을 통해 핸들러 등록에는 문제가 없음을 확인했다.
다음 원인을 생각해야 했는데, 내부 동작 원리를 정확히 모르기 때문에 정확한 문제 원인이 떠오르지 않았다. 단지, Tomcat Websocket이 Handler를 호출하는 부분에 문제가 있을 수 있을 수 있겠다는 생각만 들었다. 그래서 디버거를 통해 실행 플로우를 분석해보기로 했다.
디버깅의 시작은 로깅에 찍힌 WsFrameServer부터 시작했다.
플로우는 그림과 같았다.
스프링의 코드가 너무 멋져... 잠시 다른길로 세자면 이곳에서 두 가지 디자인 패턴을 찾을 수 있었다.
Tomcat이 WebSocketHandler를 호출할때 Adapter 패턴이 사용됐고, WebSocketHandler는 Decorator 패턴이 적용되어 있었다. 실제로도 공식문에 해당 내용이 나와있다.
26.2.3 WebSocketHandler Decoration
Spring provides a WebSocketHandlerDecorator base class that can be used to decorate a WebSocketHandler with additional behavior. Logging and exception handling implementations are provided and added by default when using the WebSocket Java-config or XML namespace. The ExceptionWebSocketHandlerDecorator catches all uncaught exceptions arising from any WebSocketHandler method and closes the WebSocket session with status 1011 that indicates a server error.
다시 본론으로 돌아오면, 플로우를 따라가면서 문제가 발생한 원인을 찾을 수 있었다. 문제는 LoggingWebSocketHandlerDecorator에서 WebSocketHandler를 호출 후에 실행되는 코드에 있었다.
LoggingWebSocketHandlerDecorator에서 WebSocketHandler를 호출하면 상위 클래스의 AbstractWebSocketHandler의 handleMessage가 실행된다.
@Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
if (message instanceof TextMessage textMessage) {
handleTextMessage(session, textMessage);
}
else if (message instanceof BinaryMessage binaryMessage) {
handleBinaryMessage(session, binaryMessage);
}
else if (message instanceof PongMessage pongMessage) {
handlePongMessage(session, pongMessage);
}
else {
throw new IllegalStateException("Unexpected WebSocket message type: " + message);
}
}
내가 보낸 메시지는 TextMessage기 때문에 handleTextMessage 함수가 호출된다. 그런데 나는 handleTextMessage가 아니라, handleBinaryMessage를 오버라이딩 했다...
@Component
class WebSocketChatHandler : TextWebSocketHandler() {
override fun handleBinaryMessage(session: WebSocketSession, message: BinaryMessage) {
val payload = message.payload
log.info { "payload : $payload" }
val textMessage = TextMessage("Welcome My First Websocket chatting server. :)")
session.sendMessage(textMessage)
}
}
문제를 확인하고, handleBinaryMessage가 아니라, handleTextMessage를 오버라이딩 하도록 수정하니 원하는대로 동작했다.. ㅎㅎ
@Component
class WebSocketChatHandler : TextWebSocketHandler() {
override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
val payload = message.payload
log.info { "payload : $payload" }
val textMessage = TextMessage("Welcome My First Websocket chatting server. :)")
session.sendMessage(textMessage)
}
}
정리
문제해결 과정을 통해 어떻게 간단한 코드로만 채팅서버를 구현할 수 있는지 알게 되었다. Tomcat Websocket과 Spring의 도움으로 나는 몇줄의 코드만 작성하면 되는 것이였다.
웃픈 경험이었지만, 새로운 지식을 배울 수 있어서 좋았다. 또한 스프링의 우아한 코드를 볼 수 있어서 재밌었다.
'Spring' 카테고리의 다른 글
Spring Data Redis Pub/Sub 파헤치기 (0) | 2023.06.28 |
---|---|
Spring's STOMP support 파헤치기 (0) | 2023.06.02 |
Spring Cloud GateWay의 non-blocking server (0) | 2022.12.31 |
@SpringBootTest의 비밀! (0) | 2022.12.11 |
@RequestBody (0) | 2022.06.10 |