오브젝트

객체 분해

앞선 챕터들에서는 객체지향 설계가 무엇인지, 객체지향적인 설계를 하기 위해서 왜 협력이라는 문맥이 중요한지, 그리고 왜 클라이언트의 입장에서 적절한 책임을 할당해야하는지 살펴보았다.

또한 캡슐화, 응집도, 결합도 측면에서 더 나은 설계를 위해 고려해야할 점들과 주의해야할 점들을 살펴보고 인터페이스의 설계와 품질을 다듬기 위해서 준수해야할 디미터 법칙, 묻지말고 시켜라, 클라이언트의 의도를 드러내는 인터페이스에 대해서 배웠다.

이번 객체 분해 장에서는 절차지향 설계에서부터 시작하여 어떤 과정을 거쳐 객체지향까지 흘러들어왔는지의 변천사를 시스템 분해에 초점을 맞춰서 설명한다.

챕터의 마지막장을 넘기고 나면 여타 설계들과 비교하여 객체지향적 설계가 갖는 특징과 장점들이 머릿속에 보다 뚜렷하게 남을것이라 기대한다.


추상화

추상화의 목적은 복잡하게 얽히고 섥힌 문제에서 공통점을 취하고 세부적인 사항은 생략함으로써 인지과부화를 최소화하고 문제 해결에 필요한 핵심에 집중하는 것이다. 다시말해 한 번에 다뤄야 하는 정보의 수를 줄이기 위해 본질적인 정보만 남기고 불필요한 세부사항을 걸러냄으로써 문제를 단순화하는 것으로 요약할 수 있다.

가장 일반적인 추상화 방법은 한 번에 다뤄야 하는 문제의 크기를 줄이는 것이다. 이처럼 큰 문제를 해결 가능한 작은 문제로 나누는 작업을 분해라고 한다.

복잡성이 존재하는 곳에 추상화와 분해 역시 함께 존재한다. 소프트웨어 개발 영역 역시 마찬가지이다.

프로시저 추상화와 데이터 추상화

프로그래밍 언어의 발전은 좀 더 효과적인 추상화를 이용해 복잡성을 극복하려는 개발자들의 노력에서 출발하였다.

현대적인 프로그래밍 언어를 특징 짓는 두 가지 중요한 추상화 메커니즘은 프로시저 추상화데이터 추상화이다. 프로시저 추상화는 소프트웨어가 무엇을 해야하는지(do)를 추상화한다. 반면에 데이터 추상화는 소프트웨어가 무엇을 알아야 하는지(know)를 추상화한다. 소픝웨어는 데이터를 이용해 정보를 표현하고 프로시저를 이용해 데이터를 조작한다.

프로시저 추상화를 이용하여 시스템을 분해한다면 기능 분해의 길을 따르게 된다. 그리고 데이터 추상화를 이용하여 시스템을 분해한다면 다시 두 가지 길을 따르게 되는데 하나는 데이터를 중심을 타입을 추상화 하는 추상 데이터 타입이고 또 다른 하나는 데이터를 중심으로 프로시저를 추상화하는 객체지향이다.

전통적인 기능 분해 방법에 비해 객체지향이 효과적이라고 말하는 이유가 무엇일까? 먼저 전통적인 기능 분해 방법을 살펴보자.


프로시저 추상화와 기능 분해

프로시저를 추상화라고 부르는 이유는 내부의 상세한 구현을 모르더라도 인터페이스만 알면 프로시저를 사용할 수 있기 때문이다. 따라서 프로시저는 잠재적으로 정보은닉의 가능성을 제시하지만 뒤에서 살펴보는 것 처럼 프로시저만으로 효과적인 정보은닉 체계를 구축하는데에는 한계가 있다.

전통적인 기능분해 방법은 하향식 접근법을 따른다. 하향식 접근법이란 시스템을 구성하는 가장 최상위 기능을 정의하고 이 최상위 기능을 좀 더 작은 단계의 하위 기능으로 분해해 나가는 방법을 말한다. 다시말해 하위 기능은 상위 기능보다 덜 추상적 이어야 한다.

기능 분해 방법에서는 기능을 중심으로 필요한 데이터를 결정한다. 기능 분해라는 무대의 주연은 기능이며 데이터는 기능을 보조하는 조연의 역할에 머무른다. 기능이 우선이고 데이터는 기능의 뒤를 따른다.

기능분해를 위한 하향식 접근법은 먼저 필요한 기능을 생각하고 이 기능을 분해하고 정제하는 과정에서 필요한 데이터의 종류와 저장 방식을 식별한다.

이것은 유지보수에 다양한 문제를 야기한다. 왜 그런지 하향식 기능 분해 방식으로 구현한 코드를 살펴보자.

