본문 바로가기
포트폴리오/개인 자작

[2인 프로젝트] 웹과 유니티를 결합한 방탈출 게임 개발 및 배포

by jamong1014 2024. 12. 7.
반응형

 

프로젝트 주제 : 웹과 유니티를 결합한 방탈출 게임

도메인: https://escape.foolblack.net

개발 기간 : 두 달

본인 파트 : 인프라 구축 및 웹 프론트/백엔드

사용 기술 : AWS Serverless(API Gateway(Rest API, Web Socket API), Lambda, Dynamodb(Stream, TTL), CloudWatch(EventBridge), S3, Cloudfront, Route53), 바닐라 코딩(HTML, CSS, JS)  + 유니티

 

목차

  • 폼 디자인
  • 개발 동기
  • 인프라
  • 첫 번째 솔루션(API Gateway 요청 수)
  • 두 번째 솔루션(Cold Start 솔루션)
  • 세 번째 솔루션(유니티 data.unityweb 파일 사전 로드)
  • 네 번째 솔루션(TTL을 통한 사용자 데이터 관리)
  • 다섯 번째  솔루션(캐싱 자동 무효화 전략)
  • 여섯 번째 솔루션(보안 전략)

 


폼 디자인

 


개발 동기

유니티 클라이언트 개발을 맡은 친구가 취업 전 포트폴리오를 하나 만들면 좋겠다 싶어서 웹과 유니티의 WebGL을 결합한 방탈출 사이트를 만들기로 했다.

 

아이디어도 나쁘지 않은 게 일반적인 사이트 방탈출은 쯔꾸르 형식의 게임 또는 클릭 방식인 텍스트 형식의 게임인 경우가 대부분이다.  

즉 유니티 게임을 결합한 웹 사이트 방탈출 게임은 없다.

 

그냥 유니티로 다 만들면 되는 것 아닌가?

맞다. 하지만 포트폴리오로써 웹과 유니티의 상호작용, 그 사이에 적용할 수 있는 여러 기술들을 보여주기 위해 제작하였으며 무엇보다도 오프라인 방탈출을 즐겨하는 사람들을 목표고객으로 삼았다.

 

오프라인 방탈출을 많이 해본 사람들은 알겠지만 스마트폰으로 할 수 있는 앱(게임), PC에서 할 수 있는 스팀 게임 등 방탈출 게임은 굉장히 많다. 하지만 오프라인 방탈출과는 아예 다른 장르라고 생각한다.

 

가장 큰 차이점은 현실에서 느끼지 못하는 이면세계의 이색적인 경험을 간접적으로 체험할 수 있는 것인데 스팀, 폰 게임에서는 그것을 전혀 느끼지 못한다.

이유는 게임은 정말 게임 그래픽으로 제작이 되어서 말 그대로 게임을 하는 느낌일 것이다.

반면에 오프라인에서 느낄 수 있는 감정은 현실세계의 느낌으로 이면세계를 인테리어 한 것이기 때문에 직접 그 세계에 들어가 플레이하는 느낌일 것이다.

 

개인적인 생각이지만 그나마 오프라인의 느낌을 가장 잘 살릴 수 있는 방법이 바로 웹 사이트로 플레이할 수 있는 방법이라고 본다. 

우리는 웹 서핑, 영상 시청, 지도 등 다양한 매체를 현실적으로 가장 쉽고 흔하게 접하는데 이것을 이용해서 탈출할 수 있는 게임을 만들면 현실세계의 느낌으로 이면세계를 탈출할 수 있는 느낌을 줄 수 있다고 본다.

 

*본 포스팅을 하기에 앞서 백엔드, 프론트의 전체적인 코드 설명은 생략할 것이다.

로직이 너무 많기에 일일이 설명하기 까다롭고 본인의 포트폴리오 방향성과 무관하기 때문에 솔루션 위주로 이야기할 것이다.

 


인프라

최대한 요약된 인프라 아키텍쳐 도식

 

최대한 요약된 도식이다.

오로지 서버리스로 구축되어 있기 때문에 함수 개수만 30개가 넘는다.

 

모든 함수를 도식에 담기에는 가독성도 떨어지고 복잡해지다 보니 최소한의 워크플로우만 표시했다.

