10장

1. 도메인 전용 언어

도메인 전용 언어(Domain-Specific Language, DSL)는 비즈니스 도메인의 규칙을 표현할 목적으로 설계된 언어이다. DSL은 특정 비즈니스 도메인을 인터페이스로 만든 API를 제공한다.

1.1. DSL의 장점과 단점

DSL의 장점은 다음과 같다.

  • 간결함: API가 비즈니스 로직을 간편하게 캡슐화하므로 반복 회피, 간결한 코드 작성 가능

  • 가독성: 도메인 용어를 사용하므로 비 도매인 전문가도 쉽게 이해 가능

  • 유지보수: 잘 설계된 DSL은 쉽게 유지보수 가능

  • 높은 수준의 추상화: DSL은 도메인과 같은 추상화 수준에서 동작

  • 집중: 도메인에 집중해서 코드 작성이 가능하므로 생산성이 좋아짐

  • 관심사분리(Seperation of concerns): 인프라 구조와 독립적으로 비즈니스 코드 집중 가능

DSL의 단점은 다음과 같다.

  • DSL 설계의 어려움: 제한적인 언어에 도메인 지식을 담기 어려움

  • 개발 비용: 초기 프로젝트에 DSL을 추가하는 건 많은 비용과 시간 소모

  • 추가 우회 계층: DSL은 추가 계층으로 도메인 모델을 감싸며 계층을 최대한 작게 만들어 성능 문제 회피

  • 새로 배워야 하는 언어: DSL을 프로젝트에 추가하면 배울 언어가 늘어남

  • 호스팅 언어 한계: 일부 프로그래밍 언어는 가독성이 떨어짐

1.2. JVM에서 이용할 수 있는 DSL

마틴 파울러(Martin Fowler)는 DSL의 카테고리를 구분하기 위해 내부 DSL과 외부 DSL로 나누는 것을 제안했다. 내부 DSL은 순수 자바 코드와 같은 기존 호스팅 언어로 구현하고 외부 DSL은 독립적인 문법을 가진다. 먼저 자바로 DSL을 구현할 수 있다. 자바로 DSL을 구현하는 걸 내부 DSL이라고 한다. 자바 7까지 자바는 DSL을 구현하기에 적합한 언어가 아니었다. 다행히 람다 표현식이 등장하면서 DSL을 간결하게 작성할 수 있게 되었다. 자바로 DSL을 구현하면 다음과 같은 특징이 있다.

  • 외부 DSL에 비해 학습량이 줄어듦

  • 순수 자바로 구현했기 때문에 나머지 코드와 함께 DSL 컴파일 가능

  • 기존에 사용하던 IDE의 기능(자동 완성, 리팩터링 등) 그대로 사용 가능

JVM에서 실행되는 언어로 DSL을 구현할 수도 있다. 이 경우 기존의 자바 코드와 다른 코드가 혼재하므로 다중 DSL이라고 한다. 다중 DSL은 다음과 같은 특징이 있다.

  • 새로 프로그래밍 언어를 학습해야 함

  • 두 개 이상의 언어가 혼재하므로 빌드 개선 필요

  • JVM에서 실행되는 언어의 호환성 확인 및 변환 필요

자신만의 문법과 구문으로 새 언어를 설계해서 DSL을 구현할 수도 있다. 새 언어를 사용하는 것을 외부 DSL이라고 한다. 외부 DSL은 무한한 유연성을 제공하지만, 간단한 작업이 아니라는 문제가 있다.

2. 최신 자바 API의 작은 DSL

Stream 인터페이스는 네이티브 자바 API에 작은 내부 DSL을 적용한 좋은 예다. Stream은 컬렉션을 조작하는 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();
}

네이티브 자바 API는 코드를 알아보기 어렵다. Stream 인터페이스를 이용하면 간결하게 코드를 구현 가능하다.

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

