본문 바로가기
스터디/오브젝트

오브젝트 07 - 객체 분해

by 디토20 2024. 3. 5.
반응형

 

 

 

 

 

 

 

오브젝트 07 - 객체 분해

 

가장 일반적인 추상화 방법은 한 번에 다뤄야 하는 문제의 크기를 줄이는 것이다. 사람들은 한 번에 해결하기 어려운 커다란 문제에 맞닥뜨릴 경우 해결 가능한 작은 문제로 나누는 경향이 있다. 이렇게 나눠진 문제들 역시 한 번에 해결하기 어려울 정도로 크다면 다시 더 작은 문제로 나눌 수 있다. 이처럼 큰 문제를 해결 가능한 작은 문제로 나누는 작업을 분해(decomposition)라고 부른다.

 

7.1 프로시저 추상화와 데이터 추상화

  • 현대적인 프로그래밍 언어를 특징 짓는 중요한 두가지 추상화 메커니즘은 프로시저 추상화데이터 추상화다.
  • 프로시저 추상화는 소프트웨어가 무엇을 해야 하는지를 추상화한다.
  • 데이터 추상화는 소프트웨어가 무엇을 알아야 하는지를 추상화한다.
  • 소프트웨어는 데이터를 이용해 정보를 표현하고 프로시저를 이용해 데이터를 조작한다.
  • 시스템을 분해하는 방법을 결정하려면 먼저 프로시저 추상화와 데이터 추상화중 어떤것을 중심으로 할지 선택해야 한다.
    • 프로시저 추상화기능 분해(=알고리즘 분해)의 길로 들어서는 것이다.
    • 데이터 추상화는 데이터를 중심으로 타입을 추상화(추상 데이터 타입) 하거나, 프로시저를 추상화(객체지향) 할 수 있다.

 

 

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

7.2.1 메인 함수로서의 시스템

  • 기능은 오랜 시간 동안 시스템을 분해하기 위한 기준으로 사용됐다.
  • 프로시저는 반복적으로 실행되거나 거의 유사하게 실행되는 작업들을 하나의 장소에 모아놓음으로써 로직을 재사용하고 중복을 방지할 수 있는 추상화 방법이다.
  • 프로시저를 추상화라고 부르는 이유는 부의 구현 내용을 모르더라도 인터페이스만 알면 프로시저를 사용할 있기 때문이다.
  • 전통적인 기능 분해 방법은 하향식 접근법 (Top-Down Approach)따른다.
    • 하향식 접근법이란 스템을 구성하는 가장 최상위(topmost) 기능을 정의하고, 이 최상위 기능을 작은 단계의 하위 기능으로 분해해 나가는 방법을 말한다.
    • 분해는 세분화된 마지막 하위 기능이 프로그래밍 언어로 구현 가능한 수준이 때까지 계속된다.
    • 세분화 단계는 단계보다 구체적이어야 한다.

 

 

7.2.2 급여 관리 시스템

  • 급여 관리 시스템을 기능 분해 방법으로 구현해보자.

1. 급여 관리 시스템에 대한 추상적인 최상위 문장을 기술한다.

직원의 급여를 계산한다

 

 

2. 실제로 급여를 계산하는 필요한 좀 세분화된 절차로 구체화한다.

직원의 급여를 계산한다
    사용자로부터 소득세율을 입력받는다
    직원의 급여를 계산한다
    양식에 맞게 결과를 출력한다

 

 

3. 구현이 가능할 정도로 저수준의 문장이 될때까지 분해한다.

직원의 급여를 계산한다
    사용자로부터 소득세율을 입력받는다
         "세율을 입력하세요: ”라는 문창을 화면에 출력한다
         키보드률 통해 세율을 입력받는다
    직원의 급여를 계산한다
        전역 변수에 저장된 직원의 기본급 정보를 얻는다
        급여를 계산한다
    양식에 맞게 결과를 출력한다
        "이름: {직원명}, 급여: {계산된 금액}" 형식에 따라 출력 문자열을 생성한다

 

  • 기능 분해의 결과는 최상위 기능을 수행하는데 필요한 절치들을 실행되는 시간 순서에 따라 나열한 것이다.
  • 기본적으로 기능 분해는 책의 목차를 정리하고 그 안에 내용을 채워 넣는 것과 유사하다.
  • 급여 관리 시스템을 입력을 받아 출력을 생성하는 커다란 하나의 메인 함수로 간주하고 기능 분해를 시작했다. 
  • 이때 입력 정보는 직원정보와 소득세율이고 출력은 계산된 급여 정보다.

 

  • 기능 분해 방법에서는 기능을 중심으로 필요한 데이터를 결정한다.
  • 기능 분해를 위한 하향식 접근법은 먼저 필요한 기능을 생각하고 기능을 분해하고 정제하는 과정에서 필요한 데이터의 종류와 저장 방식을 식별한다.
  • 기능 분해 방식에 따라 분해된 급여 관리 시스템을 구현해가면서 전통적인 하향식 기능 분해 방식이 가지는 문제점을 살펴보자.

 

 

