객체 지향에는 5가지의 원칙이 존재합니다.
단일 책임 원칙 - Single Responsibility Principle
개방 폐쇄 원칙 - Open/Closed Principle
리스코프 치환 원칙 - Liskov's Substitution Principle
인터페이스 분리 원칙 Interface Segregation Principle
의존성 역전 원칙 Dependency Inversion Principle
이 다섯 가지의 초성을 따서 SOLID 원칙이라고 명명되었습니다. 그럼 이제 어떤 원칙인지 알아보겠습니다.
단일 책임 원칙 - Single Responsibility Principle
이름 그대로 하나의 클래스는 하나의 책임만 갖는다는 원칙입니다.
간혹 하나의 클래스에 여러 가지 기능을 한 번에 구현하는 경우가 종종 있는데 이건 단일 책임 원칙에 위배되는 겁니다.
예시로 플레이어 캐릭터를 만들어본다고 생각했을 때
- 입력을 인식하는 기능
- 이동 관련 기능
- 각종 사운드 관련 기능
- 기타 추가 기능
등등 이런 기능들을 Player라는 클래스에 한 번에 구현할 수도 있습니다. 하지만, 이런 식으로 한 클래스에 모든 기능들을 구현하게 되면 단일 책임 원칙에 위배됩니다.
원칙에 따라서 구현을 하게 된다면, 오디오, 입력, 움직임 등의 기능들을 별도의 클래스로 나눠서 만들고, 이것들을 각각 별도의 컴포넌트로 오브젝트에 부착합니다.
이렇게 코드를 구성할 경우 차이점은 플레이어의 움직임만 수정하고 싶을 때 단일 책임 원칙을 지켰다면, PlayerMovement 스크립트만 수정하면 되지만, 원칙을 지키지 않은 경우 모든 코드를 수정해야 한다는 단점이 있습니다.
그리고 이렇게 작은 단위로 클래스를 분리하여 사용한다면 여러 이점이 있습니다.
1. 가독성이 좋아집니다.
당연히 단일 기능 단위로 분리했으니 코드의 길이가 짧고 명확해집니다.
2. 확장성이 좋아집니다.
하나의 기능으로만 이뤄졌기 때문에 이 클래스를 상속 받아 확장하기에 용이합니다.
3. 재사용성이 좋아집니다.
단일 기능으로 이뤄져있기 떄문에 모듈식으로 여러 부분에서 재사용할 수 있게 됩니다.
개방 폐쇄 원칙 - Open/Closed Principle
이 원칙은 클래스가 확장에는 개방 되어 있 수정에는 닫혀있어야 한다는 원칙입니다.
쉽게 말하면 원본 코드를 수정하지 않고 새로운 동작을 추가할 수 있어야 합니다.
public class Calculator
{
public float GetRectangleArea(Rectangle rectangle)
{
return rectangle.Width * rectangle.Height;
}
public float GetCircleArea(Circle circle)
{
return Circle.Radius * Circle.Radius * Mathf.PI;
}
public float GetPentagonArea(Pentagon pentagon)
{
// 오각형 넓이 구하는 코드를 추가해야 됨.
}
}
도형들의 넓이를 구하는 클래스를 구현해 본다고 했을 때, 도형들이 새로 추가될 때마다 새로운 함수들을 만들어 이 클래스들을 수정해야합니다. 이렇게 수정이 잦아진다면 여러 휴먼 에러가 발생할 가능성이 높아집니다. 그래서 수정하지 않고도 계속 기능을 추가할 수 있도록 코드를 설계해야 합니다.
기존코드에서는 도형별로 개별 함수가 존재하고, 매개변수에서 도형을 각각 넣어서 계산하고 있었는데, 이렇게 하는 것이 아닌 공용으로 사용할 수 있도록 설계해야 합니다.
도형이라는 Shape 이라는 클래스를 만들고 이 클래스에서 넓이를 구하는 함수를 만듭니다.
public abstract class Shape
{
public abstract float CalculateArea();
}
그 후에 Shape을 상속 받아서 도형별로 별도의 클래스를 만들어 줍니다.
도형별로 필요한 변수들을 만들어 주고, 상속 받은 추상 클래스를 오버라이드해서 각 도형별로 넓이를 구하는 공식을 작성해줍니다.
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;
}
}
Calculator 클래스에서 GetArea라는 메서드를 만들고, 매개변수로 Shape을 넣어줍니다.
public class Calculator
{
public float GetArea(Shape shape)
{
return shape.CalculateArea();
}
}
이렇게 되면 어떤 차이점이 발생하냐면, 도형이 더 추가되더라도 기존 코드가 전혀 수정되지 않습니다. 새로운 클래스를 만들어서 shape을 상속시켜주기만 하면 되는 것입니다.
즉, 기존 코드는 수정은 할 게 없고 새로운 코드만 사용하면 되기 때문에 확장에는 열려 있고, 수정에는 닫힌 구조가 되었습니다. 이것이 바로 개방 폐쇄 원칙 입니다.
리스코프 치환 원칙 - Liskov's Substitution Principle
리스코프 치환 원칙은 리스코프라는 사람이 발표를 했기 때문에 리스코프 치환 원칙입니다. 파생클래스가 기본 클래스를 대체할 수 있어야 한다. 라는 원칙인데, 상속을 할 때 지켜야하는 원칙을 말합니다. 상속을 하다 보면 이런 경우가 발생할 수 있습니다.
탈 것을 만들기 위해서 Vehicle 클래스를 만들고, 여러가지 변수와 함수들을 만듭니다.
탈 것이니까 앞으로도 가고, 좌회전, 우회전도 하는 함수를 만들었습니다.
public class Vehicle
{
public void GoForward()
{
}
public void TurnRight()
{
}
public void TurnLeft()
{
}
}
그리고 이 탈 것 클래스를 상속받아서, 자동차도 만들고, 기차도 만들었는데, 기차가 우회전이나 좌회전은 할 수가 없습니다. 물론 상속받았기 때문에 회전 관련 코드들은 존재합니다. 그러나 이 메서드를 비워두거나(혹은 상속 받은 코드가 오류를 발생) 할 것입니다.
public class Car : Vehicle
{
}
public class Train : Vehicle
{
}
즉, 이런 식으로 상위 클래스의 좌회전 메서드를 사용했을 때, 자동차 종류들은 정상이겠지만, 기차의 경우에는 동작을 안하거나 오류를 발생시킬 겁니다.
즉, 리스코프 치환 원칙이란 하위 클래스는 어떠한 경우에도 부모 클래스를 대체할 수 있어야 한다는 것입니다.
조금 더 쉽게 말하면, 자동차를 상속 받아서 다양한 자동차를 만드는 것은 괜찮은데, 갑자기 자동차를 상속 받아 놓고는 비행기를 만들면 안된다는 것입니다.
해당 원칙을 잘 지키기 위해서는 추상 클래스를 조금 더 간단하게 만들고, 조금 더 분류를 해서 만들고, 상속을 사용하는 것보다는 인터페이스를 생성해서 여러 인터페이스를 조합하는 것이 좋을 수 있습니다.
// 회전 기능 인터페이스
public interface ITurnable
{
public void TrunRight();
public void TrunLeft();
}
// 이동 기능 인터페이스
public interface IMovable
{
public void GoForward();
public void Reverse();
}
단순하게 탈 것 클래스를 만드는 것이 아니라 도로를 달리는 탈 것, 레일을 달리는 탈 것을 나눠서 클래스를 만들고, 해당 클래스로부터 파생된 자동차 클래스와 기차 클래스를 만드는게 바람직합니다.
public class RoadVehicle : IMovable, ITurnable
{
public void GoForward() { }
public void Reverse() { }
public void TurnRight() { }
public void TurnLeft() { }
}
public class RailVehicle : IMovable
{
public void GoForward() { }
public void Reverse() { }
}
public class Car : RoadVehicle
{
}
public class Train : RailVehicle
{
}
인터페이스 분리 원칙 Interface Segregation Principle
방금 리스코프 치환 원칙에서 인터페이스가 잠깐 등장했는데, 인터페이스를 사용할 때도 지켜야할 원칙이 있습니다. 바로 인터페이스 분리 원칙입니다. 인터페이스를 사용할 때 한 번에 크게 사용하지 말고, 작은 단위로 나눠서 사용하라는 원칙입니다.
예를 들어서 UnityStats 관련 인터페이스가 있다면, 이렇게 한 번에 모든 것을 넣지 말고 최대한 나누는 것입니다.
public interface IUnitStats
{
public float Health { get; set; }
public float Defense { get; set; }
public void TakeDamage();
public float MoveSpeed { get; set; }
public void GoForward();
public float Strength { get; set; }
public float Dexterity { get; set; }
}
이동 관련 인터페이스, 데미지 관련 인터페이스, 스텟 인터페이스 이런 식으로 나눠야 합니다.
public interface IMovable
{
public float MoveSpeed { get; set; }
public void GoForward();
}
public interface IDamageable
{
public float Health { get; set; }
public float Defense { get; set; }
public void TakeDamage();
}
public interface IUnitStats
{
public float Strength { get; set; }
public float Dexterity { get; set; }
}
그래서 이런 인터페이스들을 조합하는 형태로 코드를 발전시켜나가는 것입니다. 어떤 유닛은 위의 인터페이스를 전부 상속 받았고, 어떤 유닛은 몇 개의 인터페이스를 상속 받았다면, 데미지를 받지 않는 무적 유닛이 될 것입니다.
public class EnemyUnit : MonoBehaviour, IDamageable, IMovable, IUnitStats
{
// 일반 유닛
}
public class UnDeadEnemyUnit : MonoBehaviour, IMovable, IUnitStats
{
// 데미지 받지 않는 유닛
}
이런 식으로 인터페이스를 분리해서 사용할수록 코드 간의 결합도가 낮아지고 수정이 용이해집니다.
의존성 역전 원칙 Dependency Inversion Principle
여기 Door라는 클래스가 있고, Door 클래스에는 Open 과 Close 기능이 존재합니다.
public class Door : MonoBehaviour
{
public void Open()
{
Debug.Log("The door is Open");
}
public void Close()
{
Debug.Log("The door is Closed");
}
}
스위치 클래스에서 문을 열고 닫게 만들어 봅시다. 그럼 스위치클래스에서 Door를 연결하고, bool 로 상태를 저장한 뒤 Toggle이라는 메서드를 만들어서, 문을 열고 닫는 기능을 구현합니다.
public class Switch : MonoBehaviour
{
public Door door;
public bool isActivated;
public void Toggle()
{
if (isActivated)
{
isActivated = flase;
door.Close();
}
else
{
isActivated = true;
door.Open();
}
}
}
이렇게하면 스위치 클래스가 문을 열고 닫게 되었습니다. 근데 여기서 치명적인 문제가 스위치 클래스는 Door를 직접적으로 알고 있다는 점입니다. 그렇기 때문에 스위치 클래스는 Door만 열고 닫을 수 있습니다. 그런데,스위치라는 건 문만 열고 닫는 용도는 아닙니다. 문을 여닫는 것 외에도 조명이나 함정 여러가지를 활성/비활성화를 할 수 있습니다.
그럼 어떻게 설계를 해야 코드 수정 없이 확장성을 유지할 수 있는 지 봅시다.
스위치 기능을 인터페이스를 만들고, 이 인터페이스에 활성/비활성하는 함수를 생성합니다.
public interface ISwitchable
{
public bool IsActive { get; }
public void Activate();
public void Deactivate();
}
그리고 Door에 ISwitchable 인터페이스를 사용하면 됩니다.
public class Door : MonoBehaviour, ISwtichable
{
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");
}
}
그럼 기존의 스위치 클래스는 Door를 직접적으로 연결하는 것이 아니라 인터페이스를 통해서 연결 하고, Toggle에서 인터페이스의 메서드를 사용하면 됩니다.
public class Switch : MonoBehaviour
{
public ISwitchable client;
public bool isActivated;
public void Toggle()
{
if (client.IsActive)
{
client.Deactivate();
}
else
{
client.Activate();
}
}
}
이렇게 되면 기존에는 Door만 여닫을 수 있었던 클래스였지만, 이제는 스위치 인터페이스를 가지고 있는 모든 객체들을 활성/비활성화 할 수 있게 되었습니다.
결론적으로, 특정 클래스에 직접적으로 의존하는 것이 아니라 인터페이스를 거쳐서 사용 하기 때문에 느슨한 결합이 이뤄지게 됩니다. 이것이 바로 의존성 역전 원칙입니다.
객체지향은 설계를 할 때, 느슨한 결합과 높은 응집력을 목표로 해야합니다. 이것을 이루기 위해서는 솔리드원칙을 기반으로 해야합니다. 사실 모든 경우에서 완벽하게 모든 원칙을 적용하기 어렵겠지만, 이 원칙들을 기본 규칙으로 삼아서 연습하면, 코드적으로 많은 발전과 성장을 경험할 수 있습니다.
참고 영상
https://www.youtube.com/watch?v=wGWrOpRdu40