프로그래밍 공부
작성일
2024. 1. 31. 16:50
작성자
WDmil
728x90

6.2 재사용성이 높은 코드를 디자인하는 방법

재사용성이 높은 코드는 두 가지 특성을 만족해야 한다.

 

첫 번째로 다른 분야의 애플리케이션이나 조금 다른 사용 시나리오에서도 무리 없이 적용할 수 있도록 기능이 충분히 일반화되어 있어야 한다. 특정 프로그램에 종속적인 세부 사항이 많이 포함된 코드는 다른 프로그램에서 재사용하기 어렵다.

 

두 번째로 재사용성이 높은 코드는 언제나 사용하기 쉬워야 한다. 인터페이스와 기능을 이해하는 데 너무 많은 시간을 소비하지 않고도, 충분히 준비된 상태에서 목적하는 애플리케이션에 적용할 수 있어야 한다.

 

코드를 전달하는 수단 또한 중요하다. 소스 코드 상태 그대로 전달할 수도 있고, 정적 또는 동적 라이브러리(윈도우의 DLL 또는 리눅스의 so 같은) 형태로 전달할 수도 있다. 어떤 방법을 쓰냐에 따라 코드를 작성하는 방법에도 제한이 가해진다.

 

재사용성이 높은 코드를 디자인할 때 가장 중요한 것은 추상화다. 텔레비전의 인터페이스를 예로 들어보면, 인터페이스를 이용할 때 텔레비전이 내부적으로 어떻게 동작하는지 알 필요가 없다는 걸 눈치챌 수 있다. 이처럼 코드를 디자인할 떄도 인터페이스와 구현을 분리해야 한다. 이렇게 분리하면 고객 입장에서는 코드 내부가 어떻게 구현되었는지 이해할 필요가 없어지기 떄문에 코드를 사용하기 훨씬 쉬워진다.

 

추상화는 코드를 인터페이스와 구현으로 분리하기 떄문에, 재사용성이 높은 코드를 디자인하는 것도 이 두 부분에 집중하게 된다. 먼저 코드를 적절하게 구조화해야 한다. 클래스 계층을 어떻게 설계할 것인가? 템플릿을 사용해야 하나? 코드를 어떻게 서브시스템으로 나눌 것인가?

 

그 다음에는 인터페이스를 디자인해야 한다. 인터페이스는 작성된 라이브러리나 코드의 관문으로 다른 프로그래머가 기능을 이용할 수 있게 해준다.


6.2.1 추상화의 활용

추상화 원칙을 따르기 위해서는 내부 상세 구현이 드러나지 않도록 인터페이스를 만들어야 한다. 인터페이스와 구현에는 분명한 구분이 있어야 한다.

 

추상화를 적용하면 코드를 이용할 고객 뿐만 아니라 나 자신에게도 이익이 된다. 고객은 상세구현 내용을 공부하지 않고도 목적하는 기능을 이용할 수 있기 때문에 이익이 되고, 개발자 자신은 코드의 인터페이스를 바꾸지 않아도 내부 구현을 자유롭게 수정할 수 있기 떄문에 이익이 된다. 즉, 업그레이드나 버그 수정이 필요할 때 고객에게 인터페이스 사용 코드를 수정하라고 부담스러운 요청을 하지 않아도 된다.

 

만약, 동적 라이브러리를 사용했다면 고객은 빌드조차 하지 않아도 된다. 종국적으로 보면 작성하는 코드가 어떤 기능을 지원해야 하고, 외부 모듈과 상호작용해야 할 부분은 어떤 것들이 있는지, 인터페이스 디자인을 통해 명확히 할 수 있기 때문에 개발자 자신에게 도움이 크다. 그리고 인터페이스와 구현을 명확히 구분하면, 라이브러리가 의도하지 않은 방식으로 이용되는 것 도 막을 수 있기 떄문에, 예상치 못한 동작을 하거나 오류가 발생하는 것도 피할 수 있다.

 

[ 인터페이스를 디자인 할 때 절대로 세부 구현 사항을 고객에게 노출해서는 안 된다. ]

 

어떤 라이브러리는 클라이언트 코드로 하여금 특정 데이터를 유지하고 인터페이스 간에 주고 받을 수 있게 요구하기도 한다. 이러한 정보는 핸들(handle) 이라고 불리며 함수 호출 간에 특정 인스턴스를 추적하고 기억하기 위한 목적으로 사용된다.

 

만약 라이브러리의 디자인이 핸들을 필요로 한다면 그 핸들의 내부 구조를 노출하지 않도록 해야한다. 핸들의 클래스 타입을 최대한 불투명하게(이러한 클래스 또는 데이터 타입을 각각 opaque class, opaque type 이라고 한다.) 만들어서 프로그래머가 내부 데이터에 접근할 수 없게 한다. 그리고 클라이언트 코드가 핸들의 내부 데이터 멤버를 직접적이든 간접적이든 조작할 수 없게 한다.

 

클라이언트로 하여금 핸들이 가진 내부 변수를 조작하도록 만들어서는 안된다. 잘못된 디자인의 예로, 에러 로그를 켜기 위해 불투명 타입이어야 할 핸들에 대해 특정 멤버 변수를 세팅하도록 요구하는 라이브러리를 들 수 있다.

 

C++는 특성상 추상화 디자인을 제대로 적용하기에 용이하지 않다. C++는 클래스 정의 문법 자체가 pulbic 인터페이스와 함께 private, protected 와 같은 non-pulbic 인터페이스도 함꼐 기술하도록 하고 있다. 이 떄문에 공개하고 싶지 않은 내부 변수와 메서드가 노출되어버릴 수 밖에 없다. 이것은 내부 구현 내용을 공개하는 것과 마찬가지다.

 

