steven

웹소켓 클라이언트를 다시 구현해야 한다면 (3)

웹소켓 클라이언트를 다시 구현해야 한다면에서는 Ping/Pong과 Closing Handshake를 살펴봤고, 두 번째 글에서는 연결과 메시지 검증을 나눈 클라이언트를 구현했다. 당시 글의 마지막에는 이런 문장이 있다.

재연결 시도 중에 서버의 상태가 변했을 수 있기 때문에, 재연결 후 상태 동기화 로직을 별도로 구현해야 할 수도 있다.

실시간 메시징 기능을 만들면서 그 ‘별도’가 무엇인지 제대로 겪었다. 웹소켓을 다시 여는 코드는 전체 문제에서 작은 부분이었다. 연결이 끊긴 동안 저장된 메시지를 되찾아야 했고, 서버에 도착했을지도 모르는 명령을 다시 보낼지도 결정해야 했다.

처음에는 기존 웹소켓 클라이언트를 잘 다듬으면 해결될 거라고 생각했다. 작업을 마치고 보니 필요한 것은 더 영리한 웹소켓 클래스가 아니었다. 연결과 프로토콜, 화면 상태가 서로의 일을 대신하지 않도록 경계를 긋는 일이었다.

처음 나눈 경계

실시간 채팅 코드는 다음처럼 나눴다.

제품 UI
  └─ 대화방 상태
       └─ 채팅 프로토콜
            ├─ 범용 웹소켓 전송 계층
            └─ 메시지 계약

범용 웹소켓 계층은 연결, 종료, 재연결, 송수신 상태만 안다. 채널의 참여자나 메시지 내역은 모른다. 메시지 계약은 송수신 데이터의 형태를 정의하고, 채팅 프로토콜이 전송 계층과 계약을 함께 사용한다.

레이어마다 답할 수 있는 질문은 달랐다.

이 질문들을 하나의 connected boolean으로 답하려고 하면 곧 모순이 생겼다.

재연결과 복구는 다른 일이었다

네트워크가 바뀌면 페이지는 그대로인데 웹소켓만 끊길 수 있다. 처음 연결이 실패한 것과 한동안 잘 사용하던 연결이 끊긴 것도 성격이 달랐다.

처음 연결은 일정 횟수 안에 성공하거나 실패를 보여주는 편이 낫다. 반면 이미 사용하던 연결은 화면이 살아 있는 동안 복구를 계속 시도할 수 있어야 한다. 그래서 최초 연결과 재연결의 정책을 나눴다. 재연결에는 지수 백오프와 jitter를 적용했다. RFC 6455도 비정상 종료 뒤 첫 재연결을 무작위로 지연하고, 이후에는 대기 시간을 늘리는 backoff를 권고한다. 인증 실패나 종료된 세션처럼 다시 시도해도 결과가 달라지지 않는 경우는 재연결 대상에서 제외했다.

여기서 브라우저의 offline 이벤트와 웹소켓 종료는 구분할 필요가 있었다. HTML 스펙navigator.onLinetrue에서 false로 바뀌면 offline 이벤트를 발생시키도록 정한다. 하지만 이 절차에 웹소켓을 닫으라는 내용은 없다. offline은 사용자 에이전트가 네트워크 연결이 없다고 판단했다는 뜻이지, 특정 웹소켓의 종료가 완료됐다는 뜻은 아니다.

반대 방향도 확실하지 않다. 같은 스펙은 navigator.onLine이 본질적으로 신뢰하기 어려운 값이며, 네트워크에 연결되어 있어도 인터넷에는 접근하지 못할 수 있다고 설명한다. 따라서 online 이벤트가 왔다고 웹소켓이 복구됐다고 볼 수도 없다.

WebSockets 스펙에서 open 이벤트는 웹소켓 연결이 실제로 수립됐을 때 발생하고, close 이벤트는 연결이 닫힌 뒤 발생한다. RFC 6455도 기반 전송 연결이 예기치 않게 유실되면 클라이언트가 웹소켓 연결을 실패 처리해야 한다고 규정한다. 즉 네트워크 상태 이벤트와 웹소켓 상태 이벤트는 서로 영향을 줄 수 있지만 같은 사건은 아니다.

구현에서는 offline을 현재 연결을 의심하고 재연결 타이머를 멈추는 보조 신호로만 사용했다. 이것은 스펙이 요구한 동작이 아니라 애플리케이션이 선택한 정책이다. 이후 online이 오면 연결을 다시 시도하되, 새 웹소켓의 open 이벤트가 오기 전까지는 전송 통로가 복구됐다고 판단하지 않았다.

연결 경쟁도 있었다. 이전 connect()가 늦게 성공해서 더 최근에 만든 소켓을 덮어쓸 수 있었다. 연결마다 generation을 올리고, 완료된 연결이 현재 generation과 다르면 그 결과를 버렸다. Promise 자체를 취소하기 어렵다면 결과를 적용할 자격을 취소하는 편이 단순했다.

그런데 여기까지 해도 채팅은 복구되지 않았다. open은 다시 데이터를 주고받을 수 있다는 뜻일 뿐, 끊긴 동안의 메시지를 되찾았다는 뜻은 아니었다.

