Skip to content
himprover
GitHubVelog

[번역] 쉬운 웹소켓

Translate, JavaScript, WebSocket20 min read

banner

이 게시물은 원본 아티클인 WebSocket Simplified 를 한글로 번역한 게시글입니다. 게시물 내용의 저작권은 원작자 Shad Mirza 에게 있습니다.

이것은 제가 작성하고자 하는 웹 소켓 시리즈의 첫 번째 게시글입니다. 목표는 가능한 가장 간단한 방식으로 설명하고자 합니다. 함께 떠나보시죠.

웹소켓(WebSocket)은 무엇인가?

웹소켓은 사용자가 서버와 메세지를 주고받을 수 있도록 합니다. 기본적으로 이것은 클라이언트(Client)서버(Server) 간의 통신 방법입니다. 먼저 통신에 대해서 이해한 다음 WebSocket을 알아보도록하겠습니다.

클라이언트(Client)와 서버(Server)

웹브라우저(Client)와 서버는 TCP/IP를 통해 통신합니다. HTTP는 TCP/IP 상에서 요청(웹 브라우저로부터)과 그 응답(서버로부터) 을 지원하는 표준 응용 프로토콜입니다.

어떻게 동작하나요?

간단한 단계로 알아보도록 하겠습니다.

  1. 클라이언트(브라우저)가 서버에 요청을 보냄
  2. 연결을 성립함(established)
  3. 서버가 요청에 대한 응답을 보냄
  4. 클라이언트가 응답을 받음
  5. 연결이 종료됨 (Connection is closed)

이건 서버와 클라이언트간의 기본적인 연결 방법입니다. 다섯번째 단계를 더 자세히 알아보겠습니다.

연결이 종료됨 (Connection is closed)

HTTP 요청은 목적을 이룬 후 더 이상 필요가 없어짐으로, 연결이 종료됩니다.

만약 서버가 클라이언트에게 메세지를 보내고 싶으면요?

통신을 시작하기 위해서는 반드시 연결이 완료되어 있어야 합니다. 이를 하기 위해서는 클라이언트가 또 다른 요청을 보내서 연결하고 메세지를 받아야 합니다.

어떻게 클라이언트는 서버가 메세지를 보내고 싶어하는 걸 아나요?

이 예제를 한번 보겠습니다:

클라이언트는 배가 고파 온라인으로 음식을 주문했습니다. 그는 1초에 1번씩 요청을 보내 주문이 완료되었는지 확인합니다.

0초 : 음식이 준비됐나요? (클라이언트)
0초 : 아니요, 아직이요. (서버)
1초 : 음식이 준비됐나요? (클라이언트)
1초 : 아니요, 아직이요. (서버)
2초 : 음식이 준비됐나요? (클라이언트)
2초 : 아니요, 아직이요. (서버)
3초 : 음식이 준비됐나요? (클라이언트)
3초 : 네 준비됐어요. 여기 주문한거요. (서버)

이것이 HTTP Polling 입니다.

클라이언트는 반복적으로 서버에 요청을 보내 받을 메세지가 없는지 확인합니다.

지금 보았듯이, 이건 정말 효율적이지 않습니다. 우리는 불필요한 자원을 사용하고 있으며, 실패한 요청의 수도 문제가 될 수 있습니다.

이 문제를 해결하는 방법은 없을까요? 있습니다. 기존 폴링 기술에서 변화를 준 Long-Polling을 사용해 부족한 점을 채울 수 있습니다.

기본적으로 Long-Polling 기술은 서버로 HTTP 요청을 보내고, 연결을 유지해 서버가 나중에 (서버에 의해 결정 된 시간에) 응답할 수 있도록 하는 방식을 말합니다.

이전의 예제에서 Long-Polling을 사용해보겠습니다 :

0초 : 음식이 준비됐나요? (클라이언트)
3초 : 네 준비됐어요, 여기 주문한거요. (서버)

문제가 해결됐네요.

그러나 꼭 그렇지만은 않습니다. Long-Polling을 사용하면 잘 동작하지만 CPU, 메모리, 대역폭의 비용이 매우 많이 듭니다. (연결을 유지해 리소스를 블로킹하기 때문)

