BottleH Blog

Object - 7장 객체 분해

    Tags

  • java
Object - 7장 객체 분해 thumbnail

사람의 기억은 단기 기억과 장기 기억으로 분류할 수 있다. 실제로 문제를 해결하기 위해 사용하는 저장소는 장기 기억이 아니라 단기 기억이다.

  • 인지 과부하(cognitive overload): 문제 해결에 필요한 요소의 수가 단기 기억의 용량을 초과하는 순간 문제 해결 능력이 급격하게 떨어지는 것
  • 인지 과부하를 줄이는 가장 좋은 방법은 단기 기억 안에 보관할 정보의 양을 조절하는 것
    • 추상화: 불필요한 정보를 제거하고 현재의 문제 해결에 필요한 핵심만 남기는 작업
    • 분해(decomposition): 큰 문제를 해결 가능한 작은 문제로 나누는 작업

한 번에 단기 기억에 담을 수 있는 추상화의 수에는 한계가 있지만 추상화를 더 큰 규모의 추상화로 압축시킴으로써 단기 기억의 한계를 초월할 수 있다.

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

모든 프로그래밍 패러다임은 추상화와 분해의 관점에서 설명할 수 있다.

  1. 프로시저 추상화
    • 소프트웨어가 무엇을 해야하는지를 추상화
    • 프로시저 추상화를 중심으로 시스템을 분해한다면 기능 분해(알고리즘 분해)
  2. 데이터 추상화
    • 소프트웨어가 무엇을 알아야 하는지를 추상화
    • 데이터 추상화를 중심으로 시스템을 분해한다면 타입을 추상화(추상 데이터 타입) 하거나 프로시저를 추상화(객체지향) 해야한다.

협력하는 공동체를 구성하도록 객체들로 나누는 과정이 바로 객체지향 패러다임에서의 분해를 의미

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

🔖 7.2.1 메인 함수로의 시스템

기능 분해의 관점에서 추상화의 단위는 프로시저이며 시스템은 프로시저를 단위로 분해

  • 프로시저는 반복적으로 실행되거나 거의 유사하게 실행되는 작업들을 하나의 장소에 모아놓음으로써 로직을 재사용하고 중복을 방지할 수 있는 추상화 방법
  • 프로시저는 잠재적으로 정보은닉의 가능성을 제시

시스템은 필요한 더 작은 작업으로 분해될 수 있는 하나의 커다란 메인 함수다.

전통적인 기능 분해 방법은 **하향식 접근법(Top-Down Approach)**을 따른다.

  • 가장 최상위 기능을 정의하고, 이 기능을 좀 더 작은 단계의 하위 기능으로 분해해 나가는 방법
  • 분해는 세분화된 마지막 하위 기능이 프로그래밍 언어로 구현 가능한 수준이 될 때까지 계속된다.

🔖 7.2.2 급여 관리 시스템

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

직원의 급여를 계산한다

  • 급여 관리 시스템에 대한 추상적인 최상위 문장
직원의 급여를 계산한다 사용자로부터 소득세율을 입력받는다. 직원의 급여를 계산한다. 양식에 맞게 결과를 출력한다.
  • 세부적인 절차로 구체화
직원의 급여를 계산한다 사용자로부터 소득세율을 입력받는다. "세율을 입력하세요: "라는 문장을 화면에 출력한다. 키보드를 통해 세율을 입력받는다. 직원의 급여를 계산한다. 전역 변수에 저장된 직원의 기본급 정보를 얻는다. 급여를 계산한다. 양식에 맞게 결과를 출력한다. "이름: {직원명}, 급여: {계산된 금액}" 형식에 다라 출력 문자열을 생성한다.
  • 기능 분해의 결과
  • 입력 정보는 직원정보와 소득세율이고 출력은 계산된 급여 정보

🔖 7.2.3 급여 관리 시스템 구현

루비 언어를 기반으로 예시를 든다.

직원의 급여를 계산한다

def main(name) end
  • 최상위 문장은 하나의 메인 함수로 매핑된다.
