Computer Science/Network

서버 -> 클라이언트 실시간 통신 방법

둉이 2023. 5. 19. 00:36

웹 페이지는 브라우저에서 서버에 http 요청을 보내 데이터를 받아와서 화면에 보여줍니다.

 
이를 http 통신이라고 부르죠.
 
즉, 브라우저에서 화면을 보여주기 위해서는 서버와의 통신이 꼭 필요합니다.

 

물론 화면을 보여주는 것 외에도 로그인 등 특정 버튼을 클릭했을 때 어떠한 동작이 일어나도록 요청하는 경우도 포함됩니다.

 

 

기본적으로 아래와 같은 기능들은 일반적인 http 요청으로 구현이 가능합니다.

  • 회원 로그인
  • 상품 목록 보여주기
  • 장바구니 담기

 

왜냐하면 단순히 서버로 요청을 보내고, 필요한 경우 데이터를 전달받아 화면에 뿌려주기만 하면 되기 때문이에요.

 

클라이언트에서 요청을 보내면, 서버는 요청에 대한 데이터와 함께 응답을 보냅니다.

일반적인 HTTP 통신

 

클라이언트는 해당 응답의 데이터로 사용자에게 보여줄 화면을 렌더링하거나 요청에 대한 결과를 메시지로 보여줍니다.

 

 

하지만 아래와 같은 경우는 어떨까요? 단순한 http 요청으로는 해결하기 어려워 보이는데요.

  • 코인 가격 조회
  • 택배 위치 조회
  • 실시간 채팅방

 

데이터가 실시간으로 계속 변경되기 때문에, 이벤트가 발생할 때마다 클라이언트에서는 변경된 데이터를 수신하여 화면에 반영할 수 있어야 합니다.

 

이렇게 실시간성을 보장해야 하는 기능이 필요한 경우에는 어떤 방법을 적용할 수 있을까요?

 

지금부터 알아봅시다.

 

 

 

실시간 기능을 위한 통신 방법(HTTP)

실시간성을 보장하기 위해서는 서버에 데이터 변경 이벤트가 발생했을 때, 클라이언트에 즉시 해당 이벤트가 일어났음을 알리고 변경된 데이터를 함께 보내줄 수 있어야 합니다.

 

하지만 HTTP 통신에서 서버가 클라이언트에게 데이터를 보낼 수 있는 건, 클라이언트가 서버에게 요청을 보냈을 경우 뿐입니다.

 

즉, 서버는 요청을 받기 전까지는 클라이언트에게 어떠한 데이터도 전송할 수가 없습니다.

 

이러한 HTTP의 특성 때문에 초기에는 서버에서 클라이언트로 데이터를 보내기 위해 폴링(Polling), 롱 폴링(Long Polling) 방식을 주로 사용했습니다.

 

각각의 방식에 대해 알아봅시다.

 

 

 

폴링(Polling)

polling

 

폴링은 전송할 데이터의 유무에 관계 없이 주기적으로 요청을 수행하는 방법입니다.

 

클라이언트는 지정된 시간 간격에 맞춰 서버에 지속적인 요청을 보내고, 서버는 클라이언트에서 요청이 들어왔을 때 보낼 데이터가 있는 경우에는 해당 데이터를 응답의 바디에 넣어서 함께 보냅니다.

 

서버에서 이벤트가 발생하지 않아 보낼 데이터가 없는 경우에는 불필요한 통신을 주고받게 되는 거죠.

 

또한 일정 간격으로 요청을 보내기 때문에 완벽한 실시간성을 보장할 수 없습니다.

 

위 사진처럼 이전 응답과 새로운 요청 사이에 이벤트가 발생하는 경우에는 클라이언트가 해당 이벤트를 수신할 수 없기 때문입니다. 

 

물론 요청 간격이 조밀하다면 어느 정도 실시간에 가깝겠지만, 그만큼 불필요한 네트워크 리소스를 낭비하게 됩니다.

 

