Unity

[Unity] 디자인 패턴 : SOLID 원칙

sunlight-dby 2025. 6. 9. 17:18

 

 

이 글은 해당 유튜브를 보고 진행한 것에 대해 공부한 것을 정리한 글입니다.

 


 

디자인 패턴 (Design Pattern)

디자인 패턴은 소프트웨어 개발에서 발생하는 공통적인 문제들을 해결하기 위해 전문가들이 축적한 재사용 가능한 설계 방법입니다.

디자인 패턴은 특정 언어나 프레임워크에 종속되지 않고, 객체 간의 관계와 역할을 정형화하는 데 목적이 있습니다.

복잡한 설계를 단순하게 만들고, 코드의 재사용성, 유지보수성, 확장성을 향상시키는 데 도움을 줍니다.

 

※ 다만, 디자인 패턴은 모든 곳에 적용할 필요는 없습니다.

    디자인 패턴이 필요한 곳에 적재적소에 맞게 사용하면 됩니다.


디자인 패턴의 분류

디자인 패턴은 목적에 따라 다음 세 가지로 분류됩니다.

※ 각 패턴에 대해서는 추후 자세히 다룰 예정이기에, 간단하게만 정리하겠습니다.

 

생성 패턴 (Creational Pattern)

객체 생성에 관련된 패턴으로, 객체 생성 과정을 캡슐화하여 유연하고 재사용 가능한 방식으로 제공합니다.

  • Singleton : 하나의 인스턴스만 생성, 전역 접근 제공
  • Factory Method : 객체 생성 로직을 서브 클래스에 위임
  • Abstract Factory관련된 객체들의 집합을 생성
  • Builder : 복잡한 객체를 단계별로 생성
  • Prototype : 기존 객체를 복사하여 새 객체 생성

구조 패턴 (Structural Pattern)

클래스나 객체를 조합해 더 큰 구조를 만들 때 사용하는 패턴입니다.

  • Adapter : 호환되지 않는 인터페이스를 호환 가능하게 변환
  • Bridge : 구현과 추상을 분리해 독립적으로 확장
  • Composite : 트리 구조처럼 객체들을 구성 (예 : UI 요소 계층)
  • Decorator : 기존 객체에 기능을 동적으로 추가
  • Facade : 복잡한 시스템을 단순한 인터페이스로 감싸기
  • Flyweight : 메모리 절약을 위해 공유 객체 사용
  • Proxy : 실제 객체에 대한 대리 객체 제공

행위 패턴 (Behavioral Pattern)

객체 간의 통신, 책임 분산, 흐름 제어와 관련된 패턴입니다.

  • Chain of Responsibility : 요청을 처리할 수 있는 객체들을 체인으로 연결하여, 그 중 하나가 요청을 처리하게 함
  • Command : 요청을 객체로 캡슐화해 호출자와 수행자를 분리
  • Interpreter :  언어나 문법을 정의하고 해석할 수 있도록 구조화된 표현 사용
  • Memento : 객체의 상태를 저장해 복원하는 것이 가능
  • Observer : 상태 변화를 감지하여 구독자에게 알림 (이벤트 시스템)
  • Strategy : 알고리즘을 런타임에 교체 가능
  • Command : 명령을 객체로 캡슐화해 요청자와 수행자 분리
  • State : 상태에 따라 동작을 바꾸는 객체
  • Template Method : 알고리즘 뼈대를 정의하고, 세부는 하위 클래스에서 구현
  • Iterator : 순차적으로 요소를 접근 (컬렉션 순회)
  • Mediator : 객체 간의 복잡한 상호작용을 중재자 객체가 관리
  • Visitor : 객체 구조를 변경하지 않고 새로운 연산을 정의할 수 있도록 함

Gang of Four

디자인 패턴을 공부하게 되면, "GOF (Gang of Four)"라는 단어를 자주 접하게 됩니다.

 

GOF는 소프트웨어 공학에서 디자인 패턴을 체계적으로 정리한 이들을 뜻하는 말입니다.

그래서 이들이 정리한 23가지의 디자인 패턴을 GOF 디자인이라고 합니다.

 