이제부터 프로젝트를 진행하면서 생겼던 문제점들에 대한 솔루션들을 얘기해 보겠다.


첫 번째 솔루션(API Gateway 요청 수)

서버리스 기반으로 이루어져 있기 때문에 API Gateway URL을 통해 데이터를 송수신받는다.

일반적으로는 REST API를 많이 사용했지만 데이터를 실시간으로 계속 갱신해야 하는 상황 때문에 모든 요청을 다 REST로 처리해 버리면 기하급수적으로 API 요청 수가 늘어나고 그에 따른 비용도 늘어나게 된다.

 

즉 DynamoDB에 새로운 데이터가 들어가고 들어간 데이터에 대해 클라이언트가 실시간으로 업데이트해야 하는데 새로고침하는 식으로 REST API를 계속 불러오면 비효율적인 요청들로 인해 비용적인 부분에 문제가 생긴다.

 

그래서 새롭게 적용한 기술이 WebSocket API이다.

HTTP와는 달리, 지속적인 연결을 유지하며 클라이언트와 서버가 양방향으로 데이터를 주고받을 수 있기 때문에 비용적으로도 훨씬 절약할 수 있다.

(프리티어 기준 WebSocket API는 월 100만 메시지100만 연결 분의 무료 사용량을 제공)

 

원리를 간단히 말하면 일반적으로 WebSocket API 기본 라우팅 키 구성이 $connect, $default, $disconnect로 이루어져 있다. 클라이언트 측에서 socket.open 메서드가 동작하면 서버 측 $connect 라우팅 키가 트리거 된다.

그럼 소켓 통신이 이루어지고  connection_id = event['requestContext']['connectionId'] 이벤트를 통해 연결된 세션의 아이디를 가져올 수 있다. 

 

아이디를 DB에 저장시키고 그것을 기반으로 1:1 통신이 이루어지는 것이다.

서버에서 데이터가 업데이트된 것을 클라이언트에 전송해줘야 하기 때문에 저장된 세션 아이디를 통해 메시지를 전달해줄 수 있다.

 

여기서도 한 가지 문제점이 발생하게 되는데

세션 연결을 한 후 10분 동안 클라이언트와 서버 간에 데이터 전송이 없으면 연결이 자동으로 종료된다.

이렇게 되면 기존에 연결된 세션 아이디는 만료되기 때문에 데이터 전송에 문제가 생긴다.

 

방탈출 게임 특성상 문제를 고민하고 푸는 데 있어서 해당 스테이지에서 오래 머무르는 상황이 흔치 않게 발생하기 때문에  

중간중간에 임의로 계속 Ping을 보내줘야 한다.

pingInterval = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
console.log('ping 전송');
socket.send('ping'); 
}
}, 300000);

 

이런 식으로 5분 간격으로 Ping 메시지를 보내주어 Idle Timeout을 방지할 수 있다.

이렇게 클라이언트에서 서버로 데이터를 전송할 때 사용되는 라우팅 키가 $default이다.


두 번째 솔루션(Cold Start 솔루션)

기본적으로 람다 함수는 콜드 스타트가 발생한다.

함수가 실행될 때 약간의 지연이 발생한다는 소리인데 이러한 콜드 스타트는 람다가 새로운 실행 환경을 초기화할 때 발생한다. 이는 서버리스 아키텍처의 특성으로, 필요할 때만 리소스를 사용하여 비용 효율성을 극대화하려는 AWS의 설계 방식 때문이다.

 

이게 뭐가 문제냐면 일반적으로 람다가 실행되고 5~15분 후 콜드 스타트가 발생하는데 실시간으로 데이터를 받아와서 즉각적으로 사용자에게 업데이트된 데이터를 보여줘야 하는 방탈출 게임 특성상 콜드 스타트는 매우 치명적인 문제가 된다.

 

예를 들어 Stage1을 클리어했는데 Stage2 버튼 활성화가 늦게 된다면 사용자가 플레이하는데 큰 불편함을 느낄 수 있다.

이를 해결하기 위해서 웜 스타트를 유지시키는 방법이 있는데 CloudWatch(EventBridge)를 통해 5분 간격으로 필요한 람다 함수를 특정하여 주기적으로 실행하는 것이다.

 

그러면 지연 없는 함수 실행이 이루어진다.

 

그럼 그것 또한 비용이 발생하지 않나?