위 코드에서 알 수 있는 것처럼 하향식 기능분해는 시스템을 최상위의 가장 추상적인 메인함수로 정의하고, 메인 함수를 구현 가능한 수준까지 세부적인 단계로 분해하는 방법이다.


하향식 기능 분해의 문제점

하향식 기능 분해 방법은 겉으로는 이상적인 방법으로 보일 수 있지만 실제로 설계에 적용하다 보면 다음과 같은 다양한 문제에 직면하게 된다.

시스템은 하나의 메인함수로 구성되어있지 않다.

기능 추가나 요구사항 변경으로 인해 메인 함수를 빈번하게 수정해야 한다.

비즈니스 로직이 사용자 인터페이스와 강하게 결합된다. (위 코드에서는 console 입력과 같은 커맨드)

하향식 분해는 너무 이른 시기에 함수들의 실행순서를 고정시키기 때문에 유연성과 재사용성이 저하된다.

데이터 형식이 변경될 경우 파급 효과(side effect)를 예측할 수 없다.

설계는 코드 배치 방법이며 설계가 필요한 이유는 변경에 대비하기 위함이라는 것을 기억하자. 변경은 소프트웨어가 맞이해야 하는 피할 수 없는 운명이다.

안타깝게도 위 목록들을 통해 알 수 있는 것 처럼 하향식 접근법과 기능 분해가 가지는 근본적인 문제점은 변경에 취약한 설계를 낳는다는 것 이다.

하나의 메인함수라는 비현실적인 아이디어

대부분의 시스템에서 하나의 메인 기능이란 개념은 존재하지 않는다. 모든 기능들은 동등하게 독립적이고 완결된 하나의 기능을 표현한다. 하향식 접근법은 알고리즘을 구현하거나 배치 처리를 구현하기에는 적합하지만 현대적인 상호작용 시스템을 개발하는데는 적합하지 않다.

메인 함수의 빈번한 재설계

시스템 안에는 여러 개의 정상(기능)이 존재하기 때문에 결과적으로 하나의 메인함수를 유일한 정상으로 간주하는 하향식 기능분해의 경우에는 새로운 기능을 추가할 때 마다 매번 메인함수를 수정해야 한다.

기존 로직과는 아무런 상관이 없는 새로운 함수의 적절한 위치를 확보해야 하기 때문에 메인 함수의 구조를 급격하게 변경할 수 밖에 없다.

위의 급여 관리 시스템에서 회사에 속한 모든 직원들의 기본급의 총합을 구하는 기능을 추가해 달라는 새로운 요구사항 접수가 되었다고 가정하자. 구현 자체를 놓고 봤을 때는 전역 변수 basePays 배열에 저장되어있는 모든 기본급을 더하기만 하면 되는 간단한 작업이다.

문제는 기존의 메인 함수는 직원 각각의 급여를 계산하는 것이 목적 이기 때문에 전체 직원들의 기본급 총 합을 계산하는 함수가 들어설 자리가 마땅치 않다는 것이다.

현재 코드에서 전체 직원의 급여 총액을 계산하는 sumOfBasePays 함수와 개별 직원의 급여를 계산하는 main 함수는 개념적으로 동등한 수준의 작업을 수행한다. 따라서 현재의 main 함수 안에서 sumOfBasePays 함수를 호출할 수는 없다.

이 문제를 해결하는 방법은 현재의 main 함수 안의 로직 전체를 calculatePay라는 함수로 추출한 후 main 함수 안에서 적절하게 sumOfBasePays 함수와 calculatePay 함수를 호출하는 것이다.

이 간단한 예제는 하나의 정상인 메인 함수에서 출발한다는 하향식 접근법의 기본 가정에 어떤 문제가 있는지를 잘 보여준다. 시스템은 여러개의 정상으로 구성되기 때문에 sumOfBasePays 함수와 같은 새로운 정상을 추가할 때 마다 하나의 정상이라고 간주했던 main 함수 내부를 수정할 수 밖에 없다. 결과적으로 기존 코드의 빈번한 수정으로 인한 버그 발생 확률이 높아지기 때문에 시스템은 변경에 취약해질 수밖에 없다.

성급하게 결정된 실행순서

하향식으로 기능을 분해하는 과정은 하나의 함수를 더 작은 함수로 분해하고, 분해된 함수들의 실행 순서를 결정하는 작업으로 요약할 수 있다. 이것을 설계를 시작하는 시점부터 시스템이 무엇(what)을 해야하는지가 아니라 어떻게(how) 동작해야 하는지에 집중하도록 만든다.

직원의 급여를 계산하려면 어떤 작업이 필요한가? 와 같이 하향식 접근법의 첫 번째 질문은 무엇(what)이 아니라 어떻게(how)이다.