사용자로부터 소득세율을 입력받는다. 직원의 급여를 계산한다. 양식에 맞게 결과를 출력한다. 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 = $imployees.index(name) basePay = $basePays[index] return basePay - (basePay * taxRate) end 양식에 맞게 결과를 출력한다. "이름: {직원명}, 급여: {계산된 금액}" 형식에 다라 출력 문자열을 생성한다. def describeResult(name, pay) return "이름: #{name}, 급여: #{pay}" end

하향식 기능 분해 방식으로 설계한 시스템은 메인 함수를 루트로 하는 '트리(tree)'로 표현할 수 있다. 체계적이고 이상적으로 보이지만 우리가 사는 세계는 그렇게 체계적이지도, 이상적이지도 않다.

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

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

🛠 하나의 메인 함수라는 비현실적인 아이디어

  • 어떤 시스템도 최초에 release됐던 당시의 모습을 그대로 유지하지는 않는다. 즉, 지속적으로 새로운 기능을 추가하게 된다.
  • 대부분의 추가되는 기능은 메인 함수의 일부가 아닐 것이다. 결국 어느 시점에 이르면 유일한 메인 함수라는 개념은 의미가 없어지고 시스템은 여러 개의 동등한 수준의 함수 집합으로 성장하게 될 것이다.
  • 하향식 접근법은 하나의 알고리즘을 구현하거나 배치 처리를 구현하기에는 적합하지만 현대적인 상호작용 시스템을 개발하는 데는 적합하지 않다.

실제 시스템에 정상(top)이란 존재하지 않는다. - 버트란드 마이어

🛠 메인 함수의 빈번한 재설계

  • 기존 로직과는 아무런 상관이 없는 새로운 함수의 적절한 위치를 확보해야 하기 때문에 메인 함수의 구조를 급격하게 변경할 수밖에 없다.

급여 관리 시스템에 모든 직원들의 기본급의 총합을 구하는 기능을 추가해 달라는 새로운 요구사항이 접수됐다고 가정

def sumOfBasePays() result = 0 for basePay in $basePays result += basePay end puts(result) end def main(name) taxRate = getTaxRate() pay = calculatePayFor(name, taxRate) puts(describeResult(name, pay)) end
  • 메인함수 안에서 호출할 자리가 마땅치 않다.
def calculatePay(name) taxRate = getTaxRate() pay = calculatePayFor(name, taxRate) puts(describeResult(name, pay)) end
  • 메인함수를 calcuatePay 함수로 옮기자
def main(operation, args={}) case(operation) when :pay then calculatePay(args[:name]) when :basePays then sumOfBasePays() end end
  • 인자에 따라 다른 함수를 호출
  • 결과적으로 기존 코드의 빈번한 수정으로 인한 버그 발생 확률이 높아지기 때문에 시스템 변경에 취약해질 수 밖에 없다.

🛠 비즈니스 로직과 사용자 인터페이스의 결합

  • 비즈니스 로직을 설계하는 초기 단계부터 입력 방법과 출력 양식을 함께 고민하도록 강요
  • 코드 안에서 비즈니스 로직과 사용자 인터페이스 로직이 밀접하게 결합
    • 사용자 인터페이스가 변경되는 빈번도 ⏫
    • 비즈니스 로직이 변경되는 빈번도 ⏬
    • 사용자 인터페이스가 변경되는 경우 비즈니스 로직까지 변경에 영향을 받음
    • 근본적으로 변경에 불안정한 아키텍처를 낳는다.
  • 하향식 접근법은 기능을 분해하는 과정에서 사용자 인터페이스의 관심사와 비즈니스 로직의 관심사를 동시에 고려하도록 강요하기 때문에 "관심사의 분리"라는 아키텍처 설계의 목적을 달성하기 어렵다.

🛠 성급하게 결정된 실행 순서

  • 하향식 기능 분해는 설계를 시작하는 시점에서 시스템이 무엇(what)을 해야 하는지가 아니라 어떻게(how) 동작해야 하는지에 집중하도록 만든다.
  • 처음부터 구현을 염두에 두기 때문에 함수들의 실행 순서를 정의하는 시간 제약을 강조
  • 중앙집중 제어 스타일의 형태를 띈다.
  • 하향식 접근법을 통해 분해한 함수들은 재사용이 어렵다.

