로블록스 프로젝트 개발 회고록 #Proejct 2205

이번 프로젝트에서는 Roblox를 기반으로 하나의 월드 안에 여러 콘텐츠를 구현했다.

처음부터 콘텐츠가 이렇게 많았던 것은 아니다. 기본적인 월드와 플레이어 이동부터 만들고 NPC와 대화하는 기능, 퀘스트 정도를 붙이는 것으로 시작했다. 이후 상점, 아이템, 탈것, 미니게임, 방탈출 같은 기능이 하나씩 추가됐다.

기능만 보면 각각 크게 어려운 것은 아니었다.

문제는 서로 연결되기 시작하면서부터였다.

NPC에서 퀘스트를 시작하고, 퀘스트를 진행하면서 아이템을 얻고, 아이템을 사용하거나 상점에서 다른 아이템을 구매하고, 미니게임 결과가 다시 보상으로 이어지는 식이었다. 하나의 기능만 보고 있을 때와 여러 기능을 이어서 플레이할 때의 문제가 달랐다.

초반에는 일단 동작하게 만드는 데 집중했다.

NPC 하나에 대화 기능을 넣고 퀘스트를 연결하는 정도라면 해당 NPC에서 직접 처리해도 별 문제가 없었다. 그런데 비슷한 NPC가 계속 생기면서 같은 코드가 조금씩 복사되기 시작했다.

그때부터 구조를 신경 쓰기 시작했다.

처음부터 완성된 아키텍처를 만들고 시작한 프로젝트는 아니었다. 실제로 개발하면서 불편했던 부분을 찾아서 하나씩 정리했다. 지금 생각하면 이쪽이 오히려 현실적인 개발 과정에 가까웠다.

NPC와 상호작용

월드에서 NPC와 대화하는 것 외에도 여러 오브젝트를 조사하거나 특정 물체를 사용하는 기능이 필요했다.

처음에는 각각 따로 만들었다.

NPC는 클릭하면 대화창을 열고, 방탈출 오브젝트는 해당 오브젝트에 맞는 스크립트를 붙였다. 당시에는 오브젝트 수가 많지 않아서 크게 문제가 되지 않았다.

콘텐츠가 늘어나면서 조금씩 불편해졌다.

비슷한 상호작용인데 어떤 것은 클릭으로 처리하고 어떤 것은 다른 방식으로 처리하고 있었다. 나중에 같은 기능을 수정하려고 하면 관련 스크립트를 여러 개 찾아야 했다.

그래서 상호작용 가능한 오브젝트를 Tag로 구분하고, 세부적인 정보는 Attribute를 이용하는 방향으로 바꿨다.

상호작용을 감지하는 부분과 실제로 무엇을 할지를 나누는 방식이다.

NPC를 만났다면 대화를 열고, 퀘스트를 가지고 있는 NPC라면 퀘스트 시스템을 호출한다. 방탈출 오브젝트라면 해당 오브젝트의 상태를 확인한다.

이렇게 바꾸고 나서는 새로운 NPC나 오브젝트를 추가할 때마다 상호작용 코드를 새로 만들 필요가 없어졌다.

무엇보다 나중에 상호작용 자체의 동작을 수정할 때 여러 콘텐츠를 찾아다니지 않아도 됐다.

NPC와 퀘스트

NPC는 대화만 하는 것이 아니라 퀘스트를 시작하는 역할도 맡았다.

NPC에 어떤 퀘스트와 연결되어 있는지 식별할 수 있는 값을 넣고, 플레이어가 대화를 요청하면 서버에 전달했다.

여기서 개발하면서 생각이 조금 바뀌었다.

처음에는 일부 상태를 클라이언트에서도 가지고 있었다. 구현 자체는 편했다. UI에서 바로 값을 확인할 수 있고 처리도 간단했다.

그런데 퀘스트가 많아지면서 이 방식이 점점 마음에 들지 않았다.

퀘스트 진행도나 보상처럼 실제 게임 결과에 영향을 주는 값을 클라이언트가 가지고 있으면 기준이 애매해진다. 특히 멀티플레이 환경에서는 클라이언트가 보낸 값을 그대로 믿으면 안 된다.

그래서 실제 퀘스트 상태는 서버에서 관리하도록 정리했다.

클라이언트는 NPC와 상호작용했다는 것과 어떤 퀘스트를 요청했는지를 전달한다. 해당 퀘스트가 실제로 존재하는지, 시작할 수 있는지, 현재 플레이어의 상태가 어떤지는 서버에서 확인한다.

퀘스트가 완료된 뒤 UI를 보여주거나 효과를 재생하는 것은 클라이언트가 담당한다.

이렇게 나누고 나니 코드가 조금 더 복잡해진 부분도 있었지만 역할 자체는 훨씬 명확해졌다.