하향식 접근법의 설계는 처음부터 구현을 염두에 두기 때문에 자연스럽게 함수들의 실행 순서를 정의하는 시간 제약을 강조한다. 메인 함수가 작은 함수들로 분해되기 위해서는 우선 함수들의 순서를 결정해야 한다. 함수들의 실행 순서를 미리 결정하지 않고서는 기능 분해를 할 수 없다.

실행 순서나 조건, 반복과 같은 제어구조를 미리 결정하지 않고는 분해를 진행할 수 없기 때문에 기능 분해 방식은 중앙집중 제어스타일의 형태를 띨 수 밖에 없다. 결과적으로 모든 중요한 제어 흐름의 결정이 상위 함수에서 이뤄지고 하위 함수는 상위 함수의 흐름에 따라 적절한 시점에 호출된다.

문제는 중요한 설계 사항인 함수의 제어구조가 빈번한 변경의 대상이라는 점이다. 기능이 추가되거나 변경될 때 마다 초기에 결정된 함수들의 제어구조가 올바르지 않다는 것이 판명된다. 결과적으로 기능을 추가하거나 변경하는 작업은 매번 기존에 결정된 함수의 제어구조를 변경하도록 만든다.

이를 해결할 수 있는 한 가지 방법은 자주 변경되는 시간적인 제약에 대한 미련을 버리고 좀 더 안정적인 논리적 제약을 설계의 기준으로 삼는 것이다. 객체지향은 함수간의 호출순서가 아니라 객체 사이의 논리적인 관계를 중심으로 설계를 이끌어 나간다. 결과적으로 하나의 시스템은 어떤 한 구성요소로 제어가 집중되지 않고 여러 객체들 사이로 제어 주체가 분산된다.

하향식 접근법을 통해 분해한 함수들은 재사용하기가 어렵다. 모든 함수는 상위 함수를 분해하는 과정에서 필요에 따라 식별되며, 그에 따라 상위 함수가 강요하는 문맥(Context) 안에서만 의미를 가지기 때문이다.

하향식 설계와 관련된 모든 문제의 원인은 결합도이다. 함수는 상위 함수가 강요하는 문맥에 강하게 결합된다. 또한 함수는 함께 절차를 구성하는 다른 함수들과 시간적으로 강하게 결합된다. 강한 결합도는 시스템을 변경에 취약하게 만들고 이해하기도 어렵게 만든다. 가장 큰 문제는 전체 시스템의 핵심적인 구조를 결정하는 함수들이 데이터와 강하게 결합된다는 것이다.

데이터 변경으로 인한 파급효과

하향식 기능분해의 가장 큰 문제점은 어떤 데이터를 어떤 함수가 사용하고 있는지를 추적하기 어렵다는 것이다. 따라서 데이터 변경으로 인해 어떤 함수가 영향을 받을지 예상하기 어렵다.

데이터 변경으로 인한 영향은 데이터를 직접 참조하는 모든 함수로 퍼져나간다.

하향식 기능 분해 방법이 데이터 변경에 얼마나 취약한지 이해하기 위해 급여 관리 시스템에 새로운 기능을 추가해보자. 정규 직원과는 다르게 계산되는 아르바이트 직원에 대한 급여 계산 기능을 개발해달라는 요청이 들어왔다고 가정해보자.

아르바이트 직원의 이름과 시급은 정규 직원의 이름과 기본급을 보관하던 전역변수 employeesbasePays에 함께 보관하기로 했다. employeesbasePays의 각 인덱스에 위치한 정보가 정규 직원의 것인지, 아르바이트 직원의 것인지 여부를 결정하는 새로운 전역변수인 hourlys를 추가했다. hourlys의 특정 인덱스의 값이 true일 경우에는 아르바이트 직원에 대한 정보를, false인 경우에는 정규 직원에 대한 정보를 나타낸다.

String[] employees = {"직원A", "직원B", "직원C", "아르바이트D", "아르바이트E", "아르바이트F"};
int[] basePays     = {400, 300, 250, 1, 1, 2};
boolean[] hourlys  = {false, false, false, true, true, true};

아르바이트 직원의 급여를 계산하기 위해서는 한달 간의 업무 누적 시간이 필요하다. 이 값은 전역 변수 timeCards에 보관하기로 했다. 정규 직원의 경우 이 값은 0이다.

int[] timeCards = {0, 0, 0, 120, 120, 120};

지금까지 애플리케이션 안의 데이터를 수정했다. 이제 기존에 employeesbasePays를 사용한 함수들 중에서 아르바이트 직원을 함께 처리해야 하는 함수를 찾아 수정해야 한다. 물론 hourlystimeCards에 저장된 값도 함께 사용하도록 수정해야 할 것이다.

