본 글은 유니티에서 발간한 전자책 'Level up your programming with game programming patterns'을 정리한 글입니다. 원문은 다음 링크에서 확인하실 수 있습니다.
https://unity.com/kr/resources/level-up-your-code-with-game-programming-patterns
1. THE SOLID PRINCIPLES
디자인 패턴에 앞서서 코드를 보다 이해하기 쉽고, 유연하고, 유지 보수하기 쉽게 만들어주는 5가지의 디자인 원칙들이다.
1.1. Single-responsibility principle (SRP, 단일 책임 원칙)
: 한 모듈, 클래스 혹은 함수는 하나의 사항에만 책임을 져야 한다.
규모가 큰 클래스들보다, 하나의 사항만을 담당하는 작고 많은 클래스들로 프로젝트를 조립해나가야 한다. 클래스 혹은 메서드가 짧을 수록 설명하고, 이해하고, 구현하기 쉽다.
MeshFilter, Renderer, Transform, Rigidbody와 같은 유니티 컴포넌트들은 단일 책임을 잘 보여주며, 이들 사이의 상호작용에 의해 게임이 진행된다. 우리는 이러한 컴포넌트들처럼 커스텀 컴포넌트(스크립트)를 설계해야 할 것이다. 각 컴포넌트들이 무엇을 하는지 명확하게 이해되도록 설계하고, 복잡한 동작에 그들을 함께 사용해야 한다.
다음은 단일 책임을 지키지 않은 커스텀 컴포넌트의 예시이다.


한 컴포넌트가 여러 기능을 책임지고 있다. 이렇게 되면 프로젝트가 커질 수록 스크립트를 관리하기 어려워질 것이다. 다음과 같이 클래스를 쪼개어 단일 책임을 강화해보자.


Player 스크립트는 여전히 다른 커스텀 컴포넌트에 접근할 수 있지만, 각 커스텀 컴포넌트들은 하나의 기능을 책임진다. 이러한 디자인을 통해 코드를 쉽게 수정할 수 있고, 따라서 프로젝트를 관리하기 쉬워진다.
하지만 이러한 단일 책임 원칙에는 어느 정도 융통성이 필요하다. 단일 책임을 위해 과하게 클래스를 쪼개는 것은 오히려 독이 될 것이다.
다음과 같은 목표들을 염두해 두며 단일 책임 원칙을 적용하라.
- 가독성 (Readability)
짧은 클래스 코드가 읽기 쉽다. 명확한 규칙은 없지만 많은 개발자들이 200-300줄의 제한을 걸어놓는다. 짧음의 기준을 스스로 정하고, 그 기준을 넘으면 더 쪼개라.
- 확장 가능성 (Extensibility)
작은 클래스들로부터 상속을 받아 새로운 클래스로 쉽게 확장할 수 있을 것이다. 의도하지 않은 곳에 영향을 끼칠 두려움 없이 그들을 수정하고 대체할 수 있을 것이다.
- 재사용 가능성 (Reusability)
다른 곳에도 사용될 수 있게 클래스를 작고, 모듈식으로 만들어라.
"Simple is not easy, simple makes easy"
1.2. Open-closed principle (OCP, 개방-폐쇄 원칙)
: 클래스들은 확장에 열려있지만, 수정엔 닫혀있어야 한다.
다시 말해, 기존의 코드를 수정하지 않고도 새로운 동작을 구현할 수 있게끔 클래스를 설계해야 한다.
고전적인 예시 중 하나는 도형의 넓이를 계산하는 것이다. 가령 다음의 코드를 보자.

위 코드의 AreaCalculator 클래스는 직사각형, 원에 한해서 잘 작동한다. 하지만 다른 도형을 추가하고 싶다면 그에 따른 메서드들을 일일이 추가로 작성해야 할 것이고, 새로운 도형이 계속해서 필요하게 된다면 메서드들이 너무 많아져 관리하기가 어려워질 것이다. 이를 해결하기 위한 방법을 생각해보자.
먼저 도형 클래스들이 Shape이라는 이름의 기초 클래스를 상속받도록 하고, AreaCalculator 클래스에 파라미터로 Shape을 받아 도형의 넓이를 계산하는 메서드를 만드는 방법을 생각해 볼 수 있을 것이다. 외부 코드에서 어떤 도형이든 해당 메서드만 호출하면 되므로 API 측면에서 더 편리해지겠지만, 그렇게 하더라도 해당 메서드의 로직에는 파라미터로 받은 Shape이 어떤 도형인지 구분하고 그에 따라 일일이 넓이를 계산하는 많은 조건문들이 필요할 것이다. 또한 새로운 도형을 추가하기 위해선 해당 메서드의 로직을 수정해야 한다.
대신에 다음과 같은 방법을 사용해보자.



