[오브젝트] 객체 분해(chap7)

KwonMoYang·2025년 6월 11일

💡 복잡성을 다루는 유일한 방법은 분해(decomposition)다

📚 들어가며: 왜 분해인가?

인간의 인지 능력은 한계가 있습니다. 밀러의 법칙에 따르면 우리는 동시에 5~9개 정도의 정보만 처리할 수 있죠. 그래서 복잡한 문제를 해결하는 유일한 방법은 분해입니다.

// 복잡한 전체 시스템
public class 급여관리시스템 {
    // 수천 줄의 코드...
    // 직원 관리, 급여 계산, 세금 처리, 리포트 생성...
}

// vs 분해된 시스템
public class Employee { }
public class PayrollCalculator { }
public class TaxProcessor { }
public class ReportGenerator { }

🎯 추상화의 두 가지 메커니즘

1️⃣ 프로시저 추상화 (Procedural Abstraction)

  • 무엇을 하는지는 나타내지만 어떻게 하는지는 감춤
  • 기능 분해(Functional Decomposition)의 기반

2️⃣ 데이터 추상화 (Data Abstraction)

  • 내부 표현은 감추고 인터페이스만 제공
  • 추상 데이터 타입(ADT)과 객체지향의 기반

📌 프로시저 추상화와 기능 분해

하향식 접근법 (Top-Down Approach)

기능 분해는 시스템을 하나의 큰 기능으로 보고, 이를 작은 기능으로 분해하는 방식입니다.

// 최상위 기능
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]);
    }
}

🚨 기능 분해의 문제점들

문제 1: 하나의 메인 함수

모든 기능이 main에서 시작되고 main이 모든 것을 조율합니다.

// main이 너무 많은 것을 알고 있다
public static void main(String[] args) {
    // 파일 포맷을 안다
    // 직원 데이터 구조를 안다
    // 급여 계산 방식을 안다
    // 세금 계산 방식을 안다
    // 지급 방식을 안다
    // 리포트 형식을 안다
}

⚠️ 문제: 시스템의 모든 결정이 main에 집중되어 변경에 취약

문제 2: 비즈니스 로직과 사용자 인터페이스의 결합

// 나쁜 예: UI와 비즈니스 로직이 섞임
private static Employee[] obtainEmployees() {
    // UI 관심사
    System.out.print("Enter filename: ");  
    Scanner scanner = new Scanner(System.in);
    String fileName = scanner.nextLine();
    
    // 비즈니스 로직
    return readEmployees(fileName);
}

// 문제: 웹으로 전환하려면? 배치 처리로 바꾸려면?

문제 3: 성급한 결정

// 너무 일찍 결정된 사항들
private static Employee[] employees;  // 배열? 리스트가 더 나을 수도...
private static final String FILENAME = "employees.txt";  // 하드코딩된 파일명

문제 4: 데이터 변경 시 파급효과

// Employee 구조 변경 전
class Employee {
    String name;
    double baseSalary;
}

// Employee 구조 변경 후 - 등급 추가
class Employee {
    String name;
    double baseSalary;
    String grade;  // 추가!
}

// 영향받는 모든 함수들
void readEmployees() { /* 수정 필요 */ }
void calculateSalary() { /* 수정 필요 - 등급별 계산 */ }
void calculateTax() { /* 수정 필요 - 등급별 세율 */ }
void generateReport() { /* 수정 필요 - 등급 표시 */ }

문제 5: 비즈니스 로직의 분산

// 정규직 급여 계산이 여러 곳에 분산
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) {
        // 정규직 리포트 형식
    }
    // ...
}

💡 모듈과 정보 은닉

파나스(David Parnas)의 통찰

"모듈은 서브프로그램이 아니라 책임의 할당이다"

파나스는 1972년 "On the Criteria To Be Used in Decomposing Systems into Modules" 논문에서 혁명적인 아이디어를 제시했습니다.

기능 분해 vs 모듈 분해

// 기능 분해 방식 - 함수로 분해
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) { }
}

