devseop08 님의 블로그

[API] 람다와 스트림: Mordern Java - 10. 람다를 이용한 도메인 전용 언어 본문

Language/Java

[API] 람다와 스트림: Mordern Java - 10. 람다를 이용한 도메인 전용 언어

devseop08 2025. 7. 13. 03:38
  • 프로그래밍 언어의 주요 목표는 메시지를 명확하고, 안정적인 방식으로 전달하는 것
  • 무엇보다 의도가 명확하게 전달돼야 한다.
  • 개발팀과 도메인 전문가가 공유하고 이해할 수 있는 코드는 생산성과 직결
  • 도메인 전용 언어로 애플리케이션의 비즈니스 로직을 표현함으로 이 문제를 해결 가능
  • DSL은 범용이 아니라 특정 도메인을 대상으로 만들어진 특수 프로그래밍 언어다.
  • Maven, Gradle, Ant => 빌드 과정을 표현하는 DSL
  • HTML은 웹 페이지 구조를 정의하도록 특화된 언어
  • SQL 언어같은 종류의 DSL을 외부적이라 하는데, 이는 데이터베이스가 텍스트로 구현된 SQL 표현식을 파싱하고 평가하는 API를 제공하는 것이 일반적이기 때문이다.
menu.stream()
    .filter(d -> d.getCalories() < 400)
    .map(Dish::getName)
    .forEach(System.out::println)
  • 위 예제의 DSL은 외부적이 아니라 내부적이다.
  • 내부적 DSL에서는 SQL 구문처럼 애플리케이션 수준의 기본값이 자바 메서드가 사용할 수 있도록 데이터베이스를 대표하는 한 개 이상의 클래스 형식으로 노출된다.
  • 내부적 DSL에서는 유창하게 코드를 구현할 수 있도록 적절하게 클래스와 메서드를 노출하는 과정이 필요하다.
  • 외부 DSL은 DSL 문법 뿐 아니라 DSL을 평가하는 파서도 구현해야 한다.
  • 10.1 도메인 전용 언어
  • 자바에서는 DSL을 만들기 위해 도메인을 표현할 수 있는 클래스와 메서드 집합이 필요하다.
  • DSL이란 특정 비즈니스 도메인을 인터페이스로 만든 API라고 생각할 수 있다.
  • DSL은 범용 프로그래밍 언어가 아니다.
  • 도메인 전문가가 저수준 비즈니스 로직을 구현하도록 만드는 것은 DSL의 역할이 아니다.
  • DSL 만들 시 고려 사항
      1. 코드의 의도가 명확히 전달, 프로그래머가 아닌 사람도 이해할 수 있어야 한다.
      1. 가독성, 동료가 쉽게 이해할 수 있도록 코드를 구현해야 한다.

10.1.1 DSL의 장점과 단점

  • DSL 장점
    • 간결함
    • 가독성
    • 유지보수
    • 높은 수준의 추상화
    • 집중
    • 관심사 분리
  • DSL 단점
    • DSL 설계의 어려움
    • 개발 비용
    • 추가 우회 계층: DSL은 추가적인 계층으로 도메인 모델을 감싸며 이 때 계층을 최대한 작게 만들어 성능 문제를 회피해야 한다.
    • 새로 배워야 하는 언어
    • 호스팅 언어 한계 : 일부 자바 같은 범용 프로그래밍 언어로는 사용자 친화적인 DSL을 만들기가 힘들다.

10.1.2 JVM에서 이용할 수 있는 다른 DSL 해결책

  • DSL 구분
      1. 내부 DSL: 순수 자바 코드와 같은 기존 호스팅 언어를 기반으로 구현
      1. 외부 DSL: 호스팅 언어와는 독립적으로 자체 문법을 갖는다.
      1. 다중 DSL: JVM으로 인해 내부 DSL과 외부 DSL의 중간 카테고리에 해당하는 DSL, 스칼라나 그루비처럼 자바가 아니지만 JVM에서 실행되며 더 유연하고 표현력이 강력한 언어가 있다.

