본문 바로가기
공부/디자인 패턴

객체지향 디자인 패턴 - 설계원칙 SOLID

by 라이티아 2024. 10. 7.

SOLID설계 원칙은 이하의 다섯가지 원칙으로 구성됨

 

S - single responsibility principle : 단일 책임 원칙

O - open closed principle : 개방 폐쇄 원칙

L - liskov substitution principle : 리스코프 치환 원칙

I - interface segregation principle : 인터페이스 분리 원칙

D - deppendency inversion principle : 의존 역전 원칙

 

 

1. 단일 책임 원칙

[ 클래스는 단 한 개의 책임을 가져야 한다 ]

 

예시

 데이터를 읽는 클래스가 데이터를 작성도 같이 한다

 

문제점

 한 책임의 변경으로 인해서 다른 책임과 관련된 코드가 모두 같이 변경될 가능성을 가져옴

 

오류 예시

public class Player : MonoBehaviour
{
    private int health;
    private int score;

    void Update()
    {
        // 플레이어 이동 처리
        Move();

        // 점수 업데이트
        UpdateScore();

        // UI 업데이트
        UpdateUI();
    }

    private void Move()
    {
        // 이동 로직
    }

    private void UpdateScore()
    {
        // 점수 계산 로직
    }

    private void UpdateUI()
    {
        // UI 업데이트 로직
    }
}

플레이어가 너무 많은 책임을 가지고 있다

문제점

  1. 책임 분리 부족: Player 클래스가 이동, 점수 계산, UI 업데이트 등 여러 가지 책임을 동시에 처리하고 있습니다.
  2. 유지보수 어려움: 코드가 복잡해지고 한 부분을 수정할 때 다른 부분에 영향을 줄 가능성이 높아집니다.
  3. 테스트 어려움: 각 기능이 섞여 있기 때문에 특정 기능을 독립적으로 테스트하기 어렵습니다.

개선 후

public class Player : MonoBehaviour
{
    private int health;
    private ScoreManager scoreManager;

    void Start()
    {
        scoreManager = new ScoreManager();
    }

    void Update()
    {
        Move();
        scoreManager.UpdateScore();
        scoreManager.UpdateUI();
    }

    private void Move()
    {
        // 이동 로직
    }
}

public class ScoreManager
{
    private int score;

    public void UpdateScore()
    {
        // 점수 계산 로직
    }

    public void UpdateUI()
    {
        // UI 업데이트 로직
    }
}

플레이어의 책임과 점수를 관리하는 책임을 분리시켰다

장점

  1. 책임 분리: Player 클래스는 플레이어의 이동만 담당하고, 점수와 UI 업데이트는 ScoreManager가 담당합니다.
  2. 유지보수 용이: 각 클래스가 독립적으로 기능을 수행하므로 수정할 때 다른 부분에 영향을 덜 미칩니다.
  3. 테스트 용이: ScoreManager를 독립적으로 테스트할 수 있어 각 기능의 신뢰성을 높일 수 있습니다.

 

2. 개방 폐쇄 원칙

[ 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다 ]

< 기능을 변경하거나 확장할 수 있으면서 > < 그 기능을 사용하는 코드는 수정하지 않는다 >

 

 기능을 확정할 수있으면서, 코드 자체에 수정을 하지 말하는 것이다

 

 나는 이를 코드를 규격화 하라는 느낌으로 받아 들였다

 

 간단한 예시를 보자면

 

계산시에 쿠폰을 받는다면

 

계산할때

 if 쿠폰1 제시 return 가격

 else 쿠폰 2 제시 return 가격

.....

 

이렇게 쿠폰에 대해서 전부 하면 쿠폰이 있을때 마다 if문이 늘어나는, 즉, 폐쇄 원칙이 무너진 상태인데,

이를

 

계산할때

 가격 = 계산 (쿠폰1)

 

 자료형 계산 (쿠폰 객체명)

 return 가격

 

이렇게 하고

