SOLID Design Patterns - Unity

gooey newts·2022년 8월 31일
1

Coding

목록 보기
1/1

SOLID Overview

  1. SRP - Single Responsibility Principle
  2. OCP - Open Closed Principle
  3. LSP - Liskov's Substitution Principle
  4. ISP - Interface Segregation Principle
  5. DIP - Dependency Inversion Principle

1. Single Responsibility Principle

"A class should only have a single responsibility, that is, only changes to one part of the software's specification should be able to affect the specification of the class."

먼저 아래와 같은 끔찍한 클래스가 있다고 가정해봅시다.

public class Game : MonoBehaviour
{
	public float EnemySpeed = 1.0f;
	public float HeroSpeed = 1.0f;
    public float ParticleSmokeCount = 15.0f;
    public int NextEnemyBasketCount = 12;
    public float TimerToNextParticleRelease = 10.0f;
    public int Level = 1;
    public float MaxHealth = 100f;
    public float Health = 100f;
    public Texture[] SmokeParticles;
    public Enemy[] Enemies;
    ...
}

이렇게 많은 요소들을 한가지 클래스 안에 넣게 되면 가독성을 크게 헤치게 됩니다. 클래스에 파악이 어렵다보니 버그가 발생할 확률이 올라가겠죠.

이쯤에서 SRP에 대한 위키 검색 설명을 봅시다.

SRP의 아이디어는 프로그램의 모든 클래스, 모듈 또는 함수가 프로그램에서 하나의 책임/목적을 가져야 한다는 것입니다. 일반적으로 사용되는 정의로 "모든 클래스는 변경해야 하는 단 하나의 이유만 있어야 합니다."

단순하게 풀이하자면 클래스는 단 한가지의 일만 해야 합니다.
반대로 보면 하나의 클래스는 단 한가지 이유의 fail만 있겠죠.

위의 클래스를 아래와 같이 분할합시다.

단 많은 클래스들을 관리하기 위해 폴더 구조에도 신경을 써야 할 것입니다.


2. Open Closed Principle

"Software entities should be open for extension, but closed for modification."

아래의 추상 클래스를 상속하는

public abstract class ShapeBehaviour : MonoBehaviour
{
	public float Height { get; set; }
    public float Width { get; set; }
}

사각형 클래스가 있습니다.

public class RectangleShape : ShapeBehaviour {}

사각형의 넓이를 구하기 위해 다음과 같은 클래스를 만들었습니다.

public class ShapeTools
{
	public static float Area(ShapeBehaviour shape)
    {
    	return shape.Height * shape.Width;
	}
}

물론 메서드는 훌륭하게 작동합니다. 사각형에서 말이죠.
아래와 같은 원형 클래스가 추가되었다고 해봅시다.

public class CircleShape : ShapeBehaviour
{
	public float Radius = 2f;
}

이제 ShapeTools 클래스의 Area 메서드는 원하는 결과를 주지 못하게 되었습니다.

먼저 OCP가 적용되지 않은 해결 방법을 살펴봅시다.

public static float Area(ShapeBehaviour shape)
{
	if(shape is RectangleShape)
    {
    	return shape.Height * shape.Width;
	}
    
    if(shape is CircleShape)
    {
    	var radius = ((CircleShape)shape).Radius;
        return radius * radius * Mathf.PI;
	}
    
    throw new System.NotImplementedException("The " + shape.GetType().Name + " is not implimented in ShapeTools.Area(...)");
}

이 방법은 exception handling과 같은 멋진 일을 하고 있지만
여전히 shape이 어떤 type인지 파악해야 하고
추가되는 shape에 대해 매번 대응해야 합니다.

OCP

객체 지향 프로그래밍에서 개방-폐쇄 원칙은 "소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장을 위해 열려야 하지만 수정을 위해 폐쇄되어야 한다"라고 말합니다.
  • 클래스는 한번 쓰여지면 더 이상 수정하는 일이 없어야합니다.
  • 그러나 확장성은 유지해야 합니다.

OCP에 의거해 이 문제를 해결해봅시다.
일단 아래의 ShapeTools는 버립시다.

