본문 바로가기
IT 공부/객체지향 설계 공부

코드 컴플리트 5장 - 구현 설계 요약

by exdus3156 2023. 11. 23.
※5장은 설계와 관련된 실천법을 다룬다. 설계 방식과 그 정도가 모든 프로젝트에서 동일하게 취급되지 않는다는 점에 유의하라. 어떤 작은 프로젝트에서는 설계란 곧 코드를 입력하는 것이다. 어떤 프로젝트는 바로 코드로 작성할 수 있을 만큼 상세하게 설계하기도 한다. 많은 프로젝트에서 UML을 그리거나, 혹은 그리지 않고 의사코드로 만족하기도 한다. 설계 작업은 프로젝트마다 그 정도와 방식이 다르다. 그럼에도 설계와 관련된 실천을 익힌다면 어떤 방식과 강도로 설계 작업에 착수하든 도움이 될 것이다.

 

1. 설계의 어려움

1-1. 설계는 불명확한 문제다.

완벽한 설계란 없다! 설계자가 요구 사항으로부터 완벽하게 설계를 완성한 뒤 구현으로 넘어가는 것은 불가능한 기대다. 지금껏 그 어떤 소프트웨어도 완벽한 설계를 바탕으로 코딩된 적이 없으며, 앞으로도 그럴 것이다. 설계가 이토록 불완전한 이유는 그것이 본질적으로 "불명확한 문제"에 속하기 때문이다.

불명확한 문제란, 일단 일련의 문제를 해결해야만 문제 속에 숨은 문제를 발견할 수 있는 문제를 말한다. 설계가 이런 유형에 속한다. 설계와 구현을 시도해보지 않으면 설계 상의 숨은 문제를 발견할 수 없다.  

 

1-2. 설계는 엉성한 프로세스다. 

설계를 완료했다고 말할 수 있는 기준은 무엇일까? 안타깝게도 그런 기준은 없다. 설계는 완성 시점이 불명확하다. 사람마다 설계 결과도 다르다. 결국 적당한 수준에서 타협한 후 구현으로 넘어가야 한다. 그 과정에서 실수를 발견하게 되고 다시 설계로 되돌아가 오류를 수정한다.

즉, 실수를 하는 것이 설계의 핵심이다. 이를 "발견적 학습"이라고 부른다. 설계는 "작동해보는지 시험해보기" 프로세스에 가깝다. 만능 설계 기법은 없다.

 

1-3. 설계는 절충과 우선순위의 문제다.

프로젝트의 제한 조건과 목표의 우선순위에 따라 설계의 형태는 천지차이다. 빠른 응답 속도가 중요한 프로젝트라면 그에 맞는 설계를 해야 한다. 만약 빠른 응답 속도보다 빠르게 서비스를 배포하는 것이 더 우선해야 한다면 개발 시간을 단축하는 설계를 해야 한다. 프로젝트가 우선해야 할 조건에 따라 같은 소프트웨어라도 설계의 결과는 다르다.

 

 

2. 핵심 설계 개념

2-1. 소프트웨어의 주요 의무 : 복잡성 관리

2-1-1. 본질적 어려움과 비본질적 어려움

브룩스는 소프트웨어 개발이 어려운 이유를 두 가지로 나눈다. 하나는 소프트웨어 개발 자체에 있는 본질적 어려움이고, 다른 하나는 부수적이고 우연적인 요소로 인한 비본질적 어려움이다. 

비본질적인 어려움의 예시로 프로그래밍 언어의 가독성, 다중 사용자가 가능한 운영체제인지 아닌지, IDE의 사용 편의성, 프레임워크 등이 있다. 브룩스는 이런 문제들은 대부분 해결되었다고 말한다.

실제로 지금도 개발 툴은 발전하고 있다. 갈수록 뛰어난 신입 개발자가 많아지는 이유는 소프트웨어 개발의 비본질적인 문제가 많이 해결되었기 때문이다.

그러나 본질적인 어려움은 잘 해소되지 않는다. 소프트웨어 개발이란 복잡한 구조로 서로 연관된 개념들에 대한 세부 사항들을 파악해 문제를 정의하고 해결하는 작업이기 때문이다.

