[OOP] SOLID 원칙

심지섭·2025년 2월 25일
0

SOLID 원칙이란?

SOLID 원칙은 객체 지향 프로그래밍에서 코드 설계를 이해하기 쉽고 유연하게 만들고 유지보수를 용이하도록 만드는 5가지 원칙을 의미한다.
이 원칙을 통해 게임 시스템의 유지보수성과 확장성을 높일 수 있다.

단일 책임 원칙 (SRP, Single Responsibility Principle)

SRP는 클래스, 모듈은 오직 하나의 책임을 가져야 한다는 원칙이다. 즉, 클래스는 오직 하나의 변경 이유만 가져야 하며, 이를 통해 코드의 유지 보수성과 가독성을 높일 수 있다.
이러한 설계 원칙을 기반으로 하나의 큰 클래스를 만들기보다는 여러 개의 작은 클래스를 만드는 편이 좋다.
유니티의 컴포넌트들도 각각 한 가지 책임을 올바르게 수행한다. 예를들면 Renderer는 화면에 표시되는 방식을 제어하고, Rigidbody는 물리 시뮬레이션과 상호작용하는 하나의 책임을 수행한다.

SRP를 위반한 경우

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);
 transform.position = new Vector3(transform.position.x, yPosition *
 positionMultiplier, transform.position.z);
 }
 private void OnTriggerEnter(Collider other)
 {
 bounceSfx.Play();
 }
}

Player 컴포넌트 안에 Audio, Input, Movement를 모두 구현하면 각각에 해당하는 책임들을 하나의 클래스 안에서 모두 수행하게 된다. 이는 SRP를 잘 지키지 못한 예시이며, 규모가 커질수록 유지보수가 어려워지게 될 가능성이 크다.

SRP에 기반한 설계 방법

[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 클래스는 단순히 각 컴포넌트를 보유하고, 직접 기능을 수행하지 않는다.

그러면 이러한 의문이 떠오를 수 있다.
'단순히 각 컴포넌트를 보유만 할거면 Player는 무슨 필요가 있지?'

Player 클래스가 존재해야하는 이유는 이들을 조립하고 조정하는데에 있다. 즉, 플레이어라는 개념을 하나로 묶고 여러 시스템을 연계하는 역할을 한다.
이렇게 하면 각 클래스가 하나의 책임만 가지게 되어 변경이 필요할 때 서로 영향을 주지 않는다.
Movement, Input, Audio와 같은 비즈니스 클래스들이 서로 직접적인 의존 관계를 가지지 않도록 Player라는 중심 엔터티를 만든다고 생각하면 된다.

PlayerInput → PlayerMovement 직접 호출 ❌
PlayerInput → Player → PlayerMovement 호출 ⭕

이렇게 하면 SRP를 지키면서 유지보수성과 확장성을 높일 수 있다.

개방 폐쇄 원칙 (OCP, Open-Closed Principle)

OCP는 클래스가 확장에는 열려있고 수정에는 닫혀있어야 한다는 원칙이다. 즉, 새로운 기능을 만든다고 했을 때 기존의 클래스를 수정하는 것이 아닌 새로운 클래스를 만들어 확장 가능해야 한다는 것이다.

OCP를 위반한 경우

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 클래스는 감당할 수 없을 만큼 커지게 될 것이다. 이러한 경우 OCP를 위반했다고 볼 수 있다.

OCP에 기반한 설계 방법

public abstract class Shape
{
 public abstract float CalculateArea();
} 

Shape 추상 클래스를 정의하면 클래스 확장에 유연하게 설계 가능하다.

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;
 }
}

이렇게 모형에 대한 클래스를 만들고,

