주로 인터넷상에서 통신을 할 때는 HTTP를 사용한다. 특히 HTTP/1.1 이하에서는 클라이언트가 서버에게 요청을 보내고, 서버가 그에 응하여 응답을 보내는 식으로 단방향 소통이 이뤄진다.
서버가 클라이언트에게 메시지를 보내는 건 클라이언트의 요청에 응답을 할 때 뿐이다.
일반적인 기능들은 이러한 단방향 방식으로 구현이 가능하지만, 다른 방식이 필요할 때가 있다.
채팅 애플리케이션을 생각해보면, 내가 상대방에게 메시지를 보내는 것은 클라이언트에서 서버로 통보하면 되는 것이니 문제 없지만 상대방이 메시지를 보냈을 때, 이를 내 앱에서 감지하는 것은 불가능하다.
HTTP/1.1 이하에서는 클라이언트의 요청 없이는 서버에서 메시지를 보내지 못하기 떄문이다.
HTTP로 이 문제를 해결하고자 한다면 Polling이라는 방법이 있긴 하다.
클라이언트가 주기적으로 서버에 요청을 보내서 상대가 새 챗을 보냈는지 확인하는 방법이다.
서버는 업데이트가 있으면 있다고, 없으면 없다고 바로 응답을 보낸다.
이 방법에는 크게 두 가지 문제가 있다.
첫 번째로, 요청을 보내는 주기만큼의 지연이 발생할 수 있다.
그로인해, 서버가 갖고 있는 상태의 변화에 즉각적으로 반응하지 못하는 것이다.
두 번째는 계속해서 불필요한 요청들이 보내진다는 것이다.
요청의 주기가 짧다면 반응속도는 빨라지겠지만 트래픽의 낭비도 비례하여 더 심해질 것이다.
이를 개선한 시도로는 Long Polling이 있는데, 이는 서버가 클라이언트의 요청에 바로 응답하지 않고 업데이트가 발생할때까지 기다리는 방법이다.
그러다 상대방이 챗을 보내거나 타임아웃으로 설정된 시간이 지나면 응답을 보내고, 클라이언트는 다시 요청을 보내서 다음 응답을 기다리는 것이다.
이 방식을 사용하면 데이터의 업데이트에 반응하는 속도는 빨라지지만 서버의 부담이 커지게 된다.
서버가 클라이언트로부터 요청을 받을 때 까지 클라이언트와의 연결이 지속되는데, 동시에 여러 클라이언트가 서비스를 사용하면 그 만큼의 연결을 유ㅜ지해야 하므로 부하가 발생하기 때문이다.
이는 업데이트에 대한 반응이 느려지는 결과로 나타나기까지도 하다.
HTTP에서의 요청과 응답에 포함되는 헤더 정보의 양도 매 번 부담으로 작용한다.
때문에 이러한 서비스를 구현하기 위해서는 클라이언트와 서버가 동등하게 메시지를 주고 받을 수 있는, 즉 양방향 통신이 가능한 방식이 필요하다. 그게 바로 WebSocket이다.
HTTP/1.1이 클라이언트가 편지로 요청을 보내고 서버는 답장만하는 방식이라면, WebSocket은 서로가 자유롭게 대화를 주고받는 전화통화라고 할 수 있다.
WebSocket 통신은 다음과 같은 방식으로 이뤄진다. 클라이언트에서는 서버에게 WebSocket을 연결하자는 요청을 HTTP를 통해 보낸다. 굳이 비유하자면 전화통화를 하자는 내용의 편지를 보내는 것이다. 서버는 그것이 가능한 경우 이를 수락하는 응답을 역시 HTTP로 보내게 된다. 이 과정을 Handshake라고 한다.
그렇게 연결이 이뤄지고 나면 그 때부터 클라이언트와 서버는 HTTP가 아닌 WebSocket 프로토콜을 사용하여 소통한다. 여기서 클라이언트와 서버는 자유롭게 서로에게 메시지를 보낼 수 있다.
WebSocket에서의 통신은 헤더의 크기가 작고 오버헤드가 적기 때문에 HTTP보다 효율적인 통신이 가능하다. 이 전화통화는 한 쪽이 다른 쪽에게 통화를 종료하고자 하는 메시지를 보낼 때까지 지속된다. 한 쪽이 close 프레임을 보내면, 다른 쪽이 이를 확인하고 역시 close 프레임을 응답으로 보냄으로써 연결이 종료되는 것이다.
만일 클라이언트가 부득이하게 close를 못 보내고 애플리케이션을 꺼버리는 상황이 발생할 수도 있다. 이와 같은 비정상적인 종료를 감지하는 방법들 또한 존재한다. 지정된 시간동안 메시지가 없을 시 확인 패킷을 보내는 방법도 있고 주기적으로 ping, pong 프레임을 주고 받아서 서로의 접속 여부를 확인하는 방법도 있다.
WebSocket은 하나의 연결을 끝까지 유지하고, 그 과정에서도 적은 자원만 소모하기 때문에 Long Polling만큼 서버에 부담을 주지도 않는다. 이 WebSocket을 사용해서, 실시간으로 서버로부터의 업데이트가 필요한 서비스들을 효율적으로 구축할 수 있는 것이다.
아까 언급한 Handshake 과정을 좀 더 알아보자면, 클라이언트의 요청에는 헤더에 다음과 같은 내용들이 담긴다.
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
헤더는 우편물에 붙은 송장 같은 거라고 보면 된다.
Upgrade: websocket
Connection: Upgrade
이 부분은 혀냊의 HTTP 연결을 WebSocket 프로토콜로 업그레이드하자는 내용이다.
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Key에는 클라이언트가 랜덤으로 생성한 값을 Base64로 인코딩한 문자열이 담긴다.
서버는 이 요청을 받고 나서, GUID라 불리는 정해진 문자열을 그 키에 이어붙인 뒤 SHA-1 해시로 계산하여 다시 Base64로 인코딩한다. 서버는 이 값을 헤더에 담아 클라이언트에게 돌려보내고 클라이언트는 이것이 자기가 보낸 키로부터 생성된 값이 맞는지 확인한다. 모두에게 이미 알려진 공식이기 때문에 서버가 제대로 키를 처리했다는 것을 클라이언트도 확인할 수 있는거다. 이것으로 클라이언트는 이 응답이 자신이 요청을 보낸 상대로부터 온 것임을 확인하고 이 때부터 이 둘은 자유롭게 양방향으로 소통할 수 있는 것이다.
하지만 이러한 WebSocket에도 한계 및 극복해야 할 부분들이 있다.
먼저, 서버의 설계에 따라 구현이 복잡해질 수 있다. 특히 로드 밸런싱이 적용된 서버에서는 이를 위해 고려하고 설정할 부분이 많아진다. WebSocket은 특정 서버와의 지속적인 연결 안에서만 이뤄지기 때문에 한 서버와 웹 소켓 통신을 시작하면 이후로도 계속 그 서버로만 데이터가 전송되도록 설정해야 한다. Nginx, AWS ELB 등 WebSocket을 처리할 수 있는 로드 밸런서를 선택하여 구성하는 등 서비스에 적합한 방법을 찾아 해결해야 한다.
메시지의 크기가 제한되어 있다는 점도 고려할 부분이다. 브라우저, 서버, 네트워크 환경마다 WebSocket에서의 메시지 크기에 제약을 둘 수 있다. 대용량 데이터의 경우, 분할해서 전송하거나 다른 프로토콜을 사용하는 등의 방법으로 해결할 수 있다.
그리고 WebSocket의 기본 프로토콜인 WS은 통신이 암호화 되어 있지 않다. 때문에 보안이 중요한 서비스라면 SSL/TLS 인증서를 발급받은 뒤 이를 사용하여 WSS를 설정해야 한다.
마지막으로, Polling 등의 방식보다는 훨씬 덜하지만 WebSocket도 서버에 부담을 주는 건 마찬가지다.
많은 사용자들이 동시에 접속해 있을 수록 유지해야 하는 TCP 연결이 많아지고 메시지들이 오가는 빈도가 높다면 네트워크 대역폭과 CPU의 사용량도 증가하게 된다. 때문에, 구현하고자 하는 서비스에 이러한 문제가 발생할 경우 WebSocket이 가장 적절한 선택인지 고려해 볼 필요가 있다.