소프트웨어 개발의 본질은 복잡하고 무질서한 현실 세계와 상호작용하고, 종속 관계나 예외 상황을 정확하게 규명해내며, 대충이 아니라 시스템에 맞는 정확한 솔루션을 창조해야 한다. 실제 세계가 어떻게 돌아가는지 파악하고, 거기서 정확한 의미와 구현 사항을 추출하는 것이다.

즉, 소프트웨어의 본질적인 어려움은 현실 세계가 복잡한 이상 지속적으로 존재할 것이다. 이 모든 문제의 핵심은 "세상의 복잡성"에 있다. (이러한 본질적 어려움은 남이 작성한 코드를 읽고 이해하는데 어려움을 주는 요소이기도 하다. -> 링크)

 

2-1-2. 복잡성 관리의 중요성

소프트웨어 개발 실패의 대부분은 요구사항 판단이나 계획 수립 실패, 비용 초과 등 비즈니스적인 요소로 발생한다. 그러나 간혹 기술적으로 실패할 때도 있다. 보통 그 원인은 복잡성 관리 실패에서 온다.

프로젝트에 투입된 개발자가 자신이 수정하고 있는 코드를 변경했을 때 무슨 일이 벌어질지, 그 변경이 어떤 변경을 초래할지, 그래서 그것이 위험한지 아닌지 확신할 수 없다면 기술적으로 실패한 것이다. 즉, 복잡성을 관리하지 못하면 코드 변경이 어떤 위험이나 결과를 초래할지 분석과 예측이 불가능해지는 것이다.

소프트웨어 공학과 관련된 논의의 대부분은 바로 이 주제(복잡성 관리)에 대한 것이다. 

여기서 오해하지 말자. 소프트웨어는 간단하게 기술될 수 없다. 복잡성을 관리한다는 말의 의미는 소프트웨어 전체를 간단하고 이해하기 쉽게 만들라는 뜻이 아니다. 그건 불가능하다. 복잡성 관리란, 복잡도를 수용하되 한 번에 한 부분에만 집중할 수 있도록 프로그램의 구성 요소를 독립적으로 분리하고 통합하라는 뜻이다. 

시스템의 복잡도 증가는 피할 수 없다. 소프트웨어가 해결하려는 현실 세상이 복잡하기 때문이다. 이 세상에 그 어떤 개발자도 소프트웨어 전체를 구성하는 모든 객체 간 의존 및 구현 세부 사항을 한 번에 머릿속에 집어 넣고 다닐 수 없다. 그래서 추상성이 강조된다. 높은 수준에서는 저수준의 구현 사항을 무시하고 추상화된 개념으로 문제를 분할하고, 저수준에서는 독립적인 한 부분의 일부만 신경쓴다.

 

2-2. 바람직한 설계의 특징

아래는 바람직한 설계의 특징을 나열한 것이다. 모두 지키면 좋겠지만 프로젝트 상황에 따라 서로 충돌하는 부분도 있다. 대략적인 지침으로 받아들이자.

2-2-1. 복잡성 최소화

간단하고 이해하기 쉽게 설계하라. 즉, 기능적으로 뛰어나지만 너무 재치가 넘쳐 아무도 쉽게 이해하지 못하는 설계는 피해야 한다.

2-2-2. 느슨한 결합

프로그램 각 부분 사이의 연결을 느슨하게 만들어야 한다. 인터페이스의 추상화, 캡슐화, 정보 은닉과 같은 방법을 사용한다. 이 모든 것들은 프로그램의 구성 요소를 독립적인 부분으로 분할하기 위해서다. 결합도가 높으면 한 요소의 변경이 다른 요소의 변경으로 파급된다.

2-2-3. 높은 팬인과 낮은 팬아웃

높은 팬인(fan in)이란 특정 클래스를 사용하는 클라이언트가 많다는 뜻이다. 높은 팬인(fan in) 클래스란 많은 클래스들이 의존하고 있다는 뜻이므로 오히려 변경에 취약하다고 생각할 수도 있으나, 높은 팬인은 대부분의 경우 재사용성이 좋게 설계되었다는 결과에 가깝다.

낮은 팬 아웃(fan out)이란 특정 클래스가 사용하는 다른 클래스의 수가 적다는 뜻이다. 의존하는 클래스가 적어야 변경으로부터 클래스를 보호할 수 있다. 만약 의존하는 클래스 수가 많다면 하나의 클래스만 변경되어도 수정 압박을 받을 것이다.

