Priv's Blog
2. The SOLID principles 본문
1. SOLID 원칙
디자인 패턴을 본격적으로 다루기 전에, 디자인 패턴의 동작에 영향을 미치는 몇 가지 디자인 원칙들을 살펴봅시다. SOLID는 아래의 소프트웨어 디자인의 다섯 가지 핵심 기본 요소들의 앞 글자를 딴 두문자어입니다:
- 단일 책임 원칙
- 개방-폐쇄 원칙
이제 각각의 개념들을 검사하고 어떻게 여러분의 코드를 좀 더 이해하기 쉽고, 유연하고, 유지보수하기 쉽게 만들어 주는지 살펴봅시다.
2. 단일 책임 원칙
클래스 한 개는 수정될 이유를 단 한 개만, 즉, 오직 한 개의 책임만을 가져야 합니다. SOLID 원칙의 첫 번째이자 가장 중요한 이 단일 책임 원칙(SRP: Single-responsibility princible)은 각각의 모듈, 클래스, 또는 함수의 상태가 한 가지에 대한 책임을 지니며, 로직의 일부분만 캡슐화되어야 하는 책임을 지닙니다.
너무 많은 책임을 지니고 있어 비대해진 클래스들 대신, 하나의 책임만 가지는 작은 클래스 여러 개를 결합해 프로젝트를 구성하세요. 짧은 클래스와 메서드는 보다 쉽게 설명할 수 있고, 이해할 수 있고, 구현할 수 있습니다.
만약 여러분이 오랫동안 Unity 개발을 해오셨다면, 여러분은 이미 단일 책임 원칙에 익숙하실 겁니다. 여러분이 게임 오브젝트(GameObject)를 생성하실 때, 그 오브젝트들은 여러 개의 작은 컴포넌트들을 보유하고 있습니다. 예를 들어보자면 다음과 같습니다:
- MeshFilter는 3D 모델에 대한 참조를 저장합니다.
- Renderer는 어떻게 모델의 표면이 화면상에 표시되는 지를 조작할 수 있습니다.
- Transform 컴포넌트는 크기(scale), 회전(rotation), 위치(position) 값을 저장합니다.
- Rigidbody는 물리 상호작용을 시뮬레이션할 필요가 있을 때 사용할 수 있습니다.
각각의 컴포넌트는 하나의 역할을 가지고 원활하게 동작합니다. 여러분은 게임 오브젝트들로 이루어진 전체 씬 하나를 빌드하죠. 게임 오브젝트에 포함된 컴포넌트들은 상호 작용을 하며 게임이 동작할 수 있도록 만듭니다.
여러분들은 이와 똑같은 방식으로 여러분만의 스크립트 컴포넌트를 생성할 것입니다. 각 컴포넌트가 명확하게 이해될 수 있도록 설계하세요. 그런 다음 컴포넌트들이 상호작용하며 더 복잡한 동작을 수행할 수 있도록 만드세요.
만약 여러분이 단일 책임 원칙을 무시한다면, 아래와 같은 커스텀 컴포넌트가 생성될 것입니다:
public class UnrefactoredPlayer : MonoBehaviour
{
[SerializeField] private string inputAxisName;
[SerializeField] private float positionMultiplier;
private float yPosition;
private AudioSource bounceSfx;
private void Start()
{
bounceSfx = GetComponent<AudioSource>();
}
private void Update()
{
float delta = Input.GetAxis(inputAxisName) * Time.deltaTime;
yPosition = Mathf.Clamp(yPosition + delta, -1, 1);
transfrom.position = new Vector3(transform.position.x,
yPosition * positionMultiplier, transform.position.z);
}
private void OnTriggerEnter(Collider other)
{
bounceSfx.Play();
}
}
이 UnrefactoredPlayer 클래스는 어떤 책임을 지니고 있는지 명확하지 않습니다. 플레이어가 어떠한 물체와 충돌했을 때 사운드를 재생하기도 하고, 사용자의 입력을 관리하기도 하며, 움직임을 제어하기도 하죠. 이로 인해 여러분의 프로젝트가 발전함에 따라 유지보수는 점점 더 어려워질 것입니다.
[RequireComponent(typeof(PlayerAudio), typeof(PlayerInput), typeof(PlayerMovement))]
public class Player : MonoBehaviour
{
[SerializeField] private PlayerAudio playerAudio;
[SerializeField] private PlayerInput playerInput;
[SerializeField] private PlayerMovement playerMovement;
private void Start()
{
playerAudio = GetComponent<PlayerAudio>();
playerInput = GetComponent<PlayerInput>();
playerMovement = GetComponent<PlayerMovement>();
}
}
public class PlayerAudio : MonoBehaviour
{
...
}
public class PlayerInput : MonoBehaviour
{
...
}
public class PlayerMovement : MonoBehaviour
{
...
}
Player 스크립트는 다른 스크립트 컴포넌트들을 여전히 관리할 수 있습니다. 하지만 각각의 클래스는 오직 하나의 역할만 수행합니다. 이러한 디자인은 코드를 수정하기 더 용이하게 만들어 줍니다. 이는 여러분의 프로젝트가 시간이 지남에 따라 요구사항이 변경될 때 특히 더 유용합니다. 그러나, 여러분은 이 단일 책임 법칙을 적당한 상식과 균형을 맞춰 조절해 사용해야 합니다. 한 가지 메서드만 가지고 있는 클래스들을 생성하는 것처럼 너무 극단적으로 단순화하는 것에만 집착하지 않도록 조심하세요.
단일 책임 원칙을 적용할 때 다음과 같은 요소들을 명심해 두세요:
- 가독성: 클래스들을 읽기 쉽도록 짧게 작성하세요. 엄격한 규칙이 정해진 것은 아닙니다만 많은 개발자분은 200-300줄 정도로 길이를 제한하고 있습니다. “짧은 클래스”의 수준은 여러분 스스로 결정하셔도 되고, 팀원분들과 함께 의논하여 결정하셔도 됩니다. 만약 코드가 정해둔 한계치에 도달했을 때는 어느 부분을 더 작은 부분으로 분리할 것인지 결정하셔야 합니다.
- 확장성: 작은 클래스들로부터 상속을 받는 게 훨씬 간단합니다. 의도치 않게 기능이 동작하지 않을지도 모른다는 두려움 없이 코드를 수정 및 변경할 수 있죠.
- 재사용성: 클래스들을 작고 모듈 형식으로 디자인하여 여러분의 게임 속 다른 부분에서도 해당 코드를 재활용할 수 있게 만드세요.
코드를 리팩터링 할 때, 어떻게 코드를 재배치할지 고려하면 여러분 또는 다른 팀원분들의 개발 과정을 더욱 쾌적하게 만들어 줄 수 있습니다. 부가적인 이점으로는 차후에 발생할 수 있는 거대한 문제를 초반에 간단히 해결할 수도 있죠.
단순함은 쉽지 않습니다.
단순함은 소프트웨어 디자인에 대해 논할 때 자주 등장하는 용어이며, 신뢰도 면에서도 빼놓을 수 없는 개념이죠. 여러분의 소프트웨어 디자인은 제작 과정에서 벌어지는 변경 사항들에 유연하게 대응할 수 있나요? 여러분의 애플리케이션을 제시간 안에 확장 및 유지보수할 수 있나요?
이 가이드에서 소개된 많은 디자인 패턴과 원칙들은 단순함을 강화하는 데 도움이 됩니다. 그렇게 함으로써, 디자인 패턴들은 여러분의 코드를 보다 안전하고, 유연하며, 가독성 좋게 만들어 줍니다. 그러나, 디자인 패턴들은 부가적인 작업과 계획을 요구합니다. “단순함”은 절대 “쉬움”과 같은 의미가 아니랍니다.
여러분은 디자인 패턴을 사용하지 않고도 동일한 기능을 구현하실 수 있으며 (종종 그게 더 빠르기도 합니다.), 빠르고 쉬운 것이 항상 단순한 것을 만들어 내지는 않습니다. 단순하게 만든다는 것은 집중적으로 만들라는 것을 말합니다. 하나의 일만 하도록 설계하고, 불필요하게 다른 일들을 복잡하게 만들지 마세요.
Rich Hickey의 강연, Simple Made Easy를 참고하셔서 어떻게 단순함이 여러분이 더 나은 소프트웨어를 빌드할 수 있도록 도와줄 수 있는지 알아보세요.
3. 개방-폐쇄 원칙
개방-폐쇄원칙(OCP: open-closed principle)은 클래스들을 확장할 수 있도록 개방하되, 수정은 불가하도록 폐쇄하는 원칙을 의미합니다. 기존의 코드를 수정하지 않고 새로운 기능을 생성할 수 있도록 클래스들을 구성하세요.
개방-폐쇄 원칙을 설명할 때 전통적으로 도형의 면적을 구하는 법을 예시로 사용합니다. 사각형 또는 원의 면적을 반환하는 메서드를 가지고 있는 AreaCalculator라는 클래스를 만들 수 있습니다.
면적을 구하기 위해 Rectangle 클래스는 가로와 세로 길이를 알아야 합니다. Circle 클래스는 반지름과 원주율(Pi) 값만 알면 됩니다.
public class AreaCalculator
{
public float GetRectangleArea(Rectangle rectangle)
{
return rectangle.width * rectangle.height;
}
public float GetCircleArea(Circle circle)
{
return circle.radius * circle.radius * Mathf.PI;
}
}
public class Rectangle
{
public float width;
public float height;
}
public class Circle
{
public float radius;
}
위 코드는 잘 동작할 것입니다. 하지만 만약 여러분이 AreaCalculator 클래스에 더 많은 도형을 추가하고 싶으시다면, 각각의 새로운 도형마다 새로운 메서드를 생성해야 합니다. 만약 나중에 다각형 또는 팔각형을 추가하고 싶을 때를 가정해 보면 어떨까요? 만약 20각형 이상일 경우에는 어떨까요? AreaCalculator 클래스는 정말 빨리 복잡해질 겁니다.
Shape라는 이름의 기본 클래스를 하나 생성하고 도형들의 면적을 계산하는 하나의 메서드를 만들 수 있습니다. 그러나, 이렇게 코드를 작성하면 각 코드의 유형에 따라 로직을 제어하는 수많은 if 문을 추가해야 합니다. 그러면 확장성이 좋지 않겠죠.
기존의 코드(AreaCalculator 클래스 내부)를 수정하지 않고 프로그램을 확장(새로운 도형을 사용할 수 있는 기능)할 수 있도록 만들고자 합니다. 기능적이긴 합니다만, 현재 작성된 AreaCalculator 클래스는 개방-폐쇄 정책에 위배됩니다.
그 대신, 추상 클래스 Shape를 정의해 봅시다:
public abstract class Shape
{
public abstract float CalculateArea();
}
이 코드는 CalculateArea라는 이름의 추상 메서드를 포함합니다. 만약 Shape 추상 클래스를 상속하는 Rectangle과 Circle 클래스를 만든다면, 각 도형은 자신이 가지고 있는 면적을 계산하고 반환할 수 있습니다.
public class Rectangle : Shape
{
public float width;
public float height;
public override float CalculateArea()
{
return width * height;
}
}
public class Circle : Shape
{
public float radius;
public override float CalculateArea()
{
return radius * radius * Mathf.PI;
}
}
AreaCalculator는 아래와 같이 단순하게 바꿀 수 있습니다.
public class AreaCalculator
{
public float GetArea(Shape shape)
{
return shape.CalculateArea();
}
}
이렇게 수정된 AreaCalculator 클래스는 이제 Shape 추상 클래스를 구현하는 방법을 통해 어떠한 형태의 도형 면적도 계산할수 있습니다. 또한 기존의 소스 코드를 전혀 건드리지 않고도 AreaCalculator 클래스의 기능을 확장할 수도 있습니다.
새로운 도형이 필요할 때마다 여러분은 간단하게 Shape 클래스를 상속하는 새로운 클래스를 정의하면 됩니다. Shape 클래스의 개별적인 하위 클래스들은 CalculateArea 메서드를 재정의하여 정확한 면적을 반환합니다.
이러한 새로운 코드 디자인은 디버깅을 더 쉽게 만들어 줍니다. 만약 새로운 도형 클래스 하나가 에러를 발생시킬 경우, AreaCalculator 클래스를 건드릴 필요가 없습니다. 기존의 코드는 변경되지 않았기 때문에, 오작동하는 새로운 코드 부분만 검사해 보면 됩니다.
Unity에서 새로운 클래스를 생성할 때 인터페이스(interface)와 추상화(abstraction)의 이점을 잘 활용해 보세요. 다루기 힘든 switch문 또는 if 문을 여러분의 로직 안에서 남용하여 차후에 코드를 확장하기 어려워지지 않도록 도와줍니다. 한 번 OCP를 따르는 클래스들을 설정하는 데 적응이 되면, 장기적으로 더욱 간단하게 새로운 코드를 추가할 수 있습니다.
4. 리스코프 치환 원칙
리스코프 치환 원칙(LSP: Liskov substitution principle)은 파생된 클래스들은 필히 기반이 되는 클래스를 대신할 수 있어야 한다는 원칙입니다. 객체 지향 프로그래밍에서 상속은 하위 클래스들을 통해서 새로운 기능을 추가할 수 있도록 해줍니다. 그러나, 여러분이 주의해서 사용하지 않으면 불필요한 복잡성을 야기할 수 있습니다.
리스코프 치환 원칙은 SOLID의 세 번째 기둥으로, 하위 클래스들을 더 견고하고 유연하게 만들기 위해 상속을 어떻게 적용해야 하는지를 말해줍니다.
Vehicle이라는 이름의 클래스를 요구하는 게임을 상상해봅시다. 이 클래스는 애플리케이션을 위해 생성해야 하는 차량 자식 클래스들의 부모 클래스입니다. 예를 들어, 승용차(car)와 트럭(truck)이 필요하다고 가정해보죠.
부모 클래스(Vehicle)를 사용할 수 있는 곳이라면, 어디서든지 애플리케이션을 수정하지 않아도 Car 또는 Truck과 같은 자식 클래스를 사용할 수 있어야 합니다.
Vehicle 클래스는 아래와 같이 생겼을 겁니다:
public class Vehicle
{
public float speed = 100;
public Vector3 direction;
public void GoForward()
{
...
}
public void Reverse()
{
...
}
public void TurnRight()
{
...
}
public void TurnLeft()
{
...
}
}
보드판 위에서 차량들을 움직이는 턴제 기반 게임을 개발 중이라고 가정해봅시다.
지정된 경로를 자동차가 따라가도록 만들기 위해서는 Navigator라는 이름의 또 다른 클래스가 필요할 것입니다:
public class Navigator
{
public void Move(Vehicle vehicle)
{
vehicle.GoForward();
vehicle.TurnLeft();
vehicle.GoForward();
vehicle.TurnRight();
vehicle.GoForward();
}
}
클래스를 보시면 어떤 자동차도 Navigator 클래스의 Move 메서드를 통과할 수 있다는 걸 짐작할 수 있습니다. 또한 자동차와 트럭과도 잘 동작할 것입니다. 그러나 만약, 여러분이 Train이라는 이름의 클래스르 구현하고자 할 때는 무슨 일이 벌어질까요?
열차는 철로를 벗어날 수 없으므로, TurnLeft, TurnRight 메서드는 Train 클래스에서는 동작하지 않을 것입니다. 만약 여러분이 열차를 Navigator 클래스의 Move 메서드로 통과시키면, 해당 줄의 코드를 실행할 때, 구현되지 않은 예외(unimplemented Exception)가 발생(하거나 아무런 일도 일어나지 않을)할 것입니다. 만약 어떠한 타입을 하위 타입으로 대신할 수 없다면, 여러분은 리스코프 치환 원칙을 위반한 것입니다.
Train 클래스는 Vehicle 클래스의 하위 타입이기 때문에 Vehicle 클래스를 사용할 수 있는 어떤 곳에서도 Train 클래스를 사용할 수 있다고 예상해볼 수 있습니다. 만약 그렇지 않다면 여러분의 코드는 예측할 수 없는 방향으로 동작하겠죠.
아래에 리스코프 치환 원칙을 더욱 엄격하게 준수하기 위한 몇 가지 팁들을 살펴보세요:
- 만약 하위 클래스를 생성할 때 기능들을 제거한다면, 아마 리스코프 치환 원칙을 위반하고 있을 것입니다: NotImplementedException라는 예외는 리스코프 치환 원칙을 위반했을 때 발생하는 예외입니다. 메서드를 공백으로 남겨두는 것도 같은 문제를 야기할 수 있습니다. 만약 자식 클래스가 부모 클래스처럼 동작하지 않는다면, 라스코프 치환 원칙을 따르지 않은 것입니다. 설령 에러나 오류를 일으키지 않았더라도 말입니다.
- 추상화는 단순하게 유지하세요: 부모 클래스에 더 많은 기능을 담을수록 리스코프 치환 원칙을 위반할 가능성이 더 높아집니다. 부모 클래스는 오직 파생된 자식 클래스들의 공통된 기능들만 표현해야 합니다.
- 자식 클래스들은 부모 클래스의 public 멤버들을 동일하게 가져야 합니다: 또한 이 멤버들은 호출되었을 때 동일한 시그니처와 기능을 가지고 있어야 합니다.
- 클래스의 계층 구조를 설정하기 전에 클래스 API를 고려하세요: 모든 게 Vehicle 타입에 속한다 하더라도, Car 클래스와 Train 클래스가 별도의 부모 클래스들로부터 상속을 받는다고 생각하는 것이 더 합리적일 수 있습니다. 현실 속의 분류체계는 항상 클래스 계층 구조로 변환되지 않을 수 있습니다.
- 상속 대신 합성을 더 우선시하세요: 상속을 통해 기능을 넘겨주는 것 대신, 인터페이스 또는 캡슐화되어 특정한 동작을 수행하는 분리된 클래스를 생성하세요. 그런 다음, 섞고 조합한 다른 기능들을 “합성”하여 전체 코드를 완성하세요.
위의 디자인을 수정하기 위해, 기존의 Vehicle 타입을 폐기하고, 대부분의 기능들을 인터페이스로 옮기겠습니다:
public interface ITurnable
{
public void TurnRight();
public void TurnLeft();
}
public interface IMovable
{
public void GoForward();
public void Reverse();
}
리스코프 치환 원칙을 더 엄격하게 준수하기 위해 RoadVehicle 타입과 RailVehicle 타입을 생성하겠습니다. Car와 Train은 각각 해당되는 부모 클래스들을 상속할 것입니다.
public class RoadVehicle : Imovable, Iturnable
{
public float speed = 100f;
public float turnSpeed = 5f;
public virtual void GoForward()
{
…
}
public virtual void Reverse()
{
…
}
public virtual void TurnLeft()
{
…
}
public virtual void TurnRight()
{
…
}
}
public class RailVehicle : Imovable
{
public float speed = 100;
public virtual void GoForward()
{
…
}
public virtual void Reverse()
{
…
}
}
public class Car : RoadVehicle
{
…
}
public class Train : RailVehicle
{
…
}
위와 같은 방법으로 코드를 작성하면, 상속 대신 인터페이스를 통해 기능들이 전달됩니다. Car 클래스와 Train 클래스는 더 이상 동일한 부모 클래스를 공유하지 않으므로, 리스코프 치환 원칙을 준수하고 있습니다. 동일한 부모 클래스로부터 RoadVehicle과 RailVehicle 자식 클래스를 유도할 수는 있으나, 위와 같은 상황에서는 그다지 필요성이 있어 보이지는 않습니다.
이러한 사고 방식은 우리가 현실 세계에 대한 어떠한 가정들을 지니고 있기 때문에 직관적으로 느껴지지 않을 수 있습니다. 소프트웨어 개발에서 이러한 문제를 ‘원-타원 문제’라고 부릅니다. 상속이 적용되는 모든 관계가 실제로 “is a” 관계로 변환될 수 있는 것은 아닙니다. 명심하세요, 여러분은 소프트웨어 디자인이 현실 세계의 배경 지식이 아니라, 클래스 계층 구조를 주도하기를 원하는 것입니다.
리스코프 치환 원칙을 준수하여 여러분의 코드 베이스가 확장성과 유연성을 유지할 수 있도록 상속을 사용하는 방법을 제한하도록 만드세요.
5. 인터페이스 분리 원칙
인터페이스 분리 원칙(ISP: Interface segregation principle)은 어떤 클라이언트도 강제로 사용되지 않는 메서드에 의존해서는 안 된다는 원칙입니다.
즉, 거대한 규모의 인터페이스 사용을 피하라는 것입니다. 클래스와 메서드를 짧게 유지해야 한다는 단일 책임 원칙과 동일한 개념을 따르고 있죠. 이 원칙은 여러분에게 최대한의 유연함을 제공하며, 인터페이스들을 간결하고 명확하게 유지할 수 있습니다.
다양한 플레이어 유닛을 지니고 있는 전략 게임을 개발한다고 상상해봅시다. 각각의 유닛은 체력과 속도처럼 각기 다른 능력치를 지니고 있습니다. 아마 여러분은 아래와 같이 인터페이스를 만들어 모든 종류의 유닛들이 유사한 기능들을 상속하는 것을 보장하게 하고 싶으실 겁니다.
public interface IUnitStats
{
public float Health { get; set; }
public int Defense { get; set; }
public void Die();
public void TakeDamage();
public void RestoreHealth();
public float MoveSpeed { get; set; }
public float Acceleration { get; set; }
public void GoForward();
public void Reverse();
public void TurnLeft();
public void TurnRight();
public int Strength { get; set; }
public int Dexterity { get; set; }
public int Endurance { get; set; }
}
자, 이제 파괴할 수 있는 배럴이나 나무 상자같이 파괴할 수 있는 소품들을 게임에 추가하고 싶다고 가정해 봅시다. 이 소품들도 체력의 개념은 필요하겠지만, 움직일 수는 없을 것입니다. 또한 나무 상자나 배럴은 게임 속 다른 유닛들과 연관된 다양한 능력을 갖추지 않습니다.
파괴할 수 있는 소품 오브젝트에 너무 많은 방법을 제공하는 하나의 인터페이스를 사용하는 것 대신, 더 작은 인터페이스 여러 개로 분할하세요. 이제 인터페이스를 구현하는 클래스는 요구사항에 맞춰 필요한 부분만 섞고 조합하면 됩니다.
public interface IMovable
{
public float MoveSpeed { get; set; }
public float Acceleration { get; set; }
public void GoForward();
public void Reverse();
public void TurnLeft();
public void TurnRight();
}
public interface IDamageable
{
public float Health { get; set; }
public int Defense { get; set; }
public void Die();
public void TakeDamage();
public void RestoreHealth();
}
public interface IUnitStats
{
public int Strength { get; set; }
public int Dexterity { get; set; }
public int Endurance { get; set; }
}
또한 여러분은 IExplodable 인터페이스를 추가하여 아래와 같이 폭발하는 배럴을 구현할 수도 있죠:
pubilc interface IExplodable
{
public float Mass { get; set; }
public float ExplosiveForce { get; set; }
public float FuseDelay { get; set; }
public void Explode();
}
클래스는 한 개 이상의 인터페이스를 구현할 수 있기 때문에, IDamageable, IMoveable, IUnitStats 인터페이스를 사용해 적군 유닛을 구성할 수 있습니다.
폭발하는 배럴은 IDamageable과 IExplodable인터페이스를 사용할 수 있습니다. 다른 인터페이스의 불필요한 오버헤드 없이 말이죠.
public class ExplodingBarrel : MonoBehaviour, IDamageable, IExplodable
{
...
}
public class EnemyUnit : MonoBehaviour, IDamageable, IMovable, IUnitStats
{
...
}
즉, 이는 리스코프 분리 원칙에서 언급되었던 예제처럼, 컴포지션을 상속보다 더 선호한다는 것을 보여줍니다. 인터페이스 분리 원칙은 여러분의 시스템을 분리하고 보다 쉽게 수정 및 재배치할 수 있도록 도와줍니다.
6. 의존관계 역전 원칙
의존 관계 역전 원칙(DIP: dependency inversion principle)은 상위 모듈이 하위 모듈에서 어떤 것이든 직접적으로 가져다 쓰면 안 된다는 것을 말합니다. 즉, 두 모듈은 추상화에 의존해야 한다는 겁니다.
이것이 정확히 어떤 의미인지 하나씩 파헤쳐 봅시다. 클래스 하나가 다른 클래스와 관계를 맺고 있을 때, 이 두 클래스는 의존성 또는 결합도를 가집니다. 소프트웨어 디자인 내에서 각각의 의존성은 어떠한 리스크를 가지고 있죠.
만약 한 클래스가 다른 클래스가 어떻게 동작하는지 너무 잘 알고 있다면, 첫 번째 클래스를 수정하는 것이 두 번째 클래스에 대미지를 줄 수 있으며, 그 반대의 경우도 가능합니다. 높은 수준의 결합도를 보이는 코드는 좋지 않은 코드 작성법으로 여겨집니다. 애플리케이션의 한 부분에서 발생한 에러가 눈덩이처럼 불어나 수많은 에러를 야기할 수 있기 때문이죠.
현실적으로 봤을 때, 클래스들 사이의 낮은 결합도를 추구하는 것은 물론 가능합니다. 또한 각각의 클래스는 외부 연결에 의존하는 것보다, 클래스 내부에서 함께 조화를 이루어 동작하는 내부 파트가 필요합니다. 여러분의 오브젝트는 클래스 내부 또는 private 로직에서 동작할 때의 응집력을 고려해야 합니다.
최적의 시나리오는 느슨한 결합도와 높은 응집력을 추구하는 것입니다.
여러분은 여러분의 게임 애플리케이션을 수정 및 확장할 수 있어야 합니다. 만약 애플리케이션이 망가지기 쉽고, 코드를 수정하는 것에 저항이 있다면, 현재 코드 구조가 어떻게 되어 있는지 분석해 보세요.
의존 관계 역전 원칙은 클래스 사이의 강한 결합도를 낮추는 데 도움을 줄 수 있습니다. 여러분의 애플리케이션 내에 클래스와 시스템을 구축할 때, 일부는 자연적으로 ‘고수준’이 되고, 일부는 ‘저수준’이 됩니다. 고수준 클래스는 어떠한 작업을 끝마치기 위해 저수준 클래스에 의존하고 있습니다. SOLID 원칙은 우리에게 이러한 구조를 바꾸라고 말해주죠.
캐릭터가 레벨을 탐험하며 트리거를 사용해 문을 열 수 있는 게임을 만들고 있다고 상상해 봅시다. 여러분은 아마 Switch라는 이름의 클래스와 Door라는 다른 이름의 클래스를 생성하고 싶으실 겁니다.
고수준에서 생각해 보면, 여러분은 캐릭터가 특정한 위치로 이동하고 어떠한 일이 벌어지기를 원할 것입니다. Switch 클래스가 이러한 일들에 반응하겠죠.
저수준에는 또 다른 클래스, Door 클래스가 존재합니다. 이 클래스는 어떻게 문이 기하학적으로 열리는 것인지를 실제로 구현하는 코드를 포함하고 있습니다. 단순하게 생각하기 위해 문이 열리고 닫히는 로직을 나타내는 Debug.Log문을 하나 추가해 봅시다.
public class Switch : MonoBehaviour
{
public Door door;
public bool isActivated;
public void Toggle()
{
if (isActivated)
{
isActivated = false;
door.Close();
}
else
{
isActivated = true;
door.Open();
}
}
}
public class Door : MonoBehaviour
{
public void Open()
{
Debug.Log(“The door is open.”);
}
public void Close()
{
Debug.Log(“The door is closed.”);
}
}
Switch 클래스는 문을 여닫기 위해 Toggle 메서드를 호출합니다. 물론 잘 동작은 합니다만, Door 클래스에서 Switch 클래스로 직접적인 종속성이 연결되어 있다는 문제가 있습니다. 만약 Switch 클래스가 단순히 예시로 든 문을 여닫는 로직 외에 전등을 제어하거나 거대한 로봇을 조작하는 것 같은 더 많은 기능을 수행해야 한다면, 어떻게 될까요?
Switch 클래스에 별도의 메서드를 추가할 수도 있습니다. 하지만 그렇게 하면 개방-폐쇄 원칙을 위반하는 것이 됩니다. 여러분이 원하는 기능을 확장할 때마다 매번 기존의 코드를 수정해야 한다는 거죠.
이때 추상화가 또 한 번 우리를 구해줍니다. ISwitchable이라는 이름의 인터페이스는 여러분의 클래스 사이에 끼워 넣을 수 있습니다.
ISwitchable 인터페이스는 단순히 활성화 여부를 알 수 있는 public 속성과 Activate, Deactivate 메서드만 가지고 있으면 됩니다.
public interface ISwitchable
{
public bool IsActive { get; }
public void Activate();
public void Deactivate();
}
이제 Switch 클래스를 아래와 같이 door 클래스를 직접 참조하는 것 대신, ISwitchable 타입의 client 객체를 사용하도록 수정합니다.
public class Switch : MonoBehavior
{
public ISwitchable client;
public void Toggle()
{
if (client.IsActive())
{
client.Deactivate();
}
else
{
client.Activate();
}
}
}
또 다른 방법으로는 Door 클래스가 ISwitchable 인터페이스를 구현하도록 수정해도 됩니다.
public class Door : MonoBehavior, ISwitchable
{
private bool isActivate;
public bool IsActive => isActive;
public void Activate()
{
isActivate = true;
Debug.Log(“The door is open.”);
}
public void Deactivate()
{
isActivate = false;
Debug.Log(“The door is closed.”);
}
}
이제 여러분은 종속성을 반전시켰습니다. 인터페이스는 Switch 클래스와 Door 클래스 사이에 강한 종속성을 지니도록 만드는 것 대신, 클래스들 사이에 추상화를 생성하였습니다. Switch 클래스는 더 이상 직접적으로 문을 조작하는 것에 특화된 메서드(Open, Close 메서드)와 연관되어 있지 않습니다. 그 대신 ISwitchable 인터페이스의 Activate와 Deactivate 메서드를 사용하고 있죠.
이 작지만, 중요한 변화는 코드의 재사용성을 끌어올려 주었습니다. 기존의 Switch 클래스는 오직 Door 클래스하고만 작동할 수 있었습니다. 하지만 이제는 ISwitchable 인터페이스를 구현하는 어떠한 클래스하고도 함께 동작할 수 있게 되었습니다.
이는 여러분이 Switch 클래스를 활성화할 수 있는 더 많은 클래스를 만들 수 있게 해줍니다. 고수준에 해당하는 Switch 클래스는 문이 함정 문이든 레이저 빔이든 어쨌거나 잘 동작할 것입니다. 단지 ISwitchable 인터페이스를 구현하여 호환되는 client 메서드만 있으면 됩니다.
SOLID의 나머지 원칙들처럼 의존 관계 역전 원칙은 여러분에게 일반적으로 클래스 사이의 관계를 어떻게 설정해야 하는지에 대해 검토하라는 질문을 던져줍니다. 느슨한 결합도를 통해 여러분의 프로젝트 규모를 자유롭게 조절해 보세요.
인터페이스 vs 추상 클래스
“상속보다는 인터페이스” 철학을 따르기 위해, 이 가이드에서 제공하는 다양한 예제들이 인터페이스를 사용하고 있습니다. 하지만, 이 대신 추상 클래스를 사용하고 있는 다양한 디자인 원칙이나 패턴들을 따라 하셔도 됩니다.
두 가지 방법 모두 C#에서 추상화를 달성할 수 있는 유효한 방법입니다. 여러분이 마주한 상황에 따라서 둘 중의 하나를 골라 사용하시면 됩니다.
추상 클래스
abstract라는 키워드는 여러분에게 부모 클래스를 정의하고, 일반적인 기능들(메서드, 필드, 상수 등)을 부모 클래스를 상속하는 자식 클래스에 넘겨줄 수 있습니다.
추상 클래스를 직접 인스턴스화하는 것은 불가능합니다. 그 대신, 구체적인 클래스를 파생해야 합니다.
앞에서 언급된 예제에서 추상 클래스는 다른 접근법을 사용하고 있음에도 의존 관계 역전 원칙을 똑같이 달성할 수 있습니다. 그러므로 인터페이스를 사용하는 것 대신 구체적인 클래스(Light 또는 Door 클래스)를 Switchable 추상 클래스로부터 파생시켜도 됩니다.
상속은 “is a” 관계를 정의합니다. 위 그림 속 다이어그램을 보면 Switchable 추상 클래스가 모든 “전환할 수 있는(switchable)” 요소들을 켜고 끌 수 있게 만들어 주고 있습니다.
추상 클래스의 장점은 필드와 static 멤버로 잘 알려진 상수를 가질 수 있다는 것입니다. 이와 더불어 protected와 private처럼 코드에 접근해 값을 수정하는 것을 더 상세하게 제한할 수 있습니다. 인터페이스와 달리, 추상 클래스는 구체적인 클래스들 사이의 핵심적인 기능들을 공유할 수 있도록 함으로써 여러분이 로직을 구현할 수 있게 해줍니다.
상속은 두 가지 이상의 다른 부모 클래스의 특성이 있는 파생 클래스를 만들고 싶을 때 문제가 될 수 있습니다. C#에서는 다중 상속 개념을 지원하지 않아 두 개 이상의 부모 클래스를 동시에 상속할 수 없기 때문입니다.
만약 여러분의 게임 속 모든 로봇에 대한 또 다른 추상 클래스가 있다면, 어떤 추상 클래스를 상속해야 하는지 결정하기 난감할 것입니다. Robot 또는 Switchable 부모 클래스 중 어떤 걸 사용해야 할까요?
인터페이스
인터페이스 분리 원칙에서 볼 수 있듯이, 인터페이스는 상속의 패러다임에 깔끔하게 맞아떨어지지 않는 상황에서보다 더 유연한 대처가 가능하게 만들어 줍니다. 여러분은 더욱 쉬운 “has a” 관계를 선택해 사용할 수 있죠.
그러나, 인터페이스는 오직 멤버들의 선언만 가질 수 있습니다. 구체적인 특정 로직을 구현하는 책임은 인터페이스를 실제로 구현하는 클래스가 가지게 됩니다.
그러므로, 항상 ‘모 아니면 도’ 형식의 결정을 내릴 수 있는 건 아닙니다. 코드를 공유하고 싶은 곳에서 기본적인 기능들을 정의할 때는 추상 클래스를 사용하세요. 유연성이 필요한 곳에서는 부가 기능들을 정의하기 위해 인터페이스를 사용하세요.
다음의 예시를 보면, 추상 클래스인 Robot 부모 클래스로부터 파생된 NPC 클래스를 만들었습니다. 하지만 ISwitchable 인터페이스를 사용하여 NPC를 켜고 끌 수 있는 능력도 추가해 주었죠.
추상 클래스와 인터페이스 사이의 차이점을 아래 표를 통해 기억해 두세요.
추상 클래스 | 인터페이스 |
완전하거나 부분적인 메서드 구현 | 메서드 선언은 가능하나, 구현은 불가능 |
선언/사용 가능한 변수와 필드 | 오직 메서드와 프로퍼티만 선언 가능 (필드는 선언 불가) |
static 멤버를 가질 수 있음 | static 멤버 선언/사용 불가 |
모든 접근 제어자 사용 가능 (protected, private 등) | 접근 제어자 사용 불가 (모든 멤버는 public 형식으로 간주함) |
명심하세요: 한 클래스는 오직 한 개의 추상 클래스만 상속할 수 있지만, 인터페이스는 여러 개를 동시에 구현할 수 있습니다.
7. 단단하게(SOLID) 핵심 다지기
SOLID 원칙을 익히는 것은 매일 연습해야 하는 숙제입니다. 아래의 5가지 기본 규칙들을 코딩하실 때마다 항상 기억해 주세요. 여기 요약본을 드리겠습니다:
- 단일 책임: 클래스들은 오직 한 가지 기능만 수행해야 하며, 이를 수정해야 할 이유도 오직 한 가지 이여만 합니다.
- 개방-폐쇄: 기존에 작성해 둔 코드를 수정하지 않고 클래스의 기능을 확장할 수 있어야 합니다.
- 리스코프 치환: 자식 클래스는 부모 클래스를 대체할 수 있어야 합니다.
- 인터페이스 분리: 적은 수의 메서드를 가진 짧은 인터페이스를 사용해야 합니다. 클라이언트는 딱 필요한 부분만 구현해야 합니다.
- 의존 관계 역전: 추상 클래스에 의존해야 합니다. 특정 클래스가 다른 특정 클래스와 직접적인 의존 관계를 맺으면 안 됩니다.
SOLID 원칙은 더욱 깔끔한 코드를 작성할 수 있도록 도와줌으로써 코드 유지 보수 및 확장이 더욱 용이하게 해 줍니다. SOLID 원칙은 거의 20년 동안 기업 수준의 소프트웨어 디자인을 지배하다시피 쓰였습니다. 이는 대규모 수준의 애플리케이션을 개발할 때 효과적이기 때문입니다.
어떤 경우에는, SOLID 원칙을 준수하는 것이 추가적인 작업을 야기할 수 있습니다. 여러분이 작성한 기능들을 추상화 또는 인터페이스로 리팩토링해야 할 수도 있죠. 그러나, 장기적으로 보면 이것이 종종 자원을 더 절약할 방법이 되기도 합니다.
여러분의 프로젝트에 SOLID 원칙을 얼마나 엄격하게 적용할 것인지는 여러분 스스로 결정하시면 됩니다. 이 원칙들은 미묘한 차이점이 있고 여기서는 다루지 않은 다양한 구현 방법들도 존재합니다. 명심하세요: 앞에서 보이는 특정한 구문보다 이러한 원칙 뒤에 있는 사고방식이 더욱 중요합니다.
어떻게 SOLID 원칙들을 사용해야 할지 확신이 서지 않는다면, KISS 원칙으로 돌아가 보세요. 단순하게 유지하는 겁니다. 그리고 이 원칙들을 여러분의 스크립트에 억지로 적용하려고 하지 마세요. 단지 내가 필요하기 때문에 쓰는 도구가 되어야 합니다. 자연스럽게 필요에 따라 조직적으로 작동하도록 내버려 두세요.
더 많은 정보를 원하신다면, Unite Austin에서 소개된 Unity SOLID 강연을 살펴보세요.
수고하셨습니다!
'Unity Learn > Game Programming Patterns' 카테고리의 다른 글
6. Singleton pattern (0) | 2023.10.31 |
---|---|
5. Object pool (0) | 2023.10.23 |
4. Factory pattern (0) | 2023.10.11 |
3. Design patterns for game development (0) | 2023.10.05 |
1. Introducing design patterns (0) | 2023.09.07 |