이 페이지에서 얻을 수 있는 내용: 성장하는 프로젝트의 코드를 설계하여 문제를 줄이고 깔끔하게 확장하는 데 효과적인 전략을 알아보세요. 프로젝트가 성장함에 따라 반복해서 설계를 수정하고 정리해야 합니다. 변경 중인 사항에서 한 걸음 물러나서, 작은 요소로 나누어 정리하고, 다시 통합하는 것이 좋습니다.
유나이트 베를린 강연을위해 우리 팀이 만든 아주 기본적인 퐁(Pong) 스타일 게임의 코드 예제를 살펴보겠습니다. 위 이미지에서 볼 수 있듯이 패들 2개와 벽 4개(상하, 좌우)가 있으며 일부 게임 로직과 점수 UI가 있습니다. 패들뿐만 아니라 벽에도 간단한 스크립트가 적용되었습니다.
이 예제는 다음과 같은 몇 가지 핵심 원칙에 기반을 두고 있습니다.
- 하나의 “항목“ = 하나의 프리팹
- 하나의 “항목“에 대한 맞춤형 로직 = 하나의 MonoBehavior
- 애플리케이션 = 상호 연결된 프리팹을 포함하는 씬
이러한 원칙은 이와 같이 매우 단순한 프로젝트에 효과적이지만, 이를 확장하려면 구조를 변경해야 합니다. 그렇다면 코드를 구성하는 데 사용할 수 있는 전략에는 무엇이 있을까요?
먼저 인스턴스, 프리팹 및 스크립터블 오브젝트의 차이점에 대해 혼동되는 부분을 정리하겠습니다. 위의 이미지는 인스펙터에서 확인한 플레이어 1의 패들 게임 오브젝트에 있는 패들 컴포넌트입니다.
여기에 세 가지 파라미터가 있음을 알 수 있습니다. 그러나, 이 화면에서는 확인하고자 하는 기반 코드가 표시되지 않습니다.
인스턴스의 왼쪽 패들에서 입력축을 변경하는 것이 의미가 있을까요? 아니면 이 작업을 프리팹에서 수행해야 할까요? 입력축은 플레이어마다 다르므로, 아마도 인스턴스에서 변경해야 할 것입니다. 그렇다면 Movement Speed Scale은 어떨까요? 인스턴스와 프리팹 중 어디에서 변경해야 할까요?
패들 컴포넌트를 나타내는 코드를 살펴보겠습니다.
조금만 주의 깊게 생각한다면, 프로그램에서 여러 파라미터가 서로 다른 방식으로 사용되고 있음을 알 수 있습니다. 각 플레이어에 대해 개별적으로 InputAxisName을 변경해야 합니다. MovementSpeedScaleFactor 및 PositionScale은 두 플레이어가 공유해야 합니다. 다음은 언제 인스턴스, 프리팹 및 스크립터블 오브젝트를 사용해야 하는지 안내하는 전략입니다.
- 한 번만 필요한 것이라면 프리팹을 생성한 다음 인스턴스화하세요.
- 여러 번 필요하며 인스턴스와 관련하여 몇 가지 수정이 필요하다면 프리팹을 생성하고 인스턴스화한 다음 일부 설정을 오버라이드하면 됩니다.
- 여러 인스턴스를 동일하게 설정해야 한다면 프리팹 대신 스크립터블 오브젝트와 소스 데이터를 생성하세요.
다음 코드 예제에서 패들 컴포넌트에 스크립터블 오브젝트를 사용하는 방법을 살펴볼 수 있습니다.
이러한 설정을 PaddleData 유형의 스크립터블 오브젝트로 옮겼기 때문에, 패들 컴포넌트에는 해당 PaddleData에 대한 레퍼런스만 있습니다. 인스펙터에는 PaddleData와 두 개의 Paddle 인스턴스가 있습니다. 계속해서 개별적인 패들이 각기 가리키고 있는 공유 설정의 패킷과 축 이름을 변경할 수 있습니다. 새로운 구조를 통해 서로 다른 설정 이면에 있는 의도를 더 쉽게 확인할 수 있습니다.
만일 실제 개발하는 게임이라면, 개별 MonoBehavior가 점점 더 커질 것입니다. 각 클래스가 하나의 것을 처리해야 한다고 규정하는 단일 책임 원칙(Single Responsibility Principle)에 따르면서 MonoBehavior를 분리하는 방법을 살펴보겠습니다. 올바르게 적용한다면 "특정 클래스는 무엇을 합니까?"라는 질문에 짧은 대답을 할 수 있을 것입니다. 뿐만 아니라 "그것은 무엇을 하지 않습니까?" 이를 통해 팀의 모든 개발자가 개별 클래스의 기능을 쉽게 이해할 수 있습니다. 이 원칙은 모든 규모의 코드 기반에 적용할 수 있습니다. 위의 이미지에서 간단한 예제를 살펴보겠습니다.
위 예제는 공에 대한 코드입니다. 잘 보이지는 않지만 자세히 살펴보면 공에는 속도 값을 갖는다는 것을 알 수 있습니다. 이를 통해 디자이너는 공의 초기 속도 벡터를 설정할 수 있고, 직접 만든 물리 시뮬레이션은 현재 공의 속도를 지속해서 트래킹할 수 있습니다.
여기에서는 동일한 변수를 약간 다른 두 가지 용도로 재사용하고 있습니다. 공이 움직이기 시작하면, 초기 속도에 대한 정보가 손실됩니다.
직접 만든 물리 시뮬레이션은 FixedUpdate()의 동작이 아닙니다. 여기에는 공이 벽에 부딪칠 때의 반응도 포함됩니다.
OnTriggerEnter() 콜백 내부에는 Destroy() 연산이 있습니다. 즉, 트리거 로직이 자체 게임 오브젝트를 삭제하는 곳입니다. 규모가 큰 코드 기반에서 엔티티가 스스로 삭제할 수 있도록 허용하는 경우는 매우 드뭅니다. 대신에 소유주가 자신의 소유 항목을 삭제합니다.
여기에서는 더 작은 부분으로 나눌 수 있습니다. 이러한 클래스에는 게임 로직, 입력 처리, 물리 시뮬레이션, 프레젠테이션 등 다양한 종류의 작업이 있습니다.
다음은 작게 나누는 방법입니다.
- 일반적인 게임 로직, 입력 처리, 물리 시뮬레이션 및 프레젠테이션은 MonoBehavior, 스크립터블 오브젝트 또는 원시적인 C# 클래스 내에 있을 수 있습니다.
- Inspector에서 매개변수를 표시하려면, MonoBehavior 또는 스크립터블 오브젝트를 사용할 수 있습니다.
- 엔진 이벤트 핸들러와 GameObject의 수명주기 관리는 MonoBehaviors 내에 있어야 합니다.
대부분의 게임의 경우 MonoBehavior에 최대한 코드를 많이 확보하는 것이 좋을 것입니다. 스크립터블 오브젝트를 사용하는 것이 이를 위한 한 가지 방법이 될 수 있으며, 이 방법을 위한 몇 가지 우수한 리소스가 이미 나와 있습니다.
또 다른 방법으로 코드를 MonoBehavior에서 일반적인 C# 클래스로 이동하는 방법과 그 이점이 무엇인지 살펴보겠습니다.
일반적인 C# 클래스는 코드를 작은 덩어리로 나누어 구성하는 데 Unity 자체 오브젝트보다 우수한 언어 체계를 갖추고 있습니다. 또한 일반적인 C# 코드는 Unity 외부의 .NET 코드 기반과 공유될 수 있습니다.
반면에 일반적인 C# 클래스를 사용하면, 편집기가 객체를 이해하지 못하고 Inspector에 네이티브 형식으로 표시할 수 없게 되는 등의 문제가 발생할 수 있습니다.
이 방법을 사용하면 작업 유형별로 로직을 나눌 수 있습니다. 다시 공의 예제를 살펴보면, BallSimulation이라고 하는 간단한 물리 시뮬레이션을 C# 클래스로 옮겼습니다. 이 시뮬레이션이 하는 일은 물리 통합과 공이 무언가를 칠 때마다 반응하는 것입니다.
하지만 공 시뮬레이션이 공이 실제로 무엇을 쳤는지에 따라 결정을 내린다는 점이 이해하기 어려울 수 있습니다. 이는 게임 로직에 더 가깝습니다. 결론적으로 공에는 어떤 면에서 시뮬레이션을 제어하는 로직이 있으며, 시뮬레이션 결과는 MonoBehavior로 피드백된다고 볼 수 있습니다.
위에서 재구성된 버전을 살펴보면 중요한 변화를 볼 수 있는데, Destroy() 연산이 많은 레이어에 더는 파묻혀 있지 않습니다. 이 시점에 MoneBehavior에는 단 몇 가지의 분명한 담당 영역이 있습니다.
이에 대해 할 수 있는 많은 일이 있습니다. FixedUpdate()에서 포지션 업데이트 로직을 살펴보면, 코드를 한 위치에서 보내면 거기에서 새로운 포지션을 반환해야 한다는 사실을 알 수 있습니다. 공 시뮬레이션은 실제로 공의 위치를 갖고 있지 않습니다. 즉, 제공된 공의 위치를 기반으로 시뮬레이션 틱을 실행한 다음 결과를 반환합니다.
인터페이스를 사용하는 경우 MonoBehavior에 있는 공의 일부 중 필요한 부분(위 이미지 참조)만을 시뮬레이션과 공유할 수 있습니다.
코드를 다시 살펴보겠습니다. Ball 클래스는 간단한 인터페이스를 구현합니다. LocalPositionAdapter 클래스를 사용하면 Ball 오브젝트에 대한 레퍼런스를 다른 클래스로 넘길 수 있습니다. 이때 Ball 오브젝트 전체를 넘겨주지 않고, LocalPositionAdapter 부분만 전달합니다.
또한 BallLogic은 게임 오브젝트를 제거할 시점이 되면 Ball에 이를 알려야 합니다. 플래그를 반환하는 대신에 Ball은 BallLogic에 델리게이트를 제공할 수 있습니다. 이것이 재구성된 버전에서 마지막에 표시된 행이 하는 일입니다. 이 예시는 잘 정돈된 설계를 보여줍니다. 다양한 보일러 플레이트 로직이 있지만, 각 클래스는 세부적으로 정의된 목적을 가지고 있습니다.
이 원칙을 잘 사용하면 1인 프로젝트를 잘 구성할 수 있을 것입니다.
규모가 조금 더 큰 프로젝트를 위한 소프트웨어 아키텍처 솔루션을 살펴보겠습니다. 공 게임의 예제에서 BallLogic, BallSimulation 등의 더 구체적인 클래스를 코드에 추가하기 시작한다면, 계층을 생성할 수 있어야 합니다.
MonoBehaviour는 다른 모든 로직을 마무리하기 때문에 모든 것을 알 필요가 있지만, 게임의 시뮬레이션은 로직이 어떻게 작동하는지 반드시 알아야 할 필요는 없습니다. 시뮬레이션을 실행할 뿐입니다. 때때로 로직이 신호를 시뮬레이션에 제공하기 때문에 시뮬레이션이 적절하게 반응하는 것입니다.
입력을 별도의 독립적인 장소에서 처리하는 것이 좋습니다. 바로 이곳에서 입력 이벤트가 생성되며 로직에 이를 공급합니다. 그다음은 시뮬레이션에 달려 있습니다.
이 방법은 입력 및 시뮬레이션에 적합합니다. 하지만 프레젠테이션과 관련된 문제, 예를 들어 특수 효과를 생성하는 로직, 점수 집계 업데이트 등에 문제가 발생할 가능성이 있습니다.
프레젠테이션은 다른 시스템에서 어떤 일이 벌어지고 있는지 알 필요가 있지만, 해당 시스템에 모두 액세스할 필요는 없습니다. 가능하다면 로직과 프레젠테이션을 분리하세요. 로직만 가능한 모드와 로직 및 프레젠테이션이 모두 가능한 모드에서 코드 기반을 실행할 수 있는 위치를 확보해 보세요.
프레젠테이션이 적시에 업데이트되도록 로직과 프레젠테이션을 연결해야 할 때도 있습니다. 하지만 이 경우에도 올바르게 표시하는 데 필요한 것만 프레젠테이션에 제공해야 합니다. 이렇게 하면 두 부분 간에 자연스러운 경계가 생겨 제작하는 게임의 복잡도를 전체적으로 줄일 수 있습니다.
때로는 데이터로 수행할 수 있는 모든 로직 및 연산을 같은 클래스에 통합하지 않고, 해당 데이터만 포함하는 클래스를 갖는 것이 좋습니다.
또한 데이터는 소유하지 않지만 부여된 객체를 조작하는 함수는 포함하는 클래스를 만드는 것이 좋습니다.
전역 변수를 건드리지 않는다고 가정했을 때 정적 메서드의 장점은 메서드를 호출할 때 인수로 전달되는 내용을 살펴봄으로써 해당 메서드가 잠재적으로 영향을 미치는 범위를 식별할 수 있다는 점입니다. 메서드의 구현을 살펴볼 필요가 전혀 없습니다.
이 접근법은 기능 프로그래밍 분야를 다룹니다. 여기서 핵심은 함수에 무언가를 보내면, 함수는 결과를 반환하거나 반환 파라미터 중 하나를 수정한다는 점입니다. 이 방법을 사용하면 전형적인 객체 지향 프로그래밍을 할 때보다 버그가 적게 발생할 수 있습니다.
오브젝트 사이에 접착 로직을 삽입하여 오브젝트를 분리할 수도 있습니다. Pong 스타일의 예제 게임을 다시 보면, 공 로직과 점수 프레젠테이션이 서로 어떻게 이야기할까요? 공과 관련하여 무언가가 발생하면 공 로직이 점수 프레젠테이션에 정보를 주는 것일까요? 점수 로직이 공 로직에 쿼리하는 것일까요? 이 둘은 어떻게든 서로 정보를 교환해야 합니다.
이를 위해 로직이 항목을 만들고 프레젠테이션이 해당 항목을 읽을 수 있는 스토리지 영역을 제공하는 것이 유일한 목적인 버퍼 오브젝트를 생성할 수 있습니다. 또는 로직 시스템이 대기열에 항목을 넣을 수 있고 프레젠테이션은 대기열에서 나오는 내용을 읽을 수 있도록 항목 간에 대기열을 만들 수 있습니다.
게임 규모가 커짐에 따라 로직과 프레젠테이션을 분리하는 경우, 메시지 버스를 사용하는 것도 좋은 방법입니다. 메시징의 핵심 원칙은 수신자도 발신자도 상대방에 대해 알지 못하지만 메시지 버스/시스템은 인식하고 있다는 것을 양 쪽이 모두 안다는 것입니다. 따라서 점수 프레젠테이션은 점수를 변경하는 모든 이벤트에 대해 메시징 시스템에서 정보를 받아야 합니다. 그러면 게임 로직은 플레이어의 포인트 변화를 나타내는 이벤트를 메시징 시스템에 게시합니다. 시스템들을 분리하고 싶다면, UnityEvents를 활용해서 시작하거나 직접 작성해볼 수도 있습니다. 시스템을 분리하면 별도의 목적으로 별도의 버스를 이용할 수 있게 됩니다.
LoadSceneMode.Single을 사용하지 말고 대신 LoadSceneMode.Additive를 사용하는 것이 좋습니다.
씬을 언로드할 때는 명시적 언로드를 사용해야 합니다. 결국에는 씬을 전환할 때 일부 오브젝트를 유지해야 하기 때문입니다.
DontDestroyOnLoad도 사용하지 않는 것이 좋습니다. 이를 사용하면 오브젝트의 수명을 제어할 수 없습니다. 실제로 LoadSceneMode.Additive를 사용해 로딩할 경우, DontDestroyOnLoad를 사용할 필요가 없습니다. 대신 오래 사용해야 하는 오브젝트를 오랜 기간 유지되는 특별 씬에 넣는 것이 더 좋습니다.
제가 작업한 모든 게임에서 유용했던 팁을 추가하자면, 깔끔하고 제어된 종료를 지원하는 것입니다.
애플리케이션이 종료되기 전에 사실상 모든 리소스를 릴리스하도록 해야 합니다. 가능하다면 전역 변수를 지정하지 말고, 어떤 게임 오브젝트도 DontDestroyOnLoad로 표시해서는 안됩니다.
항목들을 종료하는 특정한 순서가 있을 때, 오류를 찾아내고 리소스 유출을 찾는 것이 더 쉽습니다. 이렇게 하면 플레이 모드를 종료할 때 양호한 상태로 Unity 에디터를 나갈 수 있습니다. Unity는 플레이 모드를 종료할 때 전체 도메인을 다시 로드하지 않습니다. 게임을 깔끔하게 종료한다면, 에디터에서 게임을 실행한 후에 해당 에디터나 모든 종류의 편집 모드 스크립팅에서 이상 동작이 나타날 가능성이 작아집니다.
Git, PerForce, Plastic과 같은 버전 관리 시스템을 사용하면 씬 파일 병합으로 인한 번거로움을 줄일 수 있습니다. 모든 에셋을 텍스트로 저장하고 오브젝트를 씬 파일에서 프리팹으로 이동시킵니다. 마지막으로 씬 파일을 여러 개의 작은 씬으로 나누면 됩니다. 단, 추가 툴이 필요할 수 있습니다.
예를 들어 10명이 넘는 사람들이 한 팀에서 일하게 된다면, 프로세스 자동화에 대한 작업을 해야 합니다.
창의적인 프로그래머라면 독창적이고 신중한 작업을 하고 가능한 반복적인 부분은 최대한 자동화하도록 해야 합니다.
먼저 코드를 테스트하는 로직을 만들어보시기 바랍니다. 특히 MonoBehaviour에서 일반 클래스로 이동하는 경우, 로직 및 시뮬레이션 모두에 대한 단위 테스트를 작성하기 위해서 단위 테스트 프레임워크를 사용하는 방법이 간단합니다. 아무 곳에나 해당하는 것은 아니지만, 나중에 다른 프로그래머가 코드에 액세스할 수 있도록 합니다.
코드만 테스트가 필요한 것은 아닙니다. 콘텐츠도 테스트가 필요할 수 있습니다. 팀에 콘텐츠 크리에이터가 있는 경우, 제작하는 콘텐츠를 신속하게 검증할 수 있는 표준화된 방법을 갖추는 것이 좋습니다.
프리팹의 유효성 검사 또는 커스텀 에디터를 통해 입력한 데이터의 유효성 검사와 같은 테스트 로직은 콘텐츠 크리에이터가 쉽게 사용할 수 있어야 합니다. 에디터에서 버튼 클릭 한 번으로 신속하게 유효성 검사를 하는 것이 곧 시간을 절약하는 길입니다.
이 다음 단계는 Unity 테스트 러너를 설정해 정기적으로 자동 재검사를 하는 것입니다. 이때 Unity 테스트 러너를 빌드 시스템의 일부로 설정해 모든 테스트를 실행하도록 하세요. 알림을 설정해 문제가 실제로 발생하면 팀원들이 Slack 또는 이메일 알림을 받도록 하는 것이 가장 좋습니다.