이 포스트의 주제는 이전의 S.O.L.I.D 의  LSP에 이어 의존 역전의 원칙 Dependency Inversion Principle (DIP) 입니다. 포스팅의 동기 또한 LSP의 동기와 같습니다. 개념이해를 돕기  위해 뽑아낸 간략화된 핵심 정의가, 오히려 상세 이해를 방해함을 느꼈습니다. 원칙의 정의만으로는 그 원칙이 의미하는 바를 정확히 이해하기 어렵습니다. 이 포스트는 DIP 원칙이 의미를 설명하는 것입니다. 원칙의 상세를 이해하고, 핵심 정의 구문은 기억을 끄집어 내기위한 실마리로 삼는 것이 가장 좋을 것입니다.

이제 의존 관계 역전의 원칙 DIP :  Dependency Inversion Principle 을 시작하도록 합시다.

먼저 원칙의 이름으로 부터 어떤 원칙인지 힌트를 얻어 보도록 하겠습니다. "의존 관계를 역전 하라는 이야기군, 가만 의존이 뭐지?"


의존이란 무엇인가?

객체-지향 프로그램은 관계를 가지고 있는 객체의 집합 그 자체 입니다. 그 관계를 UML의하면 Multiplicity, Aggregation, Composition, Dependency  4가지의 형태로 일반화 할 수 있습니다. Inheritance 는 종류가 다른 관계라고 생각하도록 하죠.  단순화 하면 객체A 가 객체B를 포함하고 있는데, 그 형태가 위에 언급한 형태 중 하나라는 것이며, 이때 객체A가 객체B를 의존하고 있다 라고 일반화 시켜 말합니다.



의존을 알았으니 추측을 넘어, DIP 의 정의를 알아 보도록 하겠습니다.

A. 고차원의 모듈은 저차원의 모듈에 의존하면 안된다. 이 두 모듈 모두 다른 추상화된 것에 의존 해야 한다.

B. 추상화 된 것은 구체적인 것에 의존하면 안 된다. 구체적인 것이 추상화된 것에 의존해야 한다.


더 쉽게 말할 수도 있다. 자주 변경되는 컨크리트 클래스에 의존하지 마라. 만약 어떤 클래스의 참조를 가져야 한다면, 참조 대상이 되는 클래스를 추상 클래스로 만들어라. 만약 어떤 함수를 호출해야 한다면, 호출 되는 함수를 추상 함수로 만들어라.

일반적으로, 추상 클래스와 인터페이스는 자신에게서 유도된 구체적인 클래스 보다 훨씬 덜 변한다.

Java 프로그래머를 위한 UML 실전에서는 이것만 쓴다!. 로버트 C.마틴 지음 / 이용원, 정지호 옮김, 인사이트 출판사, p140, 141 


음... 가능한 추상 클래스나 인터페이스를 참조 함으로써 의존을 좀더 느슨하게 만들어라는 얘기군. 알겠어, 앞으로는 최대한 추상 클래스를 만들고, 인터페이스를 끄집어 내서 DIP 를 지키는 개발자가 되겠어!  근데 관계의 역전은 뭘 의미하는 거지? 위의 정의에 역전이라는 말은 언급되지도 안잖아, 에이 머리 아파~ 이 정도만 이해하면 되겠지.

관계의 역전의 뜻에 대해 의문점을 남긴채 찜찜한 상태로 공부를 마무리 하게 됩니다.




자! 이제부터 시작입니다. 역전은 뭘 말하는 걸까요?. DIP 의 성배를 찾으러 갑시다.


DIP를 제시한 로버트 C. 마틴보다 더 잘 설명할 능력이 부족하기도 하고, 지금까지 읽은 문서중 가장 잘 설명하고 있다고 생각하는 마틴의 DIP 문서의 요약 및 해석으로 글을 쓰도록 하겠습니다.



의존 관계 역전의 원칙 The Dependency Inversion Principle 


이전 글에서 Liskov Substitution Principle LSP 에 대해 이야기 했습니다. LSP는 C++ 에서 상속을 사용할 때 일반적인 가이드를 제공하는 것입니다.  파생되는 클래스는 기반 클래스의 가상 멤버 함수의 약속이 파생 클래스에서도 제공되어 져야 한다는 것이고, 이를 위반할 경우 객체의 적절한 작동을 보장하기 위해서는  실제 객체의 타입을 확인해야 절차가 유발되며, 이 행위는 OCP를 위반하게 된다 였습니다.

우리는 OCP 와 LSP를 논했고, 이 원칙들이 엄격하게 지켜져서 얻어지는 구조를 일반화 시킬수가 있는데, 이 구조를 "의존 관계 역전의 원칙 The Dependency Inversion Principle" 이라고 이름 붙이겠습니다.


소프트웨어에 뭐가 잘못 되어가고 있는가? What goes wrong with software?

우리 대부분은 "나쁜 디자인"의 소프트웨어 조각으로 일을 해본 유쾌하지 않은 경험을 가지고 있습니다. 우리중 일부는 그 "나쁜 디자인"의 주인이 자기 자신이었다는 것을 발견하는 더욱 나쁜 경험을 가지고 있을지도 모릅니다. 도대체 디자인을 나쁘게 만드는 것은 무엇일까요?


"나쁜 디자인"의 정의 The Definition of a "Bad Design"

여러분은 다른 사람들에게 자신이 특별히 자랑스러워하는 소프트웨어 디자인을  발표해 본적이 있습니까?  그때 "왜 그런 방법으로 하셨나요?" 라는 말을 들어 본적이 있습니까? 이건 저에게 일어난 일이고, 또 많은 엔지니어들에도 벌이지고 있는 일입니다. 디자인에 동의하지 않는 엔니지어는 "나쁜 디자인"에 대한 정의에 대해 나와 동일한 기준을 가지고 있지 않았습니다. 엔지니어들에게서 발견한 가장 일반적인 기준은 "나는 그런 방법으로 하지 않아" 였습니다.

하지만, 모든 엔지니어들이 동의하는 나쁜 디자인에 대한 기준이 있었으니 다음과 같습니다.

