개발자는 기록이 답이다

오브젝트 7장 - 객체 분해 본문

기술 서적/OOP

오브젝트 7장 - 객체 분해

slow-walker 2024. 1. 5. 22:08

 

사람의 기억은 단기 기억과 장기 기억으로 분류할 수 있다

 

  • 단기 기억 : 보관돼 있는 지식에 직접 접근할 수 있지만 정보를 보관할 수 있는 속도와 공간적 측면이 모두 제약을 받는다
  • 장기 기억 : 경험한 내용을 수개월에서 길게는 평생에 걸쳐 보관하는 장소
    • 일반적으로 장기 기억안에 보관돼 있는 지식은 직접 접근하는 것이 불가능하고, 먼저 단기 기억 영역으로 옮긴 후에 처리해야 한다

 

실제로 문제를 해결하기 위해 사용하는 저장소는 단기 기억이다.

하지만 문제 해결에 필요한 요소가 단기 기억의 용량을 초과하는 순간 문제 해결 능력은 급격히 떨어지는 인지 과부하(cognitvie overload)현상이 발생한다.

 

인지 과부하를 방지하기 위해 단기 기억 안에 보관할 정보의 양을 조절한다

한번에 다뤄야 하는 정보와 수를 줄이기 위해 본질적인 정보만 남기고 불필요한 세부 사항을 걸러내면 문제를 단순화할 수 있을 것이다.

이러한 작업을 추상화라 한다.

 

  • 가장 일반적인 추상화 방법 : 한번에 다뤄야하는 문제의 크기를 줄이는 것
    • 큰 문제를 해결 가능한 작은 문제로 나누는 작업을 분해(decomposition)이라고 부른다.

 

분해의 목적은 큰 문제를 인지 과부하의 부담 없이 단기 기억 안에서 한 번에 처리할 수 있는 작은 규모의 문제로 만드는 것이다.

 

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

 

프로그래밍 패러다임이란 적절한 추상화의 윤곽을 따라 시스템을 어떤식으로 나눌 것인지를 결정하는 원칙과 방법의 집합을 의미한다.

  • 프로시저 추상화 : 스프트웨어가 무엇을 해야 하는지 추상화 (How)
    • 기능분해(functional decomposition) = 알고리즘 분해
  • 데이터 추상화 : 소프트웨어가 무엇을 알아야 하는지 추상화 (What)
    • 데이터를 중심으로 타입을 추상화
      • 추상 데이터 타입(Abstract Data Type)
    • 데이터를 중심으로 프로시저를 추상화
      • 객체지향(Object-Oriented) 

 

시스템을 분해하는 방법을 결정하려면 먼저 "프로시저 추상화"를 중심으로 할지, "데이터 추상화"를 중심으로 할지 결정한다.

  • 역할과 책임을 수행하는 객체 → 객체지향 패러다임이 이용하는 추상화
  • 협력하는 공동체를 구성하도록 객체를 나누는 과정 → 객체지향 패러다임에서의 분해

 

객체지향 언어는 클래스라는 도구를 제공한다. 프로그래밍 언어적인 관점에서 객체지향을 바라보는 일반적인 관점은 데이터 추상화와 프로시저 추상화를 함께 포함한 클래스를 이용해 시스템을 분해하는 것이다.

 

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

메인 함수로서의 시스템

 

기능 분해 관점에서 추상화 단위는 프로시저를 단위로 분해한다

 

🚩 프로시저란?

 

반복적으로 실행되거나 거의 유사하게 실행되는 작업들을 하나의 장소에 모아놓음으로써 로직을 재사용하고, 중복을 방지할 수 있는 추상화 방법이다. 프로시저를 추상화라고 부르는 이유내부의 상세한 구현 내용을 모르더라도 인터페이스만 알면 프로시저를 사용할 수 있기 때문이다. 따라서 잠재적으로는 정보 은닉의 가능성을 제시하지만, 프로시저만으로는 효과적인 정보 은닉 체계를 구축하는 데 한계가 있다.

 