이후 쿠폰들이 이 객체제 맞추게 된다면

계산할때의 클래스는 변경되지 않지만, 새로운 쿠폰의 발행에 대해서 개방되어 있게 된다

 

 

 예시

 

개방 폐쇄 원칙을 지키지 않은 적 클래스

public class Enemy : MonoBehaviour
{
    public enum EnemyType { Goblin, Orc, Dragon }
    public EnemyType enemyType;

    void Attack()
    {
        if (enemyType == EnemyType.Goblin)
        {
            // Goblin 공격 로직
        }
        else if (enemyType == EnemyType.Orc)
        {
            // Orc 공격 로직
        }
        else if (enemyType == EnemyType.Dragon)
        {
            // Dragon 공격 로직
        }
    }
}

문제점

  1. 수정 시의 위험: 새로운 적 유형을 추가하려면 Enemy 클래스를 수정해야 합니다. 이로 인해 기존의 코드에 버그가 생길 위험이 커집니다.
  2. 코드 복잡성 증가: if 문이 많아지면 코드가 복잡해지고 가독성이 떨어집니다. 나중에 새로운 적 유형이 추가될 때마다 이 로직을 계속 수정해야 하므로 유지보수도 어려워집니다.
  3. 테스트 어려움: 여러 적 유형의 로직이 하나의 클래스에 섞여 있기 때문에 각 적 유형을 독립적으로 테스트하기 어려워집니다.

 

수정 후

public interface IEnemy
{
    void Attack();
}

public class Goblin : MonoBehaviour, IEnemy
{
    public void Attack()
    {
        // Goblin 공격 로직
    }
}

public class Orc : MonoBehaviour, IEnemy
{
    public void Attack()
    {
        // Orc 공격 로직
    }
}

public class Dragon : MonoBehaviour, IEnemy
{
    public void Attack()
    {
        // Dragon 공격 로직
    }
}

장점

  1. 확장 용이: 새로운 적 유형을 추가하려면 새로운 클래스를 생성하고 IEnemy 인터페이스를 구현하면 됩니다. 기존 코드는 수정할 필요가 없습니다.
  2. 가독성 증가: 각 적 유형이 독립적인 클래스로 존재하므로 코드가 더 깔끔하고 이해하기 쉬워집니다.
  3. 테스트 용이: 각 적 유형의 공격 로직을 독립적으로 테스트할 수 있어, 코드의 신뢰성을 높일 수 있습니다.

 

간단히 말하면

개방 폐쇄 원칙은 유연성을 늘려주면서, 코드의 수정을 최소화 한다고 생각한다

 

3. 리스코프 치환 원칙

[ 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 작동해야 한다 ]

 

문제점 예시

public class Weapon
{
    public virtual void Use()
    {
        // 기본 무기 사용 로직
    }
}

public class Gun : Weapon
{
    public override void Use()
    {
        // 총 사용 로직
    }
}

public class Sword : Weapon
{
    public override void Use()
    {
        // 검 사용 로직
    }
}

public class EmptyGun : Gun
{
    public override void Use()
    {
        throw new System.Exception("이 총은 장전되지 않았습니다!");
    }
}

문제점

  1. 예외 발생: EmptyGun 클래스는 Gun의 서브타입이지만, Use 메서드에서 예외를 발생시키므로 Weapon 타입으로 대체할 수 없습니다. 이로 인해 클라이언트 코드에서 Weapon 타입을 사용하던 부분이 EmptyGun을 사용하면 예외가 발생하게 됩니다.
  2. 기대치 불일치: 클라이언트가 Weapon 타입으로 처리하는 코드가 있을 때, 서브타입으로 EmptyGun을 전달하면 그 코드가 의도하지 않은 방식으로 동작하게 됩니다.
  3. 유지보수 어려움: 만약 새로운 무기 타입을 추가할 때, 무기의 기본 사용 방식과 예외 처리를 모두 고려해야 하므로 코드의 복잡성이 증가합니다.

