본문 바로가기
TIL

객체지향 설계의 5가지 원칙 - SOLID

by vvin39 2025. 5. 16.

내일배움캠프 28일차 TIL

 

📌 SOLID란?

객체지향 프로그래밍에서 유지보수, 확장성, 유연성을 높이기 위해 지켜야 할 다섯 가지 원칙의 약어.

 

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

"클래스는 하나의 변경 이유만 가져야 한다"

즉, 하나의 책임(역할) 만 가져야 한다는 뜻. 하나의 클래스는 한 가지 일만 잘해야 한다!

단일 책임 원칙이 왜 중요할까?

  • 여러 책임을 한 클래스가 맡으면, 하나의 기능을 바꾸는 것만으로도 클래스 전체가 영향을 받음
  • 버그 발생 시 원인을 찾기 어렵고, 유지보수가 힘들어짐

비유로 이해해보기

택배 상자가 물건도 담고, 주소 출력도 하고, 배달도 한다면?

하나 바꾸면 다 망가짐!

→ 각 역할을 "상품 상자", "송장 프린터", "배달 기사"로 나누면 훨씬 유연하다!

코드 예시

플레이어가 게임에 처음 입장하면

  1. 플레이어 정보를 생성하고
  2. 파일에 저장하고
  3. 환영 메시지를 화면에 띄운다

❌ SRP 위반 코드 (하나의 스크립트가 모든 책임을 가짐)

using UnityEngine;
using System.IO;

public class PlayerManager : MonoBehaviour
{
    public void Start()
    {
        CreatePlayer("Player1");
    }

    void CreatePlayer(string playerName)
    {
        Debug.Log($"플레이어 {playerName} 생성됨");

        // 파일 저장
        File.WriteAllText(Application.persistentDataPath + "/player.txt", playerName);

        // UI 메시지 출력
        GameObject.Find("MessageText").GetComponent<TMPro.TextMeshProUGUI>().text = $"환영합니다, {playerName}!";
    }
}

 

⚠ 문제점

  • 이 PlayerManager 클래스가 (플레이어 생성, 파일 저장, UI 메시지 출력) 3가지 역할을 모두 수행
  • UI나 저장 방식이 바뀌면 이 클래스도 바꿔야 함 → 변경 이유가 여러 개

✅ SRP 적용 코드 (역할을 나눔)

 

1. Player.cs – 데이터를 표현하는 클래스

public class Player
{
    public string Name;

    public Player(string name)
    {
        Name = name;
    }
}

 

2. PlayerCreator.cs – 플레이어 생성 책임

using UnityEngine;

public class PlayerCreator : MonoBehaviour
{
    public Player CreatePlayer(string name)
    {
        Debug.Log($"플레이어 {name} 생성됨");
        return new Player(name);
    }
}
 

3. PlayerSaver.cs – 파일 저장 책임

using System.IO;
using UnityEngine;

public class PlayerSaver : MonoBehaviour
{
    public void Save(Player player)
    {
        File.WriteAllText(Application.persistentDataPath + "/player.txt", player.Name);
    }
}

 

4. UIManager.cs – UI 출력 책임

using UnityEngine;
using TMPro;

public class UIManager : MonoBehaviour
{
    public TextMeshProUGUI messageText;

    public void ShowWelcomeMessage(string playerName)
    {
        messageText.text = $"환영합니다, {playerName}!";
    }
}

 

5. GameManager.cs – 기능들을 조합하는 상위 클래스

using UnityEngine;

public class GameManager : MonoBehaviour
{
    public PlayerCreator playerCreator;
    public PlayerSaver playerSaver;
    public UIManager uiManager;

    void Start()
    {
        var player = playerCreator.CreatePlayer("Player1");
        playerSaver.Save(player);
        uiManager.ShowWelcomeMessage(player.Name);
    }
}

 

항목 SRP 위반 SRP 적용
변경 이유 파일 저장 방식이 바뀌면 UI 코드도 영향받음 저장만 수정하면 PlayerSaver만 수정하면 됨
재사용성 PlayerManager 재사용 어려움 각 컴포넌트는 독립적으로 재사용 가능
테스트 통합 테스트만 가능 유닛 테스트가 쉬움

 

정리

  • 하나의 클래스가 "AND"로 연결된 기능을 하고 있다면 SRP 위반일 수 있음
  • 단일 책임으로 나누면 코드가 늘어나는 것처럼 보여도, 버그 수정·유지보수는 훨씬 쉬움
  • 각 책임은 나중에 테스트, 재사용, 교체하기 쉬운 단위가 됨
  • Unity에서 MonoBehaviour를 남용하면 쉽게 SRP를 어기게 됨
  • 기능별 스크립트 분리는 처음엔 번거롭지만, 나중에 유지보수에서 큰 이득
  • SRP는 “대규모 프로젝트”일수록 진가를 발휘!

 

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