추상화는 너무나 중요하기 때문에 디자인 과정 전반에 걸쳐서 고려되어야 한다. 디자인 과정에서 이루어지는 작은 결정마다 추상화 원칙을 만족하는지 되물어보아야 한다. 스스로를 사용자에 두고 인터페이스 각각에 대해 그것을 이용할 때 내부 구조를 알아야 하도록 만들지 않았는지 생각해보아야 한다. 만약 이 원칙에 예외가 필요하다면 극히 드물어야 한다.


6.2.2 재사용성에 최적화된 코드 구조화

디자인 초기부터 재사용을 고려해야 한다. 이러한 전략들은 범용성을 높이는 데 초점을 두고 있다. 재사용서오가 사용 용이성을 높이는 부분은 인터페이스 디자인과 더 밀접한 관련이 있다.


6.2.2.1 관련성이 없거나 논리적으로 분리된 개념이 합쳐지지 않게 한다.

라이브러리나 프레임워크를 디자인할 떄는 하나의 작업 목표 또는 관련된 작업 목표 그룹에만 집중하고, 관계없는 개념들을 합쳐놓아서는 안 된다. (보통 응집성을 높인다[hight cohesion] 라고 말 한다.) 랜덤 넘버 생성과 XML 파서 같이 서로 관계없는 것들을 섞어서는 안된다.

 

비록 재사용과 관련없는 부분을 디자인하더라도 이러한 원칙을 고수하는 것이 바람직하다. 프로그램이 통째로 재사용되는 경우는 매우 드물다. 반면 프로그램의 코드 일부분이나 서브시스템은 다른 애플리케이션에 직접적으로 재사용되거나 조금 다른 사용 시나리오에 활용될 수 있다. 이 때문에 프로그램의 각 부분이 논리적으로 구분된 기능을 갖게 컴포넌트화 하는 것이 프로그램에서의 재사용 가능성을 높이는데 유리하다.

 

이러한 전략은 실세계의 디자인 원칙인 개별적 교체가 가능한 부품과 같다. 예를 들어 오래된 자동차에서 타이어만 떼어내서 다른 모델의 새 자동차에 부착할 수 있다. 타이어는 분리 가능한 컴포넌트로서 자동차의 다른 부분에는 종속되지 않는다. 즉, 타이어를 바꾸기 위해 엔진까지 바꿀 필요는 없다.

 

이러한 논리적 분할 전략은 거시적인 서브시스템디자인은 물론 미시적인 클래스 계층 디자인까지 모두 적용할 수 있다.


프로그램을 논리적 서브시스템으로 분할한다.

서브시스템을 개별적인 컴포넌트로 디자인하여 독립적으로 재사용 가능하게 한다. (보통 종속성을 낮춘다[low couping]라고 말한다.) 예를 들어 네트워크 게임을 디자인한다면 네트워킹과 그래픽 사용자 인터페이스를 각각 별도의 서브시스템으로 만든다.

 

이렇게 함으로써 두 컴포넌트가 서로 간섭받지 않고 재사용될 수 있다. 네트워크를 사용하지 않는 게임을 만들고 싶을 때는 그래픽 사용자 인터페이스 서브시스템만 가져다 쓰면 되고 네트워크 서브시스템은 신경 쓰지 않아도 된다. 그리고 반대로 P2P파일 공유 프로그램을 만들고 싶다면 그래픽 사용자 인터페이스 서브시스템은 재쳐두고 네트워크 서브시스템만 가져다 쓰면 된다.

 

각 서브시스템에 추상화 원칙을 적용하는 것도 잊어서는 안 된다. 각 서브시스템을 작은 라이브러리로 보고 최대한 관련 있는 것 끼리만 모아서 응집도를 높이고 사용하기 쉽게 인터페이스를 만든다. 비록 이러한 작은 라이버리를 사용할 사람이 나 혼자 뿐이라고 해도 기능이 논리적으로 잘 구분되도록 이넡페이스와 구현부가 디자인되어 있다면 분명 이득이 된다.


클래스 계층을 활용해서 논리적 개념을 분리한다.

프로그램을 논리적 서브시스템으로 분할하는 것과 더불어, 서로 상관없는 개념들이 클래스 수준에서 서로 섞이는 것을 피해야 한다. 예를 들어 멀티스레드 프로그램을 위해 균형 이진 트리 데이터 구조를 만든다고 생각해보자. 이 트리 데이터 구조는 한번에 한 스레드만 접근해서 트리 구조를 수정해야 한다. 이를 위해 데이터 구조 자체에 스레드 락킹 기능을 넣으려고 한다. 그런데 만약 이 이진트리가 싱글스레드 프로그램에서 이용된다면 어떻게 될까? 이때는 락킹 기능이 불필요한 실행시간 오버헤드만 유발한다.

 

더 최악의 상황으로는 스레드 락킹을 지원하지 않는 다른 프랫폼에서는 이 이진트리 데이터 구조가 아예 컴파일이 안될 수도 있다. 이러한 문제를 해결하는 한 가지 방법은 클래스에 계층을 추가하는 것이다. 즉, 스레드 세이프한 이진 트리를 일반적인 이진트리의 파생 클래스로 만드는 것이다. 그러면 싱글 스레드 프로그램에서 락킹 오버헤드 없는 이진트리를 이용할 수 있다.

스레드세이프가 필요하면 자식만 쓰면 된다.

