소프트웨어 설계의 묘미는 개발자가 작업하는 시스템의 창조자 역할을 할 수 있다는 것이다. 실세계의 경우 좋은 세상을 만들기 위해 적절한 질서, 정책, 의식 등이 전제돼야 하듯, 소프트웨어 설계의 경우는 객체와 객체간의 유기적 관계를 가장 효과적으로 구성하는 방법이 관건이 된다.
설계자는 자신의 문제영역에서 몇 가지 원리, 원칙, 패턴, 모델에 따라 문제들을 해결하면서 좋은 설계를 만들어간다. 이런 원리, 원칙들은 과거의 선배 설계자들이 시행착오를 거치면서 발견했던 것들이며 ‘설계자’란 종의 유전자에 묵시적으로 주입한 원칙들이다. 필자는 이번 연재를 통해 이 원칙들을 여러 측면에서 심도 있게 다룰 계획이다.
정상 세포와 암 세포는 거의 동일한 구조로 되어 있다. 단지 암 세포는 변이를 일으켜 정상 세포와 조금 달리 행동할 뿐이다. 세포 하나만 놓고 봤을 때는 크게 위험할 것 같지 않다. 하지만 이러한 세포들이 모였을 때 어떤 규칙은 생명을 유지하는 항상성을, 어떤 규칙은 생명을 앗아가는 암을 일으킨다. 개별 세포의 몇 가지 행동 방식이 다를 뿐이지만, 수천수만의 세포가 상호 작용하게 되면 이 조그만 규칙의 차이는 걷잡을 수 없이 커지게 된다.
세상은 생각보다 단순하다
유명한 NP 문제 중에 TSP(Traveling Salesman Problem)라는 것이 있다. 여러 도시가 있을 때, 가장 빠른 시간에 모든 도시를 순방하는 경로를 찾는 문제다. 언뜻 보면 쉬울 것 같지만, 도시의 수가 증가함에 따라 경로가 기하급수적으로 늘어나기 때문에 최단 경로 찾기는 지극히 어려워진다. 이는 네트워크에서 패킷이 목적지까지 도달하는 최단 경로를 구하는 문제와 똑같다.
1999년 간단한 규칙을 이용해 TSP를 가장 효율적으로 해결한 알고리즘이 발견됐다. 정말 단순하다. A에서 B를 향해 가상의 세일즈맨들을 보내면, 이들이 임의로 경로를 택해 B까지 도달한다. 그리고 B에 도달한 세일즈맨은 자신이 왔던 길을 따라 A로 돌아가면서, 각 도시에 시간이 지날수록 냄새가 옅어지는 무엇을 떨어뜨린다. 이제 다시 A에서 세일즈맨을 보낸다. 이 세일즈맨은 냄새가 가장 짙은 도시를 따라 B까지 가게 된다. 이 경로가 이 알고리즘의 최단 경로가 된다. 이 방법은 매우 효율적인 것으로 알려져 있으며, 유럽의 전화 회사에서 라우팅을 위해 사용하고 있다.
현대 의학으로 고치기 어려운 암도 결국은 구성 요소들의 간단한 규칙으로 인해 발생한다. 그리고 TSP와 같은 난공불락일 것 같은 문제도 구성 요소 간의 간단한 규칙으로 해결할 수 있다(이 때는 규칙으로 인해 암 대신 최단 경로라는 특성이 창발한다). 구성 요소들이 일정한 규칙을 따르고, 이러한 방식으로 상호 작용하는 요소가 많아질 때 부분보다 큰 전체가 나타나는 것이다.
단순하고 유연한 프로그램과 복잡하면서도 경직된 프로그램이라는 차이도 결국은 프로그램을 구성하는 가장 기본 단위인 객체들이 어떤 규칙을 가지고 상호 작용하는지에 의해 결정된다. 객체들이 정상 세포와 같이 좋은 규칙으로 관계를 맺는다면 좋은 프로그램이 나오고, 암 세포와 같이 (인간이 보기에) 나쁜 규칙으로 관계를 맺는다면 나쁜 프로그램이 나오는 것이다. 간단한 규칙이지만 이를 따라 관계 맺는 객체들이 모여 차이를 낳는다. 이러한 규칙을 알 수 있다면 얼마나 좋을까? 많은 인기를 끌고 있는 디자인 패턴이 이에 대한 답이 될 수 있을까?
디자인 패턴
디자인 패턴은 많은 프로그래머들이 성공적인 프로젝트에서 무수히 반복해 사용했던 문제 해결 기술을 발견해 정형화한 것이다. 각 패턴은 특정 맥락의 문제를 효과적으로 해결할 수 있게 해주며, 패턴들은 해결하는 문제의 맥락(GoF의 용어로는 Intent)이 다르다.
분명 패턴을 잘 이해하고 사용하면, 수많은 개발자, 연구자들의 “아하(Aha)!” 경험을 (그들이 들였던 노력에 비해서는) 손쉽게 사용할 수 있게 된다. 하지만 패턴은 빈번히 발생하는 ‘특정’ 문제에 대해 적용할 수 있는 해결책이기 때문에 객체들 간의 게임에 법칙에 관한 일반 룰까지 알려주지는 않는다.
패턴이 좋은 게임의 규칙을 이용해 객체들의 관계를 맺고 있음은 분명하다(좋은 게임의 규칙을 사용하고 있지 않다면, 특정 문제에 대해서도 좋은 해결책이 나올 수 없다). 그런데 이 때 패턴이 암묵적으로 사용하는 게임의 법칙을 모르면, 패턴이란 고도의 게임 전술을 유용하게 구사할 수 없게 된다. 패턴은 성공한 프로젝트 코드들에서 발견, 일반화되는 과정에서 코드에서 디자인으로 추상화됐기 때문에 이들 다시 코드로 구체화하려면 패턴이 사용하는 게임의 법칙을 알아야 한다. 이를 모른다면 장님이 코끼리 다리 만지듯 패턴을 사용하게 될 위험이 있다. 패턴을 사용해 프로그램을 만들었는데, 괜히 복잡해지기만 하던 걸? 글쎄….
게임의 법칙은 있다
수십 년간, 많은 개발자, 연구자들은 베일에 가려진 좋은 게임의 법칙을 찾기 위해 노력해 왔다. 노련한 개발자의 경험이 깃든 패턴으로 승화된 디자인은 이러한 게임의 규칙을 철저히 지키고 있으며, 이러한 규칙들을 지킨다면 단순하면서도 유연한 프로그램이 창발하게 된다. 이번 글에서는 많은 사람들이 노력해 발견한 이러한 게임의 법칙을 설명하려 하며, 구체적인 내용은 다음과 같다.
◆ OCP(Open-Closed Principle) : 개방-폐쇄 원칙
◆ LSP(Liskov Substitution Principle) : 리스코프 교체 원칙
◆ SRP(Single Responsibility Principle) : 단일 책임의 원칙
◆ DIP(Dependency Inversion Principle) : 의존 관계 역전 원칙
◆ ISP(Interface Segregation Principle) : 인터페이스 격리 원칙
이제 본격적으로 OCP에 대해 살펴보자.
개방-폐쇄 원칙
휴대전화를 살 때마다 느끼는 것이지만 똑같은 기능을 하는 충전기가 (같은 회사의 제품임에도 불구하고) 저마다 다른 모양으로 제작되는지 불만이었다. 의도는 뻔하게도 하나라도 더 팔아서 이윤을 높이기 위함이다. 기업에는 이익이겠지만 소비자에게는 똑같은 기능의 부품을 또 사야 하는 스트레스를 유발시키고, 국가적으로도 엄청난 자원 낭비가 될 만도 하다.
얼마 후 정통부에서 표준 규격으로 24핀 잭을 발표했다. 그 정책 덕분에 이제 휴대전화만 사고 충전기는 재사용할 수 있게 됐다. 따라서 휴대전화의 여러 종류에는 ‘개방하지만’ 충전기의 쓸데없는 생산은 ‘닫아두는’ 효과를 얻은 것이다. 바로 이번 호에서 소개할 개방-폐쇄의 원칙을 잘 반영한 결과라고 생각한다.
다시 우리의 필드로 돌아와서 <그림 2>는 클라이언트가 서버의 서비스를 실행하는 모습이다. 일반적인 경우지만 서버의 서비스 타입이 여러 개 있을 경우 1×n의 클라이언트 대 서비스의 관계가 성립된다. 따라서 각각의 다른 서비스를 호출하는 클라이언트들은 호출 코드를 각 서비스 타입에 따라 다르게 작성해야 한다. 이 때 클라이언트가 파일만을 읽는 것이 아니라 스트링 버퍼로 바이트 배열로 읽는다면 실제로 그 관계는 n×m의 관계가 된다. 이렇게 읽는 대상의 타입이 확장, 변경될수록 복잡도는 <그림 3>과 같이 두 배 이상 증가한다.
물론 약간의 경력자들은 이런 객체 관계를 무의식적으로 피한다. 실제 자바 설계에도 반영됐지만 보통 <그림 4>와 같은 상속을 통한 다형성을 이용한다. 목적은 InputStream의 확장은 열어두고 클라이언트의 변경은 닫아두기 위함이다. 따라서 클라이언트는 InputStream 자식 클래스의 실제 타입에 상관없이 InputStream을 통해 충분히 읽고 싶은 것을 읽을 수 있다. 즉, 이상적인 1×n의 관계가 됐다. 확장에 대한 비용은 단지 생성 시점에 실제로 사용할 자식 클래스의 타입을 선택해주면 되는 정도다.
이 개방-폐쇄 원칙을 잘 정의한 버틀란트 메이어(Bertrand Meyer)는 소프트웨어 구성 요소(컴포넌트, 클래스, 모듈, 함수)는 확장에 대해서는 개방돼야 하지만 변경에 대해서는 폐쇄되어야 한다고 말한다. 변경을 위한 비용은 가능한 줄이고 확장을 위한 비용은 가능한 극대화해야 한다는 의미다.
방법은 우선 변하는(확장되는) 것과 변하지 않는 것을 엄격히 구분해야 한다. 변하는 것은 가능한 변하기 쉽게, 변하지 않는 것은(폐쇄돼야 하는 것은) 변하는 것에 영향을 받지 않게 설계하는 것이다. 다음으로 이 두 모듈이 만나는 지점에 인터페이스를 정의해야 한다. 인터페이스는 변하는 것과 변하지 않는 모듈의 교차점으로 서로를 보호하는 방죽 역할을 한다.
서버에 있어서 인터페이스는 확장의 내용이 정의되고 이 규약에 따라 확장이 구체화하는 역할을 한다. 따라서 인터페이스는 서비스 내용을 추상화하는 형태로 제공되므로 인터페이스 설계에 주의가 필요하다. 또한 인터페이스는 클라이언트에 있어서 서버의 확장·변경에 따른 클라이언트 변경을 무색하게 하는 방패가 된다. 서버에서는 인터페이스 규약에 의해서만 확장·변경하기 때문이다. 따라서 안정된 계약에 의한 설계(Design by Contract)를 보장한다.
이를 통해 얻을 수 있는 효과, 즉 목적은 앞의 예처럼 객체간의 관계를 단순화해 복잡도를 줄이고, 확장·변경에 따른 충격을 줄이는 데 있다. 또한 클라이언트는 InputStream의 타입을 알아야 할 시점(InputStream의 자식 클래스 생성 시점)과 각 타입의 무관한 사용 시점(호출 시점)을 명확히 분리해 사용할 수 있다. 따라서 다른 InputStream이 확장될 때 클라이언트에 있어서 InputStream의 자식 클래스를 생성하는 코드만 변경해 주면 된다. 물론 클라이언트의 확장에 따른 코드 충격은 다른 방법으로 단순화, 자동화할 수 있다(환경 변수로 타입을 정의하거나 문자열로 동적 객체 바인딩 메커니즘을 이용하는 등).
사례들
사실 이 원칙은 실세계에서도 흔히 찾아볼 수 있을 정도로 너무도 당연한 원칙이어서 소프트웨어에서 사례를 찾는데 오히려 안 보이는 곳이 없을 정도로 많이 적용된 원칙이다.
GCC 처럼 여러 시스템을 지원하는 컴파일러의 경우와 같이 POSIX(Portable Operating System Interface)는 유닉스 기반 운영체제의 시스템 인터페이스 표준이다. 자바의 경우 한번 작성된 코드로 JVM이 제공되는 모든 플랫폼에서 실행을 보장한다(고수준의 플랫폼 추상화). 비슷하게 POSIX 표준 또한 여러 종류의 유닉스 시스템에서 공통으로 제공하는 시스템 인터페이스를 정의하고 있다(저수준의 플랫폼 추상화). 따라서 POSIX를 준수한 시스템 인터페이스를 사용하는 코드는 다른 운영체제의 시스템 함수를 사용하는 데 문제가 되지 않는다.
앞에서 예를 든 정통부의 휴대전화 충전기 24핀 표준 규격이나 자바 표준, POSIX 표준, IEEE에 이르기까지 어디에나 '표준'이 일종의 해결사, 중재자 역할을 한다. 이 '표준'의 역할은 지금의 맥락에서 의미심장하게도 OCP의 '인터페이스'의 기능을 한다. 표준에 의해 사용자는 서비스의 기능(규약)을 신뢰할 수 있으며(closed), 서비스 제공자는 자신의 목적에 맞게 확장·특화하여 서비스의 차별화, 상품성을 높인다(open).
OCP 주의점 1
다시 <그림 5>로 돌아가서, 공통 모듈(shared module)의 존재는 (그림에서와 같이) 시스템을 지저분하게 하는 경향이 있다. <그림 4>와 같이 상속 구조를 갖는 자식 클래스들이 있을 때 공통된 루틴이나 변수를 리팩토링의 ‘Pull Up Method/Pull Up Field’하고 싶은 유혹에 빠진다. 리팩토링은 설계를 깔끔하게 하는 좋은 방법이지만 문제는 대상의 크기에 있다. 위의 공통 모듈이 작을 경우 공통 모듈 재사용성을 얻기 위해 너무 잦은 (다른 영역의) 모듈을 접근해야 하고 모듈 구성도 보는 바와 같이 지저분해진다.
OCP에서 주의할 점은 확장되는 것과 변경되지 않는 모듈을 분리하는 과정에서 크기 조절에 실패하면 오히려 관계가 더 복잡해져서 설계를 망치는 경우가 있다는 것이다. 설계자의 좋은 자질 중 하나는 이런 크기 조절과 같은 갈등 상황을 잘 포착하여 (아깝지만) 비장한 결단을 내릴 줄 아는 능력에 있다.
OCP 주의점 2
하지만 재미있는 현상은 JTA와 같은 어댑터(adapter)의 역할이다. 가령 트랜잭션 모니터의 경우 MS의 MTS나 JTS, 턱시도(Tuxedo)들은 인터페이스의 차이가 있어 비슷한 기능을 함에도 불구하고 상호 운용을 위해 서로의 인터페이스를 변환시켜 주는 어댑터를 필요로 한다. 적절한 비유가 될지 모르지만, InputStream의 예에서도 공유 메모리 스트림을 확장해야 한다고 했을 때 문제가 발생한다. 공유 메모리 접근은 비동기적이기 때문에 기존의 동기적인 read() 메쏘드를 그대로 적용하는 데 문제가 생긴다. 이전까지 동기적인 스트림만 상대했던 설계자에게 있어서 공유 메모리는 경악할만한 요구 사항이다. 이 경우에도 비동기적 접근을 동기적으로 표현하기 위한 어댑터가 필요할 것이다.
확장을 보장하는 open 모듈 영역에서 예측하지 못한 확장 타입을 만났을 때 인터페이스 변경하려는 안과 어댑터를 사용하려는 안 사이에서 갈등하게 된다. 위의 두 예에서처럼 변경의 충격이 적은 후자를 택하는 경우가 대부분이다. 한 번 정해진 인터페이스는 시간이 갈수록 사용하는 모듈이 많아지기 때문에 바꾸는 데 엄청난 출혈을 각오해야 한다. 자바의 deprecated API가 대표적인 경우다.
즉, 인터페이스는 가능하면 변경해서는 안 된다. 따라서 인터페이스를 정의할 때 여러 경우의 수에 대한 고려와 예측이 필요하다. 물론 과도한 예측은 불필요한 작업을 만들고 보통, 이 불필요한 작업의 양은 크기 마련이다. 따라서 설계자는 적절한 수준의 예측 능력이 필요한데, 설계자에게 필요한 또 하나의 자질은 예지력이다.
OCP 주의점 3
이 패턴이 성공하기 위한 포인트는 요청자와 처리자 사이의 계약인 커맨드의 역할이다. 처리자는 execute()란 인터페이스만 알면 어떤 처리도 수행할 수 있다. 따라서 서로 의미적 관계가 없는 Command들도 execute()란 메쏘드로 무엇이든 확장할 수 있다. OCP 구조에서 서버가 확장할 수 있는 운신의 폭이 넓어진 반면 클라이언트는 서버가 어떤 처리를 하는지 무지해진다. 물론 커맨드 패턴에서의 execute() 메쏘드는 적절하지만 InputStream 예제에서의 read()를 doWork() 같은 메쏘드로 대치한다면 좋은 구조가 되지 못 할 것이다. 왜냐하면 클라이언트는 자신이 어떤 작업을 하는지 모르기 때문이다.
즉, 인터페이스 설계에서 적당한 추상화 레벨을 선택하는 것이 중요하다. 우리는 추상화라는 개념에 '구체적이지 않은' 정도의 의미로 약간 느슨한 개념을 갖고 있다. 그래디 부치(Grady Booch)에 의하면 ‘추상화란 다른 모든 종류의 객체로부터 식별될 수 있는 객체의 본질적인 특징’이라고 정의하고 있다. 즉, 이 '행위'에 대한 본질적인 정의를 통해 인터페이스를 식별해야 한다. 이것이 OCP의 세 번째 주의점이다.
설계 원칙의 역설
디자인 패턴은 소프트웨어 설계의 좋은 템플릿이다. 우리는 디자인 패턴을 이용하여 설계 모델을 좋은 구조로 유도한다. 이 구조는 소프트웨어 품질을 높이게 하고 기능을 강화시키기도 한다. 소프트웨어 설계 모델의 메타적인 원리가 디자인 패턴의 단위라고 한다면 디자인 패턴에 등장하는 좋은 구조들에 대한 메타적인 원리가 이번 연재를 통해 소개할 원칙들 정도 된다.
이 원리들은 물론 표준화 작업에서부터 아키텍처 설계에 이르기까지 다양하게 적용할 수 있지만 그 크기의 대비를 보면 패턴보다 훨씬 작고 여러 곳에 적용되는 원칙이다. 그리고 우리는 이 원칙들에 자연스럽게 익숙한지도 모른다. 하지만 이 원칙의 정체에 대해서는 (필자가 앞에서 추상화를 언급한 것처럼) 모호하게 생각한다. 이 연재를 통해 좀더 이 원칙들의 의미와 내용을 심도 있게 다룰 계획이다.
초로의 나이이임도 불구하고 태극권의 일인자였던 어느 노인이 이런 말을 했다고 한다. “나는 한평생을 걸쳐 무술을 연습했지만 이제 서기(자세)를 제대로 할 수 있을 것 같다.” 입문자 때 배우는 서기 자세는 아주 쉬운 것 같지만 역설적이게도 아주 어렵다고 한다. 우리에게 있어서 이 설계 원칙이 이 정도의 의미가 아닐까 생각된다. 따라서 한 번 더 고민할 만한 화두일 것이다.@
* 이 기사는 ZDNet Korea의 제휴매체인 마이크로소프트웨어에 게재된 내용입니다.