18 May 2019

도메인 주도 설계 10 - 유연한 설계

복잡하게 동작하는 소프트웨어에 좋은 설계가 결여되어 있다면, 요소들을 리팩터링하거나 파악하기가 어려워진다. 개발자들이 소프트웨어의 처리 방식에 내포된 의미를 확신하지 못하면 곧바로 중복이 나타나기 시작한다. 소프트웨어가 깔끔하게 설계돼 있지 않다면 개발자들은 엉망진창으로 꼬여버린 코드를 보는 것조차 두려워하며, 뭔가를 망가트릴지도 모르는 변경을 덜 하려고 할 것이다.

개발이 진행될수록 레거시 코드로 인한 중압감에 시달리지 않고 프로젝트 진행을 촉진하려면 즐겁게 작업할 수 있는 유연한 설계가 필요하다.

이해하기가 어렵지 않은 요소를 만들어내려면 Model-Driven Design을 적당한 수준의 설계 형식과 접목하고자 노력해야 한다. 요구사항에 잘 대응하려면 설계가 사용하는 모델과 동일한 저변의 모델을 드러내어 쉽게 이해할 수 있어야 한다.


Intention-Revealing Interface (의도를 드러내는 인터페이스)

도메인 주도 설계를 적용할 때는 의미있고 가치있는 도메인 로직에 관해 생각해볼 필요가 있다. 명확하게 표현된 규칙 없이 암묵적인 규칙에 따라 실행되는 코드를 이해하려면 코드를 구성하는 각 단계를 이해하고 기억해야 한다. 계산이 명확하지 않거나 모델과의 연관관계가 분명하지 않을 경우에도 결과를 이해하기 어렵거나 변경의 파급 효과를 예상하기 어렵다.

객체가 아름다운 이유는 이 모든 것들을 캡슐화하고 상위 수준의 관점에서 코드를 이해할 수 있기 때문이다.

개발자가 객체를 효과적으로 사용하기 위해 알아야 할 정보를 인터페이스로부터 얻지 못한다면 세부적인 내용을 이해하고자 객체 내부를 살펴볼 수 밖에 없다. 캡슐화를 통해서 얻을 수 있는 이점을 잃어버리고 인지 과부화와 싸워야 한다. 개발자가 컴포넌트를 사용하기 위해 컴포넌트의 세부 사항을 고려해야 한다면 캡슐화의 가치는 사라진다. 원래의 개발자가 아닌 다른 사람이 구현 세부 사항을 토대로 연산이나 클래스의 목적을 이레 짐작하여 개발한다면, 설계의 개념적 기반은 무너지고 개발자들은 서로 의도가 어긋난 상태에서 일하게 된다.

도메인 내의 개념을 클래스나 메서드의 형태로 명확히 모델링하여 가치를 얻으려면 도메인 개념을 반영하도록 클래스와 메서드의 이름을 지어야 한다. 클래스와 메서드의 이름은 개발자 간의 의사소통을 개선하고 추상화를 향상시킬 아주 좋은 기회다. 설계에 포함된 모든 공개 요소가 조화를 이뤄 인터페이스를 구성하고, 인터페이스의 구성 요소인 타입 이름, 메서드 이름, 인자 이름 등, 각 요소들은 이름을 토대로 설계 의도를 드러내어야 한다.

00.png

수행하는 방법에 관해서는 언급하지 말고, 결과와 목적만을 표현하도록 클래스와 연산의 이름을 정해야 한다. 그러면 이 컴포넌트를 사용하는 개발자는 컴포넌트 내부를 이해해야 할 필요성이 줄어들어 인지 과부하를 줄일 수 있다. 이름은 Ubiquitous Language에 포함된 용어를 따라야 한다.

클라이언트 개발자의 관점에서 생각할 수 있도록, 클래스와 연산을 구현하기에 앞서 테스트를 먼저 작성하는 것이 좋다.

방법이 아닌 의도를 나타내는 추상적인 인터페이스 뒤로 까다로운 매커니즘, 요구사항 변경에 따른 파급 효과를 캡슐화하여 정보은닉을 달성해야 한다.