전통적인 기능 분해 방법 : 하향식 접근법(Top-Down)

 

🚩 하향식 접근법이란?

 

1) 시스템을 구성하는 가장 최상위 기능을 정의한다

2) 이 최상위 기능을 좀 더 작은 단계의 하위 기능으로 분해해 나가는 방법을 말한다.

 

분해는 세분화된 마지막 하위 기능이 프로그래밍 언어로 구현 가능한 수준이 될때까지 계속된다.

각 세분화 된계는 바로 위 단계보다 더 구체적이어야 한다. 즉, 정제된 기능은 자신의 바로 상위 기능보다 덜 추상적이어야 한다.

상위 기능 → 하나 이상의 더 간단하고 더 구체적이고 덜 추상적인 하위 기능의 집합으로 분해

 

급여관리 시스템

 

연초에 회사는 매달 지급해야 하는 기본급에 대해 직원과 협의하며 이 금액을 12개월 동안 동일하게 직원들에게 지급한다.

회사는 급여 지급 시 소득 세율에 따라 일정 금애그이 세금을 공제한다.

따라서 직원들이 실제로 지급받게 되는 급여는 다음 공식에 따라 계산한다.

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

 

1. 추상적인 최상위 문장을 기술 : 메인 프로시저로 구현될 예정

직원의 급여를 계산한다

 

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

  • 급여를 계산하는데 필요한 정보 : 직원의 이름, 소득세율
    • 직원의 이름은 프로시저의 인자로 전달받음
    • 소득세율은 사용자로부터 직접 입력받음

급여 계산에 필요한 데이터가 결정됐으므로, 최상위 문자은 아래처럼 좀 더 세부적인 절차로 구체화 될 수 있다.

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

 

 

3. 정제 단계 : 이전 문장의 추상화 수준을 감소시킨다. 모든 문장이 정체 과정을 거치면서 하나 이상의 좀 더 단순하고 구체적인 문장들의 조합으로 분해되어야 한다.

 

좀 더 정제 가능한 문장이 존재하면, 동일한 과정을 거쳐 구현이 가능할 정도로 충분히 저수준의 문장이 될때까지 기능을 분해해야 한다.

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

 

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

 

기능 분해 방법에서는 기능을 중심으로 필요한 데이터를 결정한다. 기능이 주연이고, 데이터는 조연이다.

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

 

기능 분해 방법은 유지보수하기에 많은 문제점을 가진다.

 

급여 관리 시스템 구현

 

직원의 급여를 계산한다

def main(name)
end
직원의 급여를 계산한다
	1. 사용자로부터 소득세율을 입력받는다
    	2. 직원의 급여를 계산한다
    	3. 양식에 맞게 결과를 출력한다
        
        
def main(name)
	taxRate = getTaxRate()
    	pay = calculatePayFor(name, taxRate)
   	puts(describeResult(name,pay))
end
1. 사용자로부터 소득세율을 입력받는다
        "세율을 입력하세요: "라는 문장을 화면에 출력한다
        키보드를 통해 세율을 입력받는다
        
        
def getTaxRate()
	print("세율을 입력하세요: ")
    	return gets().chomp().to_f()
end

 

급여를 계산하기 위해서 애플리케이션 내부에 직원 목록과 기본급에 대한 정보를 유지하고 있어야 한다.

(Ruby에서 전역변수는 $로 시작한다.)

2. 직원의 급여를 계산한다
    전역 변수에 저장된 직원의 기본급 정보를 얻는다
    급여를 계산한다
    
    
$employess = ["직원A" ,"직원B", "직원C"]
$basePays - [400, 300, 250]


def calculatePayFor(name, taxRate)
	index = $employees.index(name)
   	 basePay = $basePays[index]
    	return basePay - (basePay * taxRate)
