BookReview [오브젝트: 객체 분해]
7장 객체 분해
사람은 기억은 단기 기억(STM)과 장기 기억(LTM)으로 분류할 수 있다. 이에 관한 더 자세한 내용은 프로그래머의 뇌라는 책에서 집중적으로 다루고 있다.
이 책은 전부 STM과 LTM에 관한 이야기를 다루기에 읽어보면 7장의 초반에서 하고자 하는 말이 뭔지 이해할 수 있다.
사람들이 실제로 문제를 해결하기 위해 사용하는 저장소는 장기 기억이 아닌 단기 기억이라는 점이다. 문제를 해결하기 위해서는 필요한 정보들을 먼저 단기 기억 안으로 불러들여야 한다. 그러나 문제 해결에 필요한 요소의 수가 단기 기억의 용량을 초과하는 순간 문제 해결 능력은 급격하게 떨어진다. 이를 인지 과부화(cognitive overload) 라고 한다.
인지 부조화를 방지하는 가장 좋은 방법은 단기 기억안에 보관할 정보의 양을 조절하는 것으로 한 번에 다뤄야 할 정보만 남기고 불필요한 세부 사항은 걸러내어 문제를 단순화하는 것이다. 핵심만 남기는 작업을 추상화라고 한다.
객체지향에서 다루는 추상화와 동일한 개념이고 실제로 사람은 살아가면서 인지 과부화를 피하기 위해 많은 추상화를 한다. 지나가는 사람들을 하나하나 다 특징적으로 말하지 않고 사람
이라는 추상적인 개념으로 인식한다.
가장 일반적인 추상화 방법은 한 번에 다뤄야 하는 문제의 크기를 줄이는 것으로 쉽게 Task를 이해하기 쉬운 단위로 나누는 것과 같다. 모든 문제는 더 작은 문제들로 나눌 수 있기에 이처럼 작은 문제로 나누는 작업을 분해(decomposition) 라고 한다.
분해의 목적은 큰 문제를 인지 과부화의 부담 없이 단기 기억 안에서 한 번에 처리할 수 있는 규모의 문제로 나누는 것이다. 여기서 정보의 개별 단위로서의 항목을 의미하는 것이 아니라 하나의 단위로 취급될 수 있는 논리적인 청크(chunk) 로 나누는 것이다. 청크는 더 작은 청크를 포함할 수 있으며 연속적으로 분해가 가능하다. (Boxing한다.)
한 번에 단기 기억에 담을 수 있는 추상화의 수에는 한계가 있지만 추상화를 더 큰 규모의 추상화로 압축시킴으로써 단기 기억의 한계를 초월할 수 있다. 따럿 추상화와 분해는 인간이 세계를 인식하고 분해하기 위해 사용하는 기본적인 사고 도구라고 할 수 있다.
이러한 개념들은 소프트웨어 개발에서 아주 밀접하게 연관되어 있으며, 복잡한 문제를 해결하기 위한 사고가 그대로 코드에 녹아들어 있다.
프로시저 추상화와 데이터 추상화
프로그래밍 역사에서도 어셈블리라는 이해하기 어려운 저수준 언어에서 점점 고수준 언어에 가까워지는 이 흐름 자체도 추상화를 이용해 복잡성을 극복하려는 노력의 결과라고 할 수 있다.
프로그래밍 언어를 통해 표현되는 추상화의 발전은 다양한 프로그래밍 패러다임의 탄생으로 이어졌다. 프로그래밍 언어는 대부분 프로그래밍 패러다임에 종속적이다. 프로그래밍 패러다임은 프로그래밍을 구성하기 위해 사용되는 추상화의 종류와 이 추상화를 이용해 소프트웨어를 분해하는 방법의 두 가지 요소로 결정된다. 따라서 모든 프로그래밍 패러다임은 추상화와 분해의 관점에서 설명할 수 있다.
현대적인 프로그래밍 언어를 특징 짓는 중요한 두 가지 추상화 메커니즘은 프로시저 추상화(procedure abstraction) 와 데이터 추상화(data abstraction) 이다. 프로시저 추상화는 소프트웨어가 무엇을 해야 하는지를 추상화한다. 데이터 추상화는 소프트웨어가 무엇을 알아야 하는지를 추상화한다. 소프트웨어는 데이터를 이용해 정보를 표현하고 프로시저를 이용해 데이터를 조작한다.
결국 프로그래밍 패러다임이란 적절한 추상화의 윤곽을 따라 시스템을 어떤 식으로 나눌 것인지를 결정하는 원칙과 방법의 집합이다. 따라서 현대의 설계 방법에 중요한 영향을 끼치는 프로그래밍 패러다임들은 프로시저 추상화나 데이터 추상화를 중심으로 시스템 분해 방법을 설명한다.
시스템을 분해하는 방법을 결정하려면 먼저 프로시저 추상화를 중심으로 할 것인지, 데이터 추상화를 중심으로 할 것인지를 결정해야 한다. 프로시저 추상화를 중심으로 시스템을 분해하기로 결정했다면 기능 분해(functional decomposition) 를 사용하게 된다. 알고리즘 분해(Algorithmic decomposition)라고도 한다.
데이터 추상화를 중심으로 시스템을 분해하기로 결정했다면 다시 두 가지 중 하나를 선택해야 한다. 하나는 데이터를 중심으로 타입을 추상화(type abstraction) 하는 것이고 다른 하나는 데이터를 중심으로 프로시저를 추상화(procedure abstraction) 하는 것이다. 전자를 추상 데이터 타입(Abstract Data Type, ADT) 이라고 하고 후자를 객체지향(Object-Oriented) 이라고 한다.
지금까지 객체지향 패러다임을 역할과 책임을 수행하는 자율적인 객체들의 협력 공동체를 구축하는 것으로 설명했다. 여기서 ‘역할과 책임을 수행하는 객체’가 바로 객체지향 패러다임이 이용하는 추상화다.
프로그래밍 언어의 관점에서 객체지향을 바라보면 기능을 구현하기 위해 필요한 객체를 식별하고 협력 가능하도록 시스템을 분해한 후에는 프로그래밍 언어라는 수단을 이용해 실행 가능한 프로그램을 구현해야 한다. 프로그래밍 언어의 관점에서 객체지향이란 데이터를 중심으로 데이터 추상화와 프로시저 추상화를 통합한 객체를 이용해 시스템을 분해하는 방법이다.
그리고 이런 객체를 구현하기 위해 대부분의 객체지향 언어는 클래스라는 도구를 제공한다. 따라서 프로그래밍 언어적인 관점에서 객체지향을 바라보는 일반인인 관점은 데이터 추상화와 프로시저 추상화를 함께 포함한 클래스를 이용해서 시스템을 분해하는 것이다.
프로시저 추상화와 기능 분해
메인 함수로서의 시스템
개발 역사에서 초창기에는 데이터와 기능에선 기능을 중심으로 시스템을 분해하는 방법이 주류였다. 이 같은 시스템 분해 방식을 알고리즘 분해 또는 기능 분해라고 부른다. 기능 분해의 관점에서 추상화의 단위는 프로시저이며 시스템은 프로시저를 단위로 분해된다.
프로시저는 반복적으로 실행되거나 거의 유사하게 실행되는 작업들을 하나의 장소에 모아둠으로 로직을 재사용하거나 중복을 방지할 수 있는 추상화 기법이다. 프로시저를 추상화라고 부르는 이유는 내부의 상세한 구현 내용을 모르더라도 인터페이스만 알면 프로시저를 사용할 수 있기 때문이다. 하지만 이런 캡슐화가 깨지는 이유로 효과적인 정보은닉 체계는 구축하기 어렵다.
전통적인 기능 분해 방법은 하향식 접근법(Top-Down Approach)을 따른다. 하향식 접근법이 시스템을 구성하는 가장 최상위(topmost) 기능을 정의하고, 이 최상위 기능을 좀 더 작은 단계의 하위 기능으로 분해해 나가는 방법을 말한다. 분해는 세분화된 마지막 하위 기능이 프로그래밍 언어로 구현 가능한 수준이 될 때까지 계속된다.
각 세분화 돤계는 위 단계보다 더 구체적이어야 하고, 바로 상위보단 덜 추상적이어야 한다. 상위 기능은 하나 이상의 더 간단하고 더 구체적이며 덜 추상적인 하위 기능의 집합으로 분해된다.
어떻게 보면 하양식, 폭포수, 등의 모든 계층적 구조에서는 이런 고질적인 문제들이 나온다. 상속의 문제점을 포함해서도 그러하다. 인간 관계도 그렇다고 볼 수 있다..
급여 관리 시스템
간단한 급여 관리 시스템을 통해 하향식 접근법의 문제점을 알아본다.
급여 = 기본급 - (기본급 * 소득세율)
기능 분해 방법을 이용하여 전통적으로 하향식 접근법을 따르며 최상위의 추상적인 함수 정의는 시스템의 기능을 표현하는 하나의 문장으로 나타내고, 이 문장을 구성하는 좀 더 세부적인 문장으로 분해해 나가는 방식을 따른다. 기능 분해의 초점은 하나의 문장으로 표현된 기능을 여러 개의 더 작은 기능으로 분해하는 것이다. (모든 분해가 그렇듯이 최소단위에 대한 리미트는 필요해보인다.)
직원의 급여를 계산한다.
기능 분해 방법에 따라 실제로 급여를 계산하는 데 필요한 좀 더 세분화된 절차로 구체화해야 한다.
직원의 급여를 계산한다.
사용자로부터 소득세율을 입력받는다.
직원의 급여를 계산한다.
양식에 맞게 결과를 출력한다.
각 정제 단계는 이전 문장의 추상화 수준을 감소시켜야 한다. 즉, 모든 문장이 정제 과정을 거치면서 하나 이상의 좀 더 단순하고 구체적인 문장들의 조합으로 분해돼야 한다.
직원의 급여를 계산한다
사용자로부터 소득세율을 입력받는다
"세율을 입력하세요: "라는 문장을 화면에 출력한다.
키보드를 통해 세율을 입력받는다
직원의 급여를 계산한다
전역 변수에 저장된 직원의 기본급 정보를 얻는다.
급여를 계산한다
양식에 맞게 결과를 출력한다
"이름: {직원명}, 급여: {계산된 급여}" 형식에 따라 출력 문자열을 생성한다
기능 분해의 결과는 최상위 기능을 수행하는 데 필요한 절차들을 실행되는 시간 순서에 따라 나열한 것이다. 기본적으로 기능 분해는 책의 목차를 정리하고 그 안에 내용을 채워 넣는 것과 유사하다.
대부분 이런식의 개발을 처음에 많이 시도하는 것 같다.
기능 분해 방법에서는 기능을 중심으로 필요한 데이터를 결정한다. 기능 분해라는 무대의 주연은 기능이며 데이터는 기능을 보조하는 조연의 역할에 머무른다. 기능 분해를 위한 접근법은 먼저 필요한 기능을 생각하고 이 기능을 분해하고 정제하는 과정에서 필요한 데이터의 종류와 저장 방식을 식별한다.
이것은 유지보수에 다양한 문제를 야기한다. 하향식 기능 분해 방식이 가지는 문제점을 이해하는 것은 유지보수 관점에서 객체지향의 장점을 이해할 수 있는 좋은 출발점이다.
소프트웨어 개발과 관련된 대부분의 기법이 그런 것처럼 기능 분해가 동작하는 방법과 그에 수반되는 문제점을 이해할 수 있는 가장 효과적인 방법은 실제 애플리케이션 코드를 보는 것이다.
급여 관리 시스템 구현
구현은 의사코드와 비슷한 형태인 루비로 작성한다.
def main(name)
end
하향식 접근이자 하나의 시작점인 main에서 출발하며 이는 직원의 급여를 계산한다
와 같다.
def main(name)
taxRate = getTaxRate()
pay = calculatePayFor(name, taxRate)
puts(describeResult(name, pay))
end
이제 하위 기능들을 구현한다.
def getTaxRate()
print("세율을 입력하세요: ")
return gets().chomp().to_f()
end
이후에 급여를 계산하기 위해서는 애플리케이션 내부에 직원 목록과 기본급에 대한 정보를 유지하고 있어야 한다. 직원의 목록은 $employees
라는 전역 변수에, 직원별 기본급은 $basePays
라는 전역 변수에 저장한다. 두 전역변수는 배열로 구현하며 동일한 직원에 대한 이름과 기본급 정보는 동일한 인덱스에 저장한다.
$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
따라서 직원 C에 대한 급여를 계산하려면 다음과 같이 호출하면 된다.
main("직원C")
이런 하향식 기능 분해는 시스템의 최상위의 가장 추상적인 메인 함수로 정의하고, 메인 함수를 구현 가능한 수준까지 세부적인 단계로 분해하는 방법이다. 하향식 기능 분해 방식으로 설계한 시스템은 메인 함수를 루트로 하는 트리
로 표현할 수 있다.
나도 하향식 기능 분해의 개발 방식을 선호하는 듯 하다. 유니티의 특성 상 Mono가 하나당 하나의 자율성을 가지는 객체이자 모듈이 되기에 기능을 구분하자면 해당 모듈에선 하향식 기능 분해 즉, 트리형태로 많이 개발을 진행했다.
하향식 기능 분해의 문제점
하향식 기능 분해 방법은 겉으로는 이상적인 방법으로 보일 수 있지만 실제로 설계에 적용해보면 다양한 문제에 직면하게 된다.
이런 문제점에 대해서는 직접 해보고 몸으로 겪어봤기 때문에.. 더욱 공감이 간다.
- 시스템은 하나의 메인 함수로 구성돼 있지 않다.
- 기능 추가나 요구사항 변경으로 인해 메인 함수를 빈번하게 수정해야 한다.
- 비즈니스 로직이 사용자 인터페이스와 강하게 결합된다.
- 하향식 분해는 너무 이른 시기에 함수들의 실행 순서를 고정시키기 때문에 유연성과 재사용성이 저하된다.
- 데이터 형식이 변경될 경우 파급효과를 예측할 수 없다.
실제 코드 배치 방법이며 설계가 필요한 이유는 변경에 대비하기 위한 것이라는 점을 기억하자. 변경은 성공적인 소프트웨어가 맞이해야 하는 피할 수 없는 운명이다. 100%로 발생한다. 현재의 요구사항이 변하지 않고 코드를 변경할 필요가 없다면 소프트웨어를 어떻게 설계하건 아무도 신경 쓰지 않을 것이다. 하지만 불행하게도 소프트웨어는 항상 변경된다.
실제로 도메인이 약 100년동안 고정된 모델의 경우 객체지향으로 개발할 필요가 없다. 그냥 절차적인 모델이 더 좋을 것.
하나의 메인 함수라는 비현실적인 아이디어
- “실제 시스템에 정상이란 존재하지 않는다”
- 버트란드 마이어
어떤 시스템도 최초에 릴리스됐던 당시의 모습을 그대로 유지하지는 않는다. 시간이 지나고 사용자를 만족시키기 위한 새로운 요구적사항을 도출해나가면서 지속으로 새로운 기능을 추가하게 된다. 이것은 시스템이 오직 하나의 메인함수만으로 구현된다는 개념과 완전히 모순된다.
대부분의 경우 추가되는 기능은 최초에 배포된 메인 함수의 일부가 아닐 것이다. 결국 처음에는 중요하게 생각했던 메인 함수는 동등하게 중요한 여러 함수들 중 하나로 전락하고 만다. 어느 시점에 이르면 유일한 메인 함수라는 개념은 의미가 없어지고 시스템은 여러 개의 동등한 수준의 함수 집합으로 성장하게 된다.
대부분의 시스템에서 하나의 메인 기능이란 개념은 존재하지 않는다. 모든 기능들은 규모라는 측면에서 차이가 있을 수는 있지만 가능성의 측면에서는 동등하게 독립적이고 완결된 하나의 기능을 표현한다.
메인 함수의 빈번한 재설계
시스템 안에는 여러 개의 정상이 존재하기 때문에 결과적으로 하나의 메인 함수를 유일한 정상으로 간주하는 하향식 기능 분해의 경우에는 새로운 기능을 추가할 때마다 매번 메인 함수를 수정해야 한다. 기존 로직과는 아무런 상관이 없는 새로운 함수의 적절한 위치를 확보해야 하기 때문에 메인 함수의 구조를 급격하게 변경할 수밖에 없는 것이다.
항상 기존 코드르 수정하는 것은 새로운 버그를 만들어낼 확률이 높다는 것을 의미한다.
비즈니스 로직과 사용자 인터페이스의 결합
하향식 접근법은 비즈니스 로직을 설계하는 초기 단계부터 입력 방법과 출력 양식을 함께 고민하도록 강요한다. 급여를 계산하는 기능의 경우 “사용자로부터 소득세율을 입력받아 급여를 계산한 후 계산된 결과를 화면에 출력한다.”라는 말에는 급여를 계산하는 중요한 비즈니스 로직과 관련된 관심사와 소득세율을 입력받아 결과를 화면에 출력한다는 사용자 인터페이스의 관심사가 한 곳에 섞여 있다는 것을 의미한다.
결과적으로 코드 안에서 비즈니스 로직과 사용자 인터페이스 로직이 밀접하게 결합된다. 문제는 비즈니스 로직과 사용자 인터페이스가 변경되는 빈도가 다르다는 것이다. 사용자 인터페이스는 가장 변경이 빈번하게 발생하는 것에 비해 비즈니스 로직은 상대적으로 변경이 적게 발생한다. 따라서 이런 로직이 섞임으로 서로에게 영향을 주게 되고 불안정한 아키텍처를 낳게 된다.
하향식 접근법에 기능을 분해하는 과정에서 사용자 인터페이스의 관심사와 비즈니스 로직의 관심사를 동시에 고려하도록 강요하기 때문에 “관심사의 분리”라는 아키텍처 설계의 목적을 달성하기 어렵다.
게임에서도 마찬가지로 메인 로직과 UI를 분리하라는 개발 원칙이 있다.
성급하게 결정된 실행 순서
하향식으로 기능을 분해하는 과정은 하나의 함수를 더 작은 함수로 분해하고, 분해된 함수들의 실행 순서를 결정하는 작업으로 요약할 수 있다. 이것은 설계를 시작하는 시점부터 시스템이 무엇을 해야 하는지가 아니라 어떻게 동작해야 하는지에 집중하도록 만든다.
직원의 급여를 계산하려면 소득세율을 입력받는 작업과 급여를 계산하는 작업, 계산된 결과를 화면에 출력하는 작업이 필요하다. 하향식 접근법의 첫 번째 질문은 무엇이 아니라 어떻게다.
하향식 접근법의 설계는 처음부터 구현을 염두에 두기 때문에 자연스럽게 함수들의 실행 순서를 정의하는 시간 제약(temporal constraint)을 강조한다. 메인 함수가 작은 함수들로 분해되기 위해서는 우선 함수 함수들의 순서를 결정해야 한다.
실행 순서나 조건, 반복과 같은 제어 구조를 미리 결정하지 않고는 분해를 진행할 수 없기 때문에 기능 분해 방식은 중앙집중 제어 스타일(centralized control style)의 형태를 띌 수밖에 없다. 결과적으로 모든 중요한 제어 흐름의 결정이 상위 함수에서 이뤄지고 하위 함수는 상위 함수의 흐름에 따라 적절한 시점에 호출된다.
문제는 중요한 설계 결정사항인 함수의 제어 구조가 빈번한 변경의 대상이라는 점이다. 기능이 추가되거나 변경될 때마다 초기에 결정된 함수들의 제어 구조가 올바르지 않다는 것이 판면된다. 결과적으로 기능을 추가하거나 변경하는 작업은 매번 기존에 결정된 함수의 제어구조를 변경하도록 만든다.
하향식 접근법을 통해 분해한 함수들은 재사용하기도 어렵다. 모든 함수는 상위 함수를 분해하는 과정에서 필요에 따라 식별되며, 그에 따라 상위 함수가 강요하는 문맥안에서만 의미를 가지기 때문이다. 재사용이라는 개념은 일반성이라는 의미를 포함한다는 점을 기억하자. 함수가 재사용 가능하려면 상위 함수보다 더 일반적이어야 한다. 하지만 하향식 접근법을 따를 경우 분해된 하위 함수는 항상 상위 함수보다 문맥에 더 종속적이다. 이것은 정확하게 제사용성과 반대되는 개념이다.
하향식 설계와 관련된 모든 문제의 원인은 결합도다. 함수는 상위 함수가 강요하는 문맥에 강하게 결합된다. 함수는 함께 절차를 구성하는 다른 함수들과 시간적으로 강하게 결합돼 있다. 강한 결합도는 시스템을 변경에 취약하게 만들고 이해하기 어렵게 만든다.
데이터 변경으로 인한 파급효과
하향식 기능 분해의 가장 큰 문제점은 어떤 데이터를 어떤 함수가 사용하고 있는지를 추적하기 어렵다. 따라서 데이터 변경으로 어떤 함수가 영향을 받을지 예상하기 어렵다. 물론 개별 함수의 입장에서 사용하는 데이터를 파악하는 것은 어렵지 않다. 함수의 본체를 열어 참조하고 있는 모든 지역 변수, 인자, 전역 변수를 살펴보면 된다.
그러나 반대로 어떤 데이터가 어떤 함수에 의존하고 있는지를 파악하는 것은 어려운 일인데 모든 함수를 열어 데이터를 사용하고 있는지를 모두 확인해봐야 하기 때문이다.
이것은 코드 안에서 텍스트를 검색하는 단순한 문제가 아니다. 이것은 의존성과 결합도의 문제다. 그리고 테스트의 문제이기도 하다. 데이터의 변경으로 인한 영향은 데이터를 직접 참조하는 모든 함수로 퍼져나간다. 스파게티처럼 얽히고설킨 대규모 시스템에서 데이터를 참조하는 함수들을 찾아 정상적으로 작동하는지 여부를 테스트하는 것은 기술보다는 운의 문제다.
예제와 같이 데이터가 몇가지 추가되거나 수정되는 순간 전역적인 모든 데이터를 사용하는 함수의 동작 방식을 이해해야 한다. 만약 어마어마하게 거대한 시스템이라면 이것은 불가능에 가깝다.
데이터 변경으로 인한 영향을 최소화하려면 데이터와 함게 변경되는 부분과 그렇지 않은 부분을 명확하게 분리해야 한다. 이를 위해 데이터와 함께 변경되는 부분을 하나의 구현 단위로 묶고 외부에서는 제공되는 함수만 이용해 데이터에 접근해야 한다. 즉, 잘 정의된 퍼플릭 인터페이스를 통해 데이터에 대한 접근을 통제해야 한다는 것이다.
이것이 바로 의존성 관리의 핵심이다. 변경에 대한 영향을 최소화하기 위해 영향을 받는 부분과 받지 않는 부분을 명확하게 분리하고 잘 정의된 퍼블릭 인터페이스를 통해 변경되는 부분에 대한 접근을 통제하라.
언제 하향식 분해가 유용한가?
물론 하향식 분해가 유용한 경우도 있다. 하향식 아이디어가 매력적인 이유는 설계가 어느 정도 안정화된 후에는 설계의 다양한 측면을 논리적으로 설명하고 문서화하기에 용이하기 때문이다. 그러나 설계를 문서화하는 데 적절한 방법이 좋은 구조를 설계할 수 있는 방법과 동일한 것은 아니다.
실제로 하향식 분해는 WBS라고 불리는 문서화 방식이 있을 만큼 인간이 이해하는 컨텍스트를 줄여주는 좋은 방법이기도 하다.
하향식 분해는 작은 프로그램과 개별 알고리즘을 위해서는 유용한 패러다임으로 남아 있다. 그러나 실제로 동작하는 커다란 소프트웨어를 설계하는 데 적합한 방법은 아니다.
정리하자면 하향식 분해 방식으로 설계된 소프트웨어는 하나의 함수에 제어가 집중되기 때문에 확장이 어렵다. 하향식 분해는 프로젝트 초기에 설계의 본질적인 측면을 무시하고 사용자 인터페이스 같은 비본질적인 측면에 집중하게 만든다. 과도하게 함수에 집중하게 함으로써 소프트웨어의 중요한 다른 측면인 데이터에 대한 영향도를 파악하기 어렵게 만든다. 또한 하향식 분해를 적용한 설계는 근본적으로 재사용하기 어렵다.
유니티라는 툴에서 하향식 분해를 바라봤을 때, 유니티 오브젝트는 하이어라키상 계층 구조를 가지게 된다. 따라서 대부분의 코드도 이 스타일을 따라가게 되면서 하향식 분해의 방식을 가져가는 것 같다. 하지만 유니티는 Mono를 통해 객체 마다의 라이프서클을 가지기에 Main이 하나라고 볼 수 없다.
모듈
정보 은닉과 모듈
앞에서 설명한 것처럼 시스템의 변경을 관리하는 기본적인 전략은 함께 변경되는 부분을 하나의 구현 단위로 묶고 퍼블릭 인터페이스를 통해서만 접근하도록 만드는 것이다. 즉, 기능을 기반으로 시스템을 분해하는 것이 아니라 변경의 방향에 맞춰 시스템을 분해하는 것이다.
이 부분이 정말 중요하다고 생각한다. 지금까지 메시지와 책임에 집중한 내용이 실제 코드에선 기능을 기반으로 책임을 생각하는 것이 아닌 해당 책임이 부여된 순간 해당 책임 자체가 변경의 방향성을 의미한다, 즉 내부적으로 사용하는 데이터 기능들은 전부 캡슐화되어서 은닉성을 가지지만, 변경의 방향이 열려 있다는 것은 해당 객체의 책임을 말하는 것과 같다. 송신자로 부터 받은 메시지와 같다.
데이비드 파나스는 1972년에 소프트웨어 개발의 가장 중요한 원리인 동시에 가장 많은 오해를 받고 있는 정보 은닉(information hiding)의 개념을 소개했다. 정보 은닉은 시스템을 모듈 단위로 분해하기 위한 기본 원리로 시스템에서 자주 변경되는 부분을 상대적으로 덜 변경되는 안정적인 인터페이스 뒤로 감춰야 한다는 것이 핵심이다. 데이비드 파나스는 시스템을 모듈로 분할하는 원칙은 외부에 유출돼서는 안 되는 비밀의 윤곽을 따라야 한다고 주장한다.
정보 은닉은 외부에 감춰야 하는 비밀에 따라 시스템을 분할하는 모듈 분할 원리다. 모듈은 변경될 가능성이 있는 비밀을 내부로 감추고, 잘 정의되고 쉽게 변경되지 않을 퍼블릭 인터페이스를 외부에 제공해서 내부의 비밀을 함부로 접근하지 못하게 한다.
모듈과 기능 분해는 상호 배타적인 관계가 아니다. 시스템을 모듈로 분해한 후에는 각 모듈 내부를 구현하기 위해 기능 분해를 적용할 수 있다. 기능 분해가 하나의 기능을 구현하기 위해 필요한 기능들을 순차적으로 찾아가는 탐색의 과정이라면 모듈 분해는 감춰야 하는 비밀을 선택하고 비밀 주변에 안정적인 보호막을 설치하는 보존의 과정이다. 비밀을 결정하고 모듈을 분해한 후에는 기능 분해를 이용해 모듈에 필요한 퍼블릭 인터페이스를 구현할 수 있다.
이 부분이 내가 말하고자 하는 바를 잘 설명한다고 생각한다.
모듈은 오직 퍼블릭 인터페이스를 통해서만 동작해야 하며, 시스템이 감춰야 하는 비밀을 감춰야 한다.
- 복잡성
- 모듈이 너무 복잡한 경우 이해하고 사용하기가 어렵다. 외부에 모듈을 추상화할 수 있는 간단한 인터페이스를 제공해서 모델의 복잡도를 낮춘다.
- 변경 가능성
- 변경 가능한 설계 결정이 외부에 노출될 경우 실제로 변경이 발생했을 때 파급효과가 커진다. 변경 발생 시 하나의 모듈만 수정하면 되도록 변경 가능한 설계 결정을 모듈 내부로 감추고 외부에는 쉽게 변경되지 않을 인터페이스를 제공한다.
앞의 급여 관리 시스템의 예에서 알 수 있는 것처럼 시스템의 가장 일반적인 비밀은 데이터다. 이 관점이 데이터 캡슐화와 정보 은닉을 혼동스럽게 만든 것으로 보인다. 비밀이 반드시 데이터일 필요는 없으며 복잡한 로직이나 변경 가능성이 큰 자료구조일 수도 있다. 그럼에도 변경 시 시스템을 굴복시키는 대부분의 경우는 데이터가 변경되는 경우다.
C#에서는 네임스페이스를 이용해 구현할 수 있다.
모듈의 장점과 한계
모듈 내부의 변수가 변경되더라도 모듈 내부에만 영향을 미친다
모듈을 사용하면 모듈 내부에 정의된 변수를 직접 참조하는 코드의 위치를 모듈 내부로 제한할 수 있다. 이제 어떤 데이터가 변경됐을 때 영향을 받는 함수를 찾기 위해 해당 데이터를 정의한 모듈만 검색하면 된다. 더 이상 전체 함수를 일일이 분석할 필요가 없다. 모듈은 데이터 변경으로 인한 파급효과를 제어할 수 있기 때문에 코드를 수정하고 디버깅하기 더 용이하다.
비즈니스 로직과 사용자 인터페이스에 대한 관심사를 분리한다
예를 들어 사용자 입력과 출력을 외부의 모듈로 분리하여 관심사를 분리한다.
전역 변수와 전역 함수를 제거함으로써 네임스페이스 오염(namespace pollution)을 방지한다
모듈의 한 가지 용도는 네임스페이스를 제공해는 것이다. 변수와 함수를 모듈에 포함시키기 때문에 다른 모듈에서도 동일한 이름을 사용할 수 있게 된다. 따라서 모듈은 전역 네임스페이스의 오염을 방지하는 동시에 이름 충돌(name collision)을 방지한다.
모듈은 기능이 아니라 변경의 정도에 따라 시스템을 분해하게 한다. 각 모듈은 외부에 감춰야 하는 비밀과 관련성이 높은 데이터와 함수의 집합이다. 따라서 모듈 내부는 높은 응집도를 유지한다. 모듈과 모듈 사이에는 퍼블릭 인터페이스를 통신해야 한다. 따라서 낮은 결합도를 유지한다.
여기서 눈여겨봐야 할 부분은 모듈이 정보 은닉이라는 개념을 통해 데이터라는 존재를 설계의 중심 요소로 부각시켰다는 것이다. 모듈은 있어서 핵심은 데이터다. 메인 함수를 정의하고 필요에 따라 더 세부적인 함수로 분해하는 하향식 기능 분해와 달리 모듈은 감춰야 할 데이터를 결정하고 이 데이터를 조작하는 데 필요한 함수를 결정한다.
다시 말해서 기능이 아니라 데이터를 중심으로 시스템을 분해하는 것이다. 모듈은 데이터와 함수가 통합된 한 차원 높은 추상화를 제공하는 설계 단위다. 비록 모듈이 프로시저 추상화 관점에서의 한계점이 명확하다. 모듈의 가장 큰 단점은 인스턴스의 개념을 제공하지 않는다는 점이다.
다시 말해서 다수의 직원 인스턴스가 존재하는 추상화 메커니즘이 필요한 것이다. 그리고 이를 만족시키기 위해 등장한 개념이 바로 추상 데이터 타입이다.
데이터 추상화와 추상 데이터 타입
추상 데이터 타입
프로그래밍 언어에서 타입(Type)이란 변수에 저장할 수 있는 내용물의 종류와 변수에 적용될 수 있는 연산의 가짓수를 의미한다. 정수 타입의 변수를 타입의 변수를 선언하는 것은 프로그램 내에서 변수명을 참조할 때 해당 변수를 임의의 정숫값으로 간주하라고 말하는 것과 같다.
안타깝게도 프로시저만으로는 충분히 풍부한 추상화의 어휘집을 제공할 수 없다. 이것은 언어 설계에서 가장 중요한 추상 데이터 타입의 개념으로 이어졌고 추상 데이터 타입은 추상 객체의 클래스를 정의한 것으로 추상 객체에 사용할 수 있는 오퍼레이션을 이용해 규정된다. 이것은 오퍼레이션을 이용해 추상 데이터 타입을 정의할 수 없음을 의미한다. 추상 데이터 객체를 사용할 때 프로그래머는 오직 객체가 외부에 제공하는 행위에만 관심을 가지며 행위가 구현되는 세부적인 사항에 대해서는 무시한다. 객체가 저장소 내에서 어떻게 표현되는지와 같은 구현 정보는 오직 오퍼레이션을 어떻게 구현할 것인지에 집중할때만 필요하다. 객체의 사용자는 이 정보를 알거나 제공받을 필요가 없다.
지금까지 설명했던 데이터 추상화, 정보 은닉, 데이터 캡슐화, 인터페이스-구현 분리의 개념들이 모두 다 녹아들어 있다. 추상 데이터 타입은 프로시저 추상화 대신 데이터 추상화를 기반으로 소프트웨어를 개발하게 한 최초의 발걸음이다. 추상 데이터 타입을 구현하려면 다음과 같은 특성을 위한 프로그래밍 언어의 지원이 필요하다.
- 타입 정의를 선언할 수 있어야 한다.
- 타입의 인스턴스를 다루기 위해 사용할 수 있는 오퍼레이션의 집합을 정의할 수 있어야 한다.
- 제공된 오퍼레이션을 통해서만 조작할 수 있도록 데이터를 외부로부터 보호할 수 있어야 한다.
- 타입에 대해 여러 개의 인스턴스를 생성할 수 있어야 한다.
현대의 대부분 객체지향 언어가 지원하는 기능들이다.
추상 데이터 타입 정의를 기반으로 객체를 생성하는 것은 가능하지만 여전히 데이터와 기능을 분리해서 바라본다는 점에 주의하라. 추상 데이터 타입은 말 그대로 시스템의 상태를 저장할 데이터를 표현한다. 추상 데이터 타입으로 표현된 데이터를 이용해서 기능을 구현하는 핵심 로직은 추상 데이터 타입 외부에 존재한다.
클래스
클래스는 추상 데이터 타입인가?
대부분의 프로그래밍 서적은 클래스를 추상 데이터 타입으로 설명한다. 클래스와 추상 데이터 타입 모두 데이터 추상화를 기반으로 시스템을 분해하기 때문에 이런 설명이 꼭 틀린 것만은 아니다. 두 메커니즘 모두 외부에서는 객체의 내부 속성에 접근할 수 없으며 오직 퍼블릭 인터페이스를 통해서만 외부와 의사소통할 수 있다.
그러나 명확한 의미에서 추상 데이터 타입과 클래스는 동일하지 않다. 가장 핵심적인 차이는 클래스는 상속과 다형성을 지원하는 데 비해 추상 데이터 타입은 지원하지 못한다는 점이다. 상속과 다형성을 지원하는 객체지향 프로그래밍(Object-Oriented Programming) 과 구분하기 위해 상속과 다형성을 지원하지 않는 추상 데이터 타입 기반의 프로그래밍 패러다임을 객체기반 프로그래밍(Object-Based Programming)이라고 부른다.
추상 데이터 타입은 타입을 추상화한 것이고 클래스는 절차를 추상화한 것이라고 한다.
하나의 대표적인 타입이 다수의 세부적인 타입을 감추기 때문에 이를 타입 추상화라고 부르며 타입 추상화는 개별 오퍼레이션이 모든 개념적인 타입에 대한 구현을 포괄하도록 함으로써 하나의 물리적인 타입 안에 전체 타입을 감춘다. 따라서 타입 추상화는 오퍼레이션을 기준으로 타입을 통합하는 데이터 추상화 기법이다.
추상 데이터 타입이 오퍼레이션을 기준으로 타입을 묶는 방법이라면 객체지향은 타입을 기준으로 오퍼레이션을 묶는다. 즉, 정규 직원과 아르바이트 직원이라는 두 개의 타입을 명시적으로 정의하고 두 직원 유형과 관련된 오퍼레이션의 실행 절차를 두 타입에 분배한다. (다형성)
클라이언트 관점에선 두 클래스의 인스턴스는 동일하게 보인다는 것이며 실제로 내부에서 실행되는 절차는 다르지만 클래스를 이용한 다형성은 절차에 대한 차이점을 감춘다. 다시 말해 객체지향은 절차 추상화(procedural abstraction)이다.
추상 데이터 타입에서 클래스로 변경하기
클래스를 이용하는 객체지향 버전에서는 각 직원의 타입 안에 두 가지 직원 타입을 캡슐화했다. 클래스를 이용하는 객체지향 버전에서는 각 직원의 타입을 독립적인 클래스로 구현함으로써 두 개의 타입이 존재한다는 사실을 명시적으로 표현한다.
변경을 기준으로 선택하라
단순히 클래스를 구현 단위로 사용한다는 것이 객체지향 프로그래밍을 한다는 것을 의미하지는 않는다. 타입을 기준으로 절차를 추상화하지 않았다면 그것은 객체지향 분해가 아니다. 비록 클래스를 사용하고 있더라도 말이다.
클래스가 추상 데이터 타입의 개념을 따르는지를 확인할 수 있는 가장 간단한 방법은 클래스 내부에 인스턴스의 타입을 표현하는 변수가 있는지를 살펴보는 것이다. 인스턴스 변수에 저장된 값을 기반으로 메서드 내에서 타입을 명시적으로 구분하는 방식은 객체지향을 위반하는 것으로 간주된다.
합성/구성으로 흔히 오해가 가능하지만 그런 것이 아닌 쉽게 내부에서 Enum값으로 논리를 통해 분기를 구분하는 등은 객체지향을 위반한다고 할 수 있다. 따라서 팩토리 패턴과 팩토리 메서드 패턴이 다르게 불리는 이유라고 생각된다.
객체지향에서는 타입 변수를 이용한 조건문을 다형성으로 대체한다. 리팩터링 조건부 로직을 다형성으로 바꾸기를 참고하자. 클라이언트가 객체의 타입을 확인한 후 적절한 메서드를 호출하는 것이 아니라 객체가 메시지를 처리할 적절한 메서드를 선택한다.
모든 설계 문제가 그런 것처럼 조건문을 사용하는 방식을 기피하는 이유 역시 변경 때문이다.
정리하자면 조건문을 통해 내부 값을 계산하는 등의 로직은 문제가 없지만, 조건문을 통해 다른 객체의 호출이 들어간다면 이는 다형성이 필요한 암시이다.
이처럼 기존 코드에 아무런 영향을 미치지 않고 새로운 객체 유형과 행위를 추가할 수 있는 객체지향의 특성을 개방-폐쇄 원칙(Open-Closed Principle)이라고 한다. 이것이 객체지향 설계가 전통적인 방식에 비해 변경하고 확장하기 쉬운 구조를 설계할 수 있는 이유다.
대부분의 객체지향 서적에서는 추상 데이터 타입을 기반으로 애플리케이션을 설계하는 방식을 잘못된것으로 설명한다. 그렇다면 항상 절차를 추상화하는 객체지향 설계 방식을 따라야 하는가? 추상 데이터 타입은 모든 경우에 최악의 선택일까?
기본적으로 설계는 변경과 관련된 것이다. 설계의 유용성은 변경의 방향성과 발생 빈도에 따라 결정된다. 그리고 추상 데이터 타입과 객체지향 설계의 유용성은 설계에 요구되는 변경의 압력이 ‘타입 추가’에 관한 것인지 아니면 ‘오퍼레이션 추가’에 관한 것인지에 따라 달라진다.
타입 추가라는 변경의 압력이 더 강한 경우에는 객체지향의 손을 들어줘야 한다. 추상 데이터 타입의 경우 새로운 타입을 추가하라면 타입을 체크하는 클라이언트 코드를 일일이 찾아 수정한 후 올바르게 작동하는지 테스트해야 한다. 반면 객체지향의 경우에는 클라이언트 코드를 수정할 필요가 없다. 간단하게 새로운 클래스를 상속 계층에 추가하기만 하면 된다.
이에 반해 변경의 주된 압력이 오퍼레이션을 추가하는 것이라면 추상 데이터 타입의 승리를 선언해야 한다. 객체지향의 경우 새로운 오퍼레이션을 추가하기 위해서는 상속 계층에 속하는 모든 클래스를 한번에 수정해야 한다. 이와 달리 추상 데이터 타입의 경우에는 전체 타입에 대한 구현 코드가 하나의 구현체 내에 포함돼 있기 때문에 새로운 오퍼레이션을 추가하는 작업이 상대적으로 간단하다. (상대적으로 이기에 결국 변경의 디커플링에선 완전히 벗어나지 못한다.)
즉, 새로운 타입을 빈번하게 추가해야 한다면 객체지향의 클래스 구조가 더 유용하다. 새로운 오퍼레이션을 빈번하게 추가해야 한다면 추상 데이터 타입을 선택하는 것이 현명한 판단이다. 변경의 축을 찾아라. 객체지향적인 접근법이 모든 경우에 올바른 해결 방법인 것은 아니다.
내가 정말정말로 하고 싶었던 말을 속 시원하게 해결된 것 같아서 너무 좋다. 이런 경험들은 직접 몸으로 체감을 해야하기도 하며, 코드를 짜며 궁금증을 항상 가져야 한다.
협력이 중요하다
결국 또 돌아와서 협력이 중요하다. 단순하게 오퍼레이션과 타입을 표에 적어놓고 클래스 계층에 오퍼레이션의 구현 방법을 분배한다고 해서 객체지향적인 애플리케이션을 설계하는 것은 아니다. 객체지향에서 중요한 것은 역할, 책임, 협력이다.
객체지향은 기능을 수행하기 위해 객체들이 협력하는 방식에 집중한다. 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션의 구현 방식을 타입별로 분배하는 것은 올바른 접근법이 아니다.
객체가 참여할 협력을 결정하고 협력에 필요한 책임을 수행하기 위해 어떤 객체가 필요한지에 관해 고민하라. 그 책임을 다양한 방식으로 수행해야 할 때만 타입 계층 안에 각 절차를 추상화하라. 타입 계층과 다형성은 협력이라는 문맥 안에서 책임을 수행하는 방법에 관해 고민한 결과물이어야 하며 그 자체가 목적이 되어서는 안 된다.
느낀점
7장은 간단하게 정리하는 파트라고 생각했는데, 장 이름이 객체 분해인 것 처럼 실제 우리가 코드를 짤 때 객체라고 생각하고 내부에 정의한 타입들이 과연 추상 데이터 타입인지, 객체지향적인지와 설계 과정에서 명심해야 하는 점들을 잘 풀어냈다고 생각한다.
분명 읽는 사람의 이해도에 따라 조금씩은 다르게 읽을 것 같은 장이라 생각되며 모듈과 하향식 분해와 실제 프로그래밍 역사에서 추상 데이터 타입까지의 과정을 알 수 있어서 좋았다.
논의사항
새로운 타입을 빈번하게 추가해야 한다면 객체지향의 클래스 구조가 더 유용하다. 새로운 오퍼레이션을 빈번하게 추가해야 한다면 추상 데이터 타입을 선택하는 것이 현명한 판단이다
- 각자 게임 개발에서 경험한 데이터 타입을 선택하는 경우에 현명했던 사례를 공유해보고 이야기하면 좋을 것 같습니다.
- 새로운 타입이 다형성 즉, 하나의 역할로 통일이 된다면 객체지향의 클래스 구조가 더 유용하겠지만, 게임 개발에서 같은 타입이라는 대분류를 벗어나는 특이한 사례가 많다고 생각됩니다. 예를 들어 뱀서류 게임을 제작한다고 했을 때, 무기라는 타입에 대해서 처음에 생각한 다형성의 역할에서 벗어나는 기획이 생기고 타입보단 오퍼레이션이 빈번하게 수정된다고 했을 때 이를 분기문으로 처리하는 것과 이후 유지보수를 위해 더 높은 차원의 타입으로 감싸는 방식 등을 생각해보면 좋을 것 같습니다.
- 아니면 해당 타입의 내부에 스트레이지 패턴을 적용하여 오퍼레이션을 추가하는 방식도 생각해볼 수 있을 것 같습니다.
- 새로운 타입이 다형성 즉, 하나의 역할로 통일이 된다면 객체지향의 클래스 구조가 더 유용하겠지만, 게임 개발에서 같은 타입이라는 대분류를 벗어나는 특이한 사례가 많다고 생각됩니다. 예를 들어 뱀서류 게임을 제작한다고 했을 때, 무기라는 타입에 대해서 처음에 생각한 다형성의 역할에서 벗어나는 기획이 생기고 타입보단 오퍼레이션이 빈번하게 수정된다고 했을 때 이를 분기문으로 처리하는 것과 이후 유지보수를 위해 더 높은 차원의 타입으로 감싸는 방식 등을 생각해보면 좋을 것 같습니다.
댓글남기기