ved_Rony
article thumbnail

Intro

제목은 조금 그럴듯해 보이지만, 사실 개발을 하면서 생기는 구조적인 문제들을 해결할 수 있는 방법들 도구들을 소개하고, 연습하는 프로젝트이다. 사실, 앞으로 다룰 내용들은 문제에 대한 절대적인 정답이 될 수는 없다. 요구사항에 따라, 쉽게 말하면, 내가 현실화하려는 문제들에 따라 직면한 문제에 대해 대응할 방법은 다채롭게 변화할 수 있기 때문이다. 디자인 패턴은 단순히 우리가 그 솔루션을 숙고할 때 사용할 수 있는 도구들이다. 

개인적으로 이를 공부하는 이유는 궁극적으로 개발 아키텍팅에 관심이 있고, 유니티 개발자로서 일을 하면서 퍼포먼스를 더욱 끌어올리고 싶다는 욕심이 생겼다. 이를 위한 방안으로서, 현재 친숙한 엔진인 unity로 게임 프로그래밍 아키텍처를 구현 해보고, 파고 들고자 한다.


SOLID

본격적인 unity로 디자인 패턴을 그려보기 전에, SOLID에 대해서 먼저 알아야한다. 

디자인 패턴을 구현하기 전에, 가장 먼저 하나의 의문점을 해결해야한다. 우리는 어떤 기준으로 구조를 그려야하는 가? 이다.

단순히 기능만을 만들기 위한 개발이 아니라, 내가 만든 코딩으로 만약 앞으로 확장되거나, 다른 사람들과 협업을 하게 되거나, 유지 보수 하거나 등등의 경우에 얼마나 효율적으로 일할 수 있는 지에 대한 고려 사항이 들어가야한다.

이런 경우들을 위해, oop에는 SOLID라는 다섯가지 규칙이 존재한다.

  1.  Single responsibility
  2.  Open-closed
  3.  Liskov substitution
  4.  Interface segregation
  5.  Dependency inversion

이전에 oop에 대한 내용을 다뤘을 때, 짧게 다뤘던 내용이다. 하지만, 디테일이 부족했고, 실제 코드 구조 설계에서 적용을 하기 위해서는 더 많은 이해가 필요하다 생각하여, 이번에 더욱 자세히 다뤄볼 예정이다. 

https://note4iffydog.tistory.com/20

 

객체지향 프로그래밍(OOP) - 속성 및 원칙

객체지향의 4가지 속성 - 추상, 캡슐, 상속, 다형성 추상화 (Abstraction) 객체들의 공통적인 특징(기능, 속성)을 추출해서 정의하는 것 실제로 존재하는 객체들을 프로그램으로 만들기 위해 공통적

note4iffydog.tistory.com

 

Single responsibility

단일 책임 원칙, 하나의 클래스에는 하나의 책임만 가지고 있어야한다. 또한, 그클래스를 고칠때는 하나의 이유만이 존재해야한다.

프로젝트를 완성시킬때, 여러가지 작은 조각들을 모으고 모아서 하나로 만들어야한다.

결론은 하나의 클래스에서는 하나의 연관성을 가지고 있어야한다. 유니티에서 예를 들어보자.

유니티에는 유니티에서 지원하는 여러가지 component들이 존재한다. 

 

  • Transform - 위치, 크기, 회전등 좌표공간계에서의 상태를 가지고 있다.
  • Rigidbody - 물리법칙을 적용하기 위해서 필요한 요소
  • BoxCollider - 충돌처리 했을 때를 감지해준다.

위의 예시들은 하나의 컴포넌트가 하나의 책임을 가지고 있고, 잘 작동을 하는 것을 확인 해볼수가 있다. 이러한 하나하나의 알갱이 같은 컴포넌트 들을 모아서, 상호작용을 하게 만듦으로서 프로젝트를 완성해나갈수 있다.

 

단일 책임을 준수하지 않은 케이스

public class MonolithicDebugger : MonoBehaviour
{
    [SerializeField] private Text inputText;
    private AudioSource DebuggingSfx;
    private void Start()
    {
        DebuggingSfx = GetComponent<AudioSource>();
    }
    
    private void Update()
    {
        if (inputText.text == "test") {
            PrintDebug("say hello");
        }
    }
    
    private void OnTriggerEnter(Collider other)
    {
        DebuggingSfx.Play();
        PrintDebug("sound played");
    }

