오브젝트 - 07. 객체 분해

1

오브젝트

목록 보기
7/7
post-thumbnail

불필요한 정보를 제거하고 현재의 문제 해결에 필요한 핵심만 남기는 작업을 추상화라고 부른다.

큰 문제를 해결 가능한 작은 문제로 나누는 작업을 분해라고 부른다.

01. 프로시저 추상화와 데이터 추상화

현대적인 프로그래밍 언어를 특징 짓는 중요한 두 가지 추상화 메커니즘은 프로시저 추상화데이터 추상화다. 프로시저 추상화는 소프트웨어가 무엇을 해야 하는지를 추상화한다. 데이터 추상화는 소프트웨어가 무엇을 알아야 하는지를 추상화한다. 소프트웨어는 데이터를 이용해 정보를 표현하고 프로시저를 이용해 데이터를 조작한다.

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

메인 함수로서의 시스템

기능은 오랜 시간 동안 시스템을 분해하기 위한 기준으로 사용됐으며, 이 같은 시스템 분해 방식을 알고리즘 분해 또는 기능 분해라고 부른다. 기능 분해의 관점에서 추상화의 단위는 프로시저이며 시스템은 프로시저 단위로 분해된다.

프로시저는 반복적으로 실행되거나 거의 유사하게 실행되는 작업들을 하나의 장소에 모아놓음으로써 로직을 재사용하고 중복을 방지할 수 있는 추상화 방법이다.

급여 관리 시스템

이번 예제는 간단한 급여 관리 시스템이다. 연초에 회사는 매달 지급해야 하는 기본급에 대해 직원과 협의하며 이 금액을 12개월 동안 동일하게 직원들에게 지급한다.

급여 = 기본급 - (기본급 * 소득세율)

여기서는 급여 관리 시스템을 구현하기 위해 기능 분해 방법을 이용하겠다. 전통적으로 기능 분해 방법은 하향식 접근법을 따르며 최상위의 추상적인 함수 정의에서 출발해서 단계적인 정제 절차를 따라 시스템을 구축한다. 이때 최상위의 추상적인 함수 정의는 시스템의 기능을 표현하는 하나의 문장으로 나타내고, 이 문장을 구성하는 좀 더 세부적인 단계의 문장으로 분해해 나가는 방식을 따른다.

먼저 급여 관리 시스템에 대한 추상적인 최상위 문장을 기술함으로써 시작하자.

이제 기능 분해 방법에 따라 이 프로시저를 실제로 급여를 계산하는 데 필요한 좀 더 세분화된 절차로 구체화해야 한다.

각 정제 단계는 이전 문장의 추상화 수준을 감소시켜야 한다. 즉 좀 더 단순하고 구체적인 문장들의 조합으로 분해돼야 한다. 직원 급여를 계산하기 위해 기본급 정보 역시 필요하다.

기능 분해의 결과는 최상위 기능을 수행하는 데 필요한 절차들을 실행되는 시간 순서에 따라 나열한 것이다.

기능 분해 방법에서는 기능을 중심으로 필요한 데이터를 결정한다. 기능 분해 방식에 따라 부해된 급여 관리 시스템을 구현해가면서 전통적인 하향식 기능 분해 방식이 가지는 문제점을 살펴보자.

급여 관리 시스템 구현

위의 세 단계는 모두 더 작은 세부적인 단계로 분해 가능하기 때문에 각 단계를 프로시저를 호출하는 명령문으로 변환할 수 있다.

public class TaxCalculator {
    public static void main(String[] args) {
        double taxRate = getTaxRate();
        double pay = calculatePayFor(name, taxRate);
        System.out.println((describeResult(name, pay)));
    }
}

사용자로부터 소득세율을 입력받는 getTaxRate() 함수는 다음과 같은 두 개의 절차로 분해할 수 있다.

private static double getTaxRate() {
    System.out.println("세율을 입력하세요: ");

    Scanner scanner = new Scanner(System.in);
    String input = scanner.nextLine().trim();
    return Double.parseDouble(input);
}

급여를 계산하는 코드는 기본급 정보를 이용해 급여를 계산하는 두 개의 단계로 구현할 수 있다.

