상세 컨텐츠

본문 제목

#4. RealTime - 웹소켓 통신 게임 개발 (1)

내일배움캠프 학습/진행 프로젝트

by 남민우_ 2024. 12. 20. 04:41

본문

https://github.com/Namminu/BootCamp_WebSocketServer

 

GitHub - Namminu/BootCamp_WebSocketServer: 내일배움캠프 - 웹소켓 통신 서버 게임 제작

내일배움캠프 - 웹소켓 통신 서버 게임 제작 . Contribute to Namminu/BootCamp_WebSocketServer development by creating an account on GitHub.

github.com

 

이번 프로젝트는 주어진 클라이언트 코드를 기반으로 웹 소켓 통신이 이루어지는 서버를 개발하는 것이다.

실시간으로 통신이 이루어지는 서버 개발 과정이 처음인 만큼 어려웠던 일들이 많았고 그만큼 주어진 미션들을 전부 해내진 못한 점이 아쉬움으로 남는다.

 

다음은 구현된 내용들이다.

1. 시간에 따른 스테이지 이동

2. 스테이지 별 시간 별 점수

3. 스테이지 별 생성 아이템 종류 제한

4. 아이템 획득 시 점수 추가

 

이 과정을 전부 풀이하기엔 내용이 길어, 개발 중 진행이 어려웠던 상황과 그 이유, 해결에 대해서 이야기하고자 한다.

 

웹소켓 통신 방식의 학습 고찰

프로젝트 코드를 처음 받았을 때, 이 코드로 어떻게 클라이언트 - 서버 의 연결이 이루어지는이 이해하는 것부터 어려웠다.

그동안 진행했던 프로젝트들은 클라이언트만, 혹은 서버/API만 다루어 진행했기 때문에 통신 간에 이루어지는 전달 - 응답 과정에 대한 이해가 난해해, 여기서부터 프로젝트 진행에 있어 차질이 있었다.

 

그럼 코드를 보면서 내가 이해한 해당 프로젝트의 통신 방식에 대해서 설명해본다.

대략적으로 이해한 만큼 그려서 정확하지 않을 수 있지만 그 내용은 다음과 같다.

 

클라이언트는 게임 화면, 플레이 등에 필요한 로직들이 작성되어 있고 서버에서 처리해야 하는 데이터를 요청할 때 클라이언트의 socket을 통해 서버로 요청을 전송한다. 이 과정이 SendEvent() 함수에 해당된다.

 

이후 서버는 내부에 작성된 Handler를 통해 클라이언트의 요청에 맞는 값을 계산하고, 이를 서버에 준비된 socket을 통해 클라이언트로 결과를 반환한다. 이 과정이 Socket.emit() 에 해당된다.

 

비유하자면 서로 교류가 이루어지는 도중에, 직접적인 연결이 아니라 통신을 도와주는 매개체 socket을 대리인으로 내세우는 것이라고 말할 수 있겠다.

 

이 과정을 이해한 후에 코드 개발에 들어갔다면 더 수월하지 않았을까 하는 생각이 드는데, 문제는 이 뿐만이 아니다.

 

결국에 강의 영상을 반복 시청하거나 서칭을 통해 소켓을 통해 데이터를 교류하고, 그 과정에서 payload, handlerId 와 같은 필요한 데이터들을 전달한다는 것은 알게 되었다.

다만 이해가 안갔던 것은 그게 직접적으로 '어떤 함수를 사용해서 요청하고, 어떤 함수를 통해서 응답하느냐' 였다.

 

이 과정은 코드를 보면서 풀어 설명한다.

 

예를 들어 현재 스테이지 데이터를 받아오기 위한 클라이언트 내부 코드이다.

// 서버에서 계산한 현재 스테이지 데이터 받아옴
const curStageData = await sendEvent(10, { time: this.time });
this.s_CurStageId = curStageData.message;
if (!this.s_CurStageId) console.log(`s_CurStageId Missing`);
// 서버가 계산한 현재 스테이지 로컬에 저장
this.saveStageId = this.s_CurStageId;

sendEvent를 통해 현재 유저 플레이한 시간 데이터인 this.time 을 전달하고, 이를 비동기로 처리하여 curStageData의 값을 정확히 할당받을 수 있도록 하였다.

 

그러면 sendEvent는 어디에, 어떻게 구성되어 있을까?

