[Unity/C++] SOILD Principle

녹차·2024년 8월 2일

Unity

목록 보기
7/11
post-thumbnail

Introduction

Reasons for introducing SOILD principle

해외, 국내를 막론하고 SOILD 원칙에 대한 포스팅은 정말 많습니다. 포스팅이 그만큼 많은 이유는 정말 중요하고, 기초되는 이야기이기 때문일겁니다.

그렇기에 알고 있다면 그 중요성을 다시 되뇌이면서, 몰랐다면 이번 기회에 '확실히' 학습하셨음 좋겠습니다.

SOILD principle BackGround

SOILD 원칙이라는 말은 Robert C. Martin이 2000년 논문 “디자인 원칙과 디자인 패턴"이라는 곳에 소개되었습니다. 이후 Michael Feathers가 SOLID라는 약어를 사용하면서 더욱 발전했습니다.

해당 내용을 언급하는 이유는 그만큼 기초가 되고, 중요하다는걸 말하면서 '지식의 출처'를 밝히기 때문입니다.

세종대왕이 한글을 만들었다는 사실이 큰 업적이 되듯, 지금 배우는 SOILD 원칙 또한 하나의 업적을 배운다고 생각하면 될 것 같습니다.

Body

SOILD원칙은 5가지의 개념으로 구성됩니다.

Single Responsibility (단일 책임의 원칙)
Open/Closed (개방폐쇄의 원칙)
Liskov Substitution (리스코프 치환의 원칙)
Interface Segregation (인터페이스 분리의 원칙)
Dependency Inversion (의존성역전의 원칙)

Single Responsibility Principle(SRP)

단일 책임 원칙이란? 클래스는 오직 하나의 책임만 가져야 한다는 원칙입니다.

즉, 클래스는 하나의 기능이나 목적에 집중해야 하며, 이로 인해 유지보수성과 확장성이 향상됩니다.

[참고 문헌]
https://inpa.tistory.com/entry/OOP-%F0%9F%92%A0-%EC%95%84%EC%A3%BC-%EC%89%BD%EA%B2%8C-%EC%9D%B4%ED%95%B4%ED%95%98%EB%8A%94-SRP-%EB%8B%A8%EC%9D%BC-%EC%B1%85%EC%9E%84-%EC%9B%90%EC%B9%99

SRP 장점

SRP의 EX

using UnityEngine;

/// <summary>
/// Warrior Model의 상호작용에 대한 역할을 한다.
/// </summary>
[RequireComponent(typeof(RedHoodInput))]
public class RedHoodController : Character
{
	RedHoodStat stat;
	RedHoodInput redHoodInput;

	public override void Start()
	{
		base.Start();

        #region #Components Caching 
		redHoodInput = GetComponent<RedHoodInput>();
		#endregion
    }

    public override void Update()
    {
        CharacterMove();
		RedHoodJump();
		RedHoodDash();
		...
	}

	public override void CharacterMove()
	{
        //기능
    }

	void RedHoodJump()
    {
		//기능
	}

	void RedHoodDash()
	{
		//기능
    }

	...
}

해당 Code를 보면서 두 가지 부분으로 나뉘어 짚고 싶습니다.

  1. 단일 책임 원칙 이뤄진 부분
  • RedHoodStat.cs
  • RedHoodInput.cs
  1. 단일 책임 원칙 이뤄지지 않은 부분
  • CharacterMove()
  • RedHoodJump()
  • RedHoodDash()

먼저, 다른 분들의 SOILD 원칙에 대한 Posting을 보면 Player의 행동까지 단일 클래스로 만드는 경우도 있었습니다.

물론 Player의 행동이 다른 곳에서도 사용이 되면(Move, Attack, ETC) 저 또한 단일 클래스로 만들어 관리할 것 같지만 아래와 같은 생각을 했습니다.

Player의 고유 행동에 대해서도 따로 단일 클래스를 만들어야 하나?(주요 논점)

그 생각에 대한 결론은 '객체에 대한 고유 행동은 @Controller에서 따로 만들자'고 생각했습니다.