private static final String[] employees = {"직원A", "직원B", "직원C"};
private static final int[] basePays = {400, 300, 250};


private static double calculatePayFor(String name, double taxRate) {
    int index = findEmployeeIndex(name);
    int basePay = basePays[index];
    return basePay - (basePay * taxRate);
}

private static int findEmployeeIndex(String name) {
    for (int i = 0; i < employees.length; i++) {
        if (employees[i].equals(name)) {
            return i;
        }
    }
    return -1;
}

급여를 계산했으므로 마지막으로 급여 내역을 출력 양식에 맞게 포매팅 후 반환한다.

private static String describeResult(String name, double taxRate) {
    return String.format("이름: %s, 급여: %s", name, taxRate);
}

이름이 "직원C"인 직원의 급여를 계산하려면 다음과 같이 프로시저를 호출하면 된다.

public static void main(String[] args) {
    String name = typingName();
    double taxRate = getTaxRate();
    double pay = calculatePayFor(name, taxRate);
    System.out.println((describeResult(name, pay)));
}

private static String typingName() {
    Scanner scanner = new Scanner(System.in);
    return scanner.nextLine();
}

예제에서 알 수 있듯이 하향식 기능 분해는 최상위의 가장 추상적인 메인 함수로 정의하고, 메인 함수를 구현 가능한 수준까지 세부적인 단계로 분해하는 방법이다. 이 시스템은 트리로 표현할 수 있다. 각 노드는 시스템을 구성하는 하나의 프로시저를 의미하고 한 노드의 자식 노드는 부모 노드를 구현하는 절차 중 한 단계를 의미한다.

하향식 기능 분해의 문제점

  • 시스템은 하나의 메인 함수로 구성돼 있지 않다.
  • 기능 추가나 요구사항 변경으로 인해 메인 함수를 빈번하게 수정해야 한다.
  • 비즈니스 로직이 사용자 인터페이스와 강하게 결합된다.
  • 하향식 분해는 너무 이른 시기에 함수들의 실행 순서를 고정시키기 때문에 유연성과 재사용성이 저하된다.
  • 데이터 형식이 변경될 경우 파급효과를 예측할 수 없다.

언제 하향식 분해가 유용한가?

하향식 설계는 작은 프로그램과 개별 알고리즘을 위해서는 유용한 패러다임으로 남아 있다. 특히 프로그래밍 과정에서 이미 해결된 알고맂므을 문서화하고 서술하는데는 훌륭한 기법이다. 그러나 실제로 동작하는 커다란 소프트웨어를 설계하는 데 적합한 방법은 아니다.

03. 모듈

정보 은닉과 모듈

시스템의 변경을 관리하는 기본적인 전략은 함께 변경되는 부분을 하나의 구현 단위로 묶고 퍼블릭 인터페이스를 통해서만 접근하도록 만드는 것이다. 즉, 기능을 기반으로 시스템을 분해하는 것이 아니라 변경의 방향에 맞춰 시스템을 분해하는 것이다.

정보 은닉은 시스템을 모듈 단위로 분해하기 위한 기본 원리로 시스템에서 자주 변경되는 부분을 상대적으로 덜 변경되는 안정적인 인터페이스 뒤로 감춰야 한다는 것이 핵심이다.

정보 은닉은 외부에 감춰야 하는 비밀에 따라 시스템을 분할하는 모듈 분할 원리다.

모듈은 다음과 같은 두 가지 비밀을 감춰야 한다.


다음은 전체 직원에 관한 처리를 Employees 모듈로 캡슐화한 결과를 나타낸 것이다.

public class Employees {
    private static final String[] employees = {"직원A", "직원B", "직원C", "직원D", "직원E", "직원F"};
    private static final double[] basePays = {400, 300, 250, 1, 1, 1.5};
    private static final boolean[] hourlys = {false, false, false, true, true, true};
    private static final int[] timeCards = {0, 0, 0, 120, 120, 120};

    public static double calculatePay(String name, double taxRate) {
        if (isHourly(name)) {
            return calculateHourlyPayFor(name, taxRate);
        } else {
            return calculatePayFor(name, taxRate);
        }
    }