1. 모든 변경마다 많은 다른 부분에 영향을 미쳐 변경 자체가 어렵다. (Rigidity)
2. 변경 작업을 할때 예상치 못한 다른 부분이 망가진다. (Fragility)
3. 현재의 어플리케이션에서 분리할 수 없기 때문에 다른 어플리케이션에서 재사용 하기가 매우 어렵다. (Immobility)

그 외에도 유연성, 견고성 과 같은 설명하기 어려운 요소들이 있었습니다. 따라서 위의 세가지 항목을 "좋고, 나쁨의" 기준으로 삼기로 결정 했습니다.


 "나쁜 디자인"의 원인 The Cause of "Bad Design"

무엇이 디자인을 딱딱하고 rigid, 깨지기 쉽고 fragile, 이동할 수 없게 immobile 만드는 것일까? 그건 모듈 디자인의 상호의존 interdependence 때문입니다. 

변경이 어려운 rigidity 디자인 원인은 강하게 상호의존하고 있는 부분의 단일 변경이 그가 의존하는 모듈에게 연쇄변경을 요구하기 때문입니다. 이러한 일련의 변경이 확장되면 설계자와 유지관리자는 그를 예측, 추정하지 못합니다. 이는 변경 승인을 주저하게 만들고, 디자인은 공식적으로 경화 되었다라고 선언 됩니다.

깨지기 쉬움 fragility 이란 단일 변경의 영향으로 매우 많은 부분이 고장남을 뜻합니다. 개념적으로 관련이 없는 영역에서도 종종 새로운 문제가 발생합니다. 이런 깨지기 쉬움은 설계와 관리조직의 신뢰성을 급격히  떨어트립니다. 사용자와 관리자는 상품의 품질을 예상할 수 없게 됩니다. 한 부분의 간단한 변경이 그와 명백히 관련이 없는 부분에서 실패를 만들어 냅니다. 이런 문제를 해결하는 일 자체는 더욱 문제로 부상합니다. 유지보수 프로세스가 자신의 꼬리를 쫒는 개와 닮았거든요.

이동 불가능한 immobile 디자인이란 이동을 희망하는 어떠한 부분이 원치 않는 다른 부분의 상세를 강력하게 의존하고  있는 것을 말합니다. 디자이너는 새로운 어플리이션에 재사용 될 수 있는지 기존 어플리케이션을 조사 합니다. 이동시키고 싶은 부분을 분리하기 위해 매우 많은 부분으로 부터 분리해야 한다면, 대부분 그러한 디자인은 새로 개발하는 것보다 비용이 높기 때문에 재사용될 수 없습니다.



의존 관계 역전의 원칙 The Dependency Inversion Principle


A. 하이 레벨 모듈은 로우레벨 모듈에 의존해서는 안된다. 둘다 추상에 의존해야 한다.

B. 추상은 상세를 의존해서는 안된다. 상세는 추상을 의존해야 한다.

A. HIGH LEVEL MODULES SHOULD NOT DEPEND UPON LOW LEVEL MODULES. BOTH SHOULD DEPEND UPON ABSTRACTIONS.

B. ABSTRACTIONS SHOULD NOT DEPEND UPON DETAILS. DETAILS SHOULD DEPEND UPON ABSTRACTIONS.


Copy 모듈은 Keyboard 와 Printer 디바이스의 상세를 사용합니다. - 구조적 디자인의 예



내가 왜 "역전 inversion" 이라는 단어를 사용했는지 의문이 생길 겁니다. 바로 구조적 분석, 구조적 디자인으로 대표되는 전통적인 개발 방법 때문입니다.  하이 레벨 모듈이 로우 레벨 모듈을 의존하는 구조, 추상이 상세를 의존하는 구조적 디자인 말이죠. 전통적 설계의 목적은 하이 레벨 모듈이 로우 레벨 모듈을 어떻게 호출할 것인지의 서브프로그램 구조를 정의하는 것 입니다.  [그림 1 ]은 구조적 디자인 구조의 좋은 예 입니다. 그래서 잘 설계된 객체-지향 프로그램의 의존 구조는 전통적 절차적 메소드의 결과로 만들어지는 의존 구조를 "역전 invert"한 형태 입니다.


 하이 레벨 모듈이 로우 레벨 모듈을 의존하고 있는 경우를 고려해 봅시다. 하이 레벨 모듈은 어플리케이션의 중요한 정책 결정과 비즈니스 모델을 담고 있습니다. 모델은 어플리케이션의 정체성을 담고 있죠. 이 모듈들이 로우 레벨 모듈을 의존하고 있고, 로우 레벨 모듈에서 발생하는 변경은 하이 레벨 모듈들에 직접적인 영향을 미치며 마침내 변경을 강요합니다.


이건 완전히 말이 안되는 상황입니다. 변경을 강요해야 하는 것은 하이 레벨 모듈이어야 하며, 하이 레벨 모듈이 로우 레벨의 상급자가 되어야 합니다. 어떤 상황이건 하이 레벨 모듈이 로우 레벨 모듈을 의존 해서는 안됩니다.

더 나아가, 하이 레벨 모듈은 재사용 가능해야 합니다. 우리는 이미 서브루틴 라이브러리와 같은 형태로 로우 레벨 모듈을 재사용하는데 정통해 있습니다. 하이 레벨 모듈이 로우 레벨 모듈을 의존하고 있으면, 하이 레벨 모듈은 문맥이 다른 곳에 재사용되기 어려워 집니다. 그러나, 하이 레벨 모듈이 로우 레벨 모듈에 독립적 이라면, 하이 레벨 모듈은 매우 간단히 재사용 될 수 있습니다. 이것이 바로 프레임워크 디자인의 핵심 입니다.
 



레이어링 Layering

Booch 에 따르면 " 잘 구조화된 객체-지향 아키텍처는 명확하게 정의된 레이어를 가지고 있다. 개개의 레이어는 잘-정의 되고, 통제되는 인터페이스를 통해 응집성 있는 서비스의 집합을 제공한다." 이 문장을 순진하게 해석한 디자이너가 만들어내는 구조는 [그림 3] 과 같을 것입니다. 이 다이어그램에서 하이 레벨 정책 클래스는 로워 레벨 메커니즘을 사용합니다. 이는 상세 레벨의 유틸리티 클래스를 사용하는 형태로 변합니다.  정책 레이어가 변경에 민감해서 유틸리티 레이어에 변경이 전파되는 교활한 특성을 보이더라도 괜찮지만, 의존은 전이적 transitive 입니다. 정책 레이어는 유틸리티 레이어를 의존하는 그 어떤 것(여기서는 메커니즘 레이어)에 의존하고 있습니다.  그래서 정책 레이어는 유틸리티 레이어를 의존하는 불행한 형태가 됩니다.




