핑퐁
Ping/Pong 메커니즘은 웹소켓 연결을 수립한 양쪽 소프트웨어(이하 엔드포인트)가 서로 잘 살아있는지 확인하는 과정이다. 유저 랜드1에서 ping/pong 메커니즘을 구현한다면 그래야만 하는 이유가 분명히 있어야 할 것이다.
웹소켓 프로토콜에서 두 엔드포인트가 통신할 때 주고받는 데이터를 프레임이라고 한다. 프레임의 구성 요소들 중 opcode 공간에 있는 데이터는 메시지의 내용(페이로드)을 구분하도록 설계되어 있다.
프로토콜에서는 어느 한쪽이 Ping 프레임(opcode가 0x9)을 보내면 다른 한쪽이 반드시 Pong 프레임(opcode가 0xA)을 보내야 한다고 규정하였다. 이게 Ping/Pong 메커니즘이다.
한 편, WebAPI 인 WebSocket에서도 Ping/Pong 프레임에 대한 스펙이 있다. 여기에 따르면 WebSocket 구현체에서는 현재 Ping/Pong 프레임을 보내는 API를 노출시키지 않는다고 규정하였다.
즉, 유저는 WebSocket 구현체에서 Ping/Pong 프레임을 주고받을 수 없다. 따라서 이 메커니즘은 (지금으로서는) 유저 에이전트(브라우저)가 내부적으로 관리하는 것이라고 할 수 있겠다.
스펙에서 재미있는 점이 한가지 더있다. 유저 에이전트가 Ping/Pong 프레임을 주고받을 때에는 서버가 Ping 프레임을 보내는 것을 가정한다는 것이다(it is assumed that servers will solicit pongs whenever appropriate for the server’s needs.).
그래서 웹소켓 서버 구현체들은 Ping/Pong과 관련된 로직을 직접 구현하거나 옵션을 통해 주입하는게 많아보인다.
만약 유저가 켜놓고 가만히 있었는데 웹소켓이 자동으로 끊겼다면 중간에 프록시를 해주는 툴의 설정을 봐보자. 적어도 Ping 프레임을 보내는 인터벌보다는 길게 timeout 설정이 되어 있어야 유휴한 상황에서 타임아웃 문제를 막을 수 있을 것이다.
마지막으로 어쩔수 없이 타이머 API를 사용해서 수동으로 구현해야 한다면 타이머 쓰로틀링을 겪을 수 있다는 점도 염두해주자.
Closing
웹소켓 프로토콜은 두 엔드포인트가 연결을 정상적으로 종료하는 방법을 정의하고 있다. 둘 중 하나가 연결을 종료하고자 할 때에는 Close 프레임(opcode가 0x8)을 보내야 한다.
WebSocket API에서도 close() 메서드를 제공하여 유저가 명시적으로 연결을 종료할 수 있도록 하고 있다. Close 프레임을 전달할 때에는 종료를 어떤 사유로 하는지를 나타내기위해 코드와 이유를 전달할 수 있다.
이 모든 과정은 Closing Handshake에 속한다.
스펙에서는 Document 객체가 정리되는 시점(Goes away)에 웹소켓 연결이 되어있으나 아직 종료하지 않았을 때에는 1001 코드와 함께 종료 절차를 진행하도록 규정한다. Document가 정리되는 케이스는 엄청 다양하다. 그리고 이런 케이스들에 대해서는 유저 에이전트에서 코드를 보내도록 하는게 가장 깔끔할 것이다!
- 사용자가 새로고침을 하는 경우
- 사용자가 탭을 닫는 경우
- 사용자가 브라우저를 닫는 경우
- 프로젝트가 MPA이고 사용자가 다른 페이지로 이동하는 경우
Document가 discarded 되는 경우2
한편 프로젝트가 bfcache를 적극적으로 사용하고 있다면, Document가 정리되기 전 pagehide 이벤트가 발생하는 시점에 연결을 종료해야만 한다. 그리고 bfcache로 진입하여 pageshow 이벤트가 발생한 후에 다시 연결하는 로직을 짜면 된다. (모바일 이슈가 있을 수 있다.)
function onPageHide(event: PageTransitionEvent) {
if (event.persisted) {
// closing..
}
}
function onPageShow(event: PageTransitionEvent) {
if (event.persisted) {
// reconnecting..
}
}
연결 종료와 함께 고려해야 할 점은 재연결 과정이다. 어떤 시점에 재연결을 해야하는지 정하기 위해선 웹소켓이 닫혔을 때의 맥락을 알고 있어야 한다.
먼저 프로토콜에서는 웹소켓 연결 종료를 TCP 커넥션이 닫혔을 때로 규정하고 있다.
그리고 TCP 커넥션이 닫히기 전에 Closing Handshake가 정상적으로 진행되었다면 clean하다고 표현한다.
스펙에서는 웹소켓 연결 종료가 발생하면 close 이벤트를 트리거 한다. 유저 랜드에서는 close 이벤트에 걸린 이벤트 핸들러/이벤트 리스너를 통해 연결 종료 후 뭔가를 할 수 있다.
const socket = new WebSocket("ws://example.com");
socket.addEventListener("close", (event: CloseEvent) => {
//
});
socket.onclose = (event: CloseEvent) => {
//
};
사후 처리의 관점에서 봤을 때 재연결 로직은 위에서 작성하는 것이 깔끔하다. 그렇다면 어떤 기준으로 재연결을 하면 좋을까?
- 먼저 웹소켓이
not clean하게 닫힌 경우를 생각해보자. 이 경우CloseEvent의wasClean프로퍼티에는false가 할당된다. 그리고 대개 이런 케이스의 경우 브라우저는code프로퍼티에10063을 할당한다. 이 문제가 일시적인 문제인지 아닌지를 판단할 수 없으니, 나는 재연결을 시도해보기를 권장한다. - 웹소켓이
clean하게 닫혔지만 그 이유가 비즈니스 로직상 재연결이 필요한 경우를 생각해보자. 이 경우wasClean프로퍼티는true이지만 그에 대한code와reason가 재연결을 필요로 하는 케이스 일수도 있다. 이건 팀마다 정한 규칙에 따라 코드를 짤지 안짤지 결정해야 할 것이다4.
마지막으로, 위에서 작성한 재연결은 일단 연결이 됐는데 뭔가가 발생해서 다시 연결한다는 맥락을 가지고 있다. 처음에 연결이 실패한 케이스도 재연결을 시도해보는 것이 좋을 것 같다.
Footnotes
-
나는 유저 랜드를 개발자들이 사용하는 공간이라고 생각하고 글을 썼다. ↩
-
Page Lifecycle API 의 일부로, 브라우저는 html을 캐싱해두고
Document를 날려버린다. 해당 탭이 다시 포커스가 되면 해당 HTML을 다시 파싱하고 모든 CSS, JS 코드를 실행시킨다(출처). 이 API는 작성일자 기준으로 크로미움에서만 구현이 되어있다(스펙) ↩ -
1006 코드는 Abnormal Closure 으로, Close Frame에 넣는 코드가 아니다. 이 값은 어느 엔드포인트가 비정상적인 종료를 감지할 때 유저랜드에 전달하는 용도로 사용한다. 유저 에이전트는 이 코드를 동봉해서 보낼 때
reason에 왜 1006이 전달되었는지 구체적인 맥락을 전달하지 않는다. 즉 이 코드를 받았을 때 왜 받았는지를 유저랜드에서 알 수 없다. 이 이유를 노출하면 로컬 네트워크 구조를 탐지할 수 있는 보안 취약점을 만들 수 있기 때문이다. ↩ -
나는 재연결 로직 조건을 opt-out으로 디자인했다. ↩