말은 쉽지만 사실 이 작업은 시스템 안에 구현된 모든 함수를 분석해서 영향도를 파악해야 한다는 것을 의미한다. 먼저 기존의 calculatePay 함수에 조건 분기를 추가해야 한다. 정규 직원과 아르바이트 직원에 대한 급여를 다른 방식으로 계산하기 위해서는 직원의 종류가 무엇인지를 파악하고 그에 따라 적절한 로직을 실행해야 한다.

아르바이트 직원의 급여를 계산하는 calculateHorlyPayFor 함수는 시급에 한달 동안 일한 시간을 곱해서 급여를 계산한다.

정규 직원과 아르바이트 직원을 판단하는 isHourly 함수도 추가하자. 이 함수는 직원이 아르바이트 직원이면 true를 반환환다.

이제 기존의 calculatePay 함수를 수정한다. 직원이 아르바이트 직원이면 calculateHorlyPayFor 함수를 호출하고 정규 직원이면 기존의 calculatePayFor 함수를 실행한다.

모든 코드의 수정이 완료되었을까? 안타깝게도 운영환경에 배포되고 난 다음 날 사용자로부터 직원들의 모든 기본급을 더한 sumOfBasePays 함수의 결과가 이상하다는 리포트가 전달되기 시작했다. 오랜 디버깅 끝에 basePaysemployees에 아르바이트 직원에 대한 정보를 추가했기 때문이라는 사실을 알아낼 수 있었다. 모든 직원의 기본급의 총합을 더하는 sumOfBasePays 함수도 함께 수정해야 했던 것이다.

현재의 basePays에는 정규 직원의 기본급 뿐만 아니라 아르바이트 직원의 시급도 저장되어 있기 때문에 시급을 총합에서 제외해야 한다.

이 예제가 말해주는 것은 데이터 변경으로 인해 발생하는 함수에 대한 영향도를 파악하는 것이 생각보다 쉽지 않다는 것이다. 새로운 요구사항은 아르바이트 직원에 대한 급여도 계산할 수 있도록 시스템을 개선해달라는 것이었다. 이를 위해 아르바이트 직원을 위한 데이터를 추가하고 급여를 계산하는 calculatePay 함수도 수정했다. 하지만 이 수정으로 인해 sumOfBasePays 함수도 영향을 받는다는 사실을 알지 못했기 때문에 버그가 발생한 것이다.

데이터 변경으로 인한 영향을 최소화하려면 데이터와 함께 변경되는 부분과 그렇지 않은 부분을 명확하게 분리해야 한다. 이를 위해 데이터와 함께 변경되는 부분을 하나의 구현 단위로 묶고 외부에서는 제공되는 함수만 이용해서 데이터에 접근해야 한다.

다시말해 잘 정의된 퍼블릭 인터페이스를 통해 데이터에 대한 접근을 통제해야 하는 것이다.

이것이 바로 의존성 관리의 핵심이다. 변경에 대한 영향을 최소화 하기 위해 영향을 받는 부분과 받지 않는 부분을 명확하게 분리하고 잘 정의된 퍼블릭 인터페이스를 통해 변경되는 부분에 대한 접근을 통제하라.


모듈

정보은닉과 모듈

앞서 설명한 것처럼 시스템의 변경을 관리하는 기본적인 전략은 함께 변경되는 부분을 하나의 구현 단위로 묶고 퍼블릭 인터페이스를 통해서만 접근하도록 만드는 것이다. 즉, 기능을 기반으로 시스템을 분해하는 것이 아니라 변경의 방향에 맞춰 시스템을 분해하는 것이다.

정보은닉은 시스템을 모듈단위로 분해하기 위한 기본 원리로 시스템에서 자주 변경되는 부분을 상대적으로 덜 변경되는 안정적인 인터페이스 뒤로 감춰야 한다는 것이 핵심이다.

정보 은닉은 외부에 감춰야 하는 비밀에 따라 시스템을 분할하는 모듈 분할의 원리이다. 모듈은 변경될 가능성이 있는 비밀을 내부로 감추고, 잘 정의도고 쉽게 변경되지 않을 퍼블릭 인터페이스를 외부에 제공하여 내부의 비밀에 함부로 접근하지 못하게 한다.

기능 분해가 하나의 기능을 구현하기 위해 필요한 기능들을 순차적으로 찾아가는 탐색의 과정이라면 모듈 분해는 감춰야 하는 비밀을 선택하고 비밀 주변에 안정적인 보호막을 설치하는 보존의 과정이다. 비밀을 결정하고 모듈을 분해한 후에는 기능 분해를 이용해 모듈에 필요한 퍼블릭 인터페이스를 구현할 수 있다.

시스템을 모듈 단위로 어떻게 분해할 것인가? 시스템이 감춰야 하는 비밀을 찾아라. 외부에서 내부의 비밀에 접근하지 못하도록 커다란 방어막을 쳐서 에워싸라. 이 방어막이 바로 퍼블릭 인터페이스가 된다.