[그림 4] 는 보다 좋은 모델입니다. 각각의 로워 레벨 레이어들이 추상 클래스로 표현되고 있습니다. 실체 레이어는 추상레이어로 부터 파생 됩니다. 각각의 하이 레벨 레이어는 추상 인터페이스를 이용해 다음 단계의 로워 레벨 레이어를 사용합니다. 따라서 레이어들은 다른 레이어들을 의존하는 대신에 추상 클래스를 의존합니다. 정책 레이어의 의존이 전이되면 유틸리티 레이어만을 망가뜨리는 것이 아닙니다. 정책 레이어가  직접적으로 의존하고 있는 메커니즘 레이어 조차도 망가뜨립니다. 


이 모델을 사용하면 정책 레이어는 메커니즘 레이어나 유틸리티 레이어의 변경으로 부터 영향을 받지   않습니다. 더 나아가,  정책 레이어는 메커니즘 레이어의 인터페이스를 준수하는 로워 레벨 모듈의 정의를 가진 다른 문맥에서 재사용 될 수 있습니다. 의존을 역전시킴으로써 우리는 좀더 유연하고, 견고하고, 이동성 있는 구조를 만들 수 있습니다.


단순한 예제 Simple Example

의존 역전은 한 클래스가 다른 클래스에 메시지를 보낼때 적용할 수 있습니다. button 객체와 lamp 객체를 생각해 보세요. 

button 객체는 외부 환경을 감지하고, 사용자가 버튼을 눌렀는지 여부를 결정합니다. lamp 객체는 외부 환경으로 부터 영향을 받습니다. TurnOn 메시지를 받으면 불을 밝히고, TurnOff 메시지를 받으면 불을 끕니다.

 button 객체가 lamp 객체를 제어하는 시스템을 어떻게 설계할까요?

Figure 5는 이를 구현한 순진한 모델입니다. 

button 객체는 lamp 객체에게 TurnOn, TurnOff 메시지를 보냅니다.  button 클래스는 lamp 클래스 인스턴스와 관계를 가지기 위해서 lamp를 "포함" 합니다. 

 


위 모델의 구현은 아래와 같습니다.

Figure 5 는 의존 관계 역전의 원칙을 위반합니다. 어플리케이션의 하이-레벨 정책이 로우-레벨 모듈과 분리되지 않았습니다. : 추상과 상세가 분리되지 않았다는 말입니다. 분리하지 않으면 하이-레벨 정책이 자동적으로 로우-레벨 모듈을 의존합니다. 추상이 상세를 자동적으로 의존하는 것입니다.


[전통적인 button/lamp model 구현 코드]

namespace TradButtonModel
{
    class Lamp{
        public void TurnOn()
        {
            Console.WriteLine("Trad Lamp 램프를 켭니다.");
        }
        public void TurnOff()
        {
            Console.WriteLine("Trad Lamp 램프를 끕니다.");
        }
    }

    class Button {
        private Lamp itsLamp;

        public Button(Lamp lamp)
        {
            this.itsLamp = lamp;
        }

        public void Detect()
        {
            bool buttonOn = GetPhysicalState();
            if (buttonOn)
            {
                itsLamp.TurnOn();
            }
            else
            {
                itsLamp.TurnOff();
            }
        }

        private bool GetPhysicalState()
        {
            bool isPressed = false;
            //물리적 장치가 눌려저 있으면 true 를 반환
            return isPressed;
        }
    }



}


의존 하는 추상 찾기 Finding the Underlying Abstraction

하이 레벨 정책이란 무엇일까요? 이것은 어플리케이션이 의존하는 추상입니다. 상세가 변한다 해도 변경되지 않는 것을 의미하죠. Button/Lamp 예제에서 의존하는 추상이란 사용자의 on/off 제스처를 감지하고 타겟 객체에게 전달하는 것을 의미합니다. 말도 안됩니다!, 타겟 객체는 또 뭐죠? 타겟 객체는 추상에 영향을 주지 않는 상세 입니다.

의존 관계 역전의 원칙을 적용하려면 문제의 상세로부터 추상을 분리해야 합니다. 그런 다음, 추상이 상세를 의존하는 Figure 5 를 Figure 6 와 같이 변경할 수 있습니다.



Figure 6, 우리는 button 클래스의 상세 구현으로 부터 추상을 격리하였습니다. 


상위 레벨 정책이 완전히 추상 button 클래스만을 가지고 있음을 주목하세요. Button 클래스는 사용자   제스처를 감지하는 물리적 메커니즘을 전혀 모릅니다. 그리고, Lamp 에 대해서도 전혀 모릅니다. 

이 상세들은 추상을 구현한 ButtonImplementation, Lamp 클래스에 의해 추상으로 부터 격리됩니다. 


[DIP를 적용한 button/lamp model 구현 코드] 

namespace InvertedButtonModel
{
    abstract class ButtonClient
    {
        public abstract void TurnOn();
        public abstract void TurnOff();
    }


    class Lamp : ButtonClient
    {
        public override void TurnOn()
        {
            Console.WriteLine("Lamp 램프를 켭니다.");
        }

        public override void TurnOff()
        {
            Console.WriteLine("Lamp 램프를 끕니다.");
        }
    }


    abstract class Button
    {
        private ButtonClient buttonClient;

        public Button(ButtonClient client)
        {
            this.buttonClient = client;
        }
        public void Detect()
        {
            bool buttonOn = GetState();
            if (buttonOn)
            {
                buttonClient.TurnOn();
            }
            else
            {
                buttonClient.TurnOff();
            }
        }
        public abstract bool GetState();


    }

    class ButtonImplementation : Button
    {
        public ButtonImplementation(ButtonClient client):base(client){}

        public override bool GetState(){
            bool isPressed = false;
            //물리적 장치를 감지하여 반환
            return isPressed;
        }

    }
}