한 달에 프리티어로 람다 함수가 백만 요청을 제공해 주는데 그것보다 훨씬 적은 요청이기 때문에 배포했을 경우 사용자들이 이용하는 것까지 감안해도 그렇게 큰 비용이 부담되지는 않는다.


세 번째 솔루션(유니티 data.unityweb 파일 사전 로드)

웹과 유니티를 결합한 프로젝트인 만큼 사용자에게 어떻게 최적의 플레이를 제공해 줄까라는 고민이 많이 든다.

게임 볼륨이 많이 커지면서 데이터 크기 또한 커진다.

 

그냥 단순 하나의 게임이면 문제가 되지 않지만 Stage1~5까지 유니티 클라이언트가 5가지가 있는 것이기 때문에 Stage1을 깨고 Stage2를 넘어가는 시점에 GPU 성능이 좋지 않은 컴퓨터인 경우 맨 처음 로드하는데만 20초가 넘게 걸린다.

 

이렇게 될 경우 게임 흐름에 매우 방해가 될 수 있다.

그래서 생각한 것이 각각의 스테이지 데이터 파일을 전 단계에서 미리 로드시켜 두는 것이다.

 

예를 들어 튜토리얼 단계에서 Stage1 데이터파일을 미리 다운받아놓고, 마찬가지로 Stage1에서는 Stage2 데이터 파일을 미리 다운시켜 두는 것이다.

 

방탈출 문제를 풀면서 다음 단계를 데이터 파일을 미리 다운받아놓는것이기 때문에 플레이하는데 전혀 지장 없이 즐길 수 있다. 

 

원리는 이렇다.

function storeFileInIndexedDB(url, dbName, storeName) {
        fetch(url)
            .then(response => response.blob())
            .then(blob => {
                const request = indexedDB.open(dbName, 1);
                request.onupgradeneeded = function(event) {
                    const db = event.target.result;
                    db.createObjectStore(storeName);
                };
                request.onsuccess = function(event) {
                    const db = event.target.result;
                    const transaction = db.transaction(storeName, "readwrite");
                    const store = transaction.objectStore(storeName);
                    store.put(blob, url);
                    console.log('File stored in IndexedDB:', url);
                };
            })
            .catch(error => {
                console.error('Failed to fetch and store file:', error);
            });
    }

    // 레벨 1에서 레벨 2의 리소스를 미리 다운로드하고 IndexedDB에 저장
    	storeFileInIndexedDB('../Stage1/Build/Stage1.data.unityweb', 'gameData1', 'level2Resources');

먼저 현 레벨에서 다음 단계의 데이터파일을 미리 다운로드하여 IndexedDB에 저장시킨다.

 

function loadCachedResources() {
        getFileFromIndexedDB('../Stage1/Build/Stage1.data.unityweb', 'gameData1', 'level2Resources', function(file) {
			if (file) {
				console.log('Using cached file for Stage1.data.unityweb');
				const fileURL = URL.createObjectURL(file); 
				startUnity(fileURL); 
			} else {
				console.log('File not found in IndexedDB, fetching from network.');
				startUnity(); 
			}
		});

    }
    
function getFileFromIndexedDB(url, dbName, storeName, callback) {
    const request = indexedDB.open(dbName, 1);

    request.onupgradeneeded = function(event) {
        const db = event.target.result;
       
        if (!db.objectStoreNames.contains(storeName)) {
            db.createObjectStore(storeName);
        }
    };

    request.onsuccess = function(event) {
        const db = event.target.result;

        
        if (!db.objectStoreNames.contains(storeName)) {
            console.log(`IndexedDB에 "${storeName}" 오브젝트 스토어가 없습니다. 네트워크에서 파일을 가져옵니다.`);
            callback(null);  
            return;
        }

        const transaction = db.transaction(storeName, "readonly");
        const store = transaction.objectStore(storeName);
        const fileRequest = store.get(url);

        fileRequest.onsuccess = function(event) {
            const file = event.target.result;
            if (file) {
                callback(file);
            } else {
                callback(null);  
            }
        };
        fileRequest.onerror = function() {
            console.error('IndexedDB에서 파일을 가져오는 중 오류 발생');
            callback(null);  
        };
    };

    request.onerror = function() {
        console.error('IndexedDB를 여는 중 오류 발생');
        callback(null);  
    };
}