이러한 전략은 스레드 세이프와 이진 트리처럼 두 개의 서로 다른 논리적 개념이 있을 때 효과적이다. 개념이 세 개, 네 개 로 늘어나면 점점 복잡해진다. 예를 들어 n차원 트리와 이진 트리에 대해 각각 스레드 세이프 버전과 일반 버전을 지원해야 한다고 생각해보자. 논리적으로 보면 이진트리는 n차원 트리의 특수 케이스 이기 때문에 n차원 트리의 파생 클래스가 되어야 한다.

 

비슷하게 스레드 세이프 데이터 구조도 일반 데이터 구조의 특수 케이스 이기 떄문에 일반 데이터 구조의 파생 클래스 여야 한다.

 

이러한 분할은 선형적인 게층으로 표현할 수 없다.한 가지 가능한 방법은 스레드 세이프 특성을 첨가 클래스로 적용하는 것이다.

이런 계층 디자인을 구현하려면 5개의 서로 다른 클래스를 만들어야 한다. 하지만 기능이 명확하게 구분되기 떄문에 투자할 가치가 있다.

 

클래스 수준에서 그랬듯이 메서드 수준에서도 서로 관계없는 개념이 서로 엮이도록 해서는 안된다. 클래스 수준은 물론 메서드 수준에서도 높은 응집성을 추구해야 한다. 예를들어 메서드 하나에 set과 get작업을 섞어놓아서는 안된다.


어그리게이션을 이용해서 논리적 개념을 분리한다.

어그리게이션은 has- a관계 모델과 같다. 객체가 다른 객체를 내부에 가짐으로써 그 객체가 제공해주는 기능을 수행한다. 어그리게이션을 이용하면 관계가 없거나, 관계가 있더라도 상속으로 풀 수 없는 기능을 분리할 수 있다.

 

예를 들어 Family 클래스를 만들어서 가족 구성원 정보를 담고자 하는 경우, 당연하게도 트리 데이터 구조가 가장 적합하다. 이 때 Family  클래스에 트리 데이터 구조를 직접 구현하는 대신 별도의 Tree 클래스를 이용할 수 있다. Family 클래스에 트리 데이터 구조를 직접 구현하는 대신 별도의 Tree 클래스를 이용할 수 있다. Family 클래스는 단순히 Tree인스턴스를 가진다.

 

객체지향적으로 표현해서 Family는 Tree와 has-a관계가 된다. 이러한 방법으로 트리 데이터 구조를 다른 프로그램에서도 쉽게 재사용할 수 있다.


사용자 인터페이스에 대한 종속성을 제거한다.

데이터 관리 라이브러리를 만든다면, 데이터 조작 부분이 사용자 인터페이스 부분과 분리되기를 바랄 것 이다. 이런 종류의 라이브러리는 어떤 종류의 사용자 인터페이스가 사용될지 가정해서는 안된다. 사용자가 윈도우의 GUI애플리케이션을 사용할 수도 있기 떄문에 cout, cerr, cin, stdout, strderr, stdin 과 같은 텍스트 인터페이스에 의존해서는 안된다.

 

그리고 GUI 애플리케이션에서의 사용만을 가정하더라도 팝업창이나 메시지 박스를 직접 이용해서는 안된다. 그런 부분은 라이브러리를 이용하는 쪽에서 담당할 부분이다. 사용자 인터페이스에 의존성이 있는 경우 사용성이 떨어질 뿐만 아니라 클라이언트 코드로 하여금 에러 상황에 적절하게 대응하거나 조용히 처리할 수 없게 만든다.


6.2.2.2 템플릿을 이용한 범용 데이터 구조와 알고리즘

C++에는 탬플릿 이라는 개념을 통해 내부에서 사용되는 데이터 타입이나 클래스에 독립적으로 적용할 수 있는 범용적인 코드 패턴을 만들 수 있다. 예를 들어 int타입의 배열코드를 작성햇다고 하자. 이어서 또 double 타입의 배열이 필요하다면 같은 코드를 타입만 바꿔서 또 작성해야 한다. 탬플릿은 사전적 의미대로 코드 형판을 의미하는 것으로 코드가 적용될 데이터 타입을 파라미터로 넘겨줌으로써 코드를 재작성하지 않고서도 여러 데이터 타입에 대한 작업을 수행할 수 있다.

 

템플릿은 데이터 구조 뿐만 아니라 알고리즘도 데이터 타입에 독립적으로 만들 수 있게 해준다.

 

가장 간단한 예로는 int타입의 Vector를 만들고 싶을 때, vector<int>라고 선언하고, double로 만들고 싶다면 vector<double>로 선언하는 등의 템플릿 프로그램을 볼 수 있다. 

 

템플릿 프로그래밍은 보통 굉장히 효과적이지만, 너무 복잡해질 수도 있다. 다행이도 데이터 타입을 파라미터와 하는 경우에는 간단하게 템플릿을 만들 수 있다.

 

가능하다면 데이터 구조와 알고리즘을 특정 프로그램의 이용 환경에 종속적으로 만들기 보다는 어디든 사용할 수 있게 범용적으로 만드는 것이 좋다. 균형 이진 트리를 만들면서 임의의 한 객체만 저장할 수 있게 만들어서는 안된다. 범용화해서 어느 데이터 타입이든 저장할 수 있게 해야한다. 그렇게 하면 서점 뿐만 아니라 뮤직 스토어 그리고 운영체제에 까지 균형 이진 트리를 재활용할 수 있다. 이러한 전략이 표준 템플릿 라이브러리 (STL)의 개발 의도임을 잊지 말자.

 

STL은 어떤 데이터타입에도 사용할 수 있는 범용 데이터 구조와 알고리즘을 제공한다.