소켓이 열려도 채널은 아직 쓸 수 없었다

웹소켓이 열린 뒤 애플리케이션 프로토콜의 준비 메시지가 따로 오는 구조라면, 전송 계층의 open과 채널의 준비 완료 사이에 틈이 생긴다.

이때 메시지를 보내면 소켓 자체는 전송할 수 있다. 하지만 서버가 아직 방 접속과 권한을 확정하지 않았으므로 명령이 유효하다고 말할 수 없다. 쓰기 가능 여부는 전송 상태와 채널 상태를 함께 봐야 했다.

const canSend =
  transport.status === "open" &&
  room.lifecycle === "open" &&
  room.permission.canWrite;

이 판단은 UI마다 조합하지 않고 채팅 상태 모델에서 한 번만 하도록 했다. 덕분에 재연결 직후의 명령을 버퍼에 넣어 나중에 몰아서 보내는 대신, 아직 보낼 수 없다는 결과를 바로 돌려줄 수 있었다.

작성 중인 입력과 이미 만들어진 명령도 구분했다. 입력 문자열은 UI가 보존할 수 있지만, 전송할 명령으로 바뀐 뒤에는 중복 실행 가능성을 고려해야 한다. ‘입력을 보존한다’와 ‘보내지 못한 명령을 큐에 넣는다’는 비슷해 보여도 전혀 다른 정책이었다.

모든 메시지를 버퍼링하면 더 안전할까

두 번째 글에서 만든 웹소켓 클라이언트는 연결이 닫혀 있으면 메시지를 버퍼에 쌓고, 연결 후 순서대로 보냈다. 범용 전송 계층에서는 여전히 쓸 수 있는 기능이다. 다만 서버 상태를 바꾸는 명령의 기본값으로는 위험했다.

사용자가 메시지를 보낸 직후 연결이 끊겼다고 해보자.

  1. 서버가 메시지를 저장했다.
  2. 서버가 ack를 보냈다.
  3. 네트워크가 끊겨 브라우저는 ack를 받지 못했다.

클라이언트가 아는 사실은 ‘ack를 받지 못했다’뿐이다. 서버가 명령을 실행하지 않은 것과 실행했지만 답을 잃은 것을 구분할 수 없다. 이 명령을 재연결 후 자동으로 보내면 같은 작업이 두 번 실행될 수 있다.

그래서 ack가 필요한 명령은 한 번만 보내고, 결과를 세 가지로 나눴다.

명령 채널이 준비되지 않음
  → command_not_sent

한 번 전송하고 ack 수신
  → 서버가 확정한 결과

한 번 전송했지만 연결 종료 또는 ack timeout
  → outcome_unknown

command_not_sent는 서버에 전달하지 않았다고 말할 수 있다. UI는 입력을 유지한 채 사용자의 재시도를 받을 수 있다. outcome_unknown은 이미 실행됐을 가능성이 있으므로 자동 재전송하지 않는다. 실패를 하나의 error로 뭉개지 않은 이유다.

이 구분만으로 exactly-once 실행을 보장할 수는 없다. 그러려면 서버가 명령 ID와 실행 결과를 함께 저장하고, 같은 ID가 들어왔을 때 기존 결과를 반환하거나 결과를 조회할 수 있어야 한다. 그런 계약이 없다면 클라이언트는 불확실성을 없애는 대신 드러내야 한다.

typing이나 ping처럼 시간이 지나면 의미가 사라지는 신호는 연결되어 있을 때만 보냈다. 오래된 typing 상태를 재연결 후 전달하는 것은 복구가 아니라 잘못된 정보다.

끊긴 동안의 메시지를 어떻게 메울 것인가

쓰기를 함부로 재시도할 수 없는 것과 달리 읽기 상태는 서버가 부여한 메시지 ID를 이용해 복구할 수 있었다. 재연결 직전 화면이 다음과 같다고 하자.

AA, BB

연결이 끊긴 동안 CC가 저장됐고, 재연결 직후 실시간 메시지로 CC가 먼저 도착할 수 있다. 이어서 서버의 history에도 AA, BB, CC가 들어 있다. history를 단순히 앞이나 뒤에 붙이면 순서가 틀어지거나 CC가 두 번 보인다.

대화방 상태는 연결이 끊기는 순간 가장 최근에 렌더링한 서버 메시지 ID를 anchor로 기록한다. 이후에는 다음 순서로 복구했다.

  1. 기존 타임라인과 작성 중인 입력을 그대로 둔다.
  2. 복구 중 들어오는 실시간 메시지는 바로 보여준다.
  3. 서버에서 history를 가져온다.
  4. anchor가 나올 때까지 필요한 이전 페이지를 더 요청한다.
  5. 수집한 history와 현재 화면의 메시지를 서버 ID 기준으로 병합한다.
복구 전 화면:      AA, BB
복구 중 live:      CC
history:           AA, BB, CC
최종 화면:         AA, BB, CC