Gang of Four / [왼쪽부터] Ralph Johnson Eric Gamma Richard Helm John Vlissides


솔리드 원칙 (The SOLID principles)

디자인 패턴을 공부하기 전, 모든 디자인 패턴은 솔리드 원칙을 지키는 것을 전제로 하기 때문에 솔리드 원칙은 필수적으로 알아야 합니다.


Single responsibility principle (SRP : 단일 책임 원칙)

"클래스는 오직 하나의 책임(변화의 이유)만 가져야 합니다.

 즉, 클래스는 하나의 행위 또는 관심사에만 집중해야 하며, 그렇기에 변경할 때 이유는 하나만 있어야 합니다."

  • 모든 클래스는 하나의 책임만 가집니다.
  • 클래스는 그 책임과 관련된 데이터와 동작을 완전히 캡슐화해야 합니다.
  • 클래스가 제공하는 모든 메서드나 동작은 오직 그 책임에 속해야 합니다.

 

[ 지킬 때의 장점 ]

  Readability (가독성)

    •  단일 책임을 가지는 클래스는 기능이 제한되어 자연스럽게

       짧고 명확한 코드가 됩니다.

    •  이로 인해 코드의 가독성이 높아지고 유지보수가 쉬워집니다.

 

•  Extensibility (확장성)

    •  책임이 명확히 분리된 클래스는 새로운 기능 추가나 변경 시,

       다른 클래스에 영향을 주지 않기 때문에 확장하기 쉽습니다.

 

•  Reusability (재사용성)

    •  단일 책임을 가진 작고 독립적인 클래스는 다양한 맥락에서

       재사용되기 쉽습니다.

 

 

 

 

 

 


[ 예시 - 단일 책임 원칙이 지켜지지 않은 상태 ]

public class UnrefactoredPlayer : MonoBehaviour
{
    [SerializeField] private string inputAxisName;
    [SerializeField] private float positionMultiplier;
    private float yPosition;
    private AudioSource bounceSfx;
    
    private void Start()
    {
        bouceSfx = GetComponent<AudioSource>();
    }
    
    private void Update()
    {
        float delta = Input.GetAxis(input AxisName) * 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)
    {
        bouceSfx.Play();
    }
}

[ 예시 - 단일 책임 원칙이 지켜진 상태 ]

[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 Awake()
    {
        playerAudio = GetComponent<PlayerAudio>();
        playerInput = GetComponent<PlayerInput>();
        playerMovement = GetComponent<PlayerMovement>();
    }
}

====================================================================================

public class PlayerAudio : MonoBehaviour { ... }

====================================================================================

public class PlayerInput : MonoBehaviour { ... }

====================================================================================

public class PlayerMovement : MonoBehaviour { ... }

====================================================================================

[왼쪽] 단일 책임 원칙을 지키지 않을 때 / [오른쪽] 단일 책임 원칙을 지킬 때


Open-closed principle (OCP : 개방 폐쇄 원칙)

"이미 작동하는 것에 대해서는, 작동하는 방식을 변경하지 않고도 클래스의 기능을 확장할 수 있어야 합니다."

 

소프트웨어 요소(클래스, 모듈, 함수 등)는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 합니다.

즉, 기존 코드를 변경하지 않고도 새로운 기능이나 동작을 확장할 수 있어야 합니다.

  • 확장에 대해 열려 있음
    • 모듈의 기능을 확장할 수 있다는 것을 의미합니다.
    • 요구 사항이 추가될 때, 새로운 동작을 추가해 모듈을 확장할 수 있습니다.
    • 즉, 기존 기능을 유지한 채 새로운 동작을 추가할 수 있습니다.
  • 수정에 대해서는 닫혀 있음
    • 기존 클래스나 모듈의 코드를 수정하지 않아도 모듈의 기능을 확장하거나 변경이 가능해야 합니다.
    • 모듈의 라이브러리(ex : DLL)의 수정이 필요하지 않습니다.

[ 예시 - 개방 폐쇄 원칙이 지켜지지 않은 상태 ]

public class Rectangle
{
    public float width;
    public float height;
}