추상 클래스와 오버라이드를 통해 위와 같이 AreaCalculator를 간단하게 만들 수 있다. 위 코드에서 도형을 추가하고자 한다면, AreaCalculator를 수정할 필요 없이 추상 클래스 Shape을 상속받는 도형의 클래스를 추가로 만들기만 하면 된다.
이 새로운 디자인을 통해 디버깅을 보다 쉽게 할 수 있다. 만약 새로운 도형이 에러를 발생시킨다면, AreaCalculator의 코드를 검사할 필요 없이 새로운 도형의 코드만 검사하면 된다.
유니티에서 새로운 클래스를 만들 때, 이처럼 추상이나 인터페이스를 활용하는 것이 좋다. 이는 관리하기 힘든 복잡한 switch 문이나 많은 if 문들의 사용을 피하게 해준다. 개방 폐쇄 원칙을 지키며 클래스들을 짜는 것에 익숙해진다면, 새로운 코드를 추가하는 것이 훨씬 간단해질 것이다.
1.3. Liskov substitution principle(LSP, 리스코프 치환 원칙)
: 부모 클래스의 객체는 자식 클래스의 객체로 대체 가능해야 한다.
코드 상에서 기초 클래스의 객체 자리에, 기초 클래스로부터 파생된 어떤 클래스의 객체든 대입할 수 있어야 한다는 말이다.
가령 다음과 같은 클래스 Vehicle이 있다고 하자.

Vehicle의 파생 클래스 객체들은 움직이기 위해 다음과 같은 클래스의 조작을 받는다고 하자.

이제 기차를 구현하기 위해 클래스 Train을 Vehicle의 파생 클래스로써 만들려고 한다. 다만 기차는 항상 트랙을 따라가기 때문에 TurnLeft와 TurnRight 메서드를 구현하면 안될 것이다. 그러나 Navigator의 Move 메서드에선 Vehicle의 TurnLeft와 TurnRight 메서드를 요구하므로, 해당 메서드에 Train을 넘기면 제대로 작동하지 않을 것이다.
Train은 Vehicle의 하위이기 때문에, Vehicle을 받는 어떤 자리에도 Train을 사용할 수 있기를 기대한다. 하지만 Train을 위 코드에 대입할 경우 그렇지 않으므로 리스코프 치환 원칙을 위반한 것이고, 이는 코드가 예상치 못하게 작동할 우려를 준다.
다음은 리스코프 치환 원칙을 지키기 위한 팁들이다.
- 파생 클래스를 만들 때 기초 클래스의 특징을 없애려고 한다면 리스코프 치환 원칙을 깰 확률이 높다.
파생 클래스가 기초 클래스처럼 행동하지 않는다면, 오류가 발생하지 않더라도 리스코프 치환 원칙을 어긴 것이다.
- 추상화를 간단하게 유지하라.
기초 클래스에 로직을 넣을 수록 리스코프 치환 원칙을 위반할 확률이 높아진다. 기초 클래스는 파생 클래스들의 공통적인 기능만을 표현해야 한다.
- 파생 클래스는 기초 클래스와 같은 퍼블릭 멤버들을 가져야 한다.
그러한 퍼블릭 멤버들은 매개변수, 반환 타입 등 모든 것이 같아야 한다.
- 클래스 상속 관계를 짜기 전에 클래스 API를 고려하라.
클래스 간의 상속 관계를 설정하기 전에 클래스가 외부에 노출할 메서드와 속성들을 고려해야 한다는 것이다. 위 예제를 생각해보았을 때, 실제 세계에선 자동차(Car)와 기차(Train)이 탈 것(Vehicle)의 하위이지만, 외부에 노출할 메서드를 고려했을 때 서로 다른 부모 클래스를 두는 것이 합리적일 것이다.
- 상속(inheritance)보다 구성(composition)을 선호하라.
상속을 통해 기능을 전달하는 것 대신, 특정 동작을 캡슐화하기 위해 인터페이스나 별도의 클래스를 만들고, 그들을 섞고 매치하여 다양한 기능들의 '구성'을 구축하는 것이 좋다.

