인간의 인지 능력에는 한계가 있어서, 큰 문제를 한 번에 다룰 수 없다. 그래서 우리는 문제를 추상화하고 분해한다. 이 장은 분해의 두 가지 큰 갈래를 급여 관리 시스템 예제로 비교한다 — 프로시저(기능) 중심으로 나눌 것인가, 데이터(타입) 중심으로 나눌 것인가. 그리고 데이터 중심 안에서도 추상 데이터 타입과 객체지향이 어떻게 갈라지는지 본다.
추상화의 두 계보 — 프로시저 추상화와 데이터 추상화
분해의 방식은 결국 무엇을 추상화의 단위로 삼는가에서 갈린다. 여기에는 오래된 두 계보가 있다.
- 프로시저 추상화(procedure abstraction): “무엇을 하는가”, 즉 기능을 추상화의 단위로 삼는다. 소프트웨어를 프로시저(함수)의 계층으로 조직한다. 전통적인 하향식 구조적 프로그래밍이 여기에 속한다.
- 데이터 추상화(data abstraction): “무엇을 아는가”, 즉 데이터를 추상화의 단위로 삼는다. 데이터와 그것을 다루는 연산을 한 덩어리로 묶는다.
데이터 추상화는 다시 두 갈래로 나뉜다. 데이터를 중심에 두고 타입을 추상화하면 추상 데이터 타입(ADT) 이고, 데이터를 중심에 두되 그 데이터를 이용해 프로시저를 추상화하면 객체지향이다. 이 셋을 급여 관리 예제로 하나씩 견줘 본다. 우리가 어느 계보를 택하느냐가 변경에 대한 시스템의 저항력을 결정한다.
프로시저 추상화 — 기능 분해
전통적 방식은 시스템이 “무엇을 하는가”를 큰 기능으로 잡고, 그것을 작은 서브 프로시저로 쪼갠다. 급여 계산이라면 직원_정보_조회 → 급여_계산 → 명세서_출력으로 나누는 식이다. 데이터는 프로시저들이 공유하는 전역 정보로 남는다.
// 프로시저 추상화 — 하나의 큰 함수를 작은 함수로 분해, 데이터는 전역 배열
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 배열의 형태가 바뀌면 그것을 참조하는 모든 프로시저가 흔들린다. 기능 하나를 바꾸면 그 기능이 만지는 전역 데이터를 공유하는 다른 기능까지 영향을 받는다. 요구사항 변경이 시스템 전체로 번진다.
판단 기준: 기능 분해는 “무엇을 하는가”가 안정적이고 데이터 구조가 잘 안 바뀌는 경우엔 명료하다. 함정: 소프트웨어에서 가장 자주 바뀌는 건 기능이다. 기능을 분해의 축으로 삼으면 변경이 곧 구조의 붕괴가 된다.
기능 분해가 무너지는 네 가지 이유
기능 분해가 변경에 취약한 것은 우연이 아니라 그 방식이 세운 가정 때문이다. 급여 관리 예제로 하나씩 짚어 본다.
첫째, 하나의 메인 함수를 가정한다. 기능 분해는 시스템 전체가 “직원들의 급여를 계산하고 지급한다”는 하나의 최상위 기능을 가진다고 전제하고, 그것을 정점으로 서브 프로시저를 매단다. 하지만 실제 시스템에는 지배적인 하나의 기능이 없다. 급여 조회, 세금 정산, 명세서 출력, 연말 정산이 서로 대등하게 얽혀 있다. 하나의 메인 함수를 정점으로 두면, 새 최상위 기능이 생길 때마다 트리 구조 전체를 흔들어야 한다.
둘째, 요구사항이 항상 명확하다고 가정한다. 하향식 분해는 “무엇을 만들지 이미 안다”는 전제에서 시작한다. 정점의 기능을 정확히 알아야 그 아래로 쪼갤 수 있기 때문이다. 그러나 개발은 요구사항이 흐릿한 채로 시작해 점진적으로 발견하는 과정이다. 처음 잡은 기능 트리가 요구가 바뀌면 통째로 재구성된다.
셋째, 데이터 변경으로 인한 파급 효과를 고려하지 않는다. 기능 분해에서 데이터는 프로시저들이 공유하는 전역 정보다. basePays 배열의 형태를 바꾸면, 그것을 참조하는 모든 프로시저를 함께 고쳐야 한다. 데이터와 그것을 사용하는 기능이 물리적으로 떨어져 있어, 데이터 하나의 변경이 시스템 전역으로 번진다.
// 데이터 변경의 파급 — basePays를 int[]에서 클래스로 바꾸면
// 이 배열을 참조하는 calculatePay, printPaySlip, main이 전부 깨진다
static int[] basePays = {400, 300, 250};
static double calculatePay(int i) { return basePays[i] - basePays[i] * 0.03; }
static void printPaySlip(String name, double pay) { ... } // 전부 basePays 구조에 결합넷째, 재사용이 어렵다. 기능 분해로 만든 프로시저는 특정 위치의 전역 데이터와 특정 호출 순서에 강하게 묶인다. calculatePay(i)는 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)이 바뀌어도 그 타입 밖으로 영향이 새지 않는다 — 데이터가 캡슐 안에 숨었기 때문이다. 변경의 파장이 타입 경계 안에 갇힌다.
정보 은닉과 모듈 — 변경의 방향을 국소화한다
데이터 추상화가 변경에 강한 근본 이유는 정보 은닉(information hiding) 이다. 데이비드 파나스(David Parnas)의 통찰은 이것이다 — 시스템을 나눌 때, 자주 변경되는 부분(불안정한 결정)을 하나의 모듈 안에 감추고, 그 모듈의 안정된 인터페이스만 밖으로 드러내라. 그러면 그 결정이 바뀌어도 변경이 모듈 경계를 넘지 못한다.
여기서 “무엇을 감출 것인가”의 답은 변경될 가능성이 높은 것이다. 급여 예제에서 자주 바뀌는 것은 세율(0.03)과 데이터 표현(basePay의 타입)이다. 이것들을 Employee 모듈 안에 감추면, 바깥의 어떤 코드도 세율이나 내부 표현을 모른 채 calculatePay()라는 안정된 인터페이스로만 협력한다.
public class Employee {
private int basePay; // 감춘다: 데이터 표현이 바뀔 수 있다
private static final double TAX_RATE = 0.03; // 감춘다: 세율은 자주 바뀐다
public double calculatePay() { // 드러낸다: 안정된 인터페이스
return basePay - basePay * TAX_RATE;
}
}
// 바깥은 basePay의 타입도, TAX_RATE의 값도 모른다 → 그것들이 바뀌어도 영향받지 않는다기능 분해에서는 데이터가 전역으로 노출돼 어떤 변경이든 시스템 전역으로 번졌다. 데이터 추상화는 데이터를 모듈 안에 숨겨, 변경의 방향을 그 모듈 하나로 국소화한다. 두 방식의 근본 차이는 “감출 것을 감췄는가”에 있다.
판단 기준: 모듈을 나눌 때 “무엇이 바뀔 것 같은가”를 먼저 묻고, 바뀔 것을 인터페이스 뒤로 감춘다. 함정: 감출 대상은 데이터만이 아니다. 알고리즘, 정책, 외부 시스템 연동처럼 바뀔 가능성이 높은 모든 결정이 은닉 대상이다. “구현이 아니라 변경을 감춘다”가 정확한 표현이다.
데이터 추상화의 두 갈래
데이터 추상화는 다시 두 갈래로 나뉜다.
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는 타입을 추상화한다 — 하나의 Employee 타입이 내부에 type 태그를 두고, 그 태그로 여러 종류를 스스로 흉내 낸다. 반면 객체지향은 절차를 추상화한다 — calculatePay라는 오퍼레이션이 하나의 추상적 명세가 되고, 그 절차의 실제 구현은 타입마다 다른 메서드로 흩어진다. ADT는 “타입 안에 여러 종류를 담고”, 객체지향은 “종류마다 타입을 나눈다”.
이 차이가 코드에서 드러나는 지점이 바로 태그 분기 대 다형성이다. ADT의 switch (type)은 종류를 구별하기 위한 태그 검사다. 객체지향에는 이 태그도, 그것을 검사하는 분기도 없다 — 어떤 종류인지는 그 객체가 어느 클래스의 인스턴스인가로 이미 정해져 있고, 다형적 메시지가 알아서 맞는 메서드를 부른다. 조건문이 사라진 자리를 타입 계층이 대신한다.
핵심 차이는 변경의 방향이다. ADT는 새 직원 유형이 생기면 calculatePay의 switch를 열어 case를 추가해야 한다 — 기존 코드를 수정한다. 게다가 태그 분기가 calculatePay 한 곳에만 있으란 법이 없다. calculateBonus, paySlip 등 여러 메서드가 저마다 switch (type)를 두고 있으면, 새 유형 하나에 그 모든 분기를 찾아 고쳐야 한다. 객체지향은 Employee를 상속한 새 클래스를 하나 더 만들면 끝이다 — 기존 코드를 건드리지 않는다. 전자는 타입 추가가 어렵고 연산 추가가 쉽다, 후자는 그 반대다.
판단 기준: 다뤄야 할 타입이 자주 늘어난다면 객체지향(다형성)이 유리하다 — 확장이 클래스 추가로 끝난다. 반대로 타입은 고정인데 연산(오퍼레이션)이 자주 는다면 ADT가 유리하다 — 새 연산 하나를 한곳에 추가하면 모든 타입을 커버한다. 이 대칭을 표현 문제(Expression Problem)라 부른다. 함정: enum을 필드로 두고 여기저기서 그 값으로 if/switch를 돌리면, 겉모습은 클래스여도 실제로는 ADT다. 그리고 그 분기가 여러 곳에 흩어져 있으면 타입 하나 추가에 여러 파일을 열어야 한다. 5장에서 조건 타입을 다형성으로 흩은 것이 바로 ADT식 분기를 객체지향으로 옮긴 사례다.
설계 트레이드오프 — 무엇이 자주 바뀌는가
객체지향이 언제나 옳은 것은 아니다. ADT와 객체지향은 서로 반대 방향의 변경에 강하다. 이 대칭을 정리하면 설계 선택의 기준이 명확해진다.
| 변경의 종류 | ADT (태그 분기) | 객체지향 (다형성) |
|---|---|---|
| 새 타입(직원 유형)을 추가 | 모든 분기문을 수정 — 어렵다 | 새 클래스 하나 추가 — 쉽다 |
| 새 오퍼레이션(연산)을 추가 | 한 곳에 메서드 추가 — 쉽다 | 모든 클래스를 수정 — 어렵다 |
새 오퍼레이션을 추가할 때 객체지향이 불리하다는 점은 자주 잊힌다. Employee 계층에 calculateSeverancePay()(퇴직금 계산)를 새로 넣으려면, SalariedEmployee·HourlyEmployee 등 모든 하위 클래스에 그 메서드를 구현해야 한다. 타입이 흩어져 있으니 연산 추가가 여러 파일로 번지는 것이다. ADT라면 switch가 든 메서드 하나만 새로 쓰면 모든 유형을 한 번에 커버한다.
// 객체지향에서 새 오퍼레이션 추가는 모든 하위 클래스를 열게 한다
abstract class Employee { abstract double calculatePay(); abstract double calculateSeverancePay(); }
class SalariedEmployee extends Employee { /* 두 메서드 모두 구현해야 한다 */ }
class HourlyEmployee extends Employee { /* 여기도 모두 구현해야 한다 */ }
// ADT에서 새 오퍼레이션 추가는 한 곳으로 끝난다
class Employee {
double calculateSeverancePay() {
return switch (type) { case SALARIED -> ...; case HOURLY -> ...; }; // 한 메서드
}
}판단 기준: 도메인에서 타입이 자주 느는지 오퍼레이션이 자주 느는지를 예측해 축을 고른다. 대부분의 업무 시스템은 타입(정책·유형)이 더 자주 늘어 객체지향이 유리하지만, 안정된 타입 집합에 분석 연산을 계속 붙이는 시스템(예: 컴파일러의 AST 순회)은 ADD/방문자 계열이 나을 수 있다. 함정: “객체지향이 항상 낫다”고 믿으면 오퍼레이션이 자주 느는 상황에서 모든 클래스를 반복해서 여는 고통을 겪는다. 무엇이 자주 바뀌는지가 답을 정한다.
정리
- 큰 문제는 추상화하고 분해해서 다룬다. 분해의 축을 기능으로 잡느냐 데이터로 잡느냐가 첫 갈림길이다.
- 프로시저 추상화(기능 분해)는 데이터와 기능이 분리돼, 자주 바뀌는 기능이 전역 데이터를 공유하는 다른 기능까지 흔든다.
- 데이터 추상화는 데이터와 연산을 타입으로 묶어 변경의 파장을 타입 경계 안에 가둔다.
- 데이터 추상화 안에서도 ADT와 객체지향이 갈린다. 타입이 자주 늘면 다형성(클래스), 연산이 자주 늘면 ADT가 유리하다 — 표현 문제.
enum필드로 분기하는 클래스는 겉만 객체지향인 ADT다. 그 분기를 다형성으로 흩는 것이 5장의 리팩터링이었다.
다음장으로 8장