본문 바로가기
- CS/OOP

[OOP] SOLID 설계 원칙

by david_동근 2025. 6. 7.

solid한 사람이 되어야겠습니다.


 

SOLID 설계 원칙

SOLID는 Object-Oriented(객체 지향) 설계의 5가지 핵심 원칙을 말합니다.

각각 SRP, OCP, LSP, ISP, DIP 이 5개의 원칙을 의미하며, 유지보수와 확장성을 높입니다.

유연하고 견고한 소프트웨어를 만들기 위해 여러 디자인 패턴에 입각해서 만들어집니다.

 

해당 포스트에서는 인터페이스 개념을 선행으로 알고 있으면 도움이 됩니다.

https://bulletprooves.tistory.com/36

 

[C#] virtual abstract interface

OOP에서 상속에 관한 키워드 virtual(추상), abstract(가상) 한정자와 interface를 정리했습니다.세가지 키워드 모두 Override(재정의) 할 수 있다는 공통점이 있지만, 사용 방식과 목적에 차이가 있습니다.

bulletprooves.tistory.com

 

 

SRP; Single Response Principle

단일 책임 원칙, 클래스가 단 하나의 책임만 가져야 합니다.

쉽게 생각하면... 하나의 클래스가 오직 하나의 변경될 이유를 가져야 합니다.

예를 들어, PlayerManager 라는 클래스에 뭔가 계속 덕지덕지 붙고 있다면, 분리를 고민해봅니다.

특히 Component 기반 설계의 이점이 있는 Unity 이기에, SRP 원칙 실천에 유리할 것 같습니다.

public class PlayerManager : MonoBehaviour
{
    public int hp = 100;

    void Update()
    {
        Move();
        SaveData();
        DisplayUI();
    }

    void Move() { /* 어쩌구 이동이나 공격이나 움직이는 거 */ }
    void SaveData() { /* 저쩌구 */ }
    void DisplayUI() { /* 궁시렁 */ }
}

 

우와 같이 작성하게 될 경우, PlayerManager 가 이동, 저장, UI 표시하는 것까지

관리하게 되므로 책임이 과다 상태입니다.

public class PlayerMovement : MonoBehaviour
{
    public void Move() { /* 어쩌구 직접 이동하는 거 */ }
}

public class PlayerDataSaver : MonoBehaviour
{
    public void SaveData() { /* 저쩌구 저장은 여기서 따로 */ }
}

public class PlayerUI : MonoBehaviour
{
    public void DisplayUI() { /* 궁시렁 UI는 여기서 관리 */ }
}

 

위 처럼 각 클래스마다 하나의 역할만 담당하도록 구성하여, 유지보수나 테스트, 확장 등에 유리합니다.

 

OCP; Open/Closed Principle

개방/폐쇄 원칙, 소프트웨어 요소는 확장에 열려 있어야하고, 변경에는 닫혀 있어야 합니다.

뭔가 기능을 추가하고 싶다면 기존 코드 변경 대신에, 새 클래스를 추가하는 방법을 기능을 추가하는 것 입니다.

예를 들자면...

if - else 나 switch 문에 기능을 추가하는 것보다, 다형성으로 클래스를 분리하거나, (확장)

Update() 에서 상태별로 행동을 처리하는 것보다, State (or) Strategy 디자인 패턴을 따르거나,

UI 관리할 땐, IUIHandler 인터페이스를 따로 만들어 처리 하는 등

정도로 Unity 에서 OCP를 실천할 수 있을 것 같습니다.

+ OCP 는 스크립트 변경없이 Prefab 혹은 Component 로만으로도, 원하는 행동을 유도할 수 있을 때의 진가입니다.

ScriptablObject 나 위에서 말한 디자인 패턴, DI 등으로 응용할 수 있습니다.

public class EnemySpawner : MonoBehaviour
{
    public void Spawn(string enemyType)
    {
        if (enemyType == "Zombien")
            Instantiate(Resources.Load("Zombie"));
        else if (enemyType == "Skeleton")
            Instantiate(Resources.Load("Skeleton"));
        else if (enemyType == "Spider")
            Instantiate(Resources.Load("Creeper"));
        // it goes on and on 이에용~
    }
}

 

위와 같이 작성하게 되면, 적 종류가 늘어날 수록 EnemySpawner 클래스의 내부 코드를 수정하게 됩니다.

(위에서 말했 듯이, OCP 원칙의 '변경에 닫혀'가 안되겠네용)

OCP 원칙을 적용하기 위해, 다형성 & 추상화를 고려해 볼 수 있습니다.

// 전체 공통 인터페이스를 만들어 둬용
public interface IEnemyFactory
{
    GameObject CreateEnemy();
}

// IEnemyFactory 상속, 하나씩 구현
public class GoblinFactory : IEnemyFactory
{
    public GameObject CreateEnemy() => Resources.Load<GameObject>("Zombie");
}

public class OrcFactory : IEnemyFactory
{
    public GameObject CreateEnemy() => Resources.Load<GameObject>("Skeleton");
}

// EnemySpawner는 더 이상 내부 수정 없이 확장 가능하답니당~
public class EnemySpawner : MonoBehaviour
{
    public void Spawn(IEnemyFactory factory)
    {
        Instantiate(factory.CreateEnemy());
    }
}

 

EnemySpawner 는 인터페이스로 맨 위에 한 번만 짜두고, 절대 수정할 일이 없으므로,

변경에 닫혀 있다고 할 수 있습니다.

 

LSP; Liskov Substitution Principle

리스코프 치환 원칙, 자식 클래스는 부모 클래스를 완전히 대체할 수 있어야 합니다.

(상속받았으면, 부모 클래스가 기대하는 모든 동작을 지켜야 하죵)

부모 클래스로 만든 객체를 자식 객체로 바꿔도 의미나 기능 등이 달라지면 LSP 위반입니다.

public class Bird {
    public virtual void Fly() {
        Debug.Log("날수 있지롱");
    }
}

public class Ostrich : Bird {
    public override void Fly() {
        throw new Exception("응~ 타조는 못 날아");
    }
}
// 다른 스크립트
Bird myBird = new Ostrich();
myBird.Fly();  // 런타임 에러

 

위에서 오스트리치는 못 나니까 Fly 메서드 기능이 (Bird 는 Fly 한다라는 전제가 무너져용) 구현이 안됩니다.

public interface IFlyable {
    void Fly();
}

public class Bird { }
public class Sparrow : Bird, IFlyable {
    public void Fly() => Debug.Log("참새는 잘만 날아 댕기지롱!");
}

public class Ostrich : Bird {
    // 날지 못하니께, IFlyable 구현 안 함
}
// 날 수 있는 새들만 모아놓음
List<IFlyable> flyingBirds = new List<IFlyable> {
    new Sparrow(),
    // new Ostrich() 컴파일 에러나니까 빼버려유
};

foreach (var bird in flyingBirds) {
    bird.Fly();
}

 

오스트리치는 못 나니까 IFlyable 을 구현하지 않았습니다.

LSP 원칙을 지키며, 또 동시에 상속이 기대를 어기지 않았습니다.

※ 오버라이드랑 구분을 잘 해야하는게, 부모거를 자식이 재정의 하는 것만으로는 LSP 지킨다기 보다는,
LSP 원칙이 깨지는 게, 부모가 기대하는 행동을 자식이나 거부하거나 못 할 때니까,
위에 예시처럼 아예 상속 구조를 바구고 Fly 기능을 인터페이스로 분리한 것 입니다.

 

상속 받는 구조를 만들 때, 이 타입들이 이 기능을 항상 구현해야하나...? 를 떠올립시다.

 

ISP; Interface Segregation Principle

인터페이스 분리 원칙, 클라이언트는 자신이 사용하지 않는 메서드에 의존해서는 안됩니다.

거대하고 묵직한 인터페이스 하나에 다 땨려 박지 말고, 작게 목적에 따라 여러개로 쪼개는 게 낫다입니다.

InputHandler, EventListener 등 처럼 클래스가 너무 많은 인터페이스를 구현하고 있다면,

기능별로 나누고, 각각 인터페이스를 분리하는 방법을 고려하는 것입니다.

// 하나의 인터페이스에 너무 많은 행동이 들어감
public interface ICharacter
{
    void Move();
    void Attack();
    void Heal();
    // ... 어쩌구 메서드 잔뜩
}
// 힐러는 Attack이 필요 없음
public class Healer : MonoBehaviour, ICharacter
{
    public void Move() { /* 어쩌구 */ }
    public void Attack() { throw new NotImplementedException(); } // 노노
    public void Heal() { /* 저쩌구 */ }
}

 

힐러는 Attack 구현도 안하고 필요도 없는데,

이 처럼 사용하지 않는 인터페이스에 의존하게 되는 걸 ISP 위반이라 봅니다.

아래 처럼 인터페이스를 분리해줍시다.

public interface IMovable { void Move(); }
public interface IAttackable { void Attack(); }
public interface IHealable { void Heal(); }

public class Healer : MonoBehaviour, IMovable, IHealable
{
    public void Move() { /* 어쩌구 */ }
    public void Heal() { /* 저쩌구 */ }
}

public class Warrior : MonoBehaviour, IMovable, IAttackable
{
    public void Move() { /* 어쩌구 */ }
    public void Attack() { /* 궁시렁 */ }
}

 

이렇게 캐릭터 포지션 별로 필요한 기능만 구현하도록 깔끔하게 짜줍니다.

IUIHandler로 UI의 여러 요소들을 구현하는 것보다, IOnClick, IOnHover, ICastable 쪼개거나,

MonoBehaviour 에서 이벤트를 수신 하는 것 보다,

IDamageable, ICollectable 등 이벤트에 맞게 인터페이스를 도입해줍니다.

 

DIP; Dependency Inversion Principle

의존 역전 원칙, 상위 모듈은 하위 모듈에 의존하면 안되며,

둘 다 추상(이터페이스나 추살 클래스)에 의존해야 합니다.

즉, 상위 로직이 하위 구현에 끌려다니지 말고, 구체적인 클래스 대신, 인터페이스(추상화 된)에 의존해야 합니다.

public class FileLogger {
    public void Log(string msg) {
        Debug.Log("파일 저장 : " + msg);
    }
}

public class GameManager : MonoBehaviour
{
    private FileLogger logger = new FileLogger(); // 구체적 클레스 구현에 직접적인 의존

    void Start() {
        logger.Log("Game Start");
    }
}

 

GameManager 가 FileLogger 에 직접적으로 의존하고 있습니다.

콘솔 로그를 바구고 싶거나, 서버로 전송이 필요할 경우, GameManager 를 직접 수정해야하므로, DIP 원칙에 위반입니다.

아래 처럼 인터페이스로 추상화 해줌으로, DIP 원칙을 따를 수 있습니다.

public interface ILogger {
    void Log(string msg);
}

public class FileLogger : ILogger {
    public void Log(string msg) {
        Debug.Log("파일 로그 : " + msg);
    }
}

public class ConsoleLogger : ILogger {
    public void Log(string msg) {
        Debug.Log("콘솔 로그 : " + msg);
    }
}
public class GameManager : MonoBehaviour {
    private ILogger logger;

    // 의존성 주입 (생성자/프로퍼티/서비스 로케이터 등 가능)
    public GameManager(ILogger logger) {
        this.logger = logger;
    }

    void Start() {
        logger.Log("Game Start");
    }
}

 

GamaManager 는 구체적인 구현(FileLogger, ConsoleLogger)에 관심 없응게,

이제 어떤 로거를 넣든 유연하게 동작합니다. DIP를 준수한 것이라 볼 수 있습니다.

고수준(상위) 묘듈로 수정할 필요가 없으니 기능을 교체하기 좋고,

Unit 테스트 때 Mocking 의 어려움을 어느정도 극복할 수 있으며,

의존성이 복잡하게 얽히는 것을 예방할 수 있습니다.

(ScriptableObject 기반 DI 로, 의존성 주입에 관해서 팁이 있는데, 그건 다른 포스트에서 다룰게용)

(DIP 에 대해서는 Inpa 님 께서 쉽게 설명해주셨어용! https://inpa.tistory.com/entry/OOP-%F0%9F%92%A0-%EC%95%84%EC%A3%BC-%EC%89%BD%EA%B2%8C-%EC%9D%B4%ED%95%B4%ED%95%98%EB%8A%94-DIP-%EC%9D%98%EC%A1%B4-%EC%97%AD%EC%A0%84-%EC%9B%90%EC%B9%99)

 

💠 완벽하게 이해하는 DIP (의존 역전 원칙)

의존 역전 원칙 - DIP (Dependency Inversion Principle) DIP 원칙이란 객체에서 어떤 Class를 참조해서 사용해야하는 상황이 생긴다면, 그 Class를 직접 참조하는 것이 아니라 그 대상의 상위 요소(추상 클래스

inpa.tistory.com

 

 


그럼 오늘도 좋은 하루되세용~ (⸝⸝˃ ᵕ ˂⸝⸝)

'- CS > OOP' 카테고리의 다른 글

[CS] DI Dependency Injection  (2) 2025.06.23
[OOP] OOP Object-Oriented Programming 개요  (1) 2025.06.07
[OOP] Design Patterns ( GoF )  (0) 2025.05.26