[객체지향 SW 설계의 원칙] ④ 리스코프 치환 원칙

일반입력 :2008/09/11 09:11

최상훈 (핸디소프트) 송치형 (서울대)

과거 헐리우드에서는 배우들이 좋은 영화의 배역을 구하기 위해 영화제작사에 자주 전화를 걸었다고 한다. 배우들의 잦은 전화 때문에 영화기획사 담당자들이 자신의 업무를 할 수 없을 정도로 전화에 시달리게 됐다. 고민 끝에 이들은 한 가지 좋은 묘안을 생각해 냈다. 배우들에게 자신이 어떤 역할을 잘 하며 어떤 영화 배역을 맡고 싶다고 등록하도록 한다. 기획중인 영화에서 어떤 배역이 나왔을 때 배우가 등록한 목록에서 적합한 배우를 찾은 다음 그 배우에게 전화하면 캐스팅이 시작된다. 바로 “전화하지마, 내가 전화할께(Don‘t call us, we’ll call you)?? 방식은 ‘헐리우드 원칙’이라고도 부르며, GoF가 「템플릿 메쏘드」 패턴을 소개하면서 예로 든 내용이다.

의존 관계 역전의 법칙

그럼 기획사 담당자들은 헐리우드 원칙을 통해 얻으려고 했던 효과는 무엇일까? 이 캐스팅 프로세스의 변화는 기획사나 배우에게도 효율적이다. 배우는 매번 자신의 캐스팅 의사를 밝힐 필요가 없으며 기획사의 섭외 전화를 기다리면 된다. 기획사의 경우도 수많은 배우들의 전화에 일일이 상대하지 않아도 된다. 즉, 캐스팅 프로세스는 단순화되고 전화 빈도수는 최소화된다. 단지 통화량이 줄어들므로 통신사만 손해 본다.

그렇다면 이 효과를 얻게 한 요인은 무엇이었을까? 과거의 프로세스는 배역을 원하는 소스(배우)들에게 능동적으로 배역을 요청, 확인하게 하였고, 배역을 제공하는 타겟(영화기획사)은 수동적으로 전화가 올 때 정보를 제공했다. 이 상황에서 당연히 배우들이 아쉬운 입장이므로 전화 빈도수가 높아지고 기획사는 괴로워진다. 반면 헐리우드 원칙을 도입했을 때 배우는 기획사에 자신의 정보를 등록하면 될 뿐이고 기획사는 필요한 배우에게 캐스팅을 요청하면 될 뿐이다.

즉 배우와 기획사의 능동과 수동 관계가 ‘역전’된다. 바로 이번 호에서 소개할 ‘의존관계 역전의 법칙(Dependency Inversion Principle 이하 DIP)’은 앞서 말한 헐리우드 원칙의 구조와 목적, 효과를 그대로 따르고 있다.

통제권의 역전

과거 구조지향 프로그램밍에서 라이브러리를 사용하던 방법과 현재 객체지향 프로그래밍에서 즐겨 이용하는 프레임워크 사용방법을 비교해 보자. 구조지향적 프로그램 실행 절차는 main() 함수에서 시작해서 여러 함수들을 호출하는 것으로 프로그램이 절차적으로 진행된다. 따라서 항상 절차의 통제권이 main() 함수로부터 시작되는 호출자에게 있다.

반면 프레임워크를 사용하는 방식은 프레임워크에 객체를 등록하므로 실행의 통제권을 프레임워크에게 위임한다. 가령 HTTP 서버에 서블릿을 등록하고 HTTP 서버에게 서블릿 실행을 요청하는 URL이 접수되면 HTTP 서버는 등록된 서블릿을 실행한다. 앞의 두 호출 형식을 헐리우드 원칙에 대입해서 비교해 보자.

확실히 <그림 1>에서는 통제권이 (main()으로부터 진행이 시작한) Actor들에게 있다. 따라서 통제의 흐름이 호출자(caller, 즉 Actor)에게서 서비스 함수(callee, 즉 CastingMgr)에게로 이전되며, 서비스 함수의 루틴이 종료되면 다시 호출자에게로 통제가 반환된다. 따라서 CastingMgr는 Actor의 요청에 대해 수동적으로 서비스한다. 반면 <그림 2>에서는 Actor가 CastingMgr에게 자신을 등록하며(오히려 전에는 호출을 받는 측이었던) Director가 Actor에게 confirm()을 실행한다.

이 두 모델의 특징은 능동적 객체와 수동적 객체의 역할이 달라졌다. 이것은 통제권의 주체가 호출자에게서 프레임웍으로 역전됐음을 의미하며 이 방식은 기존과 다른 방식의 제어 흐름을 제공한다. 이것을 ‘통제권의 역전(Inversion of Control, 이하 IOC)’이라 한다.

IOC는 주 통제권이 호출자에게서 프레임워크로 역전되었음을 의미한다. 이 때 ‘역전’은 통제권에 대한 의존성에 대한 것만 의미하는 것이 아니라 인터페이스 소유권에 대한 것도 의미한다(confirm() 인터페이스는 Director가 발행한다). IOC는 DIP의 중요한 골격이 된다. 서비스 요청자(Actor)는 서비스 제공자(프레임워크)에게 자신을 등록하고 서비스 제공자는 서비스를 마친 후 서비스 요청자에게 미리 정의해둔 인터페이스를 통해 결과를 알려준다. 여기서 ‘미리 정의해둔 인터페이스’를 흔히 훅(Hook) 메쏘드라고 부르며 훅 메쏘드는 ‘역전’을 위한 매개 포인트가 되는 것이다.

