[유니티] 거대한 클래스를 관리하기

짧은 코드, 간결한 메서드, 미니멀한 클래스들… 좋은 얘기입니다. 왜냐면 사람은 복잡한 걸 감당할 수 없으므로 길어지면 머리에서 이해가 안되고 수정도 확장도 할 수 없기 때문입니다. 그럼 사람이 이해하기 좋은 코드는 짧은 코드라고 할 수 있습니다. 하지만 코드는 짧아지지 않습니다. 왜냐면 그만큼의 코드가 있어야 그러한 기능이 구현되기 때문입니다. 이를 위해 클래스나 메서드로 잘게 쪼갭니다. 그렇게 함으로서 코드 전체의 길이는 변하지 않을지라도 사람이 한 번에 봐야할 코드의 양을 줄일 수 있습니다. 각 코드가 서로 관계되지 않는 격리성을 갖고 있다면 사람의 두뇌는 한 번에 적은 분량만 처리할 수 있게 되어 코드를 통제할 수 있습니다.

하지만 기계는 그 반대입니다. 이제 그 반대의 얘기와 그래도 사람은 머리가 나쁜데 어떻게 할까를 고민해보죠.

 
 
 

거대한 클래스가 존재하는 이유top

 
 

가시성과 공개범위의 문제

packege 기반의 자바에서는 internal 을 적극 활용함으로서 패키지 안의 특정 클래스만 public 으로 공개하고 나머지는 패키지 가시성으로 은닉하여 역할을 나누는 시스템을 권장합니다.

c# 에서도 못할 것은 아니지만 일단 internal 의 범위가 어셈블리라 java 처럼 사용하려면 정신적인 패닉에 빠지게 됩니다. 따라서 내부 클래스를 훨씬 더 애용하는 편입니다. 마치 클래스 자체를 패키지처럼 이용하는 형국인 거죠. 이러한 패턴은 c# 라이브러리를 비롯하여 다수의 코드에서 일반적으로 보이는 양상입니다. 자바라면 패키지 수준의 internal 클래스가 나올 법한 상황에서 c# 이라면 내부 클래스가 등장하는 경우가 많습니다.

그러다 보면 클래스의 크기가 자연스레 거대해집니다.

또한 많은 클래스를 공개하기 보다는 대표 클래스 하나만 공개하고 그 공개된 클래스의 속성이나 메서드로 나머지를 공개하는 인터페이스를 선호하는 경향이 많습니다. 예를 들어 그라데이션을 그려주는 클래스가 있다고 가정합시다. 그럼 그라데이션을 위해 원형인지, 선형인지를 구분하는 그라데이션 타입도 필요하고, 거칠게 경계면을 그릴 건지, 부드럽게 anti-alias 처리를 할 것인지에 대한 타입도 받아야 합니다. 이를 개별 클래스로 작성하면

Gradiant |  GradiantType |  GradiantAntiAliasType

이라는 세 개의 클래스가 필요합니다. 하지만 이렇게 되면 이 클래스를 사용하는 유저가 클래스를 세 개나 알아야하기 때문에 문서없이는 개발이 불가능합니다. 만약 하나의 클래스에 포함한다면 코드힌트로도 충분히 찾을 수 있겠죠.

Gradiant |  Gradiant.TYPE |  Gradiant.ANTI_ALIAS_TYPE

즉 실제로는 Gradiant 클래스의 역할이 아니지만 관련 클래스를 포함시킨 형태로 작성하는 일이 많습니다. 그렇게 되면서 자연스레 클래스의 덩치는 더욱 커집니다.
 
 

성능의 문제

유니티에서 IDE 를 통해 직접 마우스로 처리되는 씬 상의 객체 외에 스크립트로 통제하는 모든 것들은 클래스로 되어있습니다. 게임시스템에 무언가 명령을 내리기 위해서는 반드시 클래스를 작성하지 않으면 안됩니다.

