본문 바로가기
도서/오브젝트

Chapter 7. 객체 분해

by iskull 2022. 2. 2.
728x90

  사람은 문제를 해결하기 위해 단기 기억을 사용한다. 하지만 단기 기억의 용량은 한정되 있고 이 용량을 초과하는 순간 문제 해결 능력이 저하되며 인지 과부화(cognitive overload)가 발생한다.

  인지 과부화를 줄이는 방법은 추상화이다. 세부 사항을 줄이고 가장 본질적인 부분만 남기면 된다. 즉, 한번에 다뤄야 하는 문제의 크기를 줄이면 된다. 하나의 큰 문제를 작은 문제로 나누는 것이 분해(decomposition)이다.

  분해의 목적은 큰 문제를 인지 과부화 없이 단기 기억 안에서 한 번에 처리할 수 있는 규모의 문제로 나누는 것이다. 여기서 말하는 "한 번에 처리할 수 있는 규모"는 가장 작은 단위로서의 개별 항목이 하는 하나의 청크(chunck)를 의미한다. 청크는 더 작은 청크를 포함할 수 있고 연속적으로 분해 가능하다. 예를 들어 임의로 조합된 11자리 정수 8개를 한번에 기억하기는 어렵다. 하지만 11자리 정수에 전화번호 라른 개념적 청크를 도입하면 8명에 대한 전화번호를 기억할 수 있도록 인지능력을 향상시킬 수 있다.

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

 

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

  프로그래밍 언어의 발전은 효과적인 추상화를 통해 복잡성을 극복하려는 개발자의 노력에서 출발했다. 프로그래밍 언어를 통해 표현되는 추상화의 발전은 다양한 프로그래밍 패러다임의 탄생으로 이어졌다.

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

  시스템을 분해하는 방법을 결정하기 위해서는 유선 프로시저 추상화와 데이터 추상화 중 어느것을 중심으로 할 것인지를 결정해야 한다. 프로시저 추상화를 중심으로 한다면 기능 분해(functional decompositoin) 또는 알고리즘 분해(algorithmic decomposition)를 한다. 데이터 추상화를 중심으로 시스템을 분해한다면 데이터를 중심으로 타입을 추상화(type abstraction)하거나 데이터를 중심으로 프로시저 추상화를 한다. 여기서 타입 추상화를 추상 데이터 타입(Abstract Data Type)이라 하고 데이터를 중심으로 프로시저를 추상화 하는 것을 객체지향이라 한다.

  "역할과 책임을 수행하는 객체"가 객체지향 패러다임이 이용하는 추상화다. 기능을 협력하는 공동체를 구성하게 객체들로 나누는 과정이 객체지향 패러다임에서의 분해이다.

  프로그래밍 언어 관점에서 객체지향은 데이터 중심으로 데이터 추상화와 프로시저 추상화를 통합한 객체를 이용해 시스템을 분해하는 방법이다. 이런 객체를 구현하기 위해 대부분의 객체이향 언어는 클래스라는 도구를 제공한다. 따라서 프로그래밍 언어적인 관점에서 객체지향을 바라보는 일반적인 관점은 데이터 추상화와 프로시저 추상화를 함께 포함한 클래스를 이용해 시스템을 분해하는 것이다.

  복잡성을 극복하는 방법은 현재의 문제를 해결할 수 있는 효과적인 추상화 매커니즘과 분해 방법을 찾는것이다. 그러면 객체지향이 전통적인 기능 분해 방법에 비해 효과적인 이유는 무엇일까?

 

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

메인 함수로서의 시스템

  기능 분해의 관점에서 추상화의 단위는 프로시저고 시스템은 프로시저를 단위로 분해된다.

  프로시저는 반복적으로 실행되거나 거으 유사하게 실행되는 작업들을 하나의 장소에 모아놓아 로직을 재사용하고 중복을 방지할 수 있는 추상화 방법이다. 프로시저는 인터페이스만 알아도 사용할 수 있기 때문에 잠재적으로 정보은닉의 가능성을 제시하지만 한계가 있다.

  프로시저 중심의 기능 분해 관점에서 시스템은 입력 값을 계산해서 출력 값을 반환하는 수학의 함수와 같다. 시스템은 더 작은 작업으로 분리될 수 있는 하나의 커다란 메인함수이다.

  전통적인 기능 분해 방법은 하향식 접근법을 따른다. 하향식 접근법은 시스템을 구성하는 가장 최상위 기능을 정의하고, 이 최상위 기능을 좀 더 작은 단계의 하위 기능으로 분해해 나가는 방법이다.

 

