포스트

솔리드 원칙

OOP 및 설계에서 유지보수와 확장에 뛰어난 코드를 만들기 위한 5가지 핵심 원칙에 대해서 정리했습니다.

솔리드 원칙

SOLID 원칙

좋은 소프트웨어란 응집도가 높고 결합도가 낮은 모듈로 구성되어 있다.
이 말은 변화에 대응을 잘 할 수 있는 코드로 구성되어 있다는 것과 같다.

기획자가 기존 기획에 없던 추가적인 요청을 하면 어쨌거나 구현은 해야한다.
큰 수정없이 이에 대응하기 위해서는 설계와 구조가 좋아야만 한다.

좋은 설계란 시스템에 새로운 요구 사항이나 변경 사항이 있을 때 영향을 받는 범위가 적은 구조다.

SOLID 원칙은 좋은 구조의 소프트웨어 설계를 돕는 5가지 원칙을 말한다.

SOLID 원칙을 반드시 적용해야 하는가?

  • 반드시 그렇지 않다. 문제가 발생했을 때 ‘이를 적용해서 해결할 있고 생각해보면 좋다.’라는 것이지 반드시 적용해야 하는 것은 아니다.



SRP: 단일 책임 원칙

단일 책임 원칙(SRP)는 클래스는 단 하나의 책임만 가져야 한다는 원칙이다.
책임은 기능과도 같은 말이다.

하나의 클래스는 하나의 기능만을 담당하도록 한다.
이런 클래스들을 묶어 하나의 모듈로 만들 수 있다.

책임의 범위는 기준이 정해져 있지 않으며, 이는 개발자의 영역이다.
SRP에 100%에 부합하는 기준은 존재하지 않는다.


SRP 적용의 예시

예를 들어, 게임 UI의 레이아웃을 만들어야 한다고 하자.
일반적으로 레이아웃을 담당하는 View가 필요하다.

하지만 View 자체는 사용자에게 보이는 영역이다.
View가 레이아웃을 계산하고 배치까지 하는 것은 너무 과도하다.

여기서 View가 가지고 있는 책임은 2가지다.

  1. UI의 레이아웃 계산
  2. 계산에 따른 하위 UI의 배치

View의 의미에 따라 계산에 따른 하위 UI 배치만 맡고, 레이아웃 계산은 다른 클래스에서 계산하는 편이 바람직하다.

그렇다면 다음과 같이 작성할 수 있다.

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
public static class LayoutCalculator
{
    public static Vector3 GetLayout(int index)
    {
        Vector3 final_layout;

        ... // 계산 과정이 포함된다.
        
        return final_layout;
    }
}

public class LayoutUI : MonoBehaviour
{
    UIElement[] m_ui_elements;

    void UpdateUI()
    {
        Vector3 final_layout = Vector3.zero;
        for(int index = 0; index < m_ui_elements.Length; index++)
        {
            final_layout = LayoutCalculator.GetLayout(index);
            m_ui_elements[index].transform.position = final_layout;
        }
            
    }
}



OCP: 열림 닫힘 원칙

열림 닫힘 원칙(OCP)는 확장에는 열려있고, 수정에는 닫혀있어야 한다는 원칙이다.

기능 추가 요청이 오면 클래스의 확장을 통해 손 쉽게 구현하면서, 확장에 따른 클래스 수정은 최소화해야 한다.

그렇다면 OCP를 가장 쉽게 지킬 수 있는 방법은 무엇일까?
변경 가능성이 있는 저수준 모듈보다는 인터페이스 같은 고수준 모듈을 이용한 추상화를 통해 관계를 구축하는 것이다.


OCP 적용의 예시

앞서 든 예시를 확장해보도록 하겠다.

지금의 LayoutCalculatorLayoutUI와 기능이 분리되기는 했지만 여전히 확장에는 닫혀있다.

기획자가 현재 방식이 아닌 다른 방식의 레이아웃을 원한다고 하자.
그렇다면 메서드를 정의하고 LayoutUI에서 그 메서드를 호출해야 한다.

클라이언트도 수정이 불가피하기 때문에 수정에 닫혀있지 않다.
이를 인터페이스를 이용하여 해결할 수 있다.

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
31
32
33
34
35
36
37
38
39
40
public interface ILayoutStrategy
{
    Vector3 GetLayout(int index, int total_count);
}

public class HorizontalLayout : ILayoutStrategy
{
    public Vector3 GetLayout(int index, int total_count)
    {
        // 선형 레이아웃을 반환한다.
    }
}

