일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- solid 원칙
- algorithm
- 3D RPG
- Modern C++
- stack
- Euclidean
- level3
- Silver
- programmers
- Flyweight Pattern
- dirtyflag pattern
- Bronze
- effective C++
- knapsack Problem
- 프로세스 상태
- SWEA
- 8-Puzzle
- two pointer
- Zenject
- Gold
- 프로그래머스
- BFS
- LEVEL2
- PrefixSum
- trie
- level1
- BOJ
- binary search
- Project
- Unity
- Today
- Total
Patrick's Devlog
[Unity] SOLID 원칙 본문
단일 책임 원칙(Single-Responsibility Principle)
모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화해야 한다. 클래스가 제공하는 모든 기능은 이 책임과 부합하다.
- Readability(가독성) : 짧은 클래스가 읽기 쉬움(example> 200~300 lines)
- Extensibility(확장성) : 작은 클래스로부터 상속이 쉬움
- Reusability(재사용성) : 부분에서 재사용할 수 있도록 작고 모듈식으로 설계
위의 그림처럼 인스펙터 창에 있는 컴포넌트들은 모두 단일 책임 원칙을 따른다.
public class Player : MonoBehaviour {
[SerializeField] private string inputAxisName;
[SerializeField] private float positionMultiplier;
private float yPosition;
private AudioSource bounceSfx;
private void Start() {
bounceSfx = GetComponent<AudioSource>();
}
private 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);
}
private void OnTriggerEnter(Collider other) {
bounceSfx.Play();
}
}
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;
private void Start() {
playerAudio = GetComponent<PlayerAudio>();
playerInput = GetComponent<PlayerInput>();
playerMovement = GetComponent<PlayerMovement>();
}
}
public class PlayerAudio : MonoBehaviour { ... }
public class PlayerInput : MonoBehaviour { ... }
public class PlayerMovement : MonoBehaviour { ... }
Audio, Input 등을 지니고 있다는 것 보다 의존 or 사용하는 개념으로 접근하면 된다. 각각의 역할을 나누어 이를 활용하는 방식으로 단일 책임 원칙을 지키면 된다.
개방 폐쇄 원칙(Open-cloaed principle)
확장에 대해 열려있어야 하며, 수정에는 닫혀있어야 한다.
확장에 대해 열려 있어야 한다는 것은 모듈의 동작을 확장할 수 있다는 것을 의미하고, 요구 사항이 변경될 때 새로운 동작을 추가해 모듈을 확장(상속 등) 할 수 있어야한다. 즉, 모듈이 하는 일을 변경할 수 있다는 의미다.
수정에 대해서는 닫혀있어야 한다는 것은 코드를 수정하지 않아도 모듈의 기능을 확장하거나 변경이 가능하다는 의미이다. 모듈의 라이브러리(example>DLL)의 수정이 필요 없어야 한다.
public class Rectangle {
public float width;
public float height;
}
public class Circle {
public floast 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;
}
}
Rectangle, Circle의 부피를 계산하는 클래스를 만든다고 가정한다. 이때, GetRectangleArea, GetCircleArea를 따로 두게 되면 도형이 늘어날 때마다 추가를 해주어야 한다.
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의 CalculateArea 함수만 호출하면 됨
}
}
개방 폐쇄의 원칙을 지키고 코드를 구현하면 위의 코드와 같다. Shape는 CalculateArea라는 추상 메소드를 생성하고, 이를 실제 구현할 때 상속받아 구현해주면 된다.
리스코프 치환 원칙(Liskov substitution principle)
파생 클래스(자식, 하위 클래스)는 기본 클래스(부모, 슈퍼 클래스)를 대체할 수 있어야 하며, 하위 클래스를 강력하고 유연하게 만드는 원칙이다. OOP의 상속을 사용하면 하위 클래스를 통해 기능을 추가할 수 있으나, 주의하지 않으면 불필요한 복잡성이 발생한다.
서브 클래싱할 때 기능을 제거하는 경우 리스코프 치환 원에 위배된다. 쉽게 설명하면 부모 클래스에 기능이 존재할 때 서브 클래스도 이 기능을 상속받았을 때 아무것도 동작하지 않는 무효화 코드로 생성해버리면 이론적으로는 가능하나 원칙에 위배된다. 그렇기 때문에 추상화는 가능한 단순하게 유지하는 것이 좋다. 하위 클래스에는 기본 클래스의 퍼블릭 멤버가 존재하고, 상속보다는 구성에 초점을 맞추는 것이 좋다.
클래스 계층 구조를 설명하기 전, 클래스 API를 고려해야 한다. 현실의 분류가 항상 클래스 계층 구조로 정확히 변환되는 것은 아니다. 예를 들어 자동차와 기차가 존재할 때 상위 크래스에서 상속 받는 것은 합리적이지만, 무효화 기능이 존재할 수 있으므로 이를 신경써줘야 한다.
코드로 예시를 살펴보자
public class Vehicle
{
public float speed = 100;
public Vector3 direction;
public void GoForward() { ... }
public void Reverse() { ... }
public void TurnRight() { ... }
public void TurnLeft() { ... }
Vehicle이 존재하고 Vehicle 안에는 전진, 후진 등 필요한 기능이 구현되어 있다. 또한 여기서 Car와 Truck이 Vehicle을 상속 받는다. 만약, Vehicle에 Train도 추가를 해야 한다면, TurnRight와 TurnLeft가 따로 필요하지 않고 직진만 하면 된다.
public class Navigator {
public void Move(Vehicle vehicle) {
vehicle.GoForward();
vehicle.TurnLeft();
vehicle.GoForward();
vehicle.TurnRight();
behicle.GoForward();
}
}
위의 그림처럼 Train은 TurnRight와 TurnLeft가 무효화 코드로 이론상으로 문제되진 않으나, 리스코프 치환 원칙에 위배된다. 부모 클래스에 있는 기능을 무효시킨다는 의미는 부모클래스의 방향성을 따르지 않는다는 뜻이 된다.
이를 방지하기 위해 인터페이스로 나누면 조금 더 수월해진다.
public interface ITurnable {
public void TurnRight();
public void TurnLeft();
}
public interface IMovable {
public void GoForward();
public void Reverse();
}
// ---
public class RoadVehicle : IMovable, ITurnable {
public float speed = 100f;
public float turnSpeed = 5f;
public virtual void GoForward();
public virtual void Reverse();
public virtual void TrunLeft();
public virtual void TurnRight();
}
public class RailVehicle : IMovable {
public float speed = 100f;
public virtual void GoForward();
public virtual void Reverse();
}
Turn의 여부, 움직임의 여부 등 인터페이스를 분리하고 이를 조합하여 상속받는 것이 좋다. 원칙에 위배되지 않고, 추후 다른 탈것들이 추가로 존재하면 이 인터페이스를 조합하여 상속받아 사용하면 조금 더 확장에는 유용하다고 할 수 있다. "is-a" 관계보다는 "has-a" 관계 개념을 이용하는 것이다.
인터페이스 분리 원칙(Interface Segregation Principle)
클라이언트가 자신이 이용하지 않는 메소드에 의존하지 않아야하는 원칙이다. 큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리하고, 클라이언트들이 꼭 필요한 메소드들만 이용할 수 있게 한다. 단일 책임 원칙처럼 인터페이스도 꼭 필요한 것들만 조립하여 사용해 최대한 세분화하는 것이 좋다는 의미이다. 이를 이용하면 시스템의 내부 의존성은 약화하고 유연성, 확장성을 강화할 수 있다.
public interface IUnitStats {
public float Health {get; set;}
public int Defense {get; set;}
public void Die();
public void TakeDamage();
public void RestoreHealth();
public float MoveSpeed {get; set;}
public float Acceleration {get; set;}
public void GoForward();
public void Reverse();
public void TurnLeft();
public void TurnRight();
public int Strength {get; set;}
public int Dexterity {get; set;}
public int Endurance {get; set;}
}
유닛을 만든다고 가정할 때, 체력, 디펜스 등 다양한 기능을 하나의 인터페이스 안에 두는 것이 아닌 세분화하는 것이 좋다.
public interface IMovable {
public float MoveSpeed {get; set;}
public float Acceleration {get; set;}
public void GoForward();
public void Reverse();
public void TurnLeft();
public void TurnRight();
}
public interface IDamageable {
public float Health {get; set;}
public int Defense {get; set;}
public void Die();
public void TakeDamage();
public void RestoreHealth();
}
public interface IUnitStats {
public int Strength {get; set;}
public int Dexterity {get; set;}
public int Endurance {get; set;}
}
public interface IExplodable { ... }
// ---
public class ExplodingBarrel : MonoBehaviour, IDamageable, IExplodable { ... }
public class EnemyUnit : Monobehabiour, IDamagable, IUnitStats, IMovable { ... }
인터페이스를 분리해서 코드를 정리하면 위의 코드와 같다. 언어마다 다르긴 하나, C#에서의 인터페이스는 다중 상속이 가능하다.
의존 역전 원칙(Dependency Inversion Principle)
소프트웨어 모듈들을 분리하는 특정 형식을 얘기한다. 즉, 상위 모듈은 하위 모듈의 것을 직접 가져오면 안되며 둘 다 추상화에 의존해야한다는 의미이다. 추상화는 세부 사항에 의존해서는 안되고, 세부 사항이 추상화에 의존해야 한다.
또한, 클래스가 다른 클래스 직접적인 연관이 있으면 안된다. 클래스가 다른 클래스의 작동 방식을 많이 알고 있으면 안된다. 다른 클래스의 작동 방식을 알게 된다면 종속성(dependency) 또는 결합(coupling)이 발생되므로, 좋은 코드가 아니다. 또한 종속성은 잠재적인 위험이 발생한다.
너무 강하게 모듈들이 묶여있으면, 하나의 모듈을 수정하거나 제거하게 되면 나머지 모듈들도 수정하거나 제거해주어야 한다. 그러므로 서로 참조를 시킬 때 추상화를 한 후에 참조하는 것이 좋다.
public class Switch : MonoBehaviour {
public Door door; // 직접적인 의존
public bool isActivated;
public void Toggle() {
if (isActivated) {
isActivated = false;
door.Close();
}
else {
isActivated = true;
door.Open();
}
}
}
public class Door : MonoBehaviour {
public void Open() {
Debug.Log("The door is open.");
}
public void Close() {
Debug.Log("The door is closed.");
}
}
스위치를 눌러서 문을 열고 닫는 코드이다. 해당 코드의 문제점은 Door에 있는 함수를 직접적으로 호출함으로써 의존성이 발생하고 있다. 의존 역전 원칙은 상위 오브젝트와 하위 오브젝트는 의존하면 안되고 추상에 의존해야 한다. 따라서 의존 역전 원에 위배된다.
현재 코드에는 문제가 되지 않겠으나, 추후 확장해 문이 열리고 닫히는 것 뿐만 아니라 새로운 이벤트나 기믹이 추가되면 Switch에 기믹을 모두 추가해야 한다. 이를 인터페이스로 분리해주면 위배되지 않는다.
public interface ISwitchable {
public bool IsActive { get; }
public void Activate();
public void Deactivate();
}
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.");
}
}
// ---
public class Switch : MonoBehaviour {
public ISwitchable client;
public void Toggle() {
if (client.IsActive)
client.Deactivate();
else
client.Activate();
}
}
ISwitchable로 따로 분리시켜 Door는 이를 상속받아 구현을 해주면 된다. 만약에 Door가 아닌 NPC나 기믹이 존재한다면 Switchable을 상속받아 구현해주면 된다. 여기서 Switch 입장에서는 인터페이스를 가져와 Activate, Deactivate를 호출해주면 된다. 그렇게 되면 Switch 입장에서는 Door가 되든 기믹이 되든 상관이 없어진다. 이렇게 되면 확장에도 용이하다.
추상 클래스(Abstract class) VS 인터페이스(Interface)
abstract을 통해 상속을 받아서 구현을 진행하면, "is-a" 관계의 상속 개념으로 진행된다. 추상 클래스는 필드, 메소드 등을 모두 구현할 수 있다. 하지만 추상 클래스는 다중 상속이 불가능하다. (C# 기준, C++은 다중 상속 가능)
인터페이스는 다중 상속이 가능하므로 여러 기능들을 조립할 때 용이하다. 인터페이스는 "has-a" 관계라고 생각하면 된다. 추상 클래스와 인터페이스 중 어떤 것이 더 유리한지 명확하게 정해져 있는 것이 아니라 상황에 따라 선택하는 것이 좋다.
Abstract class | Interface |
메소드를 완전히 or 일부 구현 | 메소드를 선언만 가능, 구현 불가 |
변수 및 필드 선언/사용 | 메소드와 프로퍼티 선언만 가능(필드 제외) |
static 멤버 사용 가능 | static 멤버 사용 불가 |
생성자 사용 가능 | 생성자 사용 불가 |
모든 액세스 한정자 가능 : protected, private etc | 모든 멤버는 public으로 취급 |
정리
- 단일 책임 원칙 : 클래스가 한가지 작업만 수행, 변경할 이유는 하나만
- 개방 폐쇄 원칙 : 이미 작동하는 방식을 변경하지 않고 클래스의 기능을 확장할 수 있어야 함
- 리스코프 치환 원칙 : 하위 클래스는 기본 클래스를 대체할 수 있어야함. 기본 클래스의 방향성 유지
- 인터페이스 분리 원칙 : 인터페이스를 작게 유지. 클라이언트는 필요한 것만 구현
- 의존 역전 원칙 : 추상화에 의존. 하나의 구체 클래스에서 다른 클래스로 직접 의존 금지
참고 문서
[유니티 TIPS] 디자인 패턴의 기초, SOLID (솔리드) 원칙 이해하기
'Unity > Design Pattern' 카테고리의 다른 글
[Unity] Object Pool Pattern (0) | 2024.10.03 |
---|---|
[Unity] Factory Pattern (0) | 2024.10.01 |
[Unity] Command Pattern (0) | 2024.09.30 |
[Unity] Observer Pattern (0) | 2024.09.27 |
[Unity] State Pattern (1) | 2024.09.26 |