
이 페이지에서 얻을 수 있는 것: 성장하는 프로젝트의 코드를 설계하기 위한 효과적인 전략으로, 문제를 줄이면서 깔끔하게 확장할 수 있습니다. 프로젝트가 성장함에 따라 반복해서 설계를 수정하고 정리해야 합니다. 변경 사항에서 한 걸음 물러나고, 작은 요소로 나누어 정리한 다음, 다시 모두 함께 조립하는 것이 항상 좋습니다.
이 기사는 스웨덴 게임 스튜디오 Fall Damage의 CTO인 미카엘 칼름스가 작성했습니다. 미카엘은 20년 넘게 게임 개발 및 출시 관련 일을 해왔습니다. 이 모든 시간이 지나도 그는 프로젝트가 안전하고 효율적으로 성장할 수 있도록 코드를 설계하는 데 여전히 깊은 관심을 가지고 있습니다.

내 팀이 만든 매우 기본적인 Pong 스타일 게임의 코드 예제를 살펴보겠습니다. 이는 제 Unite Berlin 발표를 위한 것입니다. 위의 이미지에서 볼 수 있듯이, 두 개의 패들과 네 개의 벽이 있습니다. 위와 아래, 왼쪽과 오른쪽에 게임 로직과 점수 UI가 있습니다. 패들과 벽에 간단한 스크립트가 있습니다.
이 예제는 다음과 같은 몇 가지 핵심 원칙에 기반을 두고 있습니다.
이러한 원칙은 이와 같은 매우 간단한 프로젝트에 적용되지만, 우리가 이것을 성장시키고 싶다면 구조를 변경해야 합니다. 그렇다면 코드를 구성하기 위해 사용할 수 있는 전략은 무엇인가요?

우선, 인스턴스, 프리팹 및 스크립터블 오브젝트 간의 차이에 대한 혼란을 없애봅시다. 위는 플레이어 1의 패들 게임 오브젝트에서 패들 컴포넌트를 인스펙터에서 본 것입니다:
그 위에는 세 개의 매개변수가 있습니다. 그러나 이 보기에서는 기본 코드가 나에게 기대하는 것이 무엇인지에 대한 어떤 표시도 없습니다.
왼쪽 패들의 Input Axis를 인스턴스에서 변경하여 바꾸는 것이 의미가 있나요, 아니면 프리팹에서 그렇게 해야 하나요? 입력축은 플레이어마다 다르므로, 아마도 인스턴스에서 변경해야 할 것입니다. Movement Speed Scale는 어떻습니까? 그것은 인스턴스에서 변경해야 할까요, 아니면 프리팹에서 변경해야 할까요?
패들 컴포넌트를 나타내는 코드를 살펴보겠습니다.

잠시 멈추고 생각해보면, 서로 다른 매개변수가 프로그램에서 서로 다른 방식으로 사용되고 있음을 깨닫게 될 것입니다. 각 플레이어에 대해 InputAxisName을 개별적으로 변경해야 합니다: MovementSpeedScaleFactor와 PositionScale은 두 플레이어가 공유해야 합니다. 인스턴스, 프리팹 및 스크립터블 오브젝트를 사용할 시기를 안내할 수 있는 전략은 다음과 같습니다:
다음 코드 예제에서 패들 컴포넌트와 함께 스크립터블 오브젝트를 사용하는 방법을 확인하세요.

이 설정을 PaddleData 유형의 스크립터블 오브젝트로 이동했기 때문에, 우리는 패들 컴포넌트에서 해당 PaddleData에 대한 참조만 가지고 있습니다. 인스펙터에는 PaddleData와 두 개의 Paddle 인스턴스가 있습니다. 계속해서 개별적인 패들이 각기 가리키고 있는 공유 설정의 패킷과 축 이름을 변경할 수 있습니다. 새로운 구조는 다양한 설정 뒤에 있는 의도를 더 쉽게 볼 수 있게 해줍니다.