서버 ID 하나당 항목 하나만 남기기 때문에 CC는 한 번만 보인다. 과거 페이지가 여러 장 떨어져 있더라도 anchor를 찾기 전까지 중간 결과로 화면 전체를 교체하지 않았다. 일부 페이지만 받은 상태로 덮어쓰면 메시지가 잠시 사라졌다가 다시 나타날 수 있기 때문이다.

처음 여는 대화방은 다르게 처리했다. anchor가 없다는 이유로 전체 이력을 끝까지 가져오지 않고, 첫 페이지를 보여준 뒤 나머지는 일반적인 페이지네이션에 맡겼다. 재연결의 정합성을 위한 탐색과 최초 로딩을 같은 정책으로 묶을 필요는 없었다.

history 복구와 쓰기도 직렬화하지 않았다. 채널이 준비됐다면 이전 메시지를 가져오는 중에도 새 메시지를 보낼 수 있다. 새 메시지는 마지막 병합에서 같은 ID 규칙으로 정렬된다. history가 끝날 때까지 입력창을 막는 편이 구현은 쉽지만, 네트워크가 느릴수록 사용자가 더 오래 기다려야 했다.

이 방식은 서버가 메시지 ID와 충분한 history를 제공한다는 계약 위에 있다. 서버가 보관하지 않는 이력은 클라이언트가 복구할 수 없다.

스키마 검증 뒤에도 상태 해석은 남았다

수신 메시지의 형태를 스키마로 검증해도 화면 상태가 자동으로 정해지지는 않는다.

이 질문은 전송 계층이나 데이터 스키마만으로 답할 수 없었다. 그래서 메시지, 채널 생명주기, 쓰기 권한, typing, history 상태를 관리하는 공통 상태 모델을 두었다. UI는 이 모델이 만든 상태를 화면에 투영한다.

상태 모델의 책임과 테스트는 늘었다. 대신 메시지 순서나 종료된 채널의 쓰기 권한을 화면마다 다르게 해석하는 문제를 피할 수 있었다. 코드 양을 줄이는 선택은 아니었지만, 판단하는 곳을 한 군데로 줄이는 선택이었다.

일부러 하지 않은 일

재연결 단계를 세세하게 보여주는 별도 화면은 만들지 않았다. 기존 메시지와 작성 중인 입력은 그대로 보여주고, 실제 전송 가능 여부만 막았다. 제품에서 중요한 것은 내부 복구 단계를 노출하는 일이 아니라 대화를 잃지 않는 일이었다.

프런트엔드 시뮬레이션이 실제 네트워크 테스트를 대신한다고 생각하지도 않았다. 테스트에서는 비정상 종료, offline/online, 짧은 open/close 반복, 중복 history, 늦은 실시간 메시지, 사라진 ack를 재현할 수 있다. 그래도 실제 기기와 무선 환경은 따로 관찰해야 한다. 결정론적 테스트는 상태 전이를 검증하지만 현실의 네트워크를 그대로 복제하지는 않는다.

이전 구현에서 달라진 생각

이 작업 전에는 범용 웹소켓 클라이언트가 연결을 복구하고 메시지를 버퍼링하면 상위 레이어가 편해진다고 생각했다. 지금도 위치 정보나 최신 상태처럼 유실 정책이 명확한 데이터에는 버퍼가 쓸모 있다고 생각한다. 다만 ‘전송이 늦어져도 같은 의미인가’와 ‘중복 실행돼도 안전한가’를 모르는 전송 계층이 모든 메시지를 대신 보관해서는 안 된다.

재연결도 단일 옵션으로 끝나는 정책이 아니었다. 다시 시도해서 상태가 나아질 수 있는지를 봐야 했다. 인증 실패나 이미 종료된 채널은 재연결하지 않아야 하지만, 일시적인 네트워크 단절은 복구를 계속 시도해야 한다.

스키마로 메시지 타입을 안전하게 만드는 것 역시 출발점이었다. 데이터 형태를 검증한 뒤에도 순서와 중복, 권한, 생명주기를 해석할 상태 모델이 필요했다. 타입 안전한 message를 받았다는 사실과 그 메시지를 화면 어디에 한 번만 보여줄지는 다른 문제였다.

결국 웹소켓 레이어를 잘 만든다는 것은 모든 기능을 한 클래스에 넣는 일이 아니었다. 아래 레이어가 확실히 말할 수 있는 것만 말하고, 모르는 판단은 위로 넘기는 일이었다.

outcome_unknown 같은 이름은 기능을 더 제공하지 않는다. 대신 시스템이 보장하지 못하는 것을 숨기지 않는다. 안정적인 실시간 시스템은 모든 실패를 없애서 만들어지는 것이 아니라, 각 실패를 어느 경계에서 얼마나 확실하게 설명할 수 있는지 정하면서 만들어진다는 것을 배웠다.

서버 계약까지 함께 설계할 수 있다면 명령 ID를 idempotency key로 저장하고 결과 조회 경로를 만들고 싶다. 그러면 지금 outcome_unknown으로 남긴 영역을 더 좁힐 수 있다. 반대로 연결 복구, 읽기 복구, 쓰기 결과를 나눈 경계는 다음 실시간 기능에서도 그대로 가져갈 것 같다.