내부 DSL

  • 자바로 구현한 DSL

  • 람다를 적극적으로 활용하면 익명 내부 클래스를 사용해 DSL을 구현하는 것보다 장황함을 크게 줄여 신호 대비 잡을 비율을 적정 수준으로 유지하는 DSL을 만들 수 있다.

  • 순수 자바로 DSL을 구현함으로써 얻을 수 있는 장점

    • 기존 자바 언어를 이용하면 외부 DSL에 비해 새로운 패턴과 기술을 배워 DSL을 구현하는 노력이 현저하게 줄어든다.
    • 순수 자바로 DSL을 구현하면 나머지 코드와 함께 DSL을 컴파일할 수 있다. 외부 DSL을 만드는 도구를 사용할 필요가 없으므로 추가 비용이 들지 않는다.
    • 새로운 언어를 배우거나 또는 익숙하지 않고 복잡한 외부 도구를 배울 필요가 없다.
    • DSL 사용자는 기존의 자바 IDE를 이용해 자동 완성, 자동 리팩터링 같은 기능을 그대로 즐길 수 있다.
    • 한 개의 언어로 한 개의 도메인 또는 여러 도메인을 대응하지 못해 추가로 DSL을 개발해야 하는 상황에서 자바를 이용한다면 추가 DSL을 쉽게 합칠 수 있다.

      다중 DSL

  • 자바 외의 JVM에서 실행되는 언어들

  • 스칼라, 루비, 그루비, 코틀린, ,실론

  • DSL은 기반 프로그래밍언의 영향을 받으므로 간결한 DSL을 만드는 데 새로운 언어의 특성들이 아주 중요하다.

  • 스칼라는 커링, 임의 변환 등 DSL 개발에 필요한 여러 특성을 갖췄다.

  • DSL에 친화적인 다른 JVM 언어로 DSL을 구현하는 경우의 불현함

    • 새로운 프로그래밍 언어 습득 필요
    • 두 개 이상의 언어가 혼재하므로 여러 컴파일러로 소스를 빌드하도록 빌드 과정 개선 필요
    • JVM에서 실행되는 언어가 자바와 호환성이 완벽하지 않을 때가 많다.

      외부 DSL

  • 자신만의 문법과 구문으로 새 언어를 설계해야 한다.

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

  • 외부 DSL을 개발하는 가장 큰 장점은 외부 DSL이 제공하는 무한한 유연성이다.

  • 제대로 언어를 설계하면 비즈니스 문제를 묘사하고 해결하는 가독성 좋은 언어를 얻을 수 있다.

10.2 최신 자바 API의 작은 DSL

  • 자바의 새로운 기능의 장점을 적용한 첫 API는 네이티브 자바 API 자신이다.

  • 자바 8 이전의 네이티브 자바 API는 이미 한 개의 추상 메서드를 갖는 인터페이스를 갖고 있었지만 익명 내부 클래스를 구현하려면 불필요한 코드가 추가돼야 한다.

  • 람다와 메서드 참조가 등장하면서 전혀 다른 패러다임으로 전환되었다.(특히 DSL 관점에서 말이다.)

  • 자바 8의 Comparator 인터페이스에 새 메서드 추가되었는데, 인터페이스가 정적 메서드와 디폴트 메서드를 가질 수 있게 됐다.

  • 자바 8 이전 Comparator 인터페이스 사용: 익명 객체 생성하여 사용

Collections.sort(persons, new Comparator<Person>(){
    public int compare(Person p1, Person p2){
        return p1.getAge() - p2.getAge();
    }
});
  • 자바 8 이후 람다를 이용한 Comparator 인터페이스 사용
Collections.sort(persons, (p1, p2) -> p1.getAge() - p2.getAge());
  • Comparator 인터페이스의 정적 유틸리티 메서드를 이용해 Comparator 타입 객체 생성
  • static import Comparator.comparing
Collections.sort(persons, comparing(p -> p.getAge()));
  • 메서드 참조 이용
