01 프로시저 추상화와 데이터 추상화
프로시저 추상화(procedure abstraction)와 데이터 추상화(data abstraction)은 현대적인 프로그래밍 언어를 특징 짓는 중요한 두 가지 추상화 메커니즘이다.
프로시저 추상화 - 소프트웨어가 무엇을 해야하는지를 추상화한다.
데이터 추상화 - 소프트웨어가 무엇을 알아야하는지를 추상화한다.
현대의 설계 방법에 중요한 영향을 끼치는 프로그래밍 패러다임들은 프로시저 추상화나 데이터 추상화를 중심으로 시스템의 분해 방법을 설명한다.
시스템 분해방법을 결정하려면 프로시저 추상화 또는 데이터 추상화를 중심으로 결정해야한다.
프로시저 추상화 중심 시스템 분해라면 기능분해(=알고리즘 분해)의 길로 들어선다.
데이터 추상화 중심 시스템 분해라면 다시 두가지를 선택해야 한다.
하나는 데이터를 중심으로 타입을 추상화(=추상데이터타입)하는 것이고, 다른 하나는 데이터를 중심으로 프로시저를 추상화(=객체지향)하는 것이다.
02 프로시저 추상화와 기능 분해
일반적인으로 객체지향이 전통적인 기능 분해 방법에 비해 효가적인 이유는 뭘까?
메인 함수로서의 시스템
전통적인 기능 분해 방법은 하향식 접근법을 따른다. 시스템을 구성하는 가장 최상위 기능을 정의하고, 이 최상위 기능을 좀 더 작은 단계의 하위 기능으로 분해해 나가는 방법을 말한다.
급여 관리 시스템
회사는 세율에 따라 일정 금액의 세금을 공제 후 급여를 산정한다.
급여 = 기본급 - (기본급 * 소득세율)
전통적인 기능 분해 방법인 하향식 접근법으로 시스템을 구현해보겠다.
직원의 급여를 계산한다.
위의 문장은 급여 관리 시스템을 시작하는 메인 프로시저로 구현될 것이다.
그리고 다음과 같이 좀 더 세부적인 절차로 구체화 될 수 있다.
직원의 급여를 계산한다.
사용자로부터 소득 세율을 입력받는다.
직원의 급여를 계산한다.
양식에 맞게 결과를 출력한다.
그리고 다음과같이 좀 더 단순하고 구체적인 문장들의 조합으로 분해 돼야 한다.
직원의 급여를 계산한다.
사용자로부터 소득 세율을 입력받는다.
"세율을 입력하세요:"라는 문장을 화면에 출력한다.
키보드를 통해 세율을 입력받는다.
직원의 급여를 계산한다.
전역 변수에 저장된 기본급 정보를 얻는다.
급여를 계산한다.
양식에 맞게 결과를 출력한다.
"이름: {직원명}, 급여: {계산된 금액}" 형식에 따라 출력 문자열을 생성한다.
급여 관리 시스템을 입력을 받아 출력을 생성하는 커다란 하나의 메인 함수로 간주하고 기능 분래를 시작했다는 점을 주목해야한다.
기능 분해 방법에서는 기능을 중심으로 필요한 데이터를 결정한다. -> 기능이 우선이고 데이터는 기능의 뒤를 따른다.
하향식 기능분해 방식은 유지보수에 다양한 문제점을 야기한다.
급여 관리시스템을 계속 구현해 가면서 하향식 기능분해 방식이 가지는 문제점을 살펴보자. 이는 객체지향의 장점을 이해할 수 있는 출발점이되기도한다.
급여 관리 시스템 구현
$employees = ["직원A", "직원B", "직원C"]
$basePays = [400, 300, 250]
main("직원C")
def main(name)
taxRate = getTextRate()
pay = calculatePayFor(name, taxRate)
puts(desctibeResult(name, pay))
end
def getTaxRate()
print("세율을 입력하세요: ")
return gets().chomp().to_f()
end
def calculatePayFor(name, taxRate)
index = $employees.index(name)
basePay = $basePays[index]
return basePay - (basePay * taxRate)
end
def describeResult(name, pay)
return "이름: #{name}, 급여: #{pay}"
end
하향식 기능 분해의 문제점
하나의 메인 함수라는 비현실적인 아이디어
대부분의 시스템에서 하나의 메인 기능이란 개념은 존재하지 않으며 처음에는 중요하게 생각됐던 메인 함수는 동등하게 중요한 여러 함수들 중 하나로 전략하고 만다.
이것은 시스템이 오직 하나의 메인 함수만으로 구현된다는 개념과는 완전히 모순된다.
메인 함수의 빈번한 재설계
일반적으로 시스템 안에는 여러 개의 정상이 존재하기 때문에 결과적으로 하나의 메인 함수를 유일한 정상으로 간주하는 하향식 기능 분해의 경우 새로운 기능이 추가할 때마다 매번 메인 함수를 수정해야한다.
급여관리시스템에서 회사에 속한 모든 직원들의 기본급의 총합을 구하는 기능을 추가해 달라는 요구사항이 접수됐다고 가정하면sumOfBasePays() 함수는 직원 개별 기본급을 계산하는 main() 함수와 개념적으로 동등한 수준의 작업을 수행한다.
따라서 다음과 같이 수정될 수 있다.
def calculatePay(name)
taxRate = getTextRate()
pay = calculatePayFor(name, taxRate)
puts(desctibeResult(name, pay))
end
def main(operation, args={})
case(operation)
when :pay then calculatePay(args[:name])
when :basePays then sumOfBasePays()
end
end
main(:basePays) // 기본급 총합 계산
main(:pay, name:"직원A") // 직원A 급여 계산
결과적으로 기본 코드의 빈번한 수정으로 인해 버그 발생 확률이 높아진다.
비즈니스 로직과 사용자 인터페이스의 결합
"사용자로부터 소득세율 입력받아 급여를 계산한 후 계산된 결과를 화면에 출력한다"
라는 말에는 급여를 계산하는 중요한 비즈니스 로직과 소득 세율을 입력받아 결과를 화면에 출력하는 사용자 인터페이스가 한데 섞여있다는 것을 의미한다. -> 코드안에서 밀접하게 결합되어있다.
문제는 비즈니스 로직에 비해 사용자인터페이스는 변경이 빈번하게 발생한다.
예를 들어 급여 관리 시스템의 사용자 인터페이스를 GUI 기반으로 변경한다고 가정하면 현재 비즈니스 로직과 사용자 인터페이스로직이 main함수 안에 뒤섞여 있기 때문에 전체 구조를 재설계 해야 GUI 기반으로 변경이 가능하다.
성급하게 결정된 실행 순서
하향식 접근법의 설계의 함수는 상위 함수가 강요하는 문맥에 강하게 결합되며 함수는 함께 절차를 구성하는 다른 함수들과도 시간적으로 강하게 결합돼 있다. 즉, 자연스럽게 함수들의 실행 순서를 정의하는 시간 제약을 강조한다.
데이터 변경으로 인한 파급효과
하향식 기능 분해의 가장 큰 문제점은 어떤 데이터를 어떤 함수가 사용하고 있는지 파악하기 어렵기 때문에 데이터 변경으로 인해 어떤 함수가 영향을 받을지 예상 또한 어렵다.
이것은 의존성과 결합도의 문제다.
데이터 변경으로 인한 영향을 취소화하려면 데이터와 함께 변경되는 부분과 그렇지 않은 부분을 명확히 분리해야 한다.
-> 잘 정의된 퍼블릭 인터페이스를 통해 변경되는 부분에 대한 접근을 통제해야한다.
데이비드 피나스(David Parnas)는 기능 분해가 가진 본질적인 문제를 해결하기 위해 정보 은닉과 모듈이라는 개념을 제시했다.
03 모듈
정보 은닉과 모듈
앞에서 설명한 것처럼 시스템의 변경을 관리하는 기본적인 전략은 함께 변경되는 부분을 하나의 구현 단위로 묶고 퍼블릭 인터페이스를 통해서만 접근하도록 만드는 것이다.
정보은닉은 쉽게 변경되지 않을 퍼블릭 인터페이스를 외부에 제공해서 내부의 비밀에 함부로 접근하지 못하게 한다.
모듈과 기능 분해는 상호 배타적인 관계가 아니다.
비밀을 결정하고 모듈을 분해한 후에는 기능 분해(하나의 기능을 구현하기위해 필요한 기능을 순차적으로 탐색)를 이용해 모듈에 필요한 퍼블릭 인터페이스를 구현할 수 있다.
모듈은 다음과 같은 두 가지 비밀을 감춰야 한다.
- 복잡성: 외부에 모듈을 추상화할 수 있는 인터페이스를 제공해서 모듈의 복잡도를 낮춘다.
- 변경 가능성: 변경 가능한 어떠한것이 외부로 노출될 경우 파급효과가 커진다. 변경 발생시 하나의 모듈만 수정하면 되도록 내부로 감추며 외부에는 쉽게 변경되지 않는 인터페이스를 제공한다.
정보은닉과 데이터 캡슐화는 동일한 개념이 아니다. 데이터 캡슐화는 데이터와 메서드를 하나의 단위로 통합, 퍼블릭 메서드로 접근을 허용하는 방법을 말하며 정보은닉은 변경과 관련된 비밀을 감추는 것이다. 데이터 캡슐화는 비밀의 한 종류인 '데이터'를 감추는 캡슐화의 한 종류일 뿐이다.
다음은 전체 직원에 관한 처리를 Employees 모듈로 캡슐화한 결과이다.
module Employees
$employees = ["직원A", "직원B", "직원C"]
$basePays = [400, 300, 250]
$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.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()
return = 0
for name in $employees
if (not Employees.hourly?(name)) then
result += $basePays[$employees.index(name)]
end
end
return result
end
end
def main(operation, args={})
case(operation)
when :pay then calculatePay(args[:name])
when :basePays then sumOfBasePays()
end
end
def calculatePay(name)
taxRate = getTexRate()
pay = Employees.calculatePay(name, taxRate)
puts(describeResult(name, pay))
end
def getTaxRate()
print("세율을 입력하세요: ")
return gets().chomp().to_f()
end
모듈의 장점과 한계
모듈의 장점은 다음과 같다
- 모듈 내부의 변수가 변경되더라도 모듈 내부에만 영향을 미친다
- 비즈니스 로직과 사용자 인터페이스에 대한 관심사를 분리한다
- 전역 변수와 전역 함수를 제거함으로써 네임스페이스 오염을 방지한다.
그러나 Employees 모듈은 단지 회사에 속한 모든 직원 정보를 가지고 있는 모듈일 뿐이다. 높은 수준의 추상화를 위해서는 직원 전체가 아닌 개별 직원을 독립적인 단위로 다룰 수 있어야한다. 이를 만족시키는 개념이 추상 데이터 타입이다.
04 데이터 추상화와 추상 데이터 타입
추상 데이터 타입
추상 데이터 타입을 구현하려면 다음과 같은 특성을 위한 프로그래밍 언어의 지원이 필요하다.
- 타입 정의를 선언할 수 있어야 한다.
- 타입의 인스턴스를 다루기 위해 사용할 수 있는 오퍼레이션의 집합을 정의할 수 있어야 한다.
- 제공된 오퍼레이션을 통해서만 조작할 수 있도록 데이터를 외부로부터 보호할 수 있어야 한다.
- 타입에 대해 여러 개의 인스턴스를 생성할 수 있어야 한다.
추상 데이터 타입을 이용해 급여 관리 시스템을 개선해보자.
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 - (base * taxRate)
end
def monthlyBasePay()
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 calculate(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
05 클래스
클래스는 추상 데이터 타입인가?
추상 데이터 타입과 클래스는 동일하지 않다 가장 핵심적인 차이로 클래스는 상속과 다형성을 지원하는 데 비해 추상 데이터 타입은 지원하지 못한다.
윌리엄 쿡은 추상 데이터 타입은 타입을 추상화한 것이고 클래스는 절차를 추상화한 것이라고 말한다.
앞서 추상데이터 타입으로 구현된 Employee타입을 보면 물리적으로는 하나의 타입이지만 퍼블릭 오퍼레이션인 calculatePay, monthlyBasePay는 직원의 유형에 따라 서로 다른 방식으로 동작한다.
이처럼 하나의 대표적인 타입이 다수의 세부적인 타입을 감추기 때문에 타입 추상화라고 불렀다.
즉, 오퍼레이션으로 정규직원이나 아르바이트직원이 있다는 사실을 알 수 없다.
추상 데이터 타입은 오퍼레이션을 기준으로 타입을 묶는다.!
반대로 객체지향은 타입을 기준으로 오퍼레이션을 묶는다.
실제로 내부에서 수행되는 절차는 다르지만 클래스를 이용한 다형성은 절차에 대한 차이점을 감춘다. 즉 객체 지향은 절차 추상화이다.
변경을 기준으로 선택하라
단순히 클래스를 구현 단위로 사용한다는 것이 객체지향 프로그래밍을 한다는 것을 의미하지는 않는다. 타입을 기준으로 절차를 추상화하지 않았다면 그것은 객체지향 분해가 아니다! 비록 클래스를 사용하고 있더라고 말이다.
클래스가 객체지향의 개념을 따른다면 인스턴스 변수에 저장된 값에 기반해 메서드 내에서 타입을 명시적으로 구분하지 않는다.
즉, 메서드내에 조건문으로 타입을 구분하지 않고 다형성으로 대체한다.
조건문으로 타입을 검사한다면? 새로운 타입이 추가될 때마다 타입을 체크하는 조건문을 모두 찾아 수정해야하지만, 객체지향 즉, 다형성으로 대체된다면 기존 코드의 변경이 아닌 새로운 타입을 가지는 클래스를 추가하기만 하면 된다.
추상데이터 타입은 모든 경우에 최악의 선택일까?
새로운 타입을 빈번하게 추가해야 하다면 객체지향의 클래스 구조가 더 유용하지만 새로운 오퍼레이션을 빈번하게 추가해야 한다면 추상 데이터 타입을 선택하는 것이 현명한 판단이다.
'📚 Book > Object' 카테고리의 다른 글
09 유연한 설계 (1) | 2020.03.14 |
---|---|
08 의존성 관리하기 (0) | 2020.03.08 |
06 메세지와 인터페이스 (0) | 2020.02.19 |
05 책임 할당하기 (1) | 2020.02.16 |
04 설계 품질과 트레이드 오프 (0) | 2020.02.10 |