이것이 실제 개발 중인 게임이라면, 개별 MonoBehavior가 점점 더 커지는 것을 볼 수 있을 것입니다. 각 클래스가 하나의 것을 처리해야 한다고 규정하는 단일 책임 원칙(Single Responsibility Principle)에 따르면서 MonoBehavior를 분리하는 방법을 살펴보겠습니다. 올바르게 적용된다면, "특정 클래스는 무엇을 합니까?"와 "무엇을 하지 않습니까?"라는 질문에 짧은 답변을 할 수 있어야 합니다. 이것은 팀의 모든 개발자가 개별 클래스가 무엇을 하는지 이해하기 쉽게 만듭니다. 이 원칙은 모든 규모의 코드 기반에 적용할 수 있습니다. 위의 이미지에 표시된 간단한 예제를 살펴보겠습니다.
이것은 공에 대한 코드입니다. 많아 보이지 않지만, 자세히 살펴보면 공이 초기 속도 벡터를 설정하기 위해 디자이너에 의해 사용되고, 자작 물리 시뮬레이션에 의해 현재 공의 속도를 추적하는 데 사용되는 속도를 가지고 있음을 알 수 있습니다.
우리는 두 가지 약간 다른 목적을 위해 동일한 변수를 재사용하고 있습니다. 공이 움직이기 시작하면 초기 속도에 대한 정보가 사라집니다.
직접 만든 물리 시뮬레이션은 FixedUpdate()의 동작이 아닙니다. 여기에는 공이 벽에 부딪칠 때의 반응도 포함됩니다.
OnTriggerEnter() 콜백 내부 깊숙한 곳에 Destroy() 작업이 있습니다. 즉, 트리거 로직이 자체 게임 오브젝트를 삭제하는 곳입니다. 대규모 코드베이스에서는 엔티티가 스스로를 삭제하도록 허용하는 경우는 드물며, 대신 소유자가 자신이 소유한 것을 삭제하는 경향이 있다.
여기에서 사물을 더 작은 부분으로 나눌 기회가 있다. 이 클래스에는 여러 가지 책임 유형이 있다 - 게임 로직, 입력 처리, 물리 시뮬레이션, 프레젠테이션 등.
다음은 작게 나누는 방법입니다.
많은 게임에 대해 가능한 한 많은 코드를 MonoBehaviors에서 빼내는 것이 가치가 있다고 생각한다. 그 방법 중 하나는 ScriptableObjects를 사용하는 것이며, 이 방법에 대한 훌륭한 자료가 이미 있다.

MonoBehaviors를 일반 C# 클래스로 이동하는 것은 또 다른 방법이지만, 이 방법의 이점은 무엇인가?
일반 C# 클래스는 코드를 작고 조합 가능한 조각으로 나누는 데 있어 Unity의 객체보다 더 나은 언어 기능을 가지고 있다. 그리고 일반 C# 코드는 Unity 외부의 네이티브 .NET 코드베이스와 공유할 수 있다.
반면에 일반적인 C# 클래스를 사용하면, 편집기가 객체를 이해하지 못하고 Inspector에 네이티브 형식으로 표시할 수 없게 되는 등의 문제가 발생할 수 있습니다.
이 방법을 사용하면 책임 유형에 따라 로직을 나누고자 한다. 다시 공의 예제를 살펴보면, BallSimulation이라고 하는 간단한 물리 시뮬레이션을 C# 클래스로 옮겼습니다. 그것이 해야 할 유일한 작업은 물리 통합과 공이 무언가에 부딪힐 때 반응하는 것이다.
그러나 공 시뮬레이션이 실제로 부딪히는 것에 따라 결정을 내리는 것이 의미가 있는가? 이는 게임 로직에 더 가깝습니다. 결국 우리는 Ball이 어떤 방식으로든 시뮬레이션을 제어하는 로직 부분을 가지고 있으며, 그 시뮬레이션의 결과가 MonoBehavior로 피드백된다는 것을 알게 된다.
위의 재구성된 버전을 보면, 우리가 보는 중요한 변화 중 하나는 Destroy() 작업이 더 이상 여러 층 아래에 묻혀 있지 않다는 것이다. 현재 MoneBehavior에는 몇 가지 명확한 책임 영역만 남아 있다.
우리는 이 작업을 위해 더 많은 것을 할 수 있다. FixedUpdate()에서 포지션 업데이트 로직을 살펴보면, 코드를 한 위치에서 보내면 거기에서 새로운 포지션을 반환해야 한다는 사실을 알 수 있습니다. 공 시뮬레이션은 실제로 공의 위치를 소유하지 않으며, 제공된 공의 위치를 기반으로 시뮬레이션 틱을 실행하고 결과를 반환한다.