퀘스트 시스템

퀘스트는 여러 콘텐츠를 연결하는 역할을 했다.

NPC와 대화해서 퀘스트를 시작하고, 특정 아이템을 얻거나 오브젝트와 상호작용하면서 진행도를 올리는 방식이었다.

초기에는 퀘스트마다 완료 조건을 직접 처리했다.

A 퀘스트는 아이템을 얻었을 때 확인하고, B 퀘스트는 NPC와 대화했을 때 확인하는 식이었다. 퀘스트가 몇 개 없을 때는 오히려 빠른 방법이었다.

문제는 숫자가 늘어나면서 생겼다.

비슷한 조건을 확인하는 코드가 여기저기 생겼고, 퀘스트 하나를 수정하려면 다른 콘텐츠까지 확인해야 하는 경우가 있었다.

그래서 퀘스트의 정의와 실제 플레이어의 진행 상태를 분리했다.

퀘스트 이름이나 필요한 아이템, 목표 개수, 보상 같은 고정 정보는 별도의 데이터로 관리하고 플레이어가 어떤 퀘스트를 진행 중인지, 현재 몇 단계까지 왔는지는 퀘스트 관리 쪽에서 처리했다.

새 퀘스트를 추가할 때 기존 로직을 크게 수정하지 않아도 되는 구조가 됐다.

처음부터 데이터 중심 구조를 만들려고 했던 것은 아니다. 만들다 보니 같은 작업을 계속 반복하게 돼서 바꾼 것이다.

방탈출 콘텐츠

방탈출은 다른 콘텐츠보다 상태를 많이 가지고 있었다.

방 안에 단서를 배치하고 플레이어가 직접 조사한다. 특정 단서를 확인하면 다음 단계로 넘어가고, 필요한 조건을 모두 만족하면 문이 열리는 식이다.

처음에는 순서를 어느 정도 정해놓고 플레이하면 된다고 생각했다.

실제로는 그렇게 움직이지 않았다.

같은 단서를 여러 번 조사할 수도 있고, 이미 완료된 상태에서 다시 상호작용이 들어올 수도 있다. 문이 열린 뒤에도 관련 이벤트가 다시 호출되는 경우가 있었다.

테스트하면서 이런 부분을 하나씩 확인했다.

이미 조사한 단서인지 확인하고, 완료된 상태에서는 같은 처리를 다시 하지 않도록 했다. 문도 한번 열린 뒤에는 다시 열기 과정을 수행하지 않도록 상태를 관리했다.

방탈출을 만들면서 느낀 것은 퍼즐 자체보다 상태 관리가 더 중요하다는 점이었다.

개발자가 생각한 정상적인 플레이 순서대로만 움직인다는 보장이 없었다. 플레이어가 먼저 다른 물체를 조사하거나 같은 물체를 계속 누르는 상황까지 어느 정도는 처리해야 했다.

미니게임

메인 월드와 별도로 동작하는 미니게임도 구현했다.

미니게임에 들어가면 플레이어별 점수와 시작 시간을 가지고 진행하고, 종료하면 결과를 정리해서 메인 월드로 돌아오는 방식이었다.

처음에는 게임 로직과 점수만 정상적으로 처리하면 끝이라고 생각했다.

그런데 테스트하면서 오히려 종료 쪽에서 문제가 나왔다.

게임은 끝났는데 UI가 남아 있거나, 미니게임에서 사용하던 입력이 계속 남아 있는 경우가 있었다. 로직은 종료됐지만 플레이어 입장에서는 아직 미니게임이 끝나지 않은 것처럼 보이는 상황이었다.

이후에는 시작할 때 변경한 상태를 종료할 때 다시 돌려놓는 것을 같이 확인했다.

미니게임에 들어가면서 특정 UI를 열었다면 나올 때 닫는다. 입력 방식을 바꿨다면 다시 원래 상태로 돌린다.

단순한 내용이지만 이후 다른 콘텐츠를 만들 때도 계속 신경 쓰게 됐다.

기능을 활성화하는 부분만 만들고 끝내는 것보다 종료했을 때 무엇이 남는지를 확인하는 편이 훨씬 중요했다.

탈것

월드가 넓어서 플레이어가 이동할 수 있는 탈것도 구현했다.

처음에는 이동 기능부터 생각했다.

그런데 실제로 만들고 보니 이동 자체보다 탑승과 하차 상태를 처리하는 일이 더 번거로웠다.

탈것에 탑승하면 입력 방식이 달라지고 카메라도 변경된다. 하차하면 플레이어가 다시 일반적인 상태로 돌아와야 한다.

처음 구현했을 때는 이동은 정상적으로 됐는데 하차한 뒤 카메라가 남아 있거나 특정 입력이 계속 활성화되는 문제가 있었다.