프로젝트 규모에 따라 다르겠지만 Player의 Attack 한 사람, Player의 Jump 한 사람보다는 한 사람이 Player 기능을 전반적으로 담당하는게 더 '효율'이기 때문입니다.

개개인 마다 어디까지를 단일 책임으로 볼지에 대한 문제

그래서 저의 경우 고유 행동은 @Controller에 넣고, 그 외에 부분을 단일 책임 원칙을 적용하여 관리하고 있습니다.

Open/Closed(OCP) Principle

개방/폐쇄 원칙이란? 모든 코드는 확장에는 열려 있어야 하며 변경에는 닫혀 있어야 하는 원칙입니다.

즉, 기존의 코드를 수정하지 않고 새로운 기능을 추가할 수 있어야 한다는 것 입니다.

[참고 문헌]
https://inpa.tistory.com/entry/OOP-%F0%9F%92%A0-%EC%95%84%EC%A3%BC-%EC%89%BD%EA%B2%8C-%EC%9D%B4%ED%95%B4%ED%95%98%EB%8A%94-OCP-%EA%B0%9C%EB%B0%A9-%ED%8F%90%EC%87%84-%EC%9B%90%EC%B9%99

OCP의 장점

OCP의 EX

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public interface IState<T>
{
    public void Begin(T owner);

    public void Runtime(T owner);

    public void End(T owner);
}

여러 Behavior Pattren 중에 State Pattern을 예시로 OCP의 잘 지킨 예를 들어보았습니다. 해당 State Interface는 In-Game, Enemy, NPC 등등 많은 곳에 'Has-is' 관계로 사용되며 변화에 대한 유용성을 보여줍니다.

OCP는 '추상화'의 중요성을 의미하는 원칙입니다. 보통 우리는 추상화라는 개념에 대해 '구체적이지 않은' 정도의 의미로 느슨하게 알고만 있습니다.

하지만 '그래디 부치(Grady Booch)'에 의하면 '추상화란 다른 모든 종류의 객체로부터 식별될 수 있는 객체의 본질적인 특징'이라고 정의합니다.

즉, 추상 메서드 설계에서 적당한 추상화 레벨을 선택함으로써, 어떠한 행위에 대한 본질적인 정의를 서브 클래스에 전파함으로써 관계를 성립되게 하는 것입니다.

출처: https://inpa.tistory.com/entry/OOP-💠-아주-쉽게-이해하는-OCP-개방-폐쇄-원칙 [Inpa Dev 👨‍💻:티스토리]

그렇기에 '추상화' 와 '디자인 패턴을 배워야 하는 이유'를 두 가지를 짚으며 OCP 설명을 마무리 하겠습니다.

Liskov Substitution Principle(LSP)

리스코프 치환 원칙이란? 상속관계에서 하위 클래스는 상위 클래스를 대체할 수 있어야 한다는 것 입니다.

즉, 자식 클래스는 부모 클래스의 기능을 대체할 수 있어야 하며, 부모 클래스의 객체를 사용하는 코드에서 자식 클래스의 객체를 사용해도 문제가 없어야 합니다.

[참고 문헌]
https://inpa.tistory.com/entry/OOP-%F0%9F%92%A0-%EC%95%84%EC%A3%BC-%EC%89%BD%EA%B2%8C-%EC%9D%B4%ED%95%B4%ED%95%98%EB%8A%94-LSP-%EB%A6%AC%EC%8A%A4%EC%BD%94%ED%94%84-%EC%B9%98%ED%99%98-%EC%9B%90%EC%B9%99

LSP의 장점

LST의 EX1

먼저 LSP의 대표 예제인 'Circle-ellipse problem'를 코드로 제시하겠습니다.

코드를 제시하기 전, 직사각형은 정사각형을 포함하지만 정사각형은 직사각형을 포함하지 않다는 사실을 기억해야 합니다.

class Rectangle {
public:
    void setWidth(int width) {
        this->width = width;
    }

    void setHeight(int height) {
        this->height = height;
    }

