본문 바로가기
유니티 공부/C# 문법

C# - SOLID 원칙

by 코딩하는 돼징 2024. 5. 6.
반응형

1. 단일 책임 원칙(Single Response Principle)

이름 그대로 하나의 클래스는 하나의 책임만 갖는 원칙이다.

예를 들어 플레이어와 관련된 스크립트가 있다고 해보자 그러면 플레이어의 입력, 이동 관련, 각종 사운드 관련 기능 등이 있을때 아래와 같이 별도의 스크립트로 분리해야한다. 

https://www.youtube.com/watch?v=wGWrOpRdu40&list=LL&index=9&t=12s

왜 이렇게 해야할까?

만약 플레이어의 움직임만 수정하고 싶을 때 단일 책임 원칙을 지키고 있다면 Movement스크립트만 수정하면 되지만 원칙을 지키지 않는 경우 모든 클래스를 수정해야한다. 

01 가독성

스크립트에 단일 기능만 적혀있으니 많은 기능들이 포함된 경우보다 스크립트의 길이가 짧아질 것이다. 그러므로 가독성이 좋다.

02 확장성

하나의 기능으로만 이루어져있기 때문에 이 클래스를 상속받아 확장하기에 용이하다.

03 재사용성

단일 기능으로 이루어져있기 때문에 모듈식으로 여러 부분에서 재사용할 수 있게 된다.

예를 들어 Audio Class를 통해 장애물 오브젝트, 캐릭터 오브젝트, 아이템 오브젝트 이렇게 따로 객체를 만들어 관리할 수 있다.


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

클래스가 확장에는 개방되어있고 수정에는 닫혀있어야 한다는 원칙이다. 간단히 말하자면 원본 코드를 수정하지 않고 새로운 기능을 추가할 수 있어야 한다는 뜻이다.

 

예를 들어 아래와 같이 계산기 클래스가 있다

아래에서는 사각형과 원의 넓이를 계산 메서드가 있는데 여기에서 오각형의 넓이를 구하는 메서드 추가하고 싶은 경우 클래스가 계속해서 수정되어야한다. 이렇게 수정하는 경우가 잦아지면 휴먼 에러가 발생할 경우가 높다.

public class Calculator
{
    public int GetRectangleArea(Rectangle rectangle)
    {
        return rectangle.width * rectangle.height;
    }
    public int GetCircleArea(Circle circle)
    {
        return circle.radius * circle.radius * Mathf.PI;
    }
}

그래서 원본 코드를 수정하지 않고 계속 기능을 추가하도록 코드를 설계해야 한다.

01 Shape 클래스

넓이를 구하는 함수를 가지고 있는 클래스를 만든다.

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

02 도형별로 Shape클래스를 상속받는다.

public class Rectangle : Shape
{
    public float width;
    public float height;
    
    public override float CalculateArea()
    {
        return width * heiht;
    }
}

public class Circle : Shape
{
    public float radius;
    
    public override float CalculateArea()
    {
        return radius * radius * Mathf.PI;
    }
}

03 Calculator 클래스

public class Calculator
{
    public float GetArea(Shape shape)
    {
        return shape.CalculateArea();
    }
}

이렇게 코드를 작성하게되면 도형이 추가되더라도 기존 코드의 수정이 필요하지 않게 된다.