“확장에는 열려 있고, 변경에는 닫혀 있어야 한다”
즉, 기존 코드를 수정하지 않고도 새로운 기능을 추가할 수 있어야 한다는 원칙.

개방 폐쇄 원칙이 왜 중요할까?

  • 기존 코드를 건드리면 버그가 생길 위험이 큼
  • 이미 안정된 기능은 그대로 두고, 새로운 기능만 확장할 수 있어야 유지보수가 쉬움

비유로 이해하기

편의점 계산대가 모든 결제 수단을 if문으로 처리한다면?
새로운 결제 수단이 나올 때마다 기존 코드를 계속 고쳐야 함 → 리스크 증가

→ 💡 "결제 수단"이라는 추상 인터페이스를 만들고, 각 결제 방법은 확장해서 구현만 하면 됨!

코드 예시

플레이어가 적을 공격할 때 다양한 무기 타입(칼, 활, 마법 등) 을 사용할 수 있음

 

OCP 위반 코드 (무기 타입 추가할 때마다 기존 코드 수정)

public class PlayerAttack : MonoBehaviour
{
    public string weaponType;

    public void Attack()
    {
        if (weaponType == "Sword")
        {
            Debug.Log("칼로 공격!");
        }
        else if (weaponType == "Bow")
        {
            Debug.Log("활로 공격!");
        }
        else if (weaponType == "Magic")
        {
            Debug.Log("마법 공격!");
        }
    }
}

 

⚠ 문제점

  • 무기 타입이 늘어날 때마다 기존 if-else를 계속 수정해야 함
  • 변경에 닫혀있지 않음 → OCP 위반

✅ OCP 적용 코드: 다형성을 통한 확장

1. IWeaponAttack.cs – 무기 인터페이스 정의

public interface IWeaponAttack
{
    void Attack();
}

 

2. 무기 클래스들 – 각각 독립 구현

public class SwordAttack : IWeaponAttack
{
    public void Attack()
    {
        Debug.Log("칼로 공격!");
    }
}

public class BowAttack : IWeaponAttack
{
    public void Attack()
    {
        Debug.Log("활로 공격!");
    }
}

public class MagicAttack : IWeaponAttack
{
    public void Attack()
    {
        Debug.Log("마법 공격!");
    }
}

 

3. PlayerAttack.cs – 변경 없이 확장 가능

using UnityEngine;

public class PlayerAttack : MonoBehaviour
{
    public IWeaponAttack weapon;

    public void PerformAttack()
    {
        weapon.Attack();
    }
}

 

4. 사용 예

void Start()
{
    PlayerAttack player = GetComponent<PlayerAttack>();
    player.weapon = new SwordAttack(); // 나중에 MagicAttack으로 교체 가능
    player.PerformAttack();
}

 

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

“자식 클래스는 부모 클래스로 교체할 수 있어야 한다”

즉, 부모 클래스를 사용하는 코드에서 자식 클래스로 바꿨을 때도 아무 문제 없이 동작해야 한다는 원칙

 

리스코프 치환이 왜 중요할까?

  • 상속을 잘못 사용하면 오히려 유연성이 떨어지고 버그가 생김
  • "is-a" 관계를 신중히 판단하지 않으면 대체가 안 되는 자식 클래스가 만들어짐

 

비유로 이해하기

“컵은 물, 주스, 커피를 담을 수 있다.”
그런데 "컵"을 상속한 "종이컵"이 끓는 물을 담지 못한다면?
컵 = 종이컵이 성립하지 않음 → 리스코프 위반!

 

코드 예시

여러 종류의 적(Enemy)이 있는데,
모든 적은 이동한다(move) 라는 공통 동작을 가짐

 

LSP 위반 예시

public class Enemy
{
    public virtual void Move()
    {
        Debug.Log("적이 이동 중");
    }
}

public class FlyingEnemy : Enemy
{
    public override void Move()
    {
        Debug.Log("적이 하늘을 날아 이동 중");
    }
}

public class StaticEnemy : Enemy
{
    public override void Move()
    {
        throw new System.NotSupportedException("이 적은 움직이지 않음");
    }
}

 

⚠ 문제점

  • StaticEnemy는 Enemy를 상속했지만 Move를 제대로 구현하지 않음
  • 부모 클래스 Enemy를 사용하는 코드에서 StaticEnemy로 교체하면 예외가 발생

이런 상황이 벌어짐