Collections.sort(persons, comparing(Person::getAge));
  • Comparator 타입 객체에 추가 메서드 적용
Collections.sort(persons, comparing(Person::getAge).reverse());
Collections.sort(persons, comparing(Person::getAge)
                            .thenComparing(Person::getName));
persons.sort(comparing(Person::getAge).thenComparing(Person::getName))

10.2.1 스트림 API는 컬렉션을 조작하는 DSL

  • Stream 인터페이스는 네이티브 자바 API에 작은 DSL을 적용한 좋은 예

  • Stream은 컬렉션의 항목을 필터, 정렬, 변환, 그룹화, 조작하는 작지만 강력한 DSL

  • 스트림 API의 플루언트 형식은 잘 설계된 DSL의 또 다른 특징이다.

    • 모든 중간 연산은 게으르며 다른 연산으로 파이프라인될 수 있는 스트림으로 반환된다.
    • 최종 연산은 적극적이며 전체 파이프라인이 계산을 일으킨다.

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

  • Stream 인터페이스를 데이터 리스트를 조작하는 DSL로 간주할 수 있음을 확인

  • Collector 인터페이스는 데이터 수집을 수행하는 DSL로 간주 가능

  • Collectors 클래스에서 제공하는 정적 팩토리 메서드를 이용해 필요한 Collector 타입의 객체를 만들고 합칠 수 있다.

  • Comparator 인터페이스는 다중 필드 정렬을 지원하도록 합쳐질 수 있으며, Collector는 다중 수준 그룹화를 달성할 수 있도록 합쳐질 수 있다.

  • Comparator를 플루언트 방식으로 연결해서 다중 필드 Comparator를 선언

Comparator<Person> comparator =
    comparing(Person::getAge).thenComparing(Person::getName);
  • Collectors API를 이용해 Collector를 중첩함으로써 다중 수준 Collector를 만들 수 있다.
Collector<? super Car, ?, Map<Brand, Map<Color, List<Car>>>> carGroupCollector
    = groupingBu(Car::getBrand, groupingBy(Car::getColor));
  • 보통 셋 이상의 컴포넌트를 조합할 때는 보통 플루언트 형식이 중첩 형식에 비해 가독성이 좋다.
  • 플루언트 형식으로 Collector를 연결하지 않고 Collector 생성을 Collectors 클래스의 여러 정적 메서드로 중첩함으로써 안쪽 그룹화가 처음 평가되고 코드에서는 반대로 가장 나중에 등장하게 된다.

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

  • 주식 거래 도메인 전용 언어로 주식 거래 도메인 모델에 사용할 API를 만들어보자.

  • 먼저 주식거래 도메인 모델을 정의하자

  • 주식 거래 도메인 모델은 세 가지로 구성: 주식(Stock), 거래(Trade), 주문(Order)

  • 주식 종목을 모델링하는 순수 자바 빈즈 Stock

public class Stock {
    private String symbol;
    private String market;

    public String getSymbol() {
        return symbol;
    }

    public void setSymbol(String symbol) {
        this.symbol = symbol;
    }

    public String getMarket() {
        return market;
    }

    public void setMarket(String market) {
        this.market = market;
    }
}
  • 주어진 주식 가격에서 주어진 양의 주식을 사거나 파는 거래를 모델링하는 순수 자바 빈즈 Trade
public class Trade {
    public enum Type { BUY, SELL }
    private Type type;

    private Stock stock;
    private int quantity;
    private double price;

    public Type getType() {
        return type;
    }

    public void setType(Type type) {
        this.type = type;
    }

    public int getQuantity() {
        return quantity;
    }

    public void setQuantity(int quantity) {
        this.quantity = quantity;
    }

    public double getPrice() {
        return price;
    }

    public void setPrice(double price) {
        this.price = price;
    }

    public Stock getStock() {
        return stock;
    }

    public void setStock(Stock stock) {
        this.stock = stock;
    }

