실시간 소켓 통신의 도전기: 다양한 조건이 존재하는 통신 feat. 클라이언트와 서버 각각에서의 고민과 설계 과정

정혜인·2024년 12월 1일
0

안녕하세요, 이번 포스팅에서는 최근 구현했던 웹소켓(WebSocket) 기반의 실시간 통신에 대한 경험을 공유해보려 합니다.

특히 GuestHost라는 두 가지 역할로 나뉘는 클라이언트 간 소켓 통신을 설계하고 구현하면서 겪은 어려움과 해결 과정을 담아보았습니다.

이 프로젝트는 단순한 채팅 앱이나 기본적인 실시간 통신을 구현하는 것을 넘어서, Guest(방문자)Host(방 관리자) 간 역할 구분이 명확한 통신을 요구했습니다.

먼저 제가 해결해야 하는 고민은 아래와 같이 정리할 수 있었습니다.

  • "어떻게 Guest와 Host를 같은 방에서 실시간으로 연결할 수 있을까?"
  • "역할(role)을 기준으로 적절히 데이터를 전달하려면 어떤 구조가 필요할까?"
  • "실시간 통신의 특성상 발생할 수 있는 예외 상황을 어떻게 처리해야 할까?"

이 모든 것을 프론트엔드백엔드에서 설계하고 구현해야 했던 풀스택의 입장에서, 어떤 점을 고민했고, 어떻게 해결했는지 자세히 공유해보겠습니다.


🧐 상황 설명: Guest와 Host의 실시간 소통

이 프로젝트의 목표는 GuestHost가 실시간으로 데이터를 주고받으며 소통하는 시스템을 구현하는 것이었습니다.

예를 들어, Guest는 자신의 위치 정보를 지속적으로 전송하고, Host는 이를 실시간으로 확인하며 필요한 피드백을 전달합니다.

📋 요구사항 분석

주요 기능을 정리해보면 아래와 같습니다.

  • Host와 Guest는 특정 방(room)에서만 통신 가능해야 한다. → Guest와 Host는 각자 고유한 channelId를 기준으로 통신합니다.
  • Guest는 자신의 위치 데이터를 Host에게만 전달해야 한다. → Guest는 Host에게만 실시간 데이터를 보내며, 다른 Guest는 이를 볼 수 없습니다.
  • Host는 방 안의 모든 Guest 정보를 실시간으로 확인할 수 있어야 한다. → Host는 방에 참여 중인 모든 Guest의 실시간 위치를 볼 수 있습니다.


😩 주요 고민 사항

제가 설계를 하며 고려해야 했던 부분은 크게 아래와 같았습니다.

1. Guest와 Host의 역할 관리

소켓 통신에서 Guest와 Host의 역할을 명확히 구분해야 했습니다.

Guest는 Host에게만 실시간 데이터를 보내며, 다른 Guest는 이를 볼 수 없고,

Host는 방에 참여 중인 모든 Guest의 실시간 위치를 볼 수 있어야 했습니다.

이렇게 Guest와 Host의 역할 별로 보내고 받는 메시지가 달라져야 한다는 점이 일반적인 소켓 구현과의 차이점이었습니다.

2. 채널 관리

또 저희 프로젝트에는 채널(channel) 개념이 존재했습니다.

채널은 연결된 클라이언트를 그룹화하고, 메시지를 특정 그룹에만 전송하도록 하게 해주는 역할이었습니다.

그래서,

  • 방에 속하지 않은 클라이언트가 데이터 전송을 요청했을 때 이를 막아야 했고,
  • 방 안에서도 역할(role)에 따라 메시지가 달라져야 했습니다.

3. 실시간 통신의 특성상 발생하는 문제들

웹소켓은 연결 상태를 유지하며 데이터를 주고받습니다.

하지만 아래와 같은 경우를 대비해주어야 할 것이라고 생각했습니다.

  • 클라이언트가 비정상적으로 연결을 끊었을 때, 이를 서버가 어떻게 처리할 것인가?
  • 연결 중 클라이언트가 잘못된 데이터를 보내면 어떻게 검증할 것인가?
  • 하나의 guestID url에 여러명이 접속하면 어떻게 할 것인가?

💻 해결 과정: 설계와 구현

1️⃣ Websocket 연결 시 채널, host, guest 분리 로직

위에서 언급했듯,

저희 프로젝트에서는 채널 별로 그 그룹 내에서만 통신이 이루어져야 했습니다.

그리고 그 채널 내에서도, Host와 Guest가 각각 보내고 받는 데이터의 정보가 달라야 했습니다.

그래서 WebSocket 연결 시 이를 구분할 수 있는 논리가 필요했습니다….

그래서 가장 먼저,

WebSocket 연결 시 URL 파라미터로 role, channelId, guestId를 전달했습니다.

  • role: 클라이언트의 역할(host 또는 guest)
  • channelId: 통신할 방 ID
  • guestId: 게스트를 구분하기 위한 ID