프로그래밍의 중심축은 원활한 커뮤니케이션이다. 소프트웨어에 있어 가장 큰 비용이 발생하는 부분은 유지보수 단계이다. 유지보수는 보통 소프트웨어 비용의 40~80%(평균 60%)를 차지한다. 따라서 타이핑 시간을 줄이기 위해 변수 명을 짧게 쓰거나 자신만이 알아 볼 수 있는 메소드 명을 사용하는 것을 자제해야 한다. 코드를 작성하는 것은 한 번이지만 코드가 읽혀지는 것은 수백 번에 이른다. 읽기 편한 코드는 명확한 코드다. 명확한 코드는 의도를 드러내는 코드다. 의도를 드러내는 코드는 코드를 이해하는데 필요한 모든 세부사항이 인터페이스에 드러나는 코드다. 만약 코드를 읽는 사람이 인터페이스에서 필요한 모든 정보를 얻을 수 없다면 코드의 내부 구석구석을 뒤져봐야 한다. 방법이 아닌 의도를 표현하는 추상적인 인터페이스 뒤로 모든 까다로운 메커니즘을 캡슐화시켜야 한다. 객체 지향의 미덕은 캡슐화를 통해 복잡한 세부 내용을 감출 수 있다는 점이다. 도메인 내에 존재하는 개념을 클래스나 메소드의 형태로 명확하게 모델링함으로써 가치를 얻기 위해서는 해당 도메인 개념을 반영하도록 클래스와 메소드의 이름을 지어야 한다. 이 때, 수행 방법에 관해서는 언급하지 말고 결과와 목적만을 표현하도록 클래스와 오퍼레이션의 이름을 지어야 한다. 인터페이스를 구성하는 각 요소의 이름은 설계 의도를 드러낼 수 있는 가장 훌륭한 수단을 제공한다. 이 기회를 허투로 흘려버리지 마라. INTENTION-REVEALING INTERFACE를 구축하기 위해서는 타입 이름, 메소드 이름, 인자 이름 모두에 명확한 의도를 나타내는 이름을 지어야 한다. INTENTION-REVEALING INTERFACE의 목적을 달성하는 가장 훌륭한 방법은 코드를 작성하기 전에 클라이언트 관점에서 테스트 케이스를 작성하는 것이다.


Side-Effect-Free Function (부수효과가 없는 함수)

연산은 크게 명령질의라는 두 가지 범주로 나눌 수 있다.

  • 명령(Command): 변수의 값을 변경하는 등의 작업을 통해 상태를 변경하는 연산
  • 질의(Query): 변수 안에 저장된 데이터에 접근하거나, 저장된 데이터를 기반으로 계산을 수행하여 정보를 얻는 연산

부수효과(Side Effect)는 의도하지 않은 결과를 의미하지만, 컴퓨터 과학에서는 시스템의 상태에 대한 영향력을 의미한다. 시스템의 상태를 변경하는, 의도된 변경을 표현하기 위해 의도하지 않은 변경을 의미하는 부수효과를 용어로 채택한 점으로 보아 시스템의 상태를 변경하는 것이 개발자에게 있어서 부정적인 영향을 끼치는 경우가 많아서 그럴 것이다.

부수효과는 프로그램의 버그를 발생시키는 온상이다. 따라서 부수효과를 없애면 디버깅이 용이해지고, 프로그램의 수행 결과를 예측 가능한 상태로 유지할 수 있다.

대부분의 연산은 다른 연산을 호출하는데, 호출 깊이가 깊어지고 무질서하게 중첩되면 연산 결과를 예측하기가 어려워진다. 개발자가 의도하지 않았거나 인지를 하지 않은 상태에서, 2번째나 3번째 호출 깊이의 메서드 연산에 의해 시스템의 상태가 변경되었을 수 있다. 이렇게 발생한 효과는 시스템에 대한 부수효과에 해당한다. 이러면 개발자 입장에서는 이 연산을 호출한 결과나 파급 효과를 예측하기가 어렵다.

다수의 규칙에 따라 상호작용하거나 여러 계산을 조합하면 극도로 예측하기가 어려워지는데, 이를 위해 개발자는 연산 그 자체 뿐만 아니라 연산이 호출하는 다른 연산의 구현도 이해해야 한다. 개발자가 베일에 가려진 구현과 관련된 세부사항도 함께 이해해야 한다면, Intention-Revealing Interface (의도를 드러내는 인터페이스) 처럼 인터페이스 추상화로 얻을 수 있는 유용성이 제한된다.