템플릿이 다른 범용화 테크닉보다 나은 이유

템플릿이 범용 데이터 구조를 만들기 위한 유일한 방법은 아니다.

 

C, C++에서는 타입 지정 없이 void* 포인터를 사용해서 데이터 타입에 독립적인 코드를 작성할 수 있다. 사용자는 데이터 타입을 void*로 캐스팅해서 범용 코드를 사용할 수 있다.

 

하지만 이러한 방법은 타입 세이프(type safe)하지 않다. 즉, 데이터를 이용하거나 저장할 때 그 타입에 목적하는 것과 같은지 검사할 방법이 없다. 데이터를 이용하려면 void*에서 의도했던 타입으로 다시 캐스팅 해야 하는데, 이러한 작업은 원래 데이터 타입이 캐스팅하는 타입과 다를 수도 있기 떄문에 안전하지 않다. 예를 들어 int 타입데이터를 데이터 구조에 이용하는 경우를 생각해보자. 처음에는 int타입을 void* 타입으로 캐스팅하여 데이터 구조에 저장한다. 그런데 어떤 다른 프로그래머가 해당 데이터 구조에 들어있는 데이터 타입이 process객체 라고 잘못 알고서 Process*타입으로 캐스팅하여 Process의 멤버를 이용하려 한다면 프로그램이 예측할 수 없는 오류를 일으킬 것이다.

 

void포인터 외에 또 다른 방법은 공통 상위 클래스를 두는 것이다. 다형성(polymorphism)을 활용하여 특정 클래스의 파생 클래스 라면 해당 데이터 구조를 이용할 수 있게 한다. java는 언어 차원에서 이러한 방법을 이용한다. 모든 클래스는 Object 클래스의 파생 클래스이기 떄문에 컨테이너 클래스가 Object 객체를 대상으로 작성되어 있다면 모든 타입에 대해 대응할 수 있다. 하지만 이러한 방법은 엄밀히 말해서 타입 세이프 하지 않다. 컨테이너에서 객체를 꺼내 쓰려면 그 객체가어떤 파생 클래스 타입을 가졌는지 기억했다가 해당 타입으로 다운 캐스팅 해야 한다.

 

하지만 템플릿은 제대로 작성하면 타입 세이프 하다. 템플릿은 인스턴스화 되고 나면 지정된 타입만 저장할 수 있다. 만약 다른 타입을 저장하려 하면 컴파일 에러가 발생한다. 자바의 경우 C++템플릿과 같은 타입 세이프한 코드 범용화 기능이 없다. 그리고 C++템플릿이 지원하는 타입 세이프한 '제네릭' 개념도 존재하지 않는다.


템플릿의 문제점

템플릿도 완벽하지는 않다.

첫 번쨰로 문법 자체가 혼란스럽다. 특히 템플릿을 처음 접하는 사람이라면 더더욱 그렇다.

두 번째로 코드의 파싱이 어려워서 모든 컴파일러가 C++ 템플릿 표준을 지원하지는 않는다. 만약 사용중인 컴파일러가 C++템플릿 표준을 완전하게 지원하지 못한다면 STL의 일부 기능은 이용할 수 없을 것이다. 반면 어떤 컴파일러가 STL을 모두 지원한다면 그 컴파일러는 거의 대부분의 템플릿 프로그래밍을 소화할 수 있다.

 

템플릿은 한 데이터 구조에 한 타입의 객체만 저장할 수 있고, 여러 데이터 타입을 동시에 적용하기 어렵다. 예를 들어 균형 이진 트리를 템플릿으로 작성했다면, 인트섵느화된 템플릿에 대해 트리 항목으로 Process객체를 넣든지 int값을 넣든지 둘 중 한가지만 할 수 있다. 같은 트리에 Process객체와 int값을 모두 넣을 수는 없다. 이러한 제약은 타입 세이프한 템플릿의 특징 떄문으로, 타입 세이프가 중요하기는 하지만 어떤 사용 케이스에는 심각한 제약이 되기도 한다.


템플릿과 상속

템플릿을 이용해야 할지 상속을 이용해야 할지 결정하기 어려운 때가 있다. 이럴 떄는 다음과 같은 기준으로 생각하면 도움이 된다.

 

  • 서로 다른 타입에 대해 같은 기능을 제공하길 원한다면 템플릿이 유리하다.
    예를 들어 어떤 데이터 타입에도 적용할 수 있는 범용 정렬 알고리즘을 만든다면 템플릿을 이용한다.
    어떤 데이터 타입도 저장할 수 있는 컨테이너를 만든다면 템플릿을 이용한다.
    즉, 데이터 구조나 알고리즘이 모든 타입을 같은 방식으로 취급한다면 템플릿화 하는것이 좋다.

  • 만약, 타입에 따라서 서로 다른 동작을 해야한다면 상속을 이용한다.
    예를 들어 큐와 우선순위 큐와 같이 두 개의 서로 비슷하지만 다른 행태를 보이는 컨테이너를 만들어야 한다면 상속을 이용한다.

  • 템플릿과 상속을 함꼐 이용할 수도 있다.
    템플릿 큐를 만들어서 어느 타입이나 저장할 수 이쎅 하면서 그 템플릿 큐를 상속받아 템플릿화된 우선순위 큐를 만들 수도 있다.

6.2.2.3 오류 검사와 안전장치

안전한 코드를 작성할 떄 사용하는 두 종류의 서로 상반되는 스타일이 있다.