public class AreaCalculator
{
 public float GetArea(Shape shape)
 {
 return shape.CalculateArea();
 }
}
public class AreaCalculator : MonoBehaviour
{
 private void Start()
 {
 Debug.Log(GetArea(new RectAngle { width = 2, height = 3 }));
 Debug.Log(GetArea(new Circle { radius = 3 }));
 }
 public float GetArea(Shape shape)
 {
 return shape.CalculateArea();
}

AreaCalculator 클래스를 단순화 할 수 있다.

이렇게하면 AreaCalculator 클래스는 직접적인 수정없이 Shape 클래스를 적절히 구현하는 것으로 기능 확장이 가능해진다.

이러한 설계는 디버깅하기도 좋다. 새로운 모형에서 오류가 발생해도 AreaCalculator를 검사할 필요가 없다. 기존 코드는 변경 없이 유지되기 때문에, 새롭게 만든 코드만 확인하면 된다. 즉, 새로운 기능을 더 유연하게 추가 확장할 수 있다.

리스코프 치환 원칙(LSP, Liskov Substitution Principle)

LSP는 부모 클래스는 항상 자식 클래스로 대체될 수 있어야한다는 원칙이다. OOP에서 상속을 통해 기능을 추가할 수 있다. 하지만 조심해서 사용하지 않으면 오히려 복잡도가 높아질 수 있다.

LSP를 위반한 경우


위와 같이 Vehicle을 상속받는 Car와 Truck 클래스가 있다고 해보자. 여기까지는 크게 문제가 없다. 그러나 Train을 추가한다면 어떨까?

Train은 철도 위에서 직진, 후진만 할 뿐 좌회전, 우회전을 하지 않는다. 즉, Vehicle을 상속받았지만 TurnRight()와 TurnLeft()는 아무런 기능을 하지않는 일이 발생한다. 이러한 경우를 리스코프 치환 원칙을 위반했다고 한다.

LSP를 준수하기 위한 몇 가지 방안

1. 자식 클래스를 만들 때 메서드를 비워두는 일이 생기면 안된다.
NotImplementedException은 LSP를 위반했다는 의미이며, 메서드를 비워두는 경우도 마찬가지이다. 자식 클래스가 부모 클래스처럼 동작하지 않는다면 오류나 예외가 명시적으로 보이지 않더라도 LSP를 준수하지 않은 것이다.

2. 추상화를 단순하게 유지한다.
부모 클래스에 들어가는 로직이 많을수록 LSP를 위반할 확률도 커진다. 부모 클래스는 자식 클래스의 일반적인 기능만 표현해야 한다.

3. 자식 클래스는 부모 클래스와 동일한 공용 멤버를 가져야 한다.
공용 멤버는 호출 시 동일한 서명(Signature)과 동작을 취해야 한다.

4. 클래스 계층 구조를 수립할 때 클래스 API를 고려해야 한다.
대상을 모두 Vehicle로 간주하더라도 Car와 Train은 각각 서로 다른 부모 클래스로부터 상속하는 편이 더 나을 수 있다. 실질적으로 분류가 항상 클래스 계층 구조와 일치하지는 않는다.

5. 상속보다는 합성을 우선시 한다.
상속을 통한 기능 전달 대신, 특정한 동작을 캡슐화할 수 있도록 인터페이스나 별도의 클래스를 만드는 편이 좋다. 그런 다음 적절히 조합하여 다양한 기능의 합성물을 만든다.

LSP를 잘 지킨 경우

Vehicle 클래스를 삭제한 다음 대부분의 기능은 인터페이스로 옮긴다.

public interface ITurnable
{
 public void TurnRight();
 public void TurnLeft();
}
public interface IMovable
{
 public void GoForward();
 public void Reverse();
}


RoadVehicle 클래스와 RailVehicle 클래스를 만들면 LSP를 더 철저하게 지킬 수 있다. Car와 Train은 해당하는 부모 클래스로부터 상속한다.

이러한 방법에서는 기능이 상속 대신 인터페이스를 통해 실행된다. Car와 Train은 더 이상 같은 부모 클래스를 공유하지 않으며, 이로인해 자식 클래스인 Car, Train은 각각의 부모 클래스를 대체할 수 있게 된다. 즉, LSP를 준수한다.

인터페이스 분리 원칙(ISP, Interface Segregation Principle)

ISP는 반드시 객체가 자신에게 필요한 기능만을 가지도록 인터페이스를 분리하여 제한하는 원칙이다. 불필요한 상속이나 구현을 최대한 방지함으로써 객체의 불필요한 책임을 제거한다. 다시 말해, 인터페이스의 규모가 커지지 않도록 설계 해야 한다.

{
 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 class ExplodingBarrel : MonoBehaviour, IDamageable, IExplodable
{
 ...
}
public class EnemyUnit : MonoBehaviour, IDamageable, IMovable, IUnitStats
{
 ...
}

이러한 방식으로 인터페이스를 분리하여 오브젝트가 게임 환경과 더 유연한 방식으로 상호 작용 가능해진다.
ISP는 LSP와 유사하게 상속보다 합성을 우선시한다. 이는 시스템을 분리하고 간편하게 수정 및 확장하는데 도움이 된다.

의존성 역전 원칙(DIP, Dependency Inversion Principle)

DIP는 상위 수준의 모듈이 하위 수준의 모듈에서 어떠한 것도 직접 가져오면 안된다는 원칙이다. 이를 위해 양측 모두 추상화에 의존해야 한다.
DIP는 클래스 간의 결합도를 줄이는 데 도움이 될 수 있다. 애플리케이션에서 클래스와 시스템을 만들 때 자연스럽게 일부는 상위 수준이 되고 일부는 하위 수준이 된다. 보통 상위 수준 클래스는 하위 수준 클래스에 의존해서 작업을 수행하는데, SOLID 원칙에서는 이를 바꿔야 한다고 강조한다.
플레이어가 문을 트리거해서 여는 로직을 개발한다고 가정해보자.

DIP를 위반한 경우

{
 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 로직을 통해 Door 외의 조명, 전원 등을 토글하는 경우에 사용되려면 어떻게 해야할까? Switch 클래스에 해당 객체를 참조하여 해당하는 메서드를 추가할 수도 있지만, 그러면 OCP를 위반하게 되고, 기능을 확장할 때마다 원본 코드를 수정해야 한다.

DIP에 기반한 설계 방법

이러한 문제도 역시 추상화로 해결 가능하다. 클래스 사이에 ISwitchable 이라는 인터페이스를 삽입하면 된다.

public class Switch : MonoBehaviour
{
 public ISwitchable client;
 public void Toggle()
 {
 if (client.IsActive)
 {
 client.Deactivate();
 }
 else
 {
 client.Activate();
 }
 }
} 

이렇게하면 Switch는 문에 직접 의존하지 않고 ISwitchable 클라이언트에 의존하게 된다. 이전에는 Switch가 Door에만 작동했으나, 이제는 ISwitchable을 구현하는 모든 요소에 동작한다.

인터페이스를 도입하면서 저수준 모듈이 인터페이스를 구현하는 구조로 변경되었다. 이렇게 되면 구체적인 구현이 아닌 추상화에 의존하게 된다. 즉, 저수준 모듈이 고수준 모듈의 요구에 맞춰 동작하는 구조가 되어 의존성이 역전된다. 이를 통해 결합도를 낮춰 프로젝트를 간편하게 확장할 수 있다.

마무리

OOP의 기본이 되는 SOLID 원칙을 정리해 보았다.
절대적인 법칙은 아니지만, 이를 참고하면 유지보수성이 높고 깔끔한 코드를 작성하는 데 좋은 가이드라인이 될 것이다.

하지만 원칙에 얽매여 억지로 적용하기보다, KISS 원칙을 떠올리며 코드를 단순하게 유지하는 것이 더 중요하다.
확장할 필요가 없거나 한 번 구현 후 수정할 일이 거의 없는 기능이라면, 불필요한 추상화가 오히려 코드의 복잡도를 증가시킬 수도 있다.

결국, SOLID 원칙은 하나의 도구일 뿐, 무조건 적용해야 하는 법칙이 아니다.
따라서 필요할 때 유기적으로 활용하는 것이 가장 효과적이라고 생각한다.

레퍼런스

https://unity.com/kr/resources/design-patterns-solid-ebook
https://www.youtube.com/watch?v=J6F8plGUqv8

profile
게임 개발을 하며 배운 것과 경험한 것을 기록하는 공간입니다.

0개의 댓글