부수 효과의 존재 유무를 판단하기 위해서는 추상적인 개념 뿐만 아니라, 구현 세부 사항까지 인지하고 있어야 한다. 이는 프로그래머가 짊어져야 할 개념적 무게를 증가시킨다.

안전하게 예측할 수 있는 추상화가 마련되어 있지 않다면 개발자가 연산을 조합하여 사용하는 데 제약이 따르고, 행위를 풍부하게 할 수 있는 가능성이 낮아진다.

부수효과를 일으키지 않으면서 결과를 반환하는 연산을 함수(Function)이라고 하는데, 함수는 여러 번 호출해도 무방하며 매번 동일한 값을 반환한다. 또한 부수효과를 지는 연산에 비해 테스트가 용이하며, 결과는 오직 함수로 넘긴 인자에 의해서만 의존하기 때문에 예측도 쉽게 가능하다.

대부분의 소프트웨어 시스템에서 부수효과를 일으키는 명령을 사용하지 않기란 불가능하지만, 다음의 두 가지 방법으로 문제를 완화시킬 수 있다.

첫째, 명령과 질의를 엄격하게 분리된 서로 다른 연산으로 유지한다. 부수효과, 변경을 발생시키는 메서드는 도메인 데이터를 반환하지 않고 단순하게 유지시키고, 모든 질의나 계산을 부수효과를 발생시키지 않는 메서드 내에서 수행하도록 해야 한다.

둘째, 연산의 결과를 표현하는 새로운 Value Object를 생성하여 반환한다. Value Object는 불변 객체로, 함수와 마찬가지로 안전하게 사용할 수 있고 테스트도 쉽게 할 수 있다. 생명 주기를 신중하게 통제해야하는 Entity와는 달리 Value Object는 질의에 대한 응답으로 생성하고 반환 후 잊어버리면 된다.

상태 변경을 수반하는 로직과 계산이 혼합된 연산은 리팩터링을 거쳐 두 개의 연산으로 분리해야 한다. 부수효과를 단순한 명령 메서드 내부로 격리하는 작업은 Entity에 대해서만 적용한다. 변경과 질의를 분리하는 리팩터링 후, 복잡한 계산을 처리하는 책임을 Value Object로 옮기도록 한다. 이렇게 하면 Value Object를 이끌어내는 것만으로도 부수효과를 제거할 수 있게 된다.

가능한 한 프로그램 로직을 관찰 가능한, 부수효과 없이 결과를 반환하는 함수 안에 작성하라. 명령을 도메인 정보를 반환하지 않는 단순한 연산으로 엄격히 분리하라. 책임에 적합한 어떠한 개념이 나타나면, 복잡한 로직을 Value Object로 옮겨 부수효과를 통제하라.

부수효과를 완전히 피할 수는 없다. 대부분의 프로그램은 반환 값을 얻기 위해서가 아니라 어떤 동작을 하기 위해 실행하기 때문이다. 하지만 프로그램 내부에서는 엄격하게 통제하고자 한다. 우리는 가능한 모든 곳에서 부수효과를 제거하고, 또 제거할 수 없는 경우에는 철저하게 통제할 것이다.


Assertion (단언)

복잡한 계산을 부수효과가 없는 함수(Function)로 분리하면 해결해야 할 문제의 난이도롤 낮출 수 있지만, 여전히 Entity 내부에 부수효과를 초래하는 명령(Command)이 남아 있으므로, 개발자는 이 명령의 영향력을 이해해야 한다. Assertion을 사용한다면 Entity의 해당 명령에 대한 부수효과가 명확해지고 다루기 쉬워진다.

복잡한 계산이 포함되지 않은 명령이라면 코드를 훑어보는 것만으로도 연산의 결과를 이해할 수 있지만, 작은 부분들을 조합해서 큰 부분을 구축하는 설계에서는 명령이 다른 명령을 호출하는 경우가 많다. 상위 수준의 명령을 사용해서 개발하는 개발자는 각기 호출되는 명령의 결과를 이해해야 한다. 또 객체의 인터페이스는 부수효과를 제한하지 않으므로, 동일한 인터페이스를 구현하는 서로 다른 두 개의 하위 클래스가 서로 다른 부수효과를 일으킬 수도 있다.