결론 Conclusion

의존 관계 역전의 원칙은 객체-지향 기술이 주장하는 장점의 근원입니다. 좋은 어플리케이션은 재사용 가능한 프레임워크를 만들길 요구합니다. 변경에 강한 코드의 구조를 만드는 것은 매우 중요 합니다. 그리고, 추상과 상세를 서로 격리시키기 때문에 코드 관리는 더욱 쉬워 집니다.

의존 관계의 역전 dependency inversion  이란 구조적 디자인에서 발생하던 로워 레벨 모듈의 변경이 하이 레벨 모듈의 변경을 요구는 위계의 관계를 끊자 라는 의미로 쓰여진 역전입니다. 실제의 사용 관계는 바뀌지 않으며, 추상을 매개로 메시지를 주고 받음으로써 관계를 최대한 느슨하게 만들어야 한다는 원칙입니다.

구현 관점에서 추상 클래스, 인터페이스를 사용하는 것부터 시작하여, 아키텍처 관점까지 확장할 수 있습니다. 예를 들어,  데이터베이스를 연결하는 레이어로 OLEDB를 사용한다면, OLEDB의 인터페이스를 제공하는 물리 DB가  MSSQL 에서 ORACLE 로의 변경되더라도 그 변경이 상위로 전파되지 않는다 라고 이해 할 수 있습니다.
 


하이 레벨과 로워레벨 사이에 추상을 삽입함으로써 로워 레벨의 변경이 하이 레벨로 전파되는 것을 막는다.



끝으로, 


"A. 하이 레벨 모듈은 로우레벨 모듈에 의존해서는 안된다. 둘다 추상에 의존해야 한다." 라는 정의는  "A. 하이 레벨 모듈은 로우레벨 모듈에 의존해서는 안된다 의존 하되, 둘다 추상에 의존해야 한다." 로 해석하는 것이 혼란을 피하는 것이라 생각됩니다.


### 끝.


참고자료 : 
로버트 C. 마틴 The Dependency Inversion Principle.pdf [Martin96] Robert C. Martin, Engineering Notebook, C++ Report
 


책 : java 프로그래머를 위한 UML 실전에서는 이것만 쓴다!. 로버트 C.마틴 지음 / 이용원, 정지호 옮김, 인사이트 출판사, p140,p141 

 




저작자 표시
신고

이 글을 Twitter / Facebook 에 공유하기
이 글이 유익하다면 아래의 트위터 버튼을 눌러 공유해 주시거나, 페이스북 "좋아요" 버튼을 눌러 주세요.

   


Posted by 반더빌트
리스코프 치환 원칙 LSP Liskov Substitution Principle 은 객체-디자인의 S.O.L.I.D 5원칙 중의 가장 이해하기 어려운 원칙이라고 할 수 있습니다. 다른 원칙들은 이름에서 힌트를 얻을 수 있는데 LSP만은 이름으로 부터 힌트를 얻을 수가 없습니다.

이 글을 읽고 있다면 이미 S.O.L.I.D 를 공부해 본적이 있을 겁니다. 독일어나 러시아어 이름 같은  리스코프 라는 어감은 이 원칙을 이해하면 뭔가 대단한 것을 알고 있다는 느낌이 들것도 같습니다. 이해하기 위해 정의를 수십번 읽어 봐도 대부분의 원칙이란걸 대할때 그러하듯 "그래서 이거 가지고 뭘 하라는 건데" 라는 자조섞인 답변이 들리기도 하죠.  

수 많은 문서와 책,  인터넷의 블로그 글들은 리스코프 치환 (교체) 원칙에 대해 다음과 같이 기술하고 있습니다.

서브타입은 언제나 자신이 기반타입 (base type)으로 교체할 수 있어야 한다.

유도된 클래스의 메소드를 퇴화시키거나 불법으로 만드는 일을 피하라. 기반 클래스의 사용자는 그 기반 클래스에서 유도된 클래스에 대해 아무것도 알 필요가 없어야 한다.
java 프로그래머를 위한 UML 실전에서는 이것만 쓴다!. 로버트 C.마틴 지음 / 이용원, 정지호 옮김, 인사이트 출판사, p137,p144  에 있는 정의 입니다. 


"서브타입을 기반타입으로 교체할 수 있어야 한다고?  음... 다음 말은 무슨 뜻인지 잘 모르겠고, 상속 받고, 메소드를 빠짐없이 구현하면 되잖아, 기반 타입에 파생 타입을 대입할 수도 있고, 봐~~ 컴파일도 되고, 작동하잖아, LSP 다 됐어!" 라고 많은 개발자들이 넘어 갔지만, 화장실 갔다가 마무리를 짖지 않은 것 처럼 뭔가 찜찜한 기분이 들었다면, 아래의 내용을 한줄도 빠짐 없이 읽어주시길 바랍니다. 



리스코프 치환 원칙이란?

1998년 Barbara Liskov (MIT 컴퓨터 사이언스 교수, 2008 튜링상 수상) 가 제안한 원칙으로 :

타입 S의 객체 o1 과 타입 T의 인스턴스 o2가 있을 때, 어떤 프로그램에서 타입 T의 객체로 P가 사용된다고 하자. S가 T의 서브타입이라면 P에 대입된 o1이 o2로 치환 된다고 해도 P의 행위는 바뀌지 않는다. 

Barbara Liskov wrote LSP in 1988:
What is wanted here is something like the following substitution property: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T." - BarbaraLiskov, Data Abstraction and Hierarchy, SIGPLAN Notices, 23,5 (May, 1988).

로버트 C. 마틴의 LSP 정의  :

참조되는 기반클래스의 함수는 파생클래스 객체의 상세를 알지 않고서도 사용될 수 있어야 한다. 

FUNCTIONS THAT USE POINTERS OR REFERENCES TO BASE
CLASSES MUST BE ABLE TO USE OBJECTS OF DERIVED CLASSES
WITHOUT KNOWING IT.


리스코프 치환 원칙이 왜 중요한가?

1. 이 원칙을 지키지 않으면 클래스 계층이 지저분하게 될 꺼에요. 서브클래스 인스턴스를 파라미터로 전달 했을 때 메소드가 이상하게 작동 할 수 있습니다.