모듈은 다음과 같은 두 가지 비밀을 감춰야 한다.

1. 복잡성 : 모듈이 너무 복잡한 경우 이해하고 사용하기가 어렵다. 외부에 모듈을 추상화할 수 있는 간단한 인터페이스를 제공해서 모듈의 복잡도를 낮춘다.

2. 변경 가능성 : 변경 가능한 설계 결정이 외부에 놏루될 경우 실제로 변경이 발생했을 때 파급효과가 커진다. 변경 발생 시 하나의 모듈만 수정하면 되도록 변경 가능한 설계 결정을 모듈 내부로 감추고 외부에는 쉽게 변경되지 않을 인터페이스를 제공한다.

앞선 급여 관리 시스템의 예에서 알 수 있는 것처럼 시스템의 가장 일반적인 비밀은 데이터다. 이때문에 데이터 캡슐화정보은닉을 혼동스럽게 만든 것으로 보인다. 비밀이 반드시 데이터일 필요는 없으며 복잡한 로직이나 변경 가능성이 큰 자료 구조일 수도 있다.

다음은 전체 직원에 관한 처리를 Employees 모듈로 캡슐화한 결과를 나타낸 것이다.

지금까지 전역 변수였던 employees, basePays, hourlys, timeCardsEmployees라는 모듈 내부로 숨겨져 있다는 것에 주목하라. 이제 외부에서는 직원 정보를 관리하는 데이터에 직접 접근할 수 없다. 외부에서는 Employees 모듈이 제공하는 퍼블릭 인터페이스에 포함된 calcuatePay, hourly?, calculateHourlyPayFor, calculatePayFor, sumOfBasePays 함수를 통해서만 내부 변수를 조작할 수 있다. 심지어 모듈 외부에서는 모듈 내부에 어떤 데이터가 존재하는지조차 알지 못한다.

이제 main 함수가 Employees 모듈의 기능을 사용하도록 코드를 수정하면 된다.

모듈의 장점과 한계

Employees 예제를 통해서 알 수 있는 모듈의 장점은 당므과 같다.

모듈 내부의 변수가 변경되더라도 모듈 내부에만 영향을 미친다.

모듈을 사용하면 모듈 내부에 정의된 변수를 직접 참조하는 코드의 위치를 모듈 내부로 제한할 수 있다. 이제 어떤 데이터가 변경됐을 때 영향을 받는 함수를 찾기 위해 해당 데이터를 정의한 모듈만 검색하면 된다.

전역 변수와 전역 함수를 제거함으로써 네임스페이스 오염을 방지한다.

모듈의 한 가지 용도는 네임스페이스를 제공하는 것이다. 변수와 함수를 모듈 내부에 포함시키기 때문에 다른 모듈에서도 동일한 이름을 사용할 수 있게된다. 따라서 모듈은 전역 네임스페이스의 오염을 방지하는 동시에 이름 충돌의 위험을 완화한다.

모듈은 기능이 아니라 변경의 정도에 따라 시스템을 분해하게 한다. 각 모듈은 외부에 감춰야하는 비밀과 관련성 높은 데이터와 함수의 집합이다. 따라서 모듈 내부는 높은 응집도를 유지한다.

모듈에 있어서 핵심은 데이터다. 메인함수를 정의하고 필요에 따라 더 세부적인 함수로 분해하는 하향식 기능 분해와 달리 모듈은 감춰야 할 데이터를 결정하고 이 데이터를 조작하는데 필요한 함수를 결정한다. 기능이 아니라 데이터를 중심으로 시스템을 분해하는 것이다.

모듈은 데이터와 함수가 통합된 한 차원 높은 추상화를 제공하는 설계 단위다.

모듈의 가장 큰 단점은 인스턴스의 개념을 제공하지 않는다는 점이다. Employees 모듈은 모든 직원의 정보를 가지고 있는 모듈일 뿐이다. 좀 더 높은 수준의 추상화를 위해서는 직원 전체가 아니라 개별 직원을 독립적인 단위로 다룰 수 있어야 한다. 다시 말해서 다수의 직원 인스턴스가 존재하는 추상화 매커니즘이 필요한 것이다.


데이터 추상화와 추상 데이터 타입

바바라 리스코프는 프로시저 추상화의 한계를 인지하고 대안을 탐색한 선각자 중 한명이다. 리스코프는 프로시저 추상화를 보완하기 위해 데이터 추상화의 개념을 제안했다.

안타깝게도 프로시저만으로는 충분히 풍부한 추상화의 어휘집을 제공할 수 없다.

"추상 데이터 타입은 추상 객체의 클래스를 정의한 것"으로 추상 객체에 사용할 수 있는 '오퍼레이션'에 의해 규정된다. 이것은 오퍼레이션을 이용해 추상 데이터 타입을 정의할 수 있음을 의미한다.

