안녕하세요, 이번 포스팅에서는 최근 구현했던 웹소켓(WebSocket) 기반의 실시간 통신에 대한 경험을 공유해보려 합니다.
특히 Guest와 Host라는 두 가지 역할로 나뉘는 클라이언트 간 소켓 통신을 설계하고 구현하면서 겪은 어려움과 해결 과정을 담아보았습니다.
이 프로젝트는 단순한 채팅 앱이나 기본적인 실시간 통신을 구현하는 것을 넘어서, Guest(방문자)와 Host(방 관리자) 간 역할 구분이 명확한 통신을 요구했습니다.
먼저 제가 해결해야 하는 고민은 아래와 같이 정리할 수 있었습니다.
이 모든 것을 프론트엔드와 백엔드에서 설계하고 구현해야 했던 풀스택의 입장에서, 어떤 점을 고민했고, 어떻게 해결했는지 자세히 공유해보겠습니다.
이 프로젝트의 목표는 Guest와 Host가 실시간으로 데이터를 주고받으며 소통하는 시스템을 구현하는 것이었습니다.
예를 들어, Guest는 자신의 위치 정보를 지속적으로 전송하고, Host는 이를 실시간으로 확인하며 필요한 피드백을 전달합니다.
주요 기능을 정리해보면 아래와 같습니다.
channelId
를 기준으로 통신합니다.제가 설계를 하며 고려해야 했던 부분은 크게 아래와 같았습니다.
소켓 통신에서 Guest와 Host의 역할을 명확히 구분해야 했습니다.
Guest는 Host에게만 실시간 데이터를 보내며, 다른 Guest는 이를 볼 수 없고,
Host는 방에 참여 중인 모든 Guest의 실시간 위치를 볼 수 있어야 했습니다.
이렇게 Guest와 Host의 역할 별로 보내고 받는 메시지가 달라져야 한다는 점이 일반적인 소켓 구현과의 차이점이었습니다.
또 저희 프로젝트에는 채널(channel) 개념이 존재했습니다.
채널은 연결된 클라이언트를 그룹화하고, 메시지를 특정 그룹에만 전송하도록 하게 해주는 역할이었습니다.
그래서,
웹소켓은 연결 상태를 유지하며 데이터를 주고받습니다.
하지만 아래와 같은 경우를 대비해주어야 할 것이라고 생각했습니다.
위에서 언급했듯,
저희 프로젝트에서는 채널 별로 그 그룹 내에서만 통신이 이루어져야 했습니다.
그리고 그 채널 내에서도, Host와 Guest가 각각 보내고 받는 데이터의 정보가 달라야 했습니다.
그래서 WebSocket 연결 시 이를 구분할 수 있는 논리가 필요했습니다….
그래서 가장 먼저,
WebSocket 연결 시 URL 파라미터로 role
, channelId
, guestId
를 전달했습니다.
role
: 클라이언트의 역할(host
또는 guest
)channelId
: 통신할 방 IDguestId
: 게스트를 구분하기 위한 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 역할을 구분할 수 있었습니다.
우선, 채널 내에서만 통신이 가능하도록 설계하기 위해 WebSocket 연결 시 채널 ID(channelId)를 URL 파라미터로 전달했습니다.
백엔드는 전달받은 channelId
를 기준으로 소켓 방을 생성하며, 해당 방 안에서만 데이터를 주고받을 수 있게 했습니다.
프론트에서 요청하는 소켓의 url 코드
const ws = new WebSocket(
`${BASE_URL}/socket?channelId=${channelId}`
);
위와 같이 채널을 구분해주었습니다.
또, Guest를 구분하기 위해 각 사용자는 고유한 guestId
를 URL 파라미터로 전달했습니다.
위의 url 코드에서 guestId
를 추가해준 것입니다.
이를 통해 Host는 특정 Guest의 데이터를 명확히 구분할 수 있었습니다.
프론트에서 요청하는 소켓의 url 코드
const ws = new WebSocket(
`${BASE_URL}/socket?channelId=${channelId}&guestId=${guestId}`
);
다음으로는 Host와 Guest에서 보내고 받는 정보가 각각 다르기 때문에 이를 구분할 수 있는 로직을 추가해주었습니다.
위의 url 코드에서 role
을 추가해준 것입니다.
프론트에서 요청하는 소켓의 url 코드
const ws = new WebSocket(
`${BASE_URL}/socket?role=guest&channelId=${channelId}&guestId=${guestId}`
);
결론적으로 프론트엔드에서 Guest의 위치 데이터를 전송할 때 channelId
, guestID
, role
을 함께 보내주게 된 것입니다.
그리고 나서, 같은 방에 있는 클라이언트라도 역할에 따라 서로 다른 데이터를 전달하도록 설계했습니다.
우선 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]);
위의 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]);
위에서 구현한 것으로 실행을 하면, 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];
});
}
};
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가지 기능이 모두 구현 되었고, 실시간 소켓 통신에서 역할 구분, 채널 관리, 중복 접속 처리 등을 해결할 수 있었습니다……………….
guestId
로 여러 사용자가 접속한 경우 처리결론적으로 해결한 흐름을 나타내보면 위의 gif와 같은데,
프론트엔드(클라이언트)와 백엔드(서버)에서 각각 이 모든 기능을 처리해주기 위해 설계하는 과정이 너무 복잡했습니다.
처음에는 채널id
와 guestID
로 분리했는데,
분리해서 통신하다 보니 메시지를 보내는 시점 때문에 host가 접속하기 이전에 접속했던 게스트 정보까지 받아오는 로직도 추가해주어야 했고,
이 로직을 추가하고 보니 같은 guestId로 여러 브라우저에서 접속하게 되었을 때의 예외 처리도 필요해졌습니다…..
정말 이번에 웹소켓 구현 하면서 고려해야 할 부분이 많아 머리가 터질 것 같았는데, 확실히…… 처음부터 디테일한 설계를 했다면 좋았겠다는 생각을 하게 되었고,
소켓 관련 로직을 구현해본 것이 처음이라 디테일한 부분까지 고려하지 못했지만
다음 번에 비슷한 프로젝트를 하게 된다면, 그 때는 정말 구체적으로 발생할 문제들을 가정해가면서 설계를 탄탄히 해두어야겠다는 생각을 하게 되었습니다………..