    public double getValue() {
        return quantity * price;
    }
}
  • 고객이 요청한 한 개 이상의 거래를 담은 주문을 모델링하는 순수 자바 빈즈 Order
public class Order {
    private String customer;
    private List<Trade> trades = new ArrayList<>();

    public void addTrade(Trade trade) {
        trades.add(trade);
    }

    public String getCustomer() {
        return customer;
    }

    public void setCustomer(String customer) {
        this.customer = customer;
    }

    public double getValue() {
        return trades.stream().mapToDouble(Trade::getValue).sum();
    }
}
  • 도메인 객체의 API를 직접 이용해 주식 거래 주문을 만든다
Order order = new Order();
order.setCustomer("BigBank");


Trade trade1 = new Trade();
trade1.setType(Trade.Type.BUY);

Stock stock1 = new Stock();
stock1.setSymbol("IBM");
stock1.setMarket("NYSE");

trade1.setStock(stock1);
trade1.setPrice(125.00);
trade1.setQuantity(80);
order.addTrade(trade1);

Trade trade2 = new Trade();
trade2.setType(Trade.Type.BUY);

Stock stock2 = new Stock();
stock2.setSymbol("GOOGLE");
stock2.setMarket("NASDAQ");

trade1.setStock(stock2);
trade1.setPrice(375.00);
trade1.setQuantity(50);
order.addTrade(trade2);

10.3.1 메서드 체인

  • 메서드 체인으로 주식 거래 주문 만들기
Order order = forCustomer("BigBank")
                .buy(80)
                .stock("IBM")
                .on("NYSE")
                .at(125.00)
                .sell(50)
                .stock("GOOGLE")
                .on("NASDAQ")
                .at(375.00)
                .end();
  • 위의 메서드 체인이 동작하게 하기 위해선 플루언트 API로 객체를 만드는 몇 개의 빌더를 만들어야 한다.
  • 메서드 체인 DSL을 제공하는 주문 빌더
public class MethodChainingOrderBuilder {  
    public final Order order = new Order();  

    private MethodChainingOrderBuilder(String customer) {  
        order.setCustomer(customer);  
    }  

    public static MethodChainingOrderBuilder forCustomer(String customer) {  
        return new MethodChainingOrderBuilder(customer);  
    }  

    public TradeBuilder buy(int quantity) {  
        return new TradeBuilder(this, Trade.Type.BUY, quantity);  
    }  

    public MethodChainingOrderBuilder addTrade(Trade trade) {  
        order.addTrade(trade);  
        return this;  

    }  

    public TradeBuilder sell(int quantity) {  
        return new TradeBuilder(this,Trade.Type.SELL, quantity);  
    }  

    public Order end() {  
        return order;  
    }  
}
  • 거래 빌더
public class TradeBuilder {  
    private final MethodChainingOrderBuilder builder;  
    public Trade trade = new Trade();  

    public TradeBuilder(MethodChainingOrderBuilder builder, 
                        Trade.Type type, int quantity){  
        this.builder = builder;  
        trade.setType(type);  
        trade.setQuantity(quantity);  
    }  

    public StockBuilder stock(String symbol) {  
        return  new StockBuilder(builder, trade, symbol);  
    }  
}
  • 주식 빌더
public class StockBuilder {  
    private final MethodChainingOrderBuilder builder;  
    private final Trade trade;  
    private final Stock stock = new Stock();  


    public StockBuilder(MethodChainingOrderBuilder builder, 
                        Trade trade, String symbol) {  
        this.builder = builder;  
        this.trade = trade;  
        stock.setSymbol(symbol);  
    }  

    public TradeBuilderWithStock on(String market) {  
        stock.setMarket(market);  
        trade.setStock(stock);  
        return new TradeBuilderWithStock(builder, trade);  
    }  
}
  • 실제 주식 거래 빌더
public class TradeBuilderWithStock {  
    private MethodChainingOrderBuilder builder;  
    private final Trade trade;  

