본문 바로가기

IT Book Summary/ModernJavaInAction

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

핵심 비지니스를 모델링하는 영역에서 읽기 쉽고 이해하기 쉬운 코드.

개발팀과 도메인 전문가가 공유하고 이해할 수 있는 코드.

-> 도메인 전용 언어 DSL : 특정 도메인을 대상으로 만들어진 특수 프로그래밍 언어.

 

스트림 API의 메서드 체인 의 플루언트 스타일을 DSL에 적용.

 

 


10.1 도메인 전용 언어

 

-> 특정 비지니스 도메인을 인터페이스로 만든 API

 

1 - DSL의 장점과 단점

 

 

장점 

  • 간결함
  • 가독성
  • 유지보수 - 자바 코드로 관리하므로 유지보수 쉬움.
  • 높은 수준의 추상화 - 도메인과 같은 추상화 수준에서 동작
  • 집중 - 도메인 규칙을 표현하므로 코드에 집중하기 용이해 생산성이 좋음
  • 관심사 분리 - 독립적으로 비지니스 관련 코드에 집중.

단점

  • DSL설계의 어려움 - 도메인 지식을 담는 작업
  • 개발 비용 - 초기작업의 어려움
  • 추가 우회 계층
  • 새로 배워야 하는 언어
  • 호스팅 언어 한계

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

 

내부 DSL

: 람다 표현식을 이용해 자바로 구현한 DSL

 

다중 DSL

: 자바 바이트코드를 사용하는 JVM 기반으로 동작하는 두개 이상의 언어를 이용함.

 

외부 DSL

: 외부 DSL 구현. 자신만의 문법과 구문으로 새언어를 설계

 


10.2 최신 자바 API의 작은 DSL

 

Comparator 인터페이스를 통해 람다가 어떻게 네이티즈 자바 API의 재사용성과 메서드 결합도를 높였는지 확인할수 있음.

 

Comparator 정적 메서드를 임포트해 구현

Collections.sort(persons, comparing(p -> p.getAge()));

메서드 참조 이용

Collections.sort(persons, comparing(Person::getAge));

 

List 인터페이스에 추가된 새 sort 메서드 이용

persons.sort(comparing(Person::getAge).thenComparing(Person::getName));

 

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

 

함수형으로 로그 파일의 에러행 읽는 예제

List<String> errors = Files.lines(Paths.get(fileName)) //파일열어 문자열 스트림
                           .filter(line -> line.startsWith("ERROR"))
                           .limit(40)
                           .collect(toList());

 

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

중간 연산은 다른연산으로 파이프라인 될수 있는 또다른 스트림 반환

최종연산은 전체 파이프라인 계산

 

2 - 데이터를 수집하는 DSL인 Collectors

스트림과 마찬가지로 Collector 인터페이스는 데이터 수집을 수행하는 DSL로 간주될수 있음.

 

플루언트 형식이 중첩형식보다 가독성이 좋음.

 

groupingBy 팩터리 메서드에 작업을 위임하는 GroupingBuilder를 만들면 문제를 더 쉽게 해결.

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>
        groupingOn(Function<? super T, ? extends K> classifier) {
            return new GroupingBuilder<>(groupingBy(classifier));
        }
}

Collector<? super Car, ?, Map<Brand, Map<Color, List<Car>>>> 
    carGroupingCollector = groupOn(Car::getColor).after(Car::getBrand).get();

 

하지만 중첩된 그룹화 수준에 반대로 그룹화 함수를 구현해야 하므로 유틸리티 사용 코드가 직관적이지 않음.

 


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

 

도메인 모델 정의

public class Stock {
    private String symbol;
    private String market;
    
    //getter, setter
}

public class Trade {
    public enum Type {BUY, SELL}
    private Type type;
    
    private Stock stock;
    private int quantity;
    private double price;
    
    //getter, setter
}

public class Order {
    private String customer;
    private List<Trade> trades = new ArrayList<>();
    
    //getter, setter
    
