10 유연한 설계

소프트웨어의 궁극적인 목적은 사용자를 만족시키는 것이다.
하지만 우선 그 소프트웨어는 개발자를 만족시켜야 한다.
특히 리팩터링을 강조하는 프로세스에서는 이 점이 더 중요하다.
… 개발자들은 이 소프트웨어를 사용해서 작업해야 한다. 하지만 개발자들이 정말 그러길 원할까?

  • MDD를 제대로 반영하지 못해 설계 결여로 나타나는 취약성(fragility)
    • 소프트웨어의 처리 방식에 내포된 모든 의미를 확신하지 못하면 중복 발생
    • 재사용성을 높이고자 클래스와 메서드를 분해할 수는 있지만, 이렇게 분할된 작은 부분들이 무슨 일을 하는지 추적하기 어려워진다.
    • 의존성 탓에 (뭔가를 망가트릴지도 모르는) 변경을 덜 하게 될 것이다.
개발이 진행될 수록 현재의 레거시 코드로 인한 중압감에 시달리지 않고
프로젝트 진행을 촉진하려면 변경을 수용하고 즐겁게 작업할 수 있는 설계

유연한 설계는 심층 모델링을 보완한다.
반복주기를 거쳐 핵심 관심사를 단순하고도 명확하게 표현하는 모델을 계발하고,
클라이언트 개발자가 모델을 실제 작동 가능한 코드로 만들어낼 수 있는 설계를 구성함으로써 심층 모델을 만들 재료로 유용한 형태를 만든다.

무수히 많은 과도한 엔지니어링이 유연성이라는 명목으로 정당화되어 왔다.
그러나 대개 너무 과도한 추상 계층과 간접 계층이 존재하면 오히려 유연성에 방해가 된다. … 정교한 시스템을 만들 목적으로 조립 가능하고
그럼에도 이해하기가 어렵지 안은 요소를 만들어내려면
MDD을 적당한 수준의 엄밀한 설계 형식과 접목하고자 노력해야 한다.

개발자는 두 가지 역할을 수행하며, 각 역할은 설계에 의해 뒷받침되어야 한다.

  • 클라이언트 개발자 역할로서 설계 특징을 활용도메인 객체를 애플리케이션 코드나 다른 도메인 계층의 코드와 통합한다.
  • 설계는 설계 자체를 변경하는 개발자도 뒷받침해야 한다는 것이다. 변경에 열려 있으려면, 설계가 클라이언트 개발자가 사용하는 모델과 동일한 저변의 모델을 드러내어 쉽게 이해할 수 있어야 한다.
… 하지만 복잡성 때문에 진행이 지연될 경우
가장 중요하고 난해한 부분을 잘 다듬어 유연한 설계로 이끄는 작업이
레거시 코드를 유지보수하느라 허우적댈 것인지, 아니면
복잡도의 한계를 뚫고 전진할 것인지의 차이를 결정한다.
  선언적인 형식의 설계를 사용해서 클라이언트 코드를 작성하는 것을 가능하게 한다. pp.292

유연한 설계에 기여하는 패턴

p260