7.2.3 급여 관리 시스템 구현

1. 최상위 문장을 구현해보자

  • 최상위 문장은 하나의 메인 함수로 매핑된다.
  • 급여를 계산하는 데 필요한 소득세율은 사용자로부터 입력받고 직원의 기본급 정보는 시스템에 저장된 값을 참조하기로 한다.
  • 그리고 직원의 이름은 인자로 받는다. 따라서 급여 관리 시스템의 최상위 문장을 다음과 같은 함수 정의로 바꿀 수 있다.
# 직원의 급여를 계산한다.
def main(name)
end

 

 

2. 실제로 급여를 계산하는  필요한 좀  세분화된 절차로 구체화한다.

# 직원의 급여를 계산한다
#    사용자로부터 소득세율을 입력받는다
#        "세율을 입력하세요: "라는 문장을 화면에 출력한다
#        키보드를 통해 세율을 입력받는다
#    직원의 급여를 계산한다
#        전역 변수에 저장된 직원의 기본급 정보를 얻는다 
#        급여를 계산한다
#    양식에 맞게 결과를 출력한다
#        "이름: {직원명}, 급여: {계산된 금액}" 형식에 따라 출력 문자열을 생성한다.

def main(name)
    taxRate = getTaxRate()
    pay = calculatePayFor(name, taxRate)
    puts(describeResult(name, pay))
end

def getTaxRate()
    print("세율을 입력하세요: ")
    return gets().chomp().to_f()
end

$employees = ["직원A", "직원B", "직원C"]
$basePays = [400, 300, 250]

def calculatePayFor(name, taxRate)
    index = $employees.index(name)
    basePay = $basePays(index]
    return basePay - (basePay * taxRate) 
end

def describeResult(name, pay)
    return "이름: #{name}, 급여 : #{pay}"
end

 

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

 

 

 

7.2.4 하향식 기능 분해의 문제점

  • 하향식 기능 분해 방법은 이상적인 방법으로 보일 수 있지만 실제로 설계에 적용하다 보면 아래와 같은 다양한 문제에 직면한다.
    • 시스템은 하나의 메인 함수로 구성있지 않다.
    • 기능 추가나 요구사항 변경으로 인해 메인 함수를 빈번하게 수정해야 한다.
    • 비즈니스 로직이 사용자 인터페이스와 강하결합된.
      • 하향식 접근법은 비즈니스 로직을 설계하는 초기 단계부터 입력 방법과 출력 양식을 함께 고민하도록 강요한다.
      • 결과적으로 코드 안에서 비즈니스 로직과 사용자 인터페이스 로직이 밀접하게 결합된다.
    • 하항식 해는 너무 이른 시기에 함수들의 실행 순서를 고정시키기 때문에 유연성과 재사용성이 저하된다.
      • 처음부터 구현을 염두에 두기 때문에 자연스럽게 함수들의 실행 순서를 정의하는 시간 제약을 강조한다.
      • 모든 함수는 상위 함수를 분해하는 과정 에서 필요에 따라 식별되며, 그에 따라 상위 함수가 강요하는 문맥안에서만 의미를 가진다.
    • 데이터 형식이 변경될 경우 파급효과를 예측할 수 없다.
      • 어떤 데이터를 어떤 함수가 사용하고 있는지를 추적하기 어렵다.
      • 어떤 함수가 어떤 데이터를 사용하는지 확인하려면 모든 함수를 열어봐야한다.
  • 하향식 접근법과 기능 분해가 가지는 근본적인 문제점은 변경에 취약한 설계를 낳는 것이다.

 

 

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

  • 설계가 어느정도 안정화된 후에는 설계의 다양한 측면을 논리적으로 설명하고 문서화하기에 용이하다.
  • 특히 프로그래밍 과정에서 이미 해결된 알고리즘을 문서화하고 서술할 때 훌륭한 기법이다.

 

 

7.3 모듈

7.3.1 정보 은닉과 모듈

module Employees
  $employees = ["직원A", "직원B", "직원C", "직원D", "직원E", "직원F"]
  $basePays = [400, 300, 250, 1, 1, 1.5]
  $hourlys = [false, false, false, true, true, true]
  $timeCards = [0, 0, 0, 120, 120, 120]
  