2. 슈퍼클래스에 대해 작성된 단위테스트가 서브클래스에 대해서는 작동되지 않을 것입니다.




로버트 C. 마틴 Liskov Substitution Principle

로버트 마틴은 LSP를 다음과 같이 소개 하고 있습니다.
개방 폐쇄 원칙 OCP 은 관리가능하고 maintainable, 재사용 reusable 가능한 코드를 만드는 기반이다. 잘 설계된 코드는 기존 코드의 변경 없이 확장 가능하다. OCP 를 가능케 하는 중요 메커니즘은 추상화와 다형성이다. 추상화와 다형성을 가능케 하는 키 메커니즘이 상속이다. 추상 기반 클래스의 순수 가상 함수로 부터 클래스를 상속 파생 시킴으로써 추상화된 다형 인터페이스를 만들어 낼 수 있다.

상속을 적용함에 있어서 디자인 룰은 무엇일까? 최고의 상속 구조의 특성은 무엇일까? 계층 구조를 만듦에 있어 OCP를 위반하게 하는 덧은 무엇일까?


LSP가 바로 상속의 룰 이며, 최고의 상속 구조 특성을 갖추게 하며, OCP를 위반하지 않도록 인도하는 원칙 이라고 말하고 있습니다.  



정사각형과 사각형, 그리고 심각한 위반, Square and Rectangle, a More Subtle Violation.

LSP를 위반하는 사례를 살펴봅시다. 로버트 C. 마틴의 LSP 문서의 사례 입니다. C++ 로 작성된 예제를 C#으로 바꾸었습니다.

아래와 같이 정의된 Rectangle 클래스를 사용하는 어플리케이션이 있습니다.
 public class Rectangle
    {
        protected double itsWidth;
        protected double itsHeight;
    
        public void SetWidth(double w)
        {
            this.itsWidth = w;
        }

        public void SetHeight(double h)
        {
            this.itsHeight = h;
        }

        public double GetWidth()
        {
            return this.itsWidth;
        }
        
        public double GetHeight()
        {
            return this.itsHeight;
        }
    }


이 어플리케이션은 많은 사이트에 설치 되었고, 매우 잘 작동하고 있다고 상상해 봅시다. 매우 성공한 소프트웨어라고 할 수 있습니다. 어느날 사용자가 정사각형 square 요소를 추가해 달라고 요구해 왔습니다. 상속은 ISA 관계이고, 새로 추가할 객체가 기존 객체의 ISA 관계를 만족 시킨다면, 새로운 객체는 기존 객체로 부터 파생 시킬수 있습니다.


명확하게 정사각형은 사각형의 일반적인 의도를 만족 시켰습니다. ISA 관계가 성립되기 때문에, Square 클래스를 Rectangle 클래스로 부터 파생시키는 것은 논리적으로 완벽합니다.

ISA 관계를 사용하는 것은 객체-지향의 기반 기법을 사용하는 것으로 사료됩니다. Square 클래스를 Rectangle로 부터 파생시켜면 문제가 있을 수 있지만, 이런 문제는 실제로 코드에 적용해 보지 않고서는 알 수 없습니다.
 

Rectangle, Square 상속 다이어 그램



우리가  눈치 챈 첫번째 문제는 Square는 itsHeight, itsWidth 의 두개의 멤버 변수가 필요하지 않지만 상속된다는 것 입니다. 이건 명확하게 쓸모 없는 것이고, CAD와 같은 프로그램에서 수백 추천개의 정사각형을 그리게 된다면 문제가 발생할 건 확실합니다.


메모리 효율을 문제 삼지 않는다고 가정합시다. 다른 문제가 있을 까요?  이런! Square 는 SetWidth 와 SetHeight 함수도 상속 받습니다. Square는 가로와 세로가 같아야 하기 때문에 설계의 문제가 발생한 겁니다. 이 문제를 해결하기 위해 아래와 같이 오버라이드 override 할 수 있습니다.
 


  public class Square : Rectangle
    {
        /// 
        /// 정사각형이기 때문에 너비를 할당하더라도 높이도 같이 할당해 줌.
        /// 
        /// The w.
        /// 기반 타입의 SetWidth를 호출 하면 
        /// 기반 타입의 메소드가 호출 됨.
        /// 
        public new void SetWidth(double w)
        {
            base.itsWidth = w;
            base.itsHeight = w;
        }

        /// 
        /// 정사각형이기 때문에 높이를 할당하더라도 너비도 같이 할당해 줌.
        /// 
        /// The h.
        /// 
        /// 
        public new void SetHeight(double h)
        {
            base.itsWidth = h;
            base.itsHeight = h;
        }
    }



누군가 Square 객체에 가로값을 적용한다 해도 높이가 자동으로 적용되고, 정사각형이라는 논리를 위반하지 않습니다.



하지만 아래의 함수를 고려해 보면 문제가 있습니다.


Square 객체의 Height가 변경되지 않을 것이기 때문에 지금까지 쌓아 놓은 것은 무너 집니다. Rectangle의 SetWitdth , SetHeight가 virtual 로 선언 되지 않았기 때문에 발생한 문제 입니다.

 


     /// 
        /// Fs the specified rect.
        /// 
        /// The rect.
        /// 
        /// 
        static void f(Rectangle rect)
        {
            rect.SetWidth(32);
        }




진짜 문제 The Real Problem

이제 우리는 Rectangle, Square 두개의 클래스를 가지고 있고, Square 객체로 어떠한 일을 하더라도 문제가 없어 보입니다. Square 는 수학적으로도 일관성이 있고, Rectangle 도 문제가 없습니다. 

아래의 함수 g를 생각해 봅시다.
 


     /// 
        /// Gs the specified rect.
        /// 
        /// The rect.
        /// 
        /// 
        static void g(Rectangle rect)
        {
            rect.SetWidth(5);
            rect.SetHeight(4);

            //파라미터로 Square 타입이 전달되면 Assertion 에러를 발생 시킵니다.
            System.Diagnostics.Debug.Assert(rect.GetWidth() * rect.GetHeight() == 20);
        }



Rectangle의 메소드를 실행시키는 함수 입니다. Rectangle 객체에 대해서 올바르게 동작하고, Square 객체가 전달 될 때는 assertion 에러를 발생하도록 한겁니다.