하향식 설계와 관련된 모든 문제의 원인은 결합도 ❗️

  • 전체 시스템의 핵심적인 구조를 결정하는 함수들이 데이터와 강하게 결합된다는 것

🛠 데이터 변경으로 인한 파급효과

  • 하향식 기능 분해의 가장 큰 문제점은 어떤 데이터를 어떤 함수가 사용하고 있는지를 추적하기 어렵다는 것이다.
    • 데이터 변경으로 인해 어떤 함수가 영향을 받을지 예상하기 어렵다.

정규 직원의 급여뿐만 아니라 아르바이트 직원에 대한 급여도 관리를 할 수 있도록 해달라는 변경 요청이 왔다고 가정

  • 아르바이트 직원은 시간에 시급을 곱한 금액만큼을 지급
$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]
  • hourlys: true: 아르바이트, false: 정규직원
  • timeCards: 한달 간의 업무 누적 시간
def calculateHourlyPayFor(name, taxRate) index = $imployees.index(name) basePay = $basePays[index] * $timeCards[index] return basePay - (basePay * taxRate) end
  • 아르바이트 직원의 급여를 계산하는 함수
def hourly?(name) return $hourlys[$employees.index(name)] end
  • 정규직원과 아르바이트 직원을 판단하는 함수
def calculatePay(name) taxRate = getTaxRate() if (hourly?(name)) then pay = calculateHourlyPayFor(name, taxRate) else pay = calculatePayFor(name, taxRate) end puts(describeResult(name, pay)) end def sumOfBasePays() result = 0 for name in $employees if (not hourly?(name)) then result += $basePays[$employees.index(name)] end end puts(result) end

이처럼 데이터 변경으로 인해 발생하는 함수에 대한 영향도를 파악하는 것이 쉽지 않다.

  • 변경에 대한 영향을 최소화하기 위해 영향을 받는 부분과 받지 않는 부분을 명확하게 분리하고 잘 정의된 퍼블릭 인터페이스를 통해 변경되는 부분에 대한 접근을 통제하는 것이 의존성 관리의 핵심이다.

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

하향식 설계는 설계가 어느 정도 안정화 된 후에는 설계의 다양한 측면을 논리적으로 설명하고 문서화하기에 용이하다.

하향식은 이미 완전히 이해된 사실을 서술하기에 적합한 방법이다.

  • 하향식 분해는 작은 프로그램과 개별 알고리즘을 위해서는 유용하다.
  • 이미 해결된 알고리즘을 문서화하고 서술하는 데 유용하다.

📖 7.3 모듈

🔖 7.3.1 정보 은닉과 모듈

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

모듈은 서브 프로그램이라기보다는 책임의 할당이다.

  • 기능 분해는 하나의 기능을 구현하기 위해 필요한 기능들을 순차적으로 찾아가는 탐색의 과정
  • 모듈 분해는 감춰야 하는 비밀을 선택하고 비밀 주변에 안정적인 보호막을 설치하는 보존의 과정

모듈이 감춰야 할 비밀

  1. 복잡성
  2. 변경 가능성
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 = calculateHourlyPayFor(name, taxRate) else pay = calculatePayFor(name, taxRate) end end def Employees.hourly?(name) return $hourlys[$employees.index(name)] end def Employees.calculateHourlyPayFor(name, taxRate) index = $imployees.index(name) basePay = $basePays[index] * $timeCards[index] return basePay - (basePay * taxRate) end def Employees.calculatePayFor(name, taxRate) return basePay - (basePay * taxRate) end def Employees.sumOfBasePays() result = 0 for name in $employees if (not Employees.hourly?(name)) then result += $basePays[$employees.index(name)] end end return result end
  • ruby 언어는 module이라는 키워드를 제공하지만 모듈은 키워드의 지원 여부와 상관없이 적용할 수 있는 논리적인 개념이다.
  • 모듈 외부에서는 모듈 내부에 어떤 데이터가 존재하는지 알 수 없다.