def Employees.calculatePay(name, taxRate)
  if (Employees.hourly?(name)) then
    pay = Employees.calculateHourlyPayFor(name, taxRate)
  else
    pay = Employees.calculatePayFor(name, taxRate)
  end
end

def Employees.hourly?(name)
  return $hourly[$employees.index(name) ] 
end

def Employees.calculateHourlyPayFor(name, taxRate)
  index = $employees.index(name)
  basePay = $basePays[index] * $timeCards[index]
  return basePay - (basePay * taxRate) 
end

def Employees.calculatePayFor(name, taxRate)
  return basePay - (basePay * taxRate) 
end

 

  • 시스템 변경 관리의 기본 전략은 함께 변경되는 부분을 하나의 구현 단위로 묶고 퍼블릭 인터페이스를 통해서만 접근하게 하는 것이다.
  • 즉, 기능을 기반으로 시스템을 분해하는것이 아니라 변경의 방향에 맞춰 시스템을 분해 하는 것이다.
  • 정보 은닉은 외부에 감춰야 하는 비밀에 따라 시스템을 분할하는 모듈 분할 원리다.
  • 모듈은 변경될 가능성이 있는 정보를 내부로 감추고, 잘 정의되고 쉽게 변경되지 않을 퍼블릭 인터페이스를 외부에 제공 해서 내부의 정보에 함부로 접근하지 못하게 한다.
  • 모듈은 아래의 두가지 정보를 감춰야한다.
    • 복잡성 : 모듈이 너무 복잡한 경우 이해하고 사용하기가 어렵다. 부에 모듈을 추상화할 수 있는 간단한 인터페이스를 제공해서 모듈의 복잡도를 낮춘다.
    • 변경가능성 :변경 가능한 설계 결정이 외부에 노출될 경우 실제로 변경이 발생했을 파급효과가 커진다. 변경 발생시 하나의 모듈만 수정하면 되도록 변경 가능한 설계 결정을 모듈 내부로 감추고 외부에는 쉽게 변경되지 않을 인터페이스를 제공한다 .

 

 

7.3.2 모듈의 장점과 한계

장점
  1. 모듈 내부의 변수가 변경되더라도 모듈 내부에만 영향을 미친다.
    • 코드를 수정하고 디버깅하기 용이하다.
  2. 비즈니스 로직과 사용자 인터페이스에 대한 관심사를 분리한다.
    • GUI와 같은 다른 형식의 사용자 인터페이스를 추가하더라도 비즈니스 로직은 변경되지 않는다.
  3. 전역 변수와 전역 함수를 제거함으로써 네임스페이스 오염을 방지한다.
    • 변수와 함수를 모듈 내부에 포함시키기 때문에 다른 모듈에서도 동일한 이름을 사용할 수 있게 된다.

 

한계
  • 모듈이 프로시저 추상화보다는 높은 추상화 개념을 제공하지만 태생적으로 변경을 관리하기 위한 구현 기법이기 때문에 추상화 관점에서의 한계점이 명확하다.
  • 모듈의 가장 큰 단점은 인스턴스의 개념을 제공하지 않는다는 점이다.
  • Employees 모듈은 단지 회사에 속한 모든 직원 정보를 가지고 있는 모듈 일 뿐이다.
  • 좀 더 높은 수준의 추상화를 위해서는 직원 전체가 아니라 개별 직원을 독립적인 단위로 다룰 수 있어야 한다.
  • 다시 말해서 다수의 직원 인스턴스가 존재하는 추상화 메커니즘이 필요한 것이다.
  • 그리고 이를 만족시키기 위해 등장한 개념이 바로 추상 데이터 타입이다.

 

 

7.4 데이터 추상화와 추상 데이터 타입

7.4.1 추상 데이터 타입

  • 프로그래밍 언어에서 타입(type)이란 변수에 저장할 수 있는 내용물의 종류와 변수에 적용될 수 있는 연산의 가짓수를 의미한다.
  • 타입은 저장된 값에 대해 수행될 수 있는 연산의 집합을 결정하기 때문에 변수의 값이 어떻게 행동 할 것이라는것을 예측할 수 있게 한다.
    • 정수 타입의 변수는 덧셈 연산을 이용해 값을 더할 수 있다.
    • 문자열 타입의 변수는 연결 연산을 이용해 두 문자열을 하나로 합칠 수 있다.
  • 프로시저 추상화로는 프로램의 표현력에 한계가 있어, 프로시저 추상화를 보완하기 위해 데이터 추상화의 개념이 탄생했다.
  • 추상데이터 타입을 구현하려면 프로그래밍 언어는 다음과 같은 특성을 지원해야 한다.
    • 타입 정의를 선언 할 수 있어야 한다.
    • 타입의 인스턴스를 다루기 위해 서용할 수 있는 오퍼레이션의 집합을 정의할 수 있어야 한다.
    • 제공된 오퍼레이션을 통해서만 조작할 수 있도록 데이터를 외부로부터 보호할 수 있어야 한다.
    • 타입에 대해 여러개의 인스턴스를 생성할 수 있어야 한다.

 

  • 아래는 추상 데이터 타입을 이용해 급여 관리 시스템을 개선한 코드이다.
  • 이름, 기본급, 아르바이트 직원 여부, 작업시간을 비밀로 가지는 추상 데이터 타입인 Employee선언한다. 