2-2-4. 간결성

간결성은 불필요한 부분이 없다는 것을 뜻한다. 불필요한 부분이 없는 설계를 기준으로 삼아야 전체적인 구조가 간단하고 명확해진다.

2-2-5. 계층화

시스템을 특정 계층에서 추상적으로 바라볼 수 있도록 해야 한다. 시스템이 하나의 계층으로 구성된다면 대규모 시스템을 이해하기 위해서는 구성 요소의 모든 것과 그 사이의 상호 작용을 이해할 수 있어야 하는데, 이것은 버거운 일이다.

2-2-6. 표준화

디자인 패턴과 같이 프로젝트 참여 개발자가 이해하고 공유하고 있는 검증된 패턴을 사용하라.

 

2-3. 설계 수준

2-3-1. 첫 번째 계층 : 소프트웨어 시스템

작은 시스템의 경우 소프트웨어 시스템 관점을 따지지 않는 경우도 있지만, 보통 시스템은 제일 먼저 서브시스템이나 패키지와 같이 상위 수준의 논리적 조합부터 고려한다.

 

2-3-2. 두 번째 계층 : 서브시스템과 패키지

전체 시스템을 사용자 인터페이스, 데이터 저장소(데이터베이스), 그래픽, 엔터프라이즈 도구, 비즈니스 규칙, ... 등 큼지막한 구조로 나누는 것이다. 각 서브 시스템마다 서로 다른 구현 방식을 사용할 수 있으므로 서로 어떻게 소통할 것인지 규명하는 것이 중요하다.

여기서 중요한 점은 서브시스템간 순환 의존성을 피하고, 전체적인 커뮤니케이션의 복잡도를 줄이는 것이다. 모든 요소가 다른 모든 요소와 메시지를 주고 받을 수 있다면 시작부터 높은 복잡도로 인해 각 서브 시스템의 구현 작업이 힘들어질 수도 있다. 왜냐하면 구현 세부 사항의 변경이 초래할 위험이 다른 모든 요소로 전파될 수 있기 때문이다. 이 경우, 해당 요소를 다른 시스템에 이식하는 것도 불가능할 것이다.

작은 프로젝트의 경우 이 단계는 건너뛸 수 있다. 그럼에도 건너뛰고 있다는 사실을 인지하고 있어야 한다.

 

2-3-3. 공통 서브시스템

서브시스템의 경우 대부분의 소프트웨어에 적용되는 일반화된 요소들이 이미 개념적으로 존재하며, 많은 소프트웨어 툴과 라이브러리들이 이 용어를 따르기 때문에 식별하기도 어렵지 않다.

  • 비즈니스 규칙
  • 사용자 인터페이스
  • 데이터베이스 및 접근
  • 시스템 의존성 : 이것은 하드웨어 및 OS 종속석을 따로 관리한다. MS 윈도우 프로그램을 코딩하는 경우 윈도우의 시스템 API를 호출할 것이다. 그러나 굳이 마이크로소프트 윈도우에 종속된 프로그램으로 한정할 필요는 없다. 시스템 의존성을 따로 관리해서 핵심 로직을 분리하면 나중에 맥이나 리눅스에서도 동작하는 프로그램을 작성할 수 있다.

 

2-3-4. 세 번째 계층 : 클래스 분할

분할된 시스템을 독립적으로 구현하기 전에 먼저 클래스로 구체화하는 작업이다. 클래스의 인터페이스를 식별하고 설계해야 한다. 자세하게 분해하는 것이 좋다. 작은 프로젝트의 경우 이 작업이 곧 설계 시작 단계로 인식되곤 한다.

※ 참고로, 이 책에서는 객체와 클래스를 엄격하게 구분하지 않지만, 사실 클래스와 객체는 확실하게 구분되는 서로 다른 개념이다. 책에서는 데이터베이스 스키마와 인스턴스의 차이와 같다고 설명한다. 객체는 다른 말로는 엔티티(entity)라고 불리며, 프로그램을 실행할 때 생성되는 구체적인 개체다. 클래스는 그 객체를 분류할 수 있는 타입(type)이며 정적인 코드로 식별된다. 

 