    public static boolean isHourly(String name) {
        int index = findEmployeeIndex(name);
        return hourlys[index];
    }

    public static double calculateHourlyPayFor(String name, double taxRate) {
        int index = findEmployeeIndex(name);
        double basePay = basePays[index] * timeCards[index];
        return basePay - (basePay * taxRate);
    }

    public static double calculatePayFor(String name, double taxRate) {
        int index = findEmployeeIndex(name);
        double basePay = basePays[index];
        return basePay - (basePay * taxRate);
    }

    private static int findEmployeeIndex(String name) {
        for (int i = 0; i < employees.length; i++) {
            if (employees[i].equals(name)) {
                return i;
            }
        }
        return -1;
    }
}

위 코드에서는 전역 변수들이 모듈 내부로 숨겨져 있다는 것에 주목하라. 외부에서는 Employees 모듈이 제공하는 메서드를 통해서만 내부 변수를 조작할 수 있다. 심지어 모듈 외부에서는 모듈 내부에 어떤 데이터가 존재하는지 조차 알지 못한다.

이제 main 함수가 Employees 모듈의 기능을 사용하도록 코드를 수정하면 된다.

public class TaxCalculator {

    public static void main(String[] args) {
        String name = typingName();
        double taxRate = getTaxRate();
        double pay = Employees.calculatePay(name, taxRate);
        System.out.println((describeResult(name, pay)));
    }

    private static String typingName() {
        Scanner scanner = new Scanner(System.in);
        return scanner.nextLine();
    }

    private static double getTaxRate() {
        System.out.println("세율을 입력하세요: ");

        Scanner scanner = new Scanner(System.in);
        String input = scanner.nextLine().trim();
        return Double.parseDouble(input);
    }

    private static String describeResult(String name, double taxRate) {
        return String.format("이름: %s, 급여: %s", name, taxRate);
    }
}

모듈의 장점과 한계

Employees 예제를 통해 알 수 있는 모듈의 장점은 다음과 같다.

  • 모듈 내부의 변수가 변경되더라고 모듈 내부에만 영향을 끼친다.
  • 비즈니스 로과 사용자 인터페이스에 대한 관심사를 분리한다.
  • 전역 변수와 전역 함수를 제거함으로써 네임스페이스 오염을 방지한다.

모듈의 가장 큰 단점은 인스턴스의 개념을 제공하지 않는다는 점이다. Employees 모듈은 단지 회사에 속한 모든 직원 정보를 가지고 있는 모듈일 뿐이다. 좀 더 높은 수준의 추상화를 위해서는 직원 전체가 아니라 개별 직원을 독립적인 단위로 다룰 수 있어야 한다.

04. 데이터 추상화와 추상 데이터 타입

추상 데이터 타입

프로그래밍 언어에서 타입(type)이란 변수에 저장할 수 있는 내용물의 종류와 변수에 적용될 수 있는 연산의 가짓수를 의미한다.

추상 데이터 타입을 구현하려면 다음과 같은 특성을 위한 프로그래밍 언어의 지원이 필요하다.

  • 타입 정의를 선언할 수 있어야 한다.
  • 타입의 인스턴스를 다루기 위해 사용할 수 있는 오퍼레이션의 집합을 정의할 수 있어야 한다.
  • 제공된 오퍼레이션을 통해서만 조작할 수 있도록 데이터를 외부로부터 보호할 수 있어야 한다.
  • 타입에 대해 여러 개의 인스턴스를 생성할 수 있어야 한다.

이제 추상 데이터 타입을 이용해 급여 관리 시스템을 개선해보자.

@Getter
public class Employee {
    private String name;
    private double basePay;
    private boolean hourly;
    private int timeCard;

    public Employee(String name, double basePay, boolean hourly, int timeCard) {
        this.name = name;
        this.basePay = basePay;
        this.hourly = hourly;
        this.timeCard = timeCard;
    }

    public double calculatePay(double taxRate) {
        if (hourly) {
            return calculateHourlyPay(taxRate);
        }
        return calculateSalariedPay(taxRate);
    }

