10장: 람다를 이용한 DSL

DSL이란

  • Domain Specific Languages라는 뜻으로, 특정 도메인을 대상으로 만들어진 특수 프로그래밍 언어이다.

  • Maven, Ant 언어는 빌드 과정을 표현하는 DSL이다.

  • DSL을 개발할 때에는 코드의 의도가 명확히 드러나 프로그래머가 아닌 사람도 이해할 수 있어야 한다. 또한 가독성 있는 코드를 구현해야 한다.

장점

  • 간결함

    • DSL의 API는 비즈니스 로직을 캡슐화하므로 중복이 줄어들고 코드가 간결해진다.

  • 가독성

    • 도메인 영역의 용어를 사용하므로 비 도메인 전문가도 코드를 쉽게 이해할 수 있다.

  • 유지보수

    • 잘 설계된 DSL로 구현한 코드는 쉽게 유지보수하고 바꿀수 있다. 유지보수는 비즈니스 관련 코드, 즉 가장 빈번히 바뀌는 애플리케이션 부분에 특히 중요하다.

  • 높은 수준의 추상화

    • DSL은 도메인과 같은 추상화 수준에서 동작하므로 도메인의 문제와 직접적으로 관련되지 않은 세부 사항을 숨긴다.

  • 집중

    • 비즈니스 도메인의 규칙을 표현할 목적으로 설계된 언어이므로 프로그래머가 특정 코드에 집중할 수 있어 생산성이 높아진다.

  • 관심사 분리

    • 지정된 언어로 비즈니스 로직을 표한함으로 애플리케이션의 인프라 구조와 관련된 문제와 관련 없이 비즈니스 관련된 코드에 집중하기가 용이하다.

단점

  • DSL 설계의 어려움

    • 간결하게 제한적인 언어에 도메인 지식을 담는 것이 쉬운 작업은 아니다.

  • 개발 비용

    • 코드에 DSL을 추가하는 작업은 초기 프로젝트에 많은 비용과 시간이 소모되는 작업이다. 또한 DSL 유지보수와 변경은 프로젝트에 부담을 주는 요소이다.

  • 추가 우회 계층

    • DSL은 추가적인 계층으로 도메인 모델을 감싸며 이 때 계층을 최대한 작게 만들어야 성능 문제를 회피할 수 있다.

  • 새로 배워야 하는 언어

    • 요즘 추세는 하나의 프로젝트에도 여러가지 언어를 사용한다. DSL을 프로젝트에 추가하며 배워야 하는 언어가 늘어난다는 부담이 생긴다.

  • 호스팅 언어 한계

    • 자바 같은 언어는 장황하고 엄격한 문법을 가졌다. 따라서 사용자 친화적인 DSL을 만들기가 힘들다. Java 8의 람다식은 이 문제를 해결할 수 있다.

내부 DSL, 외부 DSL

  • 내부 DSL

    • 임베디드 DSL이라고 불리며 순수 자바 코드같은 기존 호스팅 언어를 기반으로 구현한다.

    • 기존 언어를 사용하기 때문에 새로 학습해야 할 내용이 줄어든다.

    • 다른 언어의 컴파일러를 이용하거나 외부 DSL 도구가 필요하지 않아 비용이 절감된다.

    • 언어가 같으므로 기존 IDE의 자동 완성, 리팩토링 등의 기능을 사용할 수 있다.

    • 한개의 언어로 하나 또는 여러 도메인을 대응하지 못해 추가 DSL을 개발해야 하는 상황에서 자바를 이용하면, 추가 DSL을 쉽게 합칠수 있다.

  • 외부 DSL

    • standalone이라 불리는 외부 DSL은 호스팅 언어와 독립적으로 자체적인 문법을 가진다.

    • 새 언어를 파싱하고, 파서의 결과를 분석하고, 외부 DSL을 실행할 코드를 만들어야 하므로 복잡하다.

    • 무한한 유연성을 가지므로 필요한 특성을 완벽하게 제공하는 언어를 설계할 수 있는 장점이 있다.

  • 다중 DSL

    • 자바가 아닌 언어지만 JVM에서 실행되는 Scala, Groovy 등의 언어가 이에 해당한다.

    • 새로운 프로그래밍 언어를 배우거나 또는 팀의 누군가가 리딩 할 수 있어야 한다.

    • 두 개 이상의 언어가 혼재하므로 여러 컴파일러로 소스를 빌드 하도록 빌드 과정의 개선이 필요하다.

    • JVM에서 실행되는 거의 모든 언어가 자바와 호환된다고 하지만 완벽히 호환되지 않는 경우가 존재한다.