Employee = Struct.new(:name, :basePay, :hourly, :timeCard) do
    def calculatePay(taxRate)
      if (hourly) then
        return calculateHourlyPay(taxRate)
      end
      return calculateSalariedPay(taxRate)
    end
    
private
    def calculateHourlyPay(taxRate)
      return (basePay * timeCard) - (basePay * timeCard) * taxRate 
    end
    
    def calculateSalariedPay(taxRate)
      return basePay - (basePay * taxRate) 
    end

    def monthlyBasePay()
      if (hourly) then return 0 end
      return basePay
    end
end
$employees = [
  Employee.new("직원 A", 400, false, 0),
  Employee.new("직원 A", 300, false, 0),
  Employee.new("직원 C", 250, false, 0),
  Employee.new("아르바이트D", 1, true, 120), 
  Employee.new("아르바이트 E", 1, true, 120), 
  Employee.new("아르바이트 F", 1, true, 120),
]

 

 

7.5 클래스

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

  • 추상 데이터 타입과 클래스는 동일하지 않다.
  • 가장 핵심적인 차이는 클래스는 상속과 다형성을 지원하는 데 비해 추상 데이터 타입은 지원하지 못한다는 점이다.
  • 추상 데이터 타입타입추상화한 (type abstraction)이고 클래스절차추상화한 (procedural abstraction)이다.
  • 타입 추상화와 절차 추상화의 차이점을 이해하기 위해 먼저 추상 데이터 타입으로 구현된 Employee 입의 calculatePaymonthlyBasePay 오퍼레이션을 살펴보자.
추상 데이터 타입

 

  • 추상 데이터 타입의 Employee는 물리적으로 하나의 타입이지만 개념적으로는 정규 직원과 아르바이트 직원이라는 두 개의 개별적인 개념을 포괄한다.
  • Employee 타입의 calculatePaymonthlyBasePay 직원 유형에 따라 서로 다른 방식으로 동작한다.
  • 타입 추상화는 개별 오퍼레이션이 모든 개념적인 타입에 대한 구현을 포괄하도록 함으로써 하나물리적인 타입 안에 전체 타입을 감춘다.
  • Employee사용하는 클라이언트는 calculatePaymonthlyBasePay 오퍼 레이션호출할 수 있지만 정규 직원이나 아르바이트 직원이 있다는 사실은 알 수 없다.

 

객체지향

 

  • 추상 데이터 타입이 오퍼레이션을 기준으로 타입을 묶는 방법이라면 객체지향은 타입을 기준으로 오퍼레이션을 묶는다.
  • 정규 직원과 아르바이트 직원이라는 두 개의 타입을 명시적으로 정의하고 두 직원 유형과 관련된 오퍼레이션의 실행 절차를 두 타입에 분배한다.
  • 스를 분리할 경우 공통 로직제공할 수 있는 가장 간단한 방법은 공통 로직을 포함할 부모 클래스를 정의하 상속받는 것이다.
  • 이제 클라이언트는 부모 클래스의 참조자에 대해 메시지를 전송하면 실재 클래스가 무엇인가에 따라 적절한 절차가 실행된다.
  • 즉, 동일한 메시지에 대해 서로 다르게 반응한다. 이것이 바로 다형성이다.
  • 클라이언트의 관점에서 두 클래스의 인스턴스는 동일하게 보인다.
  • 실제로 내부에서 수행되는 절차는 다르지만 클래스를 이용한 다형성은 절차에 대한 차이점을 감춘다.
  • 다시 말해 객체지향은 절차 추상화(procedural abstraction) 다.

 

 

7.5.2 추상 데이터 타입에서 클래스로 변경하기

# 부모 클래스
class Employee
  attr_reader :name, :basePay
  
  def initialize(name, basePay) 
    @name = name
    @basePay = basePay
  end
  
  def calculatePay(taxRate)
    raise NotimplementedError
  end
  
  def monthlyBasePay()
    raise NotimplementedError
  end
