웹소켓 코딩시 꼭 알아야 할 에러 처리 전략

,

웹소켓 코딩에서 알아야 할 가장 중요한 것 하나를 꼽는다면 웹소켓의 에러 처리 전략입니다.

모종의 이유로 웹소켓 연결이 끊어진다면 어떻게 처리해야 할까요? 반드시 다시 연결해야 합니다.

비즈니스 로직상 의도적으로 연결을 끊은 것이 아니라면 말입니다.

이 점만 기억하면 웹소켓에서 의도치 않게 연결이 끊어지는 치명적인 버그는 일단 피할 수 있습니다.

error 이벤트와 close 이벤트

사진 Lucas Law

그런데 의도치 않은 연결 끊김을 error 이벤트가 발생했을 때로 생각해선 안 됩니다. 의도치 않은 연결 끊김인데도 error 이벤트가 발생하지 않는 경우가 있기 때문입니다.

그래서 error 이벤트가 발생한 경우에만 재연결을 하도록 하면 낭패를 보게 됩니다.

웹소켓 서버가 강제 종료됐을 때도 error 이벤트는 발생하지 않았습니다(크롬과 파이어폭스 모두에서).

맥북 프로 2014 13인치의 크롬에서는 웹소켓 통신을 하지 않고 1-2분 정도가 지나면 에러 이벤트 없이 연결이 종료됐습니다(이유를 찾아 내진 못했습니다).

또 아마존 서버의 로드 밸런서의 경우 통신이 일어나지 않은 채로 1분이 초과하면 로드 밸런서가 연결을 종료한다고 합니다(브라우저가 이걸 정상 종료로 인식하는지 에러로 인식하는지 까지는 모르겠습니다).

분명한 것은 의도치 않은 연결 종료가 error 이벤트 없이 발생할 수 있다는 점입니다.

연결이 끊어졌을 때 반드시 발생하는 이벤트는 error가 아니라 close 이벤트입니다. 따라서 재연결 로직은 error 이벤트 핸들러가 아니라 close 이벤트 핸들러에 작성해야 합니다.

에러가 발생해 연결이 끊어지더라도 error 이벤트와 함께 close 이벤트가 발생합니다. 따라서 에러로 연결이 끊어지면 어떡하지? 하는 생각을 할 필요는 없습니다.

그렇다면 error 이벤트 핸들러에는 어떤 로직을 작성해야 할까요? error임을 알리거나 기록하는 로직을 작성하면 됩니다.

event.code

JS의 웹소켓 close 이벤트 객체에는 연결이 끊어진 이유를 알려 주는 속성(code)이 있습니다. code 속성을 활용하면 의도한 연결 끊김인지 의도하지 않은 연결 끊김인지를 파악할 수 있습니다.

code 속성의 값은 네 자리 숫자입니다. 1000번부터 1015번까지는 이미 정의가 돼 있는데, 정상 종료를 나타내는 1000번을 제외하면 모두 비정상 종료입니다.

(모든 코드 목록은 MDN의 CloseEvent: code property 페이지 혹은 RFC The Websocket Connection Close Code 페이지에서 볼 수 있습니다. 맨 아래엔 제가 번역한 에러 코드를 붙였습니다. JS 모듈 형태로 만들었으므로 임포트해 사용할 수 있습니다.)

그런데 코드가 정상 종료를 나타내는 1000번인 경우에는 재연결을 하지 않아도 될까요? 그럴 지도 모르겠습니다만 그보다는 의도한 연결 끊기인 경우를 표현하기 위한 커스텀 코드를 만드는 것을 권해 드립니다. 웹소켓 서버 쪽에서 close를 할 때 코드를 함께 보낼 수 있습니다.

명세에 따르면 4000~4999번 코드가 어플리케이션에서 사용하라고 놔둔 코드이므로 그 사이 숫자를 이용해서 의도한 종료임을 JS 쪽에 알리면 되겠습니다(저는 아래 예제에서 4000번을 사용했습니다).

event.wasClean 속성

클로즈 이벤트에서 유용한 또 하나의 속성은 wasClean입니다. 이 속성은 연결이 종료될 때 “깨끗하게(cleanly)” 종료됐는지 여부를 알려 줍니다.

웹소켓 명세의 wasClean 설명엔 “깨끗하게”가 의미하는 바가 무엇인지 자세히 설명돼 있지는 않습니다.