void MoveAllEnemies(List<Enemy> enemies)
{
    foreach (var e in enemies)
    {
        e.Move(); // ❌ StaticEnemy에서 예외 발생
    }
}

 

→ 이건 LSP 위반: 부모(Enemy)를 자식으로 바꿨더니 동작이 깨짐!

 

✅ LSP를 지킨 예시: 책임을 분리

 

인터페이스로 역할 분리

public interface IMovable
{
    void Move();
}

public class Enemy : MonoBehaviour
{
    public string Name;
}

public class MovingEnemy : Enemy, IMovable
{
    public void Move()
    {
        Debug.Log($"{Name} 이동 중");
    }
}

public class StaticEnemy : Enemy
{
    // 이동 기능 없음
}

 

이렇게 사용 가능

void MoveAllEnemies(List<IMovable> movables)
{
    foreach (var e in movables)
    {
        e.Move(); // ✅ 움직일 수 있는 적만 이동!
    }
}

 

기준 LSP 위반 LSP 준수
설계 모든 적이 Move()를 가짐 "움직일 수 있는 적"만 IMovable 구현
확장성 자식 클래스마다 NotImplementedException 주의 타입 안전하게 설계 가능
유지보수 예외 처리 복잡 역할 분리가 깔끔함

 

Unity 적용 팁

  • Unity에서는 MonoBehaviour 상속 구조가 많아서 부적절한 상속 남용이 자주 발생
  • “공통 부모”보다 “역할 기반 인터페이스”가 더 유연할 때가 많다!
  • AI, 무기, 이동, 상호작용 같은 기능은 구현 가능한 인터페이스로 분리하면 유리

 

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

클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다”

즉, 큰 인터페이스 하나를 강제로 쓰게 하지 말고, 작고 역할에 맞는 인터페이스 여러 개로 쪼개라는 원칙

 

인터페이스 분리 원칙은 왜 중요할까?

  • 불필요한 기능이 포함된 인터페이스는 클래스를 복잡하게 만들고
  • 쓰지도 않을 기능을 구현하느라 불필요한 코드가 생기고
  • 코드 변경 시 불필요한 연쇄적인 영향이 생김

 

비유로 이해하기

전동 드릴 하나에 못 박기, 페인트칠, 절단 기능이 다 있다고 해보자.

나는 못만 박고 싶은데 왜 절단 기능까지 알아야 하지?

→ 필요한 기능만 제공해라!

 

코드 예시

ICharacter라는 인터페이스에 다음 기능이 있다고 가정

  • Move()
  • Attack()
  • CastSpell()
  • Heal()

ISP 위반

public interface ICharacter
{
    void Move();
    void Attack();
    void CastSpell();
    void Heal();
}

 

이 인터페이스를 전사(Warrior)마법사(Mage) 가 모두 구현해야 한다면?

public class Warrior : ICharacter
{
    public void Move() => Debug.Log("이동!");
    public void Attack() => Debug.Log("근접 공격!");
    public void CastSpell() => throw new System.NotImplementedException(); // ❌
    public void Heal() => throw new System.NotImplementedException();     // ❌
}

 

⚠ 문제점

  • Warrior는 마법을 못 쓰는데 CastSpell()을 반드시 구현해야 함
  • 쓰지 않는 기능에 의존하고 있음 → ISP 위반

✅ ISP를 지킨 코드: 인터페이스 분리

 

1. 인터페이스들을 역할로 나누기

public interface IMovable
{
    void Move();
}

public interface IAttacker
{
    void Attack();
}

public interface ISpellCaster
{
    void CastSpell();
}

public interface IHealer
{
    void Heal();
}

 

2. 각 클래스는 필요한 인터페이스만 구현

public class Warrior : MonoBehaviour, IMovable, IAttacker
{
    public void Move() => Debug.Log("이동!");
    public void Attack() => Debug.Log("칼로 공격!");
}

public class Mage : MonoBehaviour, IMovable, ISpellCaster
{
    public void Move() => Debug.Log("이동!");
    public void CastSpell() => Debug.Log("파이어볼!");
}

 

3. 사용할 때도 필요한 인터페이스만 참조

void DoMove(IMovable character)
{
    character.Move();
}

void DoAttack(IAttacker attacker)
{
    attacker.Attack();
}

 

기준 ISP 위반 ISP 준수
인터페이스 크기 하나의 거대한 인터페이스 작고 역할별로 나뉜 인터페이스
구현 클래스 필요 없는 메서드도 구현해야 함 필요한 기능만 구현하면 됨
유연성 낮음 높음
유지보수 변경 범위 큼 변경이 최소화됨

 