따라서, 폴링 방식은 서버 이벤트가 일정 간격으로 일어나는 경우가 아니라면 적합하지 않습니다.

 

 

폴링(Polling)을 자바스크립트로 구현

이제 폴링을 자바스크립트에서 구현하는 방법을 살펴봅시다.

 

클라이언트에서는 setInterval을 활용하여 간단하게 1초 간격으로 HTTP 요청을 보내는 폴링 코드를 작성할 수 있습니다.

let lastId = 0;
const products = [];

const fetchData = async () => {
  const response = await fetch(`http://localhost:3000/products/` + lastId);
  const { data } = await response.json();
  return data;
};

const polling = () =>
  setInterval(async () => {
    const data = await fetchData();

    lastId = Math.max(0, lastId, ...data.map(({ id }) => id)); // 데이터의 마지막 id를 저장
    products.push(...data);
    result.textContent = JSON.stringify(products); // 받아온 데이터를 화면에 표출
  }, 1000); // 1초 간격으로 서버에 요청

 

응답 데이터가 유효한 값인지 확인한 후, 해당 데이터를 사용하면 됩니다.

 

 

서버에서는 폴링 요청을 수신하면, 데이터 검증을 통해 변경된 데이터를 응답에 실어서 전달합니다.

app.get('/products/:lastId', async (req, res) => {
  const lastId = +req.params.lastId;
  const response = await fetch(SERVER_API_URL);
  const data = await response.json();
  const newData = Object.values(data).filter(({ id }) => id > lastId);
  // 클라이언트에서 보낸 lastId로 새로 추가된 데이터만 필터링

  res.json({ data: newData });
});

 

검증 방법은 여러 가지가 있지만, 가장 간단한 방법으로는 이벤트 발생 여부를 flag에 저장하여 판별할 수 있습니다.

 

혹은 이전 데이터와 현재 데이터를 비교하여 데이터 변경점을 판단하거나, 위 예시 코드처럼 클라이언트에서 요청시 데이터의 ID 값을 함께 넘겨주고 서버에서 해당 ID보다 최신 ID를 갖는 데이터가 있다면 해당 데이터를 응답해주면 됩니다.

 

 

 

롱 폴링(Long Polling)

long polling

 

롱 폴링은 클라이언트에서 요청을 보냈을 때, 서버가 응답을 바로 주는 것이 아니라 이벤트가 발생했을 때, 혹은 타임아웃이 발생했을 때까지 기다렸다가 응답을 전달하는 방식입니다.

 

클라이언트는 서버가 반환한 응답을 받으면 다시 새로운 요청을 보냅니다.

 

이 때, 응답은 데이터를 포함한 응답일 수도 있고 타임아웃 에러일 수도 있습니다.

 

기존 폴링 방식보다 실시간성이 높고 불필요한 요청이 줄어든다는 장점이 있지만, 이벤트 발생이 과도하게 발생하는 경우에는 그만큼 http 통신 횟수도 늘어나게 되므로 서버에 무리가 갈 수 있습니다.

 

 

롱 폴링(Long Polling)을 자바스크립트로 구현

롱 폴링도 자바스크립트로 구현해 봅시다.

 

클라이언트는 요청 이후 서버에서 응답이 올 때까지 기다려야 하고, 응답을 받은 이후에 새로운 요청을 보내야 하므로 try ~ catch ~ finally 구문을 사용하여 구현할 수 있습니다.

let lastId = 0;
const products = [];

const fetchData = async () => {
  const response = await fetch(`http://localhost:3000/products/` + lastId);
  const { data } = await response.json();
  return data;
};

const longPolling = async () => {
  try {
    const data = await fetchData();

    lastId = Math.max(0, lastId, ...data.map(({ id }) => id)); // 데이터의 마지막 id를 저장
    products.push(...data);
    result.textContent = JSON.stringify(products); // 받아온 데이터를 화면에 표출
  } catch (err) {
    console.error('error or timeout');
  } finally {
    longPolling(); // 서버로부터 응답을 받으면 롱 폴링 재요청
  }
};

 