end
3. 양식에 맞게 결과를 출력한다
    "이름: {직원명}, 급여: {계산된 금액}" 형식에 따라 출력 문자열을 생성한다
    
    
def describeResult(name, pay)
	return "이름: #{name}, 급여: #{pay}"
end

 

이렇게 만든 기능을 통해 프로시저를 호출하면 된다.

main("직원C")

 

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

 

하향식 기능의 문제점

 

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

 

설계는 "코드 배치 방법"이며, 설계가 필요한 이유는 "변경에 대비"하기 위한 것이라는 점을 기억하라

 

 

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

 

어떤 시스템도 최초에 릴리스됐던 당시의 모습 그대로 유지하지는 않는다. 시간이 지나고 사용자를 만족시키기 위한 새로운 요구사항을 도출해나가면서 지속적으로 새로운 기능을 추가하게 된다.

 

처음에 중요하다고 생각했던 메인 함수는 동등하게 중요한 여러 함수들 중 하나로 전락하고 만다.

어느 시점에 다르면 유일한 메인 함수라는 개념은 없어지고, 여러 개의 동등한 수준의 함수 집합으로 성장한다.

 

하향식 접근법은 하나의 알고리즘을 구현하거나 배치 처리를 구현하기에는 적합하지만, 현대적인 상호작용 시스템을 개발하기에 적합하지 않다.

 

메인함수의 빈번한 재설계

 

하나의 메인 함수를 유일한 정상으로 간주하는 하향식 기능 분해는 새로운 기능이 추가될때마다 매번 메인 함수를 수정해야 한다.

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

 

기존 코드를 수정하는 것은 항상 새로운 버그를 만들어낼 확률을 높인다.

 

만일 "모든 직원들의 기본급 총합"을 구하는 기능이 추가되었다고 가정하자, 기존 메인함수는 직원 각각의 급여를 계산하는 것이 목적이므로 자리 배치가 마땅치 않다. 조건문을 통해 기본급 총합을 구할지, 직원 각각의 급여를 계산해야할지 메인함수에서 분기 처리를 해야한다.

 

def main(operation, args={})
   case(operation)
    when : pay then calculatePay(args[:name])
    whne : basePays then sumOfBasePays()
    end
end
//기본급의 총합을 구하기 위해
main(:basePays)

//직원 A의 급여를 계산하기 위해
main(:pay, name:"직원A")

 

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

 

하향식 접근법은 비즈니스 로직을 설계하는 초기 단계부터 입력 방법과 출력 양식을 함께 고민하도록 강요한다.

 

급여를 계산하는 중요한 비즈니스 로직과 관련된 관심사와 소득 세율을 입력받아 결과를 화면에 출력한다는 사용자 인터페이스의 관심사가 섞여 있다.

 

문제는 비즈니스 로직과 사용자 인터페이스가 변경되는 빈도가 다르다.

  • 사용자 인터페이스는 시스템 내에서 가장 자주 변경되는 부분이다
  • 비즈니스 로직은 사용자 인터페이스에 비해 변경이 적게 발생한다.

따라서 사용자 인터페이스 변경이 비즈니스 로직에 영향을 받아서 변경에 불안정한 아키텍처를 낳는다.

"관심사 분리"라는 아키텍처 설계의 목적을 달성하기 어렵다.

 

성급하게 결정된 실행 순서

 

하향식 기능 분해는 하나의 함수를 더 작은 함수로 분해하고, 분해된 함수들의 실행 순서를 결정하는 작업이다.

설계를 시작하는 시점부터 시스탬이 무엇(what)을 해야하는지가 아니라 어떻게(how) 동작하는지 집중하게 만든다.

 

직원의 급여를 계산하려면 어떤 작업 필요? → 소득세율 입력받는 작업, 급여 계산하는 작업, 계산결과를 출력하는 작업

 

첫번째 질문은 What이 아니라 How이다.

 

하향식 접근법은 처음부터 구현을 염두하고 설계하기 때문에, 자엽스럽게 함수들의 실행 순서를 정의하는 시간 제약을 강조한다.