사정이 이렇다 보니 클래스를 너무 많이 만들기도 하고 관리도 점점 복잡해 집니다. 사실 엄밀하게 말해 클래스는 인간에게는 이해하기 좋은 구조를 제공하지만 기계에는 다중 포인터를 참조해야 하는 부가적인 연산을 많이 일으킵니다.
성능적인 관점에서는 이러한 클래스 선언을 최소화하고 대외적으로는 static 메서드를 제공하는 인터페이스로 정리하는 편이 좋은 경우도 많습니다.

OOP 철학에서는 단일 책임 원칙 이란게 있습니다. 따라서 클래스는 되도록이면 간결하고 단일한 책임만 수행하는 작은 형태로 작성하는 게 좋은 설계 방법론입니다. 하지만 그렇게 되면 정말 수 많은 클래스가 생성됩니다. 단순한 연산에도 수 많은 클래스 참조가 필요해 성능에 악영향을 끼치게 됩니다. 이런 악영향은 업무용이나 일반적인 비지니스 로직에서는 무시할만 합니다만, 게임에서는 심각한 성능 문제와 동시에 수정이 불가능한 상황이 됩니다.

게임은 매 프레임마다 너무 많은 업데이트를 하기 때문에 다수의 클래스와 메서드 간의 포인터 참조 연산 비용이 무시할 수 없게 되는 것이죠. DOD는 많은 게임개발에서 받아들여지고 있는 방법론입니다. 아래 참조할만 내용이 있습니다.

Introduction to Data Oriented Design
 
 

static역할과 인스턴스 역할 병행

iTween 의 경우 클래스의 태반은 선언과 static 메서드와 선언으로 되어 있습니다. 실제 addComponent를 통해 GameObject에서 작동하는 인스턴스 부분은 얼마 안되죠. 하지만 네임스페이스 문제나 에셋스토어에서의 배포, 타 프로젝트에서의 용이한 사용을 생각해보면 단일 클래스로 만들고 싶은 욕심도 이해가 갑니다.

하나의 클래스를 인스턴스 역할과 static역할을 병행시키면 여러가지 잇점이 생기는데 무엇보다 대부분을 private으로 정리할 수 있다는 점이 좋고 필드의 이름 참조도 매우 간단히 코딩할 수 있게 됩니다. 하지만 코드 관리 상으로는 점점 길어지기 때문에 나중에는 어디에 뭐가 있는지 찾는 것조차 중노동이 됩니다.

 
 
 

#region로 관리하기top

M$ 는 c# 의 전처리기를 만들 때 비쥬얼 스튜디오에서 코드를 정리해 이쁘게 보여주는 코드를 포함시켰습니다. 보통은 넷빈즈나 이클립스 등 에디터가 지원하는 특수 주석인 경우가 많습니다만 언어를 직접 만든 M$ 는 아예 언어의 스펙에

에디터에서 이쁘게 정리해주는 기능

이라는 말도 안되는 전처리기 구문을 포함시켜버렸습니다. #region ~ #endregion 이 바로 그 전처리기 입니다. 사용 방법은 아래와 같습니다.

class Test{

	#region Math
	float Plus( float a, float b ){ return a + b; }
	float Minus( float a, float b ){ return a - b; }
	#endregion

	#region String
	string Concat( string a, string b ){ return a + b; }
	#endregion

}

이러한 전처리 구문을 이용하면 코드나 컴파일 자체에는 아무런 변화가 없지만 비쥬얼스튜디오에서는 Plus 와 Minus 는 Math 폴더 안에, Concat 은 String 폴더 안에 정리하여 아웃라인을 보여줍니다.

1
#region을 설정하기 전(왼쪽)과 후(오른쪽)

이 전처리 구문이 c# 표준이다보니 비주얼 스튜디오 뿐 아니라 모노디벨롭에서도 지원하고 있습니다. region 은 그룹별로 코드를 관리할 수 있게 되어 유지보수에 큰 도움이 됩니다.

iTween 의 경우 이 방법을 적극적으로 활용하여 코드를 작성한 예입니다.

2