    private void PrintDebug(string text) {
        Debug.Log(text);
    }
}

단순하게 만들어 봤다. 트리거가 활성화 되면 오디오를 틀어주고, Debug를 해주는 여러가지가 들어 있는 클래스이다. 인풋, 오디오, 디버그 출력들이 하나로 묶여있다.

이를, 아래와 같이 바꾸면

[RequireComponent(typeof(DebuggerAudio), typeof(DebuggerInput),
    typeof(DebuggerPrinter))]
public class MonolithicDebugger : MonoBehaviour {
    [SerializeField] private DebuggerAudio audio;
    [SerializeField] private DebuggerInput input;
    [SerializeField] private DebuggerPrinter printer;
    
    private void Start()
    {
        audio = GetComponent<DebuggerAudio>();
        input = GetComponent<DebuggerInput>();
        printer = GetComponent<DebuggerPrinter>();
    }
}

더욱 잘게 나눠진 기능들을 확인해 볼수가 있다. 코드를 보기가 편해지고, 추후에 코드를 바궈야할 상황이 오면 더 손 쉽게 바꿀수 있게 된다.

더욱 잘게 나위어진 모습

Open-closed

개방 폐쇄 법칙, 확장할땐 열려있고, 수정할땐 닫혀 있어야한다. 이렇게 간단한 정의만 놓고 가면, 헷갈리수가 있다. 먼저 여기서 쓰이는 확장과 수정에 대한 이해를 하고 넘어가야할 듯 하다.

개발관점에서, 개인적으로는 다음과 같이 이해하고 넘어갔다. (개인의 소견이다..)

확장 - 예를 들어, 추가적인 경우의 수가 생길 때, 기존에 있었던 경우들과 더불어 늘어날 때

수정 - 오류가 나거나, 요구사항이 바뀌어서 기능수정이 들어갈 때

 

그렇다면, 경우의수가 추가 될때는 열려있다. 기능수정이 될때는 닫혀있다 인데, 이를 풀어서 쉽게 설명 하자면, 경우의 수가 늘어날때는 자유롭게 늘려도 기존의 코드에는 지장없이 경우를 늘릴수 있고, 기능 수정이 들어 갈때도 기존의 코드에는 영향이 가면 안된다. 즉, 예의 두가지의 경우 기존의 코드에 최대한 영향을 안주자는 법칙인 것이다.

위의 경우 서로다른 카드에 따라, 어댑터에서 정보를 보내주는 기능이라고 가정해보겠다.(이전 글의 예시에서 가져와봤다.)

위와 같은 식으로 개발한다면, card의 경우가 늘어날 경우 어떨까? 어댑터에 카드에 따라 계속 메소드 추가를 해주어야 할것이다.

이는 결국 개방 폐쇄의 원칙에서 확장일때 열려있어야한다 (경우의수가 늘어날 때, base코드에 손을 대지 않고 수정할수 있어야한다.) 가 위배된다. 이를 어떻게 수정해볼수 있을까

abstract 혹은 Interface를 활용해보자. 추상 클래스로 카드를 정의하고 어댑터에서는 추상 클래스를 가져다 쓰기만 하면 된다. 이런 경우 카드의 경우가 늘어난다 하여 어댑터 코드에 손을 댈 필요가 없어지고, 새로 추가한 카드에서 문제가 발생할경우 그곳만 고치면 되기 때문이다. 이렇게 interface와 abstract를 활용할 경우 불필요한 if와 switch의 사용횟수를 줄일수가 있다. 

 

Liskov substitution

리스코프 치환 원칙, 상속받는 클래스는 상위 클래스에 대해 치환이 가능해야한다. 라는 원칙이다. 이원칙에는 알고 가야할 내용이 비교적 있는 편이다. 먼저, 앞서 말한 정의에 대해 정리를 해보자면, 어떤 baseClass(부모)가 derivedClass(자식)를 상속할 때, 다른 어딘가(자식 클래스를 사용하는 곳)에서 DoSomething(baseClass base){ base.Do(); } 이라는 메소드를 정의 하고 있다면, 이 메소드를 콜하는 곳에서 DoSomething( new derivedClass() ); 가 가능해야한다. 

왜? -> 개방 폐쇄 원칙 역시 지켜지지 않기 때문이다. 쉽게 말해서, 위의 상황이 안지켜진다는 것은 자식 클래스 중에 base.do가 예외적인 상화을 가진 경우가 존재한다는것이고, 이는 개방 폐쇄의 원칙에 위반 되게 되는것이다. 그러므로 리스코프 치환 원칙을 준수해야한다.

