반응형

1. 서론

이번 포스팅에서는 Chapter10의 람다를 이용한 도메인 전용 언어에 대해 진행하도록 하겠습니다.

 

2. 도메인 전용 언어

도메인 전용 언어 ( = Domain Specific Languages )는 특정 비즈니스 도메인의 문제를 해결하기 위해 만든 언어입니다.

간단히, 특정 비즈니스 도메인을 인터페이스로 만든 API라고 말할 수 있습니다.

 

아래 2가지를 생각하면서 DSL을 개발해야 합니다.

 

  • 코드의 의도가 개발자가 아니더라도 이해할 수 있도록 해야합니다.
  • 가독성을 높여 유지보수를 쉽게 할 수 있도록 해야합니다.

 

1) DSL의 장점과 단점

 

DSL은 비즈니스 의도와 가독성 측면에서는 좋지만, 해당 코드가 올바른지 검증과 유지보수를 해야하는 책임이 잇따르게 됩니다.

 

때문에, 장점과 단점이 있습니다.

 

아래는 DSL의 장점입니다.

 

  • 간결함 : 비즈니스 로직을 캡슐화하여 반복을 피하고 간결해집니다.
  • 가독성 : 도메인 영역의 용어를 사용하므로 비 도메인 전문가도 코드를 이해 할 수 있습니다.
  • 유지보수 : 간결함과 가독성으로 인해 어플리케이션의 유지보수가 좋습니다.
  • 높은 수준의 추상화 : 도메인과 같은 추상화 수준에서 동작하므로 도메인의 문제와 직접적으로 관련되지 않은 세부사항을 숨깁니다.
  • 집중 : 비즈니스 도메인을 표현하기 위한 언어이므로 특정 코드에 집중할 수 있습니다.
  • 관심사 분리 : 인프라구조 관련된 문제와 독립적으로 비즈니스 관련된 코드가 분리되어 집니다.

 

아래는 DSL의 단점입니다.

 

  • DSL 설계의 어려움 : 제한적인 언어에 도메인 지식을 담는것이 쉬운 작업은 아닙니다.
  • 개발 비용 : DSL을 프로젝트에 추가하는 것은 많은 비용과 시간이 소모됩니다.
  • 추가 우회 계층 : DSL 은 새로운 계층으로 기존 조메인 모델을 감싸는 형태가 되며, 계층을 최대한 작게 만들어 성능문제가 발생할 수 있습니다.
  • 새로 배워야 하는 언어 : DSL도 결국에는 언어이기 때문에, 해당 언어를 배워야합니다.
  • 호스팅 언어 한계 : 자바와 같이 엄격한 문법을 가진 언어는 사용자 친화적 DSL을 만드는데 한계가 있습니다.

 

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

 

DSL은 내부 DSL외부 DSL 이라는 카테고리로 나눌 수 있습니다.

 

  • 내부 DSL : 순수 자바코드 같은 기존 호스팅 언어를 기반으로 구현한 언어
  • 외부 DSL : 호스팅 언어와는 독립적으로 자체의 문법을 가지는 언어 (ex : SQL)

추가로 최근 자바는 아니지만 스칼라, 코틀린과 같이 JVM에서 실행되는 언어들이 나옴으로 인해 다중 DSL이라는 카테고리가 추가 되었습니다.

 

 

1.  내부 DSL

 

엄격한 문법을 가진 자바의 경우 DSL을 구현하는데 한계가 있었습니다.

 

하지만, 자바 8에서 나온 람다, 메서드 참조 등을 이용해 한계는 어느정도 해결할 수 있게 되었습니다.

 

자바를 통해 DSL을 구현함으로 얻는 장점은 아래와 같습니다.

 

  1. 새로운 언어와 기술을 배울 노력이 줄어듭니다.
  2. 다른 언어가 아닌 자바로 DSL을 구현하게 되면, 나머지 코드와 같이 컴파일이 가능해집니다.
  3. 자바를 사용하는 IDE의 혜택을 누릴수 있습니다.
  4. 자바는 유지보수에는 좋기 때문에 향후 도메인 변경 이슈에 대해 용이합니다.

 

2. 다중 DSL

 

JVM에서 동작하는 다양한 언어가 있어, 자바 어플리케이션이지만 DSL은 스칼라같은 언어로 대체가 되어 유연해질 수 있습니다.

 

스칼라가 자바에 비해 함수형 언어에 가까워 DSL을 구현하기에는 적합합니다.
이유는 함수형 언어는 객체지향보다 좀 더 코드가 직관적이기 때문입니다.

 