3. 리스코프 치환 원칙 (Liskov's Substitution Principle)

파생클래스가 기본 클래스를 대체할 수 있어야 한다는 원칙이다. 상속을 사용할때 필요하다.

 

코드로 이해해보자

아래와 같이 탈것과 관련된 Vehcile 클래스가 있다. 

public class Vehicle
{
    public void GoForward() {}
    public void GoLeft() {}
    public void GoRight() {}
    public void GoBack() {}
}

이 클래스를 아래와 같이 Car, Truck, Train이 상속받았다고 가정해보면 Train은 좌회전, 우회전, 후진을 할 수가 없으므로 이 메서드를 비워두게(혹은 상속받은 코드가 오류를 발생 시키) 된다. 

public class Car : Vehicle
{

}

public class Truck : Vehicle
{

}

public class Train : Vehcile
{

}

 

즉 하위 클래스는 어떠한 경우에도 부모 클래스를 대체할 수 있어야 한다는 것이다. 더 자세한 예를 들어 자동차 클래스를 상속받아서 다양한 자동차를 만드는것은 괜찮은데 갑자기 비행기를 만드는 것은 리스코프 치환 원칙에 위배된다.

 

해당 원칙을 지키기위해서는 추상클래스를 조금 더 간단하게 만들고 , 더 분류를 해서 만들고, 상속을 바로 사용하는 것보다는 인터페이스를 상속해서 여러 인터페이스를 조합하는 것이 좋을 수 있다.


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

인터페이스를 사용할 때 한 번에 크게 사용하지 말고 분리해서 사용하라는 원칙이다.

 

코드를 통해 알아보자

01 인터페이스에 한번에 적는 경우

public interface IUnitStats
{
    public float HP { get; set;}
    public float MP { get; set;}
    public float Speed { get; set;}
    public float Exp { get; set;}
    public float Goforward();
    public float Die();
    public float LevelUp();
}

02 인터페이스를 분리해서 적는 경우

1) 이동 인터페이스

public interface IMovable
{
    public float Speed { get; set;}
    public float Goforward();
}

2) 데미지 인터페이스

public interface IDamageable
{
    public float HP { get; set;}
    public float Die();
}

3) 스텟 인터페이스

public interface IUnitStats
{
    public float MP { get; set;}
    public float Exp { get; set;}
    public float LevelUp();
}

이렇게 코드를 적는 경우 적재적소에 맞게 인터페이스를 활용할 수 있다.

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

}
public class UnDeadEnemyUnit : MonoBehaviour-, IMovable, IUnitStats
{

}

이렇게 코드를 관리하게되면 코드간의 결합도도 낮아지고 수정이 용이해진다.


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

고수준 모듈이 저수준 모듈에서 직접 가져오면 안된다는 원칙이다.

 

코드를 통해서 이해해보자

public class Door : MonoBehaviour
{
    public void Open()
    {
        Debug.Log("The door is open.");
    }
    
    public void Close()
    {
        Debug.Log("The door is closed.");
    }
}

아래와 같이 코드를 작성하면 Switch을 이용해서 Door의 문을 열고 닫을 수 있다.

public class Switch : MonoBehaviour
{
    public Door door;
    public bool isActivated;
    
    public void Toggle()
    {
        if(isActivated)
        {
            isActivated = false;
            door.Closed();
        }
        else
        {
            isActivated = true;
            door.Open();
        }
}

하지만 여기에서 치명적인 문제점이 있다!

Switch클래스가 Door를 직접적으로 알고 있기 때문에 door만 열고 닫을 수 있는 것이다. 하지만 만약에 우리가 원하는 기능이 door뿐만 아니라 다른 기능들을 넣고 싶었었다면 어떻게 해야할까?

01 스위치 기능을 인터페이스로 만들기

이 인터페이스에 활성/비활성 함수를 생성한다.

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

02 Door클래스에 인터페이스 적용

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

03 Switch 클래스 수정

기존의 Switch클래스는 도어를 직접적으로 연결하였지만  이제는 인터페이스를 통해 연결하고 토글에서 인터페이스 메소드를 호출한다. 그러므로 이제는 Switch 인터페이스를 가지고 있는 개체 모두 활성/비활성화 가능게 되었다.

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

 

결론

특정 클래스에 직접적으로 의존하는게 아니라 인터페이스를 거쳐서 사용하기 때문에 느슨한 결합이 이뤄지게 된다. 이것이 바로 의존성 역전 법칫이다.

 

 

 

객체지향의 코드를 설계할 목표는 느슨한 결합과 높은 응집력이다.

 

 

 

 

 

 

 

 

본 게시글은  https://www.youtube.com/watch?v=wGWrOpRdu40&list=LL&index=9&t=12s 참고하였습니다.

 

 

 

반응형

댓글