열렬히.뛰기

딴짓천국 설계하기 (7) - 웹소켓으로 방들을 잇다

이번 세션의 목표는 WebSocket이었다. 이전 작업의 다음 단계로 필요했는데, 송신을 기존 HTTP API로 유지할지 WebSocket으로 받을지, 방이 종료되면 연결을 끊을지는 아무것도 정하지 않은 채로 시작했다. Java로 WebSocket을 직접 코딩해 본 적이 없어서, 큰 그림보다 작은 echo 하나를 제대로 완성하는 것부터 목표로 잡았다.

통과하는 테스트가 정말 기능을 확인하고 있는가

Spring MVC 프로젝트라 의존성은 한 줄 추가로 끝났다.

groovy
implementation 'org.springframework.boot:spring-boot-starter-websocket'

Redis나 메시지 브로커는 이 단계에서 필요 없었다. 실제 채팅 기능으로 바로 들어가지 않고 TextWebSocketHandler로 받은 문자열을 그대로 돌려주는 echo부터 시작한 이유는, 송수신 방식이나 방 종료 정책을 정하기 전에 라이브러리의 기본 동작부터 손에 익히기 위해서였다.

테스트 경계는 /ws/echo에 연결해서 "안녕"을 보내면 같은 문자열이 돌아오는 것으로 정했다. 경계를 정하고 나서 막상 코드를 쓰려니 API 이름 자체가 떠오르지 않는 지점에서 걸렸다. 목표 동작을 정하는 것과 API 호출 문법을 기억하는 것은 다른 일이라서, 클라이언트와 접속 주소만 준비하는 작은 조각부터 문서를 찾아가며 채워 넣었다.

java
HttpClient client = HttpClient.newHttpClient();
URI uri = URI.create("ws://localhost:" + port + "/ws/echo");

이 상태에서 코드 리뷰를 돌려보니 구멍이 많았다. 테스트는 클라이언트와 주소, 빌더만 준비했을 뿐 실제 연결·전송·수신·assert가 없어서, 기능이 전혀 없어도 통과할 수 있는 테스트였다. WebSocketConfiguration은 빈 클래스라 /ws/echo 경로 등록이 없었고 handleTextMessage도 비어 있었다. 컴파일 자체도 실패했는데, 핸들러 파일에서 메서드와 클래스를 닫는 중괄호 하나가 빠져 있었다.

중괄호를 고치고 핸들러와 설정을 채운 뒤에도 테스트는 모자랐다. 다시 리뷰에서 걸린 건 Thread.sleep(2000)으로 무조건 2초를 기다린 뒤 응답을 비교하지 않고 그냥 끝내는 구조였다. 전송 Future가 완료됐다고 해서 echo 응답이 도착했다는 뜻은 아니다. buildAsync의 Future는 연결 준비, sendText의 Future는 전송 완료, 서버가 보낸 응답은 또 다른 사건이다.

코드 완료의 뜻
buildAsync의 Future 연결 객체가 준비됨
sendText의 Future 메시지 전송 작업이 완료됨
테스트의 received Future 수신 콜백이 응답 문자열을 전달함

그래서 수신 콜백이 채우는 CompletableFuture<String> received를 따로 두고, onText에서 조각을 버퍼에 누적하다가 마지막 조각일 때 전체 문자열을 received.complete로 넘기는 구조로 바꿨다. 한 메시지가 여러 콜백으로 나뉠 수 있어서 첫 조각만 보고 비교하면 안 됐다. 세 Future 각각에 get(3, TimeUnit.SECONDS)로 제한 시간을 두고 assertEquals로 비교한 뒤 finally에서 webSocket.abort()로 정리했다.

onError가 비어 있어서 수신 오류가 나도 received에 전달되지 않는 문제, 쓰지 않는 client.newWebSocketBuilder() 호출 같은 것들도 리뷰로 걸러졌다. 그렇게 해서 /ws/echo에 실제로 접속해 "Hello Echo"를 보내고 같은 문자열을 받는 정상 경로 하나를 확인할 수 있었다. 조각 분할 수신이나 강제 오류 같은 경계는 아직 다루지 않았다.

WebSocket API는 왜 다 Future나 콜백으로 되어 있을까

여기서 나온 질문이 있었다. WebSocket은 왜 다 Future로 되어 있을까. 답은, WebSocket 프로토콜 자체가 Future 사용을 요구하지 않는다는 것이다. Java WebSocket 표준에도 동기 전송용 RemoteEndpoint.Basic과 비동기 전송용 RemoteEndpoint.Async가 둘 다 있다. 테스트에 쓴 java.net.http.WebSocket API가 연결과 전송 완료를 CompletableFuture로 돌려주는 선택을 한 것뿐이다. 실제로 서버 쪽 Spring WebSocketSession.sendMessage는 반환값이 void다. 어느 쪽이든 WebSocket은 연결 뒤 양방향으로 메시지가 오가고 어느 시점에 뭐가 도착할지 프로토콜이 보장해주지 않기 때문에, 연결·수신·오류를 콜백으로 다루는 API가 자연스러운 선택이라는 정도로 정리했다.

전송 경로: HTTP로 쓰고 WebSocket으로만 받을까, 양방향으로 할까

