09 암시적인 개념을 명확하게
실제로 어떻게 달성할 수 있을까?
심층 모델이 강력한 이유는
심층 모델에 사용자의 행위, 문제, 문제의 해법에 대한 본질적인 지식을 간결하고 유연하게 표현하는 중심 개념과 추상화가 담겨 있기 때문이다.
심층 모델에 향하는 첫걸음은 일단 도메인의 본질적인 개념을 모델 내에 표현하는 것이다.
그 후 성공적인 지식탐구와 리팩터링을 반복하면서 이를 정제하게 된다.
그러나 지식탐구와 리팩터링은 중요한 개념이 모델과 설계 내에 명확하게 인식되고 표현될 때에야 비소로 본 궤도에 오른다.
개발자들의 토의 중에 단서를 얻거나 설계상에 암시적으로 존재하는 개념을 인지하면
도메인 모델과 관련 코드를 대량으로 변환하게 되며,
그 후 하나 이상의 객체와 객체 간의 관계를 활용해 모델 내에 해당 개념을 명확하게 표현하게 된다.
개념 파헤치기
발견된 대부분의 암시적인 개념은
팀에서 사용하는 언어를 주의 깊게 경청하고,
설계상 부자연스러운 부분과 외견상 모순돼 보이는 전문가의 견해를 면밀하게 검토하며,
도메인과 관련된 문서를 조사하고 수없이 많은 실험 과정을 거쳐 얻어진 것이다.
언어에 귀 기울여라
도메인 전문가가 사용하는 언어에 귀 기울여라.
복잡하게 뒤얽힌 개념들을 간결하게 표현하는 용어가 있는가?
여러분이 특정 문구를 이야기할 때 도메인 전문가의 얼굴에서 곤혹스러운 표정이 사라지는가?
이 모두가 바로 모델에 기여하는 개념의 실마리에 해당한다.
하지만 이것이 “명사는 객체다”라는 진부한 개념을 표현하는 것이 아니다.
새로운 단어를 듣게 되면 명료하고 유용한 개념을 찾기 위한 대화와 지식탐구로 이어진다.
사용자나 도메인 전문가가
설계상의 어디에도 표현돼 있지 않은 어휘를 사용하고 있다면 그것은 곧 경고 신호다.
설계상에 표현돼 있지 않은 어휘를 사용하고 있다면 그것은 더욱 더 위험한 경고다.
어색한 부분을 조사하라
모순점에 대해 깊이 고민하라
도메인 전문가는 자신의 경험과 필요에 따라 각기 다른 방식으로 사물을 바라본다.
…
하지만 용어와 오해의 문제 말고도 두 도메인 전문가가 서로 모순되는 사실을 진술하는 경우도 있다.
서적을 참고하라
모델의 개념을 조사할 때는 분명해 보이는 사실이라고 해서 대강 보고 넘겨서는 안된다.
다양한 분야에 대해 근본 개념과 일반적인 통념을 설명하는 책을 찾아볼 수 있다.
…
다른 대안으로는 해당 도메인을 경험한 다른 소프트웨어 전문가의 책을 읽는 것이다.
예를 들어 “Analysis Patterns: Reusable Object Models (Fowler 1997)“의 6장을 읽었다면 좋든 나쁘든 간에 앞의 결과와는 전혀 다른 방향으로 작업을 진행했을 것이다.
책을 읽는다고 해서 그대로 이용할 수 있는 해법을 얻는 건 아니다.
다만 해당 분야를 두루 경험한 사람의 정제된 경험을 비롯해 개발자가 직접 시도해볼 만한 출발점 정도는 제시할 것이다.
덕분에 개발자는 바퀴를 다시 발명하는 수고를 아낄 수 있다.
11장, “분석 패턴의 적용”에서는 이와 같은 방식을 좀 더 깊이 있게 다루겠다.
시도하고 또 시도하라
모델러/설계자는 자신의 아이디어에 집착해서는 안된다.
어차피 선택의 여지는 없다. 실험은 유용한 것이 무엇이고 유용하지 않은 것이 무엇인지를 배우는 방법이다.
다소 불명확한 개념을 모델링하는 법
그러나
“명사와 동사”로 표현되지 않는 다른 중요한 범주의 개념도
모델 내에 명시적으로 표현할 수 있다.
명시적인 제약조건
Constraint는 특별히 중요한 범주의 모델 개념을 형성한다.
흔히 제약조건은 암시적인 상태로 존재하며, 이를 명시적으로 표현하면 설곌ㄹ 대폭 개선할 수 있다.
제약 조건을 별도의 메서드로 분리하면
부여된 이름을 사요하여 제약조건에 관한 토의가 가능해지고,
호출 메서드는 단순한 상태를 유지하고 본연의 작업에만 집중할 수 있다.
다음은 어떤 제약조건을 포함한 객체의 설계가 어딘가 잘못돼 있음을 나타내는 조짐을 일부 나열한 것이다.
제약조건을 평가하려면 해당 객체의 정의에 적합하지 않은 데이터가 필요하다.
관련된 규칙이 여러 객체에 걸쳐 나타나며, 동일한 계층구조에 속하지 않는 객체 간에 중복 또는 상속 관계를 강요한다.
설계와 요구사항에 관한 다양한 논의는 제약조건에 초점을 맞춰 이뤄지지만
정작 구현 단계에서는 절차적인 코드에 묻혀 명시적으로 표현되지 않는다.
도메인 객체로서의 프로세스
프로세스를 수행하는 방법이 한 가지 이상일 때 취할 수 있는 또 다른 접근법은
알고리즘 자체 또는 그것의 일부를 하나의 객체로 만드는 것이다. → Strategy pattern
명시적으로 표현해야 할 프로세스와
숨겨야 할 플세를 구분하는 비결은 간단하다.
도메인 전문가가 이야기하고 있는 프로세스인가?
아니면 컴퓨터 프로그램상의 메커니즘의 일부일 뿐인가?
☞ SPECIFICATION : 특정한 종류의 규칙을 표현하는 매우 간결한 수단을 제공하며, 조건 로직으로부터 규칙을 분리해서 규칙이 모델 내에서 분명해지게끔 만들어준다.
Specification
graph LR
Specification --"{satisfied by}"--> Object
규칙을 도메인 계층 내에 유지할 필요가 있지만
규칙을 통해 평가하려는 객체에 규칙을 두기에는 적절하지 않다.
그뿐만 아니라 규칙을 평가하는 메서드는
조건 코드로 팽창할 것이고 결국 규칙에 대한 가독성은 떨어지고 만다.
어떤 객체가 특정 기준(제약조건, 규칙)을 만족하는지 판단하는 술어 VO
예시 : JPA의 Specification, Java의 Predicate
패턴은 요리책이 아니다.
모델과 구현 간의 Model-Driven Design의 개념을 유지하게 해주는 프로그래밍 문제에 대한 해법이다.
Specification의 적용과 구현
Specification의 주된 가치는 매우 서로 달라 보이는 애플리케이션 기능을 하나로 통합해 준다는 점이다.
객체의 상태를 다음과 같은 세 가지 목적으로 명시하고 싶을 때가 있다.
1. 객체가 어떤 요건을 충족시키거나
특정 목적으로 사용할 수 있는지
가늠하고자 객체를 검증 → validation
2. 컬렉션 내의 객체를 선택(이를테면, 기한이 만료된 송장 목록을 조회) → selection
3. 특정한 요구사항을 만족하는 새로운 객체의 생성을 명시 → building to order (요청 구축)
Validation
classDiagram
class Specification {
+isSatisfiedBy(product)* boolean
}
class PriceSpecification {
-price: int
-dueDate: LocalDateTime
}
class RatioSpecification {
-ratio: int
-dueDate: LocalDateTime
}
class Product {
-value: int
-dueDate: LocalDateTime
}
Specification <|-- PriceSpecification
Specification <|-- RatioSpecification
Specification ..> Product
Selection (or Querying)
// Specificaiton에 맞는 컬렉션 내의 객체만을 선택한다.
Set<Product> products = productRepository.selectSatisfying(new RatioSpecification(value, dueDate));
public String asSQL(value, dueDate) {
return "SELECT * FROM PRODUCT" +
... + SQLUtility.productAsSQL(value, dueDate);
}
Building to Order (Generating)
Specificaiton을 사용해서 객체 생성기의 인터페이스를 정의하면
생성할 결과물을 명시적으로 인터페이스에 포함킬 수 있다.
이 접근법에는 여러 가지 이점이 있다.
생성기의 구현을 (생성기의) 인터페이스로부터 분리(decouple)할 수 있다.
Specificaiton은 생성할 결과물에 대한 요구사항은 선언하지만 결과물을 생성하는 방법은 정의하지 않는다.
Specification을 사용한 인터페이스는 생성 규칙을 명시적으로 전해주므로 개발자들이 연산의 세부적인 사항을 이해하지 않고도 생성기의 결과물을 예상할 수 있다.
절차적인 방식으로 정의된 생성기의 경우 어떻게 작동할지 예상하려면
여러 가지 경우를 직접 시행해 보거나 코드를 한 줄 한 줄 이해하는 수밖에 없다.
생성기는 단순히 Specification에 포함된 조건에 따라 객체를 생성하는 반면
생성 요청을 표현하는 코드는 클라이언트에 존재하므로 더 유연한 인터페이스를 얻거나 더 유연하게 개선할 수 있다
마지막으로 언급하지만 매우 중요한 이점은
이런 종류의 인터페이스는 생성기에 대한 입력(동시에 생성기의 결과물을 검증하기도 하는)을 정의하는 명시적인 방법이 모델에 포함되어 있어 테스트하기가 더 수월하다는 점이다.
즉, 생성 절차를 명시하고자 생성기의 인터페이스로 전달된 것과 동일한 Specification을
생성된 객체가 올바른지 확인하기 위한 자체적인 검증(구현에서 이를 지원할 경우) 용도로 사용할 수 있다는 것이다.
(이것은 10장에서 살펴볼 Assertion의 한 예다.)
@RequiredArgsConstructor
public class ContainerSpecification {
private final ContainerSpecification requiredSpecification;
public boolean isSatisfiedBy(Container container) {
return container.getFeatures().contains(requiredSpecification);
}
}
@Getter
@RequiredArgsConstructor
public class Container {
private final int capacity;
private final Collection<Drum> contents;
private final Collection<ContainerFeature> features;
private boolean hasSpaceFor(Drum drum) {
return remainingSpace() >= drum.getSize();
}
private double remainingSpace() {
double totalContentsSize = 0;
for (Drum content : contents) {
totalContentsSize += content.getSize();
}
return capacity - totalContentsSize;
}
public boolean canAccommodate(Drum drum) {
return hasSpaceFor(drum) &&
drum.getChemical().getContainerSpecification().isSatisfiedBy(this);
}
public void add(Drum drum) {
contents.add(drum);
}
}
public class PrototypePacker implements WarehousePacker {
@Override
public void pack(Collection<Container> containersToFill, Collection<Drum> drumsToPack) throws NoAnswerException {
for (Drum drum : drumsToPack) {
Container container = findContainerFor(containersToFill, drum);
container.add(drum);
}
}
private Container findContainerFor(Collection<Container> containers, Drum drum) {
for (Container container : containers) {
if (container.canAccommodate(drum)) {
return container;
}
}
throw new NoAnswerException();
}
}