개선 후

public interface IWeapon
{
    void Use();
}

public class Gun : IWeapon
{
    private bool isLoaded;

    public Gun(bool isLoaded)
    {
        this.isLoaded = isLoaded;
    }

    public void Use()
    {
        if (!isLoaded)
        {
            // 장전되지 않은 경우의 처리 로직
            // 예: 경고 메시지 출력
            Debug.Log("이 총은 장전되지 않았습니다!");
            return;
        }
        
        // 총 사용 로직
    }
}

public class Sword : IWeapon
{
    public void Use()
    {
        // 검 사용 로직
    }
}

장점

  1. 일관성 유지: 모든 무기 클래스는 IWeapon 인터페이스를 구현하므로, Use 메서드는 항상 정상적으로 동작하며 예외를 발생시키지 않습니다.
  2. 예외 처리 분리: Gun 클래스 내에서 장전 여부를 확인하고 적절한 로직을 처리하므로 클라이언트 코드는 항상 동일한 방식으로 무기를 사용할 수 있습니다.
  3. 유지보수 용이: 무기 타입이 추가될 때, 각 타입이 IWeapon을 구현하므로 기본 사용 방식이 일관되게 유지됩니다.

 

즉, a를 상속한 b가 a로 들어가게 되더라도 이상없이 작동할 수 있는가에 대해서 질문하는 것 같다

 

잘못된 예시를 볼시, use에 대해서 빈총에 대해서 처리될때 weapon객체가 들어가게 되면 예외처리가 나게 된다

 

수정 후의 예시는 interface를 사용해서 첫 틀을 잡았는데, 이때 Iweapon의 경우 객체를 생성할 수 없기 때문에, 코드를 작성할때 정확도를 높일 수 있다

 

이러한 위반 사례를 보면

1. 명시된 명세에서 벗어난 값을 리턴한다

2. 명시된 명세에서 벗어난 익셉션을 발생한다

3. 명시된 명세에서 벗어난 기능을 수행한다

 

가 대표적 예시라고 한다

 

 

4. 인터페이스 분리 원칙

[ 인터페이스는 그 인터페이스를 사용하는 클라이언트를 기준으로 분리해야 한다 ]

 

인터페이스 분리 원칙(ISP, Interface Segregation Principle)은 "클라이언트는 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다"는 원칙

 

예시

public interface IWeapon
{
    void Use();
    void Reload();
    void Repair();
}

public class Gun : IWeapon
{
    public void Use()
    {
        // 총 사용 로직
    }

    public void Reload()
    {
        // 장전 로직
    }

    public void Repair()
    {
        // 수리 로직
    }
}

public class Sword : IWeapon
{
    public void Use()
    {
        // 검 사용 로직
    }

    public void Reload() // 필요 없는 메서드
    {
        throw new System.NotImplementedException();
    }

    public void Repair()
    {
        // 수리 로직
    }
}

문제점

  1. 불필요한 메서드: Sword 클래스는 IWeapon 인터페이스의 모든 메서드를 구현해야 하므로, 실제로 필요하지 않은 Reload 메서드를 구현해야 합니다. 이는 코드의 가독성을 떨어뜨리고, 잘못된 사용을 유도할 수 있습니다.
  2. 유지보수 어려움: 나중에 인터페이스가 변경되거나 확장될 경우, 모든 구현 클래스에 불필요한 수정을 요구하게 됩니다.
  3. 의존성 증가: 클라이언트가 인터페이스를 사용할 때, 필요 없는 메서드에 의존하게 되어 설계의 유연성이 저하됩니다.

개선 후

public interface IWeapon
{
    void Use();
}

public interface IReloadable
{
    void Reload();
}

public interface IRepairable
{
    void Repair();
}

public class Gun : IWeapon, IReloadable, IRepairable
{
    public void Use()
    {
        // 총 사용 로직
    }

    public void Reload()
    {
        // 장전 로직
    }