모듈이 감춰야 하는 비밀들

  1. 복잡한 알고리즘
public class SortModule {
    // 내부적으로 QuickSort? MergeSort? 
    // 클라이언트는 몰라도 됨
    public List<Item> sort(List<Item> items) {
        return quickSort(items);  // 비밀!
    }
}
  1. 데이터 구조
public class EmployeeRepository {
    // 배열? 리스트? 데이터베이스?
    // 클라이언트는 몰라도 됨
    private Map<String, Employee> storage;  // 비밀!
    
    public Employee findById(String id) {
        return storage.get(id);
    }
}
  1. 외부 시스템과의 통신
public class PaymentGateway {
    // REST API? SOAP? gRPC?
    // 클라이언트는 몰라도 됨
    public PaymentResult processPayment(Payment payment) {
        return callRestApi(payment);  // 비밀!
    }
}

🏗️ 추상 데이터 타입 (ADT)

ADT의 정의와 특징

추상 데이터 타입은 데이터와 데이터를 조작하는 프로시저를 함께 묶은 것입니다.

// 리스코프(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);
        }
    }
}

ADT의 구현 - 급여관리 시스템 개선

// 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();
    }
}

ADT의 한계

// 새로운 직원 타입을 추가하려면?
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 vs 객체지향의 핵심 차이

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;
    }
}

개방-폐쇄 원칙 (OCP) 실현

// 새로운 직원 타입 추가 - 기존 코드 수정 없음!
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를 선택하는 경우

타입은 고정되고 오퍼레이션이 자주 추가되는 경우

// 복소수 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 { }  // 쉽게 추가!

🔥 실전 예제: 리팩토링 과정

Step 1: 기능 분해 방식 (문제 있는 코드)

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);
            }
        }
    }
}

Step 2: ADT 방식으로 개선

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;
        }
    }
}

Step 3: 객체지향 방식으로 최종 개선

// 추상화
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());
    }
}

💡 핵심 교훈과 실무 적용

1. 분해의 기준

  • 기능 분해: 무엇을 해야 하는가? (What)
  • 모듈 분해: 무엇이 변경되는가? (What changes)
  • 객체지향 분해: 누가 책임지는가? (Who)

2. 선택의 기준

// ADT가 적합한 경우
// - 수학적 추상화 (Complex, Vector, Matrix)
// - 자료구조 (Stack, Queue, List)
// - 값 객체 (Money, Date, Point)

// 객체지향이 적합한 경우
// - 비즈니스 도메인 (Employee, Order, Customer)
// - 다양한 변형이 예상되는 경우
// - 정책이나 전략이 다양한 경우

3. 실무 체크리스트

  • main 함수가 너무 많은 것을 알고 있지는 않은가?
  • 데이터 구조 변경 시 수정해야 할 곳이 많은가?
  • 새로운 요구사항 추가가 어려운가?
  • 비즈니스 로직이 여러 곳에 분산되어 있는가?
  • 테스트하기 어려운 구조인가?

🎯 마무리: 생각의 흐름

Chapter 7을 공부하면서 느낀 점은, 우리가 당연하게 사용하는 객체지향이 사실은 오랜 시행착오의 결과물이라는 것입니다.

기능 분해 → 모듈 → ADT → 객체지향으로 이어지는 발전 과정은 마치 건축의 역사와 같다고 생각합니다. 처음엔 단순히 벽돌을 쌓았지만(기능 분해), 점차 모듈화된 부품을 만들고(모듈), 표준화된 규격을 정의하고(ADT), 최종적으로는 각 부품이 자신의 역할을 아는 시스템(객체지향)으로 발전한 것이죠.

특히 "변경" 이라는 관점에서 분해 방법을 선택하는 것이 핵심입니다. 변경의 축이 무엇인지 파악하고, 그에 맞는 분해 방법을 선택하는 것이 좋은 설계의 시작입니다.

핵심: 복잡성과 싸우는 유일한 무기는 추상화다. 그리고 추상화의 방향은 변경의 방향과 일치해야 한다.

profile
Dot Your moment.

0개의 댓글