이제 뭘 해야하나요? 이건 이미 우리 손을 떠난 것 같아 보입니다. 다시 우리의 구세주 웹소켓을 찾아가봅시다.

왜 웹소켓인가요?

지금까지 보았듯이, 폴링과 Long-Polling은 클라이언트와 서버 간 실시간 연결을 흉내내는 데 상당히 비싼 비용을 사용합니다.

성능 병목 현상은 웹소켓을 사용하고자 하는 이유입니다.

웹소켓은 응답을 받기 위해 요청을 보내지 않아도 됩니다. 이는 양방향 데이터 흐름을 허용하므로, 데이터를 수신하기만 하면 됩니다.

서버를 기다리고 있으면(Listen), 서버가 사용 가능할 때 메세지를 보내 줄 것입니다.

이번에는 웹소켓의 성능에 대해 알아보도록 하겠습니다.

자원 사용량

아래 차트는 일반적으로 사용 할 때 웹소켓과 Long-Polling 간의 자원 사용량을 비교해 보여줍니다.

resource graph

차이는 굉장히 큽니다. 요청이 클 수록 그 차이는 더 커집니다.

속도

여기 1초에 1번, 10번, 50번 요청을 보낸 결과가 있습니다.

speed graph

보다시피, 하나의 요청을 보낼 때 연결이 성립 된 후 부터 Socket.io는 50% 느린 것이 보입니다. 이 부하는 작지만 여전히 10개의 요청에 대해서도 눈에 띄는 수준입니다. 같은 연결에서 50번 요청을 보냈을 때 Socket.io는 50% 빨라져 있습니다. 피크 처리량에 대해 더 명확히 이해하기 위해서는 초당 더 많은 요청(500, 1000, 1500)에 대한 벤치마크를 확인하겠습니다.

speed graph2

HTTP 벤치마크의 피크는 초당 950개의 요청이지만, Socket.io는 초당 3900개를 처리하고 있습니다. 효율적인 결과입니다. 그렇죠?

참고: Socket.io는 실시간 웹 어플리케이션을 만들기 위한 자바스크립트 라이브러리입니다. 이는 내부적으로 웹소켓을 사용해 구현되었습니다. 웹소켓과 추가적인 기능을 제공한다고 생각하면 됩니다.

웹소켓은 어떻게 동작하나요?

웹소켓 연결을 성립하기 위한 몇 가지 단계가 있습니다.

  • 클라이언트(브라우저)가 서버에 HTTP 요청을 보냄
  • HTTP 프로토콜로 연결이 성립됨
  • 만약 서버가 웹소켓 프로토콜을 지원하면 연결을 업그레이드할 수 있음 (이를 핸드쉐이크 라고 함)
  • 핸드쉐이크가 완료되면 초기 HTTP 연결은 TCP/IP 프로토콜과 동일한 기반인 웹소켓으로 대체됨
  • 여기서 중요한 것은, 데이터가 서버와 클라이언트 간에서 자유롭게 이동할 수 있는 것

코드로 알아봅시다

우리는 서버, 클라이언트 각각 하나의 파일을 만듭니다.

처음으로 <script> 태그가 포함 된 client.html라는 간단한 <html> 문서를 만듭니다.

Client.html

<html>
<script>
// 우리의코드는 여기에
</script>
<body>
<h1>This is a client page</h1>
</body>
</html>

이제 다른 파일인 server.js를 만들겠습니다. HTTP 모듈을 불러와 서버를 만듭니다. 그리고 8000번 포트를 열어둡니다. (listen)

간편하게 포트 8000번을 열어 둔 http 서버가 완성되었습니다.

Server.js

// http 모듈 불러오기
const http = require('http');
// http 서버 만들기
const server = http.createServer((req, res) => {
res.end('I am connected');
});
// 8000번 포트 listen 하기
server.listen(8000);

node server.js 명령어를 실행하면 8000번 포트로 listen이 시작됩니다. 이 때 포트는 무엇이던 상관없습니다. 8000번은 임의로 선정했습니다.