전체적으로 7천 줄이 넘어가는 이 거대한 클래스는 클래스 내부에 수 많은 열거형과 내부 클래스를 포함하고 있고 동시에 GameObject 에 addComponent 로서 작동할 인스턴스 부분의 코드도 갖고 있습니다.
이러한 region 구분을 해두지 않는다면 아웃라인에서 노출될 메서드만 해도 십수 개에 이르러 어디에 뭐가 있는지 찾는 것만 해도 일입니다. 하지만 region을 이용해 번호까지 일목요연하게 붙여가며 체계적인 관리를 하고 있습니다.

이러한 구조를 실제로 이용해봅시다! 예를 들어 자기만 easing함수를 추가하는 경우를 생각해보죠.

3

위의 그림처럼 0.5까지는 0으로 계속 반응하지 않다가 0.5 이후엔 곧장 1로 반응하는 easing커브를 생각해봅시다.
이 함수대로라면 애니메이션은 중간까지 아무런 번화도 없다가 갑자기 목표에 도달한 뒤 유지되는 식일겁니다. iTween 의 easing 함수는 start, end, value 를 받도록 되어 있으므로 함수는 간단히 작성할 수 있습니다.

private float halfJump( float start, float end, float value ){
	return value > .5f ? 1f : 0f;
}

이 함수를 어디에 넣을까를 고민말고 위의 region 이 포함된 아웃라인을 살펴보면 젤 하단에 Easing Curves 라는 영역이 존재합니다. 여기의 젤 끝에 추가하도록 하죠.
그리고 끝이 아닙니다. 작성한 easing 함수는 private 이므로 이대로 사용될 리가 없습니다. 열거자에 포함하여 공개해야겠죠. 이걸 본인이 만든 소스가 아닌데 어디서 찾을 까요?

다시 region 을 살펴보면 젤 상단에 Variables 영역이 보입니다. 뭔가 변수, 상수들을 모아둔 것 같으니 여길 파봅니다.

ease 가 들어간 것들을 차근히 눌러서 살펴봅시다.

  • delegate float EasingFunction(…);
    우선 다양한 함수를 교체하여 쓰기 위해 easing 함수용 델리케이션을 선언했습니다.
     
  • EasingFunction ease;
    실제 인스턴스에서 본인이 사용할 easing 함수를 기억해 둘 목적인 것 같습니다.
     
  • enum EaseType{..}
    실제 easing 함수를 열거형에 집어넣고 있습니다. c# 의 열거형은 자바7 만큼 강력하지는 않지만 매우 편리합니다. 이 열거형이야말로 코드 힌트에서 iTween.EaseType. 이라고 치면 나오는 리스트의 정체입니다. 바로 여기에 방금 만든 함수를 대입해둬야겠죠. 마지막줄에 있는 punch 다음에 방금 만든 halfJump 를 추가합니다.

이것으로 끝이 아닙니다. 열거형은 열거형일 뿐 아마 어딘가에는 열거형에서 지정한 상수를 switch 하여 델리케이트에 직접 함수를 할당하는 코드가 존재할 것입니다. 위에 보면 ease라는 변수가 그러한 역할을 수행하고 있으므로 다음과 같은 문자열로 검색을 때립니다.

ease =

막 찾다보면 6920번째 줄에 있는 GetEasingFunction()에 도달합니다(이녀석은 Internal Helpers region 소속인데, 사실 이건 좀 이름으로는 예상하기 힘들더군요 ^^)
여기 스위치문에도 새로운 easing 함수를 추가해줍니다.

이러한 과정을 통해 새로운 커스텀 easing 함수를 무사히 iTween 에 추가했습니다!

개발자가 확장과 수정을 위해 구조를 이쁘게 짜서 정리한 뒤 region 을 세심하게 지정해둔다면 저처럼 iTween 의 구조를 전혀 모르는 입장에서도 안전하게 코드를 확장할 수 있습니다[1. 솔직히 뻥인지 아닌지 잘모르겠습니다. 프로그램의 흐름을 이해하고 작성자가 어떤 생각을 하는지 꽤 뚫어볼 정도의 실력이 필요한 것도 같습니다. 제게 어려운게 아니었으니 그리 난이도 있는 조건은 아니겠죠, 설마….^^;]
 
 
 