하지만 아래와 같은 단점도 존재합니다.

 

  • 새로운 프로그래밍을 배우거나 팀원 중 해당 언어에 대해 숙련자가 있어야 합니다.
  • 두개 이상의 언어가 혼재하므로 컴파일 빌드 시 과정을 개선해야 합니다.
  • 같은 JVM에서 동작은 하지만 사실상 100% 자바와 호환이 되는것은 아닙니다.

 

3. 외부 DSL

 

외부 DSL을 구현하기 위해서는 자신만의 문법과 구문으로 새 언어를 설계해야합니다.

 

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

 

이러한 작업들은 매우 어려우며 잘못하면 성능이슈와 더불어 프로그램이 안전하지 않을 수 있습니다.

 

하지만 자신만의 언어를 설계함으로 인해 무한한 유연성을 취할 수 있다는 장점은 존재합니다.

또한, 자바코드와 외부 DSL코드의 명확한 분리가 된다는 장점도 존재합니다.

 

3. 최신 자바 API의 작은 DSL

자바 8에서 등장한 람다, 디폴트 메서드로 인해 작은 DSL을 제공하고 있다고 볼 수 있습니다.

 

아래는 자바 컬렉션에서 제공하는 작은 정렬 DSL입니다.

 

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

 

위 comparing, reverse와 같은 것들이 최신 자바에서 제공하는 DSL로 볼 수 있습니다.

 

자바 개발자가 아니더라도, comparing을 통해 비교를 하고, reverse를 보고 반대로 정렬하도록 되어있다는 것을 볼 수 있기 때문입니다.
또한, 내부적으로 어떠한 일을 하는지 숨겨놨기 때문에 자바로 만든 DSL로 볼 수 있습니다.

 

 

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

 

Stream 인터페이스는 네이티브 자바 API에 작은 내부 DSL을 적용한 좋은 예입니다.

 

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

 

위의 filter, limit, collect 모두 작은 DSL로 볼 수 있습니다.

 

 

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

 

위에서 본 filter와 limit의 경우에는 Stream 인터페이스에서 제공하는 데이터 조작 DSL이라고 볼 수 있습니다.

 

하지만 마지막 collect의 경우에는 데이터를 수집하는 DSL로 간주 할 수 있습니다.

 

Collectors 클래스 또한 디폴트 메서드를 통해 쉽세 DSL을 사용할 수 있습니다.

 

다만, 가독성 측면에서 Collectors는 좋지 않을때가 있는데 아래는 그 예로 Comparator와 비교한 코드입니다.

 

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

// Comparator
Comparator comparator = Comparator.comparing(Person::getAge).thenComparing(Person::getName);

 

위는 Collectors의 중첩함수와 Comparator의 비교 함수입니다.

 

Collectors의 중첩의 경우 메서드 안에 메서드가 있는 형태입니다.

이는 Comparator의 체이닝메서드 형태보다 가독성 측면에서 떨어지게 됩니다.

 

 

 

 

 

 

 

반응형

 

 

 

 

 

 

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

 

위 3번의 Collectors 와 같이 제공하는 DSL이 모두 좋지 않을 수 있습니다.

때문에 이번에는 직접 DSL을 만드는 패턴과 기법에 대해 소개하겠습니다.

 

먼저 들어가기에 앞서 DSL은 특정 도메인 모델을 위한 언어이므로 아래와 같은 도메인을 설정하고 시작하겠습니다.

 

@Getter
@Setter
public class Stock {
    private String symbol;
    private String merket;
}


@Getter
@Setter
public class Trade {
    
    public enum Type {BUY, SELL};
    private Type type;
    private Stock stock;
    private int quantity;
    private double price;
    
    public double getValue() {
        return quantity * price;
    }
}



public class Order {

    @Getter
    private String customer;
    private List<Trade> trades = new ArrayList<>();
    
    public void addTrade(Trade trade) {
        trades.add(trade);
    }
    
    public double getValue() {
        return trades.stream().mapToDouble(Trade::getValue).sum();
    }
    
}

 

아래는 위 도메인 모델을 이용하여 BigBank라는 고객이 요청한 두 거래를 포함하는 주문을 만들은 예제입니다.

 

Order order = new Order();
order.setCustomer("BigBank");

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

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

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

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

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

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

 

위 코드는 한눈에 봐도 장황하며 비개발자가 이해하기는 더욱 불가능한 코드입니다.

 