추상 데이터 객체를 사용할 때 프로그래머는 오직 객체가 외부에 제공하는 행위에만 관심을 가지며 행위가 구현되는 세부적인 사항에 대해서는 무시한다. 객체가 저장소 내에서 어떻게 표현되는지와 같은 구현 정보는 오직 오퍼레이션을 어떻게 구현할 것인지에 집중할 때만 필요하다. 객체의 사용자는 이 정보를 알거나 제공받을 필요가 없다.

위 인용문에는 지금까지 설명했떤 데이터 추상화, 정보 은닉, 데이터 캡슐화, 인터페이스-구현 분리의 개념들이 모두 다 녹아들어 있다.

추상 데이터 타입을 구현하려면 다음과 같은 특성을 위한 프로그래밍 언어의 지원이 필요하다.

타입 정의를 선언할 수 있어야 한다.

타입의 인스턴스를 다루기 위해 사용할 수 있는 오퍼레이션의 집합을 정의할 수 있어야 한다.

제공된 오퍼레이션을 통해서만 조작할 수 있도록 데이터를 외부로부터 보호할 수 있어야 한다.

타입에 대해 여러 개의 인스턴스를 생성할 수 있어야 한다.

이제 추상 데이터 타입을 이용해 급여 관리 시스템을 개선해 보자. 직원에 대한 추상 데이터 타입을 설계하려면 어떤 데이터를 감추기 위해 직원이라는 데이터 추상화가 필요한지를 질문해야 한다. 우리는 급여 관리 시스템 예제에서 employees, basePays, hourlys, timeCards라는 4가지 전역 변수를 사용했다. 이 변수들은 각각 직원의 이름, 기본급, 아르바이트 직원 여부, 아르바이트 직원일 경우 한달 간 작업 시간을 의미하는 4개의 데이터 항목을 나타낸다. 따라서 직원 추상화는 위 4개의 항목을 외부에 감춰야 한다.

위와 같이 내부에 캡슐화 할 데이터를 결정했다면 추상 데이터 타입에 적용할 수 있는 오퍼레이션을 결정해야 한다. Employee 타입의 주된 행동은 직원의 유형에 따라 급여를 계산하는 것이므로 calculatePay 오퍼레이션을 추가한다. 외부에서 인자로 전달받던 직원의 이름은 이제 Employee 타입의 내부에 포함돼 있으므로 calculatePay 오퍼레이션의 인자로 받을 필요가 없다. 따라서 직원을 지정해야 했던 모듈 방식보다 추상 데이터 타입에 정의된 오퍼레이션의 시그니처가 더 간단하다는 것을 알 수 있다.

Employee 타입에 정의할 두 번째 오퍼레이션은 개별 직원의 기본급을 계산하는 것이다. 정규직의 경우 basePay에 저장된 기본급을 반환하고 아르바이트 직원의 경우에는 기본급이라는 개념이 없기 때문에 0을 반환한다.

Employee 추상 데이터 타입에 대한 설계가 완료되었으므로 추상 데이터 타입을 사용하는 클라이언트의 코드를 작성하자. 먼저 필요한 직원들의 인스턴스를 준비한다.

특정 직원의 급여를 계산하는 것은 직원에 해당하는 Employee 인스턴스를 찾은 후 calculatePay 오퍼레이션을 호출하는 것이다.

지금까지 살펴본 것 처럼 추상 데이터 타입은 사람들이 세상을 바라보는 방식에 좀 더 근접해 지도록 추상화 수준을 향상시킨다. 일상 생활에서 Employee라고 말할 때는 상태와 행위를 가지는 독립적인 객체라는 의미가 담겨있다. 따라서 개별 직원의 인스턴스를 생성할 수 있는 Employee 추상 데이터 타입은 전체 직원을 캡슐화 하는 Employees 모듈보다는 좀 더 개념적으로 사람들의 사고방식에 가깝다.

비록 추상 데이터 타입을 기반으로 객체를 생성하는 것은 가능하지만 여전히 데이터와 기능을 분리해서 바라본다는 점에 주의하라

추상 데이터 타입은 말 그대로 시스템의 상태를 저장할 데이터를 표현한다.

추상 데이터 타입으로 표현된 데이터를 이용해서 기능을 구현하는 핵심 로직은 추상 데이터 타입 외부에 존재한다.

급여 관리 시스템ㄹ의 경우에는 main 함수의 로직들이 바로 이 데이터를 사용하는 코드다. 추상 데이터 타입은 데이터에 대한 관점을 설계의 표면으로 끌어올리기는 하지만 여전히 데이터와 기능을 분리하는 절차적인 설계의 틀에 갇혀있는 것이다.


클래스

클래스는 추상 데이터 타입인가?