2-3-5. 네 번째 계층 : 루틴 분할

객체지향 설계로 인터페이스가 식별되면 해당 인터페이스를 구현하기 위해 루틴을 구현한다. 메시지 인터페이스와 메소드가 동일하게 구성되는 경우도 있으나, 복잡한 책임의 경우 내부적으로 루틴을 나눠야 한다. 이렇게 분리된 루틴의 경우, 공용 인터페이스 뒤에 숨어 private 접근 제어자로 관리된다.

루틴 분할은 구현 단계에 속한다. 따라서 구현 단계에 이르러서 새로운 문제가 식별되거나, 혹은 메시지의 규격을 수정해야 할 일이 생길 수도 있다. 그 때는 다시 클래스 책임 식별 단계로 돌아가 지속적으로 설계를 수정해야 한다. 계층 구분은 단계적으로 진행되지 않고 이렇게 피드백을 주고 받으며 서서히 창발되는 프로세스다.

 

2-3-6. 루틴 설계 및 구현

코딩 작업이다. 실질적으로 프로그래밍 언어를 통해 구현을 하는 작업이며, 개발자의 전형적인 작업이라 할 수 있다. 너무 익숙한 작업이기 때문에 오히려 무의식적으로 엉터리로 수행하는 경우도 있다. 그래서 이 책에 소개된 고급 루틴 작성에 대한 조언과 방어적 프로그래밍에 대한 학습은 꼭 필요하다.

 

 

3. 설계 빌딩 블록: 발견적 학습

개발자란 코딩을 하는 작업으로 흔히 생각되기 때문에, 많은 개발자들의 성격이 명령대로 정확히 수행되고 그 어떤 오류나 회색 지대가 없는 완벽한 정답 구현에 흥미를 느끼는 것은 당연하다. 하지만 소프트웨어 설계 단계에서는 이러한 성질의 정답이 없다. 소프트웨어 설계에 대해서는 알고리즘 구현과는 다른, 색다른 관점이 필요하다.

 

3-1. 현실 세계의 객체에서 시작하라.

현실 세계의 객체에서 소프트웨어 객체를 모델링을 하는 것은 객체지향의 정석이다. 

 

3-1-1. 객체와 객체의 속성을 식별한다.

소프트웨어 객체는 종종 현실 세계의 엔티티에 거의 흡사한 구조를 띄기도 한다. 속성을 식별하라는 것은 구체적인 데이터와 프로세스를 구현하라는 것이 아니라, 현실 세계에서 엔티티와 속성을 연결해 객체의 정체를 식별하라는 뜻이다.

속성은 시스템 목표에 따라 다르다. 청구 시스템의 경우에 직원(employee) 객체는 이름과 청구율을 속성으로 가질 수 있다. 고객(client) 객체는 이름, 청구서, 계좌를 갖는다. 청구서(bill) 객체는 청구 금액, 고객, 청구 날짜 등을 가진다.

이 조언은 반드시 따라야 할 필요는 없다. 소프트웨어 세계에만 존재하는 더 좋은 객체가 존재할 수도 있다. 검증된 디자인 패턴을 사용할 수도 있다. 그러나 도메인에 대한 지식을 연결하는 것은 종종 소프트웨어 시스템을 이해하는데 도움을 준다.

 

3-1-2. 객체 간 관계를 식별한다.

각 객체가 할 수 있는 것에 대한 직관적인 감각을 토대로 각 객체가 어떤 관계를 맺고 있는지 연결할 수 있다. 예를 들어 청구서(bill) 객체는 속성으로서 고객(client) 객체를 포함할 수 있을 것이다. 직원(employee) 객체는 청구서 객체를 생성할 수도 있고, 청구서 객체는 고객에게 지불 요청을 할 수 있다. 

보통 시스템의 목표(유스케이스)에 따라 다른 객체와의 상호작용(메시지 전송과 책임 식별)을 설계한다. 객체가 수행하는 책임과 메시지 인터페이스 설계는 반복적으로 수행되어 더 나은 협력 구조를 점진적으로 설계해나간다.

 

3-2. 일관성 있는 추상화

복잡성 관점에서 추상화란 관련 없는 세부 사항을 무시하고 문제 영역의 본질적인 부분만을 다룬다. 그런데 대부분의 현실 세계는 이미 어느 정도 추상화되어 있다. 사람들은 본능적으로 끊임없이 추상화를 하기 때문이다. 

