앞선 글에서 WebSocket API에 대해 설명하고 이를 우리 Vue 서비스에 적용하였다. 웹소켓 사용법이 생각보다 간단하여 이전 아티클 내용만으로도 충분하겠지만, 늘 예외 상황이란 존재하는 법. 우리 서비스 역시 마찬가지다.
웹소켓의 이벤트가 발생하지 않은 지 1분이 되었을 때 자동으로 웹소켓 연결이 끊긴다. 이렇게 끊겼을 때를 클라이언트가 캐치해서 다시 웹소켓 연결을 해 주어야 끊김 없이 채팅을 수신할 수 있다. 따라서 이와 같이 웹소켓 연결이 비정상적으로 끊어졌을 때를 대비하여 코드를 추가로 작성해주려 한다.
WebSocket의 close
이벤트(CloseEvent
)를 통해서 웹소켓 연결이 끊어졌는지 여부를 파악할 수 있다. 정상적인 종료였는지 혹은 비정상적인 종료였는지 판단할 수 있는 방법은 크게 CloseEvent의 code 속성 혹은 wasClean 속성 두 가지가 있다.
socket.value.onclose = (e: CloseEvent) => {
console.log(e.code, e.wasClean)
// close() 사용 시 1005 true
// 자동으로 끊겼을 시 1006 false
}
웹소켓의 연결이 끊어졌을 때 그 사유 코드를 의미한다. 코드값은 MDN 문서에서 참고할 수 있다. 주요한 코드들을 살펴보자면,
정도가 있다. 이 code 값은 서버로부터 close 이벤트를 수신할 때 사용할 수 있지만, 클라이언트가 WebSocket.close()
메서드를 통해 직접 소켓 연결을 끊을 때 그 사유를 서버에 전달하기 위해서도 사용된다.
/**
* @param code: WebSocket close 유형 코드
* @param reason: 웹소켓 연결이 끊어졌을 때 그 사유를 사람이 읽을 수 있게 작성할 수 있는 문자열
*/
WebSocket.close(code, reason)
우리 서비스에서 페이지가 unmounted 되었을 때 연결을 끊으면 1005가 반환되는데, 이는 close() 메서드 안에 아무 파라미터도 넣지 않아서 그렇다. 우리가 명시적으로 연결을 종료시켰으므로 1005가 아닌 1000을 반환하는 것이 좋으므로 이를 개선하여 보자. code를 숫자로 나타내면 헷갈리므로 매직 넘버로 만들어 관리했다.
const NORMAL_CLOSURE = 1000
const ABNORMAL_CLOSURE = 1006
onUnmounted(() => {
socket.close(NORMAL_CLOSURE, 'User has left the room.')
socket = null
})
이제 close 이벤트 핸들러에서 code 값에 따른 동작을 잘 구분할 수 있게 되었다. 정상적인 끊김일 때는 추가적인 조치가 필요하지는 않지만, ABNORMAL_CLOSURE
일 때는 소켓 재연결을 시도하여야 한다.
socket.onclose = (e: CloseEvent) => {
if (e.code === NORMAL_CLOSURE) {
console.log('[Chat] WS Closed')
return
}
if (e.code === ABNORMAL_CLOSURE) {
console.log('[Chat] WS Connecting...')
// 다시 연결하는 로직을 작성하자
}
}
하지만 코드 별로 세분화하지 않아도 정상적인 종료 여부를 판단할 수 있는 플래그가 있다. 바로 wasClean
이다. wasClean
은 소켓 연결이 깔끔하게 끝났는지 여부를 나타낸 boolean 값이다. close() 메서드를 이용해서 정상적으로 연결을 끊었을 때는 true 값을 반환한다. 네트워크 에러, 타임아웃, 강제 종료 등으로 인해 연결이 자동으로 끊어졌을 때에는 false를 반환한다.
현재 시점에서는 웹소켓 종료 유형을 코드별로 구분하는 것보다는 정상적인 종료인지 아닌지로 판단하여도 충분할 것으로 생각된다. 타임아웃이 아니더라도 네트워크가 불안정하거나 다른 사유로 인해 비정상적으로 종료되었다면 그 때도 다시 연결을 요청하여야 하기 때문이다.
socket.onclose = (e: CloseEvent) => {
if (e.wasClean) {
console.log('[Chat] WS Closed')
} else {
console.log('[Chat] WS Connecting...')
// 다시 연결하는 로직을 작성하자
}
socket = null
}
웹소켓 인스턴스를 만들고 소켓 이벤트 핸들러를 등록하는 일련의 로직을 하나의 함수로 만들고, 재연결이 필요할 때 그 함수를 재귀적으로 요청하면 될 것 같다. 이를 위해서 소켓 인스턴스를 Vue ref 변수로 만들어주고, 코드를 좀 더 깔끔하게 보기 위해 각 소켓 이벤트 핸들러를 따로 분리하였다.
const webSocketUrl = 'wss://example.url'
const socket = ref<WebSocket | null>(null)
const NORMAL_CLOSURE = 1000
const ABNORMAL_CLOSURE = 1006
const startWebSocket = () => {
socket.value = new WebSocket(webSocketUrl)
socket.value.onopen = handleWebSocketOpen
socket.value.onmessage = handleWebSocketMessage
socket.value.onclose = handleWebSocketClose
}
const handleWebSocketOpen = () => {
console.log('[Chat] WS Connected')
}
const handleWebSocketMessage = (e: MessageEvent) => {
const res = JSON.parse(e.data)
// 채팅 데이터 핸들링하기
}
const handleWebSocketClose = (e: CloseEvent) => {
if (e.wasClean) {
console.log('[Chat] WS Closed')
} else {
console.log('[Chat] WS Connecting...')
startWebSocket()
}
socket = null
}
startWebSocket()
여기서 개선하면 좋을 지점이 몇 가지 있다. WebSocket 프로토콜에 대한 RFC 문서를 보면 비정상적인 종료가 일어났을 때
라고 이야기하고 있다.
만약 네트워크 장애 혹은 서버 에러 등의 문제가 발생하여 연결이 종료되었을 때, 종료되자마자 일제히 모든 클라이언트가 재연결 요청을 서버에 보내면 서버에 갑작스러운 부담을 줄 수 있다. 또한 서버 혹은 클라이언트에 문제가 발생하여 연결이 끊겼을 때 문제를 해결할 살짝의 시간 여유를 주는 것이 좋다. 클라이언트마다 랜덤한 시간차를 두고 재연결 요청을 주면 이런 문제를 해결할 수 있다. 다만 실시간으로 발화자의 음성을 채팅으로 받는 우리 서비스에서는 5초의 시간은 너무 긴 것 같으니 첫 재연결 요청의 시간차는 0초에서 2초 사이의 랜덤한 값을 갖도록 한다. 시간 간격이 꼭 정수로 나누어 떨어질 필요는 없으므로 굳이 Math.floor()를 사용하여 정수화하지는 않았다.
const MAX_TIME_INTERVAL = 1000
const reconnectTimeInterval = Math.random() * MAX_TIME_INTERVAL
const handleWebSocketClose = (e: CloseEvent) => {
//...
setTimeout(startWebSocket, reconnectTimeInterval)
}
만약 재연결 요청이 실패하여 계속 요청을 보내야 하는 상황이라면 지수 백오프 방식을 적용할 수 있다.
지수 백오프 방식이란 반복적으로 서버에 재요청을 보낼 때 각 요청 사이의 시간 간격을 점진적으로 증가시키는 방식이다. 성공 응답을 보낼 수 없는 상황에서 클라이언트가 일정한 주기로 계속 요청을 보내면 서버 입장에서는 불필요한 리소스를 낭비하는 것과 같다. 특히나 서버가 과도한 요청 트래픽을 받아 연결 장애가 일어났다면 더욱 그렇다. 그렇기 때문에 요청이 실패할 때마다 다음 요청까지의 시간 간격을 늘려 서버가 받는 잘못된 요청의 수를 조절하는 것이 오히려 빠른 연결 복구를 위한 길일 수 있다.
const MAX_TIME_INTERVAL = 1000
let reconnectTimeInterval = Math.random() * MAX_TIME_INTERVAL
const handleWebSocketOpen = () => {
console.log('[Flitto Live Translation] WS Connected')
reconnectTimeInterval = Math.random() * MAX_TIME_INTERVAL // 연결 성공했으면 시간 초기화
}
const handleWebSocketClose = (e: CloseEvent) => {
//...
setTimeout(startWebSocket, reconnectTimeInterval)
reconnectTimeInterval *= 2 // 매 재요청 시마다 2배씩 증가
}
사실 지수 백오프의 경우도 모든 클라이언트가 동일한 시간 간격 초기값을 갖는다면 시간 텀만 달라질 뿐이지 서버에 한꺼번에 모든 클라이언트의 요청이 들어간다는 것은 변함없다. 1초에 한 번, 2초에 한 번, 4초에 한 번… 하지만 우리는 초기값을 랜덤하게 부여했으므로 재시도가 계속될수록 클라이언트의 요청은 집중되지 않고 분산될 것이다. 사실 더 우아하게, 아예 시간 간격을 증가시킬 때 약간의 무작위성을 더하는 Jitter라는 방법도 있으나, 일단은 이렇게 개선하는 것으로도 충분하지 않을까 생각한다.
재연결을 요청하는 사유는 여러 가지가 있다. 발언자가 무엇인가를 보여주면서 말이 좀 길게 끊겨 연결이 종료되었을 수도 있고, 쉬는 시간이라 발언이 없어 종료되었을 수도 있다. 하지만 대화가 끝났다거나 하여 채팅이 추가될 가능성이 아예 없는 상황이라면 재요청은 불필요할 것이다. 따라서 재요청 시도 횟수에 제한을 두어 무한정 재연결을 시도할 수 없도록 한다.
const MAX_TIME_INTERVAL = 1000
let reconnectTimeInterval = Math.random() * MAX_TIME_INTERVAL
const handleWebSocketOpen = () => {
console.log('[Flitto Live Translation] WS Connected')
reconnectAttempts = 0 // 연결 성공했으면 횟수 초기화
reconnectTimeInterval = Math.random() * MAX_TIME_INTERVAL
}
const handleWebSocketClose = (e: CloseEvent) => {
//...
setTimeout(startWebSocket, reconnectTimeInterval)
reconnectAttempts++ // 매 재요청 시마다 시도 2배씩 증가
reconnectTimeInterval *= 2
}
이렇게 하여 비정상적인 웹소켓 close에 대해 어느 정도 대응을 할 수 있게 되었다. 개인적으로는 이번 프로젝트를 통해 WebSocket API를 사용해 본 게 재밌었고, 특히 지수 백오프와 같이 네트워크에서 사용하는 방법론에 대해 접해본 것이 아주 흥미로운 경험이었다.