함수 g를 만든 프로그래머는 매우 합리적인 가정을 했습니다. 다른 프로그래머가 함수 g에 Square 객체를 전달하면 assertion  에러를 보고 받을 테니깐요.

하지만, 부모인 Rectangle 객체에서 작동하는 행위가 Square 객체에 대해서 작동하지 않는다는 것은 LSP를 위반하는 것이고, 파생클래스 행위의 내부 상세를 안다는 것은 OCP open-close principle 또한 위반 하는 것입니다. 


public class Rectangle
    {
        protected double itsWidth;
        protected double itsHeight;
    
        public virtual void SetWidth(double w)
        {
            this.itsWidth = w;
        }

        public virtual void SetHeight(double h)
        {
            this.itsHeight = h;
        }

        public double GetHeight()
        {
            return this.itsHeight;
        }

        public double GetWidth()
        {
            return this.itsWidth;
        }
    }

    public class Square : Rectangle
    {
        /// 
        /// 정사각형이기 때문에 너비를 할당하더라도 높이도 같이 할당해 줌.
        /// 
        /// The w.
        /// 기반 타입의 SetWidth 메소드를 호출하더라도
        ///  파생 타입의 메소드가 호출됨.
        /// 
        public override void SetWidth(double w)
        {
            base.itsWidth = w;
            base.itsHeight = w;
        }

        /// 
        /// 정사각형이기 때문에 높이를 할당하더라도 너비도 같이 할당해 줌.
        /// 
        /// The h.
        /// 
        /// 
        public override void SetHeight(double h)
        {
            base.itsWidth = h;
            base.itsHeight = h;
        }
    }



무엇이 잘 못 되었는가? What Went Wrong (W3)


무슨 일이 일어 난거야? Rectangle과 Square라는 논리적인 모델이 뭐가 잘 못 된거지?  무엇보다도 정사각형은 사각형이다. ISA 관계도 만족 시키잖아!!!.


정사각형은 사각형이지만, Square 객체는 Rectangle 객체가 아닙니다. 왜 일까?  Square 객체의 행위는 Rectangle 객체의 행위와 일관성이 없습니다. 행위적으로 Square 는 Rectangle 이 아니고, 행위는 소프트웨어의 거의 모든 것입니다.

LSP는 OOD의 행위적 ISA 관계를 명확하게 해줍니다. : 본질적인 행위보다 클라이언트가 의존하는 비본질적인 행위를 더욱 중요시 합니다. 함수 g를 작성한 프로그래머는 사각형의 너비와 높이는 독립적으로 설정될 수 있다는 사실에 근거하였습니다. 두 변수의 독립성은 다른 프로그래머들이 의존하는 외적으로 공개적인 행위 입니다. 



계약에 의해 설계하기 Design by Contract

Bertrand Meyer (Eiffel programming language 창시자, Object Oriented Software Construction 저자) 에 따르면  LSP와 Design by Contract 는 매우 깊은 관계가 있습니다. 클래스 메소드의 선행 조건 precondition 과 후행조건 postcondition 에 이런 구조를 적용한다면, 메소드를 실행하기 위해선 선행조건이 만족해야 하고, 그렇다면, 메소드는 후행조건이 참일 것이라는 것을 보장 할 수 있습니다.


Meyer 는 파생클래스의 선행조건과 후행조건에 대한 규칙을 정의 했습니다.: 선행조건이 약하고, 후행조건이 강할 때에만 루틴을 재정의가  가능하다.

...when redefining a routine [in a derivative], you may only replace its
precondition by a weaker one, and its postcondition by a stronger one.

Object Oriented Software Construction, Bertrand Meyer, Prentice Hall, 1988, p256


다시말해서, 기반클래스의 인터페이스를 사용할 때, 사용자는 오로지 기반클래스의 선행조건과 후행조건만을 알고 있기 때문에, 파생 객체는 기반클래스 보다 더 강한 요구사항을 사용자가 지키라고 강요할 수 없다.  또한 파생 클래스는 기반 클래스의 모든 후행 조건을 만족 시켜야만 한다.  이것이 파생클래스의 행위에 대한 결과가 기반 클래스의 제약 사항을 위반하지 않는 것이고, 기반 클래스의 사용자는 파생클래스의 결과물에 혼란스럽지 않게 될 것이다.



맺음말

오리처럼 생겼고, 오리 울음 소리를 내지만 배터리가 필요하다면 - 아마도 잘못된 추상화를 한것이다. LSP



객체-지향 디자인에서 상속에 대한 룰을 어느정도로 엄격하게 지키냐는 가치판단의 문제 입니다. LSP 원칙은 상속에 대한 룰을 가장 엄격함을 적용하는 것이라 할 수 있습니다. 상속을 무분별하게 적용하고 있는 현재의 관행은 상속을 적용함에 있어 더욱 엄격해야 한다는 것이 커뮤니티의 주류 의견되고 있습니다. 객체-지향 디자인이 이루고자 하는 목적이 작동하기만 하는 소프트웨어가 아닌 관리가능한, 재사용가능한, 신뢰할 수 있는, 유연한 소프트웨어의 개발이기 때문입니다.

### 끝.

참고자료 :
로버트 C. 마틴 Liskov Substitution Principle.pdf [Martin96] Robert C. Martin, Engineering Notebook, C++ Report, Nov-Dec, 1996.
 

리스코프 치환 원칙 잘 정리된 wiki 문서


책 : java 프로그래머를 위한 UML 실전에서는 이것만 쓴다!. 로버트 C.마틴 지음 / 이용원, 정지호 옮김, 인사이트 출판사, p137,p144 
저작자 표시
신고

이 글을 Twitter / Facebook 에 공유하기
이 글이 유익하다면 아래의 트위터 버튼을 눌러 공유해 주시거나, 페이스북 "좋아요" 버튼을 눌러 주세요.

   


Posted by 반더빌트
 객체지향프로그래밍 이란 무엇인가 (OOP)?  포스트에서 객체지향프로그램의 정의를 알아 보았습니다.

객체지향 프로그래밍 이란 캡슐화, 다형성, 상속 을 이용하여 코드 재사용을 증가시키고, 유지보수를 감소시키는 장점을 얻기 위해서 객체들을 연결 시켜 프로그래밍 하는 것 입니다.