직관적으로 도메인 모델을 반영할 수 있는 DSL이 필요한 시점입니다.

 

1) 메서드 체인

 

DSL에서 가장 흔한 방식 중 하나가 메서드 체인 방식입니다.

 

메서드 체인을 사용하면 위에서 도메인 객체를 만들기 위한 장황한 코드는 아래와 같이 바뀔수 있습니다.

 

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

 

위와 같은 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 TradeBuilder sell(int quantity) {
        return new TradeBuilder(this, Trade.Type.SELL, quantity);
    }
    
    public MethodChainingOrderBuilder addTrade(Trade trade) {
        order.addTrade(trade);
        return this;
    }
    
    public Order end() {
        return order;
    }
}

 

public class TradeBuilder {
    private final MethodChainingOrderBuilder builder;
    public final 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.setMerket(market);
        trade.setStock(stock);
        return new TradeBuilderWithStock(builder, trade);

    }
}

 

@AllArgsConstructor
public class TradeBuilderWithStock {
    private final MethodChainingOrderBuilder builder;
    private final Trade trade;

    public MethodChainingOrderBuilder at(double price) {
        trade.setPrice(price);
        return builder.addTrade(trade);
    }
}

 

이러한 빌드 클래스를 이용하여 좀 더 직관적인 도메인 객체를 생성하는 DSL을 만들 수 있게 되었습니다.

 

하지만, 복잡한 빌드 클래스를 만들어야 한다는 단점이 있습니다.

 

 

2) 중첩된 함수 이용

 

중첩된 함수 DSL 패턴은 다른 함수안에 함수를 이용해 도메인 모델을 만듭니다.

 

아래는 중첩 함수 DSL를 사용하여 도메인 객체를 만드는 예제 입니다.

 

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



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 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.setMerket(market);
        return stock;
    }
    
    public static String on(String market) {
        return market;
    }
}

 

 

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

 

하지만 이러한 DSL은 사용시 괄호가 많아진다는 단점이 있습니다.

 

 

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

 

위 중첩함수 DSL을 이번에는 람다를 사용하여 더욱 깔끔하게 만들 수 있습니다.

 

아래는 예제입니다.

 

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");
        });
    });
});

 

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;
    }
    
    public void forCustomer(String customer) {
        order.setCustomer(customer);
    }
    
    public void buy(Consumer<LambdaTradeBuilder> consumer) {
        trade(consumer, Trade.Type.BUY);
    }

    public void sell(Consumer<LambdaTradeBuilder> consumer) {
        trade(consumer, Trade.Type.SELL);
    }
    
    private void trade(Consumer<LambdaTradeBuilder> consumer, Trade.Type type) {
        LambdaTradeBuilder builder = new LambdaTradeBuilder();
        builder.trade.setType(type);
        consumer.accept(builder);
        order.addTrade(builder.trade);
    }
}
public class LambdaTradeBuilder {
    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<LambdaStockBuilder> consumer) {
        LambdaStockBuilder builder = new LambdaStockBuilder();
        consumer.accept(builder);
        trade.setStock(builder.stock);
    }
}
public class LambdaStockBuilder {
    public Stock stock = new Stock();

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

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

 

위 방법은 앞서 봤던 2가지 방법의 장점을 모두 가지고 있습니다.


위 3가지 방법은 모두 장단점을 가지고 있으며 선호에 따라 3가지의 방법을 조합하여 사용해도 됩니다.

 

4) DSL에 메서드 참조 사용하기

 

DSL을 만들때 메서드 참조를 사용하여 더욱 직관적으로 만들 수 있습니다.

 

아래는 위 주식 거래 도메인에서 계산을 하는 DSL을 만드는 예제입니다.

 

double value = new TaxCalculator()
    .withTaxRegional()
    .withTaxSurcharge()
    .calculate(order);

 

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

    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;
    }
}

 

위의 TaxCalculator 클래스는 각 boolean 값을 사용하여 확장성 부분에서 미약합니다.

하지만, 메서드 참조를 통해 리팩터링이 가능합니다.

 

아래는 메서드 참조를 통해 리팩터링한 코드입니다.

 

double value = new TaxCalculator()
    .with(Tax::regional)
    .with(Tax::surcharge)
    .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());
    }
}

 

5. 마무리

이번 포스팅에서는 Chapter10 람다를 이용한 도메인 전용 언어에 대해 진행하였습니다.

다음에는 Chapter11 null 대신 Optional 클래스에 대해 포스팅하겠습니다.

반응형

+ Recent posts