graph LR iri(("Intention-
Revealing
Interface")) seff["Side-Effect-Free
Function"] assertion["Assertion"] cc["Conceptual
Contour"] sc["Standalone
Class"] coo["Closure of
Operation"] ul(("Ubiquitous
Language")) mdd(("Model-Driven
Design")) iri --"안전하고 단순하게 함"--> seff seff --"구성을 안전하게 함"--> assertion iri --"부수효과를 명확하게 함"--> assertion iri --"번역을 단순화함"--> coo mdd --"모델을 표현하는 데 활용"--> iri mdd --"번역을 단순화함"--> sc mdd --"변경의 비용을 줄임"--> cc sc --"활용"--> coo iri --"이름의 출처로 활용"--> ul

Intention-Revealing Interfaces

UL로 의미있는 이름의 캡슐화

수행 방법에 언급하지 말고 결과목적만을 표현하도록
클래스와 연산이 이름을 부여하라
이렇게 하면 클라이언트 개발자가 내부를 이해해야 할 필요성이 줄어든다.

이름은 팀원들이 그 의미를 쉽게 추측할 수 있게 UL에 포함된 용어를 따라야 한다.

클라이언트 개발자의 관점에서 생각하기 위해
클래스와 연산을 추가하기 전에
행위에 대한 테스트를 먼저 작성하라.

Side-Effect-Free Functions

  • 연산
    • 명령, Command(modifier) : 변수의 값을 변경하는 등의 작업을 통해 시스템의 상태를 변경하는 연산 (setter)
    • 질의, Query : 변수 안에 저장된 데이터에 접근하거나, 저장된 데이터를 기반으로 계산을 수행해서 시스템으로부터 정보를 얻는 연산 (getter)
다수의 규칙에 따라 상호작용하거나 여러 가지 계산을 조합하면 극도로 예측하기가 어려워진다.

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

  • 명령과 질의를 엄격하게 분리된 서로 다른 연산으로 유지한다. (→ Side-effect free!)
  • 새로운 VO를 생성해서 반환한다. (→ Immutable!)
    1. Side-effect의 단순한 명령/질의는 Entity로 격리
    2. 복잡한 계산을 처리하는 책임을 VO로 옮기는 두 번째 리팩터링을 고려
가능한 한 많은 양의 프로그램 로직을
관찰 가능한 Side-effect 없이 결과를 반환하는 함수 안에 작성하라.

명령(관찰 가능한 상태를 변경하는 메서드)을
도메인 정보를 반환하지 않는 아주 단순한 연산으로 엄격하게 분리하라.

한 걸음 나아가
책임에 적합한 어떤 개념이 나타난다면
복잡한 로직을 VO로 옮겨서 Side-effect를 통제하라.

Assertion

Side-Effect-Free Functions에서 1차적으로 리팩터링한
Entity의 명령(command)에서
Side-effect가 명확해지고 다루기 쉬워진다.
내부를 조사하지 않고도 설계 요소의 의미와 연산의 실행 결과를 이해할 수 있는 방법
사실임을 보장

Conceptual Contour

  심층 모델 탐구 → 도메인의 논리적인 일관성 발견 →  반복적인 리팩터링 → 개념적 윤곽의 발견

High cohesion, Loose coupling → 코드뿐 아니라 개념에 대해서도 동일하게 적용할 수 있다.

기계적인 관점에서 바라보는 함정을 피하려면
수시로 도메인에 관한 직관을 발휘해서
기술적인 방향으로 흐를 수 있는
사고의 흐름을 조절해야 한다.
결정을 내릴 때마다 다음과 같은 질문을 자문해보자.

이 개념이 현재 모델과 코드에 포함된 관계를 기준으로 했을 때 적절한가,
또는 현재 기반을 이루는 도메인과 유사한 윤곽을 나타내는가?
객체와 메서드를 와해시킬 정도로 광범위한 변경을 야기하는 요구사항이 나타났다는 것은
도메인에 관해 알고 있는 지식을 개선해야 한다는 메시지다. (= 개념적 윤곽을 알지 못하는 상태다)

Standalone Class

상호의존성(interdependencies)는 모델과 설계를 이해하기 어렵게 만든다.
또한 테스트를 어렵게 만들고 유지보수성을 떨어뜨린다.
그리고 쉽게 축적되는 경향이 있다.
Module 내에서조차 의존성이 증가할수록 설계를 파악하는 데 따르는 어려움이 가파르게 높아진다.
이는 개발자에게 정신적 과부하(mental overload)를 줘서 개발자가 다룰 수 있는 복잡도를 제한한다.
아울러 명시적인 참조에 비해 암시적인 개념이 훨씬 더 많은 정신적 과부하를 초래한다.

모든 개발자가 늘 염두에 두고 있는 기본 개념(표준 라이브러리, 언어 문법, 패턴 등)외에는 정신적 과부하를 초래한다.

Loose coupling은 객체 설계의 기본 원리다. 가능한 한 늘 Coupling을 낮추고자 노력하라.
현재 상황과 무관한 개념(비본질적인 의존성)을 제거하라.
그러면 완전히 독립적(self-contained)으로 바뀌고 단독으로 검토하고 이해할 수 있을 것이다.
그러한 독립적인 클래스는 MODULE을 이해하는 데 따르는 부담(정신적 과부하, 개념적 과부하)을 상당히 덜어준다.

Closure of Operation

  실수 Θ 실수 ⊂ 실수 (Θ : 연산자)
적절한 위치에 반환 타입과 인자 타입이 동일한 연산을 정의하라.
닫힌 연산은 부차적인 개념을 사용하지 않고도 고수준의 인터페이스를 제공한다.

Declarative Design

Side-effect, Boiler-plate가 발생할 때 고려해볼 방법

eg. DB 영속성과 객체 관계형 Mapping → Mapstruct, Flutter의 개발 방식
일종의 실행 가능한 명세(executable specification)로서
프로그램의 전체 혹은 일부를 작성하는 방식을 의미한다.
특성(properties)을 매우 정확하게 기술함으로써 소프트웨어를 제어하는 것이다.

선언적 설계를 작성하는 방식은 다양하며

  1. 리플렉션 메커니즘을 이용하거나
  2. 컴파일 시점에 코드를 생성하는 방법(선언을 기반으로 정해진 패턴에 따라 자동으로 코드를 생성)
    (→ Java라면 Annotation processor)

을 이용할 수도 있다.

선언적 설계에서 개발자는 선언을 보이는 모습 그대로 받아들일 수 있다. 그리고 개발자가 받아들인 선언은 절대적으로 보장된다.

선언적인 프로그램의 이점을 누리려면
모든 개발자가 프레임워크의 규칙을 준수해야 한다.

Domain-Specific Languages

특정 도메인을 위해 구축된 특정 모델에 맞게 조정된 프로그래밍 언어

A Declarative Style of Design

graph LR subgraph sub["설계에 적용했을 때, 서서히 선언적인 영역으로 나아가고 있는 징조"] iri(("Intention-
Revealing
Interface")) seff["Side-Effect-Free
Function"] assertion["Assertion"] iri --"안전하고 단순하게 함"--> seff seff --"구성을 안전하게 함"--> assertion iri --"부수효과를 명확하게 함"--> assertion end

SPECIFICATION을 선언적인 형식으로 확장하기

Extending SPECIFICATIONS in a Declarative Style

SPECIFICATION은 확립된 정형화인 술어를 각색한 것이다. 술어에는 선택적으로 사용할 수 있는 갖가지 유용한 특성이 있다.

논리 연산을 이용한 SPECIFICATION 조합

술어는 “AND”, “OR”, “NOT” 연산을 사용해 조합할 수 있다.
이러한 논리 연산은 술에 대해 닫혀 있어서
SPECIFCATION의 조합은 CLOSURE OF OPERATION을 의미한다.

Composite pattern을 이용한 SPECIFICATON 설계

Script ▼

Script ▼

classDiagram

class Specification {
    isSatisfied(Object candidate) boolean
    and(Specification specification) Specification
    or(Specification specification) Specification
    not(Specification specification) Specification
}

class OrSpecification
class AndSpecification
class NotSpecification

Specification <|-- OrSpecification
Specification <|-- AndSpecification
Specification <|-- NotSpecification

note for Specification "COMPOSITE"
note for NotSpecification "DECOATOR"

public interface Specification<T> {
    boolean isSatisfiedBy(T candidate);

    Specification<T> and(Specification<T> other);
    Specification<T> or(Specification<T> other);
    Specification<T> not();
}

public abstract class AbstractSpecification<T> implements Specification<T> {
    @Override
    public Specification<T> and(Specification<T> other) {
        return new AndSpecification<>(this, other);
    }

    @Override
    public Specification<T> or(Specification<T> other) {
        return new OrSpecification<>(this, other);
    }

    @Override
    public Specification<T> not() {
        return new NotSpecification<>(this);
    }
}



@RequiredArgsConstructor
public class AndSpecification<T> extends AbstractSpecification<T> {
    private final Specification<T> one;
    private final Specification<T> other;

    @Override
    public boolean isSatisfiedBy(T candidate) {
        return one.isSatisfiedBy(candidate) && other.isSatisfiedBy(candidate);
    }
}

@RequiredArgsConstructor
public class OrSpecification<T> extends AbstractSpecification<T> {
    private final Specification<T> one;
    private final Specification<T> other;

    @Override
    public boolean isSatisfiedBy(T candidate) {
        return one.isSatisfiedBy(candidate) || other.isSatisfiedBy(candidate);
    }
}

@RequiredArgsConstructor
public class NotSpecification<T> extends AbstractSpecification<T> {
    private final Specification<T> wrapped;

    @Override
    public boolean isSatisfiedBy(T candidate) {
        return !wrapped.isSatisfiedBy(candidate);
    }
}

Composite SPECIFICATON을 구현하는 다른 방법

graph LR subgraph stack["Stack<Specification>"] and["AndSpecificationOperator(FLYWEIGHT)"] not1["NotSpecificationoperator(FLYWEIGHT)"] Amored not2["NotSpecificationoperator"] Ventilated end

  and(not(armored), not(ventilated))

이 설계에는 다음과 같은 장담점이 있다.

  • 장점
    • 생성되는 객체의 수가 적음
    • 효율적인 메모리 사용
  • 단점
    • 좀 더 숙련된 개발자가 필요

포섭 관계

  Subsumption
  1. 상대편을 자기편으로 감싸 끌어들임.
  2. 철학 어떤 개념이 보다 일반적인 개념에 포괄되는 종속 관계. 포유류와 척추동물의 관계 따위이다.

자주 규칙(SPECIFICATION)이 변경 되는 사용.
일반적인 상황이라면 불필요하고 구현하기도 어렵지만, 가끔씩 정말 어려운 문제를 해결하는 데 요긴하게 쓸 수 있다.

더 엄격한 SPECIFICATION은 덜 엄격한 SPECIFICATION을 포함한다.
더 엄격한 SPECIFICATION은 이전의 어떤 요구사항도 간과큰 관심 없이 대강 보아 넘김하지 않은 채 추가될 수 있다.장점!

각 SPECIFICATION을 만족하는 임의의 대상은
기존의 SPECIFICATION 역시 만족시키며
이를 SPECIFICATION의 언어를 사용해서 표현하면
“새로운 SPECIFICATION은 기존의 SPECIFICATION을 포섭한다(subsume)“라고 한다.

각 SPECIFICATION을 술어로 간주할 경우
포섭은 논리적 함축(logical implication)과 동일하다.
이를 전통적인 표기법을 사용해서 A→B로 표현할 수 있으며,
이것은 문자 A가 문장 B를 함축하고 있음을 의미하고,
따라서 A가 참이면 B도 참이다.

새 명세 → 기존 명세

@Getter
@RequiredArgsConstructor
public class MinimumAgeSpecification {
    private final int threshold;
    
    public boolean subsumes(MinimumAgeSpecification other) {
        return threshold >= other.getThreshold
    }
}

public interface Specification {
    boolean isSatisfiedBy(Object candiate);
    boolean and(Specification specification);
    boolean subsumes(Specification  other);
}

오직 AND 연산만을 포함하는 함축을 증명하는 것은 간단하다

  A AND B → B
  A AND B AND C → A AND B

아쉽게도 OR와 NOT을 포함하면 증명이 훨씬 더 복잡해진다.
대부분의 경우
일부 연산을 무시하거나
포섭을 사용하지 않는 방식
중 하나를 택해서 이처럼 복잡한 상황을 피하는 것이 최선이다.
두 가지 모두 필요한 상황이라면
구현에 수반되는 어려움을 정당화할 정도로 돌아오는 이익이 큰지 신중하게 고려해봐야 한다.

// 1. 모든 인간은 죽는다.
Specification manSpec = new ManSpecification();
Specification mortalSpec = new MortalSpefication();
assert manSpec.subsumes(mortalSpec);

// 2. 아리스토텔레스는 인간이다.
Man aristole = new Man();
assert manspec.isSatisfiedBy(aristole);

// 3. 고로 아리스토텔레스는 죽는다.
assert mortalSpec.isSatisfiedBy(aristole);

Angles of Attack

영각(迎角), 받음각.1)

이 장에서 소개한 Coupling을 낮추기 위한 기법들을 어떻게 설계에 적합하게 적용할 것인가?
그에 대한 목표 선정은?

하위 도메인으로 분할하라

Carve Off Subdomains

전체 설계 영역을 피상적으로 수정하기는 어렵다.

  작은 하나의 영역에 지붕 → 상태 변경에 제약을 가하는 복잡한 규칙 적용? → 별도의 모델이나 규칙을 선언적으로 표현해주는 간단한 프레임워크 내부로 옮김

가능하다면 정립된 정형화를 활용하라

Draw on Established Formalisms, When You Can

아무것도 없는 상태에서
빈틈없는 개념적 체계를 만들어낸다는 것은
매일 할 수 있는 간단한 작업이 아니다.
간혹 프로젝트 기간 동안 이런 체계를 발견하고 정제하기도 한다.

그러나 보통은 현재의 도메인이나 다른 도메인 영역에서
오랜 시간 동안 정립되어 온 개념적인 체계를 이용하거나 수정해서 적용할 수 있으며,
그 중 일부는 몇 세기에 걸쳐 정제되고 증류된 것들이다.

기본적인 산수만으로 놀랍도록 유용하게 해법을 이끌어낼 수 있는 방법 체계는 수학이다.

각종 도메인의 어딘가에는 수학적인 개념이 존재한다.
찾아라. 그리고 파헤쳐라.

도메인에 적절히 특화된 수학은 깔끔한 동시에 명확한 규칙과 결합할 수 있어서 사람들이 이해하기도 쉽다.

1)
항공기의 익현(翼弦)과 기류의 방향으로 생기는 각도. 영각이 커지면 상승하고, 작아지면 하강한다. 그러나 임계 영각을 넘어서면 오히려 양력을 떨어뜨리는 난류가 발생하게 된다.
domain-driven_design/part_3_refactoring_toward_deeper_insight/10_supple_design.txt · Last modified: 2024/02/17 15:53 by ledyx