훌륭한 개발자는 루틴 수준, 클래스 수준, 패키지 수준 등, 단계적으로 일관적인 추상화를 제공함으로써 안전하고 이해하기 쉬운 시스템을 프로그래밍한다. 

 

3-3. 구현 세부 사항을 캡슐화하라.

추상화와 캡슐화는 다르다. 캡슐화는 추상화 다음에 수행된다. 추상화가 객체의 역할을 높은 수준에서 바라볼 수 있게 해준다면, 캡슐화는 그러한 수준에서 더 나아가 더 깊은 수준까지 이해하려는 모든 시도를 방지하는 개념이다.

 

3-4. 상속이 설계를 단순화할 수 있을 때 상속하라.

상속은 설계를 단순화할 때만 사용하면 좋다. 즉, 상속으로 인해 설계가 더 복잡해진다면 상속은 되도록 지양하는 편이 좋다. 

상속이 좋은 경우는 객체의 개념 상 서로 비슷한 구석이 있어 공통점을 추출할 수 있고, 그러한 공통점을 바탕으로 객체 간 협력 구조가 생기는 경우다.

예를 들어, 회계 프로그램에서 직원을 대상으로 급여를 계산하는 경우를 상상해보자. 모든 직원은 급여를 계산할 수 있다. 그런데 계약직 직원정규직 직원의 급여 계산은 수행 방식이 다를 수 있다. 하지만 구현 세부 사항은 다르더라도 수행하는 책임 자체는 동일하다. 급여 계산 인터페이스는 고용형태와 전혀 무관하기 때문이다. 만약 소프트웨어가 직원이 정규직이냐 계약직이냐에 따라 공통 요소 없이 완전히 다른 종류의 책임을 요구한다면 구태여 공통 클래스를 추출할 필요는 없다. 그러나 이 예시의 경우 급여 계산은 직원이라면 모두 처리할 수 있어야 하므로 상속이 설계의 단순화를 가져 온다.

 

3-5. 정보 은닉

정보 은닉은 내부 복잡성이 외부로 전파되지 않게 막는 방법이다. 복잡성 관리에 아주 강력하면서도 필수적인 전략이므로 반드시 숙지해야 한다.

 

3-5-1. 정보 은닉의 예시

각 객체의 식별자로 고유한 id를 가지는 객체를 상상해보자. id는 int로 구현되었고, 새로운 객체가 생성될 때마다 내부적으로 값을 1 올려서 저장한다.

이러한 방식은 문제가 될 여지가 많다. 나중에 특별한 목적으로 특정한 범위의 ID를 가진 객체를 따로 제외하고 싶다거나, 보안 문제로 인해 int에서 string 문자열로 데이터형이 달라진다면 어떻게 될까? 또한 ID 데이터 접근 시 다중 스레드로 접근하는 경우 생기는 문제를 어떻게 관리할 것인가?

ID는 객체로서 식별되어야 한다. "고유한 ID"라는 추상화된 개념과 연산을 제공해주는 객체로 식별되어야 한다. ID를 사용하는 개별 객체는 ID 객체를 통해 고유한 ID 값을 받을 수 있다. ID 객체는 어떻게 고유한 아이디를 생성하는지 외부로 알리지 않는다. 심지어 어떤 데이터 타입을 사용해 id를 구분하는지도 외부로 드러내면 안 된다. 따라서 ID 객체는 타입(type)으로서 각 객체는 ID 객체를 그대로 속성으로 사용할 것이다.

 

3-5-2. 정보 은닉의 장애물

정보가 지나치게 배분되는 경우 정보 은닉이 파괴된다. 대표적인 예시로 전역 데이터 사용이 있다. 전역 데이터 대신 해당 데이터를 객체로 만들어 접근 루틴을 따로 만드는 것이 좋을 것이다.

순환 의존성도 정보 은닉의 장애물이다. 정보 은닉 자체가 인터페이스 뒤로 구현 사항을 숨기는 것인데, 순환 의존 구조에서는 논리적으로 이상한 상황이 발생한다. 구현 세부 사항 안에 이미 다른 클래스의 메소드가 포함되어 있으며, 그 메소드가 다시 자신을 포함해 사용한다. 즉, 구현 세부 사항을 변경하는 순간 해당 변경이 초래할 문제점이 순환하면서 지속적으로 자기 자신으로 돌아온다.

