말감로그

24.08.20 Unity_C# - 객체 지향 프로그래밍, SOLID 원칙 본문

TIL

24.08.20 Unity_C# - 객체 지향 프로그래밍, SOLID 원칙

habbn 2024. 8. 20. 22:10
728x90

객체지향 프로그래밍

객체지향 프로그래밍(OOP)은 객체와 클래스 중심으로 코드를 구성합니다.

추상화, 캡슐화, 상속성, 다향성 개념을 통해 객체 지향 프로그래밍에서 유연하고 확장성을 높일 수 있는 코드를 작성할 수 있습니다.

 

1. 추상화

복잡한 시스템에서 불필요한 세부사항을 숨기고, 중요한 속성과 기능만을 노출하여 단순화하는 개념

2. 캡슐화

데이터를 보호하고 클래스의 내부 구현을 외부에 숨기는 개념으로, 클래스 내부의 데이터를 외부에서 직접 접근하지 못하게 하고, 대신 메소드를 통해 접근하도록 한다.

3. 상속성

객체가 다른 객체의 특성을 물려받는 작업을 말하며, 상속을 통해 기존에 구현된 클래스의 코드 재사용이 가능해지며 계층구조를 형성하게 된다. 

4. 다향성

동일한 메소드를 호출하지만 매개변수에 따라 다른 작동을 하게 만드는 개념으로, 코드 유연성을 높일 수 있다.

동일한 메소드 이름을 갖고 있지만 서로 다른 클래스에서 다른 기능을 구현할 수 있다.

- 오버로딩(Overloading) : 동일한 메소드 이름을 가지지만 매개변수의 개수나 타입이 다른 여러 메소드를 정의하는 것

- 오버라이딩(Overriding) : 상속 관계에 있는 부모 클래스의 메소드를 자식 클래스에서 재정의하는 것


객체지향 프로그래밍 제 5원칙 SOLID

객체지향 프로그래밍은 프로그램을 독립적인 객체들로 구성하여 문제를 해결하는 방식입니다. 

 

Single Responsibility Principle (단일 책임 원칙)

모든 클래스는 하나의 책임만 가져야 합니다.

단일 책임 원칙을 잘 수행하면 가독성, 확장성, 재사용성이 좋아집니다.

 

*캡슐화 : 객체 지향 프로그래밍의 중요한 원칙 중 하나로, 데이터를 보호하고 클래스의 내부 구현을 외부에 숨기는 개념

클래스 내부의 데이터를 외부에서 직접 접근하지 못하게 하고, 대신 메소드를 통해 접근하도록 한다.

 

단일 책임 원칙을 지키지 못한 코드의 예

public class Player : MonoBehaviour
{
    [SerializeField] private string inputAxisName;
    [SerializeField] private float positionMultiplier;
    private float yPosition;
    private AudioSource bounceSfx;
    
    void Start()
    {
    	bounceSfx = GetComponent<AudioSource>();
    }
    
    void Update()
    {
    	float delta = Input.GetAxis(inputAxisName) * Time.deltaTime;
        yPosition = Mathf.Clamp(yPosition + delta, -1,1);
        transform.position = new Vector3(transform.position.x, yPosition * positionMultiplier, transform.position.z);
    }
   	
    void OnTriggerEnter(Collider other)
    {
    	bounceSfx.Play();
    }
}

 

Player 스크립트에 사운드, 조작 기능이 모두 작성되어 있습니다.

Player의 기능이 많아질수록 코드는 복잡해지고, 확장이 불가할 것이며, 재사용할 수 없는 코드가 됩니다.

Player 스크립트 안에 모든 기능을 한번에 작성하지 말고 Audio, Input, Movement 등 역할을 나눠서 작성하여 단일책임원칙을 지킵니다

[RequireComponent(typeof(PlayerAudio), typeof(PlayerInput), typeof(PlayerMovement))]
public class Player : MonoBehaviour
{
    [SerializeField] private PlayerAudio playerAudio;
    [SerializeField] private PlayerInput playerInput;
    [SerializeField] private PlayerMovement playerMovement;
    
    void Start()
    {
    	playerAudio = GetComponent<PlayerAudio>();
        playerInput = GetComponent<PlayerInput>();
        playerMovement = GetComponent<PlayerMovement>();
     }
}

public class PlayerAudio : MonoBehaviour
{
}

public class PlayeInput : MonoBehaviour
{
}

public class PlayerMovement : MonoBehaviour
{
}

Open-Closed Principle (개방-폐쇄 원칙)

확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 합니다. 즉, 기존의 코드를 수정하지 않고도 새로운 기능을 추가할 수 있도록 설계되어야 합니다.

개방 폐쇄 원칙을 잘 지킨 대표적인 예로 라이브러리가 있습니다. 라이브러리는 내부적인 코드를 수정하지 못하게 하면서 필요한 기능을 제공합니다.

 

개방 폐쇄 원칙을 지키지 못한 코드

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;
    }
}

 

이렇게 작성한 경우, 삼각형이나 다른 도형의 클래스를 추가할 때마다 AreaCalculator의 코드도 계속 추가되어야 합니다.

즉, 새로운 클래스를 확장해서 사용할 때마다 AreaCalculaor 또한 수정해야 되기 때문에 개방 폐쇄 원칙에 위배됩니다.

 

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

 

삼각형 등 어떤 도형이 추가되더라도 Shape만 상속받는다면,

AreaCalculator의 코드 수정 없이 AreaCalculator의 GetArea 기능을 사용할 수 있습니다.


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

자식 클래스는 부모 클래스를 대체할 수 있어야 하며, 부모 클래스의 방향성을 유지해야 합니다.