Unity 적용 팁

  • MonoBehaviour는 모든 기능을 넣기 쉬워서 자주 ISP를 어긴다.
  • 움직임, 공격, 마법, 회복 등은 별도의 컴포넌트 또는 인터페이스로 분리하는 게 좋다.
  • 이렇게 하면 플레이어, NPC, 몬스터, 오브젝트 등 다양한 캐릭터에 재사용할 수 있다.

 

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

고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상(인터페이스)에 의존해야 한다.”

  • "고수준" 모듈: 중요한 비즈니스 로직(예: 게임 진행 제어)
  • "저수준" 모듈: 구체적인 구현 클래스(예: 데이터 저장, 사운드 재생)

 

왜 중요할까?

  • DIP를 지키지 않으면 한 클래스가 다른 클래스를 너무 많이 알고 있어서
  • 구현을 바꾸거나 테스트하기 어려워짐
  • 결합도는 높고, 유연성은 낮아짐

 

비유로 이해하기

마트 계산대(고수준 모듈)가 "현금 결제 시스템(저수준 모듈)"만 알면 카드 결제, 앱 결제는 못 씀
대신 "결제 인터페이스"에 의존하면 어떤 결제 방식이든 가능!

 

예시 코드

플레이어가 아이템을 저장할 수 있는 시스템이 있다.
처음엔 그냥 PlayerPrefs로 저장했다가
나중에는 파일, 서버, 암호화된 저장소로 바꾸고 싶을 수 있음.

 

❌ DIP 위반 예시

public class InventorySystem : MonoBehaviour
{
    public void SaveItem(string itemName)
    {
        PlayerPrefs.SetString("SavedItem", itemName); // 👈 PlayerPrefs에 직접 의존
        Debug.Log("아이템 저장됨");
    }
}

 

⚠ 문제점

  • 저장 방식이 바뀌면 InventorySystem을 직접 수정해야 함
  • 테스트도 어려움 (PlayerPrefs는 테스트 환경에서 문제 발생할 수 있음)

 

✅ DIP 준수 예시: 추상화 도입

 

1. 인터페이스 정의

public interface IDataSaver
{
    void Save(string key, string value);
}

 

2. 구현체 분리

public class PlayerPrefsSaver : IDataSaver
{
    public void Save(string key, string value)
    {
        PlayerPrefs.SetString(key, value);
    }
}

public class FileSaver : IDataSaver
{
    public void Save(string key, string value)
    {
        System.IO.File.WriteAllText($"{key}.txt", value);
    }
}

 

3. 고수준 모듈에서 인터페이스에만 의존

public class InventorySystem : MonoBehaviour
{
    private IDataSaver dataSaver;

    public void Init(IDataSaver saver)
    {
        dataSaver = saver;
    }

    public void SaveItem(string itemName)
    {
        dataSaver.Save("SavedItem", itemName);
        Debug.Log("아이템 저장됨");
    }
}

 

4. 초기화 시 구체 객체 주입

void Start()
{
    var inventory = GetComponent<InventorySystem>();
    IDataSaver saver = new PlayerPrefsSaver(); // 나중에 FileSaver로 교체 가능
    inventory.Init(saver);
}

 

기준 DIP 위반 DIP 준수
의존 구조 고수준 모듈이 직접 구현체 사용 고수준 모듈이 인터페이스에 의존
유연성 변경 시 전체 코드 수정 필요 구현 교체만 하면 됨
테스트 어려움 테스트 더블(목 등)로 손쉽게 테스트 가능
유지보수 어렵고 위험 모듈 교체가 쉬움

 

Unity 적용 팁

  • Unity는 보통 new를 잘 안 쓰고 컴포넌트를 붙이기 때문에 의존성 주입(DI) 은 직접 구성하거나, ScriptableObject 또는 Service Locator로 우회할 수 있다.
  • 유닛 테스트나 Mock 구현을 할 때 DIP가 매우 유용함

 

📌 SOLID 정리 다시 보기

원칙 핵심 의미 Unity 예시
SRP 하나의 책임만 가져야 한다 GameManager가 게임 시작/종료만 담당
OCP 확장엔 열려 있고 수정엔 닫혀야 한다 Strategy 패턴으로 무기 시스템 교체
LSP 부모를 자식으로 바꿔도 문제 없어야 한다 이동 불가능한 적은 IMovable 구현 X
ISP 인터페이스는 작게 나눠라 Move/Attack/Spell 인터페이스 분리
DIP 추상화에 의존하라 SaveSystem → IDataSaver 사용

 

 

 

 

최근댓글

최근글

skin by © 2024 ttuttak