public class Circle
{
    public float radius;
}

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 abstract class Shape
{
    public abstract float CalculateArea();
}

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 cloat GetArea(Shape shape)
    {
        return shape.CalculateArea();
    }
}

[왼쪽] 개방 폐쇄 원칙을 지키지 않을 때 / [오른쪽] 개방 폐쇄 원칙을 지킬 때


Liskov substitution principle (LSP : 리스코프 치환 원칙)

"하위 클래스는 기본 클래스를 대체할 수 있어야 합니다. 즉, 기본 클래스의 방향성을 유지해야 합니다."

  • 파생 클래스는 기본 클래스를 대체할 수 있어야 합니다.
    • 상속받은 클래스는 부모 클래스의 방향성, 즉 부모 클래스가 보장하는 계약 또는 행동 규약을 지켜야 합니다.
  • 하위 클래스를 강력하고 유연하게 만드는 원칙입니다.
  • OOP의 상속을 사용하면 하위 클래스를 통해 기능을 추가할 수 있습니다.
    그러나, 주의하지 않으면 불필요한 복잡성이 발생할 수 있습니다. 따라서 부모 클래스의 방향성을 지켜줘야 합니다.
  • 서브 클래싱할 때 기능을 제거하는 경우는 리스코프 치환 원칙에 위배됩니다.
    ※ 서브 클래싱(Sub Classing) : Super Class에 구현된 코드와 내부 표현 구조를 Sub Class가 이어받는 것
  • 추상화(abstraction)를 단순하게 유지하는 것이 중요합니다.
  • 하위 클래스에는 기본 클래스의 퍼블릭 멤버가 존재합니다.
  • 상속(inheritance)보다는 구성(composition)에 초점을 맞춰야 합니다.
  • 클래스 계층 구조를 설정하기 전에 클래스 API를 고려해야 합니다.
    • 현실의 분류가 항상 클래스 계층 구조로 변환되는 것은 아닙니다.
      예를 들어, Car와 Train이 별도의 상위 클래스에서 상속받는 것이 더 합리적일 수 있습니다.

 

  • 리스코프 치환 원칙은 클래스가 a인지( "is a" )의 측면보다 인터페이스를 통해 a를 가지고 있는지( "has a")의 측면을 활용합니다.


[ 예시 : 리스코프 치환 원칙이 지켜지지 않은 상태 ]

public class Vehicle
{
    public float speed = 100;
    public Vector3 direction;
    
    public void GoForward() { ... }
    
    public void Reverse() { ... }
    
    public void turnRight() { ... }
    
    public void TurnLeft() { ... }
}

리스코프 치환 원칙을 지키지 않는 상태

 

[ 예시 : 리스코프 치환 원칙이 지켜진 상태 ]

public interface ITurnable
{
    public void TurnRight();
    public void TurnLeft();
}

public interface IMovable
{
    public void GoForward();
    public void Reverse();
}

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 = 100f;
    public virtual void GoForward() { ... }
    public virtual void Reverse() { ... }
}

리스코프 치환 원칙을 지키는 상태


Interface segregation principle (ISP : 인터페이스 분리 원칙)

"인터페이스는 작게 유지하고 클라이언트는 필요한 것만 구현합니다."

  • 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙입니다.
  • 큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리해야 합니다.
    • 클라이언트들이 꼭 필요한 메서드들만 이용할 수 있게 해야 합니다.
  • 해당 원칙을 통해 시스템의 내부 의존성을 약화하고 유연성을 강화할 수 있습니다.

[ 예시 - 인터페이스 분리 원칙이 지켜지지 않은 상태 ]

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 Reveerse();
    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 interface IUnitStats { ... }

public interface IExplodable { ... }

public class ExplodingBarrel :MonoBehaviour, IDamageable, IExplodable { ... }

public class EnemyUnit :MonoBehaviour, IDamageable, IMovable, IUnitStats { ... }

인터페이스 분리 원칙이 지켜진 상태


Dependency inversion principle (DIP : 의존 역전 원칙)