    public TradeBuilderWithStock(MethodChainingOrderBuilder builder, Trade trade){  
        this.builder = builder;  
        this.trade = trade;  
    }  

    public MethodChainingOrderBuilder at(double price) {  
        trade.setPrice(price);  
        return builder.addTrade(trade);  
    }  
}
  • 코드에서 볼 수 있듯이 MethodChainingOrderBuilder가 끝날 때까지 다른 거래를 플루언트 방식으로 추가할 수 있다.

  • 여러 빌드 클래스 특히 거래 빌더를 따로 만듦으로써 사용자가 미리 지정된 절차에 따라 플루언트 API의 메서드를 호출하도록 강제한다.

  • 이 접근 방법은 정적 메서드 사용을 최소화하고 메서드 이름이 인수의 이름을 대신하도록 만듦으로 이런 형식의 DSL의 가독성을 개선하는 효과를 더한다.

  • 이런 기법을 적용한 플루언트 API에는 분법적 잡음이 최소화된다.

  • 메서드 체인을 이용한 DSL 패턴은 빌더를 구현해야 한다는 것이 단점이다.

  • 상위 수준의 빌더를 하위 수준의 빌더와 연결할 많은 접착 코드가 필요하다.(결합도가 높다)

  • 도메인의 객체 중첩 구조와 일치하게 들여쓰기를 강제하는 방법이 없다는 것도 단점이다.

10.3.2 중첩된 함수 이용

  • 중첩된 함수 DSL 패턴은 다른 함수 안에 함수를 이용해 도메인 모델을 만든다.
Order anotherOrder = order("BigBank",  
                            buy(80, stock("IBM", on("NYSE")), at(125.00)),  
                            sell(50, stock("GOOGLE", on("NASDAQ")), at(375.00))  
                            );
  • 중첩된 함수 DSL을 제공하는 주문 빌더
public class NestedFunctionOrderBuilder {  
    public static Order order(String customer, Trade... trades){  
        Order order = new Order();  
        order.setCustomer(customer);  
        Stream.of(trades).forEach(order::addTrade);  
        return order;  
    }  

    public static Trade buy(int quantity, Stock stock, double price){  
        return buildTrade(quantity, stock, price, Trade.Type.BUY);  
    }  

    public static Trade sell(int quantity, Stock stock, double price){  
        return buildTrade(quantity, stock, price, Trade.Type.SELL);  
    }  

    private static Trade buildTrade(int quantity, Stock stock, double price, Trade.Type type) {  
        Trade trade = new Trade();  
        trade.setQuantity(quantity);  
        trade.setStock(stock);  
        trade.setPrice(price);  
        trade.setType(type);  
        return trade;  
    }  

    public static Stock stock(String symbol, String market){  
        Stock stock = new Stock();  
        stock.setMarket(market);  
        stock.setSymbol(symbol);  
        return stock;  
    }  

    public static String on(String market) {  
        return market;  
    }  

    public static double at(double price) {  
        return price;  
    }  
}
  • 메서드 체인에 비해 함수의 중첩 방식이 도메인 객체 계층 구조에 그대로 반영된다는 것이 중첩된 함수 DSL 패턴의 장점이다.

  • 결과 DSL에 더 많은 괄호를 사용해야 한다는 단점이 있다.

  • 더군다나 인수 목록을 정적 메서드에 넘겨줘야 한다는 제약도 있다.

  • 도메인 객체에 선택 사항 필드가 있으면 인수를 생략할 수 있으므로 이 가능성 처리를 위해 여러 메서드 오버라이드를 구현해야 한다.

  • 마지막으로 인수의 의미가 이름이 아니라 위치에 의해 정의되었다.

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

Order order = order( o-> {  
    o.forCustomer("BigBank");  
    o.buy( t -> {  
        t.quantity(80);  
        t.price(125.00);  
        t.stock( s -> {  
           s.symbol("IBM");  
           s.market("NYSE");  
        });  
    });  
    o.sell( t-> {  
        t.quantity(50);  
        t.price(3755.00);  
        t.stock( s -> {  
            s.symbol("GOOGLE");  
            s.market("NASDAQ");  
        });  
    });  
});
  • 함수 시퀀싱 DSL을 제공하는 주문 빌더
