저번 클론코딩 프로젝트때도 사용했었지만, 이번 프로젝트에도 websocket을 이용해서 게임 로직을 구현해봤다. 이전 프로젝트에선 채팅 기능만 사용했지만, 나만 모른닭 프로젝트에선 채팅과 게임을 진행하는데 필요한 모든 로직을 websocket을 사용해서 구현했다.
웹소켓을 사용한 이유는 게임이라는 프로젝트의 특성상 실시간성이 중요하다고 판단되었기 때문이다. HTTP polling 기법을 사용할 수도 있었지만,
이렇게 3가지 이유로 websocket을 활용하기로 하였다.
WebSocket은 ws 프로토콜을 기반으로 클라이언트와 서버 사이에 지속적인 완전 양방향 연결 스트림을 만들어 주는 기술입니다. 일반적인 웹소켓 클라이언트는 사용자의 브라우저일 것이지만, 그렇다고 해서 이 프로토콜이 플랫폼에 종속적이지는 않습니다.
- 출처: MDN Web Docs
웹소켓 커넥션을 만들기 위해선 new Websocket
을 호출한다.
하지만 일반 websocket으로 채팅을 구현하기엔 문제점이 있기 때문에 websocket emulator 중 하나인 SockJs
를 사용했다.
const SockJs = new SockJS(`${process.env.REACT_APP_WEBSOKET_URL}`);
// sockJS를 사용했기 때문에 "new Websocket" 호출이 아니라 "new SockJS"를 호출했다.
// 소켓을 생성하면 즉시 연결이 시작된다.
커넥션이 유지되는 동안, 브라우저는 (헤더를 사용해) 서버에 '웹소켓을 지원하나요?'
라고 물어본다. 이렇게 처음으로 연결할 경우, HTTP Opening Handshake를 통해 연결을 시도하고, 서버가 요청에 응하면 서버-브라우저간 통신은 HTTP가 아닌 웹소켓 프로토콜(ws) 로 연결된다. ws 프로토콜로 전환되는 것을 WEBSOCKET HANDSHAKE 라고 한다.
웹소켓 사용 시 주의할 점:
커넥션이 만들어진 상태에서 무언가를 보내고 싶으면 socket.send(data)를 사용한다.
socket.send(data)
socket.close([code], [reason])
STOMP(Simpe Text Oriented Message Protocol) 프로토콜은 클라이언트/서버 간 전송할 메시지의 유형, 형식, 내용들을 정의한 규칙이다. Websocket 프로토콜 위에서 동작하며, publish/subscribe 구조로 데이터를 송수신 시 해당 메세지를 같은 곳을 구독하는 수신자에게 전달하는 메세지 브로커이다.
const connectHeaders = {
Authorization: cookie.access_token,
};
// 1. connectHeaders 라는 변수에 통신 시 인증을 추가할 수 있도록 쿠키에서 access_token을 가져온다.
// 1-1. 해당 값은 Stomp 커넥션 연결 시 config로 추가할 수 있다.
const connect = () => {
// 2. connect라는 변수에 함수를 담고,
client.current = new StompJs.Client({
// 2-1. client.current라는 변수에 Stomp Client 객체를 만들고,
webSocketFactory: () => SockJs,
// 2-2. webSocketFactory config 값으로 SockJS 클라이언트 객체를 넣어서 SockJS를 이용한 websocket 연결을 한다는 것을 명시한다.
connectHeaders,
// 2-3. connectHeaders config는 Stomp 연결 시 사용자 인증이 필요한 경우 사용한다.
// 2-4. 사용자의 token값을 넣어둔 변수 명을 동일하게 connectHeaders라고 지정했기 때문에 connectHeaders라고만 작성한다.
onConnect: () => {
subscribe();
// 2-4. Stomp 연결 시, subscribe 함수가 실행된다.
},
onStompError: (frame) => {
// 2-5. 에러가 발생할 경우 실행되는 config
// 2-6. 해당 프레임이 발생하면 웹소켓 연결을 종료한다.
// console.log(`Broker reported error: ${frame.headers.message}`);
// console.log(`Additional details: ${frame.body}`);
},
});
client.current.activate();
// 2-7. stomp 연결을 실행한다.
};
Stomp는 다양한 stomp config option을 갖고있다. Stomp 연결 시 사용할 config의 값을 넣고 client 객체를 activate()
해서 연결을 시작할 수 있다.
연결이 완성될 경우, 송신자의 경우 publish()
를 이용해서 데이터를 보내고, 같은 destination을 subscribe
하고 있는 수신자에게 해당 데이터가 전달된다.
// 데이터를 전달하는 송신자
function sendChat({ message, sender }) {
// 1. 채팅 데이터 전달 시, 해당 함수의 parameter는 메시지의 내용인 message와 보낸이의 닉네임인 sender다.
if (message.trim() === '') {
// 1-1. 만약 메세지의 내용이 공백을 제외하고 빈칸이라면,
return;
// 1-2. 함수는 아무것도 실행하지 않는다.
}
client.current.publish({
// 1-3. 인자의 값이 전부 제대로 들어있으면 publish를 통해 메세지를 송신한다.
destination: `/sub/gameRoom/${param.roomId}`,
// 1-4. 송신되는 데이터의 end point다.
// 1-5. 해당 end point가 동일한 수신자에게만 데이터가 전달된다.
body: JSON.stringify({
// 1-6. body는 옵션 값이지만, SEND, MESSAGE, ERROR 프레임일 경우만 body를 지정한다.
// 1-7. 해당 함수는 SEND 프레임이기 때문에 바디를 지정한다.
type: 'CHAT',
// 1-8. 전달되는 데이터의 type을 지정해서 수신자에게 전달되는 데이터가 어떤 데이터인지 알려준다.
roomId: param.roomId,
// 1-9. 서버측과 합의한 바디 값 중 하나로, roomID를 서버로 전달한다.
sender,
// 1-10. 서버측과 합의한 바디 값 중 하나로, 메세지를 전달한 사람의 id를 서버로 전달한다.
message,
// 1-11. 서버측과 합의한 바디 값 중 하나로, 메세지 내용을 서버로 전달한다.
}),
// 1-12. 해당 함수는 채팅 데이터를 전달하는 함수이기 때문에 바디에 채팅에 맞는 데이터를 담아서 전달한다.
});
}
// 데이터를 전달받는 수신자
async function subscribe() {
// 2. 데이터는 같은 end point를 구독하고 있는 수신자에게만 전달된다.
client.current.subscribe(`/sub/gameRoom/${param.roomId}`, ({ body }) => {
// 2-1. end point를 구분하기 위해 subscribe하는 destination의 주소에 roomID를 넣는다.
// 2-2. 송신자가 publish 했을 때 body에 넣었던 데이터가 수신자에게 전달된다.
const data = JSON.parse(body);
// 2-3. body는 json 형태로 전달 받았기 때문에 해당 데이터를 파싱해서 푼다.
switch (data.type) {
// 2-4. body에 담긴 type에 따라 다른 로직이 실행되어야 하기 때문에 switch 문을 이용해서 수신자가 전달받은 data의 type을 sort한다.
case 'CHAT': {
// 2-5. 전달받은 데이터의 type이 CHAT일 경우, 아래 로직이 실행된다.
setChatMessages((chatMessages) => [...chatMessages, data]);
// 2-6. 해당 로직은 데이터 안에 들어있던 message의 내용이 채팅 데이터를 담고있는 배열 state에 새로운 채팅 데이터가 추가되는 로직.
break;
}
}
})
}
subscribe()
의 경우, 수신된 메세지가 MESSAGE 프레임으로 전달되기 때문에 필수 내용은 사실 상 destination 하나이다. 하지만 구현된 게임의 특성 상 STOMP를 이용해서 여러가지 내용의 MESSAGE가 전달되기 때문에 switch 문을 사용해서 개별적으로 실행되는 로직을 작성했었다.
메세지를 수신하는 주체는 각각 다른 형태의 함수를 이용해 SEND 프레임으로 데이터를 전달하고, 송신하는 주체는 MESSAGE 프레임으로 데이터를 받았을 때 특정 로직이 실행되도록 코드를 구현했었다.
출처:
프로젝트를 끝내고 드디어 사람들을 모집해서 피드백을 수집해보았다. 2주 동안 150명 정도의 사용자를 모집할 수 있었고, 50여개의 피드백을 수집하였다.
버그를 발견한 경우도 많았지만, 사용성에 대해서 받은 피드백을 통해 STOMP 프로토콜을 사용하는 몇몇 로직에 내용을 추가했다.
게임 룰을 확인할 수 있는 모달을 따로 만들었지만, 역시 사용자는 그 모달을 잘 보지 않는다. 😅 그래서 룰을 이해 못 하겠다는 피드백이 많았기 때문에 게임이 시작될 때 냅다 룰을 채팅방에 뿌려버리도록 코드를 약간 수정했다.
특정 함수가 실행됐을 때 같은 end point를 구독하고 있는 사용자들의 화면에 표시되는 내용을 수정했기 때문에 subscribe
부분에 추가했다.
// 데이터를 전달받는 수신자
async function subscribe() {
client.current.subscribe(`/sub/gameRoom/${param.roomId}`, ({ body }) => {
const data = JSON.parse(body);
switch (data.type) {
case 'START': {
// 1. 전달 받은 데이터의 type이 START 일 때 아래 내용이 실행된다.
startEffect.play();
// 게임 시작 효과음
try {
stream.getAudioTracks().forEach((track) => {
track.enabled = false;
// 사용자 마이크 음소거
});
} catch (erorr) {
// console.log(error)
}
setIsStartModal(true);
setCategory(data.content.category);
// 서버에서 전달하는 카테고리 저장
setKeyword(data.content.keyword);
// 서버에서 전달하는 키워드 저장
setMyKeyword('???');
// 내 키워드의 경우 ???로 표시
viewKeyWord = data.content.keyword[`${myNickName}`];
if (myNickName === sessionStorage.getItem('owner')) {
// 2. 세션 스토리지에 저장된 'owner'라는 키로 데이터를 조회해서 해당 값이 myNickname과 같을 경우,
startBtn.current.style.visibility = 'hidden';
// 2-1. 게임 시작 버튼 숨김 처리
leaveBtn.current.disabled = true;
// 2-2. 나가기 버튼 비활성화
sendChat({ message: data.content.startAlert, sender: data.sender });
// 2-3. 메세지 전달 함수인 sendChat() 실행.
// 2-4. 해당 함수의 인자로 서버에서 내려주는 데이터에서 content안에 들어있는 startAlert와 데이터에서 sender로 되어있는 내용을 전달한다.
} else {
// 2-5. myNickname이 세션 스토리지의 'owner'의 값과 같지 않은 경우,
leaveBtn.current.disabled = true;
// 2-6. 나가기 버튼만 비활성화 된다.
}
break;
}
}
})
}
function sendChat({ message, sender }) {
// 3. sendChat의 매개변수는 message와 sender이다.
if (message.trim() === '') {
return;
}
client.current.publish({
destination: `/sub/gameRoom/${param.roomId}`,
// 3-1. 해당 end point를 동일하게 구독중인 사용자에게만 데이터가 전달된다.
body: JSON.stringify({
type: 'CHAT',
roomId: param.roomId,
sender,
// 3-2. 인자로 받은 sender를 서버로 전달한다.
message,
// 3-3. 인자로 받은 message를 서버로 전달한다.
}),
});
}
서버에서 내려주는 data 안엔 많은 내용이 들어있었는데, 그 중 data.content.startAlert
에는 게임룰을 약 4줄 정도로 정리한 string이 저장되어 있었다. 그리고 data.sender
는 "양계장 주인" 이라는 string이 저장되어 있었는데, 마치 관리자 계정이 말 하는 것 처럼 연출하기 위해 이렇게 지정했다고 했다.
게임방의 방장일 경우, 게임이 시작될 때 (Case START) sendChat()
함수를 실행한다. 이때 인자로 넘겨주는 내용 중 sender가 "양계장 주인"이 들어있는 data.sender
를, message의 내용은 게임룰이 적혀있는 data.content.startAlert
를 담아 보냈다.
sendChat()
함수를 실행할 경우, Case CHAT으로 데이터가 수신자에게 전달되고, 메세지의 내용이 화면에 뿌려지는데, 방장만 sendChat()
을 실행시킨 이유는 그렇게 하지 않으면 모든 사용자가 게임룰이 담긴 채팅 메세지를 송출했기 때문이다. 😂 처음에 생각없이 그렇게 했다가 얼마나 놀랐는지... 딱 한명만 메세지를 보내게 해서 게임룰은 게임 시작때 한번만 화면에 출력되도록 만들었다.
그리고 어쩌면 굉장히 당연한 내용인데, 가려진 자신의 키워드를 맞추는 게임인만큼, 게임이 종료되면 답을 맞추지 못 한 사람의 경우 키워드를 다른 사람들한테 물어봤어야 했다.
개발할 땐 키워드를 공개해야 한다는 생각조차 하지 못 했고, 가장 많이 들어온 피드백 중 하나였기 때문에 우선순위를 높혀서 수정했었다. 이 또한 subscribe()
부분을 수정했고, 게임이 시작될 때와 종료될 때의 로직을 일부 수정했었다.
// 데이터를 전달받는 수신자
async function subscribe() {
client.current.subscribe(`/sub/gameRoom/${param.roomId}`, ({ body }) => {
const data = JSON.parse(body);
switch (data.type) {
case 'START': {
startEffect.play();
try {
stream.getAudioTracks().forEach((track) => {
track.enabled = false;
});
} catch (erorr) {
// console.log(error)
}
setIsStartModal(true);
setCategory(data.content.category);
// 서버에서 전달하는 카테고리 저장
setKeyword(data.content.keyword);
// 서버에서 전달하는 키워드 저장
setMyKeyword('???');
// 내 키워드의 경우 ???로 표시
viewKeyWord = data.content.keyword[`${myNickName}`];
// 1. let 키워드를 사용해서 전역 변수 viewKeyWord를 선언했다.
// 1-1. 해당 변수에 본인의 키워드를 저장했다.
if (myNickName === sessionStorage.getItem('owner')) {
startBtn.current.style.visibility = 'hidden';
leaveBtn.current.disabled = true;
sendChat({ message: data.content.startAlert, sender: data.sender });
} else {
leaveBtn.current.disabled = true;
}
break;
}
case 'ENDGAME': {
endEffect.play();
// 게임 종료 시 효과음
muteBtn.current.style.display = 'block';
// 음소거 버튼 비활성화 해제
leaveBtn.current.disabled = false;
// 나가기 버튼 비활성화 해제
setNotice('');
setCategory('');
setKeyword('');
setMyKeyword('');
setIsSpotTimer(false);
setIsTimer(false);
setChatMessages((chatMessages) => [
...chatMessages,
// 2. 채팅 내용이 전부 저장되어있는 배열에,
{
sender: '양계장 주인',
message: `"${myNickName}"의 키워드는 "${viewKeyWord}" (이)닭`,
},
// 2-1. 새로운 채팅 내역 추가
// 2-2. 채팅을 보낸 사람의 이름을 "양계장 주인"으로 설정
// 2-3. 채팅의 내용은 본인의 닉네임(myNickName)과 키워드(viewKeyWord)
]);
try {
stream.getAudioTracks().forEach((track) => {
track.enabled = true;
});
} catch (e) {
// console.log(e);
}
setIsVoiceOn(true);
setUsers((users) =>
users.map((user) => {
return { ...user, isMyTurn: false };
}),
);
setIsMyTurn(false);
if (myNickName === sessionStorage.getItem('owner')) {
startBtn.current.style.visibility = 'visible';
}
break;
}
}
})
}
let 키워드에 전역으로 변수를 지정해서 나중에 게임이 종료될 때 사용할 수 있도록 게임이 시작될 때 본인의 키워드를 저장했었다.
서버에서 키워드를 주는 방식은 객체로, 모든 사용자의 닉네임과 키워드가 키-밸류 형식으로 들어있었다.
{"사용자a":"사과", "사용자b":"딸기", "사용자c":"포도"}
이때 전역변수 viewKeyWord
에 사용자의 닉네임을 인덱스 번호처럼 사용해서 값을 저장했다.
viewKeyWord = data.content.keyword[${myNickName}];
myNickname과 동일한 키를 갖고있는 객체의 값을 저장한 것인데, 서버에서 주는 방식이 사용자의 이름-키워드 가 연결된 형식이었기 때문에 가능한 방법이었다.
그리고 게임이 종료될 때 채팅창에 메세지를 뿌렸는데, 이때 채팅을 전달하는 sendChat()
함수를 사용하지 않고 임의로 채팅을 넣어서 본인의 키워드가 적힌 채팅이 본인에게만 보이도록 설정했다.
setChatMessages((chatMessages) => [
...chatMessages,
{
sender: '양계장 주인',
message: `"${myNickName}"의 키워드는 "${viewKeyWord}" (이)닭`,
},
])
채팅 데이터를 담고 있는 배열은 sender와 message 내용이 키-값 형태로 있는 객체를 담고 있는 배열인데, 배열에 특정 객체를 추가함으로서 새로운 채팅 데이터를 밀어 넣은 것이다.
setState
함수는 비동기로 처리되기 때문에 채팅 데이터가 바로 보일 수 있도록 동기적 처리를 했고, 기존 채팅 데이터가 사라지지 않도록 전개연산자로 기존 내용을 보존하면서 새로운 내용이 추가될 수 있도록 하였다.
피드백을 이 두개의 내용 말고도 많았지만 STOMP가 적용된 함수를 수정한 대표적인 사례가 저 둘이었다. 아직 프로토콜 자체에 대해 많이 이해한거 같진 않지만, 적어도 클라이언트 쪽에서 채팅 기능을 구현할 수 있게 된 것 같다!