"추상화에 의존해야 합니다. 하나의 구체(적인) 클래스에서 다른 구체 클래스로 직접 의존하면 안됩니다."

  • 소프트웨어 모듈들을 분리하는 특정 형식에 해당하는 원칙입니다.
  • 상위(High-level) 모듈은 하위(Low-level) 모듈에 의존해서는 안 되며, 둘 다 추상황에 의존해야 합니다.
  • 추상화는 세부 사항에 의존해서는 안되며, 세부 사항이 추상화에 의존해야 합니다.
  • 클래스 간의 관계는 구체 클래스가 아닌 추상화를 통해 맺어야 합니다.
    • 클래스가 다른 클래스의 작동 방식을 많이 알고 있으면 안됩니다.
    • 클래스가 다른 클래스와 관계가 있을시, 종속성(dependency) 또는 결합(coupling)이 발생하게 되고, 이는 잠재적인 위험이 될 수 있습니다.

[왼쪽] 의존을 최소화한 상태 / [오른쪽] 의존이 많은 상태


[ 예시 : 의존 역전 원칙이 지켜지지 않은 상태 ] 

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.");
    }
}

[ 예시 : 의존 역전 원칙이 지켜진 상태 ]

public interface ISwitchable
{
    public bool IsActive { get; }
    public void Activate();
    public void Deactivate();
}

public class Door : MonoBehaviour, ISwitchable
{
    private bool isActive;
    public bool IsActive => isActive;
    public void Activate() 
    {
        isActive = true;
        Debug.Log("The door is open.");
    }
    public void Deactivate() 
    {
        isActive = false;
        Debug.Log("The door is closed.");
    }
}

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

 

[왼쪽] 의존 역전 원칙이 지켜지지 않은 상태 / [오른쪽] 의존 역전 원칙이 지켜진 상태]


추상 클래스 (abstract class) vs 인터페이스 (Interface)


is 관계, has 관계

상속 체계 상에 있는 클래스의 관계는 "is 관계"라고 합니다.

이 관계는 "A는 B이다."라고 말할 수 있는 관계로, 상속을 사용할 때 나타나는 관계입니다.

 

인터페이스를 가지는 경우는 "has 관계"라고 합니다.

이 관계는 "A는 B를 가진다."라고 말할 수 있는 관계로, 구성(Composition) 또는 인터페이스 구현으로 표현됩니다.

※ 사실상 인터페이스는 객체가 가진다고 보기보다는,

    해당 행위를 할 수 있다고 보는게 더 적합하여 "can - do" 관계라고 하는 것이 더 정확합니다.

 

[왼쪽] is관계에 대한 설명 / [오른쪽] is 관계, has 관계에 대한 설명

 

 

C#에서는 다중 상속이 불가능합니다. 그러나 인터페이스는 다중 구현이 가능합니다.

따라서 아래 상황에서, NPC가 Robot 클래스와 Switchable 클래스를 모두 상속받지 못하기 때문에, 두 기능을 모두 하기 위해서 인터페이스를 활용해야 합니다.

Robot과 Switchable이 추상 클래스일 때


※ C++에서의 다중 상속   

다이아몬드 상속(Diamond Problem)

class A { Func(); };
class B : public A { Func(); };
class C : public A { Func(); };
class D : public B, public C { }; // 다이아몬드 상속

 

다이아몬드 상속이란 한 클래스가 다른 두 클래스를 상속할 때, 그 두 클래스가 하나의 부모 클래스를 두고 있는 상태에서의 상속을 말합니다.

 

위의 코드에서 D 클래스는 B와 C로부터 각각 A를 상속받기 때문에, D는 A를 두 번 상속받는 문제가 발생하게 됩니다.

이는 D 객체 안에 A 클래스의 인스턴스가 두 개 존재하게 되는 것이기 때문에, 어느 A의 멤버를 사용할지 모호성이 발생합니다.

 

또한, D안에서 Func을 호출할 때, B쪽 A의 Func을 호출할지, C쪽 A의 Func을 호출할지 모호해집니다.

 

이러한 문제점으로 인해 유지보수 및 설계 복잡도가 증가하며, 오류 가능성 또한 높아집니다.

 