    public void addTrade(Trade trade) {
        trades.add(trade);
    }
    
    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");

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

장황하고 직관적이지 않으므로 DSL 필요.

 

1 - 메서드 체인

 

메서드 체인으로 거래주문 정의

Order order = forCustomer("BigBank")
         .buy(80)
         .stock("IBM")
         .on("NYSE")
         .at(125.00)
         .sell(50)
         .stock("GOOGLE")
         .on("NASDAQ")
         .at(375.00)
         .end();

이 결과를 달성하기 위해 DSL구현.

플루언트 API로 도메인객체를 만드는 빌더를 구현해야함.

최상위 빌러를 만들고 주문을 감싼다음 한개 이상의 거래를 주문에 추가하도록.

 

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);
    } // 주식을 사는 TradeBuilder 만들기.
    
    public TradeBuilder sell(int quantity) {
        return new TradeBuilder(this, Trade.Type.SELL, quantity);
    } // 주식을 파는 TradeBuilder 만들기.
    
    public MethodChainingOrderBuilder addTrade(Trade trade) {
        order.addTrade(trade); // 주문에 주식추가
        return this;
    } // 유연하게 추가 주문을 만들어 주문빌더 반환
    
    public Order end() {
        return order; 
    } // 주문 만들기 종료하고 반환.
}

 

 

public class TradeBuilder {
    private final MethodChainingBuilder builder;
    public final Trade trade = new Trade();
    
    private TradeBuilder(MethodChainingBuilder 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);
    }
}

 

Stock 클래스의 인스턴스를 만드는 TradeBuilder 공개 메서드를 이용

 

 

public class StockBuilder {
    private final MethodChainingOrderBuilder builder;
    private final Trade trade;
    private final Stock stock = new Stock();
    
    private 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);
    }
}

 

StockBuilder 는 주식 시장을 지정하고 , 거래에 주식을 추가하고, 최종 빌더를 반환하는 on() 메서드를 정의 함.

 

public class TradeBuilderWithStock {
    private final 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);
    }
}

 

TradeBuilderWithStock은 거래되는 주식의 단위가격을 설정한 후 원래 주문빌더를 반환.

 

이처럼 MethodChainingOrderBuilder가 끝날때 까지 다른 거래를 플루언트 방식으로 추가할수 있음.

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

 

장점

- 이 접근 방식은 주문에 사용한 파라미터가 빌더 내부로 국한된다는 장점.

- 정적 메서드 사용을 최소화하고 메서드 이름이 인수의 이름을 대신하도록 만듦으로서 DSL 가독성을 개선하는 효과가 있음.

- 분법적 잡음을 최소화 함.

 

단점

- 빌더를 구현해야함.

- 상위수준의 빌러를 하위수준의 빌더와 연결할 접착 코드가 필요.

- 도메인 객체 중첩구조와 일치하게 들여쓰기를 강제하는 방법이 없음.

 

 

2 - 중첩된 함수 이용.

다름 함수 안에 함수를 이용해 도메인 모델을 만듦.

 

Order order = order("BigBank",
                    buy(80, stock("IBM", on("NYSE")), at(125.00)),
                    sell(50, stock("GOOGLE", on("NASDAQ")), at(375.00))
                    );

비교적 간단한 방식의 DSL 코드를 구현할 수 있다. 

 

NestedFunctionOrderBuilder는 이런 형식으로 사용자에게 API제공(모든 정적 메서드 임포트 가정.)

 

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 builderTrade(quantity, stock, price, Trade.Type.BUY);
    } // 주식 매수거래 만들기
    
    public static Trade sell(int quantity, Stock stock, Double price) {
        return builderTrade(quantity, stock, price, Trade.Type.SELL);
    } // 주식 매도거래 만들기
    
    private static Trade buildTrade(int quantity, Stock stock, double price, Trade.Type buy) {
        Trade trade = new Trade();
        trade.setQuantity(quantity);
        trade.setType(buy);
        trade.setStock(stock);
        trade.setPrice(price);
        return trade;
    }
    
    public static double at(double price) {
        return price;
    }//거래된 주식의 단가를 정의하는 더미메서드
    
    public static Stock stock(String symbol, String market) {
        Stock stock = new Stock(); // 거래된 주식 만들기
        stock.setSymbol(symbol);
        stock.setMarket(market);
        return stock;
    }
    
    public static String on(String market) {
        return market;
    } // 주식이 거래된 시장을 정의하는 더미메서드
}

 

장점

- 메서드 체인에 비해 함수의 중첩 방식이 도메인 객체 계층 구조에 그대로 반영된다는 것

 

단점

- 많은 괄호를 사용해야함.