기본적인 클라이언트, 백엔드 설정이 끝났습니다. 정말 간단하죠? 이제 본론으로 들어가보겠습니다.

클라이언트 설정

웹소켓을 생성할 때 WebSocket() 생성자를 사용해 웹소켓 객체를 얻습니다. 이 객체는 서버와 웹 소켓을 연결을 생성, 관리 하기 위한 API를 제공합니다.

더 쉽게 말하자면, 웹소켓 객체는 서버와의 연결을 성립하고, 양방향 데이터 흐름을 만드는데 도움을 줍니다.

어떻게 하는지 보겠습니다.

<html>
<script>
new WebSocket('url');
</script>
<body>
<h1>This is a client page</h1>
</body>
</html>

웹소켓 생성자는 'URL'을 listen하기 위해 기대합니다. 우리의 경우 서버가 실행중인 ws://localhost:8000 URL을 사용합니다.

이건 원래 알던 익숙한 것과 조금 다른 것입니다. 여기에서는 HTTP 프로토콜을 사용하지 않고 WebSocket 프로토콜을 사용합니다. http:// 대신 ws://를 사용함에 따라 클라이언트는 '야, 우리 웹소켓 프로토콜 써' 라고 말한 것과 같습니다. 충분히 간단한가요? 이제 서버 server.js에서 웹소켓을 생성해보겠습니다.

서버 설정

노드 서버에서 WebSocket을 설정하는 데 서드 파티 모듈 ws가 필요합니다.

첫번째로, 우리는 ws 모듈을 불러옵니다. 그리고 웹소켓 서버를 만들고 포트 8000번을 listen 중인 HTTP 서버에 전달하겠습니다.

HTTP 서버는 포트 8000번에서 listen 중이고, 웹소켓 서버는 HTTP 서버를 listen하고 있습니다. 즉 리스너를 리스닝하는 상황입니다.

이제 우리의 웹소켓은 8000번 포트로 지나는 트래픽을 보고 있습니다. 이 말인 즉슨 클라이언트가 준비되자마자 연결 성립을 시도할 수 있다는 뜻입니다. 이제 sever.js는 이렇게 생겼습니다.

const http = require('http');
// ws 모듈 불러오기
const websocket = require('ws');
const server = http.createServer((req, res) => {
res.end('I am connected');
});
// 웹소켓 서버 만들기
const wss = new websocket.Server({ server });
server.listen(8000);

이는 이전에 말한 내용과 같습니다.

WebSocket() 생성자가 반환하는 객체는 서버와 웹 소켓을 연결을 생성, 관리 하기 위한 API를 제공합니다.

여기서 wss 객체는 연결이 성립되거나, 핸드쉐이크가 완료되거나 연결이 종료되는 등의 특정한 이벤트가 발생할 때 해당 이벤트를 listen하는 데 도움을 줄 것입니다.

어떻게 메세지를 listen 하는지 보겠습니다.

const http = require('http');
const websocket = require('ws');
const server = http.createServer((req, res) => {
res.end('I am connected');
});
const wss = new websocket.Server({ server });
// 웹 소켓 객체가 유효할 때 'on' 메서드 호출
wss.on('headers', (headers, req) => {
// header를 로깅
console.log(headers);
});
server.listen(8000);

'on' 메서드는 이벤트 이름과 콜백 두 가지 매개변수를 전달받습니다. 이벤트를 listen/emit 하기 위해 인식 할 이벤트 이름과 콜백을 사용해 해당 이벤트가 발생했을 때의 동작을 지정합니다. 예제에서는 headers 이벤트를 로깅만 하고 있습니다. 뭘 얻었는지 확인해볼까요?

header