다이아몬드 상속과 SOLID 원칙 위배

다이아몬드 상속은 특히 아래 두 가지 SOLID 원칙을 위배할 수 있습니다.

  • 리스코프 치환 원칙
    • 자식 클래스가 부모 클래스를 일관되게 대체할 수 없어집니다.
    • 어느 상위 클래스의 멤버를 사용할지 애매해지므로 치환 가능성이 약화됩니다.
  • 단일 책임 원칙
    • 한 클래스가 여러 역할을 하게 될 수 있습니다.
    • 다중 상속으로 인해 클래스가 여러 부모의 책임을 떠안게 되어 응집도가 약화됩니다.

추상화 클래스

  • 메서드를 완전히 또는 일부 구현해야 합니다.
  • 변수 및 필드를 선언하고 사용하는 것이 가능합니다.
  • 정적(static) 멤버가 가능합니다.
  • 생성자(constructor)의 사용이 가능합니다.
  • 모든 엑세스 한정자(modifier)가 가능합니다.

인터페이스

  • 메서드의 선언만 가능합니다. 구현은 불가능합니다.
  • 메서드와 프로퍼티의 선언만 가능합니다. (필드는 제외합니다.)
  • 정적 멤버가 불가능합니다.
  • 생성자의 사용이 불가능합니다.
  • 모든 멤버는 퍼블릭(public)으로 취급합니다.

Unity의 SOLID 원칙

Unity 엔진은 SOLID 원칙을 의도하고 설계하였습니다.

아래는 Unity 엔진의 Button.cs 스크립트와 Selectable.cs 스크립트입니다.

 

스크립트를 보시면 아시겠지만, Button 클래스는 Selectable 클래스를 상속받고, 이외의 필요한 것은 인터페이스를 활용합니다.

IPointerClickHandler, ISubmitHandler 등 동작마다 인터페이스를 나누고 있습니다.

 

Selectable 클래스 역시 UIBehaviour 클래스를 상속받고, 이외의 필요한 것은 모두 인터페이스를 활용합니다.

 

 

Button과 Selectable 모두 하나의 역할에 집중해 설계되어 있어 "단일 책임 원칙"을 잘 따르고 있습니다.

 

또한, Unity UI 시스템은 기본 동작을 변경하지 않고도 인터페이스를 통해 기능 확장이 가능합니다. 이를 통해 "개방-폐쇄 원칙"을 잘 따르고 있는 것을 알 수 있습니다.

 

Button이 Seletable 같은 상위 레벨 모듈에 의존하며, 구체적인 구현보다는 인터페이스에 의존하는 구조로 "의존 역전 원칙"도 잘 지켜지고 있습니다.


[ Packages/com.unity.ugui/Runtime/UI/Core/Button.cs ]

namespace UnityEngine.UI
{
    /// <summary>
    /// A standard button that sends an event when clicked.
    /// </summary>
    [AddComponentMenu("UI/Button", 30)]
    public class Button : Selectable, IPointerClickHandler, ISubmitHandler
    {
        [Serializable]
        /// <summary>
        /// Function definition for a button click event.
        /// </summary>
        public class ButtonClickedEvent : UnityEvent {}

        // Event delegates triggered on click.
        [FormerlySerializedAs("onClick")]
        [SerializeField]
        private ButtonClickedEvent m_OnClick = new ButtonClickedEvent();
        
        // ...

[ Packages/com.unity.ugui/Runtime/Ul/Core/Selectable.cs ]

namespace UnityEngine.UI
{
    [AddComponentMenu("UI/Selectable", 35)]
    [ExecuteAlways]
    [SelectionBase]
    [DisallowMultipleComponent]
    /// <summary>
    /// Simple selectable object - derived from to create a selectable control.
    /// </summary>
    public class Selectable 
        : UIBehaviour, IMoveHandler, IPointerDownHandler, IPointerUpHandler,
        IPointerEnterHandler, IPointerExitHandler, ISelectHandler, IDeselectHandler
    {
        protected static Selectable[] s_Selectables = new Selectable[10];
        protected static int s SelectableCount = 0;
        
        // ...