const sendEvent = (handlerId, payload) => {
    return new Promise((resolve, reject) => {
        // 리스너 등록
        responseListeners[handlerId] = (response) => {
            try {
				// 생략 // 
            } catch (err) {
				// 생략 // 
            }
        };
        // 이벤트 전송
        socket.emit('event', {
            userId,
            clientVersion: CLIENT_VERSION,
            handlerId,
            payload,
        });
    });
};

같은 조원의 도움을 받아 작성하게 된 sendEvent 코드이다.

처음에 socket에 대한 이해가 없을 때 이 코드를 보면서 이해가 안갔던 점은 '서버의 함수를 호출하는 부분이 없다' 는 것이었다.

그동안의 코드 개발이 대부분 직접적인 함수 호출과 매개변수 전달로 로직이 이루어졌었기에 이렇게 통신을 통해 데이터를 교류한다는 방식이 받아들이기 어려웠다.

 

결론을 이야기하자면, 하단의 socket.emit을 통해 해당 데이터들을 'event' 키워드와 함께 서버로 전송한다.

 

그럼 서버에서는 어디서 이 데이터를 받아들일까?

export const handlerEvent = (io, socket, data) => {
	// 생략 // 
    const handler = handlerMappings[data.handlerId];
	// 생략 // 

    const response = handler(data.userId, data.payload);
    response.handlerId = data.handlerId;

    if (response.broadcast) {
        io.emit('response', 'broadcast');
        return;
    }

    socket.emit('response', response);
};

이 내용이다.

 

여기서 클라이언트 버전 일치, handler id 등에 대한 검증을 진행하고, handlerMappings 을 통해 sendEvent()에서 보낸 handlerId 를 호출한다.

코드에서는 다음과 같이 구성되어 있는데, 예시로 들었던 경우는 handlerId 가 10이니 해당 내용을 봐보자.

export const getCurStageHandler = (userId, payload) => {
    // 유저의 현재 스테이지 찾는 과정 : 진행한 전체 스테이지의 목록
    const curStage = getStage(userId);
    if (!curStage) return { status: 'fail', message: 'No Stage Found For User' };

    // 현재 스테이지 id에 맞는 ScoreForTime 찾는 과정
    const currentStage = curStage[curStage.length - 1];
    const { stages } = getGameAssets();
    const stageInfo = stages.data.find((stage) => stage.id === currentStage.id);
    if (!stageInfo) return { status: 'fail', message: 'StageInfo Not Found' };

    return { status: 'success', message: currentStage.id, scoreForTime: stageInfo.ScoreForTime };
}

이 핸들러는 직접 작성한 코드의 일환으로, userId를 통해 사용자의 정보, payload를 통해 사용자가 전달하고자 한 정보를 받는다.

API의 경우로 예시를 들어보자면 userId는 JWT나 쿠키, payload는 params 나 body 데이터다 라고 볼 수 있겠다.

 

이 핸들러 내부에서 결과를 도출하는 로직을 수행하고 return 을 통해 해당 결과를 반환해준다.

이 반환하는 결과는 결국 sendEvent()를 호출했던 클라이언트로 전달해야 할텐데, 그러면 서버에서 다시 socket을 통해 데이터를 전달하는 과정이 필요하다.

 

이 부분은 또 어디서 이루어질까? 사실 이미 답은 알고 있다. 위에서 보여주었던 handlerEvent이다.

export const handlerEvent = (io, socket, data) => {
	// 생략 // 
    const response = handler(data.userId, data.payload);
    response.handlerId = data.handlerId;
    // 생략//
    socket.emit('response', response);
};

handlerEvent의 제일 하단의 socket.emit을 통해 response를 전달하는 것이다.

여기서 response.handlerId의 키 값을 새롭게 추가해 data.handlerId를 할당해주었는데, 이는 소켓 통신 과정 중 핸들러 미스 매치 오류가 나는 경우가 있어 정확한 핸들러를 특정해주기 위해 추가하였다.

 

여기까지가 클라이언트가 보낸 요청에 대해 서버가 응답을 보내는 과정이다.

응답을 보냈으면? 클라이언트가 다시 받아야 할 것이다.

 

이 과정은 또 다시 클라이언트의 소켓에서 이루어진다.

socket.on('response', (response) => {
    const { handlerId } = response;

    if (response.broadcast) return;

    // handlerId에 해당하는 리스너 실행
    if (responseListeners[handlerId]) {
        responseListeners[handlerId](response);
    } else {
        console.warn(`No listener found for handlerId ${handlerId}`);
    }
});