너무 큰 클래스를 사용하는 것은 사실상 전역 데이터를 사용하는 것과 다름 없으므로 정보 은닉에 방해된다. 클래스의 루틴은 기본적으로 클래스 내부 데이터에 접근할 수 있다. 따라서 클래스의 규모가 크면 해당 범위 안에서 클래스의 데이터는 사실상 전역 데이터로 기능한다. 클래스의 데이터를 변경하려고 하면 그 여파가 모든 루틴에 영향을 끼칠 수 있다. 끔찍한 일이다.

성능 문제에 대한 지나친 걱정도 장애물의 일종이다. 정보 은닉을 위해 간접적으로 데이터를 쿼리해서 얻어야 하는 경우, 이러한 호출 명령어 및 함수 스택과 관련한 성능 저하를 걱정해 private을 풀어버리는 설계 지침도 있다. 그러나 이러한 걱정은 성급하다. 성능 최적화는 섣부르게 하는 것이 아니다. 나중에 테스트를 거치면서 병목 현상을 발견하고, 그러한 문제를 다른 시스템에 영향을 주지 않고 해결하는 것이 정석이다. 

 

3-5-3. 정보 은닉의 가치

정보 은닉은 객체지향 종속적인 개념이 아니라 프로그래밍 패러다임과 상관이 없는 기본적인 전략이다. 객체지향 설계이기 때문에 정보 은닉이 사용되는 것이 아니다! 예를 들어 ID 타입의 경우, 우리가 ID 타입을 객체로 만든 이유는 그것이 도메인 모델링을 통해 객체로 식별되어서가 아니다. 정보 은닉을 위해서였다.

순수한 객체지향 설계 원칙만 따진다면 정보 은닉이 지켜지지 않을 수 있는 것이다. 실제로 객체지향적 사고만으로는 정보 은닉은 달성되기 어렵다. 그래서 많은 개발자들이 클래스의 정보를 보호할 수 있도록 적절히 추상화된 인터페이스를 설계할 수 있는데도 정보 은닉을 따로 고려하지 않아 잘못된 인터페이스를 전혀 눈치채질 못하곤 한다.

무엇을 숨겨야 하지? 라고 질문하는 습관을 갖자. 설계 문제의 상당수가 해결될 것이다. (p.101)

 

3-6. 변경될 것 같은 영역을 찾아라

변경될 수 있는 확률이 문서에 있을 수도 있고, 개발자 개인의 직감적인 경험에 기초할 수도 있다. 이렇게 변경이 예상되거나, 혹은 분석할 수 있다면 그 항목을 고립시키는 것이 낫다. 아래는 일반적으로 변경 가능성이 크다고 알려진 것들이다.

  • 비즈니스 규칙 : 급여 계산 방식이 변하거나, 조세 관리 시스템에서 조세 구조가 변경된다거나, 보험 회사가 보험요율 산정 규칙을 변경하는 등 비즈니스 규칙에 기반을 둔 로직은 차후 변경될 소지가 있다.
  • 하드웨어 의존성 : 화면, 키보드, 마우스, 디스크 등 장치에 대한 부분도 변경될 수 있다. 특히 하드웨어 환경이 불안정하거나 테스트가 불가능할 때 시뮬레이터를 사용해 테스트하는 경우도 있다. 이러한 유형의 테스팅을 위해서라도 하드웨어 의존 부분을 고립시키는 경우도 있다.
  • 입력과 출력 : 소프트웨어적 성격의 입출력을 말한다. 하드웨어와는 달리 입출력 포맷이 변경되는 경우도 있다. 예를 들어 파일의 포맷이 변경되는 경우다.
  • 표준을 따르지 않는 기능 : 현재 사용 중인 프로그래밍 언어에서 표준으로 사용하지 않는 기능을 사용하는 경우, 다른 시스템에 이식하는 순간 망가질 위험이 있다. 환경에 따라 사용이 불가능할 수도 있는 라이브러리를 사용하려면 되도록 확장 기능을 별도의 클래스로 고립시켜라.
  • 구현이 어려운 부분 : 구현이 쉽지 않거나 효율적인 구현 아이디어가 포착되지 않는 경우, 다시 구현하거나 수정해야 할 필요성이 있으므로 고립시키는 것이 좋다.
  • 상태 변수 : 상태 변수를 되도록 데이터 타입을 고정하지 않고 열거형(enum)과 같은 추상 타입을 사용하는 것이 좋다. 간단한 상태라고 생각해서 두 가지 케이스만 있는 boolean 형을 사용하다가 새로운 상태가 추가되는 경우 변경이 쉽지 않다. 또한 상태 변수를 전역 데이터로 사용하는 대신 접근 루틴을 따로 개발하는 것이 더 안정적이다.
  • MAX나 MIN 값은 유연하게 : 간혹 배열의 크기 등 값 제약이 필요할 때 그 값을 미리 특정 값으로 고정하기도 한다. 그러나 MAX와 같은 상수를 통해 유연하게 제어하는 것이 좋다. 

 

