💡 복잡성을 다루는 유일한 방법은 분해(decomposition)다
인간의 인지 능력은 한계가 있습니다. 밀러의 법칙에 따르면 우리는 동시에 5~9개 정도의 정보만 처리할 수 있죠. 그래서 복잡한 문제를 해결하는 유일한 방법은 분해입니다.
// 복잡한 전체 시스템
public class 급여관리시스템 {
// 수천 줄의 코드...
// 직원 관리, 급여 계산, 세금 처리, 리포트 생성...
}
// vs 분해된 시스템
public class Employee { }
public class PayrollCalculator { }
public class TaxProcessor { }
public class ReportGenerator { }
기능 분해는 시스템을 하나의 큰 기능으로 보고, 이를 작은 기능으로 분해하는 방식입니다.
// 최상위 기능
public class SalaryManagement {
public static void main(String[] args) {
run();
}
public static void run() {
Employee[] employees = loadEmployees();
calculateAndPaySalaries(employees);
generateReports(employees);
}
}
// 1단계: 최상위 분해
main
├── 직원 데이터 읽기
├── 급여 계산 및 지급
└── 리포트 생성
// 2단계: 세부 분해
public class SalaryManagement {
private static Employee[] employees;
public static void main(String[] args) {
employees = obtainEmployees();
for (Employee employee : employees) {
double salary = calculateSalary(employee);
double tax = calculateTax(salary);
double netPay = salary - tax;
pay(employee, netPay);
}
generatePayrollReport();
}
// 직원 정보 읽기
private static Employee[] obtainEmployees() {
String fileName = getFileName();
return readEmployees(fileName);
}
private static String getFileName() {
System.out.print("Enter filename: ");
Scanner scanner = new Scanner(System.in);
return scanner.nextLine();
}
private static Employee[] readEmployees(String fileName) {
// 파일에서 직원 정보 읽기
List<Employee> employeeList = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(new FileReader(fileName))) {
String line;
while ((line = reader.readLine()) != null) {
Employee employee = parseEmployee(line);
employeeList.add(employee);
}
} catch (IOException e) {
e.printStackTrace();
}
return employeeList.toArray(new Employee[0]);
}
}
모든 기능이 main에서 시작되고 main이 모든 것을 조율합니다.
// main이 너무 많은 것을 알고 있다
public static void main(String[] args) {
// 파일 포맷을 안다
// 직원 데이터 구조를 안다
// 급여 계산 방식을 안다
// 세금 계산 방식을 안다
// 지급 방식을 안다
// 리포트 형식을 안다
}
⚠️ 문제: 시스템의 모든 결정이 main에 집중되어 변경에 취약
// 나쁜 예: UI와 비즈니스 로직이 섞임
private static Employee[] obtainEmployees() {
// UI 관심사
System.out.print("Enter filename: ");
Scanner scanner = new Scanner(System.in);
String fileName = scanner.nextLine();
// 비즈니스 로직
return readEmployees(fileName);
}
// 문제: 웹으로 전환하려면? 배치 처리로 바꾸려면?
// 너무 일찍 결정된 사항들
private static Employee[] employees; // 배열? 리스트가 더 나을 수도...
private static final String FILENAME = "employees.txt"; // 하드코딩된 파일명
// Employee 구조 변경 전
class Employee {
String name;
double baseSalary;
}
// Employee 구조 변경 후 - 등급 추가
class Employee {
String name;
double baseSalary;
String grade; // 추가!
}
// 영향받는 모든 함수들
void readEmployees() { /* 수정 필요 */ }
void calculateSalary() { /* 수정 필요 - 등급별 계산 */ }
void calculateTax() { /* 수정 필요 - 등급별 세율 */ }
void generateReport() { /* 수정 필요 - 등급 표시 */ }
// 정규직 급여 계산이 여러 곳에 분산
double calculateSalary(Employee employee) {
if (employee.type == REGULAR) {
return employee.baseSalary;
}
// ...
}
double calculateTax(Employee employee, double salary) {
if (employee.type == REGULAR) {
return salary * 0.1;
}
// ...
}
void generateReport() {
if (employee.type == REGULAR) {
// 정규직 리포트 형식
}
// ...
}
"모듈은 서브프로그램이 아니라 책임의 할당이다"
파나스는 1972년 "On the Criteria To Be Used in Decomposing Systems into Modules" 논문에서 혁명적인 아이디어를 제시했습니다.
// 기능 분해 방식 - 함수로 분해
class FunctionalApproach {
void main() {
readInput();
processData();
generateOutput();
}
}
// 모듈 분해 방식 - 변경 가능성으로 분해
class InputModule {
private String format; // 변경 가능성: 입력 형식
public Data readData() { }
}
class ProcessingModule {
private Algorithm algorithm; // 변경 가능성: 처리 알고리즘
public Result process(Data data) { }
}
public class SortModule {
// 내부적으로 QuickSort? MergeSort?
// 클라이언트는 몰라도 됨
public List<Item> sort(List<Item> items) {
return quickSort(items); // 비밀!
}
}
public class EmployeeRepository {
// 배열? 리스트? 데이터베이스?
// 클라이언트는 몰라도 됨
private Map<String, Employee> storage; // 비밀!
public Employee findById(String id) {
return storage.get(id);
}
}
public class PaymentGateway {
// REST API? SOAP? gRPC?
// 클라이언트는 몰라도 됨
public PaymentResult processPayment(Payment payment) {
return callRestApi(payment); // 비밀!
}
}
추상 데이터 타입은 데이터와 데이터를 조작하는 프로시저를 함께 묶은 것입니다.
// 리스코프(Liskov)의 ADT 정의 방식
public class Stack {
// 상태 - 외부에서 접근 불가
private int[] elements;
private int size = 0;
private static final int DEFAULT_CAPACITY = 16;
// 생성자
public Stack() {
elements = new int[DEFAULT_CAPACITY];
}
// 오퍼레이션들
public void push(int element) {
ensureCapacity();
elements[size++] = element;
}
public int pop() {
if (size == 0) {
throw new EmptyStackException();
}
return elements[--size];
}
public boolean isEmpty() {
return size == 0;
}
public int size() {
return size;
}
private void ensureCapacity() {
if (elements.length == size) {
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
}
// Employee를 ADT로 구현
public class Employee {
// 데이터 은닉
private String name;
private String id;
private double baseSalary;
private EmployeeType type;
// 생성자
public Employee(String name, String id, double baseSalary, EmployeeType type) {
this.name = name;
this.id = id;
this.baseSalary = baseSalary;
this.type = type;
}
// 오퍼레이션들
public double calculatePay() {
switch (type) {
case REGULAR:
return baseSalary;
case HOURLY:
return baseSalary * getHoursWorked();
case COMMISSION:
return baseSalary + getCommission();
default:
throw new IllegalStateException();
}
}
public double calculateTax() {
return calculatePay() * getTaxRate();
}
private double getTaxRate() {
// 타입별 세율 계산
return type.getTaxRate();
}
}
// 새로운 직원 타입을 추가하려면?
public class Employee {
public double calculatePay() {
switch (type) {
case REGULAR:
return baseSalary;
case HOURLY:
return baseSalary * hoursWorked;
case COMMISSION:
return baseSalary + commission;
case CONTRACTOR: // 추가! - Employee 클래스 수정 필요
return contractAmount;
}
}
public double calculateTax() {
// 이 메서드도 수정 필요
}
public String generateReport() {
// 이 메서드도 수정 필요
}
}
⚠️ 문제: 새로운 타입을 추가할 때마다 모든 오퍼레이션을 수정해야 함
ADT는 타입을 추상화한다
// 하나의 Stack 타입에 여러 오퍼레이션
Stack stack = new Stack();
stack.push(10);
stack.pop();
stack.isEmpty();
객체지향은 절차를 추상화한다
// 여러 타입이 같은 절차를 다르게 구현
Employee regular = new RegularEmployee();
Employee hourly = new HourlyEmployee();
// 같은 메시지, 다른 메서드
regular.calculatePay(); // 정규직 방식
hourly.calculatePay(); // 시급제 방식
// 추상 클래스
public abstract class Employee {
protected String name;
protected String id;
public Employee(String name, String id) {
this.name = name;
this.id = id;
}
// 절차를 추상화
public abstract double calculatePay();
public abstract double calculateTax();
}
// 구체적인 직원 타입들
public class RegularEmployee extends Employee {
private double monthlySalary;
@Override
public double calculatePay() {
return monthlySalary;
}
@Override
public double calculateTax() {
return calculatePay() * 0.1;
}
}
public class HourlyEmployee extends Employee {
private double hourlyRate;
private int hoursWorked;
@Override
public double calculatePay() {
return hourlyRate * hoursWorked;
}
@Override
public double calculateTax() {
return calculatePay() * 0.08;
}
}
// 새로운 직원 타입 추가 - 기존 코드 수정 없음!
public class ContractEmployee extends Employee {
private double contractAmount;
private double contractPeriod;
@Override
public double calculatePay() {
return contractAmount / contractPeriod;
}
@Override
public double calculateTax() {
return calculatePay() * 0.15;
}
}
// 클라이언트 코드는 변경 없음
public class Payroll {
public void processPayroll(List<Employee> employees) {
for (Employee employee : employees) {
double pay = employee.calculatePay();
double tax = employee.calculateTax();
// 다형성 덕분에 타입을 몰라도 됨
}
}
}
타입은 고정되고 오퍼레이션이 자주 추가되는 경우
// 복소수 ADT - 타입은 하나, 오퍼레이션은 계속 추가
public class Complex {
private double real;
private double imaginary;
// 오퍼레이션들을 쉽게 추가 가능
public Complex add(Complex other) { }
public Complex multiply(Complex other) { }
public Complex divide(Complex other) { }
public double magnitude() { }
public double angle() { }
// 새로운 오퍼레이션 추가가 쉬움
public Complex conjugate() { }
public Complex sqrt() { }
}
오퍼레이션은 고정되고 타입이 자주 추가되는 경우
// 도형 계층구조 - 오퍼레이션은 고정, 타입은 추가
public abstract class Shape {
public abstract double area();
public abstract double perimeter();
public abstract void draw();
}
// 새로운 도형 추가가 쉬움
public class Rectangle extends Shape { }
public class Circle extends Shape { }
public class Triangle extends Shape { }
public class Pentagon extends Shape { } // 쉽게 추가!
public class OrderManagement {
private static List<Order> orders = new ArrayList<>();
public static void main(String[] args) {
// 모든 것이 main에서 시작
loadOrders();
processOrders();
generateReport();
}
private static void processOrders() {
for (Order order : orders) {
// 타입별 처리가 분산됨
if (order.type.equals("ONLINE")) {
double amount = order.items.stream()
.mapToDouble(i -> i.price * i.quantity)
.sum();
double shipping = calculateOnlineShipping(order);
double total = amount + shipping;
chargeOnlinePayment(order, total);
} else if (order.type.equals("STORE")) {
double amount = order.items.stream()
.mapToDouble(i -> i.price * i.quantity * 0.9) // 10% 할인
.sum();
chargeStorePayment(order, amount);
}
}
}
}
public class Order {
private String id;
private List<OrderItem> items;
private OrderType type;
// 데이터와 관련 오퍼레이션을 함께 묶음
public double calculateTotal() {
double subtotal = items.stream()
.mapToDouble(OrderItem::getAmount)
.sum();
switch (type) {
case ONLINE:
return subtotal + calculateShipping();
case STORE:
return subtotal * 0.9; // 10% 할인
default:
throw new IllegalStateException();
}
}
public void processPayment() {
double total = calculateTotal();
switch (type) {
case ONLINE:
processOnlinePayment(total);
break;
case STORE:
processStorePayment(total);
break;
}
}
}
// 추상화
public abstract class Order {
protected String id;
protected List<OrderItem> items;
public Order(String id, List<OrderItem> items) {
this.id = id;
this.items = new ArrayList<>(items);
}
// 절차의 추상화
public abstract double calculateTotal();
public abstract void processPayment();
protected double calculateSubtotal() {
return items.stream()
.mapToDouble(OrderItem::getAmount)
.sum();
}
}
// 온라인 주문
public class OnlineOrder extends Order {
private ShippingMethod shippingMethod;
@Override
public double calculateTotal() {
return calculateSubtotal() + calculateShipping();
}
@Override
public void processPayment() {
PaymentGateway.processOnlinePayment(this, calculateTotal());
}
private double calculateShipping() {
return shippingMethod.calculateCost(items);
}
}
// 매장 주문
public class StoreOrder extends Order {
private static final double STORE_DISCOUNT = 0.1;
@Override
public double calculateTotal() {
return calculateSubtotal() * (1 - STORE_DISCOUNT);
}
@Override
public void processPayment() {
POSSystem.processStorePayment(this, calculateTotal());
}
}
// 새로운 주문 타입 추가 - 기존 코드 수정 없음!
public class SubscriptionOrder extends Order {
private int billingCycle;
@Override
public double calculateTotal() {
return calculateSubtotal() / billingCycle;
}
@Override
public void processPayment() {
SubscriptionService.processRecurringPayment(this, calculateTotal());
}
}
// ADT가 적합한 경우
// - 수학적 추상화 (Complex, Vector, Matrix)
// - 자료구조 (Stack, Queue, List)
// - 값 객체 (Money, Date, Point)
// 객체지향이 적합한 경우
// - 비즈니스 도메인 (Employee, Order, Customer)
// - 다양한 변형이 예상되는 경우
// - 정책이나 전략이 다양한 경우
Chapter 7을 공부하면서 느낀 점은, 우리가 당연하게 사용하는 객체지향이 사실은 오랜 시행착오의 결과물이라는 것입니다.
기능 분해 → 모듈 → ADT → 객체지향으로 이어지는 발전 과정은 마치 건축의 역사와 같다고 생각합니다. 처음엔 단순히 벽돌을 쌓았지만(기능 분해), 점차 모듈화된 부품을 만들고(모듈), 표준화된 규격을 정의하고(ADT), 최종적으로는 각 부품이 자신의 역할을 아는 시스템(객체지향)으로 발전한 것이죠.
특히 "변경" 이라는 관점에서 분해 방법을 선택하는 것이 핵심입니다. 변경의 축이 무엇인지 파악하고, 그에 맞는 분해 방법을 선택하는 것이 좋은 설계의 시작입니다.
핵심: 복잡성과 싸우는 유일한 무기는 추상화다. 그리고 추상화의 방향은 변경의 방향과 일치해야 한다.