정의를 알고 있다 해도 실제 구현에 적용하려 할때 모호함은 여전히 남아 있습니다. 객체지향설계는 나쁜 냄새(bad smell)을 제거하고 5개의 큰 원칙을 (Single Responsiblility Principle, Open-Closed Principle, Liskov Substitution Principle, Dependency Inversion Principle, Interface Segregation Principle) 지키려고 노력 할때 비로서 그 모양을 갖어갑니다.  Robert C. Martin 이 소개한 5가지 거대 원칙은 객체지향 설계의 근간이 되며, 소프트웨어 일반 책임 적용 패턴, 원칙 General Responsibility Assignment Software Patterns, GRASP  이라고 부르는 원칙에서 9가지 항목으로 세분화 됩니다.   

이번 포스트에서는 소프트웨어를 실패로 만드는 설계의 나쁜 냄새가 무엇이며, 객체지향으로 구현하기 위한 설계(Design) 원칙이 무엇인지 얘기해 보겠습니다.

 

Introduction

 우리는 무엇을 발견하려고 UML 다이어그램을 읽을까? 좋은 다이어 그램인지 아닌지 평가하는 기준은 무엇일까?

설계의 품질 - Quality of Design

잘 설계한 시스템은 이해하기 쉽고, 바꾸기 쉽고, 재사용하기 쉽습니다. 개발하는데 특별히 어렵지도 않고, 단순하고 간결하며 경제적입니다.

 

나쁜 설계의 냄새 - Smell of Bad Design

  1. 경직성 - 무엇이든 하나를 바꿀 때마다 반드시 다른 것도 바꿔야 하며, 그러고 나면 또 다른 것도 바꿔야 하는 변화의 사슬이 끊이지 않기 대문에 시스템을 변경하기 어렵다.

  2. 부서지기 쉬움 - 시스템에서 한 부분을 변경하면 그것과 전혀 상관없는 다른 부분이 작동을 멈춘다.( 바보같은 말이지만, 상관 있는 부분은 당연히 변경해야 한다.)

  3. 부동성 - 시스템을 여러 컴포넌트로 분해해서 다른 시스템에 재사용하기 힘들다.

  4. 끈끈함 - 개발 환경이 배관용 테이프나 풀로 붙인 것처럼 꽉 달라붙은 상태다. 편집-컴파일-테스트 순환을 한 번 도는 시간이 엄청나게 길다.

  5. 쓸데 없는 복잡함(과도한 디자인) - 괜히 머리를 굴려서 짠 코드 구조가 굉장히 많다. 이것들은 대개 지금 당장 하나도 필요 없지만 언젠가는 굉장히 유용할지도 모른다고 기대하고 만들었다.

  6. 필요 없는 반복 - 코드를 작성한 프로그래머 이름이 마치 '복사'와 '붙여넣기'같다.

  7. 불투명함 - 코드를 만든 의도에 대한 설명을 볼 때 그 설명에 '표현이 꼬인다'. 라는 말이 잘 어울린다.

  

Agile Software Development - Principles, Patterns, and Practices , Robert C. Martin 

 

로버트 c 마틴의 5원칙을 SOLID 라고 한다. SOLID Motivational Posters, by Derick Bailey

 

The Single Responsibility Principle - SRP

어떤 클래스를 변경해야 하는 이유는 오직 하나뿐 이어야 한다.

 어떤 객체가 너무 많은 다른 종류의 행동을 할 수 있다면, 그것은  다른 객체가 변경 될 때 함께 변경되어야 할 가능성이 많다는 것을 의미합니다. 

가능한 하나 또는 명확하게 관련이 있는 책임을 부여 함으로써 객체는 보다 명확해(Explicit) 지고, 표현가능해 지며(Expressive), 느슨한 커플링(Loose Coupling) 과 높은 응집력(High Cohesion) 을 얻게 됩니다.

 

 The Open-Closed Principle - OCP

 소프트웨어 엔터티(클래스, 모듈, 함수 등등)는 Interface에 대해서는 개방되어야 하지만, 변경에 대해서는 폐쇄되어야 한다.

 단위 테스트를 할 때는 종종 환경에 생기는 변화를 제어하고 싶은 경우가 발생합니다. 예를 들어 Emploee를 어떻게 테스트 할지 생각해 보시죠. Emploee객체는 데이터베이스를 변경합니다. 하지만 테스트 환경에서 실제 데이터베이스를 바꾸고 싶지 않습니다. 그렇다고 해서 단위 테스트를 하기 위해 테스트용 더미 데이터베이스를 만들고 싶지도 않습니다. 그렇다면, 테스트 환경으로 환경을 변경해서 테스트 할 때 Emploee가 데이터베이스에 하는 모든 호출을 가로챈 다음 이 호출들이 올바른지 검증하면 좋을 것입니다.

 엔터티가 외부로 부터 영향을 받지 않고, 내부의 필요에 의해서만 변경하게 하려면 보호막으로 감싸고 노출되지 않아야 합니다. 그러나 다른 객체와 상호 작용하기 위해서는 선택적인 노출이 필요 합니다. 다른 객체와 대화하기 위한 인터페이스와 확장을 위해서는 Open 하고, 이외의 경우에는 Closed 해야 정보가 감추어지고 (Information Hiding) , 객체들끼리 연결하게 되는 끈이 줄어듦으로써 커플링이 타이트 해질 가능성이 줄어 듭니다.

Liskov Substitution Principle - LSP

서브타입(Sub Type)은 언제나 자신의 기반 타입(Base Type)으로 교체할 수 있어야 한다.

