인간의 인지 능력에는 한계가 있어서, 큰 문제를 한 번에 다룰 수 없다. 그래서 우리는 문제를 추상화하고 분해한다. 이 장은 분해의 두 가지 큰 갈래를 급여 관리 시스템 예제로 비교한다 — 프로시저(기능) 중심으로 나눌 것인가, 데이터(타입) 중심으로 나눌 것인가. 그리고 데이터 중심 안에서도 추상 데이터 타입과 객체지향이 어떻게 갈라지는지 본다.
프로시저 추상화 — 기능 분해
전통적 방식은 시스템이 “무엇을 하는가”를 큰 기능으로 잡고, 그것을 작은 서브 프로시저로 쪼갠다. 급여 계산이라면 직원_정보_조회 → 급여_계산 → 명세서_출력으로 나누는 식이다. 데이터는 프로시저들이 공유하는 전역 정보로 남는다.
// 프로시저 추상화 — 하나의 큰 함수를 작은 함수로 분해, 데이터는 전역 배열
public class Payroll {
static String[] employees = {"직원A", "직원B", "직원C"};
static int[] basePays = {400, 300, 250};
public static void main(String[] args) {
for (int i = 0; i < employees.length; i++) {
double pay = calculatePay(i); // 기능으로 분해
printPaySlip(employees[i], pay);
}
}
static double calculatePay(int i) {
return basePays[i] - basePays[i] * 0.03; // 세금 규칙이 여기 박혀 있다
}
static void printPaySlip(String name, double pay) {
System.out.println(name + ": " + pay);
}
}이 구조의 급소는 데이터와 기능이 분리돼 있다는 점이다. basePays 배열의 형태가 바뀌면 그것을 참조하는 모든 프로시저가 흔들린다. 기능 하나를 바꾸면 그 기능이 만지는 전역 데이터를 공유하는 다른 기능까지 영향을 받는다. 요구사항 변경이 시스템 전체로 번진다.
판단 기준: 기능 분해는 “무엇을 하는가”가 안정적이고 데이터 구조가 잘 안 바뀌는 경우엔 명료하다. 함정: 소프트웨어에서 가장 자주 바뀌는 건 기능이다. 기능을 분해의 축으로 삼으면 변경이 곧 구조의 붕괴가 된다.
데이터 추상화 — 타입 중심
반대 방식은 데이터를 중심에 놓는다. 관련된 데이터와 그 데이터를 다루는 연산을 한 덩어리로 묶어 하나의 타입으로 만든다. 급여 예제라면 Employee라는 타입이 자기 기본급과 세금 규칙을 함께 가진다.
// 데이터 추상화 — 데이터와 연산을 하나의 타입으로 묶는다
public class Employee {
private String name;
private int basePay;
public Employee(String name, int basePay) {
this.name = name;
this.basePay = basePay;
}
public double calculatePay() { // 데이터를 다루는 연산이 함께 산다
return basePay - basePay * 0.03;
}
public String paySlip() {
return name + ": " + calculatePay();
}
}이제 급여 규칙이 바뀌어도 Employee 안만 열면 된다. 데이터 표현(basePay)이 바뀌어도 그 타입 밖으로 영향이 새지 않는다 — 데이터가 캡슐 안에 숨었기 때문이다. 변경의 파장이 타입 경계 안에 갇힌다.
데이터 추상화는 다시 두 갈래로 나뉜다.
flowchart TD P["큰 문제를 어떻게 나눌까?"] P --> A["프로시저 추상화<br/>기능으로 분해 · 데이터는 전역 공유"] P --> D["데이터 추상화<br/>데이터+연산을 타입으로 묶음"] D --> ADT["추상 데이터 타입(ADT)<br/>타입 안에서 스스로 분기<br/>if(type == ...)"] D --> OOP["객체지향(클래스)<br/>타입을 상속·다형성으로 분리<br/>분기 대신 오버라이딩"] A -.->|기능 변경이 전체로 번짐| Weak["변경에 취약"] ADT -.->|타입 추가 시 분기문 수정| Weak2["개방-폐쇄 위반"] OOP -.->|타입 추가는 클래스 추가| Strong["확장에 열림"]
추상 데이터 타입 vs 객체지향 클래스
데이터 추상화라고 다 객체지향은 아니다. 둘의 차이는 여러 타입을 어떻게 다루는가에서 갈린다. 급여 규칙이 정규직/시급직으로 나뉜다고 하자.
// 추상 데이터 타입(ADT) — 타입을 데이터 필드로 두고 안에서 분기한다
public class Employee {
enum Type { SALARIED, HOURLY }
private Type type;
private int basePay;
private int workedHours;
public double calculatePay() {
return switch (type) { // 타입이 늘면 이 분기를 고쳐야 한다
case SALARIED -> basePay - basePay * 0.03;
case HOURLY -> basePay * workedHours * 0.97;
};
}
}
// 객체지향 — 타입 자체를 클래스로 나누고 오버라이딩으로 분리한다
public abstract class Employee {
protected int basePay;
public abstract double calculatePay(); // 분기문이 없다
}
public class SalariedEmployee extends Employee {
public double calculatePay() { return basePay - basePay * 0.03; }
}
public class HourlyEmployee extends Employee {
private int workedHours;
public double calculatePay() { return basePay * workedHours * 0.97; }
}핵심 차이는 변경의 방향이다. ADT는 새 직원 유형이 생기면 calculatePay의 switch를 열어 case를 추가해야 한다 — 기존 코드를 수정한다. 객체지향은 Employee를 상속한 새 클래스를 하나 더 만들면 끝이다 — 기존 코드를 건드리지 않는다. 전자는 타입 추가가 어렵고 연산 추가가 쉽다, 후자는 그 반대다.
판단 기준: 다뤄야 할 타입이 자주 늘어난다면 객체지향(다형성)이 유리하다 — 확장이 클래스 추가로 끝난다. 반대로 타입은 고정인데 연산(오퍼레이션)이 자주 는다면 ADT가 유리하다 — 새 연산 하나를 한곳에 추가하면 모든 타입을 커버한다. 이 대칭을 표현 문제(Expression Problem)라 부른다. 함정: enum을 필드로 두고 여기저기서 그 값으로 if/switch를 돌리면, 겉모습은 클래스여도 실제로는 ADT다. 그리고 그 분기가 여러 곳에 흩어져 있으면 타입 하나 추가에 여러 파일을 열어야 한다. 5장에서 조건 타입을 다형성으로 흩은 것이 바로 ADT식 분기를 객체지향으로 옮긴 사례다.
정리
- 큰 문제는 추상화하고 분해해서 다룬다. 분해의 축을 기능으로 잡느냐 데이터로 잡느냐가 첫 갈림길이다.
- 프로시저 추상화(기능 분해)는 데이터와 기능이 분리돼, 자주 바뀌는 기능이 전역 데이터를 공유하는 다른 기능까지 흔든다.
- 데이터 추상화는 데이터와 연산을 타입으로 묶어 변경의 파장을 타입 경계 안에 가둔다.
- 데이터 추상화 안에서도 ADT와 객체지향이 갈린다. 타입이 자주 늘면 다형성(클래스), 연산이 자주 늘면 ADT가 유리하다 — 표현 문제.
enum필드로 분기하는 클래스는 겉만 객체지향인 ADT다. 그 분기를 다형성으로 흩는 것이 5장의 리팩터링이었다.
다음장으로 8장