public class ShapeTools
{
	public static float Area(ShapeBehaviour shape)
    {
    	return shape.Height * shape.Width;
	}
}

대신 ShapeBehaviour 클래스에 추상 메서드 Area를 추가합니다.

public abstract class ShapeBehaviour : MonoBehaviour
{
	public float Height { get; set; }
    public float Width { get; set; }
    public abstract float Area();
}

이렇게 하면 ShapeBehaviour를 상속 받는 각각의 shape에서 Area 메서드가 어떻게 작동할지 정의할 수 있게 됩니다.

아래와 같이 말이죠.

public class RectangleShape : ShapeBehaviour
{
	public override float Area()
    {
    	return Height * Width;
	}
}
public class CircleShape : ShapeBehaviour
{
	public float Radius = 2f;
    
	public override float Area()
    {
    	return Radius * Radius * Mathf.PI;
	}
}


3. Liskov's Substitution Principle

"Objects in a program should be replaceable with instance of their subtypes without altering the correctness of that program."

아래와 같이 타일을 관리하는 GameBoard 클래스가 있습니다.

public class GameBoard : MonoBehaviour
{
	public GameTile[,] Tiles;
    
    public void SetTile(int x, int y, GameTile tile)
    {
    	Tiles[x, y] = tile;
	}

좋아요. 잘 작동합니다.
하지만 특정 레벨에서는 3D Tile을 사용해야 한다고 가정해봅시다.

LSP에 어긋나는 예를 먼저 보여드리겠습니다.

기존의 GameBoard와 매우 유사하기 때문에 GameBoard로 부터 상속 받아 GameBoard3D 클래스를 만들었습니다.

public class GameBoard3D : GameBoard
{
	public GameTile[,,] Tiles3d;
    
    public void SetTile(int x, int y, int z, GameTile tile)
    {
    	Tiles3d[x, y, z] = tile;
	}

작동에는 문제가 없겠지만,
이런 방식의 상속은 추후 개발에 큰 혼란을 가져올 수 있습니다.
GameBoard와 GameBoard3D를 잘못 혼용할 수 있기 때문이죠.

LSP - 두개의 다른 유형이 동일한 기본 유형을 갖는 경우, 둘 다 기본 유형을 사용하는 모든 구성원에 대해 작동해야 합니다.

LSP에 따라 상속하지 말아야 할 때도 알아야 합니다.

GameBoard3D가 좋은 예죠.
이것은 GameBoard와 동일한 기능이 많다고 해도 분리하는게 올바른 방법입니다.

public class GameBoard3D : MonoBehaviour
{
	public GameTile[,,] Tiles3d;
    ...

어떠한 타입을 가져다 쓸 때, 기본 타입으로 신뢰해서 사용할 수 있어야 합니다.
string 타입이라면 string이 할 수 있는 모든 것을 할 수 있는 것 처럼 말이죠.


4. Interface Segregation Principle

"Many client-specific interfaces are better than one general-purpose interface."

'Interface Seperation Principle'이라고 부리기도 합니다.

ISP(인터페이스 분리 원칙)는 코드가 사용하지 않는 방법에 의존하도록 강요되어서는 안 된다고 명시하고 있습니다. ISP는 매우 큰 인터페이스를 더 작고 보다 구체적인 인터페이스로 분할하여 클라이언트가 관심 있는 방법에 대해서만 알면 됩니다.
  • interface는 '하나의 맴버' 또는 '하나의 목적'만을 가져야합니다.

다음과 같은 interface를

public interface IContentIO
{
	string LoadAsString(string id);
    object LoadAsObject(string id);
    T LoadAs<T>(string id);
    byte[] LoadAsByteArray(string id);
    JsonObject LoadAsJson(string id);
}

아래와 같이 바꿔줍시다.

public interface IStringIO
{
	string LoadAsString(string id)
}
public interface IJsonIO
{
	JsonObject LoadAsJson(string id);
}

이렇게 interface를 잘게 나누면, 필요 없는 interface 까지 작성하는 일이 없어지겠죠. interface는 원하는 만큼 가질 수 있습니다. 따라서 잘게 나눠, 필요한 만큼 가져다 쓰세요. 하지만 대부분의 경우에 하나 또는 두개의 interface면 충분할겁니다.

ISP