이밖에도 훅 메쏘드는 확장성을 확보하는 기능을 한다. 우리는 ‘미리 정의해둔 인터페이스’로 다양한 루틴을 정의할 수 있다. 가령 서블릿에서 doGet()이나 doPost()와 같은 인터페이스는 개발자로부터 무한한 확장을 제공한다. 단지 서블릿 컨테이너는 서블릿 호출이 왔을 때 해당하는 서블릿의 doGet()이나 doPost()을 실행하면 될 뿐이다. 즉 서블릿의 doGet(), doPost() 메쏘드는 개발자에게 있어 확장성을 제공하는 반면 서블릿 컨테이너에게 있어 훅 메쏘드의 역할을 하게 된다. 그렇다면 IOC를 골격으로 하는 DIP로써 얻을 수 있는 효과는 무엇일까? 다음 사례들로 그 목적과 형태를 살펴보자.

DIP에서도 역시 훅 메쏘드를 통해 확장성을 제공한다. 확장을 보장하는 이유는 이미 정의된 인터페이스가 존재하기 때문이다. 이 인터페이스는 사용자로부터 사용자가 정의한 컴포넌트를 은닉시켜 사용자 정의 컴포넌트에 대한 의존성을 제거하기 위함이다. 즉, 확장성을 보장하기 위해 추상화가 이용된다.

하지만 OCP와 DIP가 다른 점은 DIP는 IOC를 한다는 것이다. <그림 1>에서도 <그림 3>과 같이 confirm()이란 인터페이스를 여러 Actor의 자식들이 확장할 수 있다. confirm()을 확장한 Actor들이 프레임워크에 등록됐을 때 confirm()은 훅 메쏘드가 된다. 따라서 DIP는 확장되는 훅 메쏘드를 정의하기 위해 (물론 OCP 자체의 용도도 있지만) OCP를 이용하고 있다. 설계의 원칙은 이렇게 서로 관계성을 가지고 있으며 서로가 서로를 포함하기도 하고 이용하기도 한다.

사례 1 : 통신 프로그래밍 모델

일반적으로 소켓 프로그램은 클라이언트가 서버에게 요청을 send()하고 서버로부터 결과를 recv()하므로 서버의 서비스를 이용하게 된다. 멀티쓰레드 프로그래밍에서 이 send() & recv()를 하게 되면 recv()하는 동안 쓰레드는 서버의 응답이 오기까지 대기하게 된다. recv() 함수는 블럭되기 때문이다. 따라서 이 때 recv()하는 모든 쓰레드들은 블럭되기 때문에 쓰레드 자원이 아까워진다. 왜냐하면 서버로부터의 응답을 받기 위해 대기하는 동안 recv()를 호출한 쓰레드는 다른 작업을 할 수 없기 때문이다.

이 방식의 대안으로 제시되는 모델이 폴링(polling) 모델이다. 클라이언트 쓰레드는 서버에게 메시지를 보내고 recv()를 전담하는 쓰레드에게 recv()를 맡긴다. 그리고 이 쓰레드들은 다른 작업을 실행하면서 계속 일을 한다. 서버로부터 응답을 확인하고 싶은 시점에서 접수된 서버의 메시지를 가져온다. 따라서 클라이언트 쓰레드는 다른 일을 할 수 있는 기회비용을 얻을 수 있다.

하지만 폴링 모델에서 어느 순간 클라이언트 쓰레드는 서버의 응답을 확인해야 한다. 단지 자신이 원하는 시점에 서버의 응답을 확인하는 장점과 응답을 기다리는 시간에 다른 작업을 할 수 있는 기회를 확보할 뿐이다. 이 모델까지는 확실히 모든 통제가 클라이언트 쓰레드의 스케쥴 안에 있다. 그리고 동기적으로 (자신이 원하는 시점에) 서버의 응답을 확인할 수 있다. 하지만 만약 서버의 응답이 예상보다 지연될 경우 클라이언트 쓰레드는 서버의 응답이 올 때까지 여러 번 응답 큐를 확인하는 비용이 따른다. 또한 서버의 응답을 확인하는 시점이 동기적이지 않아도 될 경우 더더욱 이 확인 작업은 지난해지게 된다. 즉, 서버의 응답에 대한 처리가 비동기적이어도 될 때, 그리고 클라이언트 쓰레드가 서버의 응답 확인하는 시도가 여러 번 발생할 때 폴링 모델도 오버헤드를 얻게 된다.

이 때 DIP를 적용하기 적당한 시점이 되는데 클라이언트 쓰레드는 메시지를 send()한 후에 recv()하는 대신 서버의 응답을 처리하는 훅 메쏘드를 Reply DeMuxer에 등록한다. - 구조적 프로그램에서는 함수 포인터를 등록하지만 객체지향 세계에서의 트렌드는 커멘드 오브젝트를 등록한다(GoF의 커멘드 패턴 참조). Reply DeMuxer의 recv()를 담당하는 쓰레드는 서버로부터 응답을 접수하면 대응하는 훅 메쏘드를 찾아 훅 메쏘드를 실행한다. 즉 recv() 쓰레드는 서버의 응답 접수(여기까진 폴링 모델)와 훅 메쏘드 실행을 담당한다.