try 구문 내에서는 오류를 제외한 응답을 처리하므로, 받아온 데이터를 화면에 뿌려주는 로직을 작성합니다.

 

finally 구문은 서버로부터 http 응답을 받았을 때 실행되므로 다시 롱 폴링 함수를 실행하는 코드를 작성합니다.

 

 

서버에서는 롱 폴링 요청을 수신하면, 이벤트가 발생할 때까지 응답을 미룹니다.

app.get('/products/:lastId', async (req, res) => {
  const lastId = +req.params.lastId;

  let intervalId = setInterval(async () => {
    const response = await fetch(SERVER_API_URL);
    const data = await response.json();
    const newData = Object.values(data).filter(({ id }) => id > lastId);
    // 클라이언트에서 보낸 lastId로 새로 추가된 데이터만 필터링

    if (newData.length) {
      clearInterval(intervalId);
      intervalId = null;
      res.json({ data: newData });
    }
  }, 1000);  // 1초 간격으로 변경된 데이터 여부 확인

  setTimeout(() => {
    if (!intervalId) return;
    clearInterval(intervalId);
    res.status(404).send('Timeout: no event received');
  }, 5000); // 5초 동안 보낼 데이터가 없으면 타임아웃 처리
});

 

일정 시간 동안 대기하면서 이벤트가 발생하면(= 데이터가 변경되면) 해당 데이터와 함께 클라이언트에 응답을 전달합니다.

 

일정 시간 내에 이벤트가 발생하지 않는다면 서버는 대신 타임아웃 에러를 응답하게 됩니다. 

 

 

 

SSE(Server Sent Event)

위에서 알아봤듯, 폴링과 롱 폴링은 네트워크 리소스도 많이 소모하고 완벽한 실시간성을 보장하기에는 아쉬운 점이 있습니다.

 

이러한 폴링과 롱 폴링의 단점을 해결할 수 있는 개념이 바로 SSE(Server Sent Event)입니다.

server sent event

 

SSE는 클라이언트에서 요청을 보내면, 서버는 응답 후 바로 연결을 종료하는 것이 아니라 연결을 유지한 채로 chunk 단위로 응답 스트림에 데이터를 보내는 방식입니다.

 

IE를 제외한 대부분의 모던 브라우저에서 사용할 수 있습니다. (IE도 폴리필을 지원합니다)

 

 

SSE에서 사용하는 데이터 포맷은 다음과 같습니다.

id: 1
event: products
data: "{\"imageUrl\":\"http://gdimg.gmarket.co.kr/2066395517/still/400\",\"name\":\"20+15% 혜택가 11960원/펩시콜라 제로 210ml x 30캔/탄산음료/제로콜라/음료수/펩시제로\",\"seller\":\"롯데칠성\"}"
retry: 1000

 

id / event / data / retry 등의 속성을 지원하며, event를 지정하지 않은 경우에는 message라는 값으로 자동 지정됩니다.

 

각 속성의 구분은 개행 문자인 \n으로 구분하며, chunk의 끝을 나타낼 때는 \n\n으로 구분합니다.

key value
id 메시지 이벤트 id
event 이벤트 구분자
명시하지 않으면 message로 전송
data 이벤트와 함께 보내지는 데이터
문자열 형태이므로 필요시 파싱 필요
retry 서버와의 연결이 끊어진 경우에 재연결을 위한 대기 시간 

 

 

SSE는 하나의 HTTP 연결을 재사용하기 때문에 기존 폴링, 롱 폴링 방식이 갖고 있던 문제점인 네트워크 리소스 낭비를 해결할 수 있고, 마찬가지로 구현이 쉽다는 장점이 있습니다.

 

또한 접속에 문제가 있다면 자동으로 재연결을 지원해줍니다. (retry 속성 참고)

 

하지만 브라우저 종료 등의 이벤트까지 감지해주지는 않습니다.

 