  • interface를 활용합시다.
  • interface가 하나의 목적만을 가지게 합시다.
  • class는 여러개의 interface를 가질 수 있습니다.

유니티에서의 ISP

  • 유니티 인스펙터는 interface를 지원하지 않습니다.
  • 인스펙터에 노출된 field가 필요하다면 abstract 클래스를 활용합시다.


5. Dependency Inversion Principle

One should "depend upon abstractions, not concretions."

아래의 클래스가 있다고 가정해봅시다.

public class Shot : MonoBehaviour
{
	public float Damage = 1f;
}

그리고 이 클래스의 Damage 필드를 사용하는 다양한 클래스들도 있다고 해봅시다.

  • Enemy 클래스(Damage 만큼 HP 감소)
  • BlastTrigger 클래스(맞았을때 반응하는 프랍)
  • Score 클래스(누적된 Damage에 따라 Score 획득)
    등등...

물론 이렇게 쓰여진 코드도 잘 작동합니다.
하지만 만약에 6개월 뒤 RocketShot이라는 클랙스가 추가된다면 어떨까요?

public class RocketShot : Monobehaviour
{
	public float Damage = 1f;
}

RocketShot클랙스는 기본적인 구성은 같지만 Shot클래스에 몇가지 기능이 추가되어 있습니다.
하지만 Damage가 필요한 다른 클래스들은 모두 Shot클래스만을 레퍼런싱하고 있죠. RocketShot 클래스의 연결이 되지 않는 상태입니다.

이 문제를 해결하기 위한 방법은 여러가지이지만,
DIP 방식에 의거해 해결해봅시다.

먼저 나쁜 예 부터 확인해봅시다.

public class Score : MonoBehaviour
{
	public float Points = 0f;
    public float DamagePointRatio = 0.1f;
    
    void ApplyShotDamageScore(Shot shot)
    {
		Points += DamagePointRatio * shot.Damage;
	}

다양한 클래스를 받기 위해 위에 Shot클래스 대신 object 타입으로 매개변수로 받습니다.

void ApplyShotDamageScore(object shot)
{
	if(shot is Shot)
    {
    	Points += DamagePointRatio * ((Shot)shot).Damage;
	}
    
    if(shot is RocketShot)
    {
    	Points += DamagePointRatio * ((RocketShot)shot).Damage;
	}
}

이처럼 클래스를 확인하고 타입별로 if문을 넣어서 처리할 수 있습니다. 하지만 이 방법은 코드를 관리하기 어려워질 뿐 아니라 문제를 결국 더 키우게 됩니다.

이런 경우에 DIP가 적절하게 사용될 수 있습니다.

DIP(Dependency Inversion Principle)에 따르면 상위 수준 모듈은 하위 수준 모듈에 의존해서는 안 됩니다. 둘 다 추상화에 의존해야 합니다. 추상화는 세부 사항에 의존해서는 안됩니다.

매우 간략하게 설명하자면 "그냥 interface나 abstract를 사용하자"입니다.

DIP를 활용한 예입니다.
아래와 같이 추상 클래스를 만들고,

public abstract class ShotBehaviour : MonoBehaviour
{
	public float Damage = 1f;
}

Shot, RoctShot 클래스와 같이 필요한 클래스에서 상속하게 만듭니다.

public class Shot : ShotBehaviour {}
public class RockeShot : ShotBehaviour {}

이제 기존 Shot클래스에 의존하던 다른 클래스들도 아래와 같이 추상 클래스인 ShotBehaviour를 쓰면, 또 다른 ShotBehaviour가 추가되어도 문제가 없는 유연한 구조를 갖게 됩니다.

void ApplyShotDamageScore(ShotBehaviour shot)
{
	Points += DamagePointRatio * shot.Damage;
}

유니티에서의 DIP

  • 유니티 인스펙터는 interface를 지원하지 않습니다.
  • 인스펙터에 노출된 field가 필요하다면 abstract 클래스를 활용합시다.


0개의 댓글