그래서 탈것을 단순한 이동 기능으로 두지 않고 하나의 상태 전환으로 생각했다.

탑승하면서 무엇이 바뀌는지를 정리하고, 하차할 때 그 변경 사항을 다시 복구하는 식으로 수정했다.

이 문제를 겪고 나서는 탈것뿐만 아니라 다른 콘텐츠에서도 상태를 변경하는 기능을 만들 때 종료 시점을 같이 확인하게 됐다.

상점과 아이템

상점에서는 플레이어가 가진 재화로 아이템을 구매할 수 있도록 했다.

아이템 이름이나 가격처럼 고정된 정보는 별도의 데이터로 관리했고, 실제 플레이어가 가진 재화와 아이템은 서버에서 관리했다.

상점 UI에서는 아이템 목록과 가격을 보여준다.

하지만 구매 버튼을 눌렀다고 바로 아이템을 지급하지는 않았다.

클라이언트에서 구매 요청을 보내면 서버에서 해당 아이템이 실제로 존재하는지, 플레이어가 충분한 재화를 가지고 있는지를 다시 확인한다. 조건이 맞으면 재화를 차감하고 아이템을 지급한다.

이 부분은 멀티플레이를 고려하면 당연한 구조에 가깝지만, 개발 초반에는 지금보다 구분이 덜 되어 있었다.

UI에서 보여주는 값과 실제 게임 상태가 섞이면 나중에 문제가 생겼을 때 추적하기 어렵다.

그래서 점점 클라이언트와 서버의 역할을 명확하게 나눴다.

클라이언트는 상점을 보여주고 구매 요청을 보낸다.

실제 재화 차감과 아이템 지급은 서버가 한다.

모바일 대응

모바일 대응은 마지막에 따로 붙인 기능이라기보다는 개발하면서 계속 확인했던 부분이다.

PC에서는 키보드와 마우스를 기준으로 만들면 되지만 모바일에서는 터치 입력을 사용한다.

처음부터 PC용 로직과 모바일용 로직을 각각 만들지는 않았다. 그렇게 하면 같은 기능을 수정할 때 두 곳을 같이 수정해야 하기 때문이다.

입력을 받는 부분만 플랫폼에 맞게 처리하고 실제 게임플레이 동작은 공유하는 방향으로 구성했다.

PC에서 키를 누르거나 모바일 버튼을 누르는 것은 입력 방식만 다르다. 실제 상호작용이나 탈것 탑승 같은 동작은 같은 시스템을 사용하도록 했다.

이 구조는 나중에 수정할 때 꽤 편했다.

반면 UI는 생각보다 손이 많이 갔다.

PC에서 적당해 보이던 버튼이 모바일에서는 작게 느껴지거나, 여러 UI가 동시에 나오면서 화면을 가리는 문제가 있었다.

에디터에서 해상도만 맞춰보는 것과 실제 모바일에서 플레이하는 것은 차이가 있었다.

그래서 모바일은 기능을 추가할 때마다 직접 플레이하면서 확인했다.

특히 입력과 UI는 실제로 움직여보지 않으면 놓치는 부분이 많았다.

클라이언트와 서버

개발하면서 계속 기준을 잡아야 했던 부분이다.

초반에는 빠르게 기능을 만들다 보니 클라이언트에서 처리하는 상태가 조금씩 늘어났다. 화면에 필요한 데이터와 실제 게임 상태가 섞이기 시작하면 나중에 무엇을 믿어야 하는지가 애매해진다.

그래서 프로젝트가 진행되면서 역할을 나눴다.

UI, 카메라, 로컬 이펙트 같은 플레이어 화면과 직접 관련된 부분은 클라이언트에서 처리했다.

퀘스트 진행도, 인벤토리, 재화, 보상처럼 게임 결과에 영향을 주는 값은 서버에서 관리했다.

입력 역시 클라이언트가 받지만 입력을 받았다는 이유만으로 중요한 게임 상태를 바로 바꾸지는 않았다.

상점에서 구매 버튼을 누르는 경우를 생각하면 간단하다.

클라이언트는 구매 요청을 보낸다. 서버가 재화를 확인하고 실제 구매가 가능한 경우에만 처리한다.

퀘스트도 비슷하다.

클라이언트는 플레이어가 어떤 행동을 했는지를 전달하고 실제 진행 상태는 서버가 판단한다.

이렇게 나누고 나니 문제가 생겼을 때 확인할 범위도 줄었다. UI 문제인지, 입력 문제인지, 서버 상태 문제인지 구분하기가 이전보다 쉬웠다.

콘텐츠가 늘면서 바뀐 구조

돌이켜보면 이 프로젝트에서 가장 많이 바뀐 것은 NPC나 상점 같은 개별 기능이 아니었다.

기능 사이를 연결하는 부분이었다.

