<오브젝트 - 코드로 이해하는 객체지향 설계> (조영호 저, 위키북스 출판) 를 스터디하며 요약한 내용입니다.
추상화와 분해
사람의 기억은 단기 기억과 장기 기억으로 분류되며, 장기 기억의 용량은 거의 무한대에 이른다. 그러나 장기 기억에 접근하려면 단기 기억 영역으로 옮긴 후에 처리해야 한다. 그러나 단기 기억의 저장 공간은 제약이 있다. 따라서 문제 해결에 필요한 요소의 수가 단기 기억 용량을 초과하는 순간 인지 과부하가 발생하며 문제 해결 능력이 급격하게 떨어진다. 인지 과부하를 방지하는 방법은 단기 기억 안에 보관할 정보의 양을 조절하는 것이다. 이 작업을 추상화와 분해로 설명할 수 있다.
- 추상화: 불필요한 정보를 제거하고 문제 해결에 필요한 핵심만 남김
- 분해: 커다란 문제를 해결 가능하며 논리적인 chunk 단위로 나눔
복잡성이 존재하는 곳에 추상화와 분해가 함께 존재하며, 소프트웨어 프로그래밍의 패러다임은 추상화와 분해의 관점에서 설명할 수 있다.
1. 프로시저 추상화와 데이터 추상화
현대적인 프로그래밍 언어를 특징짓는 두 가지 추상화 메커니즘. 시스템 분해 방법을 결정하려면 둘 중 무엇을 중심으로 할 것인지 먼저 결정해야 한다.
- 프로시저(데이터 조작) 추상화: 소프트웨어가 무엇을 해야 하는지를 추상화함
- 프로시저 추상화 중심의 시스템 → 기능 분해, 알고리즘 분해
- 데이터(정보 표현) 추상화: 소프트웨어가 무엇을 알아야 하는지를 추상화함
- 데이터 추상화 중심의 시스템 → 타입 추상화(추상 데이터 타입) 또는 데이터 중심 프로시저 추상화(객체지향) 중 하나를 선택
객체 지향 패러다임에서의 추상화란 ‘역할과 책임을 수행하는 객체’이며, 분해란 기능을 ‘협력하는 공동체’를 구성하도록 객체들로 나누는 과정이다.
프로그래밍 언어 관점에서 객체지향이란 데이터를 중심으로 데이터 추상화와 프로시저 추상화를 통합한 객체를 이용해 시스템을 분해하는 방법이다. 이런 객체를 구현하기 위해 대부분의 객체지향 언어는 클래스라는 도구를 제공한다. 따라서 객체지향을 바라보는 일반적인 관점은 클래스를 이용해 시스템을 분해하는 것이다. 그렇다면 객체지향이 전통적인 기능 분해 방법에 비해 효과적이라고 말하는 이유를 이해하기 위해 전통적인 기능 분해 방법부터 살펴보자.
2. 프로시저 추상화와 기능 분해
메인 함수로서의 시스템
기능은 오랜 시간 동안 시스템을 분해하기 위한 기준으로 사용되었다. 기능 분해 관점에서 추상화의 단위는 프로시저이다. 전통적인 기능 분해 방법은 하향식 접근법으로, 시스템을 구성하는 최상위 기능을 정의하고, 이 기능을 더 작은 단계의 하위 기능으로 분해해 나가는 방법이다. 즉, 상위 기능(메인 함수)이 하나 이상의 더 간단하고 더 구체적이며 덜 추상적인 하위기능의 집합으로 분해된다.
급여 관리 시스템 구현하기
예제로 급여 관리 시스템을 살펴보고자 한다. 구현 내용은 기본급과 소득세율을 참고하여 직원의 급여를 계산하고, 시스템 실행 결과로 이름: {직원명}, 급여: {계산된 금액} 형식으로 스크린에 출력한다. 급여 계산에 필요한 정보로 직원의 이름은 프로시저의 인자로 전달받고, 소득세율을 사용자로부터 직접 입력받는다. 직원의 목록과 개별 직원에 대한 기본급 데이터는 시스템 내부에 보관하기로 한다. 이때 기능 분해의 과정은 다음과 같다.
- 최상위 문장 기술 (급여 관리 시스템을 시작하는 메인 프로시저로 구현될 내용)
- 직원의 급여를 계산한다.
- 최상위 문장을 더 세부적인 절차로 구체화
- 직원의 급여를 계산한다.
- 사용자로부터 소득세율을 입력받는다.
- 직원의 급여를 계산한다.
- 양식에 맞게 결과를 출력한다.
- 직원의 급여를 계산한다.
- 모든 문장을 위와 같은 정제 과정을 거치며 더욱 단순하고 구체적인 문자들의 조합으로 분해
- 직원의 급여를 계산한다.
- 사용자로부터 소득세율을 입력받는다.
- 세율을 입력하세요 라는 문장을 화면에 출력한다.
- 키보드를 통해 세율을 입력받는다.
- 직원의 급여를 계산한다.
- 전역 변수에 저장된 직원의 기본급 정보를 얻는다.
- 급여를 계산한다. 급여 = 기본급 - (기본급 * 소득세율)
- 양식에 맞게 결과를 출력한다.
- 정해진 형식에 따라 출력 문자열을 생성한다.
- 사용자로부터 소득세율을 입력받는다.
- 직원의 급여를 계산한다.
하향식 접근법은 먼저 필요한 기능을 생각하고, 이 기능을 분해하고 정제화는 과정에서 필요한 데이터의 종류와 저장 방식을 식별한다. 이는 유지보수에 다양한 문제를 야기하는데, 코드를 통해 문제점을 살펴보자. (작성 언어: ruby)
def main(name) # 최상위 함수
taxRate = getTaxRate() # 사용자로부터 소득세율을 입력받는다 (i)
pay = calculatePayFor(name, taxRate) # 직원의 급여를 계산한다 (ii)
puts(describeResult(name, pay)) # 양식에 맞게 결과를 출력한다 (iii)
end
def getTextRate()
print('세율을 입력하세요: ') # 세율을 입력하세요 라는 문장을 화면에 출력한다.
return gets().chomp().to_f() # 키보드를 통해 세율을 입력받는다.
end
# 직원의 이름과 기본급 정보를 전역 변수로 두 배열 내의 동일안 인덱스에 저장한다.
$employees = ['직원A', '직원B', '직원C']
$basePays = [400, 300, 250]
def calculatePayFor(name, taxRate)
index = $employees.index(name)
basePay = $basePays[index] # 전역 변수에 저장된 직원의 기본급 정보를 얻는다.
return basePay - (basePay * taxRate) # 급여를 계산한다.
end
def describeResult(name, pay)
return '이름: #{name}, 급여: #{pay}' # 정해진 형식에 따라 출력 문자열을 생성한다.
end
위 코드를 통해 알 수 있는 점은 다음과 같다.
- 메인 함수를 루트로 하는 트리 구조로 표현됨
- 트리에서 각 노드는 시스템을 구성하는 하나의 프로시저를 의미
- 한 노드의 자식 노드는 부모 노드를 구현하는 절차 중의 한 단계를 의미
그러나 체계적이고 이상적으로 보이는 기능 분해 방법은 불규칙하고 불완전한 인간과 만나면서 혼란과 동요를 발생시킨다.
하향식 기능 분해의 문제점
설계는 코드 배치 방법이며, 설계가 필요한 이유는 변경에 대비하기 위함이다. 안타깝게도 하향식 접근법과 기능 분해의 근본적인 문제점은 변경에 취약한 설계를 낳는다는 점이다. 문제점을 순서대로 살펴보자.
(1) 시스템은 하나의 메인 함수로 구성되어 있지 않다.
모든 시스템은 최초 릴리즈 당시의 모습을 유지하지 않으며 시간이 지나면서 새로운 요구사항을 반영하게 된다. 추가되는 기능은 최초에 배포된 메인 함수의 일부가 아니라 메인 함수와 동등하게 중요한 또 다른 함수일 것이다. 그러다 보면 유일한 메인 함수라는 개념은 의미가 없어진다. 이때 시스템의 전체 기능을 트리로 정리해 보면 모든 기능을 자식 노드로 가지는 하나의 메인 기능을 선택하기란 생각보다 어렵다.
하향식 접근법은 하나의 알고리즘이나 배치 처리를 구현하기에는 적합하지만 현대적인 상호작용 시스템을 개발하는 데는 적합하지 않다.
(2) 기능 추가나 요구사향 변경으로 인해 메인 함수를 빈번하게 수정해야 한다.
새로운 기능을 추가할 때마다 기존 로직과 상관 없는 새로운 함수의 적절한 위치를 확보해야 하기 때문에 메인 함수의 구조를 급격하게 변경해야만 한다. 기존 코드를 수정하는 것은 새로운 버그를 만들어낼 확률을 높인다. 결과적으로 새로운 기능을 추가하거나 기존 코드를 수정하는 데 필요한 용기를 꺾는다. (변경에 취약한 설계)
예를 들어, 모든 직원들의 기본급 총햡을 구하는 기능을 시스템에 추가해 달라는 요구사항이 있을 때, 구현 자체는 간단한 작업이다. 전역 변수 $basePays
에 저장된 모든 기본급을 더하기만 하면 된다.
def sumOfBasePays()
result = 0
for basePay in $basePays
result += basePay
end
puts(result)
end
문제는 기존의 메인 함수가 개별 직원의 급여를 계산하는 목적이므로 위 함수를 넣을 자리가 마땅치 않다. 위 작업은 개별 직원 급여를 계산하는 main
함수와 개념적으로 동등한 수준의 작업을 수행하기 때문이다. 따라서 기존 main
함수 안의 로직을 calculatePay
라는 함수로 추출한 후 main
함수 내에서 sumOfBasePays
와 calculatePay
를 호출하도록 하는 것이다. 이제 main
함수에서 어떤 함수를 호출할지 지정하는 값을 인자로 받도록 해야 한다.
def main(operation, args={})
case(operation)
when :pay then calculatePay(args[:name])
when :basePays then sumOfBasePays()
end
end
main(:basePays) # 기본급 총합 구하기
main(:pay, name:'직원A') # 개별 직원 급여 계산하기
(3) 비즈니스 로직이 사용자 인터페이스와 강하게 결합된다.
하향식 접근법은 비즈니스 로직을 설계하는 초기 단계부터 입력 방법과 출력 양식을 함께 고민하도록 강요한다. 비즈니스 로직과 사용자 인터페이스에 대한 관심사가 밀접하게 결합되어 한데 섞여 있게 된다. 문제는 비즈니스 로직과 사용자 인터페이스가 변경되는 빈도가 다르다는 점이다. 사용자 인터페이스는 자주 변경되는 반면 비즈니스 로직은 변경이 적게 발생하는데, 하향식 접근법은 사용자 인터페이스를 변경하는 경우 비즈니스 로직까지 영향을 받게 된다.
(4) 하향식 분해는 너무 이른 시기에 함수들의 실행 순서를 고정시킨다.
하향식 기능 분해 과정은 시스템이 무엇(what)을 해야 하는지가 아니라 어떻게(how) 동작해야 하는지에 집중하도록 만든다. 이러한 설계는 처음부터 구현을 염두에 두기 때문에 함수들의 실행 순서에 대한 시간 제약을 강조한다. 함수의 제어 구조는 중요한 설계 결정 사항인데 이는 빈번하게 변경되는 대상이다. 따라서 기능 추가시마다 기존의 제어 구조를 변경해야 한다. 또한 상위 함수가 강요하는 문맥 안에서만 의미를 가지기 때문에 함수의 재사용도 어려워진다.
하향식 설계와 관련된 모든 문제의 원인은 결합도다. 강한 결합도는 시스템을 변경에 취약하게 만든다.
(5) 데이터 형식이 변경될 경우 파급효과를 예측할 수 없다.
하향식 기능 분해는 어떤 데이터를 어떤 함수가 사용하고 있는지 추적하기 어렵다. 개별 함수의 입장에서 사용하는 데이터를 파악하는 것은 어렵지 않지만, 반대로 데이터가 어떤 함수들에 의존하고 있는지를 파악하는 것은 어렵다. 단순히 코드 안에서 변수명을 검색하는 문제가 아니라 의존성과 결합도, 그리고 테스트의 문제이다. 데이터 변경으로 인한 영향이 데이터를 참조하는 모든 함수로 퍼져 나가는데 운이 나쁘면 수많은 버그와 장애에 시달리게 된다.
→ 하향식 기능 분해 방법의 최약성을 알아보기 위해 기능 추가 예제를 살펴보자.급여 관리 시스템에 아르바이트 직원의 급여를 계산하는 기능 추가 요청이 들어왔다. 필요한 데이터 변경 사항은 다음과 같다.
- 아르바이트 직원에게는 일한 시간에 시급을 곱한 금액에서 소득세율 만큼을 공제한 금액을 지급한다.
- 아르바이트 직원의 이름과 시급은 기존
$employees
와$basePays
전역 변수에 함께 보관한다.- 전역 변수 배열의 정보가 아르바이트 직원인지 정규 직원인지 여뷰를 저장하는 새로운 전역 변수
$hourlys
를 추가한다. (true
일 경우 아르바이트 직원,false
일 경우 정규 직원)
- 전역 변수 배열의 정보가 아르바이트 직원인지 정규 직원인지 여뷰를 저장하는 새로운 전역 변수
- 한달간의 업무 누적 시간을 보관하는
$timeCards
전역 변수를 추가한다. (정규 직원의 경우 이 값은 0)
$employees = ['직원A', '직원B', '직원C', '아르바이트D', '아르바이트E', '아르바이트F']
$basePays = [400, 300, 250, 1, 1, 1.5]
$hourlys = [false, false, false, true, true, true]
$timeCards = [0, 0, 0, 120, 120, 120]
다음으로는 calculatePay
함수에 조건 분기를 추가함으로써 새로운 기능을 구현한다.
def calculateHourlyPayFor(name, taxRate)
index = $employees.index(name)
basePay = $basePays[index] * $timeCards[index]
return basePay - (basePay * taxRate)
end
def hourly?(name)
return $hourlys[$employees.index(name)]
end
def calculatePay(name)
taxRate = getTaxRate()
if (hourly?(name)) then
pay = calculateHourlyPayFor(name, taxRate)
else
pay = calculatePayFor(name, taxRate)
end
puts(describeResult(name, pay))
end
이 상태로 코드 수정을 완료한 후 배포한 다음날, sumOfBasePays
함수의 결과가 이상하다는 리포트가 전달되었다. 디버깅 결과 아르바이트 직원에 대한 정보가 데이터에 포함되었기 때문에 기본급 총합에서 아르바이트 직원의 시급을 제외해야 하는 문제였다. 이 예제를 통해 데이터 변경으로 인한 영향도를 파악하는 것이 쉽지 않다는 것을 알 수 있었다.
데이터 변경으로 인한 영향을 최소화하려면
데이터와 함께 변경되는 부분과 그렇지 않은 부분을 명확하게 분리해야 한다. 데이터와 함께 변경되는 부분은 하나의 구현 단위로 묶고 외부에서는 제공되는 함수(퍼블릭 인터페이스)만 이용해 데이터에 접근해야 한다. 이 개념을 기반으로 하여 정보 은닉과 모듈에 대해 알아보자.
3. 모듈
시스템 변경을 관리하는 기본적인 전략은 기능을 기반으로 시스템을 분해하지 않고, 변경 방향에 맞춰 시스템을 분해하는 것이다. 정보 은닉이란, 소프트웨어 개발의 가장 중요한 원리로, 시스템에서 가장 자주 변경되는 부분을 상대적으로 덜 변경되는 안정적인 인터페이스 뒤로 감춰야 한다는 것이 핵심이다.
모듈은 서브 프로그램이라기보다는 책임의 할당이다. [Parnas72]
기능 분해가 필요한 기능들을 순차적으로 찾아가는 탐색의 과정이라면, 모듈 분해는 감춰야 하는 비밀을 선택하고 비밀 주변에 보호막을 설치하는 과정이다. 모듈은 다음 두 가지 비밀을 감춰야 한다.
- 복잡성
- 변경 가능성
시스템의 가장 일반적인 비밀은 데이터다. 비밀이 반드시 데이터일 필요는 없으며 복잡한 로직, 변경 가능성이 큰 자료 구조일 수도 있다. 급여 관리 시스템을 개선할 방법 역시 모듈을 이용해 직원 정보라는 비밀을 내부로 감추고 외부에 대해서는 인터페이스만 노출시켜야 한다.
루비에서 제공하는 module
키워드를 이용하여 직원에 관한 처리를 Employees
모듈로 캡슐화한 코드를 살펴보자.
module Employees
# 전역 변수였던 데이터를 모듈 내부로 숨김
$employees = ['직원A', '직원B', '직원C', '아르바이트D', '아르바이트E', '아르바이트F']
$basePays = [400, 300, 250, 1, 1, 1.5]
$hourlys = [false, false, false, true, true, true]
$timeCards = [0, 0, 0, 120, 120, 120]
# 외부에서는 모듈이 제공하는 퍼블릭 인터페이스 함수를 통해서만 내부 변수를 조작함
def Employees.calculatePay(name, texRate)
if (Employees.hourly?(name)) then
pay = Employees.calculateHourlyPayFor(name, taxRate)
else
pay = Employees.calculatePayFor(name, TaxRate)
end
end
def Employees.hourly?(name)
return $hourlys[$employees.index(name)]
end
def Employees.calculateHourlyPayFor(name, taxRate)
index = $employees.index(name)
basePay = $basePays[index] * $timeCards[index]
return basePay - (basePay * taxRate)
end
def Employees.calculatePayFor(name, taxRate)
return basePay - (basePay * taxRate)
end
def Employees.sumOfBasePays()
result = 0
for name in $employees
if (not Employees.hourly?(name)) then
result += basePays[$employees.index(name)]
end
end
return result
end
end
이제 main
함수에서도 Employees
모듈 기능을 사용하도록 코드를 수정해 보자.
def main(operation, args={})
case(operation)
when :pay then calculatePay(args[:name])
when :basePays then sumOfBasePays()
end
end
def calculatePay(name)
taxRate = getTaxRate()
pay = Employees.calculatePay(name, taxRate)
puts(describeResult(name, pay))
end
def getTaxRate()
print('세율을 입력하세요: ')
return gets().chomp().to_f()
end
def describeResult(name, pay)
return '이름: #{name}, 급여: #{pay}'
end
def sumOfBasePays()
puts(Employees.sumOfBasePays())
end
모듈의 장점과 한계
- 모듈 내부의 변수가 변경되더라도 모듈 내부에만 영향을 미친다.
- 어떤 데이터가 변경되었을 때 영향도를 파악하기 위해 해당 데이터를 정의한 모듈만 검색하면 된다. 코드 수정시 디버깅이 용이해진다.
- 비즈니스 로직과 사용자 인터페이스에 대한 관심사를 분리한다.
- 사용자 입력과 화면 출력을 담당하는 부분은 모듈이 아닌 main 함수 쪽에 위치한다. 이제 다른 사용자 인터페이스를 추가하더라도 Employees 모듈에 포함된 비즈니스 로직은 변경되지 않는다.
- 전역 변수와 전역 함수를 제거함으로써 네임스페이스 오염을 방지한다.
- 변수와 함수를 모듈 내부에 포함시키므로 다른 모듈에서도 동일한 이름을 사용할 수 있다. 전역 네임스페이스 오염 방지와 함께, 이름 충돌의 위험을 완화한다.
결과적으로 모듈 내부는 높은 응집도를 유지하며, 모듈과 모듈 사이에는 퍼블릭 인터페이스를 통해서만 통신하므로 낮은 결합도를 유지한다. 또한 모듈은 정보 은닉이라는 개념을 통해 데이터를 설계의 중심 요소로 부각시켰다. 모듈의 핵심은 데이터다. 모듈은 감춰야 할 데이터를 결정하고 → 이 데이터를 조작하는 데 필요한 함수를 결정한다. 기능이 아니라 데이터 중심으로 시스템을 분해하는 것이다.
한계는, 인스턴스 개념을 제공하지 않는다는 점이다. 더 높은 수준의 추상화를 위해서는 개별 직원을 독립적인 단위로 다룰 수 있어야 한다. 이를 만족시키기 위한 개념이 바로 추상 데이터 타입이다.
4. 데이터 추상화와 추상 데이터 타입
프로그래밍 언어에서 타입(type)이란, 변수에 저장할 수 있는 내용물의 종류와 변수에 적용될 수 있는 연산의 가짓수를 의미한다.
추상 데이터 타입(Abstract Data Type)
사람들은 ‘직원의 급여를 계산한다’는 하나의 커다란 절차를 통해 사고하기보다는, ‘직원’과 ‘급여’라는 추상적인 개념들을 머릿속에 떠올린 후 이들을 이용해 계산에 필요한 절차를 생각하는 데 익숙하다. [p.241]
추상 데이터 타입을 구현하려면 다음과 같은 특성을 위한 프로그래밍 언어의 지원이 필요하다.
- 타입 정의를 선언할 수 있어야 한다.
- 타입의 인스턴스를 다루기 위해 사용할 수 있는 오퍼레이션의 집합을 정의할 수 있어야 한다.
- 제공된 오퍼레이션을 통해서만 조작할 수 있도록 데이터를 외부로부터 보호할 수 있어야 한다.
- 타입에 대해 여러 개의 인스턴스를 생성할 수 있어야 한다.
루비는 추상 데이터 타입을 흉내낼 수 있는 Struct 라는 구성 요소를 제공한다. 이를 이용해 개별 직원을 위한 추상 데이터 타입을 구현해 보자.
Employee = Strunct.new(:name, :basePay, :hourly, :timeCard) do
def calculatePay(taxRate)
if (hourly) then
return calculateHourlyPay(taxRate)
end
return calculateSalariedPAy(taxRate)
end
private
def calculateHourlyPay(taxRate)
return (basePay * timeCard) - (basePay * timeCard) * taxRate
end
def calculatesalariedPay(taxRate)
return basePay - (basePay * taxRate)
end
def monthluBasePay()
if (hourly) then return 0 end
return basePay
end
end
# 추상 데이터 타입을 사용하는 클라이언트 코드
$employees = [
Employee.new('직원A', 400, false, 0),
Employee.new('직원B', 300, false, 0),
Employee.new('직원C', 250, false, 0),
Employee.new('아르바이트D', 1, true, 120),
Employee.new('아르바이트E', 1, true, 120),
Employee.new('아르바이트F', 1, true, 120),
]
# 특정 직원의 급여 계산
def calculatePay(name)
taxRate = getTaxRate()
for each in $employees
if (each.name == name) then employee = each; break end
end
pay = employee.calculatePay(taxRate)
puts(describeResult(name, pay))
end
# 정규 직원 전체의 기본급 총합 계산
def sumOfBasePays()
result = 0
for each in $employees
result += each.monthlyBasePay()
end
puts(result)
end
추상 데이터 타입 정의를 기반으로 객체를 생성하는 것은 가능하지만, 여전히 데이터와 기능을 분리해서 바라본다는 점에 주의하라. 프로그래밍 언어 관점에서 추상 데이터 타입은 언어의 내장 데이터 타입과 동일하다. 단지 타입을 개발자가 정의할 수 있다는 점이 다를 뿐이다. 여기서 다음과 같은 의문이 생긴다. 클래스는 추상 데이터 타입인가?
5. 클래스
명확한 의미에서 추상 데이터 타입과 클래스는 동일하지 않다. 가장 핵심적인 차이는 클래스는 상속과 다형성을 지원하는 데 비해 추상 데이터 타입은 지원하지 못한다는 점이다.
위의 예제 코드에서 Employee
타입은 물리적으로는 하나의 타입이지만 개념적으로는 정규 직원과 아르바이트 직원이라는 두 개의 개별적인 개념을 포괄하는 복합 개념이다. calculatePay
와 monthlyBasePay
오퍼레이션은 직원 유형에 따라 서로 다른 방식으로 동작한다. 객체지향은 정규 직원과 아르바이트 직원 각각에 대한 클래스를 정의하고 각 클래스가 필요한 오퍼레이션을 적절하게 구현한다.
두 가지 클래스로 직원을 분리할 경우, 공통 로직은 부모 클래스를 정의하여 각 유형이 상속받게 한다. 클라이언트는 부모 클래스의 참조자에 대해 메시지를 전송하고, 실제 클래스에서 적절한 절차가 실행된다. 동일한 메시지에 대해 서로 다르게 반응하는 이것이 바로 다형성이다. 클라이언트 관점에서 두 클래스의 인스턴스는 동일하게 보인다. 실제로 내부에서 수행되는 절차는 다르지만 클래스는 절차에 대한 차이점을 감춘다. (절차 추상화)
추상 데이터 타입에서 클래스로 변경하기
# 공통 속성과 메서드 시그니처를 정의하는 클래스
class Employee
attr_reader :name, :basePay
def initialize(name, basePay)
@name = name
@basePay = basePay
end
def calculatePay(taxRate)
raise NotImplementedError
end
def monthlyBasePay()
raise NotImplementedError
end
end
# 정규 직원 클래스
class SalariedEmployee < Employee
def initialize(name, basePay)
super(name, basePay)
end
def calculatePay(taxRate)
return basePay - (basePay * taxRate)
end
def monthlyBasePay()
return basePay
end
end
# 아르바이트 직원 클래스
class HourlyEmployee < Employee
attr_reader :timeCard
def initialize(name, basePay, timeCard)
super(name, basePay)
@timeCard = timeCard
end
def calculatePay(taxRate)
return (basePay * timeCard) - (basePay * timeCard) * taxRate
end
def monthlyBasePay()
return 0
end
end
# 전체 직원 데이터 생성
$employees = [
SalariedEmployee.new('직원A', 400),
SalariedEmployee.new('직원B', 300),
SalariedEmployee.new('직원C', 250),
HourlyEmployee.new('아르바이트D', 1, 120),
HourlyEmployee.new('아르바이트E', 1, 120),
HourlyEmployee.new('아르바이트F', 1, 120),
]
# 전체 정규 직원 기본급의 합 계산
def sumOfBasePays()
result = 0
for each in $employees
result += each.monthlyBasePay() # each가 해당하는 타입을 신경쓰지 않고 메시지를 전송
end
puts(result)
end
단순히 클래스를 구현 단위로 사용하는 것이 아니라, 타입을 기준으로 절차를 추상화해야만 객체지향 프로그래밍이다. ‘객체지향이란 조건문을 제거하는 것’이라는 견해는 다소 편협하지만, 조건문을 기피하는 이유는 변경 때문이다. 객체지향에서는 타입 변수를 이용한 조건문을 다형성으로 대체하여 새로운 직원 유형을 구현하고 싶은 경우 Employee
를 상고하는 클래스를 추가하기만 하면 된다. 이 외에 어떤 조건 분기와 같이 코드를 수정할 필요가 없다.
기존 코드에 아무런 영향도 미치지 않고 새로운 객체 유형과 행위를 추가할 수 있는 특성을 개방-폐쇄 원칙(Open-Closed Principle, OCP)이라고 부른다.
협력이 중요하다
항상 절차를 추상화해야 하는가? 추상 데이터 타입은 최악의 선택인가? 설계는 변경과 관련된 것이며, 설계의 유용성은 설계에 요구되는 변경의 압력이 ‘타입 추가’에 관한 것인지, 아니면 ‘오퍼레이션 추가’에 관한 것인지에 따라 달라진다. 또한 객체지향에서 중요한 것은 역할, 책입, 협력이다. 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션 구현 방식을 타입별로 분배하는 것은 올바른 접근법이 아니다. → 3장에서 설명했던 책임 주도 설계의 흐름을 기억할 것