자바 내부의 DSL

정렬

  • 기존에 람다가 없었을 때에는 객체 컬렉션을 정렬할 때 아래와 같이 내부 클래스를 구현해야 했다.

Collections.sort(persons, new Comparator<Person>() {
  public int compare(Person p1, Person p2) {
    // ...
  }
});
  • 이를 람다를 사용하면 아래와 같이 간결해진다.

Collections.sort(persons, (p1, p2) -> p1.getAge() - p2.getAge())
  • 메서드 참조와 Comparator.comparing 메서드를 static import 하면 더욱 간결해진다.

Collections.sort(persons, comparing(Person::getAge).thenComparing(Person::getName))

// 역순 정렬도 가능
Collections.sort(persons, comparing(Person::getAge).reverse())

스트림 API

  • 컬렉션의 항목을 필터, 정렬, 그룹화, 조작하는 작은 내부 DSL이다.

  • 파일을 읽어 문자열 스트림을 만들고, ERROR로 시작하는 라인만 40줄 추출하는 요구사항을 간결하게 아래와 같이 작성할 수 있다.

List<String> errors = Files.lines(Paths.get(filename))
                           .filter(line -> line.startsWith("ERROR"))
                           .limit(40)
                           .collect(toList());

Collectors

  • 데이터 수집을 수행하는 DSL이다.

  • 다중 수준의 그룹화를 아래와 같이 안쪽 그룹화부터 바깥쪽 그룹화까지 이뤄지도록 한다.

Map<String, Map<Color, List<Car>>> groupedCars = cars.stream()
    .collect(groupingBy(Car::getBrand, groupingBy(Car:getColor)));

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

메서드 체인

  • 자바에서는 아래와 같이 한 개의 메서드 호출 체인으로 주식 거래 주문을 정의할 수 있다.

Order order = forCustomer("Alice")
    .buy(80)
    .stock("IBM")
    .on("NYSE")
    .at(125.00)
    .sell(50)
    .stock("GOOGLE")
    .on("NASDAQ")
    .at(375.00)
    .end();
  • 메서드 체인 방식을 구현하기 위해서는 buy, sell 메서드의 반환 객체가 다음 메서드에서 어떤 주식을 구매할 지 선택하는 메서드가 존재하는 빌더 객체여야 한다.

  • 마찬가지로 stock 메서드의 반환 객체는 어느 주식의 시장에 속하는 주식인지 선택하는 메서드가 존재하는 빌더 객체가 필요하다.

중첩된 함수 이용

  • 메서드 체인에 비해 함수의 중첩 방식이 도메인 객체 계층 구조에 그대로 반영되는 장점이 있다.

  • 결과 DSL에 더 많은 괄호를 사용해야 하고 인수 목록을 정적 메서드에 넘겨주어야 하는 단점이 있다.

Order order = order("BigBank", 
                    buy(80, stock("IBM", on("NYSE")), at(125.00)),
                    sell(50, stock(...))
                    );
  • 중첩된 함수 방식을 구현하기 위해서는 하나의 클래스에 여러 정적 메서드들을 두고 order 메서드는 여러 Trade 객체를 입력받을 수 있도록 하고, buy 메서드는 구매 수량, 주식 정보, 가격 등을 입력받도록 해야 한다.

  • 다시 stock 메서드는 주식 이름과 시장 이름을 입력받아야 한다.

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

  • 메서드 체인 패턴처럼 플루언트 방식으로 정의할 수 있고, 중첩 함수 형식처럼 도메인 객체의 계층 구조를 유지한다.

  • 자바 8 람다 표현식에 따른 잡음의 영향을 받는다.

  • 람다 표현식을 받아 도메인 모델을 만들어내는 여러 빌더를 구현해야 한다.

  • 아래는 order, buy, sell 메서드에서 Consumer 타입의 함수형 인터페이스 구현체를 입력받아 빌더 객체 내부 변수 자체를 변경하도록 하여 Order 객체를 생성해낸다.

Order order = order(o -> {
  o.forCustomer(...);
  o.buy(t -> {
    t.quantity(80);
    t.price(132);
    ...
  });
  o.sell(t -> {
    t.quantity(2);
    t.price(12);
    ...
  })
})

조합하기

  • 앞서 다룬 각 방식들을 조합하여 사용할 수도 있다.

Order order = forCustomer("Alice", buy(t ->
    t.quantity(80)
     .stock("IBM")),
  sell(t -> 
    t.quantity(2)
     .price(12)
    ...
  ));

DSL 개발의 장단점

Last updated