    int getArea() const {
        return width * height;
    }

protected:
    int width;
    int height;
};

위 코드에서 부모 클래스인 Rectangle을 작성합니다. 바로 다음 코드를 제시하겠습니다.

Class Square : public Rectangle {
public:
    void setWidth(int width) {
        this->width = width;
        this->height = width; // 정사각형이므로 너비와 높이를 같게 설정
    }

    void setHeight(int height) {
        this->height = height;
        this->width = height; // 정사각형이므로 너비와 높이를 같게 설정
    }
};

void printArea(Rectangle& rect) {
    rect.setWidth(5);
    rect.setHeight(10);
    std::cout << "Area: " << rect.getArea() << std::endl;
}

main 함수를 보기 전에 실행결과를 생각해본다면 직사각형은 50, 정사각형은 25가 나와야 정상적인 출력이 될 것입니다. 하지만 main 함수의 code를 보며 더 이야기 해보겠습니다.

int main() {
    Rectangle rect;
    printArea(rect); // 정상 동작

    Square square;
    printArea(square); // LSP 위반: 예상과 다른 결과
    return 0;
}

실행결과
Area : 50
Area : 50

예상과 반대로 정사각형이 넓이가 50이 출력되었습니다.

왜 이런 문제가 생기는 걸까요?

이는 바로 정사각형이 직사각형을 상속 받는 것이 올바른 상속 관계가 아니라는 것을 의미합니다. 자식 객체가 부모 객체의 역할을 완전히 대체하지 못한다는 의미죠.

어떻게 해결할 수 있을까요?

현재 부모-자식 관계를 형제 관계로 바뀌면 쉅게 해결이 됩니다. 해당 코드에 대해서 제시는 안하지만 직접 구현해보셨으면 좋겠습니다.

LST의 EX2

이번에 두번 째 예시로 저의 Posting 일부분의 Code를 가져와 Unity스러운 설명을 하겠습니다.

Unity에서 2d 플랫포머 게임을 만들 때 수 많은 오브젝트가 존재합니다. user가 직접적인 상호작용을 하는 player부터 Enemy, NPC 등등 무려 장애물 조차 오브젝트에 포함됩니다.

object들을 어떻게 SW Design하는게 좋을까요? 제 경험에 빗대어 말씀드리면 경험이 부족할 때는 @Controller을 만들어 따로따로 구현하였습니다.

결국 반복되는 코드에 문제를 느낀 저는 OOP스럽게 바뀌기 위해 디자인패턴, OOP 등 공부하며 이에 대한 저의 결론을 해당 코드와 제시하며 LST의 설명을 보충하겠습니다.

/// <summary>
/// 모든 객체의 Actor의 역할을 한다.
/// </summary>
public abstract class Actor : MonoBehaviour
{
    public abstract void ActorInit();

    public abstract void ActorUpdate();

    public abstract void ActorDestory();
}

먼저 해당 코드를 설명하면 추상 함수를 만들어 모든 object는 해당 추상 함수를 상속을 받아 구현합니다.

LST의 핵심은 상속이라 했는데, 해당 함수는 추상함수라서 LST와 거리가 멀지 않나요?

절반은 맞고, 절반은 틀립니다. 물론 LST는 상속과 깊은 관련이 있고 추상과 비슷하지만 분명히 다른 개념입니다.

하지만 LST의 관점에서 말하면, LST는 업캐스팅된 상태에서 부모의 메서드를 사용해도 동작이 의도대로만 흘러가도록 구성하면 되는 것이기 때문에 해당 코드는 LST 관점에서 올바르다고 할 수 있습니다.

마지막으로 여러분들이 게임 개발을 하다 보면 현실 세계와 빗대어 상속 관계를 생각하곤 합니다.

하지만, 실제로 클래스 구조를 짜고 구현할 때엔, 서로 다른 부모클래스가 필요할 수도 있기 때문에 SW Design할 때 현실 세계와 똑같은 상속 관계로 만들 이유는 없습니다.