public class CircularLayout : ILayoutStrategy
{
    public Vector3 GetLayout(int index, int total_count)
    {
        // 호 레이아웃을 반환한다.
    }
}

public class LayoutUI : MonoBehaviour
{
    UIElement[] m_ui_elements;
    private ILayoutStrategy m_layout_strategy;

    void UpdateUI()
    {
        if(m_layout_strategy == null)
            return;

        Vector3 final_layout = Vector3.zero;
        for(int index = 0; index < m_ui_elements.Length; index++)
        {
            final_layout = m_layout_strategy.GetLayout(index, m_ui_elements.Length);
            m_ui_elements[index].transform.position = final_layout;
        }
            
    }
}



LSP: 리스코프 치환 원칙

리스포크 치환 원칙(LSP)은 자식 클래스는 언제나 부모 클래스로 교체할 수 있어야 한다는 원칙이다.

업캐스팅된 상태에서 부모의 메서드를 사용해도 동작이 의도한 대로 흘러가야 함을 의미한다.


LSP 설명의 예시

LSP를 설명하기 위한 예시는 많이 있지만 유닛과 몬스터 유닛에 대해서 예시를 들어보겠다.

유닛의 피격과 죽음의 연관 고리를 설명하겠다.

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
public class Unit
{
    protected int m_hp = 100;

    public virtual void TakeDamage(int dmg)
    {
        m_hp -= dmg;
        if(m_hp <= 0)
            Die();
    }

    protected virtual void Die()
    {
        // 죽음 처리
    }
}

public class Boss
{
    private bool m_invincible = true;

    public override void TakeDamage(int dmg)
    {
        // 과연 부모 클래스가 의도한 동작이 맞는가?
        if(invincible)
            return;

        base.TakeDamage(dmg);
    }
}

언뜻 보면 어? BossTakeDamage()UnitTakeDamage()는 같은게 아닌가? 라는 생각이 들 수도 있다.

LSP에서 말하고자 하는건 이런 가상 함수와 재정의 관계에 국한되는 것이 아니다.
의미를 깨면 안된다는 것이다.

플레이어는 언젠가 TakeDamage()를 호출함으로 보스 몬스터가 죽을 것이라고 기대한다.
하지만 무적 상태라면 TakeDamage()가 아무리 호출되도 보스 몬스터는 죽지 않는다.

따라서 계약을 더 보완하거나 책임 분리를 하는 편이 더 좋다.



ISP: 인터페이스 분리 법칙

인터페이스 분리 법칙(ISP)는 인터페이스를 기능이 맞게끔 작은 단위로 분리해야 한다는 원칙이다.

SRP는 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조한다고 생각하면 된다.


ISP 적용의 예시

예를 들어 인벤토리 서비스를 구현한다고 가정하자.

인벤토리 정보를 로컬에 저장할 수도 클라우드에 저장할 수도 있으니 인벤토리의 기능은 변경에 대응하기 쉽도록 interface를 사용한다.

1
2
3
4
5
6
7
8
9
public interface IInventoryService
{
    void AddItem(ItemCode code, int count);
    void RemoveItem(ItemCode code, int count);
    ...  // 생략한다.

    bool Load();
    void Save();
}

인벤토리는 아이템을 획득하거나 제거할 수도 있다.
인벤토리 정보는 세이브 및 로드도 가능하다.

하지만 저장 기능이 필요 없는 클라이언트도 있을 수 있다.

그런 경우의 인벤토리는 이 인터페이스를 사용할 수 없다.
따라서 인벤토리가 세이브 및 로드되는 것은 분리해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
public interface IInventoryService
{
    void AddItem(ItemCode code, int count);
    void RemoveItem(ItemCode code, int count);
    ...  // 생략한다.
}

public interface ISaveable
{
    bool Load();
    void Save();    
}



DIP: 의존성 역전 원칙

의존성 역전 원칙(DIP)은 저수준 모듈을 참조할 일이 생긴다면 고수준 모듈을 대신 참조해야 한다는 원칙이다.

이는 의존 관계를 맺을 때 변화하기 쉬운 것보다는 변화가 거의 없는 것에 의존하라는 말이다.


DIP 적용의 예시

이미 앞선 OCP 설명에서 인터페이스에 의존하면서 변경에 대응하기 쉬워진 것을 확인했으므로 예제는 생략하겠다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.