서버 → 클라이언트 데이터 전송만 가능한 단방향 통신 방식이므로 양방향 통신이 굳이 필요하지 않은 경우에 적합합니다.

 

 

SSE(Server Sent Event)을 자바스크립트로 구현

그럼 SSE도 코드를 보면서 이해해 봅시다!

const evtSource = new EventSource('http://localhost:3000/products');

evtSource.addEventListener('products', ({ data }) => {
  // 이벤트를 받으면 실행
  products.push(JSON.parse(data));
  result.textContent = JSON.stringify(products); // 받아온 데이터를 화면에 표출
});

evtSource.addEventListener('open', (event) => {
  // 연결이 완료되면 실행
});

evtSource.addEventListener('error', ({ eventPhase, target }) => {
  // 연결에 문제가 생기면 실행
  if (eventPhase == EventSource.CLOSED) {
    evtSource.close();
  }
  if (target.readyState == EventSource.CLOSED) {
    // 연결이 종료된 경우 실행
  }
  if (target.readyState == EventSource.CONNECTING) {
    // 재연결을 시도할 때 살행
  }
});

 

클라이언트에서 EventSource 인스턴스를 생성한 후, 해당 인스턴스에 전달 받을 이벤트명으로 이벤트 리스너를 달아줍니다.

 

만약, 서버에서 별도의 이벤트명을 지정하지 않는다면 message 이벤트를 사용하면 됩니다.

 

연결이 완료됐을 때 실행하고자 하는 코드가 있다면 open 이벤트 리스너에 작성해 주면 됩니다.

 

만약 중간에 연결에 오류가 생긴다면, error 이벤트 리스너에서 확인이 가능합니다.

 

 

이제 서버 코드를 살펴봅시다. (편의상 예제 코드는 setInterval로 구현했습니다)

app.get('/products', async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  const response = await fetch(SERVER_API_URL);
  const data = await response.json();
  const newData = Object.values(data);
  const intervalId = setInterval(() => {
    const { id, ...data } = newData.shift() || {};
    if (!id) return clearInterval(intervalId);
    res.write(`id: ${id}\nevent: products\ndata: ${JSON.stringify(data)}\n\n`);
  }, 500); // 0.5초 간격으로 SSE 청크 전송
});

 

SSE 방식에서 주의할 점으로는 반드시 응답 헤더의 Content-Type 값을 text/event-stream로 내려줘야 합니다.

 

 

 

실시간 기능을 위한 통신 방법(WebSocket)

위에서 알아본 실시간 통신 방법들은 모두 HTTP로 통신하기 때문에 요청/응답시 HTTP 헤더도 함께 전달되므로 불필요한 리소스 낭비가 발생한다는 단점이 있습니다.

 

이러한 HTTP 오버헤드를 해결할 수 있는 방법이 바로 WebSocket입니다.

 

 

웹 소켓(WebSocket)

WebSocket은 HTML5에서 등장한 실시간 양방향 통신을 위한 매커니즘으로, ws(혹은 wss) 프로토콜을 사용합니다.

 

웹 소켓 서버와의 연결을 위해서는 프로토콜 변경을 위한 HTTP 요청을 보내게 되고, 서버에서는 해당 요청에 대한 응답을 보냅니다.

 

이러한 과정을 웹 소켓 핸드셰이크(handshake)라고 합니다.

핸드셰이크를 통한 프로토콜 변경

 

응답을 보내고 난 후 연결을 종료하는 HTTP 통신과는 다르게, 클라이언트와 서버 간 통신을 열어두고 서버와 클라이언트는 양방향으로 데이터를 주고받을 수 있습니다.

(전화 통화와 비슷하다고 보면 됩니다.)

 

채팅방의 경우를 예시로 들어볼까요?

 

만약 단체 채팅방에 나와 함께 여러 친구들이 함께 입장한다고 가정해봅시다.

 

채팅을 하는 동안, 마치 채팅방에 있는 모두가 서로 연결되어 있는 것처럼 느껴집니다. 하지만 사실은 모두 하나의 웹소켓 서버에 연결되어 있습니다.