    public void Repair()
    {
        // 수리 로직
    }
}

public class Sword : IWeapon, IRepairable
{
    public void Use()
    {
        // 검 사용 로직
    }

    public void Repair()
    {
        // 수리 로직
    }
}

장점

  1. 인터페이스의 명확성: 각 인터페이스는 특정 기능만을 포함하므로, 각 클라이언트는 필요한 기능만 구현하면 됩니다.
  2. 유지보수 용이: 인터페이스가 변경되더라도, 필요한 구현 클래스만 수정하면 되므로 코드가 더 관리하기 쉬워집니다.
  3. 유연성 증가: 클라이언트가 필요한 인터페이스에만 의존하게 되어 코드의 유연성이 높아집니다.

즉, 인터페이스를 작성할때 이 인터페이스를 받을 클래스를 기준으로 분리하라는 것이다

 

즉, 총 / 검이 있을때 무기 인터페이스가 "장전"이라는 기능을 "무기"인터 페이스에 두는 것이 아니라, 각 기능을 분리해서 필요한 클라이언트에 배치한다는 것이다.

 

즉, 하위 클래스를 인터페이스에 맞추는 것이 아니라, 인터페이스를 하위 클래스에 맞게 작성하라는 것이다

 

 

5. 의존 역전 원칙

[ 고수준의 모듈은 저수준 모듈의 구현에 의존해서는 안 된다. 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다 ]

 

예시

public class Gun
{
    public void Fire()
    {
        // 총 발사 로직
    }
}

public class Player
{
    private Gun gun;

    public Player()
    {
        gun = new Gun(); // 의존성 주입 없이 직접 생성
    }

    public void Attack()
    {
        gun.Fire(); // 고수준 모듈이 저수준 모듈에 직접 의존
    }
}

문제점

  1. 결합도 증가: Player 클래스가 Gun 클래스를 직접 생성하므로 두 클래스가 강하게 결합됩니다. Gun 클래스를 변경하거나 다른 종류의 무기를 추가하려면 Player 클래스를 수정해야 합니다.
  2. 유연성 저하: Player 클래스는 Gun 클래스에 직접 의존하므로, 다른 종류의 무기(예: Sword, Bow)로 변경하는 것이 어렵습니다. 새로운 무기를 추가하려면 Player 클래스를 수정해야 합니다.
  3. 테스트 어려움: Player 클래스의 테스트를 수행할 때, Gun 클래스도 함께 생성되므로 테스트가 복잡해지고 독립적으로 테스트하기 어려워집니다.

개선 후

public interface IWeapon
{
    void Attack();
}

public class Gun : IWeapon
{
    public void Attack()
    {
        // 총 발사 로직
    }
}

public class Sword : IWeapon
{
    public void Attack()
    {
        // 검 사용 로직
    }
}

public class Player
{
    private IWeapon weapon;

    // 생성자 주입을 통해 의존성 주입
    public Player(IWeapon weapon)
    {
        this.weapon = weapon;
    }

    public void Attack()
    {
        weapon.Attack(); // 고수준 모듈이 추상화에 의존
    }
}

장점

  1. 결합도 감소: Player 클래스는 이제 IWeapon 인터페이스에 의존하므로, 특정 무기 클래스에 대한 의존성이 없습니다. 이를 통해 유연성이 높아집니다.
  2. 유연성 증가: Player 클래스의 생성자에 다양한 무기(예: Gun, Sword)를 주입할 수 있으므로, 무기를 쉽게 변경할 수 있습니다.
  3. 테스트 용이: Player 클래스의 테스트를 할 때, IWeapon의 목(Mock) 객체를 주입하여 독립적으로 테스트할 수 있습니다.

A를 상속한 B라는 객체가 있더라도, B를 A로 호출한 뒤, A의 매서드로 불러도 문제 없이 작동해야 한다는 것 같다

 

이를 잘 사용할시 코드에 유연성이 늘어나게 된다