객체 분해 - 코드로 이해하는 객체지향
프로시저 추상화와 데이터 추상화
프로그래밍 언어의 두가지 추상화 메커니즘은 프로시저 추상화(procedure abstraction) 와 데이터 추상화(data abstraction) 입니다 . 각각 소프트웨어가 무엇을 해야 할지를 추상화하고, 소프트웨어가 무엇을 알아야 하는지를 추상화합니다.
시스템을 분해하는 방법을 결정하려면 프로시저 추상화를 중심으로 할 것인지, 데이터 추상화를 중심으로 할 것인지를 결정해야 합니다. 프로시저 추상화를 중심으로 하면 기능 분해(functional decomposition) == 알고리즘 분해(algorithmic decomposition) 을 하는 것이며, 데이터 추상화를 중심으로 하면 데이터를 중심으로 타입을 추상화(type abstraction) == 추상 데이터 타입(Abstract Data Type) 하거나 데이터를 중심으로 프로시저를 추상화(procedure abstraction) == 객체 지향(Object-Oriented) 하는 것입니다.
프로그래밍 언어적인 관점에서 객체지향은 데이터 추상화와 프로시저 추상화를 함께 포함한 클래스를 이용해 시스템을 분해하는 것입니다. 왜 객체지향이 전통적인 기능 방법에 비해서 효과적인지 천천히 봅시다.
프로시저 추상화와 기능 분해
메인 함수로서의 시스템
기능 분해 관점에서 추상화의 단위는 프로시저이며 시스템은 프로시저를 단위로 분해됩니다. 잠재적으로 정보 은닉의 가능성을 가지지만 프로시저만으로는 효과적인 정보 은닉이 불가능합니다.
전통적인 기능 분해 방법은 하향식 접근법(Top-Down Approach) 을 따릅니다. 상위 기능은 더 간단하고 더 구체적이며 덜 추상적인 하위 기능의 집합으로 분해됩니다.
급여 관리 시스템
간단한 급여 관리 시스템 예제를 통해 프로시저 추상화와 기능 분해를 해봅시다. 먼저 급여 관리 시스템의 추상적인 최상위 문장을 기술해서 시작해봅시다.
직원의 급여를 계산한다.
이 프로시저를 실제로 급여를 계산하는데 필요한 더 세분화된 절차로 구체화해야 합니다. 급여 계산에는 직원 이름(프로시저의 인자로 전달받을 것), 소득 세율(사용자가 직접 입력)이 필요합니다.
직원의 급여를 계산한다.
사용자로부터 소득세율을 입력받는다.
직원의 급여를 계산한다.
양식에 맞게 결과를 출력한다.
모든 문장이 정제 과정을 거치면서 하나 이상의 더 단순하고 구체적인 문장들의 조합으로 분해되어야 합니다. 개발자는 각 단계에서 불완전하고 더 구체화될 수 있는 문장들이 남아 있는지 검토합니다. 있다면 동일한 과정을 거쳐서 가능한 저수준의 문장이 될 때까지 기능을 분해해야 합니다.
직원의 목록과 개별 직원에 대한 기본급 데이터를 시스템 내부에 보관하고, 급여 계산 결과는 '이름: [직원명], 급여: [계산된 금액]" 형식으로 스크린에 출력하기로 결정되었습니다.
직원의 급여를 계산한다.
사용자로부터 소득세율을 입력받는다.
"세율을 입력하세요: " 라는 문장을 화면에 출력한다.
키보드를 통해서 세율을 입력받는다.
직원의 급여를 계산한다.
전역 변수에 저장된 직원의 기본급 정보를 얻는다.
급여를 계산한다.
양식에 맞게 결과를 출력하낟.
"이름: [직원명], 급여: [계산된 금액]" 형식에 따라 출력 문자열을 생성한다.
급여 관리 시스템을 입력을 받아 출력을 생성하는 커다란 하나의 메인 함수로 간주하고 기능 분해를 시작했습니다.
기능 분해 방법에서는 기능을 중심으로 필요한 데이터를 결정합니다. 데이터는 기능을 보조하는 역할에 머무릅니다. 먼저 필요한 기능을 생각하고 이 기능을 분해하고 정제하는 과정에서 필요한 데이터의 종류와 저장 방식을 식별합니다.
이는 유지보수에 많은 문제를 야기합니다. 기능 분해 방식에 따라 분해된 급여 관리 시스템을 구현해가면서 전통적인 하향식 기능 분해 방식이 가지는 문제를 알아봅시다.
급여 관리 시스템 구현
루비(Ruby) 언어로 코드를 구현해보겠습니다.
최상위 문장 '직원의 급여를 계산한다.' 는 하나의 메인 함수로 매핑됩니다. 직원에 대한 정보를 찾기위한 직원의 이름은 함수의 인자로 받기로 했습니다.
def main(name)
end
그리고 세부적인 단계로 나눕니다.
def main(name) # 직원의 급여를 계산한다.
taxRate = getTaxRate() # 사용자로부터 소득세율을 입력받는다.
pay = calculatePayFor(name, taxRate) # 직원의 급여를 계산한다.
puts(describedResult(name, pay)) # 양식에 맞게 결과를 출력한다.
end
소득세율을 입력받는 getTaxRate 함수는 두 절차로 분해할 수 있습니다.
def getTaxRate() # 사용자로부터 소득세율을 입력받는다.
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
이름이 "직원C" 인 직원의 급여를 계산하려면 main("직원C") 프로시저를 호출하면 됩니다.
하향식 기능 분해 방식으로 설계한 시스템은 메인 함수를 루트로 하는 트리(tree) 로 표현할 수 있습니다.
큰 기능을 작은 기능으로 단계적으로 정제해 가는 과정은 체계적이고 이상적으로 보이지만 우리의 실세계는 체계적이지도 이상적이지도 않다는 점이 문제가 됩니다.
하향식 기능의 분해의 문제점
▶ 시스템은 하나의 메인 함수로 구성되어 있지 않다.
▶ 기능 추가나 요구사항 변경으로 인해 메인 함수를 빈번히 수정해야 한다.
▶ 비즈니스 로직이 사용자 인터페이스와 강하게 결합된다.
▶ 하향식 분해는 너무 이른 시기에 함수들의 실행 순서를 고정시키기 때문에 유연성과 재사용성이 저하된다.
▶ 데이터 형식이 변경될 경우 파급효과를 예측할 수 없다.
위처럼 하향식 접근법과 기능 분해의 근본적인 문제점이 변경에 취약한 설계를 낳습니다.
하나의 메인 함수는 불가능하다. & 메인 함수의 빈번한 재설계
시스템에 새로운 기능이 추가되면서 유일한 메인 함수라는 개념은 없어지고 결국 여러 개의 동등한 함수 집합으로 성장하게 될 것입니다. 대부분의 시스템에서는 하나의 메인 기능이라는 개념이 없습니다.
하향식 접근법은 하나의 알고리즘을 구현하거나 배치 처리에는 적합하지만 현대적은 상호작용 시스템을 개발하는 데는 적합하지 않습니다.
기존 로직과는 아무 상관이 없는 새로운 기능이 추가될 때마다 새로운 함수의 적절한 위치를 확보하기 위해 메인 함수의 구조가 급격히 변경됩니다. 만약 회사의 모든 직원들의 기본급을 구하는 총합을 구하는 기능(sumOfBasePays)이 생긴다고 합시다. 원래 main 함수에 있던 모든 로직들을 통째로 추출하는 모습이 됩니다.
def calcaulatePay(name) # 기존 main 에 있던 로직을 통째로 추출함.
taxRate = getTaxRate()
pay = calculatePayFor(name, taxRate)
puts(describedResult(name, pay))
end
def sumOfBasePays()
result = 0
for basePay in $basePays
result += basePay
end
puts(result)
end
def main(operation, args={})
case(operation)
when :pay then calculatePay(args[:name])
when :basePays then sumOfBasePays(*)
end
end
기본급의 총합을 구하려면 main(:basePays) , 이름이 '직원A' 인 직원의 급여를 계산하려면 main(:pay, name:"직원A") 을 호출하면 됩니다.
비즈니스 로직과 사용자 인터페이스의 결합
"사용자로부터 소득세율을 입력받아 급여를 계산한 후 계산된 결과를 화면에 출력한다." 에는 급여를 계산하는 중요한 비즈니스 로직과 관련된 관심사와 소득세율을 입력받아 결과를 화면에 출력한다는 사용자 인터페이스의 관심사가 한데 섞여 있습니다.
자주 변경되는 사용자 인터페이스가 변경될 때마다 비즈니스 로직까지 변경에 영향을 받게 됩니다. 하향식 접근법은 기능을 분해하는 과정에서 사용자 인터페이스의 관심사와 비즈니스 로직의 관심사를 동시에 고려하도록 강요하기 때문에 '관심사의 분리' 라는 아키텍쳐 설계의 목적을 달성하기 어렵습니다.
성급하게 결정된 실행 순서
하향식 기능 분배는 한 함수를 더 작은 함수로 분해하고, 분해된 함수들의 실행 순서를 결정하는 작업입니다. 이는 설계 시작 시점부터 시스템이 무엇(what)을 해야 하는지가 아니라 어떻게(how) 동작해야 하는지에 집중하도록 만듭니다.
하향식 접근법의 설계는 처음부터 구현을 염두에 두어 함수들의 실행 순서를 정의하는 시간 제약(temporal constraint) 을 강조합니다. 이러한 이유로 중앙집중 제어 스타일(centralized control style) 의 형태를 띨 수 밖에 없습니다. 결과적으로 기능을 추가하거나 변경하는 작업은 매번 기존에 결정된 함수의 제어구조를 변경하도록 만듭니다.
이를 해결하는 한 방법은 논리적 제약(logical constraint) 을 설계의 기준으로 삼는 것입니다. 시간 순서가 아닌 객체 사이의 논리적인 관계를 중심으로 설계를 이끌어 나갑니다. 제어가 집중되지 않고 여러 객체에게 분산되겠죠.
하향식 접근법으로 분해한 함수는 재사용도 어렵습니다. 모든 함수는 상위 함수가 강요하는 문맥 안에서만 의미를 가지기 때문이죠. 함수가 재사용 가능하려면 상위 함수보다 더 일반적이어야 합니다.
하향식 설계에서는 함수가 상위 함수가 강요하는 문맥에 강하게 결합됩니다.
데이터 변경으로 인한 파급효과
하향식 기능 분해에서는 어떤 데이터를 어떤 함수가 사용하고 있는지를 추적하기 어렵습니다. 데이터 변경으로 어떤 함수가 영향을 받을지 예상하기가 어렵습니다. 이는 의존성, 결합도, 테스트의 문제입니다.
급여 관리 시스템에 새로운 기능을 추가해봅시다. 정규 직원 뿐아니라 아르바이트 직원의 급여도 계산하는 기능입니다. 아르바이트 직원의 이름, 시급은 전역변수 $employees , $basePays 에 함께 보관할 것입니다. 그리고 각 인덱스의 직원이 아르바이트 직원인지를 판단하는 $hourlys 라는 전역변수를 추가합니다.
$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] # 한달 업무 누적 시간
이렇게 애플리케이션 안의 데이터를 수정했습니다. 이제 $employee 와 $basePays 을 사용하는 함수 중 아르바이트 직원을 함께 처리해야 하는 함수를 찾아 수정합시다. $hourlys 와 $timeCards 에 저장된 값도 함께 사용하도록 수정해야 합니다. 이 작업은 모든 함수를 분석해서 영향도를 파악해야 하는 것입니다.... 만약 이 시스템이 굉장히 많은 함수로 이루어져 있다면 굉장히 어렵고 오래걸리는 작업일 것입니다.
def calculateHourlyPayFor(name, taxRate) # 아르바이트 직원의 급여 계산 함수
index = $employees.index(name)
basePay = $basePays[index] * $timeCard[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 의 결과가 이상할 것입니다. $basePays 와 $employees 에 아르바이트 직원에 대한 정보를 추가했기 때문에 모든 직원의 기본급 총합을 더하는 sumOfBasePays 함수도 수정해야 합니다.
def sumOfBasePays()
result = 0
for name in $employees
if (not hourly?(name)) then
result += $basePays[$employees.index(name)]
end
end
puts(result)
end
데이터 변경으로 인해 발생하는 함수에 대한 영향도를 파악하는 것이 쉽지 않아 보입니다. 데이터 변경으로 인한 영향을 최소화하려면 데이터와 함께 변경되는 부분과 그렇지 않은 부분을 명확히 분리해야 합니다. 이를 위해서 데이터와 함께 변경되는 부분을 하나의 구현 단위로 묶고 외부에서는 제공되는 함수만 이용해서 데이터에 접근해야 합니다. 즉, 잘 정의된 퍼블릭 인터페이스를 통해서 데이터에 대한 접근을 통제하는 것입니다. 이것이 의존성 관리의 핵심입니다.
언제 하향식 분해가 유용한가?
하향식 아이디어는 설계가 어느 정도 안정화된 후에 설계의 다양한 측면을 논리적으로 설명하고 문서화하기에 용이합니다. 쟉은 프로그램과 개별 알고리즘을 위한 유용한 패러다임입니다. 특히 프로그래밍 과정에서 이미 해결된 알고리즘을 문서화하고 서술하는데 훌륭한 기법입니다.
이렇게 하향식 분해 방식으로 설계된 소프트웨어는 하나의 함수에 제어가 집중되어 확장이 어렵고, 설계의 본질적인 비즈니스 로직보다 사용자 인터페이스와 같은 비본질적인 측면에 집중하게 만들며, 과도하게 함수에 집중하여 데이터에 대한 영향도를 파악하게 어렵게 됩니다. 또 재사용하기 어렵습니다.
모듈
정보 은닉과 모듈
정보 은닉(information hiding) 은 시스템을 모듈 단위로 분해하기 위한 기본 원리로 시스템에서 자주 변경되는 부분을 상대적으로 덜 변경되는 안정적인 인터페이스로 감춰야 한다는 것입니다. 정보 은닉은 외부에 감춰야 하는 비밀에 따라 시스템을 분할하는 모듈 분할 원리입니다. 변경될 가능성이 있는 비밀을 내부로 감추고 잘 정의되고 쉽게 변경되지 않을 퍼블릭 인터페이스를 외부에 제공해서 내부의 비밀에 함부로 접근하지 못하게 합니다.
모듈 분해는 감춰야 하는 비밀을 선택하고 비밀 주변에 안정적인 보호막을 설치하는 과정입니다. 모듈을 분해하고 해당 모듈 내부를 구현하기 위해서 기능 분해를 적용할 수 있습니다. 이 보호막이 바로 퍼블릭 인터페이스가 됩니다.
너무 복잡한 모듈의 경우 외부에 모듈을 추상화할 수 있는 간단한 인터페이스를 제공해서 모듈의 복잡성을 낮추고, 변경 가능한 설계 결정이 외부에 노출되지 않도록 감추면 됩니다.
급여 관리 시스템을 구원할 수 있는 방법은 함께 사용되는 데이터를 자신의 비밀로 삼는 모듈을 만드는 것입니다. 외부로 감춰야 하는 비밀은 직원 정보와 관련된 것이지요. 모듈을 이용해 직원 정보라는 비밀을 내부로 감추고 외부에 대해서는 퍼블릭 인터페이스만 노출시켜야 합니다.
루비 언어의 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, taxRate)
if (Employees.hourly?(name)) then
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
지금까지 전역변수였던 $employees, $basePays, $hourlys, $itemCards 가 Employees 라는 모듈 내부로 숨겨져 있습니다. 이제 모듈 외부에서는 직원 정보를 관리하는 데이터에 직접 접근할 수 없습니다. 외부에서는 모듈이 제공하는 퍼블릭 인터페이스에 포함된 함수를 통해서만 내부 변수를 조작할 수 있습니다. 모듈 외부에서는 모듈 내부에 어떤 데이터가 있는지도 모릅니다.
이제 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
모듈의 장점과 한계
모듈의 장점은
- 모듈 내부의 변수가 변경되더라도 모듈 내부에만 영향을 미침.
- 어떤 데이터가 변경되었을 때 영향을 받는 함수를 찾기 위해 전체 함수를 모두 분석할 필요 없이 해당 데이터를 정의한 모듈만 검색하면 됨.
- 비즈니스 로직과 사용자 인터페이스에 대한 관심사 분리.
- 입출력은 Employees 모듈 외부에 있음.
- 모듈은 비즈니스 로직 관련 관심사만 담당하고 있어 GUI 가 변경/추가되어도 모듈에 포함된 비즈니스 로직은 변경되지 않음.
- 전역 변수와 전역 함수를 제거해서 네임스페이스 오염(namespace pollution)을 방지
- 변수, 함수가 모듈 내부에 있어 다른 모듈에서도 동일한 이름 사용 가능.
- 이름 충돌(name collision) 의 위험을 완화
모듈은 외부에 감춰야 하는 비밀과 관련된 데이터와 함수이므로 내부는 응집도가 높습니다. 모듈과 모듈은 서로 퍼블릭 인터페이스를 통해서만 통신해야 하므로 결합도가 낮습니다.
모듈은 먼저 감춰야할 데이터를 결정하고 이 데이터를 조작 함수를 결정합니다. 기능이 아닌 데이터를 중심으로 시스템을 분해하는 것이죠. 하지만 모듈도 한계를 가집니다.
- 인스턴스 개념을 제공하지 않음.
- 높은 추상화를 위해 모듈을 가지고 만든 개별 인스턴스가 있어야 함.
- 이를 만족하기 위해 등장한 개념이 추상 데이터 타입임.
데이터 추상화와 추상 데이터 타입
추상 데이터 타입
프로시저 추상화의 한계를 보완하기 위해 데이터 추상화(data abstraction) 의 개념이 등장했습니다.
추상 데이터 타입을 구현하려면 프로그래밍 언어 차원에서 아래를 만족해야 합니다.
- 타입 정의를 선언할 수 있어야 함.
- 타입의 인스턴스를 다루기 위해서 사용할 수 있는 오퍼레이션들을 정의할 수 있어야 함.
- 제공된 오퍼레이션을 통해서만 조작할 수 있도록 데이터를 외부로부터 보호할 수 있어야 함.
- 타입에 대해 여러 인스턴스를 생성할 수 있어야 함.
계속해서 Ruby 언어로 급여 관리 시스템을 개선해봅시다.
직원에 대한 추상 데이터 타입 설계 시 "어떤 데이터를 감추기 위해서 직원 데이터 추상화가 필요한가?"
이전 코드에서의 4개의 전역 변수인 $employees, $basePays, $hourlys, $timeCards 를 감추기 위함입니다.
# 이름, 기본급, 아르바이트 직원인지 여부, 작업시간
Employee = Strcut.new(:name, :basePay, :hourly, :timeCard) do
End
캡슐화할 데이터를 결정하고 나면 오퍼레이션을 결정해야 합니다.
직원 유형에 따라 급여를 계산하는 오퍼레이션
Employee = Struct.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
end
외부에서 인자로 전달받던 직원의 이름이 이제 Employee 타입의 내부에 포함되어 시그니처가 모듈 방식보다 더 간단해졌습니다.
개별 직원의 기본급 계산하는 오퍼레이션
Employee = Struct.new(:name, :basePay, :hourly, :timeCard) do
...
def monthlyBasePay()
if (hourly) then return 0 end
return basePay
end
end
Employee 추상 데이터 타입에 대한 설계가 완료되었으니 이제
추상 데이터를 사용하는 클라이언트 코드를 작성해봅시다.
필요한 직원들의 인스턴스
$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),
]
직원 급여를 계산하는 것은 해당 직원의 Employee 인스턴스를 찾아서 calculatePay 오퍼레이션을 호출하는 것입니다.
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
메인 함수는 여전히 이렇게 됩니다.
def main(operation, args={})
case(operation)
when :pay then calculatePay(args[:name])
when :basePays then sumOfBasePays()
end
end
이렇게 추상 데이터 타입 정의를 기반으로 객체를 생성할 수 있게 되어 더 상위 수준의 추상화가 가능해졌습니다.
하지만 여전히 데이터와 기능을 분리해서 바라보고 있어 절차적인 설계의 틀 안에 있습니다. 추상 데이터 타입으로 표현된 데이터를 이용해서 기능을 구현하는 핵심 로직은 추상 데이터 타입 외부 main 함수 에 있습니다.
클래스는 추상 데이터 타입인가?
클래스
클래스는 추상 데이터 타입인가?
명확히 말하면 클래스와 추상 데이터 타입은 다릅니다.
클래스 | 추상 데이터 타입 | |
공통점 | 데이터 추상화를 기반으로 시스템을 분해함. 외부에서는 객체 내부 속성에 직접 접근할 수 없고 오직 퍼블릭 인터페이스를 통해서만 외부와 의사소통할 수 있음. |
|
상속, 다형성 | 지원함. | 지원 X |
무엇을 추상화? | 절차 추상화(procedural abstraction) | 타입을 추상화(type abstraction) |
Employee 타입이 제공하는 퍼블릭 오퍼레이션 calculatePay , montlyBasePay 가 직원 유형에 따라 서로 다르게 동작합니다.\
Employee Type | ||
오퍼레이션 | 정규 직원 | 아르바이트 직원 |
calculatePay() | basePay - (basePay * taxRate) | (basePay * timeCard) - (basePay * timeCard) * taxRate |
monthlyBasePay() | basePay | 0 |
하나의 타입처럼 보이는 Employee 내부에는 정규 직원과 아르바이트 직원이라는 두 개의 타입이 공존합니다. Employee 타입이 구체적인 직원 타입을 외부에 캡슐화(타입 추상화)하고 있는 것입니다.
타입 추상화(type abstraction) 는 오퍼레이션을 기준으로 타입을 통합하는 데이터 추상화 기법입니다.
반면 객체지향은 타입을 기준으로 오퍼레이션 추상화 합니다. 절차 추상화(procedural abstraction)
Employee Type | ||
오퍼레이션 | 정규직원 | 아르바이트 직원 |
calculatePay() | basePay - (basePay * taxRate) | (basePay * timeCard) - (basePay * timeCard) * taxRate |
monthlyBasePay() | basePay | 0 |
정규 직원, 아르바이트 직원이라는 두 타입을 명시적으로 정의하고 두 직원 유형에 따른 오퍼레이션의 실행 절차를 두 타입에 분배합니다. 결과적으로 정규직원과 아르바이트 직원 각각의 클래스가 정의되고 각 클래스들이 calculatePay 와 montlyBasePay 오퍼레이션을 적절하게 구현하게 될 것입니다.
공통 로직을 포함한 부모 클래스를 정의하고 두 직원 유형의 클래스가 부모 클래스를 상속받게 하면 되겠죠. 클라이언트가 부모 클래스의 참조자에 대해 메시지를 보내면 실제 클래스가 무엇인지에 따라 적절한 절차가 실행될 것입니다. 즉, 동일한 메시지에 서로 다르게 반응하는 다형성을 가지게 됩니다.
추상 데이터 타입에서 클래스로 변경
Employee 클래스는 정규 직원과 아르바이트 직원 타입이 공통적으로 가져야 하는 속성과 메서드 시그니처만 정의하고 잇는 불완전한 구현체입니다.
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
Ruby에서는 변수명은 @ 로 시작함.
Employee 는 추상 클래스, calculatePay, montlyBasePay 를 추상 메서드라고 생각하면 편하게 이해할 수 있습니다.
정규 직원 클래스 SalariedEmployee
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
Ruby에서는 상속 관계는 '자식 클래스 < 부모 클래스' 형태임.
아르바이트 직원 클래스 HourlyEmployee
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()
end
puts(result)
end
변경을 기준으로 선택하자.
클래스를 구현 단위로 사용했다고 해서 객체지향 프로그래밍인것은 아닙니다. 타입을 기준으로 절차를 추상화하지 않았다면 객체지향 분해가 아닙니다.
객체지향에서는 타입 변수를 이용한 조건문을 다형성으로 대체합니다. 객체가 메시지를 처리할 적절한 메서드를 선택합니다. 클라이언트가 아니고요.
기존 코드에 아무런 영향을 미치지 않고 새로운 객체 유형과 행위를 추가할 수 있는 객체지향의 특성을 개방-폐쇄 원칙(Open-Closed Principle, OCP) 라고 부릅니다. 이로 인해 더 변경/확장이 쉬운 구조 설계가 가능합니다.
결국 중요한 것은 협력
위 그림처럼 단순히 오퍼레이션과 타입을 표에 적어두고 클래스 계층에 오퍼레이션의 구현 방법을 분배한다고 해서 객체지향적인 것이 아닙니다. 객체지향에서 중요한 것은 역할, 책임, 협력입니다. 객체지향은 기능을 수행하기 위해 객체들이 협력하는 방식에 집중합니다. 협력이라는 문맥이 중요한 것이죠.
객체를 설계하는 방법은 책임 주도 설계의 흐름을 따르는 것 , 객체가 참여할 협력을 결정하고 협력에 필요한 책임을 수행하기 위해 어떤 객체가 필요한지 고민하는 과정입니다. 타입 계층과 클래스, 다형성은 협력이라는 문맥에서 책임을 수행하는 방법에 대한 하나의 결과물이고 그 자체가 목적이 되어서는 안됩니다.