<aside> 📑

</aside>


들어가며

자소서 첨삭 서비스를 개발하면서 Writer(작성자)와 Reviewer(검토자)가 같은 문서를 실시간으로 함께 보는 기능이 필요했습니다. Writer는 텍스트를 수정하고, Reviewer는 특정 구간을 드래그해 리뷰를 달거나 수정 제안을 남깁니다. 두 사람이 같은 화면을 보고 있으므로, 한쪽의 행동이 반대편에 즉시 반영되어야 합니다.

언뜻 보면 단순해 보이지만, 실제로는 까다로운 문제들이 숨어 있었습니다. 텍스트 변경과 리뷰 생성이 별개의 이벤트로 쪼개져서 순서대로 도착하고, 서버가 보내는 인덱스는 태그가 포함된 텍스트 기준인데 프론트엔드는 태그가 없는 순수 텍스트를 다루고 있으며, React의 비동기 렌더링 특성 때문에 이벤트가 연속으로 도착하면 이전 이벤트의 결과가 아직 state에 반영되기 전에 다음 이벤트가 처리될 수 있었습니다.

이 글에서는 이러한 문제들을 어떻게 정의하고, 어떤 구조로 해결했는지 설명합니다.


1. 전체 구조를 잡기 전에 한 고민

가장 먼저 결정해야 했던 것은 WebSocket 관련 코드를 어디에 둘 것인가였습니다.

처음에는 상태 관리 훅(useReviewState) 안에 WebSocket 구독 로직까지 함께 넣는 방안을 생각했습니다. 하지만 이 방식에는 문제가 있었습니다. useReviewState는 Writer(CoverLetterLiveMode)와 Reviewer(ReviewLayout) 양쪽에서 동일하게 사용되는 훅인데, 두 컴포넌트의 WebSocket 연결 방식이 미묘하게 다릅니다. Reviewer는 SHARE_DEACTIVATED라는 별도 메시지를 받아서 페이지를 이동시켜야 하고, Writer는 텍스트 변경 시 서버로 메시지를 발송해야 합니다. 이런 차이를 하나의 훅에 모두 담으면 관심사가 뒤섞이게 됩니다.

그래서 결론은 useReviewState는 WebSocket을 전혀 모르게 한다는 것이었습니다. 훅은 "어떤 데이터가 들어오면 상태를 어떻게 바꿀 것인가"만 책임지고, WebSocket 연결·구독·메시지 수신은 각 컴포넌트가 직접 처리하도록 나눴습니다.

결과적으로 전체 구조는 아래처럼 역할이 명확하게 분리됐습니다.

CoverLetterLiveMode (Writer)
  ├── useStompClient()       ← STOMP 연결 수립, 재연결, sendMessage 제공
  ├── useReviewState()       ← 문서·리뷰 상태 관리, dispatchers 노출
  ├── useSocketSubscribe()   ← 토픽 구독/해제, 메시지를 onMessage 콜백으로 전달
  └── useSocketMessage()     ← 이벤트 타입 분기 → dispatchers 호출
ReviewLayout (Reviewer)
  ├── useStompClient()
  ├── useReviewState()
  ├── useSocketSubscribe()
  └── useSocketMessage()

useSocketMessage는 switch 분기만 담당하는 얇은 레이어로, 타입에 따라 알맞은 dispatcher를 호출하는 것 외엔 아무것도 하지 않습니다. 덕분에 useReviewState는 WebSocket 없이도 테스트할 수 있고, 두 컴포넌트가 동일한 훅을 코드 중복 없이 재사용할 수 있습니다.


2. STOMP 연결: 놓치기 쉬운 세부사항들

WebSocket 연결은 @stomp/stompjssockjs-client를 조합해서 구현했습니다. 라이브러리 자체는 간단하지만, 실제 서비스에 쓰려면 신경 써야 할 부분이 몇 가지 있었습니다.

소켓 팩토리를 콜백으로 넘겨야 합니다. 처음엔 new SockJS(...) 인스턴스를 직접 넘겼는데, 그러면 재연결 시 이미 닫힌 소켓을 재사용하려다 실패합니다. 이미 만들어진 소켓 객체가 아니라 새 소켓을 생성하는 팩토리 함수를 webSocketFactory에 넘겨야 자동 재연결이 올바르게 동작합니다.