급여 관리 시스템

  급여 관리 시스템 예제를 살펴보자. 회사는 직원과 합의된 금액을 12개월 동안 동일하게 직원들에게 지급한다. 회사는 세율에 따라 일정 금액의 세금을 공제하기 때문에 직원이 실제로 받는 급여는 다음과 같다.

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

  급여 관리 시스템을 구현하기 위해 기능 분해 방법을 사용하고 하향식 접근법을 사용해 보자. 급여 관리 시스템을 입력을 받아 출력을 생성하는 하나의 큰 메인함수로 간주하고 기능 분해를 하면 다음과 같은 결과를 얻게 된다.

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

  기능 분해 방법은 기능을 주심으로 필요한 데이터를 결정한다. 기능 분해를 위한 하향식 접근법은 먼저 필요한 기능을 생각하고 이 기능을 분해하고 정제하는 과정에서 필요한 데이터의 종류와 저장 방식을 식별한다.

  이런 기능 분해 방법은 유지보수에 다양한 문제를 야기한다. 문제점들에 대해 더 설명하기 위해 실제로 급여 시스템을 구현해 보자.

급여 관리 시스템 구현

  위에서 진행한 기능 분해를 코드로 작성하면 다음과 같은 코드를 얻는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
$employees = ["직원A""직원B""직원C"# 직원 리스트
$basePays = [400300250# 급여 리스트
 
def main(name)
  # 사용자로부터 소득세율을 입력받는다.
  taxRate = getTaxRate()
  # 직원의 급여를 계산한다.
  pay = calculatePayFor(name, tasRate)
  # 양식에 맞게 결과를 출력한다.
  puts(describeResult(name, pay))
end
 
def getTaxRate()
  print("세율을 입력하세요")
  return gets().comp().to_f()
end
 
def calculatePayFor(name, texRate)
  index = $employees.index(name)
  basePay = $basePays[index]
  return basePay - (basePay * taxRate)
end
 
def describeResult(name, pay)
  return "이름 #{name}, 급여: #{pay}"
end
 
# 이름이 직원C인 직원의 급여 계산.
main("직원C")
cs

  하향식 기능 분해 방식으로 설계한 시스템은 메인 함수를 루트로 하는 트리이다. 각 노드는 시스템을 구성하는 하나의 프로시저를 의미하고 한 노드의 자식 노드는 부모 노드를 구현하는 절차 중 한 단계다.

  이처럼 하향식 기능 분해는 논리적이고 체계적인 시스템 개발 절차를 제시한다. 하지만 실제 세계는 논리적이지도, 체계적이지도 않다. 따라서 이런 논리적인 방식은 불규칙하고 불완전한 인간과 만나는 지점에서 혼란과 동료를 일으킨다.

하향식 기능 분해의 문제점

  하향식 기능 분해를 실제 설계에 적용하면 다음과 같은 문제들에 직면한다.

    1. 시스템은 하나의 메인 함수로 구성돼 있지 않다.

    2. 기능 추가나 요구사항 변경으로 인해 메인 함수를 빈번히 수정해야 한다.

    3. 비즈니스 로직이 사용자 인터페이스와 강하게 결합된다.

    4. 하향식 분해는 너무 이른 시기에 함수들의 실행 순서를 고정시키기 때문에 유연성과 재사용성이 저하된다.

    5. 데이터 형식이 변경될 경우 파급효과를 예측할 수 없다.

  설계가 필요한 이유는 변경에 대비하기 위한 것이다. 하지만 위 문제점들을 보면 하향식 접근법과 기능 분해는 변경에 취약한 설계를 낳는다. 위의 문제들을 자세히 설명하면 다음과 같다.

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

  어떤 시스템도 시간이 지나면 사용자 요구사항이 변함에 따라 새로운 기능들을 추가하게 된다. 이 기능들은 처음 존재했던 메인 함수 내부에 없는 기능인 경우가 많다. 따라서 처음에는 중요했던 메인 함수가 나중에는 동등하게 중요한 여러 함수들 중 하나로 전락한다.

  대부분의 시스템에서 하나의 메인 기능이라는 개념은 존재하지 않는다. 모든 기능들이 규모의 측면에서는 차이가 있지만 기능성 측면에서는 동등하게 독립적이고 완결된 하나의 기능을 표현한다. 따라서 하향식 접근법은 오직 단일 앍리즘 구현이나 배치 처리 구현에 적합하다.

메인 함수의 빈번한 설계

  하향식 접근법은 요구사항에 변동이 있을때 마다 기존 로직과 관련 없는 새로운 함수를 위한 위치를 확보해야 되기 때문에 메인 함수를 수정해야 한다. 기존 코드를 수정하는 행위는 버그를 발생시킬 확률을 높이므로 추가된 코드와는 관련 없는 위치에서 빈번히 발생하는 버그는 새로운 기능을 추가하거나 기존 코드를 수정하기 주저하게 한다.  예를 들어 위에서 설명했던 급여 관리 시스템에서 회사에 속한 모든 직원들의 기본급의 총합을 구하는 기능을 추가한다고 해보자. 이를 위해서는 sumOfBasePays라는 간단한 함수를 작성하면 되지만 기존 메인 함수에 이 함수 호출을 넣을 자리가 마땅치 않다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# sumOfBasePays 함수를 어디서 호출해야 하나
def main(name)
  # 사용자로부터 소득세율을 입력받는다.
  taxRate = getTaxRate()
  # 직원의 급여를 계산한다.
  pay = calculatePayFor(name, tasRate)
  # 양식에 맞게 결과를 출력한다.
  puts(describeResult(name, pay))
end
 
def sumOfBasePays()
  result = 0
  for basePay in $basePays
    result += basePay
  end
  puts(result)
end
cs

  현재 코드에서는 메인 함수 내부에 있는 로직과 sumOfBasePays 함수 내부에 있는 로직이 개념적으로 동등한 수준의 작업을 한다. 따라서 메인 안에서 sumOfBasePays를 호출할 수 없으므로 메인 내부 로직을 calculatePay라는 함수로 정의해 메인에서 calculatePay와 sumOfBasePays를 호출하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def main(operation, args={})
  # operation의 값에 따라 수행하는 작업이 달라진다.
  case(operation)
  when :pay thwn calculatePay(args[:name])
  when :basePays then sumOfBasePays()
  end
end
 
def calculatePay(name)
  # 사용자로부터 소득세율을 입력받는다.
  taxRate = getTaxRate()
  # 직원의 급여를 계산한다.
  pay = calculatePayFor(name, tasRate)
  # 양식에 맞게 결과를 출력한다.
  puts(describeResult(name, pay))
end
 
def sumOfBasePays()
  result = 0
  for basePay in $basePays
    result += basePay
  end
  puts(result)
end
cs

  이러한 변경 역시 하향식 접근법의 문제를 여실히 드러낸다. main 함수 내부를 보면 조건에 따라 수행하는 로직이 나뉘어 있다. 따라서 새로운 기능이 추가된다면 main 역시 수정해야 한다. 이런 기존 코드의 빈번한 수정은 버그 발생 확률을 높이고 시스템이 변경에 취약하게 한다.

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

  하향식 접근법은 비즈니스 로직 설계 단계 초기 부터 비즈니스 로직과 사용자 인터페이스 로직을 함께 고려하게 만든다. 급여 계산 시스템을 예로 들면 "사용자로부터 소득세율을 입력 받아 급여를 계산한 후 계산된 결과를 화면에 출력한다"라는 말에는 급여를 계산하는 비즈니스 로직과 관련된 관심사와 소득 세율을 입력받아 결과를 출력한다는 사용자 인터페이스가 섞여있다.

  문제는 비즈니스 로직과 사용자 인터페이스가 변경되는 빈도가 다르다는 것이다. 사용자 인터페이스는 빈번히 변경되지만 비즈니스 로직은 빈번히 변경되지 않는다. 따라서 비즈니스 로직과 사용자 인터페이스가 섞여 있으면 사용자 인터페이스의 변경이 비즈니스 로직에 영향을 줄 수 있다. 결론적으로 하향식 접근법은 변경에 불안정한 아키텍처를 낳는다. 또 한 기능을 분해하는 과정에서 사용자 인터페이스와 비즈니스 로직을 동시에 고려하도록 강요하기 때문에 "관심사 분리"라는 아키텍처 설계의 목적으 달성하기 어렵다.

성급하게 결정된 실행 순서

  하향식 접근법으로 기능을 분해하는 과정은 하나의 함수를 더 작은 함수로 만들고 분해된 함수들의 실행 순서를 결정하는 것이다. 이는 설계를 시작할 때 무엇을 해야하는지 보다는 어떻게 동작해야 하는지에 집중하게 한다.

  하향식 접근법은 처음부터 구현을 염두에 두기 때문에 자연스럽게 함수들의 실행 순서를 정의하는 시간 제약(temporal constraint)을 강조한다. 메인 함수가 작은 함수들로 분리되기 위해서는 함수들의 순서를 결정해야 한다. 급여 계산 시스템의 경우도 급여 계산에 필요한 함수들의 실행 순서를 정하지 않고서는 기능 분해를 진행할 수 없다.

  실행 순서, 제어 구조를 미리 결정하지 않으면 분해를 진행할 수 없다. 따라서 기능 분해 방식은 중앙집중 제어 스타일(centralized control style)의 형태를 띤다. 결과적으로 모든 중요한 제어가 상위 함수에서 이뤄지고 하위 함수는 상위 함수의 흐름에 따라 적절한 시점에 호출된다.

  문제는 제어 구조가 매번 변경된다는 것이다. 결과적으로 기능 추가와 변경은 매번 기존에 결정된 함수의 제어구조를 변경하게 만든다.

  이 문제를 해결하기 위해서는 논리적 제약(logical constraint)을 사용해야 한다. 객체지향은 함수 간의 호출 순서가 아닌 객체 사이의 논리적인 관계를 중심으로 설계를 한다. 결과적으로 시스템의 한 구성요소에 제어가 집중되지 않는다.

  하향식 접근법에서 모든 함수는 상위 함수를 분리하는 과정에서 필요에 따라 식별되고, 그에 따라 상위 함수가 강요하는 문맥 안에서만 의미를 가진다. 즉, 분해된 함수는 상위 함수보다 더 문맥에 종속적이다. 재사용이라는 개념은 일반성이라는 의미를 포함한다. 재사용 가능한 함수는 상위 함수보다 더 일반적이여야 한다.

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

  하향식 접근법의 또 다른 문제는 함수의 관점이 아닌 데이터 관점에서 어떤 함수가 이 데이터를 상요하고 있는지 알기 어렵다는 것이다. 이를 알기 위해서는 모든 함수를 확인해야 한다.

  이는 의존성과 결합도 그리고 테이스의 문제이다. 데이터 변경으로 인한 파급은 이를 직접 참조하고 있는 모든 함수로 퍼져나간다. 따라서 하나의 변경이 수 많은 버그를 야기할 수 있다.

  위에서 설명한 급여 시스템에 아르바이트도 추가한다고 해보자. 아르바이트 직원은 정규직 직원과 같이 $employees에, 급여 역시 $basePays에 저장한다. 그러면 $employees와 $basePays를 사용하는 모든 함수 중 아르바이트 직원을 함께 처리해야 하는 함수를 찾아 수정해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
$employees = ["직원A""직원B""직원C""아르바이트D""아르바이트E""아르바이트F"# 직원 리스트
$basePays = [4003002501115# 급여 리스트
$hourlys = [falsefalsefalsetruetruetrue# 알바생 여부, 정규직은 false
$timeCards = [000120120120# 알바생이 일한 시간, 정규직은 0
 
def main(operation, args={})
  case(operation)
  when :pay thwn calculatePay(args[:name])
  when :basePays then sumOfBasePays()
  end
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 getTaxRate()
  print("세율을 입력하세요")
  return gets().comp().to_f()
end
 
# 추가
def hourly?(name)
  reutrn $hourlys[$employees.index(name)]
end
 
# 추가
def calculateHourlyPayFor(name, taxRate)
  index = $employees.index(name)
  basePay = $basePays[index] * $timeCards[index]
  return basePay - (basePay * taxRate)
end
 
def calculatePayFor(name, texRate)
  index = $employees.index(name)
  basePay = $basePays[index]
  return basePay - (basePay * taxRate)
end
 
def describeResult(name, pay)
  return "이름 #{name}, 급여: #{pay}"
end
 
# 수정
def sumOfBasePays()
  result = 0
  for name in $employess
    if (not hourly?(name)) then
      result += &basePays[$employess.index(name)]
    end
  end
  puts(result)
end
cs

  위 코드를 보면 하나의 변경으로 인해 함수들이 추가되고 수정되었다. 만약 수정될 함수들을 정확히 파악하지 못한다면 이는 버그로 이어질 것이다.

  데이터 변경으로 인한 영향을 최소화 하기 위해서는 데이터와 함께 변경된느 부분과 그렇지 않은 부분을 명확하게 분리해야 한다. 이를 위해서는 잘 정의된 퍼블릭 인터페이스를 통해 변경되는 부분에 대한 접근을 통제해야 한다. 초기 소프트웨어 개발 분야의 선구자인 David Parnas는 이 같을 개념을 기반으로 정보 은닉과 모듈이라는 개념을 제시했다.

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

  하향식 분해는 설계가 어느 정도 안정화된 후에 설계의 다양한 측면을 논리적으로 설명하고 문서화하기에 유용하다. 하지만 설계를 문서화하는 데 적절한 방법이 좋은 구조를 설계할 수 있는 방법과 동일한 것은 아니다.

  하향식 분해는 작은 프로그램과 알고리즘을 위해서는 유용한 패러다임으로 남아있다. 이미 해결된 알고리즘을 문서화하고 서술하는 데는 훌륭한 기법이다. 하지만 거대한 소프트웨어를 설계하는 데는 적합하지 않다.

 

모듈

정보 은닉과 모듈

  시스템 변경을 잘 관리하기 위해서는 변경되는 부분을 하나의 구현 단위로 묶고 퍼블릭 인터페이스를 통해서만 접근하게 만들어야 한다. 즉, 변경의 방향에 맞춰 시스템을 분해해야 한다.

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

  모듈은 책임의 할당이다. 모듈화는 개별적인 모듈에 대한 작업이 시작되기 전에 정해져야 하는 설계 결정들을 포함한다. 분할된 모듈은 다른 모듈에 대해 감춰야 하는 설계 결정에 따라 특징지어진다. 해당 모듈 매부의 작업을 가능 한 적게 노출하는 인터페이스 또는 정의를 선택한다. 

  모듈과 기능 분해는 상호 배타적인 관계가 아니다. 기능 분해는 하나의 기능을 구현하기 위해 필요한 기능들을 순차적으로 찾아가는 탐색 과정이다. 모듈 분해는 감춰야 하는 비밀을 선택하고 비밀 주변에 안정적인 보호막을 설치하는 보존의 과정이다. 비밀을 결정하고 모듈을 분해한 후 기능 분해를 이용해 모듈에 필요한 퍼블릭 인터페이스를 구현할 수 있다.

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

    1. 복잡성: 모듈이 너무 복잡하면 이해하고 사용하기 어렵다. 외부에 모듈을 추상화할 수 있는 간단한 인터페이스를

        제공해서 모듈의 복잡도를 낮춘다.

    2. 변경 가능성: 변경 발생 시 하나의 모듈만 수정하면 되게 변경 가능한 설계 결정을 모듈 내부로 감추고 외부에는 쉽게

        변경되지 않을 인터페이스를 제공한다.

  위에서 다룬 급여 관리 시스템에서 알 수 있듯이 가장 일반적인 비밀은 데이터이다. 이때문에 데이터 캡슐화와 정보 은닉을 혼동하는 경우가 있다. 하지만 비밀이 반드시 데이터일 필요는 없다. 복잡한 로직이나 변경 가능성이 큰 자료 구조일 수도 있다. 그럼에도 변경 시 시스템을 굴복시키는 대부분의 경우는 데이터가 변경되는 경우다.

  급여 관리 시스템을 변경에 취약하지 않게 하려면 모듈을 이용해 비밀을 내부로 감추고 퍼블릭 인터페이스만 노출시켜야 한다. 루비의 경우 언어에서 제공하는 module이라는 키워드를 통해 모듈의 개념을 구현한다. 이를 통해 모듈화한 급여 관리 시스템은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
module Employees
  $employees = ["직원A""직원B""직원C""아르바이트D""아르바이트E""아르바이트F"# 직원 리스트
  $basePays = [4003002501115# 급여 리스트
  $hourlys = [falsefalsefalsetruetruetrue# 알바생 여부, 정규직은 false
  $timeCards = [000120120120# 알바생이 일한 시간, 정규직은 0
 
  def Employees.calculatePay(name)
    taxRate = getTaxRate()
    if (hourly?(name)) then
      pay = calculateHourlyPayFor(name, taxRate)
    else
      pay = calculatePayFor(name, taxRate)
    end
    puts(describeResult(name, pay))
  end
 
  def Employees.hourly?(name)
    reutrn $hourlys[$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, texRate)
    index = $employees.index(name)
    basePay = $basePays[index]
    return basePay - (basePay * taxRate)
  end
 
  def Employees.sumOfBasePays()
    result = 0
    for name in $employess
      if (not hourly?(name)) then
        result += &basePays[$employess.index(name)]
      end
    end
    puts(result)
  end
end
 
def main(operation, args={})
  case(operaion)
  when :pay then calculatePay(args[:name])
  when :basePays then sumOfBasePays()
  end
end
 
def calculatePay(name)
  taxRate = getTaxRate()
  pay = Employees.calculatePay(name, taxRate)
  puts(describeResult(name, pay))
end
 
def getTaxRate()
  print("세율을 입력하세요")
  return gets().chomp().to_f()
end
cs

  이제 외부에서는 Employees 모듈이 제공하는 퍼블릭 인터페이스를 통해서만 내부 변수를 조작할 수 있다. 

모듈의 장점과 한계

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

    모듈 내부의 변수가 변경되어도 모듈 내부에서만 영향을 미친다

      모듈 내부에 정의된 변수를 직접 참조하는 코드의 범위를 모듈로 제한해 데이터 변경으로 인한 파급효과를 제어할 수

     있다. 따라서 수정과 디버깅이 용이해 진다.

   비즈니스 로직과 사용자 인터페이스에 대한 관심사를 분리한다

      Employees 모듈은 비즈니스 로직과 관련된 부분만 담당한다. 사용자 인터페이스와 관련된 관심사는 모두

     Employees 모듈을 사용하는 main 함수에 위치한다. 이제 GUI에 다른 사용자 인터페이스가 추가되도 Employees는

     변경되지 않는다.

   전역 변수와 전역 함수를 제거해 namespace pollution을 방지한다

      모듈은 변수와 함수를 모두 모듈 내부에 위치시키기 때문에 다른 모듈에서 동일한 이름을 사용할 수 있게 된다. 따라서

    모듈은 global namespace pollution을 방지하고 name collision의 위험을 완화한다.

  모듈은 변경의 정도에 따라 나눠야 한다. 각 모듈은 외부에 감춰야 하는 비밀과 관련성 높은 데이터와 함수의 집합이다. 따라서 모듈 내부는 높은 응집도를 유지해야 한다. 모듈과 모듈 사이에는 퍼블릭 인터페이스 만을 사용해 통신해야 한다. 따라서 모듈과 모듈은 낮은 결합도를 유지해야 한다.

  모듈이 프로시저 추상화 보다는 높은 추상화 개념을 제공한다. 하지만 모듈은 태생적으로 변경을 관리하기 위한 구현 기법이다. 그때문에 모듈은 인스턴스 개념을 제공하지 않는다. 좀 더 높은 추상화를 위해서는 인스턴스가 필요하고 이를 만족시키기 위해서 등장한 것이 추상 데이터 타입이다.

 

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

추상 데이터 타입

  프로그래밍 언어에서 타입은 변수에 저장할 수 있는 내용물의 종류와 적용할 수 있는 연산들을 의미한다. 따라서 타입을 통해 변수의 값이 어떻게 행동할 것인지를 예측할 수 있다. 예를 들어 정수 타입의 변수는 덧셈 연산을 이용해 값을 합칠 수 있다.

  프로그래밍 언어들은 다양한 형태의 내장 타입(built-in type)들을 제공한다. 기능 분해 시절에 나온 언어들은 적은 수의 내장 타입만을 제공했고 새로운 타입을 정의하지 못했다. 그때문에 프로시저 추상화가 주된 추상화였다. 그 후 프로시저 추상화 만으로는 프로그램의 표현력을 향상시키는 데 한계가 있다는 사실이 발견되었고 이를 보완하기 위해 데이터 추상화(data abstraction)가 나오게 되었다.

  추상 데이터 타입은 추상 객체의 클래스를 정의한 것으로 추상 객체에 사용할 수 있는 오퍼레이션을 이용해 규정된다. 이는 오퍼레이션을 이용해 추상 데이터 타입을 정의할 수 있음을 의미한다.

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

    1. 타입 정의를 선언할 수 있어야 한다.

    2. 타입의 인스턴스를 다루기 위해 사용할 수 있는 오퍼레이션의 집합을 정의할 수 있어야 한다.

    3. 제공된 오퍼레이션을 통해서만 조작할 수 있게 데이터를 외부로 부터 보호할 수 있어야 한다.

    4. 타입에 대해 여러 개의 인스턴스를 생성할 수 있어야 한다.

  추상 데이터 타입을 구현할 수 있는 언어적인 장치를 제공하지 않는 언어에서도 추상 데이터 타입을 구현할 수 있다. 하지만 언어 차원에서 추상 데이터 타입을 구현한 것과 관습, 약속, 기법을 통해 추상 데이터 타입을 모방하는 것은 다른 얘기다. 이는 객체지향 언어를 사용하지 않아도 객체지향 프로그래밍을 할 수 있다는 낭설과 같다.

  다시 급여 시스템으로 돌아가 보자. 루비에서 제공하는 추상 데이터 타입을 흉내 낼 수 있는 Struct를 통해 구조를 개선해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
Employee = Struct.new(:name, :basePay, :hourly, :timeCard) do
  def calculatePay(texRate)
    if (hourly) then
      return calculateHourlyPay(taxRate)
    end
    return calculateSalariedPay(taxRate)
  end
 
  def monthlyBasePay()
    if (hourly) 
      then return 0
    end
    return basePay
  end
 
private
   def calculateHourlyPay(taxRate) 
     return (basePay * timeCard) - (basePay * timeCard) * taxRate
   end
   
   def claculateSalariedPay(taxRate)
     return basePay - (basePay * taxRate)
   end
end
 
$employees = [
  Employee.new("직원A"400false0),
  Employee.new("직원B"300false0),
  Employee.new("직원C"250false0),
  Employee.new("아르바이트D"1true120),
  Employee.new("아르바이트E"1true120),
  Employee.new("아르바이트F"1true120),
  Employee.new("아르바이트G"1true120),
]
 
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
cs

  이처럼 추상 데이터 타입 정의를 기반으로 객체를 생성하는 것은 가능하다. 하지만 데이터와 기능을 분리해서 바라보고 있다. 추상 데이터 타입으로 표현된 데이터를 이용해 기능을 구현하는 핵심 로직은 추상 데이터 타입 외부에 존재하고 있다. 

클래스

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

  클래스와 추상 데이터 타입 모두 데이터 추상화를 기반으로 시스템을 분석하고 객체의 내부 속성에 직접 접근할 수 없으며 오직 퍼블릭 인터페이스를 통해 외부와 의사소통할 수 있다.

  하지만 엄밀히 말하면 추상 데이터 타입과 클래스는 서로 다르다. 클래스는 상속과 다형성을 지원하는 반면 추상 데이터 타입을 제공하지 않는다. 그때문에 상속과 다형성을 지원하는 프로그래밍 패러다임을 객체지향 프로그래밍이라 하고 상속과 다형성을 지원하지 않는 추상 데이터 타입 기반 프로그래밍 패러다임을 객체기반 프로그래밍(Object-Based Programming)이라 한다.

  William Cook의 <<Object-Oriented Progarmming Versus Abstract Data Types>>에 따르면 객체지향과 추상 데이터 타입 간의 차이는 추상 데이터 타입은 타입을 추상화(type abstraction)한 것이고 클래스는 절차를 추상화(procedural abstraction)한 것이다.

  타입 추상화와 절차 추상화를 이해하기 위해 Employee 타입을 다시 한번 보자. 이 타입을 걷보기에는 하나의 타입이지만 내부에서는 정규직과 아르바이트 직원이라는 두 개의 타입이 공종한다. 설계의 관점에서 Employee는 세부적인 타입을 외부에 캡슐화하고 있는 것이기 때문에 타입을 추상화 한 것이다. 타입 추상화는 개별 오퍼레션이 모든 개변적인 타입에 대한 구현을 포괄하게 해서 하나의 물리적인 타입 안에 전체 타입을 감춘다. 따라서 타입 추상화는 오퍼레이션을 기준으로 타입을 통합하는 데이터 추상화 기법이다.

  객체지향은 타입을 기준으로 오퍼레이션을 묶는다. 급여 계산 시스템의 경우 객체지향은 정규 직원과 아르바이트 직원이라는 두 개의 타입을 명시적으로 정의하고 두 직원 유형과 관련된 오퍼레이션의 실행 절차를 두 타입에 분배한다. 공통 로직의 경우 공통 로직을 포함한 부모 클래스를 정의하고 두 직원 클래스가 부모 클래스를 상속받게 하면 된다. 그러면 클라이언트는 동일한 메시지를 요청하지만 실제로 실행되는 절차는 다르게 된다. 이것이 다형성이다.

  클라이언트 관점에서 두 클래스의 인스턴스는 동일하게 보인다. 그럼에도 내부에서 실행되는 절차는 다르다. 따라서 객체짖향은 절차 추상화다.

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

  이제 급여 관리 시슽템을 클래스로 구현해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class Employee
  attr_reader :name, :basePay
 
  def initialize(name, basePay)
    @name = name # 인스턴수 변수 명은 @로 시작한다
    @basePay = basePay
  end
 
  def calculaptePay()
    raise NotImplementedError
  end
 
  def monthlyBasePay()
    raise NotImplementedError
  end
end
 
# Employee 상속
class SalariedEmployee < Employee
  def initialize(name, basePay)
    super(name, basePay)
  end
 
  def calculaptePay(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 calculaptePay(taxRate)
    return (basePay * timeCard) - (basePay * timeCard) * taxRate
  end
 
  def monthlyBasePay()
    return 0
  end
end
 
$employees = [
  SalariedEmployee.new("직원A"400false0),
  SalariedEmployee.new("직원B"300false0),
  SalariedEmployee.new("직원C"250false0),
  HourlyEmployee.new("아르바이트D"1true120),
  HourlyEmployee.new("아르바이트E"1true120),
  HourlyEmployee.new("아르바이트F"1true120),
  HourlyEmployee.new("아르바이트G"1true120),
]
 
def sumOfBasePays()
  result = 0
  for each in $employees
    result += each.monthlyBasePay()
  end
  puts(result)
end
cs

변경을 기준으로 선택하라

  단순히 클래스를 구현 단위로 사용한다는 것이 객체지향이 아니다. 타입을 기준으로 절차를 추상화하지 않으면 객체지향 분해가 아니다.

  클래스가 추상 데이터 타입의 개념을 따르는지를 확인할 수 있는 좋은 방법은 클래스 내부에 인스턴스의 타입을 표현하는 변수가 있는지를 살펴보는 것이다. 추상 데이터 타입으로 구현한 Employee를 다시 봐보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Employee = Struct.new(:name, :basePay, :hourly, :timeCard) do
  def calculatePay(texRate)
    if (hourly) then
      return calculateHourlyPay(taxRate)
    end
    return calculateSalariedPay(taxRate)
  end
 
  def monthlyBasePay()
    if (hourly) 
      then return 0
    end
    return basePay
  end
  
  ...
end
cs

  이 구현은 hourly라는 변수로 메서드 내에서 타입을 명시적으로 구분하고 있다.

  객체지향에서는 타입 변수를 이용한 조건문을 다형성으로 대체한다. 클라이언트가 객체의 타입을 확인해 적절한 메서드를 호출하는 것이 아닌 객체가 메시지를 처리할 적절한 메서드를 선택한다. 조건문을 기피하는 이유는 변경 때문이다. 

  다형성을 활용하면 새로 추가한 클래스의 메서드를 실행하기 위해서 클라이언트 코드를 수정할 필요가 없다. 이처럼 기존 코드에 아무런 영향을 미치지 않고 새로운 객체 유형과 행위를 추가할 수 있는 특성을 개방-폐쇄 원칙(Open-Closed Principle)이라 한다. 

  그렇다고 추상 데이터 타입을 기반으로 한 설게가 잘못된 것은 아니다. 설계는 변경과 관련되 있다. 설계의 유용성은 변경의 방향성과 발생 빈도에 따라 결정된다. 만약 타입 추가라는 변경 압력이 더 강하다면 객체지향을, 오퍼레이션 추가라는 변경 압력이 더 강하면 추상 데이터 타입을 선택해야 한다. 변경의 축을 찾는 것이 핵심이다. 객체지향이 반드시 올바른 해결법은 아니다.

  레베카 워프스브록은 추상 데이터 타입의 접근법을 객체지향 설계에 구현한 것을 데이터 주도 설계라 했다.

 

출처 - 오브젝트