서버는 이 정보를 기준으로 역할을 분리하고, Host와 Guest의 데이터를 적절히 관리하도록 설계해주었습니다.

서버 코드 일부

const params = new URLSearchParams(req.url.split('?')[1]);
const channelId = params.get('channelId'); // 방 ID
const guestId = params.get('guestId'); // Guest 고유 ID
const role = params.get('role'); // 'host' or 'guest'

이렇게 구분해줌으로써 각 소켓 연결이 어떤 채널에 속해 있는지, 그리고 Guest와 Host 역할을 구분할 수 있었습니다.

1. 채널별 소켓 방 생성

우선, 채널 내에서만 통신이 가능하도록 설계하기 위해 WebSocket 연결 시 채널 ID(channelId)를 URL 파라미터로 전달했습니다.

백엔드는 전달받은 channelId를 기준으로 소켓 방을 생성하며, 해당 방 안에서만 데이터를 주고받을 수 있게 했습니다.

프론트에서 요청하는 소켓의 url 코드

const ws = new WebSocket(
  `${BASE_URL}/socket?channelId=${channelId}`
);

위와 같이 채널을 구분해주었습니다.

2. Guest ID로 사용자 구분

또, Guest를 구분하기 위해 각 사용자는 고유한 guestId를 URL 파라미터로 전달했습니다.

위의 url 코드에서 guestId를 추가해준 것입니다.

이를 통해 Host는 특정 Guest의 데이터를 명확히 구분할 수 있었습니다.

프론트에서 요청하는 소켓의 url 코드

const ws = new WebSocket(
  `${BASE_URL}/socket?channelId=${channelId}&guestId=${guestId}`
);

3. Host와 Guest 구분

다음으로는 Host와 Guest에서 보내고 받는 정보가 각각 다르기 때문에 이를 구분할 수 있는 로직을 추가해주었습니다.

위의 url 코드에서 role을 추가해준 것입니다.

프론트에서 요청하는 소켓의 url 코드

const ws = new WebSocket(
  `${BASE_URL}/socket?role=guest&channelId=${channelId}&guestId=${guestId}`
);

결론적으로 프론트엔드에서 Guest의 위치 데이터를 전송할 때 channelId, guestID, role 을 함께 보내주게 된 것입니다.

2️⃣ 역할에 따른 데이터 전달 구조

그리고 나서, 같은 방에 있는 클라이언트라도 역할에 따라 서로 다른 데이터를 전달하도록 설계했습니다.

1) Guest가 Host에게 위치 데이터를 보낼 때

우선 Guest는 자신의 위치를 Host에게 실시간으로 전달합니다.

프론트엔드 코드 일부

useEffect(() => {
  // 위치 정보가 변경될 때마다 전송
  if (lat && lng && wsRef.current?.readyState === WebSocket.OPEN) {
    wsRef.current.send(
      JSON.stringify({
        type: 'location',
        location: { lat, lng, alpha },
      }),
    );
  }
}, [lat, lng, alpha]);

2) Host가 Guest 목록을 요청할 때

위의 1번은 Guest의 입장이었다면, 이번에는 Host의 입장을 고려해야 했습니다.

Host는 방 안에 있는 모든 Guest의 현재 위치를 가져와야 했습니다.

그래서 아래와 같이 웹소켓에서 메시지를 받았을 때, (해당 데이터는Guest의 실시간 위치이기 때문에) 같은 채널에 속한 Guest들의 실시간 위치라고 판단하여 데이터를 사용할 수 있게 해주었습니다.

프론트엔드 코드 일부

useEffect(() => {
  ws.onmessage = event => {
    const data = JSON.parse(event.data);

    const updatedLocations = data.clients.map((client: any, index: number) => {
      const matchingGuest = channelInfo?.guests?.find(guest => guest.id === client.guestId);
      return {
        ...client,
        color: matchingGuest?.markerStyle.color ?? markerDefaultColor[index],
      };
    });
    setOtherLocations(updatedLocations);
  };
}, [ws, guestsData]);

3️⃣ Guest의 메시지 전달 시점 관리

위에서 구현한 것으로 실행을 하면, host가 먼저 접속해있고, guest가 이후에 접속한 상황에서는 아무런 문제가 없었지만,

guest가 먼저 접속해 있고, 이후 host가 접속한 상황 에서는 guest는 host가 접속하기 전에 메시지를 보낸 것이기 때문에 host 접속 이전의 guest들에 대한 위치는 받아올 수 없는 문제가 발생했습니다.

그래서 어떻게 해결하면 좋을지 고민하다, 소켓에 타입을 하나 더 추가해주는 방식을 생각해냈습니다.

쉽게 말하면,

호스트가 처음 접속하면, 소켓에 보내는 메시지의 type을 init 으로 보내주고,