LST는 '확실한 Is-A' 관계가 아니면 그 외의 경우에는 'Has-A(합성)' 관계을 권하고 있습니다.

Interface Segregation Principle(ISP)

인터페이스 분리 원칙이란? 사용하지 않는 인터페이스에 의존하도록 강요되지 말아햐 하는 원칙입니다.

즉, 인터페이스를 클라이언트의 요구에 따라 분리하여 불필요한 의존성을 제거합니다.

SIP가 클래스의 단일 책임을 얘기한다면, ISP는 인터페이스의 단일 책임을 강조하고 있습니다.

ISP의 장점

ISP의 EX

MMORPG의 Enemy로 예시를 들어 코드를 제공하겠습니다. 보통 Enemy은 크게 3가지로 구분됩니다.

  • Player를 보면 공격하는 Type
  • Player가 먼저 공격해야 공격하는 Type
  • Player가 공격해도 공격하지 않는 Type

ISP의 적용하지 않았을 때 코드를 먼저 보며 필요성을 보겠습니다.

public abstract class Enemy로 : MonoBehaviour
{
	int HP;
    ...각종 스탯....
    
    void abstract Move();
    
    void abstract Attack();
    
  	void abstract Trace();
}

해당 코드를 작성하고 난 뒤 'Player가 공격해도 공격하지 않는 Type'을 적용하려 보니까 해당 type은 Attack()와 Trace()을 구현할 필요가 없습니다.

굳이 분리할 필요가 있나요?

프로그래머에 따라 다르겠지만 여기서 중요한 점은 '강제로 구현해야 된다는 점'입니다. 이를 없애기 위해 ISP을 적용한 코드를 제시하겠습니다.

public interface IMove
{
    void Move();
}

public interface ITrace
{
    void Trace();
}

public interface IAttack
{
    void Attack();
}

public class Enemy : MonoBehaviour
{
	int HP;
    ...각종 스탯....
}

public class EnemyType2: Enemy, IMove, ITrace, IAttack
{
	...
	
    public void Move()
    {
    }
    
    public void Trace()
    {
    }
    
    public void Attack()
    {
    }
}

public class EnemyType3 : Enemy ,IMove
{
	...
    
    public void Move()
    {
        
    }
}

위 코드처럼 ISP을 적용하면 'Player가 공격해도 공격하지 않는 Type'에 대해 전 처럼 Attack() 그 외에 함수를 강제로 구현할 필요성이 사라집니다.

이처럼 위에 배운 OCP가 잘 적용되어야 ISP 원칙에 위배되지 않을 수 있습니다.

Dependency Inversion Principle(DIP)

의존 역전 원칙이란? 상위 모듈은 하위 모듈에 의존해서는 안 된다. 두 모듈 모두 추상화에 의존해야 하며, 구체적인 구현이 아닌 인터페이스나 추상 클래스에 의존해야 합니다.

즉, Class를 직접 참조하는 것이 아니라 그 대상의 상위 요소(추상 클래스 or 인터페이스)로 참조하라는 원칙입니다.

DIP의 장점

DIP의 EX1

DIP의 대표적인 예제인 'Cars & Snow tires Story'를 코드로 제시하겠습니다.

코드를 제시하기 전, 'Cars & Snow tires Story'란 겨울에는 자동차에 스노우 타이어를 끼도록 설계하였습니다.

즉, 고수준 모듈인 자동차가 저수준 모듈인 스노우 타이어에 의존하는 상태입니다.

하지만 날씨가 여름으로 바뀌며 더 이상 스노우 타이어를 낄 필요가 없어졌습니다. 그래서 스노우 타이어를 뺄려 했지만 자동차의 코드에 영향이 끼치기 때문에 이를 DIP을 적용하여 이를 해결합니다

이제 Cars & Snow tires Story 코드로 보겠습니다.

DIP의 EX2

Conclusion

[참고 문헌]
https://www.nextree.co.kr/p6960/

https://www.youtube.com/watch?v=J6F8plGUqv8&t=1712s

profile
CK23 Game programmer

0개의 댓글