07 객체 분해
* 장기 기억: 경험한 내용을 수개월에서 길게는 평생에 걸쳐 보관하는 저장소를 의미한다.
* 단기 기억: 보관돼 있는 지식에 직접 접근할 수 있지만 정보를 보관할 수 있는 속도와 공간적인 측면 모두에서 제약을 받는다.
* 인지 과부화: 문제 해결에 필요한 요소의 수가 단기 기억의 용량을 초과하는 순간 문제 해결 능력은 급격하게 떨어지고 만다.
* 인지 과부하 방지 : 단기 기억 안에 보관할 정보의 양을 조절하는 것
* 인류가 복잡한 분야(소프트웨어 개발 영역)의 문제를 해결하기 위해 사용한 것
* 추상화 : 불필요한 정보를 제거하고 현재의 문제 해결에 필요한 핵심만 남기는 작업. 즉, 한 번에 다뤄야 하는 문제의 크기를 줄이는 것 -> 한 번에 단기 기억에 담을 수 있는 추상화의 수에는 한계가 있지만 추상화를 더 큰 규모의 추상화로 압축시킴으로써 단기 기억의 한계를 초월할 수 있다.
* 분해(decomposition) : 큰 문제를 해결 가능한 작은 문제로 나누는 작업
01 프로시저 추상화와 데이터 추상화
현대적인 프로그래밍 언어를 특정 짓는 중요한 두 가지 추상화 메커니즘은 프로시저 추상화(procedure abstraction)와 데이터 추상화(data abstraction)다.
프로시저 추상화를 중심으로 시스템을 분해하기로 결정했다면 기능 분해(functional decomposition) 의 길로 들어서는 것이다.
기능 분해는 알고리즘 분해라고 부르기도 한다. 하나는 데이터를 중심으로 타입을 추상화(type abstraction) 하는 것이고 다른 하나는 데이터를 중심으로 프로시저를 추상화(procedure abstraction)하는 것이다. 전자를 추상 데이터 타입(Abstract Data Type)이라고 부르고 후자를 객체지향(Object-Oriented) 이라고 부른다.
프로그래밍 언어의 관점에서 객체지향이란 데이터를 중심으로 데이터 추상화와 프로시저 추상화를 통합한 객체를 이용해 시스템을 분해하는 방법이다. 그리고 이런 객체를 구현하기 위해 대부분의 객체지향 언어는 클래스라는 도구를 제공한다.
따라서 프로그래밍 언어적인 관점에서 객체지향을 바라보는 일반적인 관점은 데이터 추상화와 프로시저 추상화를 함께 포함한 클래스를 이용해 시스템을 분해하는 것이다.
02 프로시저 추상화와 기능 분해
메인 함수로서의 시스템
기능은 오랜 시간 동안 시스템을 분해하기 위한 기준으로 사용됐으며, 이 같은 시스템 분해 방식을 알고리즘 분해 또는 기능 분해 라고 부른다. 기능 분해의 관점에서 추상화의 단위는 프로시저이며 시스템은 프로시저를 단위로 분해된다.
전통적인 기능 분해 방법은 하향식 접근법(Top-Down Approach)를 따른다. 하향식 접근법이란 시스템을 구성하는 가장 최상위(topmost) 기능을 정의하고, 이 최상위 기능을 좀 더 작은 단계의 하위 기능으로 분해해 나가는 방법을 말한다.
급여 관리 시스템
급여 관리 시스템을 구현하기 위해 기능 분해 방법을 이용하겠다. 전통적으로 기능 분해 방법은 하향식 접근법을 따르며 최상위의 추상적인 함수 정의에서 출발해서 단계적인 정제 절차를 따라 시스템을 구축한다.
기능 분해 방법에서는 기능을 중심으로 필요한 데이터를 결정한다. 기능 분해라는 무대의 주연은 기능이며 데이터는 기능을 보조하는 조연의 역할에 머무른다. 기능이 우선이고 데이터는 기능의 뒤를 따른다. 기능 분해를 위한 하향식 접근법은 먼저 필요한 기능을 분해하고 정제하는 과정에서 필요한 데이터의 종류와 저장 방식을 식별한다.
하향식 기능 분해 방식이 가지는 문제점을 이해하는 것은 유지보수 관점에서 객체지향의 장점을 이해할 수 있는 좋은 출발점이다.
급여 관리 시스템 구현
급여 관리 시스템의 최상위 문장은 다음과 같다
* 직원의 급여를 계산한다.
직원에 대한 정보를 찾기 위해 필요한 직원의 이름은 함수의 인자로 받기로 결정했었다.
def main(name)
end
이제 최상위 함수를 구현하기 위해 세분화한 내용을 이용해 메인 함수의 내부를 채울 차례다.
직원의 급여를 계산한다.
사용자로부터 소득세율을 입력받는다.
직원의 급여를 계산한다.
양식에 맞게 결과를 출력한다.
위의 세 단게는 모두 더 작은 세부적인 단계로 분해 가능하기 때문에 각 단계를 프로시저를 호출하는 명령문으로 변환할 수 있다.
def main(name)
taxRate = getTaxRate()
pay = calculatePayFor(name, taxRate)
puts(describeResult(name, pay))
end
사용자로부터 소득세율을 입력받는 getTaxRate 함수는 다음과 같은 두 개의 절차로 분해할 수 있다.
직원의 급여를 계산한다.
사용자로부터 소득세율을 입력받는다.
"세율을 입력하세요: "라는 문장을 화면에 출력한다.
키보드를 통해 세율을 입력받는다.
직원의 급여를 게산한다.
양식에 맞게 결과를 출력한다.
이 절차는 언어나 라이브러리에서 제공하는 기능을 이용해 충분히 구현 가능한 수준이다. 따라서 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
급여를 계산했으므로 마지막으로 급여 내역을 출력 양식에 맞게 포매팅한 후 반환하면 모든 작업이 완료된다.
직원의 급여를 계산한다.
사용자로부터 소득세율을 입력받는다.
직원의 급여를 게산한다.
양식에 맞게 결과를 출력한다.
"이름: {직원명} , 급여: {계산된 금액}" 형식에 따라 출력 문자열을 생성한다.
describeResult 함수는 이름과 급여 정보를 이용해 출력 포맷에 따라 문자열을 조합한 후 반환한다.
def describeResult(name, pay)
return "이름 : #{name}, 급여 : #{pay}"
end
이름이 "직원C"인 직원의 급여를 계산하려면 다음과 같이 프로시저를 호출하면 된다.
main("직원A")
하향식 기능 분해 방식으로 설계한 시스템은 메인 함수를 루트로 하는 '트리'로 표현할 수 있다.
이처럼 하향식 기능 분해는 논리적이고 체계적인 시스템 개발 절차를 제시한다.
하향식 기능 분해의 문제점
실제로 설계에 적용하다 보면 다음과 같은 다양한 문제에 직면한다.
1. 시스템은 하나의 메인 함수로 구성돼 있지 않다.
시간이 지나고 사용자를 만족시키기 위한 새로운 요구사항을 도출해 나가면서 지속적으로 새로운 기능을 추가하게 된다. 모든 기능들은 규모라는 측면에서 차이가 있을 수는 있겠지만 가능성의 측면에서는 동등하게 독립적이고 완결된 하나의 기능을 표현한다.
2. 기능 추가나 요구사항 변경으로 인해 메인 함수를 빈번하게 수정해야 한다.
기존 로직과는 아무런 상관이 없는 새로운 함수의 적절한 위치를 확보해야 하기 때문에 메인 함수의 구조를 급격하게 변경할 수밖에 없을 것이다. 기존 코드를 수정하는 것은 항상 새로운 버그를 만들어낼 확률을 높인다는 점에 주의하라.
3. 비즈니스 로직이 사용자 인터페이스와 강하게 결합된다.
4. 성급하게 결정된 실행 순서
5. 데이터 형식이 변경될 경우 파급효과를 예측할 수 없다.
하향식 기능 분해의 가장 큰 문제점은 어떤 데이터를 어떤 함수가 사용하고 있는지를 추적하기 어렵다는 것이다. 데이터의 영향 범위를 파악하기 위해서는 모든 함수를 열어 데이터를 사용하고 있는지를 모두 확인해봐야 하기 때문이다. 이를 해결하기 위해서는 변경에 대한 영향을 최소화하기 위해 영향을 받는 부분과 받지 않는 부분을 명확하게 분리하고 잘 정의된 퍼블릭 인터페이스를 통해 변경되는 부분에 대한 접근을 통제해야 한다.
언제 하향식 분해가 유용한가?
하향식 아이디어가 매력적인 이유는 설계가 어느 정도 안정화된 후에는 설계의 다양한 측면을 논리적으로 설명하고 문서화 하기에 용이하기 때문이다. 그러나 설계를 문서화 하는 데 적절한 방법이 좋은 구조를 설계할 수 있는 방법과 동일한 것은 아니다.
03 모듈
정보 은닉과 모듈
정보 은닉: 시스템을 모듈 단위로 분해하기 위한 기본 원리로 시스템에서 자주 변경되는 부분을 상대적으로 덜 변경되는 안정적인 인터페이스 뒤로 감춰야 한다는 것이 핵심이다.
정보 은닉은 외부에 감춰야 하는 비밀에 따라 시스템을 분할하는 모듈 분할 원리이다. 모듈은 변경될 가능성이 있는 비밀을 내부로 감추고, 잘 정의되고 쉽게 변경되지 않을 퍼블릭 인터페이스를 외부에 제공해서 내부의 비밀에 함부로 접근하지 못하게 한다.
시스템을 모듈로 분해한 후에는 각 모듈 내부를 구현하기 위해 기능 분해를 적용할 수 있다.
기능 분해가 하나의 기능을 구현하기 위해 필요한 기능들을 순차적으로 찾아가는 탐색의 과정이라면 모듈 분해는 감춰야 하는 비밀을 선택하고 비밀 주변에 안정적인 보호막을 설치하는 보존의 과정이다.
모듈은 다음과 같은 두 가지 비밀을 감춰야 한다.
* 복잡성: 모듈이 너무 복잡한 경우 이해하고 사용하기가 어렵다. 외부에 모듈을 추상화할 수 있는 간단한 인터페이스를 제공해서 모듈의 복잡도를 낮춘다.
* 변경 가능성 : 변경 가능한 설계 결정이 외부에 노출될 경우 실제로 변경이 발생했을 때 파급효과가 커진다. 변경 발생 시 하나의 모듈만 수정하면 되도록 변경 가능한 설계 결정을 모듈 내부로 감추고 외부에는 쉽게 변경되지 않을 인터페이스를 제공한다.
자바에서 모듈의 개념은 패키지(package)를 이용해 구현 가능하다.
모듈의 장점과 한계
Employees 예제를 통해 알 수 있는 모듈의 장점은 다음과 같다.
1) 모듈 내부의 변수가 변경되더라도 모듈 내부에만 영향을 미친다.
-> 모듈을 사용하면 모듈 내부에 정의된 변수를 직접 참조하는 코드의 위치를 모듈 내부로 제한할 수 있다. 이제 어떤 데이터가 변경됐을 때 영항을 받는 함수를 찾기 위해 해당 데이터를 정의한 모듈만 검색하면 된다.
2) 비즈니스 로직과 사용자 인터페이스에 대한 관심사를 분리한다.
사용자 입력과 화면 출력을 Employees 모듈이 아닌 외부에 뒀다는 점을 주목하라. 수정된 코드에서 Employees 모듈은 비즈니스 로직과 관련된 관심사만을 담당하며 사용자 인터페이스와 관련된 관심사는 모두 Employees 모듈을 사용하는 main함수 쪽에 위치한다.
3) 전역 변수와 전역 함수를 제거함으로써 네임스페이스 오염(namespace pollution)을 방지한다.
모듈의 한 가지 용도는 네임스페이스를 제공하는 것이다. 변수와 함수를 모듈 내부에 포함시키기 때문에 다른 모듈에서도 동일한 이름을 사용할 수 있게 된다. 따라서 모듈은 전역 네임스페이스의 오염을 방지하는 동시에 이름 충돌(name collision)의 위험을 완화한다.
모듈의 기능이 아니라 변경의 정도에 따라 시스템을 분해하게 한다.
각 모듈은 외부에 감춰야 하는 비밀과 관련성이 높은 데이터와 함수의 집합이다. 따라서 모듈 내부는 높은 응집도를 유지한다. 모듈과 모듈 사이에는 퍼블릭 인터페이스를 통해서만 통신해야 한다. 따라서 낮은 결합도를 유지한다.
모듈이 정보 은닉이라는 개념을 통해 데이터라는 존재를 설계의 중심 요소로 부각시켰다는 것이다.
모듈에 있어서 핵심은 데이터다. 메인 함수를 정의하고 필요에 따라 더 세부적인 함수로 분해하는 하향식 기능 분해와 달리 모듈은 감춰야 할 데이터를 결정하고 이 데이터를 조작하는데 필요한 함수를 결정한다.
기능이 아니라 데이터를 중심으로 시스템을 분해해라
04 데이터 추상화와 추상 데이터 타입
추상 데이터 타입
프로그래밍 언어에서 타입(Type) 이란 변수에 저장할 수 있는 내용물의 종류와 변수에 적용될 수 있는 연산의 가짓수를 의미한다.
프로그래밍 언어는 다양한 형태의 내장 타임(built-in type)을 제공한다. 기능 분해의 시대에 사용되던 절차형 언어들은 적은 수의 내장 타입만을 제공했으며 설상가상으로 새로운 타입을 추가하는 것이 불가능하거나 제한적이었다.이 시대의 프로그램에서 사용하는 주된 추상화는 프로시저 추상화였다. 시간이 흐르면서 사람들은 프로시저 추상화로는 프로그램의 표현력을 향상시키는 데 한계가 있다는 사실을 발견했다.
라스코프는 프로시저 추상화를 보완하기 위해 데이터 추상화의 개념을 제안했다.
라스코프의 업적은 소프트웨어를 이용해 표현할 수 있는 추상화의 수준을 한 단계 높였다는 점이다. 사람들은 '직원의 급여를 계산한다'라는 하나의 커다란 절차를 이용해 사고하기보다는 '직원'과 '급여'라는 추상적인 개념들을 머릿속에 떠올린 후 이들을 이용해 '계산'에 필요한 절차를 생각하는 데 익숙하다. 추상 데이터 타입은 프로시저 추상화 대신 데이터 추상화를 기반으로 소프트웨어를 개발하게 한 최초의 발걸음이다.
추상 데이터 타입을 구현하려면 다음과 같은 특성을 위한 프로그래밍 언어의 지원이 필요하다.
* 타입 정의를 선언할 수 있어야 한다.
* 타입의 인스턴스를 다루기 위해 사용할 수 있는 오퍼레이션의 집합을 정의할 수 있어야 한다.
* 제공된 오퍼레이션을 통해서만 조작할 수 있도록 데이터를 외부로부터 보호할 수 있어야 한다.
* 타입에 대해 여러 개의 인스턴스를 생성할 수 있어야 한다.
리스코프는 추상 데이터 타입을 정의하기 위해 제시한 언어적인 메커니즘을 오퍼레이션 클러스터(operation cluster)라고 불렀다.
프로그래밍 언어의 관점에서 추상 데이터 타입은 프로그래밍 언어의 내장 데이터 타입과 동일하다. 단지 타입을 개발자가 정의할 수 있다는 점이 다를 뿐이다.
05 클래스
클래스는 추상 데이터 타입인가?
상속과 다형성을 지원하는 객체지향 프로그래밍(Object-Oriented Programming)과 구분하기 위해 상속과 다형성을 지원하지 않는 추상 데이터 타입 기반의 프로그래밍 패러다임을 객체기반 프로그래밍(Object-Based Programming)이라고 부르기도 한다.
윌리엄 쿡은 Cook90에서 객체지향과 추상 데이터 타입 간의 차이를 프로그래밍 언어적인 관점에서 설명한다. 쿡의 정의를 빌리자면 추상 데이터 타입은 타입을 추상화한 것이고 클래스는 절차를 추상화한 것이다.
타입 추상화와 절차 추상화의 차이점을 이해하기 위해 먼저 추상 데티어 타입으로 구현된 Employee 타입의 calculatePay와 monthlyBasePay 오퍼레이션을 살펴보자. Employee 타입은 물리적으로는 하나의 타입이지만 개념적으로는 정규 직원과 아르바이트 직원이라는 두 개의 개별적인 개념을 포괄하는 복합 개념이다.
Employee 타입이 제공하는 퍼블릭 오퍼레이셔인 calculatePay와 monthlyBasePay는 직원 유형에 따라 서로 다른 방식으로 동작한다.
Employee 인스턴스가 정규 직원을 나타낼 경우 calculatePay 오퍼레이션은 기본급에서 세액을 공제해서 급여를 계산한다. 이에 비해 Employee 인스턴가 아르바이트 직원을 나타낼 경우 시급에 한 달 근로 시간을 곱한 금액에서 세액을 공제한다.
추상 데이터 타입이 오퍼레이션을 기준으로 타입을 묶는 방법이라면 객체지향은 타입을 기준으로 오퍼레이션을 묶는다.
객체지향은 정규 직원과 아르바이트 직원 각각에 대한 클래스를 정의하고 각 클래스들이 calculatePay와 monthlyBasePay 오퍼레이션을 적절하게 구현하게 될 것이다.
공통 로직을 제공할 수 있는 가장 간단한 방법은 공통 로직을 포함한 부모 클래스를 정의하고 두 직원 유형의 클래스가 부모 클래스를 상속받게 하는 것이다. 이제 클라이언트는 부모 클래스의 참조자에 대해 메시지를 전송하면 실제 클래스가 무엇인가에 따라 적절한 절차가 실행된다. 즉, 동일한 메시지에 대해 서로 다르게 반응한다. 이것이 바로 다형성이다.
객체지향은 절차 추상화다.
추상 데이터 타입은 오퍼레이션을 기준으로 타입들을 추상화한다. 클래스는 타입을 기준으로 절차들을 추상화한다. 이것이 추상화와 분해의 관점에서 추상 데이터 타입과 클래스의 다른 점이다.
추상 데이터 타입에서 클래스로 변경하기
두 개의 직원 타입 모두를 완전하게 구현한 추상 데이터 타입인 Employee와 다르게 클래스로 구현하는 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
루비에서 상속관계는 '자식클래스<부모클래스'의 형태로 선언한다.
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),
]
변경을 기준으로 선택하라
public Integer calculatePay(taxRate) {
if(this.houly) return calculateHourlyPay(taxRate);
return calculateSaliedPay(taxRate);
}
객체지향에서는 타입 변수를 이용한 조건문을 다형성으로 대체한다. 객체가 메시지를 처리할 적절한 메서드를 선택하게 된다.