CHAP10 - Modern-Java-in-Action/Online-Study GitHub Wiki

Chapter 10 람다를 이용한 도메인 전용 언어

이장의 내용

  • 도메인 전용 언어(Domain-Specific Languages, DSL)란 무엇이며 어떤 형식으로 구성되는가?
  • DSL을 API에 추가할 때의 장단점
  • JVM에서 활용할 수 있는 자바 기반 DSL을 깔끔하게 만드는 대안
  • 최신 자바 인터페이스와 클래스에 적용된 DSL에서 배움
  • 효과적인 자바 기반 DSL을 구현하는 패턴과 기법
  • 이들 패턴을 자바 라이브러리와 도구에서 얼마나 흔히 사용하는가?

프로그램은 사람들이 이해할 수 있도록 작성되어야 하는 것이 중요하며 기기가 실행하는 부분은 부차적일 뿐 - 하롤드 아벨슨

  • 프로그래밍 언어도 결국 언어이며 언어의 주요 목표는 메시지를 명확하고 안정적인 방식으로 전달하는 것
  • 의도가 명확하게 전달되어야 한다.
  • DSL
    • 특정 도메인을 대상으로 만들어진 특수 프로그래밍 언어
    • 애플리케이션의 비즈니스 로직을 표현
    • 도메인 전문가가 비즈니스 관점에서 소프트웨어가 제대로 되었는지 확인할 수 있음
  • 메이븐, 앤트는 빌드 과정을 표현하는 DSL, HTML은 웹페이지의 구조를 정의하도록 특화된 언어

10.1 도메인 전용 언어

도메인 전용 언어 (domain-specific language, DSL)

  • 특정 비즈니스 도메인의 문제를 해결하려고 만든 언어
    • ex) 회계전용 소프트웨어 애플리케이션을 개발 -> 비즈니스 도메인에는 통장 입출금 내역, 계좌 와 같은 개념이 포함
  • 특정 비즈니스 도메인을 인터페이스로 만든 API

DSL의 장점

  • 간결함 : API는 비즈니스 로직을 간편하게 캡슐화하므로 반복을 피할 수 있고 코드를 간결하게 만들 수 있다.
  • 가독성 : 도메인 영역의 용어를 사용하므로 비 도메인 전문가도 코드를 쉽게 이해할 수 있다. 결과적으로 다양한 조직 구성원 간에 코드와 도메인 영역이 공유될 수 있다.
  • 유지보수 : 잘 설계된 DSL로 구현한 코드는 쉽게 유지 보수하고 바꿀 수 있다.
  • 높은 수준의 추상화 : DSL은 도메인과 같은 추상화 수준에서 동작하므로 도메인의 문제와 직접적으로 관련되지 않은 세부 사항을 숨긴다.
  • 집중 : 비즈니스 도메인의 규칙을 표현할 목적으로 설계된 언어이므로 프로그래머가 특정 코드에 집중할 수 있다. 결과적으로 생산성이 좋아진다.
  • 관심사 분리 : 지정된 언어로 비즈니스 로직을 표현함으로 애플리케이션의 인프라구조와 관련된 문제와 독립적으로 비즈니스 관련된 코드에서 집중하기가 용이하다.

DSL의 단점

  • DSL 설계의 어려움 : 간결하게 제한적인 언어에 도메인 지식을 담는 것이 쉬운 작업은 아니다.
  • 개발 비용 : 코드에 DSL을 추가하는 작업은 초기 프로젝트에 많은 비용과 시간이 소모된다. 또한 DSL 유지보수와 변경은 프로젝트에 부담을 주는 요소다.
  • 추가 우회 계층 : DSL은 추가적인 계층으로 도메인 모델을 감싸며 이때 계층을 최대한 작게 만들어 성능 문제를 회피한다.
  • 새로 배워야 하는 언어 : DSL을 프로젝트에 추가하면서 팀이 배워야 하는 언어가 한 개 더 늘어난다는 부담이 있다.
  • 호스팅 언어 한계 : 일부 자바 같은 범용 프로그래밍 언어는 장황하고 엄격한 문법을 가졌다. 이런 언어로는 사용자 친화적 DSL을 만들기가 힘들다.

10.1.2 JVM에서 이용할 수 있는 다른 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을 쉽게 합칠수 있다.

다중 DSL

장점

  • 같은 자바 바이트코드를 사용하는 JVM 기반 프로그래밍 언어를 이용하여 DSL을 만들 수 있다.
  • 문법적 잡음이 없으며 개발자가 아닌 사람도 코드를 쉽게 이해할 수 있다.
  • 자바 언어가 가지는 한계를 넘을 수 있다 (스칼라 - 커링, 임의 변환 등 DSL 개발에 필요한 여러 특성을 갖춤)