연산의 부수효과가 구현에 의해서 함축적으로 정의될 때, 다수의 위임을 통해 구현하는 것은 개발자에게 혼란스러움을 느껴지게 할 수 있다. 프로그램을 이해하려면 다수의 분기 경로를 따라 실행을 추적할 수 밖에 없다. 이렇게 되면 캡슐화의 가치가 사라지고, 구체적인 실행 경로를 추적해야되기 때문에 추상화가 무의미해진다.

내부를 조사하지 않고도 설계 요소의 의미와 연산의 실행 결과를 이해할 수 있는 방법이 필요한데, Intention-Revealing Interface를 통해서 부분적인 효과를 얻을 수는 있겠지만, 코드에 포함된 의도를 비형식적인 방법으로 암시하는 것만으로는 한계가 있다. 이는 연산의 사후 조건과 클래스 및 Aggregate의 불변식을 명시하는 것으로 해결할 수 있다. 만약 Assertion을 따로 명시할 수 없다면, 자동화된 단위 테스트를 통해 Assertion의 내용을 표현해야 한다.

많은 객체지향 언어가 직접적으로 Assertion을 지원하고 있지 않지만, Assertion은 좋은 설계를 증진시키는 강력한 사고방식이다. 자동화된 단위 테스트를 통해 언어 차원에서의 지원 부족을 보완할 수 있다. 사전조건을 테스트의 적절한 곳에 두고 테스트를 실행 후, 사후조건을 만족하는지 검사한다.


Conceptual Contour (개념적 윤곽)

모델 또는 설계를 구성하는 요소가 모놀리식 구조에 묻혀 있을 경우 각 요소의 기능이 중복될 수 있다. 그렇다고 클래스나 메서드를 잘게 나누면 클라이언트 쪽 객체가 무의미하게 복잡해질 수 있다. 클라이언트 객체가 작은 부분들의 협력 방식을 이해해야 하기 때문이다.

개념적으로 의미있는(개념이 현재 모델과 코드에 포함된 관계를 기준으로 할때 적절한가, 또는 현재 기반을 이루는 도메인과 유사한 윤곽을 드러내는가) 기능의 단위를 찾게 되면, 그 결과로 만들어진 설계는 유연하고 이해하기 쉬워진다. 어떤 메서드의 연산이 도메인에서 의미를 가진다면 그 수준에서 메서드를 구현해야 한다. 이를 두 개의 개별적인 단계로 잘개 쪼개거나, 이 메서드의 목적을 넘는 수준까지 처리하려고 해서는 안된다.

도메인을 중요 영역을 나누는 것과 관련한 직관을 감안해서, 설계 요소 (연산, 인터페이스, 클래스, Aggregate)를 응집력 있는 단위로 분해하라. 높은 응집도와 낮은 결합도와 같은 두 가지 기본 원리에 따라, 요구사항에 따라 변경될 수 있는 영역을 분리하라. 계속적인 리팩터링을 통해 변경되는 부분과 변경되지 않는 부분을 나누는 중심 축을 식별하고, 변경 영역을 분리하기 위한 패턴을 명확히 표현하는 Coneceptual Contour를 찾아라.

높은 응집도와 낮은 결합도는 개별 메서드에서부터 클래스와 Module, 그리고 대규모 구조에 이르기까지 모든 규모의 설계에 중요한 역할을 한다.

만약 요구사항에 대한 변경이나 리팩터링시 지역적으로 한정된 범위에서만 이루어진다면 모델이 현제 도메인에 적합해졌다는 뜻이다. 반대로 객체와 메서드를 와해시킬 정도로 광범위한 변경을 야기한다면 도메인에 관해 알고 있는 지식을 개선하고 그에 따라 설계 및 구현도 따라야 한다는 메시지이다.


Standalone Class (독립형 클래스)

의존성(dependency)이란 “두 요소 간의 관련성으로 한 요소에 대한 변경이 다른 요소가 필요로 하는 정보를 제공하거나 다른 요소가 제공하는 정보에 영향을 주는 관계”를 의미한다. 즉, 어떤 요소의 변경이 다른 요소에 영향을 미친다면 두 요소 간에 의존성이 존재한다고 말한다. 클래스가 다른 클래스의 인스턴스를 속성으로 포함하는 경우, 메소드의 파라미터로 사용하는 경우, 메소드 내부에서 지역적으로 인스턴스를 생성하는 경우, 다른 클래스를 상속받는 경우 모두 두 클래스 간에 의존 관계의 형성된다.

