실제로 문제를 해결하기 위해 사용하는 저장소는 장기 기억이 아닌 단기 기억이다.
문제 해결에 필요한 요소의 수가 단기 기억의 용량을 초과하는 순간 인지 과부하가 발생한다.
인지 과부화를 방지하기 위해 불필요한 정보를 제거하고 현재의 문제 해결에 필요한 핵심만 남기는 작업이 추상화다.
가장 일반적인 추상화 방법은 한 번에 다뤄야 하는 문제의 크기를 줄이는 것이다.
큰 문제를 해결 가능한 작은 문제로 나누는 작업이 분해다.
추상화를 더 큰 규모의 추상화로 압축시킴으로써 단기 기억의 한계를 초월할 수 있다.
1. 프로시저 추상화와 데이터 추상화
프로그래밍 패러다임은 다음 두 가지로 결정된다.
- 프로그래밍을 구성하기 위해 사용하는 추상화의 종류
- 이 추상화를 이용해 소프트웨어를 분해하는 방법
모든 프로그래밍 패러다임은 추상화와 분해의 관점에서 설명할 수 있다.
소프트웨어는 데이터를 이용해 정보를 표현하고, 프로시저를 이용해 데이터를 조작한다.
- 프로시저 추상화: 소프트웨어가 무엇을 해야 하는지를 추상화 => 기능 분해
- 데이터 추상화: 소프트웨어가 무엇을 알아야 하는지를 추상화 => 알고리즘 분해
- 추상 데이터 타입: 데이터를 중심으로 타입을 추상화
- 객체지향: 데이터를 중심으로 프로시저를 추상화
'역할과 책임을 수행하는 객체'가 바로 객체지향 패러다임이 이용하는 추상화다.
기능을 '협력하는 공동체'를 구성하도록 객체들로 나누는 과정이 객체지향 패러다임에서의 분해다.
기능을 구현하기 위해 필요한 객체를 식별하고 협력 가능하도록 시스템을 분해한 후에는 프로그래밍 언어로 구현을 해야 한다.
프로그래밍 언어의 관점의 객체지향: 데이터를 중심으로 데이터 추상화와 프로시저 추상화를 통합한 객체를 이용해 시스템을 분해하는 방법
클래스: 이런 객체를 구현하기 위한 도구
2. 프로시저 추상화와 기능 분해
알고리즘 분해/기능 분해: 기능을 기준으로 사용한 시스템 분해 방식
추상화의 단위는 프로시저이며 시스템은 프로시저를 단위로 분해된다.
시스템은 더 작은 작업으로 분해될 수 있는 하나의 커다란 메인 함수다.
프로시저: 반복적으로 또는 유사하게 실행되는 작업들을 모아놓아서 로직을 재사용하고 중복을 방지하는 추상화 방법
내부 상세 구현을 모르더라도 인터페이스만 알면 프로시저를 사용할 수 있다. (잠재적인 정보은닉의 가능성 제시)
하향식 접근법: 전통적인 기능 분해 방법. 시스템을 구성하는 최상위 기능을 정의하고 좀 더 작은 단계의 하위 기능으로 분해해 나가는 방법
정제된 기능은 상위 기능보다 덜 추상적이어야 한다.
기능 분해 방법을 이용하여 급여 관리 시스템을 구현해보자.
좀 더 세부적인 절차로 구체화시키며 분해해 나가자.
- 직원의 급여 계산 (메인 프로시저)
- 사용자로부터 소득세율을 입력받는다
- "세율을 입력하세요: "라는 무장을 화면에 출력한다
- 키보드를 통해 세율을 입력받는다.
- 직원의 급여를 계산한다
- 전역 변수에 저장된 직원의 기본급 정보를 얻는다
- 급여를 계산한다
- 양식에 맞게 결과를 출력한다
- "이름: {직원명}, 급여: {계산된 금액}" 형식에 따라 출력 문자열을 생성한다
- 사용자로부터 소득세율을 입력받는다
기능 분해의 결과는 최상위 기능을 수행하는 데 필요한 절차들을 실행 순서에 따라 나열한 것이다.
급여 관리 시스템은 커다란 하나의 메인 함수고, 입력은 직원정보와 소득세율, 출력은 계산된 급여정보다.
위 절차들을 코드로 표현해보자.
import readline from "readline";
import { stdin as input, stdout as output } from "process";
const rl = readline.createInterface({ input, output });
const employees = ["직원A", "직원B", "직원C"];
const basePays = [400, 300, 250];
// 1. 직원의 급여를 계산한다
const main = (name: string) => {
const taxRate = getTaxRate();
const pay = calculatePayFor(name, taxRate);
describeResult(name, pay);
};
// 1-1. 사용자로부터 소득세율을 입력받는다
const getTaxRate = () => {
rl.on("line", (line: string) => {
// 1-1-1. "세율을 입력하세요: "라는 문장을 화면에 출력한다
// 1-1-2. 키보드를 통해 세율을 입력받는다
console.log("세율을 입력하세요: ", line);
rl.close();
return line;
});
};
// 2. 직원의 급여를 계산한다
const calculatePayFor = (name: string, taxRate: number) => {
// 1-2-1. 전역 변수에 저장된 직원으 ㅣ기본급 정보를 얻는다
const index = employees.findIndex((i) => i === name);
const basePay = basePays[index];
// 1-2-2. 급여를 계산한다
return basePay - basePay * taxRate;
};
// 3. 양식에 맞게 결과를 출력한다
const describeResult = (name, pay) => {
// 1-3-1. "이름: {직원명}, 급여: {계산된 금액}" 형식에 따라 출력 문자열을 생성한다
return `이름: ${name}, 급여: ${pay}`;
};
// 프로시저 호출
main("직원C");
하향식 기능 분해는 메인 함수를 루트로 하는 트리로 표현할 수 있다.
트리에서 각 노드는 시스템을 구성하는 하나의 프로시저이며, 한 노드의 자식 노드는 부모 노드를 구현하는 절차 중 한 단계다.
하향식 기능 분해 방법을 실제 설계에 적용하려면 다양한 문제에 직면한다.
- 시스템은 하나의 메인 함수로 구성돼 있지 않다.
대부분의 시스템에서 하나의 메인 기능이란 개념은 존재하지 않는다. 실제 시스템에 정싱이란 없다. - 기능 추가나 요구사항 변경으로 인해 메인 함수를 빈번하게 수정해야 한다.
실제 시스템은 여러 개의 정상으로 구성되기 때문에, 새로운 정상을 추가할 때마다 내부 구현을 수정해야 한다. - 비즈니스 로직이 사용자 인터페이스와 강하게 결합된다.
설계 초기 단계부터 입력 방법과 출력 양식을 함께 고민하게 만든다.
코드 내에 비즈니스 로직과 사용자 인터페이스 로직이 밀접하게 결합되어 관심사의 분리가 힘들어진다. - 하향식 분해는 너무 이른 시기에 함수들의 실행 순서를 고정시키기 때문에 유연성과 재사용성이 저하된다.
what이 아닌 how에 집중하게 만든다. 실행 순서를 정의하는 시간 제약을 강조하게 된다.
하위 함수는 상위 함수가 강요하는 문맥 안에서만 의미를 가지기 때문에 결합도가 높고 재사용성이 낮다. - 데이터 형식이 변경될 경우 파급효과를 예측할 수 없다.
어떤 데이터를 어떤 함수가 사용하고 있는지 추적하기 어렵다.
설계는 코드 배치 방법이며 설계가 필요한 이유는 변경에 대비하기 위한 것이다.
하향식 접근법과 기능분해는 변경에 취약한 설계를 낳는다는 근본적인 문제점을 가진다.
시간적 제약이 아닌 논리적 제약을 설계의 기준으로 삼곡 객체 사이으이 논리적인 관계를 중심으로 설계를 해야 한다.
퍼블릭 인터페이스를 통해 데이터에 대한 접근을 통제하여 데이터와 함께 변경되는 부분과 그렇지 않은 부분을 분리해야 한다.
하향식 분해는 작은 프로그램과 개별 알고리즘에 대해서는 유용한 패러다임이다.
그러나 소프트웨어의 경우에는 확장이 어렵게 된다.
3. 모듈
함께 변경되는 부분을 하나의 구현 단위로 묶고 퍼블릭 인터페이스를 통해서만 접근하도록 해야 한다.
기능 기반이 아닌, 변경 방향을 기반으로 시스템을 분해해야 한다.
모듈은 서브 프로그램이라기보다는 책임의 할당이다.
정보 은닉: 시스템을 모듈 단위로 분해하기 위한 기본 원리
시스템에서 자주 변경되는 부분을 덜 변경되는 인터페이스 뒤로 감추자.
모듈과 기능 분해는 상호 배타적인 관계가 아니다.
- 기능 분해: 하나의 기능을 구현하기 위해 필요한 기능들을 순차적으로 찾아가는 탐색의 과정
- 모듈 분해: 감춰야 하는 비밀을 선택하고 비밀 주변에 안정적인 보호막을 설치하는 보존의 과정
모듈을 분해한 후 기능 분해를 이용해 모듈에 필요한 퍼블릭 인터페이스를 구현할 수 있다.
모듈이 감춰야 하는 비밀
- 복잡성: 모듈을 추상화할 수 있는 간단한 인터페이스를 제공해서 모듈의 복잡도를 낮추자.
- 변경 가능성: 변경 가능한 설계 결정을 모듈 내부로 감추고 외부에는 쉽게 변경되지 않을 인터페이스를 제공하자.
급여 관리 시스템에서 외부로 감춰야 하는 비밀은 직원 정보와 관련된 것이다.
전체 직원에 관한 처리를 Employees 모듈로 캡슐화하자.
const employees = ["직원A", "직원B", "알바A"];
const basePays = [400, 300, 250];
const hourlys = [false, false, true];
const timeCards = [0, 0, 120];
const calculatePay = (name: string, taxRate: number) => {
if (hourly(name)) {
return calculateHourlyPayFor(name, taxRate);
} else {
return calculatePayFor(name, taxRate);
}
};
const hourly = (name: string) => {
return hourlys[employees.findIndex((e) => e === name)];
};
const calculateHourlyPayFor = (name: string, taxRate: number) => {
const index = employees.findIndex((e) => e === name);
const basePay = basePays[index] * timeCards[index];
return basePay - basePay * taxRate;
};
const calculatePayFor = (name: string, taxRate: number) => {
const index = employees.findIndex((e) => e === name);
const basePay = basePays[index];
return basePay - basePay * taxRate;
};
const sumOfBasePays = () => {
let result = 0;
employees.forEach((employee) => {
if (!hourly(employee)) {
result += basePays[employees.findIndex((e) => e === employee)];
}
});
return result;
};
export const Employee = {
calculatePay,
sumOfBasePays,
};
기존 전역변수들이 Employees라는 모듈 내부로 숨겨졌다.
외부에서는 퍼블릭 인터페이스에 포함된 함수를 통해서만 내부 변수를 조작할 수 있다.
import { Employee } from "./employees";
const main = (operation, args = {}) => {
switch (operation) {
case "pay":
calculatePay(args["name"]);
case "basePays":
sumOfBasePays();
}
};
const calculatePay = (name: string) => {
const taxRate = getTaxRate();
const pay = Employee.calculatePay(name, taxRate);
describeResult(name, pay);
};
const getTaxRate = () => {
let taxRate = 0;
rl.on("line", (line: string) => {
console.log("세율을 입력하세요: ", line);
rl.close();
taxRate = Number(line);
});
return taxRate;
};
const describeResult = (name: string, pay: number) => {
return `이름: ${name}, 급여: ${pay}`;
};
const sumOfBasePays = () => {
Employee.sumOfBasePays();
};
모듈의 장점
- 모듈 내부의 변수가 변경되더라도 모듈 내부에만 영향을 미친다.
- 비즈니스 로직과 사용자 인터페이스에 대한 고나심사를 분리한다.
- 전역 변수와 전역 함수를 제거함으로써 네임스페이스 오염을 방지한다.
모듈의 한 가지 용도는 네임스페이스를 제공하는 것이다. 다른 모듈에서도 동일한 이름을 사용할 수 있게 된다.
모듈은 기능이 아니라 변경의 정도에 따라 시스템을 분해하게 한다.
각 모듈은 감춰야 하는 비밀과 관련성 높은 데이터와 함수의 집합이다.
모듈 내부는 높은 응집도를, 모듈 간에는 낮은 결합도를 유지한다.
모듈은 정보 은닉이라는 개념으로 데이터를 설계의 중심 요소로 부각시켰다.
하향식 기능 분해와달리 모듈은 감춰야 할 데이터를 결정하고 이 데이터를 조작하는 데 필요한 함수를 결정한다.
모듈은 프로시저 추상화보다는 높은 추상화 개념을 제공하지만, 변경을 관리하기 위한 구현 기법이기 때문에 여전히 한계가 있다.
모듈은 인스턴스 개념을 제공하지 않는다. 모든 직원 정보를 가지고 있을 뿐이다.
좀 더 높은 수준의 추상화를 위해서는 개별 직원을 독립적인 단위로 다룰 수 있어야 한다.
4. 데이터 추상화와 추상 데이터 타입
추상 데이터 타입
타입: 변수에 저장할 수 있는 내용물의 종류와 변수에 적용될 수 있는 연산의 가지수
저장된 값에 대해 수행될 수 있는 연산의 집합을 결정하기 때문에 값의 행동을 예측할 수 있게 한다.
기존 프로시저 추상화를 보완하기 위해 데이터 추상화 개념이 등장했다.
추상 데이터 타입은 추상 객체의 클래스를 정의한 것으로 추상 객체에 사용할 수 있는 오퍼레이션을 이용해 규정된다.
추상 데이터 타입을 구현하려면 다음과 같은 언어의 지원이 필요하다.
타입 정의를 선언할 수 있어야 한다.
- 타입의 인스턴스를 다루기 위해 사용할 수 있는 오퍼레이션의 집합을 정의할 수 있어야 한다.
- 제공된 오퍼레이션을 통해서만 조작할 수 있도록 데이터를 외부로부터 보호할 수 있어야 한다.
- 타입에 대해 여러 개의 인스턴스를 생성할 수 있어야 한다.
클래스를 이용해 개별 직원을 위한 추상 데이터 타입을 구현해보자.
(클래스와 추상 데이터 타입은 동일하지 않다.)
class Employee {
private name: string;
private basePay: number;
private hourly: boolean;
private timeCard: number;
constructor(name: string, basePay: number, hourly: boolean, timeCard: number) {
this.name = name;
this.basePay = basePay;
this.hourly = hourly;
this.timeCard = timeCard;
}
public calculatePay(name: string, taxRate: number) {
if (this.hourly) {
return this.calculateHourlyPayFor(taxRate);
} else {
return this.calculateSalariedPay(taxRate);
}
}
public monthlyBasePay() {
if (this.hourly) return 0;
return this.basePay;
}
private calculateHourlyPayFor(taxRate: number) {
return this.basePay * this.timeCard - this.basePay * this.timeCard * taxRate;
}
private calculateSalariedPay(taxRate: number) {
return this.basePay - this.basePay * taxRate;
}
}
const employees = [
new Employee("직원A", 400, false, 0),
new Employee("직원B", 300, false, 0),
new Employee("알바A", 250, true, 120),
];
Employee 추상 데이터 타입은 전체 직원을 캡슐화하는 Employees 모듈보다는 좀 더 개념적으로 사람들의 사고방식에 가깝다.
하지만 여전히 데이터와 기능을 분리해서 바라본다. 기능을 구현하는 핵심 로직은 추상 데이터 타입 외부에 존재한다. (main의 로직들)
5. 클래스
클래스와 추상 데이터 타입 모두 데이터 추상화를 기반으로 시스템을 분리하며,
퍼블릭 인터페이스를 통해서만 의사소통할 수 있다.
그러나 클래스는 상속과 다형성을 지원하지만 추상 데이터 타입은 지원하지 못한다.
- 객체지향 프로그래밍: 상속과 다형을 지원하는 프로그래밍 패러다임
- 객체기반 프로그래밍: 상속과 다형성을 지원하지 않는 추상 데이터 타입 기반 프로그래밍 패러다임
추상 데이터 타입으로 구현된 Employee 내부에는 정규 직원과 아르바이트 직원이라는 두 개의 타입이 공존한다.
직원 유형에 따라 calculatePay와 monthlyBasePay는 서로 다르게 동작한다.
Employee를 사용하는 클라이언트는 두 오퍼레이션을 호출할 수 있지만, 직원에 두 종류가 있다는 사실은 알 수 없다.
구체적인 직원 타입을 외부에 캡슐화하고 있는 것이다.
타입 추상화는 하나의 대표적인 타입이 다수의 세부적인 타입을 감춘다.
개별 오퍼레이션이 모든 개념적인 타입에 대한 구현을 포괄하도록 함으로써 하나의 물리적인 타입 안에 전체 타입을 감춘다.
즉, 오퍼레이션을 기준으로 타입을 통합하는 데이터 추상화 기법이다.
- 추상 데이터 타입: 오퍼레이션을 기준으로 타입을 묶는 방법
- 객체지향: 타입을 기준으로 오퍼레이션을 묶는 방법
반면 객체지향은 정규 직원과 아르바이트 직원 각각에 대한 클래스를 명시적으로 정의하고,
각 클래스에서 각 오퍼레이션을 적절히 구현할 것이다.
공통 로직을 포함할 부모 클래스를 정의하고 두 직원 유형의 클래스가 부모 클래스를 상속받게 될 것이다.
클래스를 이용한 다형성으로 클라이언트는 서로 다른 두 절차에 대한 차이를 모른다.
- 클래스: 타입을 기준으로 절차를 추상화
- 추상 데이터 타입: 오퍼레이션을 기준으로 타입을 추상화
이제 추상 데이터 타입에서 클래스로 변경해보자.
추상 클래스와 추상 메서드를 사용했다.
abstract class Employee {
private name: string;
private basePay: number;
constructor(name: string, basePay: number) {
this.name = name;
this.basePay = basePay;
}
abstract calculatePay(taxRate: number): number;
abstract monthlyBasePay(): number;
}
두 종류의 직원 클래스를 구현하자.
class SalariedEmployee extends Employee {
constructor(name: string, basePay: number) {
super(name, basePay);
}
calculatePay(taxRate: number): number {
return this.basePay - this.basePay * taxRate;
}
monthlyBasePay(): number {
return this.basePay;
}
}
class HourlyEmployee extends Employee {
private timeCard: number;
constructor(name: string, basePay: number, timeCard: number) {
super(name, basePay);
this.timeCard = timeCard;
}
calculatePay(taxRate: number): number {
return this.basePay * this.timeCard - this.basePay * this.timeCard * taxRate;
}
monthlyBasePay(): number {
return 0;
}
}
모든 직원 타입에 대해 Employee의 인스턴스를 생성해야 했던 추상 데이터 타입의 경우와 달리,
클라이언트가 원하는 직원 타입에 해당하는 클래스의 인스턴스를 명시적으로 지정할 수 있다.
const employees = [
new SalariedEmployee("직원A", 400),
new SalariedEmployee("직원B", 300),
new HourlyEmployee("알바A", 250, 120),
];
객체를 생성하고 나서는 객체의 클래스가 무엇인지는 중요하지 않다.
클라이언트의 입장에서는 두 종류의 인스턴스 모두 Employee의 인스턴스인 것처럼 다룰 수 있다.
변경을 기준으로 선택하라
타입을 기준으로 절차를 추상화하지 않았다면 객체지향 분해가 아니다.
클래스가 절차가 아닌 타입을 추상화했는지 확인하려면 클래스 내부에 인스턴스의 타입을 표현하는 변수가 있는지 살펴보면 된다.
추상 데이터 타입 Employee의 경우 hourly라는 인스턴스 변수에 직원의 유형을 저장한다.
인스턴스 변수에 저장된 값을 기반으로 메서드 내에서 타입을 명시적으로 구분하는 것은 객체지향을 위반하는 것이다.
객체지향은 다형성을 사용한다. 클라이언트가 아닌 객체가 메시지를 처리할 적절한 메서드를 선택한다. 따라서 변경에 대처하기 쉽다.
기존 코드에 영향을 미치지 않고 새로운 객체 유형과 행위를 추가할 수 있는 객체지향의 특성을 개방-폐쇄 원칙이라고 한다.
설계는 변경과 관련된 것이다.
- 추상 데이터 타입이 유용할 때: 변경의 압력이 '오퍼레이션 추가'에 관한 것일 때
- 객체지향 설계가 유용할 때: 변경의 압력이 '타입 추가'에 관한 것일 때
객체지향의 경우 새로운 오퍼레이션을 추가하기 위해서는 상속 계층에 속하는 모든 클래스를 한 번에 수정해야 한다.
변경의 축을 찾아라. 객체지향이 무조건 올바른 해결 방법인 것은 아니다.
데이터 주도 설계가 추상 데이터 타입의 접근법을 객체지향 설계에 구현한 것이다.
모듈과 추상 데이터 타입은 데이터 중심적인 관점을 취하며, 객체지향은 서비스 중심적인 관점을 취한다.
협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션의 구현 방식을 타입별로 분해하는 것은 올바른 접근법이 아니다.
이번 장에서는 객체에게 로직을 분배하는 방법에 있어서 추상 데이터 타입과 클래스의 차이를 살펴 본 것이다.
객체를 설계하는 방법은 책임 주도 설계를 따라야 한다.
객체가 참여할 협력 결정하고 협력에 필요한 책임을 수행하기 위해 어떤 객체가 필요한지 고민한 다음,
그 책임을 다양한 방식으로 수행해야 할 때만 타입 계층 안에서 각 절차를 추상화하라.