    private double calculateHourlyPay(double taxRate) {
        return (basePay * timeCard) - (basePay * timeCard) * taxRate;
    }

    private double calculateSalariedPay(double taxRate) {
        return basePay - (basePay * taxRate);
    }
    
    public double monthlyBasePay() {
        if (hourly) {
            return 0;
        }
        return basePay;
    }
}

Employee 추상 데이터 타입에 대한 설계가 완료됐으므로 추상 데이터 타입을 사용하는 클라이언트 코드를 작성하자.

public class Employees {
    private static final List<Employee> employees = List.of(
            new Employee("직원A", 400, false, 0),
            new Employee("직원A", 300, false, 0),
            new Employee("직원C", 250, false, 0),
            new Employee("아르바이트D", 1, true, 120),
            new Employee("아르바이트E", 1, true, 120),
            new Employee("아르바이트F", 1, true, 120)
    );

    public static double calculatePay(String name, double taxRate) {
        if (isHourly(name)) {
            return calculateHourlyPayFor(name, taxRate);
        } else {
            return calculatePayFor(name, taxRate);
        }
    }

    public static boolean isHourly(String name) {
        int index = findEmployeeIndex(name);
        return employees.get(index).isHourly();
    }

    public static double calculateHourlyPayFor(String name, double taxRate) {
        int index = findEmployeeIndex(name);
        double basePay = employees.get(index).getBasePay() * employees.get(index).getTimeCard();
        return basePay - (basePay * taxRate);
    }

    public static double calculatePayFor(String name, double taxRate) {
        int index = findEmployeeIndex(name);
        double basePay = employees.get(index).getBasePay();
        return basePay - (basePay * taxRate);
    }

    private static int findEmployeeIndex(String name) {
        for (int i = 0; i < employees.size(); i++) {
            if (employees.get(i).getName().equals(name)) {
                return i;
            }
        }
        return -1;
    }

    private static double sumOfBasePays() {
        double result = 0;

        for (int i = 0; i < employees.size(); i++) {
            String name = employees.get(i).getName();
            if (!isHourly(name)) {
                result += employees.get(i).getBasePay();
            }
        }
        return result;
    }
}

지금가지 살펴본 것처럼 추상 데이터 타입은 사람들이 세상을 바라보는 방식에 좀 더 근접해지도록 추상화 수준을 향상시킨다.

05. 클래스

클래스는 추상 데이터 타입인가?

명확한 의미에서 추상 데이터 타입과 클래스는 동일하지 않다. 가장 핵심적인 차이는 클래스는 상속과 다형성을 지원하는 데 비해 추상 데이터 타입은 지원하지 못한다는 점이다.

추상 데이터 타입은 타입을 추상화한 것이고 클래스는 절차를 추상화한 것이다.

Employee를 사용하는 클라이언트는 calculdatePay와 monthlyBasePay 오퍼레이션을 호출할 수 있지만 정규 직원이나 아르바이트 직원이 있다는 사실은 알 수 없다. 두 직원 타입은 Employee 내부에 감춰져 있으며 암묵적이다.

클라이언트 관점에서 두 클래의 인스턴스는 동일하게 보인다는 것에 주목하라. 실제로 내부에서 수행되는 절차는 다르지만 클래스를 이용한 다형성은 절차에 대한 차이점을 감춘다. 다시 말해 객체지향은 절차 추상화다.

변경을 기준으로 선택하라

기존 코드에 아무런 영향도 미치지 않고 새로운 객체 유형과 행위를 추가할 수 있는 객체지향의 특성을 개방-폐쇄 원칙이라고 부른다. 이것이 객체 지향 설계가 전통적인 방식에 비해 변경하고 확장하기 쉬운 구조를 설계할 수 있는 이유다.

협력이 중요하다

객체지향에서 중요한 것은 역할, 책임, 협력이다. 객체지향은 기능을 수행하기 위해 객체들이 협력하는 방식에 집중한다. 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션의 구현 방식을 타입별로 분배하는 것은 올바른 접근법이 아니다.

profile
서버 백엔드 개발자

0개의 댓글