바람직한 방법은 두 방법의 장점을 균형있게 취하는 것이다. 

 

  1. 계약에 의한 설계
    함수나 클래스를 문서화 할 때 클라이언트가 지켜야할 것이 무엇인지 하나하나 계약조건 처럼 기술하는 것이다.
    이 방식은 STL에서 자주 사용된다. 예를들어 Vector는 다음과 같은 계약조건이 있다. vector의 항목에 접근하면서 배열과 동일한 방식으로 항목에 대한 인덱스를 사용할 때는 그 인덱스가 실제 항목이 들어있는 범위를 넘어가는지 검사하지 않는다. 이부분은 클라이언트 코드에서 책임져야 한다.
    인덱스의 경계를 검사하고 싶다면, 배열 방식 대신 at()메서드를 호출해야 한다.

  2. 함수나 클래스를 최대한 안전하게 만들기
    오류검사 코드가 들어가는 것으로, 예를 들어 난수 생성기가 씨드 값으로 양의 정수를 입력받아야 한다면, 사용자가 항상 올바르게 양의 정수를 입력할 것이라 믿지 말고, 값이 올바른지 검사하고 잘못되었다면 에러를 리턴시킨다.

 

오류검사와 안전장치를 구현하는 데는 언어 차원에서 제공되는 기능과 더불어 몇 가지 유용한 기법이 있다.

  1. 에러 코드 또는 false, nullptr을 리턴하거나 클라이언트에 익셉션 호출
  2. 스마트 포인터를 이용하여 동적으로 할당된 메모리를 관리
  3. 세이프 메모리 테크닉 이용

6.2.3 사용성 높은 인터페이스 디자인

재사용성을 높이기 위한 추상화, 구조화와 더불어 한 가지 더 집중해야 할 부분이 다른 프로그래머들을 위한 인터페이스 디자인이다. 내부 구현이 엉망으로 되어 있더라도 훌륭한 인터페이스 뒤에 가려져 있다면 아무도 불만을 제기하지 않는다. 하지만 아무리 구현이 훌륭하더라도 인터페이스가 엉망이라면 문제가 된다.

 

서브시스템이나 클래스는 어떠한 상황에서도, 심지어 프로그램에서 재사용할 계획이 없다고 해도, 좋은 인터페이스를 가져야 한다. 왜냐하면 나중에 정말 재사용될 일이 없다고 장담할 수 없고, 특히 여러 개발자가 팀으로 개발할 때 좋은 인터페이스 없이는 생산성을 높이기 어렵기 떄문이다.

 

인터페이스의 기본적인 역할을 코드를 사용하기 쉽게 만드는 것이다. 하지만 어떤 경우에는 다양한 경우에 범용적으로 적용할 수 있어야 할 수도 있다.


6.2.3.1 사용하기 쉬운 인터페이스 디자인하기.

인터페이스는 사용하기 쉬워야 한다. 그렇다고 사소한것만 담으라는 것은 아니다. 

최대한 단순해야 하고 목적하는 기능을 직관적으로 수행할 수 있어야 한다.

간단한 데이터 구조를 이용하고나 필요한 기능을 찾기 위해 라이브러리 소스 코드를 분석하게 해서는 안된다.


사용하기 쉬운 인터페이스를 개발한다.

사용하기 쉬운 인터페이스를 만드는 가장 좋은 방법은 이미 통용되는 표준이나 익숙한 방법을 채용하는 것이다. 사람들이 무언가를 처음 볼 때는 과거에 사용해봤던 비슷한 것과 사용법이 같을 것이라고 짐작한다. 이 때문에 기존의 것과 비슷한 인터페이스를 가지고 있다면 쉽게 이해하고 받아들이며 문제도 적게 일으킨다.

 

예를 들어 자동차 조향장치를 디자인 한다고 생각해보자. 조이스틱으로 할 수도 있고, 왼쪽과 오른쪽 두개의 버튼으로 할 수도 있고, 슬라이딩 버튼으로 할 수도 있고, 기존과 같은 둥근 헨들을 채용할 수도 있다. 어떤 인터페이스가 가장 사용하기 쉬울까? 어떤 인터페이스를 가진 자동차가 가장 잘 팔릴까? 고객은 둥근 핸들에 가장 익숙하다. 따라서 당연하게도 둥근헨들이 답이다.

 

비록 다른 방법이 좀 더 안전하거나 기술적으로 더 뛰어날 수 있지만, 익숙하지 않은 인터페이스를 가진 자동차를 팔려면 상당한 노력이 필요하다. 새로운 방식과 기존의 표준적 방식 사이에 선택해야 한다면 대부분 사람들은 이미 익숙한 기존 방식을 따르는것이 바람직하다.

 

물론 혁신또한 중요하다. 그런데 인터페이스의 혁신 보다는 구현 내용의 혁신에 집중하는 것이 좋다. 예를 들어 고객이 가솔린 엔진과 전기 모터가 하이브리드된 자동차 기술 혁신에 관심이 많다고 하자. 이러한 자동차는 시장에서 일정 부분을 차지하며 잘 팔릴 것이다.

 

왜냐하면 기존 자동차와 인터페이스가 동이랗기 떄문에, 새로운 방법을 배워야하는 부담 없이 혁신적인 고연비 자동차를 이용할 수 있기 떄문이다.

 

이 이론을 C++에 적용하면, C++프로그래머에게 익숙한 C++표준에 맞춰서 인터페이스를 디자인 해야 한다. 예를들어 C++프로그래머는 클래스 객체를 만들고 제거하려 할때 클래스 생성자와 소멸자를 이용하는데 익숙하다. 클래스를 디자인할 떄는 이러한 표준 사용법을 지키는 것이 좋다.

 