명확한 의미에서 추상 데이터 타입클래스는 동일하지 않다. 가장 핵심적인 차이는 클래스는 상속과 다형성을 지원하는데 비해 추상 데이터 타입은 지원하지 못한다. 상속과 다형성을 지원하는 객체지향 프로그래밍과 구분하기 위해 상속과 다형성을 지원하지 않는 추상데이터 타입 기반의 프로그래밍 패러다임을 객체 기반 프로그래밍이라고 부르기도 한다.

추상 데이터 타입은 타입을 추상화한 것이고 클래스는 절차를 추상화한 것이다.

타입 추상화절차 추상화의 차이점을 이해하기 위해 먼저 추상 데이터 타입으로 구현된 Employee 타입의 calculatePay, monthlyBasepay 오퍼레이션을 살펴보자. Employee 타입은 물리적으로는 하나의 타입이지만 개념적으로는 정규 직원, 아르바이트 직원 이라는 두 개의 개별적인 개념을 포괄하는 복합 개념이다. Employee 타입이 제공하는 퍼블릭 오퍼레이션인 calculatePay, monthlyBasepay직원 유형에 따라 서로 다른방식으로 동작한다.

여기서 강조하고 싶은 것은 하나의 타입처럼 보이는 Employee 내부에는 정규 직원과 아르바이트 직원이라는 두 개의 타입이 공존한다는 것이다. 설계의 관점에서 Employee 타입은 구체적인 직원 타입(정직원, 아르바이트)을 외부에 캡슐화 하고 있는 것이다.

이처럼 하나의 대표적인 타입이 다수의 세부적인 타입을 감추기 때문에 이를 타입 추상화라고 한다.

타입 추상화는 개별 오퍼레이션(caculatePay, monthlyBasepay)이 모든 개념적인 타입에 대한 구현(calculateHorlyPayFor, calculateSalariedPayfor)을 포괄하도록 함으로써 하나의 물리적인 타입 안에 전체 타입을 감춘다. 따라서 타입 추상화는 오퍼레이션을 기준으로 타입을 통합하는 데이터 추상화 기법이다.

타입 추상화를 기반으로 하는 대표적인 기법이 바로 추상 데이터 타입이다. Employee를 사용하는 클라이언트는 calculatePaymonthlyBasepay 오퍼레이션을 호출할 수 있지만 정규 직원이나 아르바이트 직원이 있다는 사실은 알 수 없다. 두 직원 타입은 Employee 내부에 감춰져 있으며 암묵적이다.

추상 데이터 타입은 오퍼레이션을 기준으로 타입을 묶는 방법(오퍼레이션 내부로 타입이 감춰짐)이라면 객체지향은 타입을 기준으로 오퍼레이션을 묶는다.(타입을 분리하고 각 타입에 오퍼레이션을 분배) 즉, 정규 직원과 아르바이트 직원이라는 두 개의 타입을 ‘명시적’으로 정의하고 두 직원 유형과 관련된 오퍼레이션의 실행 절차를 두 타입에 분배한다. 결과적으로 객체지향은 정규 직원과 아르바이트 직원 각각에 대한 클래스를 정의하고 각 클래스들이 calculatePay와 monthlyBasepay 오퍼레이션을 적절하게 구현하게 될 것이다.

클라이언트 관점에서 두 클래스의 인스턴스는 동일하게 보인다는 것에 주목하자. 실제로 내부에서 수행되는 절차는 다르지만 클래스를 이용한 다형성은 절차에 대한 차이를 감춘다.

추상 데이터 타입은 오퍼레이션을 기준으로 타입들을 추상화한다.

클래스는 타입을 기준으로 절차들을 추상화한다.

이것이 추상화와 분해의 관점에서 추상 데이터 타입클래스의 다른 점이다.


추상 데이터 타입에서 클래스로 변경하기.

이제 클래스를 이용해 급여 관리 시스템을 구현해보자. 추상 데이터 타입을 사용한 구현 예에서는 Employee라는 하나의 타입 안에 두 가지 직원 타입을 캡슐화 하였다. 클래스를 이용한 객체지향 버전에서는 각 직원 타입을 독립적인 클래스로 구현함으로써 두 개의 타입이 존재한다는 사실을 명시적으로 표현한다. 결과적으로 클래스를 이용한 구현에서는 Employee 추상 데이터 타입에 구현되어 있던 타입별 코드가 두 개의 클래스로 분배된다.

자식 클래스가 @Overriding 할 수 있도록 calcuatePaymonthlyBasepay의 시그니처를 정의한다.

이제 정규 직원 타입을 독립적인 SalariedEmployee 클래스로 구현한다. SalariedEmployee 클래스의 calcuatePaymonthlyBasepay 메서드는 오직 정규직원과 관련된 로직만 구현한다.