하나의 웹소켓에 연결되어 있는 사용자들

 

내가 웹소켓 서버에 메시지를 보내면, 웹소켓 서버에서는 해당 메시지를 채팅방에 입장한 다른 사용자에게 전달해 줍니다.

 

하나의 서버가 다수의 클라이언트를 제어하는 웹소켓의 특성 때문에 서버에 부하가 집중된다는 단점이 있습니다.

 

또한, 문자열 형태로 데이터를 주고 받기 때문에 클라이언트에서의 데이터 파싱이 필수적입니다.

 

 

웹 소켓(WebSocket)을 자바스크립트로 구현

웹 소켓도 코드를 보면서 이해해 봅시다.

const products = [];

const webSocket = async () => {
  const ws = new WebSocket('ws://localhost:3000');

  ws.addEventListener('open', () => {
    // 연결이 완료되면 실행
  });

  ws.addEventListener('close', () => {
    // 연결이 종료되면 실행
  });

  ws.addEventListener('message', ({ data }) => {
    // 이벤트를 받으면 실행
    products.push(JSON.parse(data));
    result.textContent = JSON.stringify(products); // 받아온 데이터를 화면에 표출
    ws.send('잘 받았어용'); // 서버에 메시지 전송
  });
};

 

클라이언트에서는 SSE와 비슷하게 WebSocket 인스턴스를 생성하여 서버와의 연결을 만들고, 전송된 메시지를 처리하기 위해 이벤트 리스너를 추가합니다.

 

send 메소드를 사용하여 서버로 데이터를 전송하는 것도 가능합니다.

 

 

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 3000 });

wss.on('connection', async (ws) => {
  ws.on('message', (message) => {
    console.log('received: %s', message);
  });

  const response = await fetch(SERVER_API_URL);
  const data = await response.json();
  const newData = Object.values(data);
  const intervalId = setInterval(() => {
    const data = newData.shift();
    if (!data) return clearInterval(intervalId);
    ws.send(JSON.stringify(data));
  }, 500); // 0.5초 간격으로 메시지 전송
});

 

서버 코드도 유사한데요, express에서 웹 소켓 서버를 오픈하기 위해서는 ws 패키지가 필요하니 설치해 줍니다.

 

해당 패키지의 WebSocketServer 생성자를 사용하여 웹 소켓 서버 인스턴스를 생성합니다.

 

send 메소드를 사용하여 클라이언트에 요청을 보낼 수도 있고, message 이벤트에 리스너를 추가하여 클라이언트로부터 데이터를 전달받아서 실행할 로직을 작성할 수도 있습니다.

 

 

 

WebRTC(Realtime Web Communication)

WebRTC는 오디오, 영상 전송에 특화된 기술로, 웹 소켓처럼 HTML5 표준 중 하나입니다.

 

웹 소켓이 하나의 서버에서 여러 클라이언트를 제어하는 방식이라고 하면, WebRTC는 각각의 클라이언트를 직접 연결하여 실시간 통신을 지원해주는 P2P(Peer to Peer) 기술입니다.

WebRTC 설명 사진

 

중간자인 서버를 거치지 않고 클라이언트끼리 직접 데이터를 주고받기 때문에 굉장히 빠릅니다.

 

그렇기 때문에 주로 WebRTC는 스트리밍이나 실시간 화상 채팅 서비스에 사용됩니다.

 

물론 완벽해 보이는 WebRTC도 사용자가 많아질 수록 속도가 느려진다는 단점이 있습니다.

 

채팅방 내에 100명의 사용자가 있다고 가정해 봅시다.

 

화상 채팅을 위해서는 사용자마다 실시간으로 99명의 비디오 및 음성 데이터를 다운로드받아야 하고, 자신의 데이터도 99명에게 브로드캐스팅을 해야 하므로 네트워크 리소스 부담이 많겠죠?

 

그래서 팀즈로 화상채팅을 할 때마다 렉이 심한가 싶기도 합니다.