이 모델은 비동기 소켓 모델로서 DIP의 원칙을 그대로 따르고 있다. - 클라이언트 쓰레드들은 헐리우드 원칙에서의 배우로 receive 쓰레드는 영화기획사 담당자로 등치해 보라. 비동기 모델에서 얻을 수 있는 장점은 첫째, 클라이언트 쓰레드의 잦은 응답 확인을 제거할 수 있다. 둘째, 클라이언트 쓰레드는 응답을 확인하는 작업에서 자유로워지므로 다른 작업을 할 수 있는 기회비용을 확보할 수 있다. 물론 이 과정은 비동기적으로 이루어져도 괜찮은 상황에 한한다.

무엇보다 중요한 것은 이런 구조의 바탕에는 통제권이 클라이언트 쓰레드에서 Reply DeMuxer로 역전되는 IOC가 전제된다. DIP를 적용할 때 기대할 수 있는 장점은 상술한 두 가지 장점을 그대로 확보하는데 있다. 퍼포먼스를 높이고 요청에 대한 응답으로부터 관심을 제거하여 클라이언트의 역할을 단순화하는데 있다.

사례 2 : 이벤트 드리븐, 콜백 그리고 JMS 모델

자바 API는 소프트웨어 설계의 좋은 모델이 된다. 반면에 개발자로서 하고 싶은 마법들을 API 수준에서 제공해주니 마법을 부릴 기회가 줄어들어 약간 억울하기까지 하다. 자바 스윙에서 이벤트 모델에도 마법이 녹아 있다. 자바 스윙 컴포넌트는 이벤트를 처리할 java.awt.event.ActionListener를 등록(addActionListener())한다. 이 스윙 컴포넌트에 이벤트가 발생하면 등록된 ActionListener의 훅 메쏘드인 actionPerformed()를 후킹한다. 스윙 컴포넌트에는 복수 개의 ActionListener를 등록할 수 있는데 이유는 복수 개의 이벤트가 발생할 수 있기 때문이다. 이와 유사한 구조로 더 일반화된 Observer & Observable 인터페이스도 있다.

더 나아가서 분산 시스템에서도 똑같은 구조가 적용된다. 서버와 클라이언트간의 통신에 있어서 클라이언트는 서버에 자신의 원격 객체 레퍼런스를 등록한다. 서버는 자신의 작업을 진행하면서 원격 객체 레퍼런스를 통해 그때그때 필요한 정보를 클라이언트에게 제공한다. 이 구조를 위해서 클라이언트의 콜백(callback) 메쏘드가 미리 정의되어 있어야 한다. 콜백 메쏘드는 서버가 비동기적으로 클라이언트에게 정보를 전달하는 훅 메쏘드가 된다. 따라서 콜백의 구조는 원격지에서 훅킹이 제공되는 형태를 갖는다.

이와 같은 구조는 비동기적인 분산 훅킹(콜백)구조를 형성할 때 사용된다. 가령 서버에게 장시간의 작업들을 할당하고 클라이언트가 각 작업의 결과에 대한 중간보고를 비동기적으로 받고 싶을 때 유용하다. 클라이언트의 호출이 비동기적이기 때문에 서버의 작업을 할당한 다음 클라이언트는 다시 자신의 작업이 진행된다. 따라서 앞서 예시한 소켓의 비동기 모델에서 recv() 쓰레드가 서버의 역할로 전이된 형태를 갖는다.

JMS의 토픽 모델은 좀 더 다양한 구조를 갖는다. - 이 모델은 전통적인 MOM 아키텍처에서 Publish/Subscribe 메시징 모델로 알려져 있다. 이 모델은 멀티캐스팅 같은 그룹 메시징을 제공할 때 유용한데, 가령 주식정보 시스템을 예로 들었을 때 주식정보 제공자는 가입한 모든 클라이언트에게 현재 증시정보를 멀티캐스팅한다. 이 때 주식정보 제공자는 Publisher가 되고 클라이언트 프로그램은 Subscriber가 된다.

참고로 이 모델의 장점은 클라이언트/서버에서 메시지 기반으로 패러다임이 바뀐다는 것이다. 기존의 클라이언트/서버 모델의 경우 서버는 클라이언트들을 상대한다. 따라서 클라이언트의 위치 정보와 인터페이스 등을 알아야 한다. Publish/Subscribe 모델에서는 이 클라이언트와 서버 간의 상호의존도가 제거된다.

이제부터 서버는 각종 클라이언트들에게 메시지를 보내는 것이 아니라 그냥 ‘주식정보’라는 메시지를 보내면 될 뿐이다. 즉, 어떤 클라이언트들이 얼마나 접속되어있는지, 각 클라이언트들의 위치와 인터페이스는 어떤지 등의 여부와 같은 클라이언트 정보는 관심 대상에서 제외되고(주식정보라는) 메시지에 관심을 집중하게 된다. 이 패러다임은 클라이언트가 몇 개 접속되어 있는지 혹은 아예 없든지, 클라이언트의 상태나 위치가 어떤지에 관심 없이 그룹 메시징 제공자에게 메시지를 보내기만 하면 될 뿐이다.

이 모델에서 Subscriber들은 Topic 제공자에게 자신을 등록한다. Publisher가 Topic 제공자에게 메시지를 전송하면 JMS Topic 제공자는 등록된 Subscriber들에게 메시지를 멀티캐스팅한다. 이 때 메시지 멀티캐스팅을 하기 위해 등록된 각 Subscriber들의 onMessage()를 호출하게 된다. 그럼 상술한 훅 메쏘드들, 즉 ActionListener.actionPerformed(), MessageListener.onMessage(), 그리고 콜백 메쏘드는 어떤 의미를 가질까? 훅 메쏘드는 IOC이면서 확장 인터페이스를 제공한다. 사용자 정의 컴포넌트들이 자신의 목적에 맞게 이 메쏘드를 확장하여 사용할 수 있게 하기 위함이다.