서브클래싱 할 때 부모 클래스의 기능을 제거하는 경우 리스코프 치환 원칙이 위배됩니다.

좀 더 자세히 설명하자면, 부모 클래스에 있는 기능을 자식 클래스에서 참조 받은 후 아무 기능이 없는 무효화 코드로 바꾼다면 리스코프 치환 원칙에 위배되는 것입니다.

 

리스코프 치환 원칙에 위배되는 코드

public class Vehicle
{
    public float speed;
    public Vector3 dir;
    
    public virtual void GoForward() { ... }
    public virtual void Reverse() { ... }
    public virtual void TurnRight() { ... }
    public virtual void TurnLeft() { ... }
}

public class Train : Vehicle
{
    public override void GoForward() { ... }
    public override void Reverse() { ... }
}

 

Trian의 경우 레일을 따라 이동하기 때문에 TurnRight와 TurnLeft의 기능이 필요하지 않습니다.

즉, 무효화시키기 때문에 리스코프 치환 원칙에 위배됩니다.

부모 클래스의 있는 기능을 무효화시킨다는 것은 부모 클래스의 방향성을 따르지 않는다는 것입니다.

 

public interface ITurnable
{
    public void TurnRight();
    public void TurnLeft();
}
public interface IMoveable
{
    public void GoForward();
    public void Reverse();
}
public class RoadVehicle : ITurnable, IMoveable
{
    public float speed = 100f;
    public float turnSpeed = 5f;
    public virtual void GoForward() { ... }
    public virtual void Reverse() { ... }
    public virtual void TurnRight() { ... }
    public virtual void TurnLeft() { ... }
}
public class RailVehicle : IMovable
{
    public float speed = 100f;
    public virtual void GoForward() { ... }
    public virtual void Reverse() { ... }
}

 

인터페이스로 나눠서 구현한다면 리스코프 치환 원칙에 위배되지 않고 깔끔하게 작성할 수 있습니다.

인터페이스 하나에 모든 기능을 넣지 말고, 역할에 따라 인터페이스를 나눠 만들어서 조립 형식으로 구현하면 됩니다.


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

인터페이스를 작게 유지하고 필요한 것만 구현하여 이용하지 않는 메서드에 의존하지 않아야 합니다.

큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리해서 클라이언트들이 꼭 필요한 메서드들만 이용할 수 있게 합니다.

 

간단하게 요약하면 인터페이스를 꼭 필요한 부분만 구현해서 최대한 세분화하는 원칙입니다.

public interface IUnit
{
    float HP { get; set; }
    int Defence { get; set; }
    float MoveSpeed { get; set; }
    float Acceleration { get; set; }
    int Strength { get; set; }
    void Die();
    void Attack();
    void Heal();
    void GoForward();
    void Reverse();
    void TurnLeft();
    void TurnRight();
}

 

위의 코드는 IUnit 인터페이스 안에 많은 기능이 들어있습니다.

public interface IMovable
{
    float MoveSpeed ( get; set; }
    float Acceleration { get; set; }
    void GoForward();
    void Reverse();
    void TurnLeft();
    void TurnRight();
}

 

IMovable 인터페이스로 세분화하여 구현하고 필요한 부분에 IMovable을 조합해서 사용한다면 확장해서 사용하기 편해집니다.


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

상위 모듈은 하위 모듈의 것을 직접 가져오면 안되고, 둘 다 추상화에 의존해야 합니다.

추상화는 세부 사항에 의존해서는 안되고, 세부사항이 추상화에 의존해야 합니다.

클래스가 다른 클래스와 직접적인 연관(의존성)이 있으면 안됩니다.

 

Switch를 누르면 문이 닫히고 열리는 코드를 보겠습니다.

public class Door : MonoBehaviour
{
    public void Open()
    {
    	Debug.Log("Open");
    }
    public void Close()
    {
    	Debug.Log("Close");
    }
}

public class Switch : MonoBehaviour
{
    public Door _door;
    public bool isActivated;
    public void Toggle()
    {
    	if(isActivated)
        {
            isActivated = False;
            _door.Close();
        }
       	else
        {
            isActivated = True;
            _door.Open();
        }
    }
}

 

위의 코드는 스위치를 누르면 문이 열리고 닫히는 코드입니다.

만약에 스위치를 누르면 무기가 날라간다거나, 함정이 발생하는 등의 기능을 추가하고 싶다면 Switch의 코드를 수정해야 할 것입니다.

즉, 스위치가 새로운 기능과 연결될 때마다 스위치의 코드는 계속해서 수정해야 됩니다. 

이는 스위치가 다른 클래스와 직접적인 연관이 생기므로 의존 역전 원칙에 위배됩니다.

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

public class Door : MonoBehaviour, ISwitchable
{
    bool isActive;
    public bool IsActive => isActive;
    
    public void Activate()
    {
    	Debug.Log("Open");
    }
    public void Deactivate()
    {
    	Debug.Log("Close");
    }
}

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

 

ISwitchable 인터페이스를 사용하여 Door를 구현하고 Switch는 ISwitchable 인스턴스를 만들어 사용한다면, 새로운 기능을 추가하거나 Door의 기능을 수정할 때 Switch 코드를 수정할 필요가 없어집니다.

 

예를 들어 스위치를 누를 때마다 함정이 발생하는 기능을 만들고 싶으면

public class Trap : MonoBehaviour, ISwitchable
{
    bool isActive;
    public bool IsActive => isActive;
    
    public void Activate()
    {
    	Debug.Log("함정 발동");
    }
    public void Deactivate()
    {
    	Debug.Log("함정 제거");
    }
}

 

ISwitchable 인터페이스를 사용하여 Trap을 구현하면, Switch의 코드 수정 없이 간단하게 구현할 수 있습니다.

728x90