partial class로 관리하기top

7천줄 규모의 iTween 에도 region 으로 관리가 될 지경이니 사실 region 의 유용성은 충분히 설명이 되었습니다. 하지만 역시 너무 깁니다.
그리고 트위너는 기능적인 영역이 명확하여 그룹지어둘 수 있지만 거대 클래스의 여러 부분이 유기적으로 연동하는 경우는 지리적(?)으로 그룹짓기 어려워지는 경우도 많습니다.

c# 에서는 php 나 jsp 등의 웹플랫폼에 흔히 있는 include 기능 대신 partial class (빠셜~) 라는 기능을 지원합니다. 이 기능을 이용하면 거대 클래스를 여러 개의 파일로 쪼갤 수 있습니다.

사실 이 개념은 헤더파일과 다수의 본문파일로 구성하는 c전처리 시스템을 문법에 포함시킨 정도입니다만 사용하기는 매우 편리합니다.

유니티도 다행히 이 시스템을 그대로 지원해주고 있습니다(유니티가 c#의 어디까지 지원할까는 순전히 지 마음입니다. 언어스펙을 보면 3.0과 4.0 사이에서 미묘하게 지원하고 있습니다 =.=)

아까 등장했던 Test 클래스를 이번에는 patial로 쪼개봅시다. 첫 번째 클래스는 다음과 같이 작성합니다.

partial class Test{
	float Plus( float a, float b ){ return a + b; }
	float Minus( float a, float b ){ return a - b; }
}

두 번째 클래스도 대동소이하게 partial 이란 키워드로 작성합니다.

partial class Test{
	string Concat( string a, string b ){ return a + b; }
}

작성이야 하면되지만 문제는 파일의 이름을 어떻게 붙일 것이냐입니다.

핵심은 아무렇게나 붙여도 상관없이 컴파일러가 알아서 해준다는 것입니다.

유니티도 물론 이 시스템을 지원하므로 이름을 아무렇게나 붙여도 됩니다. 즉 첫 번째 클래스는 AAA 로 짓고 두 번째 클래스는 BBB 로 지어도 제대로 Test 클래스로 컴파일 해 줍니다. 모노디벨롭도 이를 정확히 인식합니다.
하지만 그렇게 하면 나중에 진짜 AAA 클래스가 등장할 때 사용할 이름이 없어집니다. 따라서 partial 클래스의 경우 점으로 계층화 된 이용한 이름을 권장합니다(제 개인적인 권장입니다 =.=)

예를 들이 위의 두 클래스라면 아래와 같이 이름 짓는게 좋겠죠.

Test.Math, Test.String

그럼 아마도 유니티에서는 아래와 같이 보일겁니다.

5
(괜찮습니다. 잘됩니다. 걱정하지 마세요 ^^)

사실 에셋스토어에 배포할 게 아니면 이 편이 훨씬 관리하기 좋습니다. 하나의 클래스지만 마치 독립된 여러 개의 클래스처럼 관리할 수 있으니까요. 이렇게 되면 클래스를 Java 의 패키지처럼 사용할 수 있게 됩니다.

//Game.Player
partial class Game{
	class Player{
		//..
	}
}

//Game.Enemy
partial class Game{
	class Enemy{
		//..
	}
}

//Game.Asset
partial class Game{
	class Asset{
		//..
	}
}

//Game
partial class Game{
	//..본체의 코드
}

마치 Game 이란 패키지를 만든 느낌이 되고, 실제 파일도 그렇게 분리되어 있어서 관리하는 느낌도 비슷해 집니다.
 
 
 

결론top

특히 게임엔진에서 거대 클래스는 피해야할 설계 상의 오류가 아니라 성능 상 권장마저 되고 있습니다. 거대한 클래스를 관리할 수 있는 요령을 숙지하지 않으면 다량의 코드속에 수정도 확정도 불가능하게 되므로 언어가 지원하는 기능을 확실하게 익히고 미리미리 코드를 관리해가야합니다.