정리

DIP의 키워드는 ‘IOC’, ‘훅 메쏘드’, ‘확장성’이다. 이 세 가지 요소가 조합되어 복잡한 컴포넌트들의 관계를 단순화하고 컴포넌트 간의 커뮤니케이션을 효율적이게 한다. 이 목적을 위해 Callee 컴포넌트(예를 들어 프레임워크)는 Caller 컴포넌트들이 등록할 수 있는 인터페이스를 제공해야 한다. 따라서 자연스럽게 Callee는 Caller들의 컨테이너 역할이 된다(JMS의 Topic 제공자, 스윙 컴포넌트, 배우 섭외 담당자들은 등록자들을 관리한다). Callee 컴포넌트는 Caller 컴포넌트가 확장(구현)할, 그리고 IOC를 위한 훅 메쏘드 인터페이스를 정의해야 한다. Caller 컴포넌트는 정의된 훅 메쏘드를 구현한다.

이로써 DIP를 위한 준비가 완료됐다. 이 상태에서 다음과 같은 시나리오가 전개된다. Caller는 Callee에게 자신을 등록한다. Callee는 Caller에게 정보를 제공할 적당한 시점에 Caller의 훅 메쏘드를 호출한다. 바로 이 시점은 Caller와 Callee의 호출관계가 역전되는 IOC 시점이 된다. DIP를 이용해서 얻을 수 있는 장점은 무엇일까? 이 질문은 DIP를 사용할 수 있는 상황과도 밀접하게 연관되어 있다.

DIP는 다음과 같은 상황에서 사용된다. 비동기적으로 커뮤니케이션이 이루어져도 될 (혹은, 이뤄져야 할) 경우 컴포넌트 간의 커뮤니케이션이 복잡할 경우 컴포넌트 간의 커뮤니케이션이 비효율적일 경우(빈번하게 확인해야 하는)에 사용된다. DIP는 복잡하고 지난한 컴포넌트간의 커뮤니케이션 관계를 단순화하기 위한 원칙이다. 실세계에서도 헐리우드 원칙에서와 같이 귀찮도록 자주 질문과 요청하는 동료에게도 써먹어 볼만한 원칙이다.

리스코프 대체 원칙(Liskov Substitution Principle)

얼마 전 하이버네이트 3.0 RC 버전이 발표되었다. 어떻게 바뀌었을까 궁금해서 기존에 2.1 버전을 사용했던 서버 프로그램에 3.0 RC를 적용해 보기로 마음먹었다. 그런데 일이 생각보다 쉽지 않았다. 필자는 당연히 하이버네이트가 하위호환성을 고려했을 것이라 믿었는데 전반적인 패키지 명이 바뀌고 없어진 인터페이스도 있었다. 몇 가지만 예를 들면 기존 패키지 명은 net.sf.hibernate로 시작했는데 3.0부터는 org.hibernate로 시작한다. 그리고 예전에 Session에서 바로 사용할 수 있었던 delete, find 등의 메쏘드가 없어졌다. 이클립스의 여러 기능을 이용하여 5~10분 정도 걸려 프로그램을 3.0에 맞게 바꾸었다. 그리고 서버를 다시 실행시켰는데 잘 돌아가나 싶더니만 금새 에러가 났다.