상호의존성은 모델과 설계를 이해하기 어렵게 만든다. 모든 연관관계는 의존성을 의미하므로 클래스를 이해하려면 연관관계를 토대로 어떤 요소가 연결되어 있는지 이해해야 한다. 메서드에 정의된 매개변수 타입이나 반환 타입에 대해서도 이해해야 한다.

Module이나 Aggregate 모두 지나치게 얽히고 설키는 상호의존성을 방지하는 것이 목적이다. Module이나 Aggregate 모두 스스로 내부의 의존성을 제어하려고 노력하지 않으면 고려할 사항이 많아질 수 있다. 이는 설계를 파악하는데 어려움이 따르고, 정신적 과부하를 주어 개발자가 다룰 수 있는 설계의 복잡도를 제한한다.

어떤 객체 개념을 구성하는 데 필수적이라는 사실이 증명되기 전까지는 객체의 모든 의존성을 검토해야 한다.

낮은 결합도는 개념적 과부하를 줄이는 객체 설계의 기본 원리이다. 늘 결합도를 낮추도록 노력하라. 객체와 무관한 모든 개념을 제거하라. 그러면 클래스가 완전히 독립적으로 바뀌고 단독으로 검토하고 이해할 수 있다. 모든 의존성을 제거하자는 것이 아니라 비본질적인 모든 의존성을 제거하는 것이 목표다.


Closure Of Operation (연산의 닫힘)

두 실수를 곱하면 실수가 나온다. 실수를 곱하면 실수가 나온다는 것은 항상 참이므로 실수를 가리켜 “곱셈에 닫혀있다.” 라고 한다. 두 실수를 곱한 결과가 실수 집합에 포함되지 않는 경우는 존재하지 않는다.

의존성은 늘 존재하겠지만 근본 개념을 구성하는 의존성은 나쁜 것이 아니다. 정제된 설계을 위한 흔히 볼 수 있는 일반적인 지침으로 Closure Of Operation이 있다. 이는 “1 + 1 = 2와 같은 덧셈 연산은 실수 집합에 닫혀있다.”라는 수학에서 유래한 것이다. XML 문서를 다른 XML 문서로 변환할 때 XSLT를 사용하는 데 XSLT 연산은 XML 문서 집합에 닫혀 있다. 이러한 닫힘의 특성은 연산을 간단히 해석할 수 있고 닫힌 연산을 연결하거나 결합하는 것에 쉽게 생각할 수 있다.

적절한 위치에 매개변수 타입과 반환 타입이 동일한 연산을 정의하라. 객체가 연산에 사용되는 상태를 포함한다면 매개변수 타입과 반환 타입을 객체의 타입과 동일하게 정의한다. 이런 방식으로 정의된 연산은 해당 타입의 집합에 닫혀 있다. 닫힌 연산은 부차적인 개념을 사용하지 않고도 고수준의 인터페이스를 제공한다.

이 패턴은 Value Object의 연산을 정의하는데 주로 사용된다. 도메인 내에서 Entity의 생명주기는 중요하므로, 연산의 결과로 새로운 Entity를 생성해서 반환할 수는 없다. Entity는 어떤 계산의 수행 결과를 표현하는 개념이 아니기 때문이다.

때로는 매개변수 타입이 객체의 타입과 같지만 반환 타입이 다르거나, 반환 타입이 같은데 매개변수 타입은 다를 수 있다. 이런 연산은 특정 타입에 닫혀 있는 것은 아니지만 어느 정도 Closure의 혜택을 받을 수 있다.

유연한 설계란 명확한 설계다 유연한 설계를 논의할 때 막연하게 메카닉적으로 코드를 추가하고 수정하기 용이한 설계라고만 생각하기 쉽다. 그러나 코드를 추가하고 수정하기 위해서는 우선 현재의 코드를 이해하고 변경 시의 부수효과를 예측하기 용이해야 한다. 따라서 유연한 설계란 코드 내에 도메인의 개념과 구조를 명시적으로 드러내고 부수효과를 명시적으로 표현하며, 도메인에 대한 개념적 모델과 일치하는 구조를 가진 설계라고 말할 수 있다.


Tags:
Stats:
0 comments