이제 이러한 팁들을 이용해 위 예제를 수정해보자.



상속 대신 인터페이스를 통해 RoadVehicle, RailVehicle의 기초 클래스를 '구성'했고, 이제 Car와 Train은 같은 기초 클래스를 상속받지 않아 리스코프 치환 원칙을 위반하지 않게 되었다.
하지만 이러한 방식은 실제 세계에서의 방식과 다르기 때문에 반직관적이다. 소프트웨어 개발에선 이러한 문제를 원-타원 문제(circle-ellipse problem)라고 부른다. 현실의 "is-a" 관계가 상속에 항상 대입되진 않는다. 우리는 현실 세계의 지식이 아니라, 소프트웨어 디자인을 통해 클래스 상속 관계를 끌어내야 한다.
상속은 주의를 기울이지 않으면 불필요한 복잡성을 야기하지만, 이처럼 리스코프 치환 원칙을 지키며 상속을 이용한다면 코드베이스를 확장 가능하고 유연하게 유지할 수 있다.
1.4. Interface segregation principle (ISP, 인터페이스 분리 원칙)
: 클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다.
다시 말해, 클래스는 클라이언트가 요구하는 인터페이스만을 제공해야 한다.
가령 게임의 유닛들에 적용할 인터페이스를 다음과 같이 구성했다고 하자.

이제 게임에 파괴될 수 있는 장애물과 같은 유닛을 추가하려고 한다. 해당 유닛에 위와 같은 인터페이스를 적용한다면, 해다 유닛은 인터페이스로부터 불필요한 기능들을 제공받게 될 것이다.
그 대신에 인터페이스를 작게 쪼개고, 그들을 조합해서 사용해보자.



마찬가지로 상속보다 구성을 선호하는 방식으로 인터페이스 분리 원칙을 지킬 수 있다. 인터페이스 분리 원칙은 시스템들이 분리될 수 있도록 하고, 그들을 쉽게 수정하고 재배치할 수 있게 해준다.
1.5. Dependency inversion principle (DIP, 의존성 역전)
: 하이레벨의 모듈은 로우레벨의 모듈로부터 어떤 것도 직접적으로 임포트하면 안되며, 두 모듈 모두 추상화(abstraction)에 의존해야 한다.
어떤 클래스가 다른 클래스와 관계가 있을 때, 그 클래스는 의존성(dependency) 혹은 결합(coupling)을 가지고 있다고 하며, 소프트웨어 디자인에서 이러한 의존성은 리스크를 동반한다.
만약 한 클래스가 다른 클래스들이 어떻게 동작하는지에 대해 너무 많이 알고 있다면, 그 클래스를 수정하는 것은 다른 클래스들에도 영향을 끼칠 수 있으며, 그 반대도 마찬가지다. 이러한 강한 결합도는 클린하지 못한 코딩으로 간주되며, 한 부분의 에러가 다른 부분에도 영향을 미치며 스노우볼처럼 커질 수 있다.
따라서 우리는 클래스간 의존성이 가능한 최소가 되도록 해야한다. 또한, 각 클래스들은 외부와의 연결에 의존하는 것보다 내부적인 파트들이 조화롭게 동작하는 것이 필요하다. 이렇듯 객체가 내부적이거나 프라이빗한 로직으로 기능할 수록 응집도(cohesion)가 높다고 표현한다.
최고의 시나리오는 느슨한 결합도와 높은 응집도를 향하는 것이다.

우리는 게임 애플리케이션을 수정하고, 또 확장할 줄 알아야 한다. 만약 수정하기가 어렵거나 애플리케이션이 수정에 취약할 때, 위와 같이 결합도와 응집도를 고려하여 애플리케이션의 구조를 살펴봐야 한다.
애플리케이션의 클래스들과 시스템들을 짤 때, 어떤 것들은 자연스럽게 하이레벨이 되고, 어떤 것들은 로우 레벨이 된다. 그리고 우리는 하이 레벨의 클래스가 로우 레벨의 클래스에 의존하도록 하곤 하는데, 의존성 역전 원칙은 이를 막아 클래스 간 결합도를 줄여준다.
가령 게임에서 스위치를 통해 문을 여는 것을 구현한다고 하자. 일반적으로 다음과 같은 방법을 생각할 수 있다.