이건 우리의 HTTP 헤더입니다. 이것을 궁금해 하셨으면 좋겠네요. 왜냐하면 이는 바로 뒷면에서 일어나는 일과 정확히 일치하기 때문입니다. 좀 더 이해하기 쉽게 설명해보겠습니다.

  • 첫 번째는 우리가 101 상태 코드를 얻었다는 정보입니다. 200, 201, 404 코드는 봤겠지만 이건 생소해보일겁니다. 101은 사실 프로토콜 변경 상태 코드 입니다. 쉽게 말해 "야 나 업그레이드 하고싶어" 라고 말하는 것입니다.
  • 두번째 줄에는 업그레이드 정보가 보입니다. 이는 websocket 프로토콜로 업그레이드 하고 싶다고 지정한 것입니다.
  • 이것이 실제로 핸드쉐이크 과정에서 일어나는 일입니다. 브라우저는 HTTP/1.1 프로토콜로 HTTP 연결을 성립하고, websocket 프로토콜로 Upgrade합니다.

이제 이것은 더 의미를 가집니다.

Headers 이벤트는 핸드쉐이크 과정 중 일부로, 응답 헤더가 소켓에 기록되기 전에 발생합니다. 이를 사용해 응답을 보내기 전에 헤더를 검증/수정 할 수 있습니다.

즉, 헤더를 수정해 요청을 허용, 거부 또는 원하는 어떤 것으로 변경할 수 있음을 의미합니다. 기본적으로는 요청을 허용합니다.

비슷하게도, 우리는 핸드쉐이크가 종료됐을 때 발생하는 connection 이벤트를 추가할 수 있습니다. 클라이언트와 성공적으로 연결을 성립했을 경우 메세지를 보낼 것입니다.

const http = require('http');
const websocket = require('ws');
const server = http.createServer((req, res) => {
res.end('I am connected');
});
const wss = new websocket.Server({ server });
wss.on('headers', (headers, req) => {
//console.log(headers); 이제 로깅하지 않음
});
//Event: 'connection'
wss.on('connection', (ws, req) => {
ws.send('This is a message from server, connection is established');
// 클라이언트로부터 'message' 이벤트를 받으면 메세지를 전달받음
ws.on('message', (msg) => {
console.log(msg);
});
});
server.listen(8000);

또한 클라이언트로부터 오는 message 이벤트를 listen 할 수 있습니다. 만들어볼까요?

<html>
<script>
let ws = new WebSocket('url');
console.log(ws);
ws.onopen = (event) => ws.send("This is a message from client");
ws.onmessage = (message) => console.log(message);
</script>
<body>
<h1>This is a client page</h1>
</body>
</html>

브라우저에서는 이렇게 보입니다.

client

첫번째 로그는 WebSocket이 웹 소켓 객체의 모든 속성을 보여줍니다. 그리고 두번째 로그에서 data 속성을 가진 MessageEvent를 보여줍니다. 좀 더 자세히 보면 서버로부터 받은 메세지를 볼 수 있습니다.

서버측 로그는 이렇습니다.

server

클라이언트의 메세지도 정확히 받았습니다. 이는 우리의 연결이 정상적으로 성립했음을 의미합니다! 좋아요!

결론

요약하자면, 우리는 이것들을 배웠습니다.

  • HTTP 서버가 실제로 동작하는 방식, 폴링, Long-Polling
  • 웹소켓이 무엇이고 왜 필요한지
  • 웹 소켓이 실제로 어떻게 동작하는지
  • 헤더에 대한 더 깊은 이해
  • 직접 클라이언트와 서버를 만들고 연결을 성공적으로 성립

이것은 기본적인 웹소켓의 동작 방식이었습니다. 이 시리즈의 다음 게시글socket.io가 무엇인지, 어떻게 동작하는지 자세히 알아보겠습니다. 또한 순수 WebSocket()로도 잘 동작하는데 왜 socket.io를 사용해야 하는지에 대해 알아보겠습니다. 메세지를 잘 받고 보낼 수 있는데 왜 굳이 덩치 큰 라이브러리를 사용해야 할까요?

출처

  • WebSocket - Web APIs | MDN
  • ws module for Node server | Docs

역주 의견

여기서부터는 저(himprover)의 의견 입니다.

평소 흥미를 가지고 있던 내용이기도 했고, 원문 자체도 잘 정리된 글이었습니다.

그래서 그런지 기존 아티클보다 길이가 긴데도 불구하고, 평소보다 더 수월하게 진행할 수 있었습니다.

다음 시리즈도 한번 번역해봐야겠습니다.

다음 게시글이 추가됐습니다!