public class LamdaOrderBuilder {  
    private Order order = new Order();  

    public static Order order(Consumer<LamdaOrderBuilder> consumer){  
        LamdaOrderBuilder builder = new LamdaOrderBuilder();  
        consumer.accept(builder);  
        return builder.order;  
    }  

    public void forCustomer(String bigBank) {  
        order.setCustomer(bigBank);  
    }  

    public void buy(Consumer<TradeBuilder> consumer) {  
       trade(consumer, Trade.Type.BUY);  
    }  

    public void sell(Consumer<TradeBuilder> consumer) {  
        trade(consumer, Trade.Type.SELL);  
    }  

    private void trade(Consumer<TradeBuilder> consumer, Trade.Type type) {  
        TradeBuilder builder = new TradeBuilder();  
        builder.trade.setType(type);  
        consumer.accept(builder);  
        order.addTrade(builder.trade);  
    }  

}
public class TradeBuilder {  
    public Trade trade = new Trade();  

    public void quantity(int quantity) {  
        trade.setQuantity(quantity);  
    }  

    public void price(double price) {  
        trade.setPrice(price);  
    }  

    public void stock(Consumer<StockBuilder> consumer) {  
        StockBuilder builder = new StockBuilder(trade);  
        consumer.accept(builder);  
    }  
}
public class StockBuilder {  
    private final Trade trade;  
    private final Stock stock = new Stock();  


    public StockBuilder(Trade trade) {  
        this.trade = trade;  
    }  

    public void symbol(String symbol) {  
        stock.setSymbol(symbol);  
    }  

    public void market(String market) {  
        stock.setMarket(market);  
    }  
}
  • 이 패턴은 많은 설정 코드가 필요하다며 DSL 자체가 자바 8 람다 표현식 문법에 의한 잡음의 영향을 받는다는 단점이 있다.

    10.3.4 조합하기

  • 여러 DSL 패턴을 이용해 주식 거래 주문 만들기

Order order = forCustomer(  
  "BigBank",  
  buy(t -> t.quantity(80).stock("IBM").on("NYSE").at(125.00)),  
  sell(t -> t.quantity(50).stock("GOOGLE").on("NASDAQ").at(375.00))        
);
  • 위의 주식 거래 주문은 중첩된 함수 패턴을 람다 기법과 혼용했다.
  • TradeBuilder의 Consumer가 만든 각 거래는 람다 표현식으로 구현된다.
  • 여러 형식을 혼합한 DSL을 제공하는 주문 빌더
public class MixedBuilder {  
    public static Order forCustomer(String customer, TradeBuilder... builders) {  
        Order order = new Order();  
        order.setCustomer(customer);  
        Stream.of(builders).forEach(b -> order.addTrade(b.trade));  
        return order;  
    }  

    public static TradeBuilder buy(Consumer<TradeBuilder> consumer){  
        return buildTrade(consumer, Trade.Type.BUY);  
    }  

    public static TradeBuilder sell(Consumer<TradeBuilder> consumer){  
        return buildTrade(consumer, Trade.Type.SELL);  
    }  


    private static TradeBuilder buildTrade(Consumer<TradeBuilder> consumer, 
                                           Trade.Type type){  
        TradeBuilder builder = new TradeBuilder();  
        builder.trade.setType(type);  
        consumer.accept(builder);  
        return builder;  
    }  
}
  • 헬퍼 클래스 TradeBuilder와 StockBuilder는 내부적으로 메서드 체인 패턴을 구현해 플루언트 API를 제공한다. 이제 람다 표현식 바디를 구현해 가장 간단하게 거래를 구현할 수 있다.
public class TradeBuilder {  
    public Trade trade = new Trade();  

    public TradeBuilder quantity(int quantity) {  
        trade.setQuantity(quantity);  
        return this;  
    }  

