CHAP10 - Modern-Java-in-Action/Online-Study GitHub Wiki
- 도메인 전용 언어(Domain-Specific Languages, DSL)란 무엇이며 어떤 형식으로 구성되는가?
- DSL을 API에 추가할 때의 장단점
- JVM에서 활용할 수 있는 자바 기반 DSL을 깔끔하게 만드는 대안
- 최신 자바 인터페이스와 클래스에 적용된 DSL에서 배움
- 효과적인 자바 기반 DSL을 구현하는 패턴과 기법
- 이들 패턴을 자바 라이브러리와 도구에서 얼마나 흔히 사용하는가?
프로그램은 사람들이 이해할 수 있도록 작성되어야 하는 것이 중요하며 기기가 실행하는 부분은 부차적일 뿐 - 하롤드 아벨슨
- 프로그래밍 언어도 결국 언어이며 언어의 주요 목표는 메시지를 명확하고 안정적인 방식으로 전달하는 것
- 의도가 명확하게 전달되어야 한다.
- DSL
- 특정 도메인을 대상으로 만들어진 특수 프로그래밍 언어
- 애플리케이션의 비즈니스 로직을 표현
- 도메인 전문가가 비즈니스 관점에서 소프트웨어가 제대로 되었는지 확인할 수 있음
- 메이븐, 앤트는 빌드 과정을 표현하는 DSL, HTML은 웹페이지의 구조를 정의하도록 특화된 언어
- 특정 비즈니스 도메인의 문제를 해결하려고 만든 언어
- ex) 회계전용 소프트웨어 애플리케이션을 개발 -> 비즈니스 도메인에는 통장 입출금 내역, 계좌 와 같은 개념이 포함
- 특정 비즈니스 도메인을 인터페이스로 만든 API
- 간결함 : API는 비즈니스 로직을 간편하게 캡슐화하므로 반복을 피할 수 있고 코드를 간결하게 만들 수 있다.
- 가독성 : 도메인 영역의 용어를 사용하므로 비 도메인 전문가도 코드를 쉽게 이해할 수 있다. 결과적으로 다양한 조직 구성원 간에 코드와 도메인 영역이 공유될 수 있다.
- 유지보수 : 잘 설계된 DSL로 구현한 코드는 쉽게 유지 보수하고 바꿀 수 있다.
- 높은 수준의 추상화 : DSL은 도메인과 같은 추상화 수준에서 동작하므로 도메인의 문제와 직접적으로 관련되지 않은 세부 사항을 숨긴다.
- 집중 : 비즈니스 도메인의 규칙을 표현할 목적으로 설계된 언어이므로 프로그래머가 특정 코드에 집중할 수 있다. 결과적으로 생산성이 좋아진다.
- 관심사 분리 : 지정된 언어로 비즈니스 로직을 표현함으로 애플리케이션의 인프라구조와 관련된 문제와 독립적으로 비즈니스 관련된 코드에서 집중하기가 용이하다.
- DSL 설계의 어려움 : 간결하게 제한적인 언어에 도메인 지식을 담는 것이 쉬운 작업은 아니다.
- 개발 비용 : 코드에 DSL을 추가하는 작업은 초기 프로젝트에 많은 비용과 시간이 소모된다. 또한 DSL 유지보수와 변경은 프로젝트에 부담을 주는 요소다.
- 추가 우회 계층 : DSL은 추가적인 계층으로 도메인 모델을 감싸며 이때 계층을 최대한 작게 만들어 성능 문제를 회피한다.
- 새로 배워야 하는 언어 : DSL을 프로젝트에 추가하면서 팀이 배워야 하는 언어가 한 개 더 늘어난다는 부담이 있다.
- 호스팅 언어 한계 : 일부 자바 같은 범용 프로그래밍 언어는 장황하고 엄격한 문법을 가졌다. 이런 언어로는 사용자 친화적 DSL을 만들기가 힘들다.
- DSL의 카테고리를 구분하는 방법 -> 내부 DSL, 외부 DSL, 다중 DSL
- 자바로 구현한 DSL을 의미
- 람다 표현식의 등장으로 읽기 쉽고, 간단하며 표현력 있는 DSL을 만들 수 있게 됨
- 익명 내부 클래스 대신 람다를 사용하면 장황함을 크게 줄여 신호 대비 잡음 비율을 적정 수준으로 유지하는 DSL을 만들 수 있다.
// forEach를 이용하여 문자열 목록 출력하기
List<String> numbers = Arrays.asList("one", "two", "three");
// 1. 익명 내부 클래스
numbers.forEach(new Consumer<String>() { -> 코드의 잡음1
@Override
public void accept(String s) { -> 코드의 잡음2
System.out.println(s); -> 코드의 잡음3
}
});
// 2. 람다 표현식
numbers.forEach(s -> System.out.println(s));
// 3. 메서드 참조
numbers.forEach(System.out::println);
- 외부 DSL에 비해 새로운 패턴과 기술을 배워 DSL을 구현하는 노력이 줄어든다
- 순수 자바로 DSL을 구현하면 나머지 코드와 함께 DSL을 컴파일할 수 있다
- 개발팀이 새로운 언어를 배울 필요가 없다.
- 기존 자바 IDE를 통해 자동 완성, 자동 리팩터링 같은 기능을 그대로 사용할 수 있다.
- 한 개의 언어로 하나 또는 여러 도메인을 대응하지 못해 추가 DSL을 개발해야 하는 상황에서 자바를 이용하여 추가 DSL을 쉽게 합칠수 있다.
장점
- 같은 자바 바이트코드를 사용하는 JVM 기반 프로그래밍 언어를 이용하여 DSL을 만들 수 있다.
- 문법적 잡음이 없으며 개발자가 아닌 사람도 코드를 쉽게 이해할 수 있다.
- 자바 언어가 가지는 한계를 넘을 수 있다 (스칼라 - 커링, 임의 변환 등 DSL 개발에 필요한 여러 특성을 갖춤)
단점
- 누군가가 해당 언어에 대해 고급 기술을 사용할 수 있을 정도의 충분한 지식을 가지고 있어야 한다.
- 두 개 이상의 언어가 혼재하므로 여러 컴파일러로 소스를 빌드하도록 빌드 과정을 개선해야 한다.
- 호환성 문제를 고려해야한다
- 자신만의 문법과 구문으로 새로운 언어를 설계해야 한다는 단점
- 우리에게 필요한 특성을 완벽하게 제공하는 언어를 설계할 수 있다는 장점
- 자바의 새로운 기능의 장점을 적용한 첫 API는 네이티브 자바 API 자신이다.
- 람다 표현식과 메소드 참조를 이용해 DSL의 가독성, 재사용성, 결합성이 높아졌다.
사람들을 가지고 있는 리스트에서 나이순으로 객체를 정렬하는 예제
Collections.sort(persons, new Comparator<Person>() {
public int compare(Person p1, Person p2) {
return p1.getAge() - p2.getAge();
}
});
- java8 이전에는 위와 같이 익명 클래스를 활용하여 구현 -> 람다 표현식으로 변경
Collections.sort(persons, (p1, p2) -> p1.getAge() - p2.getAge());
- 정적 유틸리티 메서드 집합과 메서드 참조 제공
Collections.sort(persons, comparing(p -> p.getAge()));
Collections.sort(persons, comparing(Person::getAge));
- reverse 메서드를 사용하여 역순으로 정렬하거나 이름으로 비교를 수행하는 Comparator를 구현하여 알파벳 순 정렬
Collections.sort(persons, comparing(Person::getAge).reverse());
Collections.sort(persons, comparing(Person::getAge)
.thenComparing(Person::getName));
- List 인터페이스에 추가된 새 sort 메서드를 이용해 코드를 깔끔하게 정리
persons.sort(comparing(Person::getAge)
.thenComparing(Person::getName));
- 컬렉션 정렬 도메인의 최소 DSL
- 작은 영역에 국한된 예제지만 이미 람다와 메서드 참조를 이용한 DSL이 코드의 가독성, 재사용성, 결합성을 높일수 있는지 보여준다.
- Stream 인터페이스는 네이티브 자바 API에 작은 내부 DSL을 적용한 좋은 예시
- 데이터 조작(필터링, 정렬, 변환, 그룹화 등) 기능 제공
반복 형식으로 예제 로그 파일에서 에러 행을 읽는 코드
List<String> errors = new ArrayList<>();
int errorCount = 0;
BufferedReader bufferedReader = new BufferedReader(new FileReader(fileName));
String line = bufferedReader.readLine();
while (errorCount < 40 && line != null) {
if (line.startsWith("ERROR")) {
errors.add(line);
errorCount++;
}
line = bufferedReader.readLine();
}
- 코드가 장황하여 의도를 한 눈에 파악하기 어렵고, 문제가 분리되지 않아 가독성과 유지보수성 모두 저하됨
함수형으로 로그 파일의 에러 행을 읽는 코드
List<String> errors = Files.lines(Paths.get(fileName))
.filter(line -> line.startsWith("ERROR"))
.limit(40)
.collect(toList());
- 스트림 API의 플루언트 스타일인 메서드 체인은 잘 설계된 DSL의 또 다른 특징
- 모든 중간 연산은 게으르며 다른 연산으로 파이프라인될 수 있는 스트림으로 반환
- 최종 연산은 적극적이며 전체 파이프라인이 계산을 일으킨다.
- Collector 인터페이스는 데이터 수집을 수행하는 DSL
- 차를 브랜드와 색상으로 그룹화하는 로직
// 중첩형식
Map<String, Map<Color, List<Car>>> carsByBrandAndColor
= cars.stream().collect(groupingBy(Car::getBrand,
groupingBy(Car::getColor)));
// 플루언트 방식
Comparator<Person> comparator =
comparing(Person::getAge).thenComparing(Person::getName);
- 셋 이상의 컴포넌트를 조합할 때는 보통 플루언트 형식이 중첩 형식에 비해 가독성이 좋음
- groupingBy 팩터리 메서드에 작업을 위임하는 GroupingBuilder를 만들어 유연한 방식으로 그룹화 작업 가능
import static java.util.stream.Collectors.groupingBy;
public class GroupingBuilder<T, D, K> {
private final Collector<? super T, ?, Map<K, D>> collector;
private GroupingBuilder(Collector<? super T, ?, Map<K, D>> collector) {
this.collector = collector;
}
public Collector<? super T, ?, Map<K, D>> get() {
return collector;
}
public <J> GroupingBuilder<T, Map<K, D>, J>
after(Function<? super T, ? extends J> classifier) {
return new GroupingBuilder<>(groupingBy(classifier, collector));
}
public static <T, D, K> GroupingBuilder<T, List<T>, K>
groupOn(Function<? super T, ? extends K> classifier) {
return new GroupingBuilder<>(groupingBy(classifier));
}
}
- 플루언트 형식 빌더 사용시 중첩된 그룹화 수준에 반대로 그룹화 함수를 구현해야함 -> 직관적이지 못함
Collector<? super Car, ?, Map<Brand, Map<Color, List<Car>>>>
carGroupingCollector =
GroupingBuilder.groupOn(Car::getColor)
.after(Car::getBrand).get();
- 자바의 복잡한 루프 제어와 비교해 유창함을 의미하는 플루언트 스타일의 메서드 체인을 이용하여 DSL을 만든 것
장점
- 주문에 사용한 파라미터가 빌더 내부로 국한된다
- 정적 메서드 사용을 최소화하고 메서드 이름이 인수의 이름을 대신하도록 만듦으로서 DSL 가독성을 개선하는 효과
- 이런 기법을 적용한 플루언트 DSL에는 분법적 잡음이 최소화
단점
- 빌더를 구현해야 한다
- 상위다 수준의 빌더를 하위 수준의 빌더와 연결할 접착 코드가 필요하다
- 도메인 객체 중첩구조와 일치하게 들여쓰기를 강제하는 방법이 없음
- 다른 함수 안에 함수를 이용해 도메인 모델을 만든다
장점
- 메서드 체인에 비해 함수의 중첩 방식이 도메인 객체 계층 구조에 그대로 반영됨
단점
- DSL에 더 많은 괄호를 사용해야 한다
- 인수 목록을 정적 메서드에 넘겨주어야 한다
- 인수의 의미가 이름이 아니라 위치에 의해 정의 됨
- 인수의 역할을 확실하게 만드는 여러 더미메서드(at, on)를 이용
- 람다 표현식으로 정의한 함수 시퀀스를 사용하는 DSL 패턴
장점
- 메서드 체인 패턴처럼 플루언트 방식으로 거래 주문을 정의할 수 있다
- 중첩 함수 형식 처럼 다양한 람다 표현식의 중첩 수준과 비슷하게 계층 구조를 유지
단점
- 많은 설정 코드가 필요하며, 람다 표현식 문법에 의한 잡음의 영향을 받음다
- 세 가지 DSL 패턴을 혼용해 가독성 있는 DSL을 만들 수 있음
- 사용자가 각 DSL 패턴을 배우는데 오랜 시간이 걸린다
- 주문의 총 합에 0개 이상의 세금을 추가해 최종값을 계산하는 기능 추가
double value = calculate(order, true, false, true);
- 불리언 변수의 순서를 기억하기도 어렵고 어떤 세금이 적용되었는지 파악하기 어려움
double value = new TaxCalculator().withTaxRegional()
.withTaxSurcharge()
.calculate(order);
- 도메인의 각 세금에 해당하는 불리언 필드가 필요하므로 확장성이 제한적임
- 자바의 함수형 기능을 이용하여 더 간결하고 유연한 방식으로 리팩터링
double value = new TaxCalculator().with(Tax::regional)
.with(Tax::surcharge)
.calculate(order);
- DSL 패턴의 장점과 단점
- SQL을 구현하는 내부적 DSL, 자바에 직접 내장된 형식 안전 언어
- 동작 주도 개발(BDD, Behavior-driven development) 프레임워크
- 개발자가 비즈니스 시나리오를 평문 영어로 구현할 수 있도록 도와주는 BDD 도구
- 전제 조건 정의(Given), 시험하려는 도메인 객체의 실질 호출(When), 테스트 케이스의 결과를 확인하는 assertion(Then)
- 엔터프라이즈 통합패턴을 지원할 수 있도록 의존성 주입에 기반한 스프링 프로그래밍 모델을 확장
- 스프링 통합의 핵심 목표는 복잡한 엔터프라이즈 통합 솔루션을 구현하는 단순한 모델을 제공하고 비동기, 메시지 주도 아키텍처를 쉽게 적용하도록 돕는 것
- DSL의 주요 기능은 개발자와 도메인 전문가 사이의 간격을 좁히는 것
- DSL은 내부적 DSL과 외부적 DSL로 분류할 수 있다
- JVM에서 이용할 수 있는 스칼라, 그루비 등의 다른 언어로 다중 DSL을 개발할 수 있다
- 자바는 자바의 장황함과 문법적 엄격함 때문에 내부 DSL 개발 언어로 적합하지 않았으나 자바 8의 람다 표현식과 메서드 참조 덕분에 상황이 많이 개선됨