ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 객체 지향 설계 SOLID
    프로그래밍 언어 2023. 11. 16. 21:40

    C++는 객체 지향 언어이고 객체 지향 프로그래밍 및 설계를 위한 5가지 법칙이 있다.

     

    이를 SOLID 법칙이라고 칭하며 이는 소스 코드가 읽기 쉽고 확장하기 쉽게 될 때까지 리펙토링(Refactoring)을 위한 지침이다.

     

    단일 책임 원칙 Single Responsibility Principle

    단일 책임 원칙이란 "한 클래스는  하나의 책임만 가져야 한다"라고 정의할 수 있으며, 이는 "모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화해야 함."를 의미한다.

     

    여기서 책임이란 해당 클래스를 변경해야 하는 이유이며, 단일 책임 원칙을 다시 정의해 보면 "클래스나 모듈은 반드시 한 가지의 이유에 의해 변경되어야 한다"라고 생각할 수 있다.

     

    단일 책임 원칙을 지켜야 하는 이유는 어떠한 코드를 수정했을 때 사이드 이펙트(Side Effect)를 방지하고 클래스의 기능을 명시적으로 파악하여 유지보수를 용이하게 하기 위함이다.

     

    캡슐화(Encapsulation)란 접근 지정자(private, protected, public)를 통해 클래스의 정보를 은닉하는 행위를 뜻하며, 객체의 속성(Data Fields)과 행위(Methods)를 하나로 묶고 실제 구현 내용 일부를 외부에 감추어 정보를 은닉할 수 있다.

     

    예를 들어, 던전 앤 파이터의 캐릭터를 설계한다고 해보자. 캐릭터에는 이동, 점프, 공격, 피격 등 다양한 기능이 존재한다. 이를 Character라는 클래스에 전부 구현한다면, 코드의 양이 방대해질 뿐만 아니라 공격 기능에 문제가 생겨 수정할 경우 어떤 Side Effect를 발생할지 예상할 수 없다. 최악의 경우 Character라는 클래스를 전부 수정해야 하는 상황이 생기기도 한다.

     

    Character에 단일 책임 원칙을 적용하면, 각 기능을 CharacterMove, CharacterJump, CharacterAttack, CharacterDamagable 등 여러 클래스로 나눠 해당 클래스에서 기능을 구현할 수 있다. 

    Character Class의 UML

     

    개방 폐쇄 원칙 Open-Closed Principle

    개방 폐쇄 원칙이란 "소프트웨어 개체(class, module, function 등)는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다"라고 정의할 수 있으며, "시스템의 구조를 올바르게 Refactoring 하여 변경이 더 이상의 수정을 유발하지 않도록 설계해야 함"을 의미한다. 

     

    개방 폐쇄 원칙은 두 가지 속성이 존재한다. 첫 번째는 "확장에 대해 열려 있다"이며 이는 모듈의 동작을 확장할 수 있음을 뜻한다. 두 번째는 "수정에 대해 닫혀 있다"이며 이는 모듈의 소스 코드나 바이너리 코드를 수정하지 않아도 모듈의 기능을 확장하거나 변경할 수 있음을 뜻한다.

     

    예를 들어, 캐릭터 아이템을 설계한다고 생각해 보자. 귀검사 캐릭터의 무기로 칼 아이템을 추가하기 위해 Sword 클래스를 설계했다.

    Sword Class 설계

     

    기존 Character 클래스에선 Sword 클래스를 참조하고 있기 때문에 이후 새로운 무기로 망치를 추가하고 싶다면 망치 아이템인 Hammer 클래스를 추가뿐 아니라 Character 클래스 역시 수정해야 한다.

     

    Character 클래스가 Sword 클래스를 참조하는 것이 아닌 IEquipment라는 새로운 인터페이스를 추가하여 해당 인터페이스를 참조한다면, Character 클래스를 수정할 필요 없이(수정에는 닫혀 있다) IEquipment를 상속받는 클래스를 추가하여 새로운 종류의 무기를 추가할 수 있다.(확장에는 열려 있다)

    Interface를 추가한 설계

     

    리스코프 치환 원칙 Liskov Substitution Principle

    리스코프 치환 원칙이란 "프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다"라고 정의할 수 있다.

     

    이를 위한 표준적인 요구사항으로 세 가지가 필요하다.

    1. 하위형에서 메서드 인수의 반공변성
    2. 하위형에서 반환형의 공변성
    3. 하위형에서 메서드는 상위형 메서드에서 던져진 예외의 하위형을 제외하고 새로운 예외를 던지면 안 됨

    또한 하위형이 만족해야 하는 추가적인 행동 조건으로 세 가지가 존재한다.

    1. 하위형에서 선행조건은 강화될 수 없음
    2. 하위형에서 후행조건은 약화될 수 없음
    3. 하위형에서 상위형의 불변조건은 반드시 유지되어야 함

    리스코프 치환 원칙은 객체 지향 설계에서 다형성을 사용하기 위한 조건이며, 자식 클래스에서 함수를 잘못 오버라이딩하거나 상속 관계를 잘못 설계할 때 위반할 수 있다.

     

    예를 들어, 캐릭터 직업과 몬스터를 설계한다고 생각해 보자. 캐릭터와 몬스터 모두 움직이기 위한 로직이 필요하므로 Character라는 클래스와 상속 관계가 있다고 잘못 판단하여 설계할 수 있다.

    잘못 설계한 클래스 UML

     

    다른 프로그래머가 해당 클래스를 활용하여 코드를 짤 때 추상화 개념인 Character 클래스를 보고 캐릭터를 움직이기 위해  Move란 함수를 호출했으나 할당된 객체가 오크 몬스터였다면 의도했던 바와는 다르게 플레이어가 몬스터를 조종하는 결과를 출력할 것이다.

     

    즉, 상위 타입 클래스인 Character를 하위 타입 클래스인 OakMonster로 치환하면 프로그램의 정확성이 깨지므로 이는 리스코프 치환 원칙을 위배한 상황이다.

     

    이를 해결하기 위해선 잘못된 상속 관계를 수정하여 해결할 수 있다.

    올바른 클래스 설계

    인터페이스 분리 원칙 Interface Segregation Principle

    인터페이스 분리 원칙이란 "특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다"라고 정의할 수 있으며, 이는 "클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 함"을 뜻한다.

     

    인터페이스 분리 원칙을 지키기 위해 인터페이스를 설계할 땐 구체적이고 작은 단위들로 분리시켜 클라이언트들이 꼭 필요한 메서드만 이용할 수 있게 설계해야 한다. 이를 통해 시스템의 내부 의존성을 약화시키고, 리펙토링, 수정 및 재배포가 용이해진다.

     

    예를 들어, 캐릭터의 장비 시스템을 설계한다고 생각해 보자. 현재 던전 앤 파이터에는 무기나 방어구와 같은 장비 아이템과, 스킬을 변경해 주는 탈리스만 아이템이 존재한다. 

    장비 아이템과 탈리스만 아이템을 모두 구현하는 인터페이스

     

    두 아이템 모두 장착 가능한 아이템이기에 범용 인터페이스인 IEquipment로 구현한다면 해당 인터페이스는 여러 예외처리와 분기가 생기며, 장비 아이템과 탈리스만 아이템에 불필요한 구현 내용이 추가되고, 장비 아이템이나 탈리스만 아이템에 변경 사항이 발생할 경우 모든 아이템 클래스를 수정해야 한다.

     

    이를 두 개의 interface로 분리하여 구현한다면 탈리스만 아이템에 변경 사항이 발생하더라도 장비 아이템은 수정할 필요가 없어 내부 의존성을 약화시킬 수 있다.

    IEquipment를 분리한 interface

    의존관계 역전 원칙 Dependency Inversion Principle

    의존관계 역전 원칙이란 "프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안 된다"라고 정의할 수 있다. 즉, 상위 모듈은 하위 모듈에 의존하면 안 되고, 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다. 또한 추상화는 세부 사항에 의존해선 안된다.

     

    예를 들어, 캐릭터 무기 아이템을 설계한다고 생각해 보자. 귀검사 캐릭터의 경우 소검, 도, 둔기, 대검, 광검을 장착할 수 있는데, 만약 프로그래머가 하위 모듈인 각 무기 클래스에 의존한다면 캐릭터 클래스의 클래스 필드 변수 타입을 교체하거나 여러 함수를 추가로 구현해야 한다.

    하위 모듈에 의존할 경우 클래스 UML

     

    이를 추상화 개념에 의존하도록 Refactoring 한다면 무기 변경에 따라 Character의 코드를 변경할 필요가 없고, 새로운 무기를 추가하더라도 기존 코드를 수정할 필요가 없다.

    DIP 원칙을 지켜 새로 설계한 클래스 UML

     

    회고

    이렇게 객체 지향 설계를 위한 SOLID 법칙을 살펴봤다. 사실 클래스 설계를 하다 보면 "이렇게 설계해야 나중에 편하겠지?"라고 생각하는, 당연하게 여기는 부분일 것이다.

     

    다만 이 글을 작성하게된 이유는 당연한 것이라도 인지하고 사용하는 것과 아닌 것의 차이는 굉장히 크다고 생각했기 때문이다.

     

    앞으로도 프로젝트를 진행할 때 클래스를 설계함에 있어 계속해서 인지하고 "왜"를 상기하도록 노력해야겠다.

Designed by Tistory.