실행 순서나 조건, 반복과 같은 제어 구조를 미리 결정하지 않고는 분해를 진행할 수 없기 때문에 기능 분해 방식은 "중앙 집중 제어 스타일"이다. 문제는 설계 결정사항인 함수의 제어구조가 빈번하게 변경된다는 것이다. 

 

해결 방법 : 시간적 제약이 아니라 논리적 제약을 설계 기준으로 삼는 것이다.

 

하향식 접근법을 통해 분해된 함수들은 재사용하기도 어렵다. 모든 함수는 상위 함수를 분해하는 과정에서 필요에 따라 식별되며, 그에 따라 상위 함수가 강요하는 문맥안에서만 의미가 있기 때문이다.

 

함수가 재사용 가능하려면 상위 함수보다 더 일반적이어야 한다. 하지만 하향식 접근법의 분해된 하위 함수는 상위 함수에 종속적이다. 재사용성과 반대되는 개념이다. 즉, 다시 말해서 결합도가 강하다는 것이 문제다.

 

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

 

하향식 접근법의 가장 큰 문제점은 어떤 데이터를 어떤 함수가 사용하고 있는지 추적하기 어렵다.

따라서, 데이터 변경으로 인해 어떤 함수가 영향을 받을지 예상하기 어렵다.

 

  • 개별함수가 아니라 전체적으로 봤을때, 어떤 데이터가 어떤 함수에 의존하고 있는지를 파악하려면 모든 함수를 열어 데이터를 사용하고 있는지 모두 확인해야 한다.
  • 코드안에서 텍스트를 검색하는 단순한게 아니라, 의존성과 결합도의 문제다.
  • 데이터의 변경은 데이터를 직접 참조하는 모든 함수로 퍼져나간다 → 스파게티 코드일 경우 문제를 찾기가 더 어렵다

 

급여 관리 시스템에서 정규 직원의 급여뿐만 아니라 "아르바이트 직원에 대한 급여"도 개발해야한다고 가정하자.

 