인터페이스를 사용하면 아마도 그 공 MonoBehavior의 일부를 시뮬레이션과 공유할 수 있을 것이다. 필요한 부분만 (위 이미지를 참조).
코드를 다시 살펴보자. Ball 클래스는 간단한 인터페이스를 구현합니다. LocalPositionAdapter 클래스를 사용하면 Ball 오브젝트에 대한 레퍼런스를 다른 클래스로 넘길 수 있습니다. 우리는 전체 Ball 객체를 전달하지 않고, 오직 LocalPositionAdapter 측면만 전달한다.
BallLogic은 GameObject를 파괴할 시간임을 Ball에 알려야 한다. 플래그를 반환하는 대신에 Ball은 BallLogic에 델리게이트를 제공할 수 있습니다. 이것이 재구성된 버전에서 마지막에 표시된 행이 하는 일입니다. 이것은 깔끔한 디자인을 제공합니다: 많은 보일러플레이트 로직이 있지만, 각 클래스는 좁게 정의된 목적을 가지고 있습니다.
이 원칙을 잘 사용하면 1인 프로젝트를 잘 구성할 수 있을 것입니다.

조금 더 큰 프로젝트를 위한 소프트웨어 아키텍처 솔루션을 살펴보겠습니다. 볼 게임의 예를 사용하면, 코드에 더 구체적인 클래스를 도입하기 시작할 때–BallLogic, BallSimulation 등–계층 구조를 구성할 수 있어야 합니다:
MonoBehaviours는 모든 다른 로직을 감싸기 때문에 모든 것을 알아야 하지만, 게임의 시뮬레이션 조각은 로직이 어떻게 작동하는지 알 필요는 없습니다. 시뮬레이션을 실행할 뿐입니다. 때때로, 로직은 시뮬레이션에 신호를 공급하고, 시뮬레이션은 그에 따라 반응합니다.
입력을 별도의 독립된 장소에서 처리하는 것이 유익합니다. 바로 이곳에서 입력 이벤트가 생성되며 로직에 이를 공급합니다. 다음에 일어나는 일은 시뮬레이션에 달려 있습니다.
이것은 입력과 시뮬레이션에 잘 작동합니다. 그러나, 특별 효과를 생성하는 로직, 점수 카운터를 업데이트하는 것 등과 같이 프레젠테이션과 관련된 모든 것에서 문제에 직면할 가능성이 높습니다.
프레젠테이션은 다른 시스템에서 무슨 일이 일어나고 있는지 알아야 하지만, 모든 시스템에 대한 완전한 접근 권한이 필요하지는 않습니다. 가능하다면 로직과 프레젠테이션을 분리하세요. 로직만 있는 모드와 로직과 프레젠테이션이 모두 있는 모드에서 코드 베이스를 실행할 수 있는 지점에 도달하도록 노력하세요.
때때로 프레젠테이션이 적절한 시점에 업데이트되도록 로직과 프레젠테이션을 연결해야 할 필요가 있습니다. 하지만 이 경우에도 올바르게 표시하는 데 필요한 것만 프레젠테이션에 제공해야 합니다. 이렇게 하면, 당신이 구축하고 있는 게임의 전반적인 복잡성을 줄여줄 두 부분 간의 자연스러운 경계를 얻을 수 있습니다.
때로는 데이터로 수행할 수 있는 모든 로직 및 연산을 같은 클래스에 통합하지 않고, 해당 데이터만 포함하는 클래스를 갖는 것이 좋습니다.
또한 데이터는 소유하지 않지만 부여된 객체를 조작하는 함수는 포함하는 클래스를 만드는 것이 좋습니다.
정적 메서드의 장점은, 만약 그것이 어떤 전역 변수를 건드리지 않는다고 가정한다면, 메서드를 호출할 때 전달되는 인수만 보고 메서드가 잠재적으로 영향을 미치는 범위를 식별할 수 있다는 것입니다. 메서드의 구현을 전혀 살펴볼 필요가 없습니다.
이 접근 방식은 함수형 프로그래밍 분야에 관련됩니다. 여기서 핵심은 함수에 무언가를 보내면, 함수는 결과를 반환하거나 반환 파라미터 중 하나를 수정한다는 점입니다. 이 접근 방식을 시도해 보세요; 고전적인 객체 지향 프로그래밍을 할 때보다 버그가 적게 발생할 수 있습니다.
객체들 사이에 접착 로직을 삽입하여 객체들을 분리할 수도 있습니다. Pong 스타일의 예제 게임을 다시 보면, 공 로직과 점수 프레젠테이션이 서로 어떻게 이야기할까요? 공과 관련하여 무언가가 발생하면 공 로직이 점수 프레젠테이션에 정보를 주는 것일까요? 점수 로직이 공 로직에 쿼리하는 것일까요? 그들은 서로 대화해야 할 필요가 있습니다, 어떤 식으로든.
로직이 데이터를 쓸 수 있는 저장 공간을 제공하고 프레젠테이션이 데이터를 읽을 수 있는 버퍼 객체를 생성할 수 있습니다. 또는, 그들 사이에 큐를 두어 논리 시스템이 큐에 항목을 넣고 프레젠테이션이 큐에서 오는 것을 읽을 수 있도록 할 수 있습니다.
게임이 성장함에 따라 논리를 프레젠테이션에서 분리하는 좋은 방법은 메시지 버스를 사용하는 것입니다. 메시징의 핵심 원칙은 수신자도 발신자도 상대방에 대해 알지 못하지만 메시지 버스/시스템은 인식하고 있다는 것을 양 쪽이 모두 안다는 것입니다. 따라서 점수 프레젠테이션은 점수를 변경하는 모든 이벤트에 대해 메시징 시스템에서 정보를 받아야 합니다. 그러면 게임 로직은 플레이어의 포인트 변화를 나타내는 이벤트를 메시징 시스템에 게시합니다. 시스템을 분리하고 싶다면 UnityEvents를 사용하여 시작하는 것이 좋습니다. 또는 직접 작성할 수도 있습니다. 그러면 별도의 목적을 위한 별도의 버스를 가질 수 있습니다.
LoadSceneMode.Single 사용을 중지하고 대신 LoadSceneMode.Additive를 사용하세요.
장면을 언로드하려면 명시적 언로드를 사용하세요. 언젠가는 장면 전환 중에 몇 개의 객체를 살아있게 유지해야 할 필요가 있습니다.
DontDestroyOnLoad 사용도 중지하세요. 이를 사용하면 오브젝트의 수명을 제어할 수 없습니다. 실제로 LoadSceneMode.Additive를 사용해 로딩할 경우, DontDestroyOnLoad를 사용할 필요가 없습니다. 오래 살아있는 객체를 특별한 오래 살아있는 장면에 넣으세요.
제가 작업한 모든 게임에서 유용했던 팁을 추가하자면, 깔끔하고 제어된 종료를 지원하는 것입니다.
애플리케이션이 종료되기 전에 사실상 모든 리소스를 해제할 수 있도록 하세요. 가능하다면, 전역 변수가 여전히 할당되지 않아야 하며, GameObject는 DontDestroyOnLoad로 표시되지 않아야 합니다.
특정한 순서로 종료하는 경우, 오류를 발견하고 리소스 누수를 찾기가 더 쉬워집니다. 이렇게 하면 플레이 모드를 종료할 때 양호한 상태로 Unity 에디터를 나갈 수 있습니다. Unity는 플레이 모드를 종료할 때 전체 도메인을 다시 로드하지 않습니다. 깨끗한 종료가 이루어지면, 에디터나 어떤 종류의 편집 모드 스크립팅이 게임을 에디터에서 실행한 후 이상한 동작을 보일 가능성이 줄어듭니다.
Git, Perforce 또는 Plastic과 같은 버전 관리 시스템을 사용하여 이를 수행할 수 있습니다. 모든 에셋을 텍스트로 저장하고 오브젝트를 씬 파일에서 프리팹으로 이동시킵니다. 마지막으로, 장면 파일을 여러 개의 작은 장면으로 나누되, 이는 추가 도구가 필요할 수 있음을 인식하세요.
10명 이상의 팀이 될 예정이라면 프로세스 자동화 작업을 해야 합니다.
창의적인 프로그래머라면 독창적이고 신중한 작업을 하고 가능한 반복적인 부분은 최대한 자동화하도록 해야 합니다.
코드에 대한 테스트를 작성하는 것부터 시작하세요. 특히 MonoBehaviour에서 일반 클래스로 이동하는 경우, 로직 및 시뮬레이션 모두에 대한 단위 테스트를 작성하기 위해서 단위 테스트 프레임워크를 사용하는 방법이 간단합니다. 모든 곳에서 의미가 있는 것은 아니지만, 나중에 다른 프로그래머가 코드를 접근할 수 있도록 만드는 경향이 있습니다.
테스트는 단순히 코드를 테스트하는 것이 아닙니다. 콘텐츠도 테스트가 필요할 수 있습니다. 팀에 콘텐츠 제작자가 있다면, 그들이 만든 콘텐츠를 신속하게 검증할 수 있는 표준화된 방법이 있다면 모두 더 나은 결과를 얻을 수 있습니다.
로직 테스트 - 예를 들어 프리팹을 검증하거나 커스텀 에디터를 통해 입력한 데이터를 검증하는 것은 콘텐츠 제작자에게 쉽게 제공되어야 합니다. 그들이 에디터에서 버튼을 클릭하고 빠른 검증을 받을 수 있다면, 곧 이것이 시간을 절약한다는 것을 인식하게 될 것입니다.
이 다음 단계는 유니티 테스트 러너를 설정하여 정기적으로 자동으로 테스트를 다시 수행하는 것입니다. 이때 Unity 테스트 러너를 빌드 시스템의 일부로 설정해 모든 테스트를 실행하도록 하세요. 좋은 방법은 알림을 설정하여 문제가 발생할 때 팀원들이 슬랙 또는 이메일 알림을 받도록 하는 것입니다.
자동화된 플레이스루는 당신의 게임을 플레이할 수 있는 AI를 만들고 오류를 기록하는 것을 포함합니다. 간단히 말해, 당신의 AI가 찾는 모든 오류는 당신이 찾는 데 시간을 덜 소비해야 한다는 것입니다!
우리의 경우, 우리는 같은 기계에서 약 10개의 게임 클라이언트를 설정하고 가장 낮은 세부 설정으로 모두 실행했습니다. 충돌을 확인한 후에 오류 기록을 보았습니다. 클라이언트 중 하나가 충돌할 때마다 버그를 찾기 위해 직접 게임을 하거나 QA팀을 배정하지 않아도 되므로 시간을 절약할 수 있었습니다. 그것은 우리가 실제로 게임을 테스트할 때 다른 플레이어와 함께 게임이 재미있는지, 시각적 결함이 어디에 있는지 등에 집중할 수 있음을 의미했습니다.