end

# 자식 클래스
class SalariedEmployee < Employee
  def initialize(name, basePay)
    super(name, basePay)
  end
  
  def calculatePay(taxRate)
    return basePay - (basePay * taxRate) 
  end
  
  def monthlyBasePay()
    return basePay
  end
end

 

  • 모든 직원 타입에 대해 Employee의 인스턴스를 생성해야 했던 추상 데이터 타입의 경우와 달리 클래스를 이용해서 구현한 코드의 경우에는 클라이언트가 원하는 직원 타입에 해당하는 클래스의 인스턴스를 명시적으로 지정할 수 있다.
$employees = [
  SalariedEmployee.new("직원A", 400), 
  SalariedEmployee.new("직원B", 300), 
  SalariedEmployee.new("직원C", 250), 
  HourlyEmployee.new("아르바이트D", 1, 120), 
  HourlyEmployee.new("아르바이트E", 1, 120), 
  HourlyEmployee.new("아르바이트F", 1, 120),
]

 

  • 일단 객체를 생성하고 나면 객체의 클래스가 무엇인지는 중요하지 않다.
  • 클라이언트는 자식 인스턴스를 모두 부모 클래스인 Employee의 인스턴스인것처럼 다룰 수 있다.
  • 클라이언트는 메시지를 수신할 객체의 구체적인 클래스에 관해 고민할 필요가 없다.
  • 그저 수신자가 이해할 것으로 예상되는 메시지를 전송하기만 하면 된다.

 

7.5.3 변경을 기준으로 선택하라

  • 단순히 클래스를 구현 단위로 사용한다는 것이 객체지향 프로그래밍을 한다는 것을 의미하지는 않는다.
  • 비록 클래스를 사용 하고있더라도 타입을 기준으로 절차를 추상화하지 않았다면 그것은 객체지향 분해가 아니다.
  • 스턴스 변수에 저장된 값을 기반으로 메서드 내에서 타입을 명시적으로 구분하는 방식은 객체지향을 위반하는 것으로 간주된다.
  • 객체지향에서는 타입 변수를 이용한 조건문다형성으로 대체한다.
  • 이처럼 기존 코드에 영향을 미치지 않고 새로운 객체 유형과 행위를 추가할 수 있는 객체지향의 특성을 개방-폐쇄 원칙이라고 부른.

 

추상 데이터 타입은 모든경 우에 최악의 선택인가?

 

  • 설계는 변경과 관련된 것이고 설계의 유용성은 변경의 방향성과 발생 빈도에 따라 결정된다.
  • 추상 데이터 타입과 객체지향 설계의 유용성은 설계에 요구되는 변경의 압력이 ‘타입 추가’에 관한 것인지, 아니면 ‘오퍼레이션 추가’에 관한 것인지에 따라 달라진다.

1. 타입 추가라는 변경의 압력이 강한 경우

  •  객체지향의 손을 들어줘야 한다.
  • 추상 데이터 타입의 경우 새로운 타입을 추가하려 면 타입을 체크하고 클라이언트 코드를 일일이 찾아 수정한 후 올바르게 작동하는지 테스트해야 한다.
  • 반면 객체지향의 경우에는 클라이언트 코드를 수정할 필요가 없다.
  • 간단하게 새로운 클래스를 상속 계층에 추가하기만 하면 된다.

 

2. 변경의 주된 압력이 오퍼레이션을 추가하는 것일 경우

  • 추상 데이터 타입이 유리하다.
  • 객체지향의 경우 새로운 오퍼레이션을 추가하기 위해서는 상속 계층에 속하는 모든 클래스를 한번에 수정해야 한다.
  • 객체지향을 기반으로 설계한 Employee 클래스에 새로운 추상 오퍼레이션을 추가하려면 Employee아니라 자식 클래스전부 수정해야 한다.
  • 반면 추상데이터 타입의 경우에는 전체 타입에 대한구현 코드가하나의 구현체 내에 포함돼 있기때문에 새로운 오퍼레이션을 추가하는 작업이 상대적으로 간단한다.

 

결론
  • 새로운 타입을 빈번하게 추가해야 한다면 객체지향의 클래스 구조가 더 유용하다.
  • 새로운 오퍼레이션을 빈번하게 추가해야 한다면 추상 데이터 타입을 선택하는 것이 현명한 판단이다.
  • 객체지향적인 접근법이 모든 경우에 올바른 해결 방법인 것은 아니다. 변경의 축을 찾아라.

 

 

 

 

 

 

728x90
반응형

댓글