백엔드 입장에서는 게스트들의 정보들을 미리 저장해두었다가

호스트가 처음 접속한 경우(type이 init인 경우) 에는 이전 게스트들의 정보를 모두 보내주는 방식으로 변경해야겠다고 생각하였습니다.

결론적으로 아래와 같이, host 클라이언트 에서는 받아온 소켓의 메시지 타입이 init 인지, location인지에 따라

이전 게스트들의 정보도 받아오고, 새로 들어오는 게스트들의 정보도 받아올 수 있게 해주었습니다.

ws.onmessage = event => {
  const data = JSON.parse(event.data);
  console.log(data);
  if (data.type === 'init') {
    // 기존 클라이언트들의 위치 초기화
    const updatedLocations = data.clients.map((client: any, index: number) => {
      const matchingGuest = channelInfo?.guests?.find(guest => guest.id === client.guestId);
      return {
        ...client,
        color: matchingGuest?.markerStyle.color ?? markerDefaultColor[index],
      };
    });
    setOtherLocations(updatedLocations);
  } else if (data.type === 'location') {
    // 새로 들어온 위치 업데이트
    const matchingGuest = guestsData?.find(guest => guest.id === data.guestId);
    const updatedLocation = {
      guestId: data.guestId,
      location: data.location,
      token: data.token,
      color: matchingGuest?.markerStyle.color ?? '#ffffff',
    };

    setOtherLocations(prev => {
      const index = prev.findIndex(el => el.guestId === data.guestId);

      if (index !== -1) {
        const updatedLocations = [...prev];
        updatedLocations[index] = updatedLocation;
        return updatedLocations;
      }
      return [...prev, updatedLocation];
    });
  }
};

4️⃣ 동일한 Guest ID 접속 처리

guestId가 동일한 상태에서 여러 사용자가 접속할 경우, 기존의 연결이 유지되면서 데이터 충돌이 발생할 수 있습니다.

이를 해결하기 위해 브라우저 단위의 고유 토큰을 생성해 localStorage에 저장했습니다.

브라우저 단위 고유 토큰 생성 및 저장

const token = localStorage.getItem('socketToken') || uuidv4();
localStorage.setItem('socketToken', token);

그리고 백엔드에서는 새로운 사용자가 동일한 guestId로 접속할 경우, 이전 연결을 강제로 끊는 로직을 추가했습니다.

백엔드에서 중복 Guest ID 처리

const guestSockets = {};

io.on('connection', socket => {
  const { channelId, guestId, token } = socket.handshake.query;

  if (guestSockets[guestId] && guestSockets[guestId] !== token) {
    io.to(guestSockets[guestId]).emit('disconnect');
  }
  guestSockets[guestId] = socket.id;

  socket.on('disconnect', () => {
    if (guestSockets[guestId] === socket.id) {
      delete guestSockets[guestId];
    }
  });
});

이제 동일한 guestId로 접속한 사용자가 있으면, 기존 연결은 끊기고 새로운 사용자만 연결됩니다.


🎉 결론…..

이제 Host와 Guest는 같은 방에서 실시간으로 소통할 수 있습니다……..

결국 아래 3가지 기능이 모두 구현 되었고, 실시간 소켓 통신에서 역할 구분, 채널 관리, 중복 접속 처리 등을 해결할 수 있었습니다……………….

  1. Guest는 자신의 위치를 Host에게 실시간으로 전달
  2. Host는 같은 채널 내의 모든 Guest 정보를 확인 및 관리
  3. 동일한 guestId로 여러 사용자가 접속한 경우 처리

결론적으로 해결한 흐름을 나타내보면 위의 gif와 같은데,

프론트엔드(클라이언트)와 백엔드(서버)에서 각각 이 모든 기능을 처리해주기 위해 설계하는 과정이 너무 복잡했습니다.

처음에는 채널idguestID로 분리했는데,

분리해서 통신하다 보니 메시지를 보내는 시점 때문에 host가 접속하기 이전에 접속했던 게스트 정보까지 받아오는 로직도 추가해주어야 했고,

이 로직을 추가하고 보니 같은 guestId로 여러 브라우저에서 접속하게 되었을 때의 예외 처리도 필요해졌습니다…..


정말 이번에 웹소켓 구현 하면서 고려해야 할 부분이 많아 머리가 터질 것 같았는데, 확실히…… 처음부터 디테일한 설계를 했다면 좋았겠다는 생각을 하게 되었고,

소켓 관련 로직을 구현해본 것이 처음이라 디테일한 부분까지 고려하지 못했지만

다음 번에 비슷한 프로젝트를 하게 된다면, 그 때는 정말 구체적으로 발생할 문제들을 가정해가면서 설계를 탄탄히 해두어야겠다는 생각을 하게 되었습니다………..

0개의 댓글