namespace LSP {
    public class SuperClass : MonoBehaviour {
        public int power;
        public int speed;
        public Vector3 direction;

        public void Move() {
            //move this..
        }

        public void Turn(SuperClass super) {
            if (super.GetType() == typeof(DerivedClass)) {
                return;
            }
            //rotate...
        } 
    }
}
namespace LSP {
    public class DerivedClass : SuperClass {
        public void Accelerate() {
            //increase speed...
        }
    }
}
namespace LSP {
    public class Controller : MonoBehaviour {
        void Start() {
            MoveModel(new DerivedClass());
        }

        private void MoveModel(SuperClass super) {
            super.Move();
        }
    }
}

controller의 movemodel을 콜하게 될때, 만약 dereivedClass가 move를 사용하지 않고 있거나(이는 큰문제 처럼 보이지는 않는 데, 뒤에서 다시 설명 하겠다), 다른 경우들과 다른 예외적인 상황인 경우, 위에서 다룬 개방 폐쇄에 위반이 되는 게 눈에 보이게 된다.

 public class SuperClass : MonoBehaviour {
        public int power;
        public int speed;
        public Vector3 direction;

        public virtual void Move() {
            
        }
 public override void Move() {
 //derived class method..
            throw new Exception();
        }

또다른, 치환 법칙의 오류를 부르는 또다른 예로서, 부모가 지정해준 리턴 타입의 범위를 하위 타입에서 그범위 외의 값을 리턴 할때 이다.

 

private static void Calc(SuperClass super) {
            while (super.Read() != 0) {
                Debug.Log("?");
            }
        }

controller에서 부모 클래스를 사용하는 모습이다.

public override int Read() {
            //blah blah
            return 1;
        }

자식클래스

public virtual int Read() {
            // blah blah
            return 0;
        }

부모 클래스

 

controller에서 super.read가 0일 때까지 반복하고 있다. 그런데 super를 상속하는 derivedclass에서 derived.read는 1을 리턴하게 되면 무한loop에 빠져 들게 된다. 이런 일이 벌어지지 않도록 주의해야한다.

치환이 가능해야하는 이유에 대하여 알아보았다. 이제, 치환 법칙의 주요 팁들에 대해 정리해보려고 한다.

 

  • 상속 할 때, 하위 클래스에서 상위 클래스의 메소드를 사용하지 않아 비워두거나, NotImplementedException가 나올 때 : LSP위반이다.
  • 추상 클래스는 최대한 단순하게 유지해야한다. 복잡할수록, LSP 위반 가능성이 높아진다.
  • 자식 클래스는 부모클래스와 똑같은 Public method를 정의하고 있어야한다. 여기서 정의 한다는 것은, 단순 정의만 하는게 아니라 행위가 비슷 한 행위를 가지고 있어야 한다는 것이다.
  • 클래스 구조를 고려하기전에 class api를 먼저 고려해야한다. 우리 기존 상식에서는 자동차와 오토바이 등 탈것은 하나의 부모 클래스로 묶일수 있다고 생각하지만, 클래스 구조를 짜고 실제로 구현할 때엔, 서로 다른 부모클래스가 필요할수도 있다. 얽메이지 말자
  • 상속 보다 컴포지션에 비중을 두자. 상속을 통한 기능 구현 보다, 인터페이스 또는 다른 별도의 클래스를 만들어 캡슐화를 하자. 그리하여 덩어리들로 나누고, 그를 조합하 것에 비중을 두자.

출처 :&nbsp;https://resources.unity.com/games/level-up-your-code-with-game-programming-patterns

LSP를 고려한 모습은 아래와 같다.

항상 주의하자. 현실의 상식과 개발 세계에서의 구현을 구분할줄 알아야한다.(나에게 하는 말이다..)

 

 

Interface Segregation

인터페이스 분리 법칙이란, 사용하지 않는 메소드를 굳이 의존하지 않아도 된다에 의의가 있다.

무슨 말일까 -> 우리는 메소드(기능성)을 받아 올 때, (상속이나 인터페이스 등을 통해) 굳이 필요없는 메소드를 가져올 필요가 없다는 의미이다. 

인터페이스는 최대한 단순하고, 작게 나눠줘야한다. 사용처에서는 이런 인터페이스는 다수로 사용이 가능하기 때문에, 복잡하게 인터페이스를 구현할 필요가 전혀 없다. 맨처음 다룬 단일 책임의 원칙과 비슷한 성격을 갖는다.

namespace ISP {
    public interface IUnit {
        public void DoA();
        public void DoB();
        public void DoC();
        public void DoD();
    }
}

위처럼 A,B,C,D를 다 구현 하기보단 IA,IB,IC,ID 이런식으로 구현해도 된다는 의미이고, 권장한다는 의미이다.

namespace ISP {
    public interface IA {
        public void A();
    }
}
..IB
..IC

이는 결국 oop는, 상속 보다는 구조체, 복합물, etc.. 마치 레고 블락 처럼 붙였다 뗐다하는 구조화를 지향한다는 것이다.

-favors composition over inheritance (이말을 쉽게 풀어쓰려고 하다보니, 위퍼럼 말하게 되었다. 개인적으로 이 한줄이 더 와닿았다)

 

Dependancy Inversion

의존성 역전 법칙이란, 상위 모듈은 하위 모듈에 의존하면 안되며, 추상화는 세부사항에 얽메이면 안된다는 뜻이다. 

-> 변하기 쉬운것에 의존하는 것이 아니라, 변하지 않는 것에 의존하라! 

하지만, 개발을 하다보면, 이말을 지키기 정말 어렵다는 것을 알수 있다.

 

서로다른 클래스가 관계를 갖게되면(참조.. 등등), 의존성 혹은 커플링이 생기게 되는 데, 이런 관계성은 위험성을 지니게 된다. 한곳의 이슈가 다른곳까지 스노우볼을 일으켜 더 큰 문제를 야기 할수도 있다. 커플링을 줄이고, 결합력을 키우라고 한다. 이미지로 보면,

의존성 역전은 이런 커플링을 줄일수가 있게 된다. 하이레벨에서 로우레벨의 관계를 역전한다. 

public class Switch : MonoBehaviour
{
    [SerializeField] private Door dooe;

    public bool isActivated;

    private void Toggle() {
        if (isActivated) {
            dooe.Open();
        } else {
            dooe.Close();
        }
    }
public class Door : MonoBehaviour
{
    public void Open() {
        Debug.Log("open..");
    }

    public void Close() {
        Debug.Log("close..");
    }
}

switch가 door을 참조함으로서 switch(high) -> door(low)로 커플링을 가지고 있다. 

 

public class Door : MonoBehaviour, ISwitchable
{
    private void Open() {
        Debug.Log("open..");
    }

    private void Close() {
        Debug.Log("close..");
    }

    public bool isActive { get; set; }
    public void Activate() {
        Open();
    }

    public void DeActivate() {
        Close();
    }
}
public interface ISwitchable {
    public bool isActive { get; }
    public void Activate();
    public void DeActivate();
}
public class Switch : MonoBehaviour
{
    [SerializeField] private ISwitchable switcher;

    public bool isActivated;

    private void Toggle() {
        if (switcher.isActive) {
            switcher.Activate();
        } else {
            switcher.DeActivate();
        }
    }

    // Update is called once per frame
    void Update()
    {
        
    }
}

 

커플링 줄였고, swtich에서는 iswitchable을 사용하는 어떤 클래스에서도 Toggle을 쓸수가 있게 되었으므로 재사용성까지 갖추게 되었다.

 

인터페이스냐 추상클래스냐 그것이 문제

위에서 보면 인터페이스를 정말 많이 쓰게 된다. 하지만 추상 클래스로도 똑같은 역할을 할수가 있다.

또한, 변수를 가질수가 있고 static 및 상수도 갖고, 접근 제한자를 다양하게 쓸수도 있다. 하지만, c#에서는 클래스를 하나만 상속 받을수가 있다. 반면 interface는 다양하게 여러가지 implement할수가 있는 장점을 가지고 있다. 이를 인지하고 상황에 맞는 것을 선택하여 사용하도록 하자.

 

Solid원칙을 살펴봤는데, 당장 이를 적용하기엔 시간도 많이 걸리고 귀찮기도 할수도 있고, 어려울수 있다. 하지만, 장기적인 측면을 봤을 때, 가치가 있는 방식들이다. 또한, 이에 대한 무조건 적인 공식 같은것은 없다. 중요한건, 베이스로 깔린 아이디어가 중요한것이다.

profile

ved_Rony

@Rony_chan

검색 태그