단점

  • 누군가가 해당 언어에 대해 고급 기술을 사용할 수 있을 정도의 충분한 지식을 가지고 있어야 한다.
  • 두 개 이상의 언어가 혼재하므로 여러 컴파일러로 소스를 빌드하도록 빌드 과정을 개선해야 한다.
  • 호환성 문제를 고려해야한다

외부 DSL

  • 자신만의 문법과 구문으로 새로운 언어를 설계해야 한다는 단점
  • 우리에게 필요한 특성을 완벽하게 제공하는 언어를 설계할 수 있다는 장점

10.2 최신 자바 API의 작은 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이 코드의 가독성, 재사용성, 결합성을 높일수 있는지 보여준다.

10.2.1 스트림 API는 컬렉션을 조작하는 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의 또 다른 특징
    • 모든 중간 연산은 게으르며 다른 연산으로 파이프라인될 수 있는 스트림으로 반환
    • 최종 연산은 적극적이며 전체 파이프라인이 계산을 일으킨다.

10.2.2 데이터를 수집하는 DSL인 Collectors

  • 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();

10.3 자바로 DSL을 만드는 패턴과 기법

10.3.1 메서드 체인

  • 자바의 복잡한 루프 제어와 비교해 유창함을 의미하는 플루언트 스타일의 메서드 체인을 이용하여 DSL을 만든 것

장점

  • 주문에 사용한 파라미터가 빌더 내부로 국한된다
  • 정적 메서드 사용을 최소화하고 메서드 이름이 인수의 이름을 대신하도록 만듦으로서 DSL 가독성을 개선하는 효과
  • 이런 기법을 적용한 플루언트 DSL에는 분법적 잡음이 최소화

단점

  • 빌더를 구현해야 한다
  • 상위다 수준의 빌더를 하위 수준의 빌더와 연결할 접착 코드가 필요하다
  • 도메인 객체 중첩구조와 일치하게 들여쓰기를 강제하는 방법이 없음

10.3.2 중첩된 함수 이용

  • 다른 함수 안에 함수를 이용해 도메인 모델을 만든다

장점

  • 메서드 체인에 비해 함수의 중첩 방식이 도메인 객체 계층 구조에 그대로 반영됨

단점

  • DSL에 더 많은 괄호를 사용해야 한다
  • 인수 목록을 정적 메서드에 넘겨주어야 한다
  • 인수의 의미가 이름이 아니라 위치에 의해 정의 됨
    • 인수의 역할을 확실하게 만드는 여러 더미메서드(at, on)를 이용

10.3.3 람다 표현식을 이용한 함수 시퀀싱

  • 람다 표현식으로 정의한 함수 시퀀스를 사용하는 DSL 패턴

장점

  • 메서드 체인 패턴처럼 플루언트 방식으로 거래 주문을 정의할 수 있다
  • 중첩 함수 형식 처럼 다양한 람다 표현식의 중첩 수준과 비슷하게 계층 구조를 유지

단점

  • 많은 설정 코드가 필요하며, 람다 표현식 문법에 의한 잡음의 영향을 받음다

10.3.4 조합하기

  • 세 가지 DSL 패턴을 혼용해 가독성 있는 DSL을 만들 수 있음
  • 사용자가 각 DSL 패턴을 배우는데 오랜 시간이 걸린다

10.3.5 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);

10.4 실생활의 자바8 DSL

  • DSL 패턴의 장점과 단점

10.4.1 jOOQ(java Object Oriented Querying)

  • SQL을 구현하는 내부적 DSL, 자바에 직접 내장된 형식 안전 언어

10.4.2 큐컴버(Cucumber)

  • 동작 주도 개발(BDD, Behavior-driven development) 프레임워크
  • 개발자가 비즈니스 시나리오를 평문 영어로 구현할 수 있도록 도와주는 BDD 도구
  • 전제 조건 정의(Given), 시험하려는 도메인 객체의 실질 호출(When), 테스트 케이스의 결과를 확인하는 assertion(Then)

10.4.3 스프링 통합

  • 엔터프라이즈 통합패턴을 지원할 수 있도록 의존성 주입에 기반한 스프링 프로그래밍 모델을 확장
  • 스프링 통합의 핵심 목표는 복잡한 엔터프라이즈 통합 솔루션을 구현하는 단순한 모델을 제공하고 비동기, 메시지 주도 아키텍처를 쉽게 적용하도록 돕는 것

10.5 마치며

  • DSL의 주요 기능은 개발자와 도메인 전문가 사이의 간격을 좁히는 것
  • DSL은 내부적 DSL과 외부적 DSL로 분류할 수 있다
  • JVM에서 이용할 수 있는 스칼라, 그루비 등의 다른 언어로 다중 DSL을 개발할 수 있다
  • 자바는 자바의 장황함과 문법적 엄격함 때문에 내부 DSL 개발 언어로 적합하지 않았으나 자바 8의 람다 표현식과 메서드 참조 덕분에 상황이 많이 개선됨
⚠️ **GitHub.com Fallback** ⚠️