def main(operation, args={}) case(operation) when :pay then calculatePay(args[:name]) when :basePays then sumOfBasePays() end end def calculatePay(name) taxRate = getTaxRate() pay = Employees.calculatePayFor(name, taxRate) puts(describeResult(name, pay)) end def getTaxRate() print("세율을 입력하세요: ") return gets().chomp().to_f() end def describeResult(name, pay) return "이름: #{name}, 급여: #{pay}" end def sumOfBasePays() puts(Employees.sumOfBasePays()) end

🔖 7.3.2 모듈의 장점과 한계

모듈의 장점

  • 모듈 내부의 변수가 변경되더라도 모듈 내부에만 영향을 미친다.
    • 데이터 변경으로 인한 파급효과를 제어할 수 있으므로 코드를 수정하고 디버깅이 용이
  • 비즈니스 로직과 사용자 인터페이스에 대한 관심사를 분리한다.
    • 사용자 인터페이스가 변경되어도 비즈니스 로직은 변경되지 않는다.
  • 전역 변수와 전역 함수를 제거함으로써 네임스페이스 오염을 방지한다.
    • 모듈은 네임스페이스를 제공
    • 이름 충돌의 위험 방지

모듈은 기능이 아니라 변경의 정도에 따라 시스템을 분해하게 한다.

  • 높은 응집도
    • 비밀과 관련성 높은 데이터의 집합
  • 낮은 결합도
    • 모듈과 모듈은 퍼블릭 인터페이스를 통해서만 통신

모듈은 데이터와 함수가 통합된 한 차원 높은 추상화를 제공하는 설계 단위이다 ❗️

모듈의 가장 큰 단점은 인스턴스의 개념을 제공하지 않는다는 점이다. 다수의 직원 인스턴스가 존재하는 추상화 메커니즘이 추상 데이터 타입이다.

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

🔖 7.4.1 추상 데이터 타입

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

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

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

리스코프는 추상 데이터 타입을 정의하기 위해 제시한 언어적인 메커니즘을 오퍼레이션 클러스터라고 불렀다.

Employee = Struct.new(:name, :basePay, :hourly, :timeCard) do End
  • 개별 직원을 위한 추상 데이터 타입
Employee = Struct.new(:name, :basePay, :hourly, :timeCard) do def calculatePay(taxRate) if (hourly) then return calculateHourlyPayFor(taxRate) end return calculateSalariedFor(taxRate) end private def calculateHourlyPayFor(taxRate) return (basePay * timeCard) - (basePay * timeCard) * taxRate end def calculateSalariedFor(taxRate) return basePay - (basePay * taxRate) end end
  • 직원을 지정하지 않아도 된다.
Employee = Struct.new(:name, :basePay, :hourly, :timeCard) do def monthlyBasePay() if (hourly) then return 0 end return basePay end end $employees = [ Employee.new("직원A", 400, false, 0), Employee.new("직원B", 300, false, 0), Employee.new("직원C", 200, false, 0), Employee.new("아르바이트D", 1, true, 120), Employee.new("아르바이트E", 1, true, 120), Employee.new("아르바이트F", 1, true, 120), ]
  • 직원들의 인스턴스
def calculatePay(name) taxRate = getTaxRate() for each in $employees if (each.name === name) then employee = each; break end end pay = employee. calculatePay(taxRate) puts(describeResult(name, pay)) end def sumOfBasePays() result = 0 for each in $employees result += each.monthlyBasePay() end puts(result) end
  • 추상 데이터 타입은 사람들이 세상을 바라보는 방식에 좀 더 근접해지도록 추상화 수준을 향상시킨다.
  • 여전히 데이터와 기능을 분리해서 바라본다.
  • 추상 데이터 타입은 말 그대로 시스템의 상태를 저장할 데이터를 표현한다.

추상 데이터 타입의 기본 의도는 프로그래밍 언어가 제공하는 타입처럼 동작하는 사용자 정의 타입을 추가할 수 있게 하는 것