그 클래스를 이용하는 프로그래머는 혼란에 빠질 수 밖에 없다. 다른 C++클래스와 사용법이 다르고 initialize()와 cleanup()이라는 새로운 함수를 배워야 하기 때문이다. 새로운 사용법을 확인할 시간이 없는 프로그래머라면 클래스 객체를 만들고 제거할 때 initialize()나 cleanup()의 호출을 뺴먹고 오류를 발생시킬 것이다.

 

인터페이스를 만들 때는 항상 사용자 입장에서 생각해야 한다. 일반적인 관례와 상식에 맞는지, 사용자의 기대와 다르게 행동하지 않는지 생각해보라.

 

C++에서는 연산자 오버로딩 이라는 기능을 제공한다. 이 기능을 이용하면 객체를 쉽게 사용할 수 있도록 인터페이스를 만들 수 있다. 연산자 오버로딩은 int와 double같은 내장 데이터 타입에서 작동하는 표준 연산자를 클래스 객체에도 활용할 수 있게 해준다. 예를 들어 소수의 클래스 Fraction을 만ㄷ르고 덧셈, 뺄셈, 스트림 출력 연산자를 이용할 수도 있다.

 

연산자 오버로딩을 이용하면 훨씬 사용하기 쉽고 직관적인 인터페이스를 만들 수 있다. 하지만 연산자 오버로딩을 남용해서는 안된다. + 연산자를 뺼셈하도록 오버로딩 하고 - 연산자를 곱셈하도록 오버로딩 하는것도 가능하다. 하지만 그렇게 한다면 직관성을 해치는 결과를 초래할 것이다. 

 

그렇다고 연산자의 원래 의미에만 맞춰서 오버로딩 해야한다는 뜻은 아니다.

예를들어 string클래스의 + 연산자 오버로딩은, 문자열을 이어붙이는데 적용한다. 이것은 분명 문자열의 덧셈에 대해 직관적인 동작이다.


필요한 기능을 뺴먹지 않는다.

이 전략은 두 단계로 이루어진다. 먼저 사용자가 필요라 하는 모든 기능을 인터페이스에 담는다. 얼핏 생각하기에 너무 당연해보인다. 자동차를 만들 떄 속도계를 빼먹고는 사용자에게 속도를 확인하면서 운전하라고 하지는 않는다.

 

마찬가지로, Fraction클래스를 만들면서 분모와 분자에 접근할 메서드를 뺴먹지는 않을 것이다.

 

하지만, 애매모호한 기능이 있을 수 있다. 사용자가 이용할 가능성이 있는, 불명확하지만 잠재적인 요구 사항까지 모두 포함하는 것이 첫 번째 단계다. 만약 인터페이스를 특정 관점에서만 생각한다면 사용자가 조금 다른 방식으로 이용할 때 필요한 기능을 뺴먹을 가능성이 높다. 예를 들어 게임보드 클래스를 만들 때 체스나 바둑만고려하면 윷놀이 처럼 한 위치에 말이 여러 개 들어갈 수 있는 경우는 지원하지 못할 수 있다.

 

당연하지만 모든 경우에 대비한 라이브러리를 만드는 것은 어렵기 떄문에 실현 불가능 할 수도 있다. 그렇다고 모든 케이스를 담아서 완벽한 인터페이스를 만들려고 할 필요는 없다. 미리 생각해본다는 것이 중요하고, 가능한 수준에서 최선을 다하면 된다.

 

다음 단계는 인터페이스에 담을 기능을 최대한 구현하는 것이다. 이 때 라이브러리 자체적으로 알 수 있거나 인터페이스를 달리했더라면 필요하지 않았을 정보를 사용자에게 요구해서는 안된다.

 

에를 들어 라이브러리에서 임시 파일이 필요할 때 사용자한테 임시 파일을 생성할 경로를 물어봐서는 안된다. 사용자는 임시파일이 있든 없든 아무 사오간이 없다. 라이브러리 스스로 임시 파일을 생성할만한 경로를 찾아야 한다.

 

더 나아가서 라이브러리가 스스로할 수 있는 중간 작업을 사용자한테 떠넘겨서는 안된다. 예를 들어 랜덤 생성 라이브러리에서 랜덤 넘버 알고리즘이 상위 비트와 하위비트를 따로 출력할 떄 그 결과를 합친 후 사용자에게 전달하지 않고 사용자가 직접 비트연산을 하게 만들어서는 안된다.


어수선하지 않고 간결한 인터페이스를 제공한다.

빠진 기능이 없도록 하는 데 너무 골몰한 나머지 상상 가능한 모든 기능을 너무 많이 작성하는 경우도 있다. 이런 인터페이스는 필요한 기능을 담고 있더라도 인터페이스가 너무 난잡함으로 사용자가 목적하는 작업을 수힝할 때 어떻게 사용해야 하는지 도대체 알 수가 없다.

 

불필요한 기능이 인터페이스에 있으면 안된다. 인터페이스는 최대한 단순하고 가벼워야 한다. 이러한 요건은 앞서 이야기한 전략과 모순되는 것으로 보일 수도 있다. 그런데 빠뜨리는 것 없이 담는 것과 상상 가능한 모든 것을 담는 것은 분명 다르다.

 

상상 가능한 모든 것을 담는 것은 분명 정상적이지 않다. 꼭 필요한 기능 외에 불필요하거나 생산성을 떨어뜨리는 기능은 뺴는것 이 바람직하다.

 

다시 한번 자동차를 생각해보자. 자동차를 운전할 떄는 몇가지 컴포넌트를 이용한다. 핸들, 브레이크 페달, 가속 페달, 변속 스틱, 백미러, 속도계, 그밖에 이런저런 편의 조정을 위한 버튼과 다이얼이 있다. 이제 자동차가 아닌 비행기를 생각해보자.

 