위 항목처럼 이미 널리 알려진 "변경이 예측되는 부분들"도 있으나 실제 설계에서는 프로젝트에 특화된 부분도 존재한다. 이때 변경을 예측하는 좋은 방법은 시스템의 목표에 맞는 최소한의 부분부터 설계하는 것이다. 즉, 절대로 변경되지 않을 것 같은 부분부터 신경쓴다. 왜냐하면 좋은 설계자라면 변경을 예측하고 그것을 대비하는데 드는 비용도 고려하기 때문이다.

 

3-7. 결합을 느슨하게 유지하라.

느슨한 결합은 자주 강조되지만 지키는데 많은 의식적 노력이 필요하다. 다른 모듈에 의존하지 않는다는 것은 섬세하게 추상적인 인터페이스를 설계하고 정보 은닉과 캡슐화를 고려해야만 달성 가능하기 때문이다. 

3-7-1. 결합의 기준

  • 연결 횟수 : 모듈 사이의 연결 횟수를 말한다. 연결 횟수는 적은 것이 좋다. 
  • 단순한 인터페이스 : 매개변수가 적은 인터페이스가 느슨하게 결합된다.
  • 가시성 : 필요한 정보가 인터페이스에 드러나야 한다. 만약 인터페이스가 전역 데이터를 건드리고 있고, 이것을 주석으로 알려주는 것은 가시성이 그다지 좋지 않은 것이다. 이때는 데이터를 매개변수로 받아 코드 상에서 구체적으로 드러내는 것이 좋다.
  • 유연성 : 어떤 모듈이 다른 모듈을 호출하는게 쉬워야 한다. 쉽다는 것은 알아야 할 정보가 적다는 것이다.

유연성을 예로 들자면, 모듈 A가 입사 날짜와 직무 정보가 담긴 Employee 객체를 관리한다. 모듈 A는 휴가 일수를 반환하는 모듈 B의 lookUpVacation() 메소드에 매개변수로 Employee 객체를 전달한다. 

이때 완전히 새로운 모듈 C가 모듈 B의 lookUpVacation() 메소드를 사용하고 싶다면 어떻게 해야 할까? 이 메소드가 매개변수로 Employee 객체를 받기 때문에 모듈 C도 Employee 객체에 의존해야 한다. 모듈 B의 lookUpVacation() 메소드는 유연성이 많이 떨어진다. 차라리 입사 날짜와 직종을 기본 데이터 타입으로 받게 해서 다른 시스템과 쉽게 호환될 수 있도록 유연하게 설계했어야 한다.

 

3-7-2. 결합의 종류

기본 데이터 타입을 사용한 결합은 모든 데이터가 기본 타입이므로 결합이 매우 느슨하고, 이는 허용 가능하다.

모듈이 객체를 인스턴화한다. 사실 어느 지점에서는 특정 모듈이 객체를 인스턴스로 생성해야 할 것이다. 이러한 결합은 필수적이므로 허용된다. 다만 생성과 사용을 분리하는 원칙을 지켜야 한다.

매개변수로 객체가 전달되는 결합이 있다. 기본 데이터형을 전달하는 것보다 결합이 매우 강하다. 어떤 방식으로든 메시지를 송수신하는 두 객체 모두 매개변수 객체에 결합되기 때문이다.