LSP에 따르면, 기반 클래스의 사용자는 그 기반 클래스에서 유도된 클래스를 기반 클래스로써 사용할때, 특별한 것을 할 필요 없이 마치 원래 기반 클래스를 사용하는 양 그대로 사용할 수 있어야 한다. 더 자세히 말하자면, instanceof나 다운캐스트를 할 필요가 없어야 합니다. 강조하건데, 사용자는 파생클래스에 대해서 아무것도 알 필요가 없어야 합니다. 파생 클래스가 있다는 사실 조차도.

 LSP는 상속(Inheritance), 다형성(Polymorphism) 과 관련된 원칙이죠. 상속은 코드 재사용 이라는 이유로 과용 될수 있는 기능입니다. 과잉 사용된 상속은 복잡한 계층구조와 커플링을 타이트하게 함으로써 객체지향으로 얻기 위한 유지관리비용 감소에 악영향을 미치는 요소중 하나 입니다. LSP란 상속을 사용할때 지켜야 하는 원칙을 말하는데요. 상속은 코드 재사용을 위해서가 아니라 명확한 가족 관계가 있을때 사용해야 하며, 그 행위(Behavior) 와 연관이 있습니다. 부모와 파생된 객체들이 존재 할때 가능한 모두 동일한 메소드 이름과 갯수를 가지게 함으로써  언제든지 서브 타입이 기반타입으로 교체될 수 있어야 함을 말합니다. 

다시 말하면, 상속의 오용은
가족이 아닌 객체를 비슷한 일을 하는 것을 보고 코드 재사용 욕심에 가족 관계로 묶는 것.
다층 상속으로 계층 구조가 깊고 복잡하게 하는 것.
파생 타입에 부모, 형제들과는 전혀 동떨어진 능력을 부여 함으로써 돌연변이를 만들어 버리는 것.
을 의미하며 LSP는 돌연변이(부모, 형제들의 형질과 관련이 없는 능력을 가진 파생타입 이며 이렇게 될때는 이미 가족이 아님을 의미)가 발생하지 않도록 관리해서 상속 관계의 타입들이 유연하게 대체 될 수 있게 하는 원칙 입니다. 

LSP의 더욱 자세한 설명은 LSP in Depth  포스트를 참조

 

Dependency Inversion Principle - DIP

  1. 고차원의 모듈은 저차원의 모듈에 의존하면 안된다. 이 두 모듈 모두 다른 추상화된 것에 의존한다.
  2. 추상화 된 것은 구체적인 것에 의존하면 안 된다. 구체적인 것이 추상화된 것에 의존해야 한다. 

자주 변경되는 구상 클래스(Concreate class)에 의존하지 마라. 만약 어떤 클래스에서 상속받아야 한다면, 기반 클래스를 추상 클래스로 만들어라. 만약 어떤 클래스의 참조(Reference)를 가져야 한다면, 참조 대상이 되는 클래스를 추상 클래스로 만들어라. 만약 어떤 함수를 호출해야 한다면, 호출 되는 함수를 추상 함수로 만들어야 합니다.

DIP는 그 용어 자체의 어려움 때문에 이해하기가 쉽지 않지만 내용은 단순합니다. 추상화된 것 (Absctract Class, Interface) 을 사용해야 커플링을 느슨하게 만들 수 있다는 말 입니다. 예를 들어

abstract class Car {} 로 부터 상속 받은 class Truck : Car {}, class Bus : Car {} 가 존재 할때,

Truck porter = new Truck() 과 같이 사용하지 말고,

Car myCar = new Truck();  과 같이 추상 클래스를 기반으로 작업하거나.


    abstract class Car
    {
       public abstract void Run();
       public abstract void Stop();
    }

    class Truck : Car
    {


        public override void Run()
        {
            throw new NotImplementedException();
        }

        public override void Stop()
        {
            throw new NotImplementedException();
        }
    }

    class Bus : Car
    {

        public override void Run()
        {
            throw new NotImplementedException();
        }

        public override void Stop()
        {
            throw new NotImplementedException();
        }
    }


    static void main()
    {
        Truck myCar = new Truck();
        myCar.Run();

        Car myCar = new Truck();
        myCar.Run();

    }

 인터페이스를 사용하는 경우.

  abstract class Car
    {

    }

    interface IDrive
    {
        public void Run();
        public void Stop();

    }

    class Truck : Car, IDrive
    {



        #region IDrive 멤버

        public void Run()
        {
            throw new NotImplementedException();
        }

        public void Stop()
        {
            throw new NotImplementedException();
        }

        #endregion
    }

    class Bus : Car, IDrive
    {


        #region IDrive 멤버

        public void Run()
        {
            throw new NotImplementedException();
        }

        public void Stop()
        {
            throw new NotImplementedException();
        }

        #endregion
    }


    static void main()
    {
        Car myCar = new Truck();
        IDrive drive = (IDrive)myCar;

        drive.Run();
        drive.Stop();
    }

 

와 같이 추상적인 것에 구체적인 것을 대입해 사용함으로써 커플링을 느슨하도록 만드는 원칙 입니다.

DIP 에 대한 상세한 개념에 대해서는 '의존 관계 역전의 원칙 Dependency Inversion Principle' 포스트를 읽어 보세요.


Interface Segregation Principle - ISP

클라이언트는 자신이 사용하지 않는 메소드에 의존 관계를 맺으면 안된다. 

비대한 클래스(Fat class) 란 메소드를 몇십 몇백개 가지는 클래스를 가르키는 말이다. 대개 시스템 안에 이런 클래스를 두고 싶어 하지 않지만, 가끔 피 할 수 없는 경우도 있습니다.

비대한 클래스가 거대하고 보기 흉하다는 사실 말고도, 한 사용자가 이 비대한 클래스의 메소드를 다 사용하는 일이 매우 적다는 것도 문제입니다. 즉. 메소드를 몇십개 선언한 클래스에서 사용자는 단지 두세 개만 호출 할 지도 모르죠. 불행하게도 이 사용자들은 호출하지도 않는 메소드에 생긴 변화에도 영향을 받습니다.


ISP 인터페이스 분리의 원칙은 가족관계가 아닐때 같은 행동을 하는 객체들에게 인터페이스를 할당 하고 사용 함으로써 DIP 를 가능하도록 만들고 , 그 결과로 느슨한 커플링과 명시성과 표현성을 얻습니다.





더 읽을 꺼리 :
* 객체지향 5원칙을 예제까지 포함하여 설명한 사내양 님의 글 
* SOLID 설명 캐스트

신고

이 글을 Twitter / Facebook 에 공유하기
이 글이 유익하다면 아래의 트위터 버튼을 눌러 공유해 주시거나, 페이스북 "좋아요" 버튼을 눌러 주세요.

   


Posted by 반더빌트


티스토리 툴바