📖 7.5 클래스

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

클래스와 추상 데이터 타입 모두 데이터 추상화를 기반으로 시스템을 분해한다. 그러나 명확한 의미에서 추상 데이터 타입과 클래스는 동일하지 않다.

  • 클래스는 상속과 다형성을 지원(객체지향 프로그래밍)하지만 추상 데이터 타입은 지원하지 못한다(객체기반 프로그래밍).

하나의 대표적인 타입이 다수의 세부적인 타입을 감추기 때문에 이를 타입 추상화라 부른다.

  • Employee 타입은 직원 타입과 아르바이트 타입이 있다.
  • 타입 추상화를 기반으로 하는 대표적인 기법이 바로 추상 데이터 타입이다.

추상 데이터 타입이 오퍼레이션을 기준으로 타입을 묶는 방법이라면 객체지향은 타입을 기준으로 오퍼레이션을 묶는다.

  • 클래스를 이용한 다형성은 절차에 대한 차이점을 감춘다.
  • 객체 지향은 절차 추상화다.

🔖 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
  • java 기준으로 보면 Employee class는 추상 클래스, calculatePay, monthlyBasePay는 추상 메서드이다.
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 class HourlyEmployee < Employee attr_reader :timeCard def initialize(name, basePay, timeCard) super(name, basePay) @timeCard = timeCard end def calculatePay(taxRate) return (basePay * timeCard) - (basePay * timeCard) * taxRate end def monthlyBasePay() return 0 end end $employees = [ SalariedEmployee.new("직원A", 400), SalariedEmployee.new("직원B", 300), SalariedEmployee.new("직원C", 200), HourlyEmployee.new("아르바이트D", 1, 120), HourlyEmployee.new("아르바이트E", 1, 120), HourlyEmployee.new("아르바이트F", 1, 120), ] def sumOfBasePays() result = 0 for each in $employees result += each.monthlyBasePay() end puts(result) end
  • 메시지를 수신한 객체는 자신의 클래스에 구현된 메서드를 이용해 적절하게 반응할 수 있다.

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

비록 클래스를 사용하고 있더라도 타입을 기준으로 절차를 추상화하지 않았다면 그것은 객체지향 분해가 아니다.

  • 클래스가 추상 데이터 타입의 개념을 다르는지를 확인할 수 있는 가장 간단한 방법은 클래스 내부에 인스턴스의 타입을 표현하는 변수가 있는지를 살펴보는 것
  • 객체지향에서는 타입 변수를 이용한 조건문을 다형성으로 대체

개방-폐쇄 원칙(Open-Closed Principle, OCP)

  • 기존 코드에 아무런 영향도 미치지 않고 새로운 객체 유형과 행위를 추가할 수 있는 객체지향의 특성
  • 대부분의 객체지향 서적에서는 추상 데이터 타입을 기반으로 애플리케이션을 설계하는 방식을 잘못된 것으로 설명한다.
    • 설계에 요구되는 변경의 압력이 '타입 추가'에 관한 것인지, 아니면 '오퍼레이션 추가'에 관한 것인지에 따라 달라진다.
    • 새로운 타입을 빈번하게 추가해야 한다면 객체지향의 클래스 구조가 더 유용
    • 새로운 오퍼레이션을 빈번하게 추가해야 한다면 추상 데이터 타입이 더 유용

🔖 7.5.4 협력이 중요하다

협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션의 구현 방식을 타입별로 분배하는 것은 올바른 접근법이 아니다.

객체가 참여할 협력을 결정하고 협력에 필요한 책임을 수행하기 위해 어떤 객체가 필요한지에 관해 고민하라.

  • 그 책임을 다양한 방식으로 수행해야 할 때만 타입 계층 안에 각 절차를 추상화하라.
  • 타입 계층과 다형성은 협력이라는 문맥 안에서 책임을 수행하는 방법에 관해 고민한 결과물이어야 하며 그 자체가 목적이 되어서는 안 된다.
Written by@BottleH
Back-End Developer

GitHub