의미론적으로 결합되는 경우가 있다. 컴파일이나 문법이 아니라 인간 개발자가 해석하는 과정에서 정보 은닉이 지켜지지 못하고 누수되는 경우다.

  • Module1이 Module2에게 무엇을 할지 지시할 수 있도록 제어 플래그를 전달한다. 이러한 방식은 Module1이 Module2가 구체적으로 어떤 일들을 하는지 알고 있어야 하므로 위험할 수 있다. 되도록 피해야 하며, 만약 사용해야 한다면 제어플래그를 열거형으로 취급하라.
  • Module1과 Module2가 전역 데이터를 공유한다.
  • Module1은 Module2.routine(); 내부에서 Module2.initialize(); 를 호출한다는 사실을 알고는 해당 메소드를 호출하지 않고 바로 Module2.routine();을 호출한다.
  • Module1이 Module2.routine(); 을 호출하고, 매개변수로 Object를 전달한다. 이때 Module2.routine()이 내부적으로 Object의 일부 필드와 메소드만 사용한다는 사실을 알고는 성능 향상이라는 명목 아래 Object의 일부만 초기화해 전달했다.

의미론적 결합과 같은 오류가 발생하는 이유는 클래스와 루틴에 대한 본질을 모르고 있기 때문이다. 클래스와 루틴은 전체 프로그램의 복잡성을 줄이기 위해 모듈을 최대한 독립적인 부분으로 분할하기 위해 존재하는 도구다. 데이터와 프로세스를 묶어주는 단순 개념이 아니다. 서로의 내부적 논리를 들여다보고 그에 맞춰 행동하는 것은 독립이라 할 수 없다.

 

3-7-3. 널리 사용되는 디자인 패턴을 사용하라.

디자인 패턴의 가치는 이미 검증된 해결 기법을 사용하는 것이며, 또한 개발자 사이의 설계에 대한 커뮤니케이션 복잡도를 줄여준다. "이 부분은 팩토리 메소드를 사용하자!"라고 말하는 것만으로도 상호작용의 비용을 덜어줄 수 있다.

또한 디자인 패턴 자체는 설계의 가장 본질적이고 추상적인 부분을 다룬다. 거시적이고 일반적인 해법이기 때문이다. 따라서 디자인 패턴을 학습하면 설계의 원리에 대해 심도 있는 토론을 할 수 있다.

그러나 패턴을 억지로 사용해서는 안 된다. 패턴은 그것이 적절한 솔루션일 때 사용되어야 한다. 즉, 패턴처럼 보이게 만들기 위해 패턴을 사용하면 안 된다. 

 

3-8. 다른 발견적 학습

3-8-1. 응집력을 강하게 하라

응집력이 낮다는 것은 클래스 내부의 데이터와 루틴들이 클래스의 핵심 목표와 동떨어져 있다는 뜻이다. 이는 책임을 잘못 할당했을 때 벌어진다. 응집력이 부족한 클래스는 한 번에 여러 가지 일을 수행하므로 시스템 상에서 구체적으로 어떤 역할을 하는지 개념적으로 포착하기 힘들다.

 

3-8-2. 계층을 만들어라.

추상화된 계층으로 시스템이 분할되지 않는다면 한 번에 모든 클래스 사이의 상호작용과 책임을 머릿속에 넣고 다녀야 할 것이다.

 

3-8-3. 클래스 계약을 형식화하라.

...?

 

3-8-4. 책임을 할당하라.

책임을 식별하고 적절한 객체에게 그 책임을 할당하는 것은 좋은 프로세스다. 왜냐하면 책임을 묻는 것이 더 광범위하고 추상적인 인터페이스를 설계하는데 도움이 되기 때문이다. (링크)

 

3-8-5. 테스트가 가능하도록 설계하라.

테스트를 고민하게 하는 것 자체가 왜 설계에 도움이 되는가? 테스트는 기본적으로 모듈의 인터페이스를 독립적으로 조사할 수 있는 방식으로 유도해준다. 따라서 해당 모듈은 인터페이스가 테스트 가능하도록 만들기 위해 다른 서브 시스템과의 의존도를 줄이고, 알아야 할 정보를 최소화하여 추상화된 인터페이스를 고민하게 만든다.