echo 다음은 같은 방에 접속한 다른 연결에도 메시지를 전달하는 것이었다. 선택지는 두 가지였다. 기존 POST /rooms/{roomId}/messages로 작성하고 WebSocket으로는 수신만 하는 방식, 아니면 작성도 WebSocket으로 받는 양방향 방식.

기준 HTTP 작성 + WebSocket 수신 WebSocket으로 작성·수신
기존 코드 ChatController와 ChatService 재사용 입력 처리 어댑터를 새로 작성
검증·오류 기존 RequestBody, Valid, HTTP 오류 응답 재사용 JSON 해석·검증·오류 메시지 형식을 직접 설계
저장 성공 확인 HTTP 응답의 201 저장 성공 응답 메시지 규약 필요; send만으로는 저장 성공을 모름
학습 범위 HTTP 트랜잭션과 실시간 전달 연결 위에 더해 요청-결과 식별, 성공/실패 메시지 규약까지

여기서 "HTTP로 작성하면 느려지지 않나"라는 질문이 나왔다. 수신자는 이미 열려 있는 WebSocket으로 서버가 보내는 메시지를 받는 구조라서, DB를 주기적으로 조회하거나 폴링 주기를 기다리는 것과는 다르다. 작성자의 HTTP 응답이 브라우저에 도착했는지와 별개로, 서버는 검증·저장·커밋을 마치는 대로 방에 전달할 수 있다. 실제 지연은 네트워크·서버 부하·DB 처리·전달·렌더링이 함께 결정하는데, 측정해 본 적은 없어서 어느 쪽이 더 빠르다고 단정하지는 않았다.

결국 양방향 WebSocket으로 가기로 했다. 핵심 제어 흐름 — 방별 연결 등록·제거, 수신 메시지에서 기존 서비스 호출, 전달 대상 선택 — 은 직접 작성하고, DTO의 JSON 변환이나 동시 전송 보호 같은 반복 작업은 AI에게 맡기는 식으로 나눴다.

다시 나타난 같은 질문: RoomSessionRegistry

WebSocketSession은 Spring이 연결마다 주는 객체지만, 그 연결들을 방 ID로 묶는 목록은 없다. 그 역할을 하는 자료구조로 Map<Integer, Set<WebSocketSession>>을 썼다. 키는 Room.id, 값은 그 방의 연결 모음이다. 등록·제거·전달이 서로 다른 스레드에서 일어날 수 있어서 ConcurrentHashMap과 ConcurrentHashMap.newKeySet()을 썼다.

broadcast에서는 이미 닫힌 연결과 전송 중 실패(sendMessage가 IOException을 던지는 경우)를 구분해서 둘 다 목록에서 제거하되 한 연결이 실패해도 나머지 연결로는 계속 전달하게 했다. unregister가 없는 방을 대상으로 호출돼도 죽지 않아야 한다는 것도 고려했다.

테스트 경계는 Registry의 private Map을 들여다보지 않고 register/broadcast라는 공개 메서드의 외부 동작만 확인하는 것으로 정했다. Mockito로 WebSocketSession을 모의 객체로 대체하고, 두 연결에 전달된 문자열을 캡처해서 기대 문자열과 비교했다.

방 1에 alice, bob 등록 → broadcast("점심 뭐 먹지?") → 둘 다 수신 확인

다음 테스트는 "제거한 연결에는 전달하지 않기"였다. alice를 unregister한 뒤 broadcast하면 alice는 호출되지 않고 bob만 받아야 한다. 여기서 alice를 닫힌 상태로 만들어서 테스트하면 안 된다는 점을 짚었다. 그러면 broadcast의 "닫힌 연결 처리" 로직만으로도 테스트가 통과해버려서 unregister 자체의 효과를 구분할 수 없기 때문이다. 두 연결 다 열린 상태를 유지한 채로 unregister의 효과만 봐야 했다.

이 테스트도 리뷰에서 같은 문제가 또 나왔다. alice가 호출되지 않았다는 검증만 있고 bob이 실제로 받은 본문이 맞는지 확인하는 코드가 없었던 것이다. unregister가 실수로 방 전체 연결을 지워버리는 구현이라도 이 상태로는 테스트를 통과할 수 있었다. 첫 테스트는 제거 이전의 방송만 다루니 이 문제를 대신 잡아주지 않는다. ArgumentCaptor로 bob의 sendMessage 인자를 캡처해서 본문을 비교하는 코드를 추가하고 나서야 의미 있는 테스트가 됐다.

echo 테스트에서 걸렸던 문제와 같은 모양이다. 테스트가 통과한다는 것과 테스트가 실제로 틀린 구현을 걸러낸다는 것은 다른 이야기였다.

남은 것

Registry는 register/unregister/broadcast의 공개 동작을 테스트로 묶었지만, 실제 네트워크 연결이나 다른 방에는 전달되지 않는지, 세션별 동시 전송 보호, 그리고 실제 Handler와의 연결은 아직 확인하지 않았다. 전송 경로는 양방향 WebSocket으로 정했지만 ChatWebSocketHandler 자체는 손대지 않은 상태다. 다음은 이 Registry를 실제 Handler에 붙이는 일이다.