초기에는 NPC에서 퀘스트 상태를 직접 변경하고, 상점에서 플레이어 데이터를 직접 수정하는 식으로 빠르게 만들었다.

한두 개의 기능만 있을 때는 이게 더 편하다.

그런데 콘텐츠가 늘면 얘기가 달라진다.

NPC도 여러 개가 되고 퀘스트도 늘어난다. 상점과 아이템이 연결되고 미니게임 결과가 보상으로 이어진다. 방탈출의 상태가 퀘스트와 연결되기도 한다.

이때부터 한 시스템이 다른 시스템의 내부 데이터를 직접 건드리는 구조가 부담이 됐다.

그래서 반복되는 부분부터 분리했다.

NPC는 퀘스트 시스템을 호출하고, 상점은 인벤토리와 플레이어 데이터 관리 쪽을 통해 값을 변경하도록 했다. 미니게임 결과도 필요한 경우 보상 처리와 연결할 수 있게 정리했다.

모든 것을 거대한 하나의 시스템으로 합친 것은 아니다.

오히려 각 기능이 자기 역할을 가지고 필요한 부분만 서로 호출하도록 만드는 쪽에 가까웠다.

그리고 이런 구조는 처음부터 계획한 것이 아니라 실제로 코드가 불편해진 뒤에 만들어졌다.

테스트하면서 발견한 문제

기능 하나씩 테스트하면 대부분 정상적으로 동작했다.

NPC 대화도 되고, 상점에서 구매도 되고, 미니게임도 실행됐다.

그런데 실제 플레이 순서대로 진행하면 이야기가 달랐다.

NPC와 대화해서 퀘스트를 시작하고 아이템을 얻은 다음 상점을 이용하고 미니게임에 들어갔다가 방탈출로 넘어가는 식으로 플레이하면 앞에서 변경한 상태가 뒤쪽 콘텐츠에 영향을 주는 경우가 있었다.

미니게임 UI가 남아 있거나 탈것에서 내린 뒤 입력 상태가 복구되지 않는 문제도 이런 테스트에서 발견했다.

그래서 후반에는 기능별 테스트만 하기보다 실제 플레이 흐름을 처음부터 끝까지 따라가는 테스트를 많이 했다.

특히 상태가 변경되는 기능은 시작과 종료를 같이 봤다.

PC에서 처음부터 끝까지 플레이한 뒤 모바일에서도 같은 과정을 다시 진행했다.

모바일은 입력 방식과 화면 구성이 다르기 때문에 PC에서는 발견하지 못한 문제가 나왔다.

결국 테스트하면서 중요하게 본 것은 단순히 “기능이 작동하는가”가 아니었다.

그 기능을 사용한 다음 다른 기능으로 넘어갔을 때 이전 상태가 남아 있지는 않은지. 이쪽이 생각보다 중요했다.

마무리

처음에는 기능 하나를 완성하는 것에 집중했다.

NPC를 만들고 퀘스트를 연결하고, 다음에는 상점을 만들고, 그 다음에는 미니게임을 붙이는 식이었다.

그런데 콘텐츠가 늘어나니까 개별 기능보다 기능 사이의 관계가 더 중요해졌다.

퀘스트는 NPC와 연결되고, 퀘스트에는 아이템이 필요하고, 아이템은 상점과 연결된다. 미니게임 결과가 보상으로 이어질 수도 있고 방탈출 상태가 퀘스트 진행에 영향을 줄 수도 있다.

하나씩 보면 단순한 기능인데 전부 연결하면 관리해야 할 상태가 많아진다.

개발하면서 반복되는 부분은 공통화했고, 실제 게임 결과에 영향을 주는 값은 서버에서 관리하도록 정리했다. 클라이언트는 UI나 카메라, 입력, 연출 같은 부분에 집중시켰다.

모바일도 별도의 게임 로직을 만드는 대신 입력만 플랫폼에 맞게 처리하고 실제 동작은 공유하는 방향으로 갔다.

가장 크게 남은 것은 특정 기술을 사용했다는 사실보다는 구조를 고정해서 생각하지 않게 된 것이다.

처음에는 빠르게 만드는 게 중요했다.

그런데 프로젝트가 커지면서 빠르게 만든 코드가 오히려 개발 속도를 떨어뜨리기 시작했다. 그때는 구조를 다시 손보는 게 맞았다.

처음부터 완벽한 구조를 만드는 것도 좋지만 실제 개발에서는 만들면서 문제가 보이는 경우가 많다.

이번 프로젝트도 비슷했다.

일단 만들고, 테스트하고, 불편한 부분을 찾고, 그 부분만 다시 바꿨다.

결과적으로 처음 만들었던 구조와 마지막 구조가 꽤 달라졌다.

개인적으로는 그게 오히려 이번 프로젝트에서 얻은 가장 큰 경험이었다.