원인을 보니 기존에 잘 돌아가던 HQL(Hibernate Query Language)이 3.0에서는 실행되지 않는 것이었다. 결국 HQL을 하이버네이트가 지원하는 다른 종류의 쿼리인 Native 쿼리로 바꾸어 실행시키는 것으로 해결했다(http://www.hibernate.org/250.html에 가보면 하이버네이트 3 마이그레이션 가이드가 있다).

이번 기사에서 하이버네이트의 정책을 비난하려는 것은 아니다. 오히려 전체적으로는 소스포지(www.sf.net)를 의미하는 net.sf보다는 org가 패키지 명으로 적절하다 생각하고, Session의 인터페이스도 응집력 있게 바뀌었다. 아마도 하이버네이트 개발진들도 이 문제에 대해 많은 고민을 했을 것이고 고민 끝에 용단을 내렸을 것이다. 하지만 하이버네이트 포럼의 글에서 볼 수 있듯 많은 개발자들이 불편을 겪었던 것은 분명한 사실이다.

라이브러리의 버전업은 하위 호환성을 고려해야만 한다. 즉 새로운 기능은 추가되겠지만 기존 라이브러리를 사용하던 프로그램들은 수정 없이 상위 버전의 라이브러리를 사용할 수 있어야만 하는 것이다. 2.1에서 애플리케이션 개발자들에게 제공한 인터페이스는 하이버네이트 개발자와 애플리케이션 개발자들 간의 약속이다. 만약 JDK가 버전업되면서 하위 호환성을 지키지 않았다면 개발자들의 외면으로 인해 오늘의 자바는 없었을 지도 모른다. OO의 상속 구조도 이와 비슷하다. 이 때 기반 클래스를 하위 버전의 라이브러리, 서브 클래스를 상위 버전의 라이브러리로 생각해 볼 수 있다.

상속의 경우에도 서브 클래스가 기반 클래스의 인터페이스에 대한 정확한 구현을 제공하지 않을 경우, 즉 규약을 어길 경우 많은 문제가 발생할 수 있다. 가령 List 인터페이스를 구현 상속한 List 구현체에서 add() 메쏘드를 제공하지 않는다면 List 인터페이스의 add()를 기대하고 호출한 사용자 코드는 제대로 작동하지 않을 것이다. 이처럼 라이브러리에서도 최신 버전은 이전 버전의 인터페이스를 그대로 준수하여 이 두 라이브리의 교체가 문제가 되지 말아야 하고, 상속 구조에서는 기반 클래스를 파생 클래스로 교체할 수 있어야 한다. 이와 같은 하위 버전으로의 호환성 문제, OO의 용어로 이야기하자면 서브 클래스의 기반 클래스로의 호환성 문제가 LSP 파트의 주제이다.

개요

다음과 같은 메소드를 생각해 보자.

class InfoHelper{

public static java.util.List addInfo(java.util.List currentInfo){

String info = “new info??;

currentList.add(info);

return currentInfo;

}

}

InfoHelper 클래스는 인자로 얻어진 List에 새로운 정보를 추가하는 헬퍼 메쏘드이다. 이 때 반환 타입과 파라미터 타입은 ArrayList, LinkedList, Vector 등 구체적인 클래스를 사용하지 않고 인터페이스인 List를 사용한다. List 인터페이스를 사용하는 것은 상황에 따라 List의 여러 구현체로 대체할 수 있기 때문에 사용자가 형변환하여 자신에 알맞은 용도로 사용할 수 있게 해준다. 그런데 다음과 같은 경우 문제가 생긴다.

String[] infoValues = new String[]{“info1??, ??info2??, ??info3??};

List infoList = Arrays.asList(infoValues);

infoList = InfoHelper.addInfo(infoList);

이 코드는 컴파일한 뒤 실행시켜 보면 다음과 같은 에러가 난다.

Exception in thread main java.lang.UnsupportedOperationException

at java.util.AbstractList.add(AbstractList.java:150)

여기서 발생한 예외는 Arrays.asList(infoValues)가 반환한 List 구현체가 List 인터페이스의 add() 메쏘드를 지원하지 않아서 발생된다. 즉, List 인터페이스 중 add()가 제공되어야 한다는 규약이 지켜지지 않아서 생기는 에러이다. LSP는 구현이 선언을, 하위 클래스가 상위 클래스의 규약을 준수하여 사용자에게 하위 타입의 상세정보를 관심 밖으로 돌리는 기법을 다루고 있다. 따라서 다음과 같은 규칙이 보장되어야 한다.

서브 타입은 언제나 기반 타입으로 교체할 수 있어야 한다.

즉 서브 타입은 언제나 기반 타입과 호환될 수 있어야 한다. 달리 말하면 서브 타입은 기반 타입이 약속한 규약(public 인터페이스, 물론 메쏘드가 던지는 예외까지 포함된다)을 지켜야 한다는 것이다. 이 규칙은 라이브러리 버전간의 관계에서도 똑같이 적용된다.

앞에서 살펴본 예의 경우 배열을 변환한 리스트인 infoList를 infoList = new ArrayList(infoList);와 같이 다시 한 번 생성하면 이와 같은 문제가 없어지긴 한다.

하지만 배열을 List로 만들면 왜 변경할 수 없는 리스트(불변 리스트)를 생성해야 할까? 불변 리스트가 꼭 필요한 리스트이고, 뒤에서 살펴본 것처럼 불변 리스트가 LSP를 따르지 않은 것은 다른 특성과의 트레이드 오프 결과이다. 하지만 이와 별도로 Arrays.aslist()의 결과가 불변 리스트인 것은 쉽게 납득할 수 없다(자바 디자인 패턴 워크북의 저자인 스티븐 존 메스커 역시 불합리한 결정이라 생각한다고 밝히고 있다).

상속은 구현 상속(extends 관계)이든 인터페이스 상속(implements 관계)이든 궁극적으로는 다형성을 통한 확장성 획득을 목표로 한다. LSP 원칙도 역시 서브 클래스가 확장에 대한 인터페이스를 준수해야 함을 의미한다. 다형성과 확장성을 극대화하려면 하위 클래스를 사용하는 것보다 상위의 클래스(인터페이스)를 이용하는 것이 좋다. 예를 들어 Collection 프레임워크를 이용할 땐 가능하면 Collection 인터페이스를 사용하고, Collection 인터페이스가 불가능할 때 List 혹은 Set 인터페이스를 이용하는 하면 변경에 유연해진다. 예를 들어 List 인터페이스의 get() 메쏘드를 사용해야 한다면 Collection 인터페이스를 사용할 수 없다.

따라서 ArrayList 등의 구체 클래스를 선언하는 것은 가능한 피해야 한다. 일반적으로 선언은 기반 클래스로 생성은 구체 클래스로 대입하는 방법을 사용한다. 생성 시점에서 구체 클래스를 노출시키기 꺼려질 경우 생성 부분을 Abstract Factory 등의 생성 패턴을 사용하여 유연성을 높일 수 있다.

혹시 상속의 목적은 단지 재사용으로 생각할 수 있다. 하지만 상속을 통한 재사용은 기반 클래스와 서브 클래스 사이에 IS-A 관계가 있을 경우로만 제한되어야 한다. 그 외의 경우에는 합성(composition)을 이용한 재사용을 해야 한다. 이를테면 Vector 클래스를 extends하여 만든 Stack 클래스는 ‘Stack is a Vector’ 관계가 성립하지 않기 때문에 상속 대신 합성을 사용했어야 했다. 왜냐하면 Stack은 인덱스를 통한 접근을 제공하는 get() 메쏘드 등을 제공하면 안 되기 때문이다. 즉 Stack과 Vector 관계는 개념적으로 상속관계가 성립하지 않는다.

상속은 다형성과 따로 생각할 수 없다. 그리고 다형성으로 인한 확장 효과를 얻기 위해서는 서브 클래스가 기반 클래스와 클라이언트 간의 규약(인터페이스)를 어겨서는 안 된다. 결국 이 구조는 다형성을 통한 확장의 원리인 OCP를 제공하게 된다. 따라서 LSP는 OCP를 구성하는 구조가 된다. OO 원칙은 이렇게 서로가 서로를 이용하기도 하며 포함하기도 하는 특징이 있다. LSP는 규약을 준수하는 상속 구조를 제공한다. LSP를 바탕으로 OCP는 확장하는 부분에 다형성을 제공해 변화에 열려 있는 프로그램을 만들 수 있도록 해준다.

컬렉션 프레임워크를 통해 OCP, LSP 적용 예 살펴보기

<그림 10>은 컬렉션 프레임워크의 인터페이스를 보여준다. 컬렉션 프레임워크는 크게 Collection과 Map이라는 인터페이스를 갖고 있다. Collection 인터페이스는 값들을 묶어서 저장, 탐색, 조작하는 반면, Map 인터페이스는

집합을 관리한다. 이를테면 주민번호(key) - 사람 이름(value)이 되는 식이다. 자바 1.2에서 도입된 컬렉션 프레임워크는 객체지향의 묘미를 가장 잘 살린 라이브러리 중 하나로 평가되고 있으며, OCP와 LSP의 좋은 예이기도 하다. 다음과 같은 코드를 보자.

void f(){

LinkedList list = new LinkedList();

// …

modify(list);

}

 

void modify(LinkedList list){

list.add(…);

doSomethingWith(list);

}

LinkedList만 사용할 것이라면 이 코드도 문제는 없다. 하지만 만약 속도 개선을 위해 HashSet을 사용해야 하는 경우가 온다면 LinkedList를 다시 HashSet으로 어떻게 바꿀 것인가? LinkedList와 HashSet은 모두 Collection 인터페이스를 상속하고 있으므로 다음과 같이 작성하는 것이 바람직하다.

void f(){

Collection collection = new HashSet();

// …

modify(collection);

}

 

void modify(Collection collection){

collection.add(…);

doSomethingWith(collectoin);

}

이제 컬렉션 생성 부분만 고치면 마음대로 어떤 컬렉션 구현 클래스든 사용할 수 있다. 이 프로그램에서 LSP와 OCP 모두를 찾아볼 수 있는데 우선 컬렉션 프레임워크가 LSP를 준수하지 않았다면 Collection 인터페이스를 통해 수행하는 범용 작업이 제대로 수행될 수 없다. 하지만 앞에서 살펴보았던 Arrays.toList()의 경우와 뒤에서 살펴볼 불변 컬렉션의 경우를 제외하면 모두 LSP를 준수하기 때문에 이들을 제외한 모든 Collection 연산에서는 앞의 modify() 메쏘드가 잘 동작하게 된다.

그리고 이를 통해 modify()는 변화에 닫혀 있으면서, 컬렉션의 변경과 확장에는 열려 있는 구조(OCP)가 된다. 물론 Collection이 지원하지 않는 연산을 사용한다면 한 단계 계층 구조를 내려가야 한다. 그렇다 하더라도 ArrayList, LinkedList, Vector 대신 이들이 구현하고 있는 List를 사용하는 것이 현명할 것이다.

다음은 전형적인 JDBC 프로그래밍 코드이다.

Class.forName(“com.mysql.jdbc.Driver??).newInstance();

Connection con = DriverManager.getConnection(jdbc_url, id, password);

Statement stmt = con.createStatement();

ResultSet rs = stmt.executeQuery(sql);

그리고 보통 드라이버 명, jdbc_url, id, password는 설정 파일로 빼내어 프로그램의 재컴파일 없이 DB를 바꿀 수 있도록 한다. JDBC를 사용하면 오라클 DB, IBM DB2, MSSQL, MySQL, PostgreSQL 등 다양한 데이터베이스를 동일한 인터페이스를 통해 사용할 수 있다. 이러한 확장에 열려 있는 구조는 인터페이스를 통한 프로그래밍(구현 상속)과 다형성을 통해 가능하며 JDBC의 구조에는 LSP와 OCP가 정석으로 반영되어 있다.

우선 JDBC 드라이버를 구현하는 개발자는 java.sql.Driver, java.sql.Connection, java.sql.ResultSet, java.sql.ResultSetMetaData, java.sql.Statement 5개의 인터페이스를 구현해야 한다. 이 인터페이스들을 상속하여 구현할 때 이들이 정의하고 있는 규약을 어겨서는 안 된다. 즉 서브 클래스가 이들 인터페이스가 제공하는 모든 메쏘드들을 제대로 구현(구현 상속에서의 LSP 준수)해야만 JDBC를 이용한 모든 프로그램이 변경 없이 작동할 수 있다. 일부 메쏘드를 빼놓고 구현한다면 이런 기능을 이용한 프로그램은 해당 드라이버를 이용할 때 수정이 불가피하다. 결국 수정에 닫혀 있다는 OCP를 위반하게 된다. 이제 OCP를 가능케 하는 구조에 대해 자세히 살펴보기로 하자.

Class.forName(“com.mysql.jdbc.Driver”).newInstance();를 호출할 때 과연 어떤 일이 벌어질까? 각 Driver는 java.sql.Driver 인터페이스를 구현하며 생성시 JDBCDriver에 자신의 인스턴스를 등록하도록 되어 있다. 다음의 생성자가 이러한 역할을 한다.

public JDBCDriver(){

try{

java.sql.DriverManager.registerDriver(new JDBCDriver());

}catch(SQLException e){

}

}

그렇다면 Connection은 어떻게 얻어올까? DriverManager.getConnection()을 보면 첫 번째 인자로 jdbc_url이 들어간다. 예를 들어 PostgreSQL의 경우엔 jdbc:postgresql로 시작하게 되는데 java.sql.Driver 인터페이스에는 public boolean acceptsURL(String url) throws SQLException란 메쏘드가 정의되어 있으며 PostgreSQL은 다음과 같이 이 메쏘드를 구현한다.

public boolean acceptsURL(String url) throws SQLException{

return url.startWith(“jdbc:postgresql??);

}

DriverManager는 getConnection()의 첫 번째 인자인 url를 이용해 자신에게 등록된 드라이버들의 acceptsURL을 호출한다. 그리고 true를 반환하는 드라이버의 Connection을 클라이언트에게 넘겨준다. 클라이언트 프로그램은 반환된 Connection 인터페이스를 이용해 드라이버의 Connection 구현체를 사용한다. DriverManager은 전역적으로 1개의 인스턴스만 존재하는 Singleton이고, getConnection은 url의 앞부분을 통해 Connection을 생성하는 Abstract Factory이다. 그리고 다시 Connection은 Statement의 Abstract Factory가 되고, Statement는 ResultSet의 Abstract Factory이다.

이와 같은 구조에서는 5개의 인터페이스를 구현 상속한 클래스들이 LSP만 지키고 있다면 설정 파일의 값을 바꿔주는 것만으로도 DB를 얼마든지 바꿀 수 있다. 즉 JDBC를 이용하는 프로그램은 변화에 닫혀 있고(Closed to Modification), DB의 확장에는 열려 있다(Open for Extension).

트레이드 오프

모든 선택에는 트레이드 오프가 있다. 하나를 얻으면 하나를 포기해야 한다는 것이다. 항상 LSP를 지킬 수 있다면 좋겠지만 현실은 손에 진흙을 묻혀야만 하는 곳인가 보다. 우선 Collection 프레임워크에서 LSP를 어겼지만 많은 개발자들이 어쩔 수 없는, 더 나아가 올바른 선택이었다고 이야기 하는 예를 함께 보자.

Collection list = new LinkedList();

list = Collections.unmodifiableCollection(list);

Collectons의 unmodifiableCollection 메쏘드를 이용하면 불변 컬렉션 객체를 만들 수 있다. 불변 컬렉션 객체에 대해 add() 혹은 remove() 등의 메쏘드를 호출하게 되면 앞에서 보았던 UnSupportedOperationException을 던지게 된다. 명백한 LSP 위반이다. 하지만 이와 같은 랩퍼(wrapper)를 이용하지 않았다면 계층 구조가 2배로 커진다. 즉 Collection 인터페이스가 ModifiableCollection과 UnmodifiableCollection으로 나누어져야 하고, 이를 구현하는 모든 서브 클래스들 또한 숫자가 두 배가 된다.

문제는 랩퍼가 여러 개 생길 경우이다. 쓰레드 세이프한 컬렉션을 만드는 synchronizedCollection을 랩퍼가 아닌 계층 구조에 반영했다면 계층 구조의 크기는 또 다시 배로 늘어났을 것이다. 그렇기에 Collection 프레임워크를 디자인한 Joshua Bloch는 계층 구조의 폭주와 LSP 위반 사이에서 LSP 위반을 택한 것이다. - 자바의 Collection 프레임워크가 발표되기 전에 「java Concurrent Design Patterns」의 저자인 Doug Lea는 자신의 Collection 프레임워크를 웹에 공개했다. 이 프레임워크에서는 Collection이 UpdatableCollection과 ImmutableCollection으로 나누어져 있다(http://gee.cs.oswego.edu/dl/classes/collections/index.html 참조).

Joshua는 불변 컬렉션을 계층 구조에 반영하지 않아 LSP를 어긴 반면 계층 구조를 단순화시켰다. Duug Lea는 이를 반영해 LSP를 준수하게 한 대신 계층 구조가 2배로 늘어났다. 두 선택 모두 일장일단을 가진 선택으로 무엇이 옳은 선택인지에 대해선 관점에 따라 달리질 수밖에 없다. 상속을 통한 기능 추가로 인해 계층 구조가 폭주하게 된다. 이는 상속의 어쩔 수 없는 단점인데 보통 Decorator 패턴을 사용해 이 문제를 해결한다. 앞의 Collection 프레임워크 역시 Decorator 패턴을 사용하여 문제를 해결한 경우로 예제 코드를 보면 다음과 같다.

public class Collections{

public static Collection unmodifiableCollection(final Collection wrapped){

return new UnmodifiableCollection(wrapped);

}

private static class UnmodifiableCollection implements Collection{

private Collection unmodifiable;

public UnmodifiableCollection(Collection modifiable){

this.unmodifiable = modifiable;

}

public boolean contains(){

return unmodifiable.contains();

}

public void remove(Object obj){

throw UnSupportedOperationException();

}

// 컬렉션을 조회하는 메쏘드는 contains와 같은 랩퍼 메쏘드를 구현하고,

// 컬렉션을 변경하는 메쏘드는 예외를 던진다.

}

}

리팩토링

LSP를 지키지 않았을 경우에는 거부된 유산(Refused Bequest)이란 악취가 나게 된다. 증상은 다음과 같다.

① 부모를 상속한 자식 클래스에서 메쏘드를 지원하는 대신 예외를 던진다(예를 들어 콜렉션 프레임워크에서 UnsupportedOperationException)

② 자식 클래스가 예외를 던지지는 않지만 아무런 일도 하지 않는다.

③ 클라이언트가 부모보다는 자식을 직접 접근하는 경우가 많다.

이에 대한 해결책은 다음과 같다.

① 혼동될 여지가 없고 여러 트레이드 오프를 고려해 선택한 것이라면 그대로 놔둔다. 단 트레이드 오프와 프로그램의 범용성의 한계에 대해서 스스로 인지하고 있어야 한다.

② 다형성을 위한 상속 관계가 필요없다면 Replace Inheritance with Delegation을 한다. 상속은 깨지기 쉬운 기반 클래스 등을 지니고 있으므로 IS-A 관계가 성립되지 않는다. LSP를 지키기 어렵다면 상속 대신 합성(composition)을 사용하는 것이 좋다.

③ 상속 구조가 필요하다면 Extract Subcless, Push Down Field, Push Down Method 등의 리팩토링 기법을 이용하여 LSP를 준수하는 상속 계층 구조를 구성한다.

다음은 상속 구조를 재조정하는 일련의 과정들을 보여준다.

객체지향의 꽃 ‘다형성’

객체지향 프로그래밍은 캡슐화(데이터 캡슐화, 구현 캡슐화), 상속(인터페이스 상속, 구현 상속), 그리고 다형성을 그 기초로 한다. 캡슐화를 지키기 위해선 내부의 데이터와 구현은 외부로 노출시키지 않고 public 인터페이스만 개방해야 한다. 이 때 public 인터페이스는 객체와 외부 클라이언트 사이의 약속·계약이며, 이러한 약속은 상속과 다형성을 위한 첫걸음이 된다. 우리가 이전 기사에서 논의했던 SRP(단일 책임 원칙)은 각 객체가 어떤 역할을 캡슐화 할 것인지에 대한 가이드라인을 준다.

상속은 많은 책에서 재사용을 위한 것이라 말하고 있지만 실제로는 다형성을 위한 것이다. 잘 정의된 상속 구조는 기반 클래스와 서브 클래스 간에 IS-A 관계가 성립하며 기반 클래스는 사용자로부터 구체 구현 클래스를 캡슐화 해준다. 상술했던 Collection 인터페이스는 List와 Set을 캡슐화해주고, List는 ArrayList와 LinkedList, Vector를 캡슐화해주는 형태다. 이 때 객체를 생성하는 부분에서만 구체 클래스가 사용되는 데 이 또한 Abstract Factory 등의 생성 패턴을 사용해 적절히 추상화시킬 수 있다(JDBC를 생각해 보자). 그리고 LSP가 상속이 다형성을 위해 사용될 수 있도록 해준다. LSP를 지키지 않으면 Arrays.asList()와 같이 상속 구조에 포함되어 있다 하더라도 다형성으로 인한 이점을 제대로 살리지 못하게 된다.

마지막으로 다형성이야 말로 확장 가능하고 유지보수하기 쉬운 소프트웨어를 만들 수 있게 해주는 객체지향의 꽃이다. 하지만 다형성을 얻으려면 우선은 각 객체들이 적절히 책임 분배되어 있고, 캡슐화되어 있어야 하며, 다형성을 얻을 수 있는 부분은 LSP를 준수하는 상속 구조를 보장해야 한다. 그러므로 캡슐화와 SRP, 상속과 LSP가 제대로 되지 않은 객체 구조에서는 다형성과 OCP를 제공할 수 없다. 다음은 적절히 책임이 분배되지 않은 객체 구조를 SRP, LCP, OCP를 준수하는 객체 구조로 진화시켜 나가는 과정을 잘 보여준다.

개발자들은 가능한 단순한 구조, 프로그램의 완전성 그리고 수정의 용이함이란 서로 상충하는 특성을 갖는다. 객체지향 시스템은 본질적으로 절차지향 시스템에 비해 구조가 복잡하지만, 확장하고 유지보수하기 쉬우며 직관적이다. 디자인 패턴 역시 프로그램의 복잡도를 증가시키지만 역시 확장과 유지보수를 용이하게 해준다. 우리는 본질적으로 복잡한 세상을 다루고 있다. 그렇기 때문에 복잡성 자체를 피할 수 없다. 대신 복잡성을 관리하는 방법에 대해 찾으려고 노력해야 한다.

이에 대한 명쾌한 하나의 답은 없다. 객체지향 시스템을 사용하여 복잡성을 관리하려 한다면 객체지향의 특질, 그리고 이들의 장점과 단점을 파악하고, 문제 상황에서 적절히 트레이드 오프하면서 최선의 선택을 찾을 뿐이다. 즉, 그때 그때 다르다. 다행히 여러 객체지향의 특질, 원리, 패턴은 복잡한 상황 속에서 (복잡성을 고려한다면) 최대한 단순한 구조와 용이한 수정과 확장을 가능하게 해준다. 하지만 상황에 따라 이들을 어길 수도 있다. 하지만 왜 어길 수밖에 없는지, 그리고 이로 인한 장점과 단점이 무엇인지는 분명히 알고 선택해야 한다. 트레이드 오프와 장점과 단점을 생각하지 않은 선택은 라이트 없는 야간 비행을 시도하는 것이다.@

* 이 기사는 ZDNet Korea의 제휴매체인 마이크로소프트웨어에 게재된 내용입니다.