비행기에는 수백 개의 다이얼, 조정 스틱, 모니터, 버튼이 있다. 자동차 운전에 비교하면 비행기 조종은 거의 불가능하다! 자동차는 비행기보다 인터페이스가 간단하기 떄문에 운전하기 쉽다. 하지만 비행기 조종실의 인터페이스는 매우 복잡하다. 자동차를 운전할 떄는 고도를 확인할 필요도, 관제탑과 통신할 필요도, 날개나 엔진 렌딩기어를 조정할 필요도 없다.

 

라이브러리 개발 측면에서 봤을 떄, 작은 라이브러리는 유지보수하기 편하다. 모두를 만족시킬 수 있는 큰 라이브러리를 만들어야 한다면, 실수의 가능성을 열어놓아야 한다. 모든부분이 서로 연관되어 복잡하게 구현된 코드는 작은 실수 하나에도 라이브러리 전체 기능이 망가질 수 있다.

 

불행하게도 인터페이스를 간결하게 만드는 일은 말로 하기는 쉽지만, 현실에 적용하기는 굉장히 어렵다. 왜냐하면 어떤 부분이 필요하고 어떤 부분이 필요하지 않은지는 주관적이기 때문이다.

 

어떤 부분이 필요하고 필요하지 않은지는 개발자도 판단할 수 있고, 사용자도 판단할 수 있다!


문서와 주석을 제공한다.

인터페이스가 아무리 쉬워도 사용법에 대한 문서는 있어야 한다. 사용법을 알려주지 않고서 프로그래머가 알아서 잘 사용하기를 기대할 수는 없다. 아무리 하찮은 물건을 사더라도 항상 설명서가 따라온다.

 

인터페이스에 대한 문서를 제공하는 데는 두 가지 방법이 있다.

  1. 인터페이스 자체에 주석을 달기
  2. 별도의 문서를 만들기

이 두가지 방법을 모두 지원해야 한다. 운영체제의 API는 별도 문서로만 제공한다. UNIX나 윈도우 헤더 파일은 주석이 거의 지원되지 않는다. UNIX에서는 보통 man 페이지 라 부르는 온라인 메뉴얼로 제공하고 윈도우에서는 통합개발환경(intergrated development environment[IDE])의 도움말 시스템에 내장되어 있다.

 

대부분 API나 라이브러리는 인터페이스 코드에 주석을 제공하지 않지만, 주석을 통한 문서화는 사실 매우 중요하다. 주석 없이 코드만 있는 헤더파일을 제공해서는 안된다. 별도 문서에 제공된 내용과 주석이 중복된다고 하더라도 주석은 중요하다.

 

코드에 남겨진 주석은 프로그래머가 직접 남긴 흔적으로 친근함을 주며 많은 훌륭한 프로그래머가 아직 선호하고 있다.

 

소스 코드 주석이든 별도의 문서든, 라이브러리의 구현이 아니라 행동에 대해 설명하고 있어야 한다. 여기서 행동은 입력, 출력, 오류 조건과 그 처리방법, 주요 사용 케이스, 그리고 성능 스펙 등을 의미한다. 예를 들어 랜덤 넘버를 생성하는 라이브러리 설명서라면 입력 파라미터가 없다는 것과 사전에 설정한 범위의 정수를 리턴한다는 것을 설명해야 하고 오류 상황에서 발생하는 익셉션 목록을 나열해야 한다.

 

하지만 랜덤 넘버 생성 알고리즘이 일차 합동식(linear congruence)을 어떻게 활용하는지 등 상세 구현 내용을 설명할 필요는 없다. 구현 내용을 과도하게 많이 설명하는 것은 인터페이스 설명서의 가장 흔한 실수중 하나다. 인터페이스와 구현이 완벽하게 분리되었지만, 인터페이스의 주석이 인터페이스 사용자보다 라이브러리 유지보수 담당자에게 적합하게 되어 있어 안타까운 경우를 자주 볼 수 있다.

 

당연하지만 내부 구현에 대한 문서화도 분명 필요하다. 단, 사용자용으로 공개된 인터페이스를 설명하는 자료에 활용해서는 안된다.


6.2.3.2 범용 인터페이스 디자인

인터페이스는 여러 가지 작업에 모두 적용할 수 있도록 충분히 범용적이어야 한다. 만약 특정 애플리케이션에 종속적인 형태로 되어있다면 다른 목적으로는 사용하기 어려울 것이다. 

 

다음은 범용 인터페이스를 디자인하기 위한 가이드 라인이다.


같은 기능을 수행하는 여러 가지 방법을 제공한다.

모든 고객을 만족시키려면 어떤 때는 동일 기능을 수행하는 여러 가지 방법을 제공하는 것이 필요하다. 하지만 조심해서 적절한 수준을 지키지 못하면 인터페이스 자체가 어수선해질 수도 있기 떄문에 주의해야 한다.

 

자동차 디자인을 생각해보자. 오늘날 대부분의 신차는 원격키 기능을 제공한다. 이 기능을 이용하면 원거리에서 버튼을 누르는 것 만으로도 문을 열 수 있따. 원격키를 이용할 수 있음에도 모든 자동차가 물리적으로 열쇠를 삽입하고 돌려야하는 표준 키를 동시에 지원하고 있다. 비록 두 기능이 중복되지만 원격키의 배터리가 방전되었을 때도 문을 열고 시동을 걸 수 이썽야 하므로 운전자는 두 가지 기능을 모두 필요로 한다.

 