이 코드는 다음 레벨의 코드인데 getFileFromIndexedDB 함수를 통해 IndexedDB에 저장된 데이터파일을 불러와 바로 로드시킬 수 있다. 

만약 IndexedDB에 데이터파일이 저장되어있지 않다면 그땐 어쩔 수 없이 네트워크를 통해 데이터를 가져온다.

 

 

 

이렇게 구성하면 지연 없는 스테이지 이동이 가능하다.


네 번째 솔루션(TTL을 이용한 사용자 데이터 관리)

게임을 하다가 중간에 재미가 없어 나가는 경우도 있을 것이다.

그럼 나간 경우 이미 등록되어 버린 사용자 데이터는 어떻게 할 것이냐?

 

애초에 먼저 이름 등록이 이루어질 때 TTL을 붙여서 등록을 한다.

ttl_time = int(time.time() + 5400)
table.put_item(
            Item={
                'name': name,
                'token': token,
                'ttl': ttl_time  # TTL을 위한 타임스탬프 추가
            }
        )

1시간 30분 후 TTL에 의해 만료되어서 해당 데이터는 삭데 된다.

 

만약 끝까지 게임을 하고 엔딩을 본 사용자는 반대로 처음에 적용되어 있던 TTL을 삭제해 주는 것이다.

Key={'name': name},
            UpdateExpression='SET playtime = :val1 REMOVE #ttl', 
            ExpressionAttributeValues={
                ':val1': playtime_formatted
            },

 

DynamoDB TTL은 정해진 시간이 지났다고 해서 바로 삭제되지는 않는다. 정해진 시간이 지난 후에 그 데이터는 만료상태로 간주되고, 삭제 작업은 백그라운드 스케줄러에 의해 처리되며, 삭제 지연 시간이 발생할 수 있다.

 

이런 식으로 DynamoDB TTL을 통해 사용자 데이터 관리를 적용해 보았다.


다섯 번째 솔루션(캐싱 자동 무효화 전략)

서버리스 아키텍처로 구성하고 배포할 경우 이젠 항상 적용하는 전략이다.

Cloudfront를 통해 캐싱 히트가 이루어지고 만약 여기서 새로운 파일을 업로드시키고 배포에 바로 적용시켜야 하는 경우 캐싱 TTL이 지나야 적용이 된다.

 

그렇기 때문에 맨 처음 Cloudfront를 적용했을 때도 모든 파일이 다 히트가 이루어지지 않아 불필요한 리소스를 낭비하고 워크로드 또한 비효율적으로 이루어진다.

 

일반적인 캐싱 전략

 

Cloudfront는 TTL 기준으로 데이터를 업데이트하기 때문에 Invalidations(무효화)를 통해 수동으로 캐싱 삭제를 해야 하지만 이 작업 또한 Lambda 함수를 호출하여 자동화시킬 수 있기 때문에 훨씬 효율적이다.

 

 

Lambda를 통한 자동 무효화 수행

 

이렇듯이 Invalidations을 자동 무효화를 통해 좀 더 효율적인 오버헤드를 기대해 볼 수 있는 구성이다.

 

import boto3
import time
import os

def lambda_handler(event, context):
    paths = []
    for items in event["Records"]:
        key = items["s3"]["object"]["key"]
        if key.endswith("index.html"):
            paths.append("/" + key[:-10])
        paths.append("/" + key)
    print("Invalidating " + str(paths))

    client = boto3.client('cloudfront')
    batch = {
        'Paths': {
            'Quantity': len(paths),
            'Items': paths
        },
        'CallerReference': str(time.time())
    }
    invalidation = client.create_invalidation(
        DistributionId=os.environ['CLOUDFRONT_DISTRIBUTION_ID'],
        InvalidationBatch=batch,
    )
    return batch

(CLOUDFRONT_DISTRIBUTION_ID는 환경변수 추가)

 

CloudWatch 로그 그룹 S3에 업로드 된 즉시 무효화가 이루어진 모습

 


여섯 번째 솔루션(보안 전략)

실제로 서버리스 아키텍처로 배포했을 경우 보안이 굉장히 중요한 걸 느꼈다.