socket.on 메서드는 키워드 (여기서는 'response'이다) 에 맞는 데이터를 수신하는 메서드이다.

이 메서드를 통해 클라이언트는 서버가 소켓을 통해 전달한 값을 수신받을 수 있다.

 

그러면 이제서야 다시 처음으로 돌아와

const curStageData = await sendEvent(10, { time: this.time });

curStageData는 처음에 sendEvent를 호출하면서 전달받기 원했던 값, 

{status : 'success', message : ex) 1000, scoreForTime : ex) 1 }

이 객체를 할당받게 된다.

이후에 curStageData.message 등의 데이터 분해를 활용하면서 내부 로직을 수행하는 것이다.

 

간단한 데이터를 서버에게서 하나 요청하는 것만으로도 왕복 총 8번의 과정이 이루어졌고, 이 중에서 함수를 명시적으로 호출한 것은 한번 내지 두번이었다.

 

이 과정을 이해하는데 시간이 상당히 소요된 것이 프로젝트 진행에 있어 가장 큰 걸림돌이었다고 생각된다.

 

트러블 슈팅 : 왜 프로젝트를 제대로 마치지 못했나?

앞서서도 언급했듯, 그동안의 로직 개발은 모두 클라이언트에서만, 혹은 서버/API에서만 이루어졌다보니 클래스 등 내부 설계만 잘 구축해두면 함수를 호출하는 등 데이터를 주고 받으면서 수행할 수 있었다.

 

이런 경험과는 다르게 이번 프로젝트는 '웹 소켓 통신' 이 메인이다보니 클라이언트와 서버의 코드를 모두 조작해야 했고, 그 과정에서 이루어지는 통신의 기본 원리들을 이해하지 못했던 점이 주된 원인이라고 보인다.

 

결과론적으로 이를 해결할 방법은 많았을 것이다.

예를 들어 '웹소켓 통신' 이라는 키워드는 프로젝트가 주어지는 시점부터 알고 있었다.

그렇다면 강의를 한번 듣고도 원리를 이해하지 못했을 때, 다른 자료들을 찾아보면서 실제 코드에서는 어떤 방식으로 통신이 이루어지는지 알 수 있었을 것이라 생각된다.

 

다만 왜 그러지 않았냐? 라고 했을 때의 대답은 '어떤 주제를 검색해야 하는지도 몰랐다' 라고 할 수 있다.

흔히 말하는 '내가 뭘 모르는지 모르겠다' 와 같은 현상이 일어난 것인데, 실제로 프로젝트 진행 중 gpt를 다수 사용했음에도 현재 일어난 문제에서 어디가 원인인지를 모르니 이 코드에서 문제점을 찾아줘 등의 난해한 질문을 하는 경우가 있었다.

물론 난해한 질문인 만큼 정확한 답변을 얻은 적이 없다.

 

또 한가지 대답으로는 강박관념이라고 해야 할까, '이 코드를 받아서 주어진 문제니 이 코드를 보고 해결해야 한다' 라는 생각을 갖고 있었던 것 같다. ~것 같다 라고 표현한 이유는 이성적으로 봤을 때 그럴 이유가 없는데 이 행동을 했다는 점에서 그럴 만한 이유가 이것이기 때문이다. 

 

고찰의 결론

현재도 프로그래밍에 대해 배우는 과정이고, 계속 발전하는 기술에 있어 학습의 끝은 없다고 생각하는 편이지만 그럼에도 시점을 특정하자면 개발을 배운 중반 즈음이라고 할 수 있을 것 같다.

이 시점의 나는 'CS 이론보다 실제 알고리즘 구현 능력이 더 주요하다' 라고 생각했고, 솔직하게 지금까지도 이러한 생각이 아예 없었다고는 못할 듯 하다.

 

다만 이번 프로젝트를 진행하면서 가장 크게 배운 것은 결국엔 기반이 받쳐줘야 높은 기둥이 세워질 수 있듯이 이론적인 지식을 모두 이해한 후에 그 지식에 기반한 알고리즘을 구현할 수 있고, 이렇게 구현된 알고리즘은 무엇보다 강력하게 동작한다는 것이다.

 

생소한 코드라도 단번에 보고 이해할 수 있을 정도의 실력이 아니라면, 내가 그렇듯이 말이다, 정확한 지식이 기반되어야 코드의 논리적인 흐름을 이해할 수 있고 그에 기반된 로직이 수행되는 것이 가장 이상적인 형태라는 것을 다시끔 느끼게 되는 프로젝트였다.

관련글 더보기