    public TradeBuilder price(double price) {  
        trade.setPrice(price);  
        return this;  
    }  

    public StockBuilder stock(String symbol) {  
        return new StockBuilder(this, trade, symbol);  
    }  

    public void at(double price) {  
        trade.setPrice(price);  
    }  
}
public class StockBuilder {  
            private final TradeBuilder builder;  
            private final Trade trade;  
            private final Stock stock = new Stock();  


            public StockBuilder(TradeBuilder builder, Trade trade, 
                                String symbol) {  
                this.builder = builder;  
                this.trade = trade;  
                stock.setSymbol(symbol);  
            }  

            public void symbol(String symbol) {  
                stock.setSymbol(symbol);  
            }  

            public void market(String market) {  
                stock.setMarket(market);  
            }  

            public TradeBuilder on(String market) {  
                stock.setMarket(market);  
                trade.setStock(stock);  
                return builder;  
    }  
}

10.3.5 DSL에 메서드 참조 사용하기

  • 주식 거래 도메인 모델에 다른 간단한 기능을 추가해본다.
  • 주문의 총 거래 가격에 0개 이상의 세금 정책을 적용해 최종값을 계산하는 기능을 추가해본다.
public class Tax {
    public static double regional(double value) {
        return value * 1.1;
    }

    public static double general(doublie value) {
        return value * 1.3;
    }

    public static double surcharge(double value) {
        return value * 1.05;
    }
} 
public static double calculate(Order order, boolean useRegional, 
                               boolean useGeneral, boolean useSurcharge) {
    double value = order.getValue();
    if(useRegional) value = Tax.regional(value);
    if(useGeneral) value = Tax.general(value);
    if(useSurcharge) value = Tax.surcharge(value);
    return value;
}
double value = calculate(order, true, false, true)
  • 이 구현은 가독성이 좋지 못하다. 불리언 변수의 올바른 순서를 기억하기도 어렵고 어떤 세금이 적용되었는지도 파악하기 어렵다.
  • 플루언트 API를 이용해 불리언 플래그를 설정하는 최소 DSL을 제공하는 TaxCalculator로 주문의 총 거래 가격을 구하도록 하는 것이 더 좋은 방법이다.
public class TaxCalculator {
    private boolean useRegional; 
    private boolean useGeneral; 
    private boolean useSurcharge;

    public TaxCalculator withRegional() {
        useRegional = true;
        return this;
    }

    public TaxCalculator withGeneral() {
        useGeneral = true;
        return this;
    }

    public TaxCalculator withSurcharge() {
        useSurcharge = true;
        return this;
    }

    public double calculate(Order order) {
        return calculate(order, useRegional, useGeneral, useSurcharge)
    }
}
double value = new TaxCalculator().withRegional()
                                  .withSurcharge()
                                  .calculate(order);
  • 코드가 장황하다는 것이 이 기법의 문제다.
  • 도메인의 각 세금에 해당하는 불리언 필드가 필요하므로 확장성도 제한적이다.
  • 자바의 함수형 기능을 이용하면 더 간결하고 유연한 방식으로 같은 가독성을 달성할 수 있다.
public class TaxCalculator {
    public DoubleUnaryOperator taxFunction = d -> d;

    public TaxCalculator with(DoubleUnaryOperator f) {
        taxFunction = taxFunction.andThen(f); // 인수로 전달된 함수와 현재 함수 합침
        return this;
    }

    public double calculate(Order order) {
        return taxFunction.applyAsDouble(order.getValue());
    }
}
double value = new TaxCalculator().with(Tax::regional)
                                  .with(Tax::surcharge)
                                  .calculate(order);
  • 메서드 참조는 읽기 쉽고 코드를 간결하게 만들어준다. 새로운 세금 함수를 Tax 클래스에 추가해 도 함수형 TaxCalculator는 변경할 필요가 없이 바로 사용할 수 있는 유연성도 제공한다.

DSL 패턴의 장점과 단점