Collector 인터페이스는 데이터 수집을 수행하는 DSL로 간주할 수 있다. DSL 관점에서 Comparator 인터페이스는 다중 필드 정렬을 지원하도록 합쳐질 수 있다. Collectors는 다중 수준 그룹화를 달성할 수 있도록 합쳐질 수 있다.

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

DSL은 특정 도메인 모델에 적용할 친화적이고 가독성 높은 API를 제공한다. 예제를 통해 DSL을 만들어보자.

// 주어진 시장에 주식 가격을 모델링하는 주식 클래스
@Getter @Setter
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;    
}

// 고객이 요청한 한 개 이상의 거래와 관련된 주문
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();
    }
}

3.1. 메서드 체인

메서드 체인을 사용해서 DSL을 만들 수 있다.

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

메서드 체인을 제공하는 주문 빌더는 다음과 같다.

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

TradeBuilder는 StockBuilder 인스턴스를 생성하는 stock 메서드를 정의한다.

public class TradeBuilder {
    private final MethodChainingOrderBuilder builder;
    public final Trade trade = new Trade();

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

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

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

TradeBuilderWithStock은 거래되는 주식의 단위 가격을 설정한 다음 원래 주문 빌더를 반환하는 메서드를 정의한다.

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

메서드 체인의 단점은 빌더를 구현해야 한다는 것이다. 상위 수준의 빌더를 하위 수준의 빌더와 연결할 많은 코드가 필요하다.

3.2. 중첩된 함수 이용

함수 안에 함수를 이용하는 중첩된 함수 패턴으로 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 buildeTrade(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;
    }
}

메서드 체인에 비해 함수의 중첩 방식이 도매인 객체 계층 구조에 그대로 반영된다. 그러나 여전히 문제점이 있다. 결과 DSL에 더 많은 괄호를 사용해야 하며, 인수 목록을 정적 메서드에 넘겨줘야 하는 등의 제약이 있다.

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

함수 시퀀싱 DSL을 제공하는 주문 빌더는 다음과 같다.

public class LambdaOrderBuilder {
    private Order order = new Order();

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

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

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

주문 빌더의 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는 세 번째 빌더의 Consumer 즉 거래된 주식을 받는다.

public class StockBuilder {
    private Stock stock = new Stock();

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

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

함수 시퀀싱 DSL 패턴은 메서드 체인 패턴처럼 플루언트 방식으로 거래 주문을 정의할 수 있다. 또한 중첩 함수 패턴처럼 람다 표현식의 중첩 수준과 비슷하게 도메인 객체의 계층 구조를 유지할 수 있다. 세 가지 DSL 패턴 각자가 장단점을 가지고 있다. DSL에 여러 가지 패턴을 조합해서 사용할 수 있다. 또한 DSL에 메서드 참조를 사용해서 구현할 수 있다.

3.4. 요약

DSL 패턴의 장점과 단점을 요약하면 다음과 같다.

패턴 이름
장점
단점

메서드 체인

* 메서드 이름이 키워드 인수 역할 * 선택형 파라미터와 잘 동작 * 정해진 순서로 메서드 호출 강제 가능 * 정적 메서드를 최소화/없앨 수 있음 * 문법적 잡음 최소화

* 구현이 장황함 * 빌드를 연결하는 접착 코드가 필요함 * 들여쓰기 규칙으로만 도매인 객체 계층을 정의

중첩 함수

* 구현의 장황함을 최소화 가능 * 함수 중첩으로 도메인 객체 계층 반영

* 정적 메서드의 사용이 빈번함 * 이름이 아닌 위치로 인수를 정의 * 선택형 파라미터를 처리할 메서드 오버로딩 필요

람다를 이용한 함수 시퀀싱

* 선택형 파라미터와 잘 동작 * 정적 메서드를 최소화하거나 없앨 수 있음 * 람다 중첩으로 도메인 객체 계층 반영 * 빌더의 접착 코드 없음

* 구현이 장황함 * 람다 표현식으로 인한 문법적 잡음

참고 자료

  • 모던 자바 인 액션 - 한빛미디어

Last updated