$employess = ["직원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]

 

기존의 $employees와 $basePays에 아르바이트생도 포함하면, 해당 인덱스에 위치한 정보가 정규 직원인지 아르바이트인지 여부를 확인하는 $hourlys 전역변수도 필요하게 된다. 또한 한달 간의 업무 누적 시간을 위해 $timeCards 전역 변수도 필요하다.

 

이제 $employees와 $basePays를 사용하는 함수 중에 아르바이트 직원을 함께 처리해야 하는 함수를 찾아 수정해야한다.

 

calculatePay함수에 조건 분기를 추가함으로써 정규 직원과 아르바이트 직원에 대한 급여를 다른 방식으로 계산할 수 있다.

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

 

정규 직원인지 아르바이트 직원인지 판단하는 hourly?함수도 추가한다.

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

 

기존 calculatePay함수에 추가된 아르바이트 직원일 경우의 로직을 분기처리로 넣는다.

def calculatePay(name)
    taxRate = getTaxRate()
    if(hourly?(name)) then
    	pay = calculatePayFor(name, taxRate)
    else
    	pay = calculateHourlyPayFor(name, taxRate)
    end
    puts(describeResult(name,pay))
end

 

그러면 모든 코드가 수정이 되었을까?? No

 

모든 직원의 기본급 총합을 더하는 sumOfBasePays함수도 함께 수정해야 한다.

현재까지 $basePays에는 정규 직원의 기본급 뿐만 아니라 아르바이트 직원의 시급도 저장돼 있기 때문에 시급을 총합에서 제외해야 한다.

def sumOfBasePays()
    result = 0
    for name in $employees
      if (not hourly?(name)) then
        result += $basePays[$employees.index(name)]
      end
     end
    puts(result)
end

 

이 예제를 통해 데이터 변경으로 인해 발생하는 함수에 대한 영향도를 파악하는게 어렵다는 것을 알 수 있다.

지금은 간단한 예제이지만, 실제로 어마어마한 수의 함수로 구성된 거대한 시스템일 경우 심하게 얽힌 실타래를 풀긴 어렵다.

 

해결 방법 : 데이터와 함께 변경되는 부분과 그렇지 않은 부분을 명확히 분리해야 한다. 즉, 잘 정의된 퍼블릭 인터페이스를 통해 데이터에 대한 접근을 통제해야 한다.

 

이와 관련해서 정보 은닉모듈이라는 개념이 있다.

 

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

 

작은 프로그램과 개별 알고리즘을 위해서는 유용한 패러다임이다. 특히, 프로그래밍 과정에서 이미 해결된 알고리즘을 문서화하고 서술하는데는 훌륭한 기법이다.

 

문제점 정리

  • 하나의 함수에 제어가 집중되기 때문에 확장이 어렵다
  • 하향식 분해는 프로젝트 초기에 설계의 본질적은 측면을 무시하고 사용자 인터페이스 같은 비본질적인 측면에 집중하게 만든다
  • 과도하게 함숭 집중하게 함으로써 소프트웨어의 중요한 다른 측면인 데이터에 대한 영향도를 파악하기 어렵게 만든다
  • 근본적으로 재사용하기 어렵다

3. 모듈

정보 은닉과 모듈

 

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

 

🚩 정보은닉이란?

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

- 데이터 캡슐화와 동일한 개념이 아니다.
- 변경과 관련된 비밀을 감춘다는 측면에서 정보은닉과 캡슐화는 동일 개념이지만, 데이터 캡슐화는 비밀의 한 종류인 데이터를 감추는 캡슐화의 한 종류일 뿐이다.

 

🚩 모듈이란?

책임의 할당이다. 모듈화는 개별적인 모듈에 대한 작업이 시작되기 전에 정해져야 하는 설계 결정들을 포함한다.
변경될 가능성이 있는 비밀을 내부로 감추고, 잘 정의되고 쉽게 변경되지 않을 퍼블릭 인터페이스를 외부에 제공해서 내부의 비밀에 함부로 접근하지 못하게 한다.

 

 

모듈이 감춰야할 2가지 비밀

  • 복잡성 : 모듈이 너무 복잡한 경우 이해하고 사용하기 어렵다. 외부에 모듈을 추상화할 수 있는 간단한 인터페이스를 제공해서 모듈의 복잡도를 낮춘다
  • 변경 가능성 : 변경 가능한 설계 결정이 외부에 노출될 경우 실제로 변경이 발생했을 때 파급효과가 커진다. 변경 발생 하나의 모듈만 수정하면 되도록 변경 가능한 설계 결정을 모듈 내부로 감추고, 외부에는 쉽게 변경되지 않을 인터페이스를 제공한다
module Employees
  $employees = ["직원A", "직원B", "직원C", "직원D", "직원E", "직원F"]
  $basePays = [400.0, 300.0, 250.0, 1.0, 1.0, 1.5]
  $hourly = [false, false, false, true, true, true]
  $timeCards = [0, 0, 0, 120, 120, 120]

  def Employees.calculate_pay(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 = $employees.index(name)
    basePay = $basePays[index] * $timeCards[index]
    return basePay - (basePay * taxRate)
  end

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

  def Employees.sumOfBasePays
    result = 0
    for name int $employees
    	if (not Employees.hourly?(name)) then
      		result += $basePays[$employees.index(name)]
        end
    end
    return result
  end

 

지금까지 전역 변수였던 $employees, $basePays, $hourlys, $timeCard가 Employees라는 모듈 내부에 숨겨져 있다.

이제 모듈 외부에서는 직원 정보를 관리하는 데이터에 직접 접근할 수 없다.

Employees모듈 의 퍼블릭 인터페이스인 calculatePay, houry?, calculateHourlyPayFor, calculatePayFor, sumOfBasePays 함수를 통해서만 내부 변수를 조작할 수 있다.

 

def main(operation, args={})
   case(operation)
    when : pay then calculatePay(args[:name])
    whne : basePays then sumOfBasePays()
    end
end

def calculatePay(name)
	taxPrate = getTaxRate()
   	pay = Employees.calculatePay(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

 

모듈의 장점과 한계

 

  • 모듈 내부의 변수가 변경되더라도 모듈 내부에만 영향을 미친다
    • 모듈을 사용하면 모듈 내부에 정의된 변수를 직접 참조하는 코드의 위치를 모듈 내부로 제한할 수 있다
    • 특정 데이터가 변경됐을 때 영향받는 함수를 찾기위해 해당 데이터를 정의한 모듈만 검색하면 된다
    • 모듈은 데이터 변경으로 인한 파급효과를 제어할 수 있기 때문에코드를 수정하고 디버깅하기가 더 용이하다
  • 비즈니스 로직과 사용자 인터페이스에 대한 관심사를 분리한다
    • 사용자 입력과 화면 출력을 Employees모듈이 아닌 외부에 뒀다는 점을 주목하라
    • 수정된 코드에서 Employees모듈은 비즈니스 로직과 관련된 관심사만을 담당하며 사용자 인터페이스와 관련된 관심사는 모두 Employees모듈을 사용하는 main함수쪽에 위치한다
    • 이제 GUI같은 다른 형식을 사용자 인터페이스에 추가하더라도 Employees 모듈에 포함된 비즈니스 로직은 변경되지 않는다
  • 전역 변수와 전역 함수를 제거함으로써 네임스페이스 오염을 방지한다
    • 모듈의 한가지 용도는 네임스페이스를 제공하는것이다
    • 변수와 함수를 모듈 내부에 포함시키기에 다른 모듈에서도 동일한 이름을 사용할 수 있게 된다
    • 전역 네임스페이스의 오염을 방지하는 동시에 이름 충돌 위험을 완화한다.

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

 

모듈이 정보 은닉이라는 개념을 통해 데이터라는 존재를 설계 중심의 요소로 부각시켰다. 모듈에 있어서 핵심은 데이터다.

 

메인 함수를 정의하고 필요에 따라 더 세부적인 함수로 분해하는 하향식 기능 분해와 달리 모듈은 감춰야 할 데이터를 결정하고 이 데이터를 조작하는데 필요한 함수를 결정한다→ 기능이 아니라 데이터를 중심으로 시스템을 분해한다

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

 

모듈이 프로시저 추상화보다 높은 추상화 개념을 제공하지만, 태생적으로 변경을 관리하기 위한 구현 기법이기 때문에 추상화 관점에서 한계점이 명확하다.

 

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

 

이를 만족시키기 위해 등장한 개념이 추상 데이터 타입이다.

 

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

추상 데이터 타입

 

 

🚩 타입이란?

변수에 저장할 수 있는 내용물의 종류와 변수에 적용될 수 있는 연산의 가짓수를 의미한다.

 

프로그래밍 언어는 다양한 형태의 내장 타입(built-in type)을 제공한다.

기능 분해의 시대에 사용되던 절차형 언어들은 적은 수의 내장 타입만 제공했으며, 새로운 타입을 추가하는 것이 불가능하고 제한적이었다.

프로시저만으로는 풍부한 추상화를 제공할 수 없었고, 점차 추상 데이터 타입으로 변화했다.

 

"직원의 급여를 계산한다" → "직원"과"급여"라는 추상적인 개념을 이용해 "계산"에 필요한 절차를 생각한다

 

추상데이터 타입을 구현하기 위한 프로그래밍 언어의 조건

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

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

 

과거의 많은 프로그래머들은 추상 데이터 타입을 구현할 수 있는 언어적인 장치를 제공하지 않은 프로그래밍언어에서도 모듈을 기반으로 추상 데이터 타입을 구현해왔다. Ruby에서 Struct라는 구성요소를 제공해서 추상 데이터 타입을 흉내낸다.

 

 

5. 클래스

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

 

클래스와 추상 데이터 타입 모두 데이터 추상화를 기반으로 시스템을 분해하기 때문에 이런 설명이 꼭 틀린 것만은 아니다.

모두 외부에서는 객체의 내부 속성에 직접 접근할 수 없으며 오직 퍼블릭 인터페이슬르 통해서만 외부와 의사소통 할 수 있다.

 

그러나 핵심적인 차이가 있다. 클래스는 상속과 다형성을 지원하는데 비해 추상 데이터 타입은 지원하지 못한다.

 

  • 객체지향 프로그래밍 : 상속과 다형성 지원O
    • 절차를 추상화한 것
  • 객체기반 프로그래밍(추상 데이터 타입) : 상속과 다형성 지원X
    • 타입을 추상화한 것

 

추상데이터 타입으로 구현된 Employee타입의 calculatePay()와 monthlyBasePay()오퍼레이션을 살펴보자.

Employee타입은 물리적으로는 하나의 개념이지만,

개념적으로는 정규 직원과 아르바이트 직원이라는 2개의 개별적인 개념을 포괄하는 복합개념이다.

 

Employee Type
오퍼레이션 정규직원 아르바이트 직원
calculatePay() basePay - (basePay * taxRate) (basePay * timeCard) -
(basePay * timeCard) * taxRate
monthlyBasePay() basePay 0

 

 

Employee타입은 구체적인 직원 타입을 외부에 캡슐화하고 있다.

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

 

타입 추상화는 개별 오퍼레이션이 모든 개념적인 타입에 대한 구현을 포괄하도록 함으로써 하나의 물리적인 타입 안에 전체 타입을 감춘다.

따라서, 오퍼레이션을 기준으로 타입을 통합하는 데이터 추상화 기법이다.

 

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

 

객체 지향은 타입을 기준으로 오퍼레이션을 묶는다.

즉, 정규 직원과 아르바이트 직원 각각에 대한 클래스(타입)를 정의하고, 각 클래스들이 관련된 오퍼레이션을 적절하게 구현한다.

2가지 클래스로 분리할 경우, 공통 로직은 부모 클래스를 정의해서 두 직원 유형의 클래스가 상속받게 하는 것이다.

이제 클라이언트는 부모 클래스의 참조자에 대해 메시지를 전송하면 실제 클래스가 무엇인가에 따라 적절한 절차가 실행된다.

즉, 동일한 메세지에 대해 서로 다르게 반응한다. 이것이 바로 다형성이다.

 

클라이언트 관점에서 두 클래스의 인스턴스는 동일하게 보인다는 점을 주목해라.

객체지향은 절차 추상화(procedural abstraction)다.

Employee Type
오퍼레이션 정규직원 아르바이트 직원
calculatePay() basePay - (basePay * taxRate) (basePay * timeCard) -
(basePay * timeCard) * taxRate
monthlyBasePay() basePay 0

 

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

 

앞에서 2 개의 직원 타입 모두를 완전하게 구현한 추상 데이터 타입인 Employee와 다르게 

클래스로 구현하는 Employee클래스는 정규 직원과 아르바이트 직원 타입이 공통적으로 가져야 하는 속성과 메서드 시그니처만 정의하고 있는 불완전한 구현체다.

 

모든 직원 타입에 대해 Employee의 인스턴스를 생성해야 했던 추상 데이터 타입과 달리 클래스를 이용해 구현한 코드는

클라이언트가 원하는 직원 타입에 해당하는 클래스의 인스턴스를 명시적으로 지정할 수 있다.

  • 정규 직원 : SalariedEmployee
  • 아르바이트 직원 : HourlyEmployee

하지만 일단 객체를 생성하고 나면 객체의 클래스가 무엇인지 중요하지 않다.

클라이언트 입장에서는 SalariedEmployee와 HourlyEmployee 의 인스턴스를 모두 부모 클래스인 Employee의 인스턴스 인것 처럼 다룰 수 있다. 클라이언트는 메세지를 수신할 객체의 구체적인 클래스에 대해 고민할 필요가 없다.

그저 수신자가 이해할 것으로 예상되는 메세지를 전송하기만 하면 된다.

 

변경을 기준으로 선택하라

 

단순히 클래스를 구현 단위로 사용한다는게 객체지향 프로그래밍을 한다는 것을 의미하지는 않는다.

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

 

클래스가 추상 데이터 타입의 개념을 따르고 있는지 확인하는 가장 간단한 방법은 클래스 내부에 인스턴스 타입을 표현하는 변수가 있는지 살펴보는 것이다. 추상 데이터 타입으로 구현된 Employee클래스에서 hourly라는 인스턴스 변수에 직원 유형을 저장하는데, 이처럼 인스턴스 변수에 저장된 값을 기반으로 메서드 내에서 타입을 명시적으로 구분하는 방식은 객체지향을 위반한 것이다.

 

객체지향에서는 타입을 체크하는 조건문을 다형성으로 대체한다. 

  • 클라이언트가 객체의 타입을 확인 후 적절한 메서드 호출 X
  • 객체가 메시지를 처리할 적절한 메서들르 선택 O

객체지향이란 조건문을 제거한다는 오해가 여기서 나왔다.

개방-폐쇄 원칙(OCP)이란?

기존 코드에 아무런 영향도 미치지 않고 새로운 객체 유형과 행위를 추가할 수 있는 객체지향의 특성

 

추상데이터 타입은 항상 최악이고, 객체지향이 항상 최고일까?

 

설계의 유용성은 변경의 방향성과 발생 빈도에 따라 결정된다.

  • 타입 추가에 대한 변경 압력이 심할 경우
    • 객체지향
    • 추상 데이터 타입은 새로운 타입을 추가하려면 클라이언트 코드를 일일이 찾아 수정해야한다
    • 객체지향은 클라이언트 코드를 수정할 필요없이 새로운 클래스를 상속 계층에 추가하기만 하면 된다
  • 오퍼레이션 추가에 대한 변경 압력이 심할 경우
    • 추상 데이터 타입
    • 객체지향의 경우 새로운 오퍼레이션을 추가하기 위해 상속 계층에 속하는 모든 클래스를 한번에 수정해야 한다
    • Employee 클래스에 새로운 추상 오퍼레이션을 추가하려면 자식 클래스에서도 함께 오버라이딩 해야한다.
    • 추상 데이터 타입은 전체 타입에 대한 구현 코드가 하나의 구현체 내에 포함돼 있어서 상대적으로 간단하다

 

데이터 주도 설계란?

추상 데이터 타입의 접근법을 객체 지향 설계에 구현한 것을 데이터 주도 설계라 한다.

책임 주도 설계는 데이터 주도 설계 방법을 개선하고자 하는 노력의 산물이다.

모듈과 추상 데이터 타입 객체지향
데이터 중심 관점 서비스 중심 관점

 

협력이 중요하다

 

위에 있는 그림 7.6처럼 단순하게 오퍼레이션과 타입을 표에 적어놓고 클래스 계층에 오퍼레이션 구현 방법을 분배한다고 해서 객체지향 애플리케이션인건 아니다.

 

역할 , 책임, 협력이 중요하고, 객체들이 협력하는 방식에 집중해야 한다.

협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션의 구현 방식을 타입별로 분배하는 건 올바르지 않다.

 

그림 7.6은 객체에게 로직을 분배하는 방법에서 추상 데이터 타입 vs 클래스의 차이일 뿐이지, 객체를 설계하는 방법을 설명한게 아니다.

객체를 설계하는 방법은 3장에서 나온 것처럼 책임 주도 설계의 흐름 따른다는 것을 기억하라