제 경험상 wasClean 속성이 false였을 때는 웹소켓 서버가 갑자기 죽었을 때나 인터넷 연결이 끊어졌을 때였습니다. wasClean 속성이 true인 경우는 새로고침을 해서 브라우저가 연결을 끊은 경우나 웹소켓 객체의 close() 메서드를 이용해서 연결을 끊었을 때였습니다.

close 이벤트 핸들러 전략

따라서 close 이벤트 핸들러는 이런 식으로 작성하면 되겠습니다.

function onclose(e) {
    if (e.code !== 4000) {
        // 의도한 종료를 나타내는 커스텀 코드를 4000으로 가정. 
        // 의도한 종료가 아니면 무조건 재연결.
        setTimeout(() => this.initWebSocket(), 200);

        if (!e.wasClean && e.code !== 1000) {
            // 코드상 비정상 종료라면 별도로 알리거나 로그를 기록하는 등 추가 처리
        }
    }
}

추가적 조처: 30초에 한 번씩 통신하기

저는 보험 삼아 안전 장치를 하나 더 뒀습니다. 30초에 한 번씩 무조건 통신을 하게 한 것입니다.

이 코드는 매우 간단한데요. 아래와 같습니다. 웹소켓이 연결돼 있는 경우(webSocketConnection?.readyState === 1) 30초에 한 번씩 hello라는 메시지를 보내게 했습니다.

setInterval(() => {
    if (this.webSocketConnection?.readyState === 1) {
        // 웹소켓이 연결돼 있으면 연결 끊김 방지용으로 hello 라고 보낸다.
        // 아마존 같은 호스팅 서버에서 일정 시간 동안 통신이 없는 연결을 끊는 경우가 있다고 함.
        // 참고: https://jjongwoo.tistory.com/47
        this.webSocketConnection.send(JSON.stringify({command: 'hello'}));
    }
}, 30000);

이것의 효과가 얼마나 좋을지는 확신할 수 없습니다. 철저한 테스트를 거친 것은 아니기 때문입니다. 그러나 적어도 AWS에서 1분 간 통신이 없는 경우 연결을 종료하는 것과 같은 경우는 예방할 수 있습니다. 또한 맥북 프로 2014 13인치에서 통신이 없는 경우 1-2분 만에 연결이 끊어지는 문제도 이 조처 덕에 해결된 것으로 보입니다.

별첨: 에러 코드별 이유를 한글로 가져오는 JS 모듈

/**
 * 아래와 같이 사용하면 됩니다.
 * import getCloseEventCodeReason from './web-socket-close-event-code-reason';
 * ...
 * const reason = getCloseEventCodeReason(event);
 *
 * @param event
 * @returns {string|string}
 */
export default function getCloseEventCodeReason(event) {
    // 웹소켓 에러 코드는 https://datatracker.ietf.org/doc/html/rfc6455#section-7.4 페이지를 참고하세요.
    const reasonMap = new Map([
        [1000, "정상 종료"],
        [1001, "엔드포인트가 “떠나간” 상황. 예컨대 서버가 다운되거나, 브라우저가 페이지에서 나간 경우"],
        [1002, "프로토콜 오류"],
        [1003, "허용되지 않은 데이터 유형을 수신으로 인한 종료. (예: 텍스트 데이터만 이해할 수 있는 엔드포인트가 바이너리 메시지를 받은 경우)"],
        [1004, "1004번 종료 이벤트 코드는 예약돼 있지만 현재는 의미가 없음. 향후에 의미가 정의될 수 있음"],
        [1005, "상태 코드가 없는 채로 종료"],
        [1006, "비정상 종료(ex. close 명령 없이 종료)"],
        [1007, "엔드포인트가 메시지 내에서 일치하지 않는 데이터를 받음. (예: 텍스트 메시지 내의 비 UTF-8 데이터)"],
        [1008, "정책 위반(다른 적합한 원인이 없거나, 정책에 대한 구체적인 세부 사항을 숨길 필요가 있을 때)"],
        [1009, "메시지 처리 용량 초과"],
        [1010, "서버에서 하나 이상의 확장을 협상할 것으로 예상했지만, 서버가 WebSocket 핸드셰이크의 응답 메시지에서 그것들을 반환하지 않음. 구체적으로 필요한 확장은 : " + event.reason],
        [1011, "서버가 요청을 완료하는 데 방해가 되는 예기치 않은 상황에 직면하여 연결을 종료"],
        [1015, "TLS 핸드셰이크 수행 실패(ex. 서버 인증서를 검증할 수 없음)."],
    ]);

    return reasonMap.get(event.code) || "알 수 없는 이유";
}

카테고리 글 목록 👉

,

대표글

댓글 남기기