위 코드는 잘 작동할 테지만, 문제는 하이레벨인 Switch 클래스가 Door 클래스에 의존하고 있다는 것이다. Switch가 Door 이외의 클래스를 조작하게 하고 싶을 때 어떻게 해야 할까? Switch의 로직을 수정하여 각 클래스에 맞는 메서드를 실행시킬 수 있을 것이다. 하지만 이것은 개방-폐쇄 원칙을 위반하는 일이다.
이때 다시 추상화가 도움이 된다.



인터페이스를 도입하여 의존성을 역전시켰다. Switch는 더 이상 Door의 특정한 메서드에 직접적으로 의존하지 않고, ISwitchable의 Activate와 Deactivate를 사용한다. Switch는 Door 뿐만 아니라 ISwitchable을 구현한 모든 클래스들에 동작하게 되었으므로, 확장성과 재사용성 역시 크게 높아졌다.
예제에서의 의존성 역전을 도식화하면 다음과 같다.


의존성 역전을 적용하면 다음 도식과 같이 쉽게 확장이 가능하다.

1.6. SOLID understanding
SOLID 원칙들은 당신의 코드를 더 클린하게 만들어 효율적인 유지와 확장을 도와주는 가이드라인이다. 이들은 확장이 필수적인 큰 애플리케이션에 잘 들어맞아, 무려 두 세기 가까이 기업 단위의 소프트웨어 디자인을 지배하고 있다.
일부 경우, SOLID 원칙들을 준수하는 것이 일부 기능을 추상화나 인터페이스로 리팩터링해야는 등의 추가 작업을 초래할 수 있다. 그러나 장기적으로 봤을 때 훨씬 더 많은 작업과 시간을 절약할 수 있을 것이다.
이러한 SOLID 원칙들을 익히는 것은 매일 연습해야 하는 문제이며, 코딩할 때 항상 염두해 두고 있어야 한다. 그러나 SOLID 원칙들은 절대적인 것이 아니며, 이를 적용하기 위한 다양한 방법이 존재한다. 그저 원칙을 적용하는 것에 대한 만족감이 아닌, 필요에 의해서 원칙을 유연하게 사용해야 한다.
"KISS: Keep it simple, stupid"
2. Interface vs. abstract class
'상속보다 구성'을 선호하고자 하는 철학을 지키기 위해 본 가이드의 많은 예제들이 인터페이스를 사용한다. 하지만 추상 클래스로도 많은 디자인 원칙과 패턴을 따를 수 있으며, 두 방법 모두 C#에서 추상화를 달성할 수 있는 유효한 방법이다. 상황에 따라 필요한 방법을 사용하면 된다.
추상 클래스는 '상속'의 개념으로 "is a" 관계를 나타내며, 인터페이스는 '구성'의 개념으로 "has a" 관계를 나타낸다. 클래스는 기본적으로 최대 하나의 기초 클래스만을 상속받을 수 있으므로 추상 클래스를 하나만 상속받을 수 있지만, 여러 개의 인터페이스를 가질 수 있다.
구체적인 차이점은 다음과 같다.

추상 클래스와 인터페이스를 사용한 예시는 다음과 같다.

추상 클래스 Robot으로부터 파생된 NPC는 Robot의 핵심 기능들을 상속받으며, 동시에 NPC를 켜고 끄는 기능을 추가하기 위해 ISwitchable 인터페이스를 사용한다. 이외에 다른 기능을 추가하기 위해 다른 인터페이스를 추가로 사용할 수도 있다.
'Unity > Study' 카테고리의 다른 글
| Unity C#) 디자인 패턴 가이드 #6. 스테이트 패턴(State pattern) (0) | 2024.03.17 |
|---|---|
| Unity C#) 디자인 패턴 가이드 #5. 커맨드 패턴(Command pattern) (0) | 2024.03.11 |
| Unity C#) 디자인 패턴 가이드 #4. 싱글톤 패턴(Singleton pattern) (3) | 2024.03.05 |
| Unity C#) 디자인 패턴 가이드 #3. 오브젝트 풀(Object pool) (1) | 2024.03.01 |
| Unity C#) 디자인 패턴 가이드 #2. 팩토리 패턴(Factory pattern) (0) | 2024.02.21 |