프로그램 인터페이스 디자인에도 비슷한 상황이 있을 떄가 있다. 예를 들어 메서드 중 하나가 문자열을 받아들인다고 하자. 이때 C++의 String객체를 받는 인터페이스와 더불어 C스타일의 문자열 포인터를 받는 인터페이스도 지원하고 싶을 수도 있다. 두 데이터 타입 간에 변환이 가능 하지만, 프로그래머의 성향이나 처한 환경에 따라 선호하는 방식이 다를 수 있기 때문에 두 방법을 모두 지원하는 것이 도움 될 수 있다.

 

이러한 전략은 간결한 인터페이스의 예외임을 명심해야 한다. 이러한 예외가 적용될 만한 경우는 그리 많지 않다. 대부분의 상황에서는 간결한 인터페이스가 더 중요하다.


커스터마이즈를 지원한다.

인터페이스의 유연성을 높이려면 프로그래머의 개별 상황에 맞추어 커스터마이즈 할 수 있도록 해야한다. 커스터마이즈는 에러 로글르 껐다 켜는 것처럼 아주 단순한 것일 수도 있다. 커스터마이즈의 기본은 사용자에게 모든 기능을 제공하되 필요에 따라 인터페이스의 기능을 조금 다르게 바꿀 수 있는 통로를 제공하는 것이다.

 

함수 포인터나 템플릿 파라미터를 이용해서 높은 수준의 커스터마이즈를 지원할 수도 있다. 예를 들어 사용자가 에러 핸들링 루틴 자체를 자신의 것으로 바꿔치기 하도록 할 수 있다. 데커레이터 패턴은 이러한 기능을 지원하기 위한 기법이다.

 

 STL은 커스터마이즈 전략을 극단적으로 활용하고 있다. STL에서는 사용자가 각 컨테이너의 메모리 할당자를 사용자가 만든 것으로 바꿔치기 할 수 있도록 하고 있다. 단, 이 기능을 사용하려면 메모리 할당자 객체를 STL에서 가이드하는 인터페이스에 맞추어서 구현해야 한다. 

 

STL의 각 컨테이너는 할당자를 템플릿 파라미터로 넘겨받고 있다.


6.2.4 사용성과 범용성의 조화

사용성과 범용성이라는 두 목표는 서로 충돌될 떄가 있다. 범용성을 높이면 인터페이스가 복잡해지는 경우가 많다. 예를 들어 지도 프로그램에서 도시를 저장할 용도로 그래프 데이터 구조가 필요하다고 하자. 범용성을 우선시한다면 그래프 데이터 구조를 템플릿으로 만들어서 도시 뿐만 아니라 어떤 타입도 저장할 수 있게 해야한다. 그렇게 하면 다음에 네트워크 시뮬레이터 프로그램을 만들 때 같은 그래프 데이터 구조를 라우터 위치 저장용으로 재활용할 수 있다.

 

하지만, 템플릿 이용 덕분에 인터페이스를 이용하기 더 까다로워 졌다. 윽히 사용자가 템플릿에 익숙하지 않다면 매우 어렵게 느낄 것이다.

 

그렇다고 사용성과 범용성이 항상 서로 배치되는 것은 아니다. 범용성이 사용성을 저해하는 경우가 있기는 하지만 두 경우를 모두 만족하도록 인터페이스를 디자인할 방법이 있다.


6.2.4.1 복수의 인터페이스를 제공한다.

인터페이스가 복잡하지 않게 하면서도 많은 기능을 지원해야 한다면 두 개의 분리된 인터페이스를 만든다.

예를 들어 범용 네트워킹 라이브러리에서 게임에 유용한 인터페이스와 HTTP와 같이 웹 서비스에 유용한 인터페이스 두 가지를 따로따로 제공할 수 있다.


6.2.4.2 자주 사용되는 기능을 쉽게 제공한다.

범용 인터페이스를 제공할 떄 어떤 기능은 다른 기능보다 더 자주 이용될 수 있다. 이럴 때는 자주 사용되는 기능은 사용하기 쉽게 만들고 그렇지 않은 기능은 옵션으로 제공한다. 예를 들어 지도 프로그램에서 각 도시의 이름을 지도에 표시할 때 사용할 언어의 종류를 선택할 수 있게 하고 싶다고 하자. 영어는 많이 사용됨으로 기본으로 지원하고 다른 언어는 부가적인 옵션으로 처리할 수 있다.

 

이렇게 하면 대부분의 사용자는 언어 설정에 대한 고민 없이 기본 언어를 사용하면 되고, 언어 변경이 필요한 사용자는 조금 복잡하지만, 별도의 옵션 설정을 통해 목적하는 언어를 사용할 수 있게 된다.


6.3 요약

재사용 가능한 코드는 범용성과 사용 편의성을 모두 갖추어야 한다.

재사용 코드를 디자인할 떄는 추상화를 통해 코드를 적절히 구조화하고 좋은 인터페이스를 만들어야 한다.

 

인터페이스에 관련된 6가지 전략

  1. 사용하기 쉽게 할 것
  2. 필요한 기능을 빠뜨리지 말 것
  3. 어수선하지 않고 간결하게 할 것
  4. 문서와 주석을 제공할 것
  5. 같은 기능을 수행하는 데 여러가지 방법을 제공할 것
  6. 커스터마이즈를 지원할 것

 

상호 배치되기 쉬운 범용성과 사용성을 조화시키기 위한 두가지 팁

  1. 복수의 인터페이스를 지원해야 한다.
  2. 자주 사용되는 기능을 쉽게 쓸 수 있게 해야한다.

 

 

 

728x90