- 인수 목록을 정적메서드에 넘겨줘야 한다는 제약.

- 인수의 의미가 이름이 아니라 위치에 의해 정의 됨.

    -> 인수의 역할을 확실하게 만드는 여러 더미메서드( at(), on() )를 이용해 해결책 제시.

 

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(375.00);
        t.stock(s -> {
            s.symbol("GOOGLE");
            s.market("NASDAQ");
        });
    });
});

 

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

빌더는 메서드 체인 패턴을 이용해 만들려는 객체의 중간상태를 유지.

 

메서드 체임 패턴에는 주문은 만드는 최상위 수준의 빌더를 가졌지만

이번엔 Cunsumer 객체를 빌더가 인수로 받음으로 DSL사용자가 람다 표현식으로 인수를 구현할 수있게 함.

 

public class LambdaOrderBuilder {
    private Order order = new Order(); // 빌더로 주문을 감쌈.
    
    public static Order order(Consumer<LambdaOrderBuilder> consumer) {
        LambdaOrderBuilder builder = new LambdaOrderBuilder();
        consumer.accept(builder); // 주문빌더로 전달된 람다 표현식 실행
        return builder.order;
        //OrderBuilder의 Consumer를 실행해 만들어진 주문을 반환.
    }
    
    public void forCustomer(String customer) {
        order.setCustomer(customer); // 주문을 요청한 고객 설정.
    }
    
    public void buy(Consumer<TradeBuilder> consumer) {
        trade(consumer, Trade.TYPE.BUY);
    } // 주식 매수 주문을 만들도록 TradeBuilder 소비
    
    public void sell(Consumer<TradeBuilder> consumer) {
        trade(consumer, Trade.TYPE.SELL);
    } // 주식 매도 주문을 만들도록 TradeBuilder 소비
    
    private void trade(Consumer<TradeBuilder> consumer, Trade.Type type) {
        TradeBuilder builder = new TradeBuilder();
        builder.trade.setType(type);
        consumer.accept(builder); // TradeBuilder로 전달할 람다 표현식 실행
        order.addTrade(builder.trade); // TradeBuilder의 Consumer를 실행해 만든 거래를 주문에 추가.
    }
}

 

주문 빌더의 buy(), sell() 메서드는 두개의 Consumer<TradeBuilder> 람다 표현식을 받음.

이 람다표현식을 실행하는 주식 매수, 매도 거래가 만들어짐.

 

public class TradeBuilder {
    private 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();
        consumer.accept(builder);
        trade.setStock(builder.stock);
    }
}

// TradeBuilder는 세번째 빌더의 Consutmer 즉 거래된 주식을 받음.
public class StockBuilder {
    private Stock stock = new Stock();
    
    public void symbol(String symbol) {
        stock.setSymbol(symbol);
    }
    
    public void market(String market) {
        stock.setMarket(market);
    }
}

 

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

중첩된 함수 패턴을 람다 기법과 혼용.

 

public class MixedBuilder {
    public static Order forCustomer(String customer, TradeBuilder ... builders) {
        Order order = new Order();
        order.setCustomer(customer);
        Steram.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 buy) {
        TradeBuilder builder = new TradeBuilder();
        builder.trade.setType(buy);
        consumer.accept(builder);
        return builder;
    }
}

 

헬퍼 클래스 TradeBuilder와 StockBuilder는 내부적으로 메서드 체인 패턴을 구현해 플루언트 API 제공.

 

public class TradeBuilder {
    private Trade trade = new Trade();
    
    public TradeBuilder quantity(int quantity) {
        trade.setQuantity(quantity);
        return this;
    }
    
    public TradeBuilder at(double price) {
        trade.setPrice(price);
        return this;
    }
    
    public StockBuilder stock(String symbol) {
        return new StockBuilder(this, trade, symbol);
    }
}

public class StockBuilder {
    private final TradeBuilder builder;
    private final Trade trade;
    private final Stock stock = new Stock();
    
    private StockBuilder() {
        this.builder = builder;
        this.trade = trade;
        stock.setSymbol(symbol);
    }
    
    public TradeBuilder on(String market) {
        stock.setMarket(market);
        trade.setStock(stock);
        return builder;
    }
}

 

여러 기법을 혼용하므로 한가지 기법을 적용한 DSL에 비해 사용자가 DSL을 배워야 함.

 

 

