디자인 패턴의 기초인 SOLID 원칙을 정리하겠습니다.
S (Single responsibility) : 단일 책임 원칙
- 클래스는 한 가지 작업만 수행
O (Open-closed) : 개방 폐쇄 원칙
- 작동하는 방식을 변경하지 않고 클래스의 기능을 확장
L (Liskov substitution) : 리스코프 치환 원칙
- 자식 클래스는 부모 클래스를 대체할 수 있어야하며, 부모 클래스의 방향성 유지
I (Interface segregation) : 인터페이스 분리 원칙
- 인터페이스를 작게 유지하며 클라이언트는 필요한 것만 구현
D (Dependency inversion) : 의존 역전 원칙
- 추상화에 의존하고, 하나의 구체 클래스에서 다른 클래스로 직접 의존 금지
SOLID 원칙에 대해서 조금 더 자세하게 알아보겠습니다.
아래의 영상을 참고하여 제작하였습니다.
https://www.youtube.com/watch?v=J6F8plGUqv8&t=141s
S (Single responsibility principle, 단일 책임 원칙)
모든 클래스는 하나의 책임만 가져야 하며, 클래스는 그 책임을 완전히 캡슐화해야 합니다.
단일 책임 원칙을 잘 수행하면 가독성, 확장성, 재사용성이 좋아집니다.
가독성 : 클래스가 하나의 책임만 수행한다면 작은 클래스가 되어 읽기 쉬워집니다.
확장성 : 작은 클래스는 상속, 확장 등이 수월해집니다.
재사용성 : 모듈식으로 설계가 가능하여 재사용을 쉽게 할 수 있습니다.
유니티로 예를 들자면, Cylinder의 Capsule Collider는 Collider의 기능만 가져야합니다.
Collider가 오디오를 출력한다거나 카메라 기능을 가지면 Single responsibility에 어긋납니다.
단일 책임 원칙을 지키지 못한 코드의 예를 작성해봤습니다.
public class Player : MonoBehaviour
{
[SerializeField]
float _moveSpeed;
void Update()
{
MyPlayerMovement();
MyPlayerRotation();
MyPlayerInput();
}
void MyPlayerMovement()
{
float h = Input.GetAxis("Horizontal") * _moveSpeed;
float v = Input.GetAxis("Vertical") * _moveSpeed;
transform.Translate(h, 0, v);
}
void MyPlayerRotation()
{
if(Input.GetMouseButton(0))
{
float h = -Input.GetAxis("Mouse Y");
float v = Input.GetAxis("Mouse X");
transform.Rotate(h, v, 0);
}
}
void MyPlayerInput()
{
if (Input.GetKeyDown(KeyCode.Space)) Debug.Log("점프");
else if (Input.GetKeyDown(KeyCode.Z)) Debug.Log("공격");
}
}
Player 스크립트에 Movement, Rotation, Input 기능이 모두 작성되어 있습니다.
Player의 기능이 많아질수록 코드는 복잡해지고, 확장이 불가할 것이며, 재사용을 할 수 없는 코드가 됩니다.
영상에서 설명한 코드는 Player 스크립트에 오디오, 조작 등 모든 기능을 한번에 작성하지 말고
Audio, Input, Movement 등 역할을 나눠서 작성하여 단일 책임 원칙을 지켰습니다.
[RequireComponent(typeof(PlayerAudio), typeof(PlayerInput), typeof(PlayerMovement))]
public class PlayerTest : MonoBehaviour
{
[SerializeField] private PlayerAudio _playerAudio;
[SerializeField] private PlayerInput _playerInput;
[SerializeField] private PlayerMovement _playerMovement;
void Start()
{
_playerAudio = GetComponent<PlayerAudio>();
_playerInput = GetComponent<PlayerInput>();
_playerMovement = GetComponent<PlayerMovement>();
}
}
따로 클래스로 작성한다면 Player 이 외에 다른 오브젝트에도 재사용할 수 있다는 장점이 생깁니다.
예를 들면, 필요에 따라 Enemy에 PlayerMovement와 PlayerInput 등을 재사용할 수 있습니다.
O (Open-closed principle, 개방 폐쇄 원칙)
확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 합니다.
요구 사항이 변경될 때, 새로운 기능을 추가해 모듈을 확장할 수 있어야 합니다.
이때, 내부적인 코드를 수정하지 않고 모듈의 기능을 확장하거나 변경해야 합니다.
개방 폐쇄 원칙을 잘 지킨 대표적인 예로 라이브러리가 있습니다.
라이브러리는 내부적인 코드를 수정하지 못하게 하면서 필요한 기능을 제공합니다.
영상에서 나온 개방 폐쇄 원칙을 지키지 못한 코드를 보겠습니다.
public class Rectangle
{
public float _width, _height;
}
public class Circle
{
public float _radius;
}
public class AreaCalculator
{
public float GetRectangleArea(Rectangle obj)
{
return obj._width * obj._height;
}
public float GetCircleArea(Circle obj)
{
return obj._radius * obj._radius * Mathf.PI;
}
}
이렇게 작성한 경우,
삼각형이나 다른 도형의 클래스를 추가할 때마다 AreaCalculator의 코드도 계속 추가되어야 합니다.
즉, 새로운 클래스를 확장해서 사용할 때마다 AreaCalculator 또한 수정해야 되기 때문에 개방 폐쇄 원칙에 위배됩니다.
개방 폐쇄 원칙을 지킨 코드를 작성하고 유니티에서 출력했습니다.
public abstract class Shape : MonoBehaviour
{
public abstract string GetName();
public abstract float GetArea();
}
public class Rectangle : Shape
{
public float _width, _height;
public override string GetName()
{
return "Rectangle";
}
public override float GetArea()
{
return _width * _height;
}
}
public class Circle : Shape
{
public float _radius;
public override string GetName()
{
return "Circle";
}
public override float GetArea()
{
return _radius * _radius * Mathf.PI;
}
}
public class AreaCalculator : MonoBehaviour
{
[SerializeField]
Shape[] _obj;
private void Start()
{
foreach (Shape shape in _obj) GetArea(shape);
}
public float GetArea(Shape obj)
{
float ret = obj.GetArea();
Debug.Log(obj.GetName() + " Area : " + ret);
return ret;
}
}
원하는 출력 결과가 나온 것을 확인했습니다.
삼각형 등 어떤 도형이 추가되더라도 Shape만 상속 받는다면,
AreaCalculator의 코드 수정 없이 AreaCalculator의 GetArea 기능을 사용할 수 있습니다.
L (Liskov substitution principle, 리스코프 치환 원칙)
자식 클래스는 부모 클래스를 대체할 수 있어야 하며, 부모 클래스의 방향성을 유지해야 합니다.
상속을 사용하면 자식 클래스는 기능을 추가할 수 있는데, 불필요한 복잡성이 발생하지 않도록 주의해야 합니다.
서브클래싱할 때 부모 클래스의 기능을 제거하는 경우 리스코프 치환 원칙을 위배됩니다.
좀 더 자세히 설명하자면 부모 클래스에 있는 기능을 자식 클래스에서 참조 받은 후 아무 기능이 없는 무효화 코드로 바꾼다면 리스코프 치환 원칙에 위배되는 것입니다.
아래의 코드는 부모 클래스 Vehicle의 코드입니다.
자식 클래스는 Car와 Train이 있다고 가정합니다.
public class Vehicle
{
public float _speed;
public Vector3 _dir;
public virtual void GoForward()
{
Debug.Log("탈것 전진");
}
public virtual void Reverse()
{
Debug.Log("탈것 후진");
}
public virtual void TurnRight()
{
Debug.Log("탈것 우회전");
}
public virtual void TurnLeft()
{
Debug.Log("탈것 좌회전");
}
}
public class Train : Vehicle
{
public override void GoForward()
{
Debug.Log("기차 전진");
}
public override void Reverse()
{
Debug.Log("기차 후진");
}
}
Train의 경우 레일을 따라 이동하기 때문에 TurnRight와 TurnLeft의 기능이 필요하지 않습니다.
public class Navigator : MonoBehaviour
{
private void Start()
{
Vehicle train = new Train();
Move(train);
}
public void Move(Vehicle vehicle)
{
vehicle.GoForward();
vehicle.Reverse();
vehicle.TurnRight();
vehicle.TurnLeft();
}
}
Vehicle의 전진, 후진, 우회전, 좌회전을 모두 호출하는 Move 함수가 있다고 가정합니다.
위의 코드를 실행하면 Train은 TurnRight와 TurnLeft를 구현하지 않았으니,
부모 클래스인 Vehicle의 TurnRight와 TurnLeft가 호출됩니다.
[사진 3]처럼 탈것 좌/우 회전이 호출됩니다.
기차는 레일에 따라 달리도록 설계가 되어 있어 좌/우 회전이 실행되면 안되기 때문에, Train은 Vehicle의 TurnRight와 TurnLeft가 호출되지 않도록 구현해야 합니다.
즉, Train은 TurnRight와 TurnLeft를 무효화 시킬 것이며, 리스코프 치환 원칙에 위배됩니다.
부모 클래스의 있는 기능을 무효화 시킨다는 것은 부모 클래스의 방향성을 따르지 않는다는 것입니다.
리스코프 치환 원칙을 위배하지 않는 코드를 작성해보겠습니다.
public interface ITurnable
{
void TurnRight();
void TurnLeft();
}
public interface IMovable
{
void GoForward();
void Reverse();
}
public class RoadVehicle : IMovable, ITurnable
{
public float _speed = 100f;
public virtual void GoForward() { }
public virtual void Reverse() { }
public virtual void TurnRight() { }
public virtual void TurnLeft() { }
}
public class Car : RoadVehicle
{
public override void GoForward()
{
base.GoForward();
}
public override void Reverse()
{
base.GoForward();
}
public override void TurnRight()
{
base.GoForward();
}
public override void TurnLeft()
{
base.GoForward();
}
}
public class RailVehicle : IMovable
{
public float _speed = 100f;
public virtual void GoForward() { }
public virtual void Reverse() { }
}
public class Train : RailVehicle
{
public override void GoForward()
{
base.GoForward();
}
public override void Reverse()
{
base.GoForward();
}
}
인터페이스로 나눠서 구현한다면 리스코프 치환 원칙에 위배되지 않고 깔끔하게 작성할 수 있습니다.
또한 새로운 클래스로 배 클래스를 만들 때, 물 위를 다닐 수 있는 인터페이스를 만들고 다른 인터페이스와 조합해서 구현한다면 확장성 있게 만들 수 있습니다.
모든 소프트웨어에서 통용되는 원칙이기 때문에 유니티 등 많은 엔진과 소프트웨어들이 이런 형태로 구성되어 있습니다.
인터페이스 하나에 모든 기능을 넣지 말고, 역할에 따라 인터페이스를 나눠 만들어서 조립 형식으로 구현하면 됩니다.
I (Interface Segregation 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을 조합해서 사용한다면 확장해서 사용하기 편해집니다.
D (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.IsActive) _client.Deactivate();
else _client.Activate();
}
}
ISwichable 인터페이스를 사용하여 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를 구현하여 사용하면, Switch의 코드 수정 없이 간단하게 구현할 수 있습니다.
지금까지 작성했던 코드들이 디자인 패턴을 지키지 못한 코드였다는 사실을 직시하게 되었고, 더 좋은 코드와 구조 위해 많은 노력을 해야 한다는 것을 느끼게 해준 영상이였습니다.
무조건 SOLID 원칙을 지켜야한다는 강박관념을 가지지 말고, SOLID 원칙을 좋은 도구처럼 어떤 상황에 사용해야 좋을지 고민하며 개발을 하고, 컴포넌트형 개발 방식과 어떻게 적절하게 사용할 수 있을지 고민해봐야 할 것 같습니다.
제가 공부한 부분을 정리한 내용이기 때문에 틀린 부분 있을 수 있습니다!!
혹시 틀린 부분이 있으면 알려주세요!!
'Unity' 카테고리의 다른 글
[Unity] Stats Window 살펴보기 (1) | 2024.06.04 |
---|