애초에 서버와 통신하는 매체가 API URL이고 서버리스 특성상 정적 콘텐츠에 그대로 노출이 되기 때문에 보안 공격에 취약하다.

 

REST API는 HTTP 기반으로 동작하기에 Curl, Wget 등 HTTP(S) 요청을 수행할 수 있는 도구로 데이터 조작이 가능하여 보안에 취약할 수 있다.

 

물론 WAF을 이용해서 User-Agent, SQL Injection, XSS, DDoS 방어 등 여러가지 보안을 적용할 수 있지만 비용이 든다.

프리티어가 아니고 비용이 지속적으로 들 수 있는 솔루션은 여러가지 상황을 보고 적용해야한다.

 

현 시점에서는 요청에 관련된 보안을 고안해내야 하기 때문에 굳이 WAF까지 사용하는건 비효율적일 수 있다.

(람다측에서 충분히 구현이 가능)

 

또한 그냥 동적인 서버(php 등) 으로 x-api-key 헤더 키 추가해서 보내는 방법도 있지만, 오로지 정적인 콘텐츠로 사용중이기 때문에 이것도 의미가 없다.

 

백엔드 람다측에서 프록시 서버 형식으로 만들어서 헤더 추가 하면 되지 않냐' 라는 생각도 있었지만 그것도 결국 프록시 URL이 노출이 되기 때문에 클라이언트에서 아무런 동작 없이 우회 될 수 있다.

 

사실 제일 안전한 방법으로는 reCAPTCHA를 이용해서 토큰 인증 방식을 사용하는 것이다.

원리 자체가 클라이언트에서 토큰을 생성해서 람다 측 서버로 보내고 람다에서 구글 인증 서버로 보내 토큰 검증이 이루어진 후 유효한 토큰이면 'statusCode': 200을 반환하는 것이다.

 

보안적으로 가장 안전한 방법이라고 생각하지만 너무 느리다..

검증 로직이 애초에 외부 서버를 통해 가져오는 거다 보니 데이터 통신이 너무 느려 게임 진행에 불편함을 느낄 수 있었다.

 

그래서 그냥 사용자들 관리를 위해 미리 만들어놓은 jwt 토큰을 이용해서 DB에 있는 토큰과 비교하여 검증할 수 있는 로직으로 바꾸어 진행하였다.

 

애초에 토큰을 이용해서 통신하는 것은 마찬가지기 때문에 문제 될 건 없다.

 

결국 본인 토큰을 알면 데이터 조작이 가능한 거 아닌가? 

맞다. 모든 헤더와 토큰을 알면 조작이 가능하다.

하지만 애초에 HTTP 요청이 가능한 Curl, Wget 등과 같은 도구는 데이터들을 생성해서 보낼 수 있는 도구이기 때문에 불가피한 것도 있는 것 같다..

 

그래서 본인 아이디의 데이터를 조작하는 것까진 어느 정도 허용을 하지만 토큰을 추가하여 남의 데이터까지 조작하는 것은 막아야 한다는 생각이다.  

 

물론 데이터 조작 자체가 가능하다는 건 그만큼 보안이 허술하다는 것이기에 여러 가지 헤더를 많이 추가하여 최대한 조작이 불가능하게끔 만들어야 한다.

 

아직 보안 쪽은 공부가 부족한 걸 느껴서 다른 솔루션이 있는지 많이 찾아봐야 한다. 

 


느낀 점

이번 프로젝트를 진행하면서 가장 크게 느낀 점은 게임을 만든다는 것은 매우 힘든 일인 것 같다.

물론 게임 개발이 본인 파트는 아니었지만 스토리, 디자인 에셋, 전체적인 게임 콘셉트, 문제 개발, 음악 등등 모든 능력이 다 필요한 분야라 2인 개발이 쉽지는 않았다. 

 

그리고 실제로 배포를 해보고 많은 사용자들이 이용했을 때 발생할 수 있는 솔루션들 또한 어느정도 알 수 있는 계기가 됐었던 것 같다. 현재 워크플로우에는 없는 솔루션이지만 사용자가 많아질 경우 동시 요청에 대한 방안으로 SQS도 적용해볼 수 있을 것 같다.

 

덕분에 새롭게 알아가는 솔루션들도 많이 알았고, 서버리스 솔루션에 대해 좀 더 가까워질 수 있었던 프로젝트였다.

반응형