5 - DSL에 메서드 함조 사용하기

주식거래 도메인 모델에 간단한 기능 추가.

주문의 총 합에 0개 이상의 세금을 추가해 최종값을 계산하는 기능 추가.

 

public class Tax {
    public static double regional(double value) {
        return value * 1.1;
    }
    
    public static double general(double 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(useSeucharge) value = Tax.surcharge(value);
    
    return value;
}

 

하지만 불리언 변수의 순서를 기억하기 어려우므로 파악하기 쉽지 않음.

불리언 플래그를 설정하는 DSL을 베공하는 TaxCalculator를 이용하자.

 

public class TaxCalculator {
    private boolean useRegional;
    private boolean useGeneral;
    private boolean useSurcharge;
    
    public TaxCalculator withTaxRegional() {
        useRegional = true;
        return this;
    }
    public TaxCalculator withTaxGeneral() {
        useGeneral = true;
        return this;
    }
    public TaxCalculator withTaxSurcharge() {
        useSurcharge = true;
        return this;
    }
    
    public double calculate(Order order) {
        return calculate(order, useRegional, useGeneral, useSurcharge);
    }
}

double value = new TaxCalculator().withTaxRegional()
                                  .withTaxSurcharge()
                                  .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);

주문의 총 합에 적용할 함수 한개의 필드만 필요.

with() 메서드로 새 세금이 추가되면 현재 세금 계산 함수에 이 세금이 조합되는 방식으로 한 함수에 모든 추가된 세금이 적용.


10.4 실생활 자바 8 DSL

 

 

1 - jOOQ

SQL 매핑 도구, sql을 구현하는 내부적 DSL로 자바에 직접 내장된 형식 안전 언어.

 

SELECT * FROM BOOK

WHERE BOOK.PUBLISHED_IN = 2016

ORDER BY BOOK.TITLE

 

다음과 같이 구현

create.selectFrom(BOOK)
      .where(BOOK.PUBLISHED_IN.eq(2016))
      .orderBy(BOOK.TITLE)

플루언트 구문으로 데이터를 메모리에서 조작

 

 

2 - 큐컴버

동작 주도 behavior-driven 개발 프레임워크

개발자가 비지니스 시나리오를 평문 영어로 구현할 수 있도록 도와주는 도구

 

Feature: Buy stock

  Scenario: Buy 10 IBM stocks

    Given the price of a "IBM" stock is 125$

    When I buy 10 "IBM"

    Then the order value should be 1250$

 

전제조건정의 Given 시험하려는 도메인 객체의 실질 호출 When 테스트케이스의 결과를 확인하는 어써션 Then

public class BuyStocksSteps implements cucumber.api.java8.En {
    private Map<String, Integer> stockUnitPrices = new HashMap<>();
    private Order order = new Order();
    public BuyStocksSteps() {
        Given ("^the price of a \"(.*?)\" stock is (\\d+)\\$$", 
               (String stockName, int unitPrice) -> {
                   stockUnitValues.put(stockName, unitPrice);
               });
               
               ...
    }
}

 

3 - 스트링 통합

엔터프라이즈 통합패턴을 지원할 수 있도록 의존성 주입에 기반한 스프링 프로그래밍 모델을 확장

단순한 모델을 제공하고 비동기, 메시지 주도 아키텍처를 쉽게 적용하도록 도움.

 

@Bean 
public IntegrationFlow myFlow() {
    return flow -> flow.filter((Integer p) -> p%2 == 0)
                       .transform(Object::toString)
                       .handle(System.out::println);
}

스프링 통합 DSL에서 가장 널리 사용하는 패턴은 메서드 체인.

하지만 최상위 객체를 만들때는 함수 시퀀싱과 람다표현식을 사용.

 

 


  • DSL의 주요기능은 개발자와 도메인 전문가 사이 간격을 좁히는것.
  • 내부적 DSL과 외부적 DSL로 분류. 내부적은 개발 노력이 적게들지만 호스팅 언어의 문법제약을 받음. 외부적은 높은 유연성을 제공하지만 구현하기 어려움.
  • JVM 이용하는 스칼라, 그루비 등 다른언어로 다중 DSL 구현가능
  • 보통 자바는 문법의 엄격함으로 내부적 DSL 개발언어로 적합하지 않지만 자바 8 람다, 메서드참조 도입으로 많이 개선됨.