동일한 방식으로 아르바이트 직원을 나타내는 HourlyEmployee 클래스를 추가한다. HourlyEmployee 클래스의 calculatePaymonthlyBasepay 메서드는 오직 아르바이트 직원과 관련된 로직만 구현한다.

모든 직원 타입에 대해 Employee의 인스턴스를 생성해야 했던 추상 데이터 타입의 경우와 달리 클래스를 이용해서 구현한 코드의 경우는 클라이언트가 원하는 직원 타입에 해당하는 클래스의 인스턴스를 명시적으로 지정할 수 있다.

하지만 일단 객체를 생성하고 나면 객체의 클래스가 무엇인지는 중요하지 않다. 클라이언트의 입장에서는 SalariedEmployeeHourlyEmployee의 인스턴스를 모두 부모 클래스인 Employee의 인스턴스인 것처럼 다룰 수 있다. 클라이언트는 메시지를 수신할 객체의 구체적인 클래스에 관해 고민할 필요가 없다. 그저 수신자가 이해할 것으로 예상되는 메시지를 전송하기만 하면 된다.

다음은 employees에 포함된 전체 정규 직원에 대한 기본급의 합을 구하는 sumOfBasePays 메서드의 구현을 나타낸 것이다. sumOfBasePays 메서드가 employees에 포함된 객체가 어떤 타입인지를 고민하지 않고 monthlyBasepay 메시지를 전송한다는 것에 주목하자. 메시지를 수신한 객체는 자신의 클래스에 구현된 메서드를 이용해 적절하게 반응할 수 있다.


변경을 기준으로 선태하라.

단순히 클래스를 구현 단위로 사용한다는 것이 객체지향 프로그래밍을 한다는 것을 의미하지는 않는다. 타입을 기준으로 절차를 추상화하지 않았다면 그것은 객체지향 분해가 아니다. 비록 클래스를 사용하고 있더라도 말이다.

클래스가 추상 데이터 타입의 개념을 따르는지를 확인할 수 있는 가장 간단한 방법은 클래스 내부에 인스턴스 타입을 표현하는 변수가 있는지를 살펴보는 것이다. 추상 데이터 타입으로 구현된 Employee를 보면 hourly라는 인스턴스 변수에 직원의 유형을 저장한다는 것을 알 수 있다.

인스턴스 변수에 저장된 값을 기반으로 메서드 내에서 타입을 명시적으로 구분하는 방식은 객체지향을 위반하는 것으로 간주된다.

객체지향에서는 타입 변수를 이용한 조건문을 다형성으로 대체한다. 클라이언트가 객체의 타입을 확인한 후 메서드를 호출하는 것이 아니라 객체가 메시지를 처리할 적절한 메서드를 선택한다. 흔히 객체지향이란 조건문을 제거하는 것이라는 다소 편협한 견해가 널리 퍼진 이유가 바로 이때문이다.

모든 설계 문제가 그런 것처럼 조건문을 사용하는 방식을 기피하는 이유 역시 변경 때문이다. 추상 데이터 타입을 기반으로 한 Employee에 새로운 직원 타입을 추가하기 위해서는 hourly의 값을 체크하는 클라이언트의 조건문을 하나씩 다 찾아 수정해야 한다.

이에 반해 객체지향은 새로운 직원 유형을 구현하는 클래스를 Employee 상속 계층에 추가하고 필요한 메서드를 오버라이딩 하면 된다. 이것은 시스템에 새로운 로직을 추가하기 위해 클라이언트 코드를 수정할 필요가 없다는 것이다.


협력이 중요하다.

단순하게 오퍼레이션과 타입을 표에 적어놓고 클래스 계층에 오퍼레이션의 구현방법을 분배한다고 해서 객체지향적인 어플리케이션을 설계하는 것은 아니다.

객체지향에서 중요한 것은 역할, 책임, 협력이다. 객체지향은 기능을 수행하기 위해 객체들이 협력하는 방식에 집중한다. 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션의 구현 방식을 타입별로 분배하는 것은 올바른 접근법이 아니다.

지금껏 설명했던 내용은 객체에게 로직을 분배하는 방법에 있어서 추상 데이터 타입클래스의 차이를 보여주기 위한 것이지 객체를 설계하는 방법을 설명한 것은 아니다. 객체를 설계하는 방법은 3장에서 설명했던 것 처럼 책임 주도 설계의 흐름을 따른다는 점을 기억하기 바란다.

객체가 참여할 협력을 결정하고 협력에 필요한 책임을 수행하기 위해 어떤 객체가 필요한지에 관해 고민하라. 그 책임을 다양한 방식으로 수행해야 할 때만 타입 계층 안에 각 절차를 추상화 하라.

타입 계층과 다형성은 협력이라는 문맥 안에서 책임을 수행하는 방법에 관해 고민한 결과물이어야 하며 그 자체가 목적이 되어서는 안된다.

참고 및 출처

  • 오브젝트