- 특정 비즈니스 도메인의 문제를 해결하려고 만든 언어
- 특정 비스니스 도메인을 인터페이스로 만든 API
- 도메인을 표현할 수 있는 클래스와 메서드 집합이 필요하다
즉, DSL에서 동작과 용어는 특정 도메인에 국한되므로 다른 문제는 걱정할 필요 없고 오직 자신의 앞에 놓인 문제를 어떻게 해결할지에만 집중할 수 있다.
e.g. sql문을 통해 데이터베이스의 내부 구현 로직을 알지 못해도 데이터베이스를 CRUD 할 수 있다.
장점
- 간결함 : API는 비즈니스 로직을 간편하게 캡슐화하므로 반복을 피할 수 있고 코드를 간결하게 만들 수 있다.
- 가독성 : 도메인 영역의 용어를 사용하므로 비 도메인 전문가도 코드를 쉽게 이해할 수 있다. 다양한 조직 구성원 간에 코드와 도메인 영역이 공유될 수 있다.
- 유지보수 : 잘 설계된 DSL로 구현한 코드는 쉽게 유지보수하고 바꿀 수 있다.
- 높은 수준의 추상화 : DSL은 도메인과 같은 추상화 수준에서 동작하므로 도메인의 문제와 직접적으로 관련되지 않은 세부 사항을 숨긴다.
- 집중 : 비즈니스 도메인의 규칙을 표현할 목적으로 설계된 언어이므로 프로그래머가 특정 코드에 집중할 수 있다.
- 관심사분리(SoC) : 지정된 언어로 비즈니스 로직을 표현함으로 애플리케이션의 인프라구조와 관련된 문제와 독립적으로 비즈니스 관련된 코드에서 집중하기가 용이하다.
단점
- DSL 설계의 어려움 : 간결하게 제한적인 언어에 도메인 지식을 담는 것이 쉬운 작업은 아니다.
- 개발 비용 : 코드에 DSL을 추가하는 작업은 초기 프로젝트에 많은 비용과 시간이 소모된다. 또한 DSL 유지보수와 변경은 프로젝트에 부담을 주는 요소다.
- 추가 우회 계층 : DSL은 추가적인 계층으로 도메인 모델을 감싸며 이 때 계층을 최대한 작게 만들어 성능 문제를 회피한다.
- 새로 배워야 하는 언어 : DSL을 프로젝트에 추가하면서 팀이 배워야 하는 언어가 한 개 더 늘어난다는 부담이 있다.
- 호스팅 언어 한계 : 일부 자바 같은 범용 프로그래밍 언어는 장황하고 엄격한 문법을 가졌다. 이런 언어로는 사용자 친화적 DSL을 만들기가 힘들다.
내부 DSL이란 자바로 구현한 DSL을 의미한다.
람다 표현식이 등장하며 쉽고 간단하며 표현력있는 DSL을 만들 수 있게 되었다.
익명 내부 클래스를 사용하는 것보다 람다를 사용하면 신호 대비 잡음 비율을 적정 수준으로 유지하는 DSL을 만들 수 있다.
- 외부 DSL에 비해 새로운 패턴과 기술을 배워 DSL을 구현할 필요가 없다
- 순수 자바로 DSL을 구현하면 나머지 코드와 함께 DSL을 컴파일할 수 있다.
- 개발팀이 새로운 언어를 배울 필요가 없다.
- 기존 자바 IDE를 통해 자동 완성, 자동 리팩터링 같은 기능을 그대로 사용할 수 있다.
e.g.
List<String> numbers = Arrays.asList("one", "two", "three"); numbers.forEach( new Consumer<String>() {
@Override
public void accept( String s ) {
System.out.println(s);
}
} );
// 내부 DSL 즉, 람다 표현식 사용
numbers.forEach(s -> System.out.println(s));
같은 자바 바이트코드를 사용하는 JVM 기반 프로그래밍 언어(Jython, JRuby, 코틀린 등)를 이용하여 DSL을 만들 수 있다.
- 문법적 잡음이 없으며 개발자가 아닌 사람도 코드를 쉽게 이해할 수 있다.
- 자바 언어가 가지는 한계를 넘을 수 있다 (스칼라는 꼬리 호출 최적화를 통해 함수 호출을 스택에 추가하지 않는다).
- 누군가가 해당 언어에 대해 고급 기술을 사용할 수 있을 정도의 충분한 지식을 가지고 있어야 한다.
- 두 개 이상의 언어가 혼재하므로 여러 컴파일러로 소스를 빌드하도록 빌드 과정을 개선해야 한다.
- 호환성 문제를 고려해야한다
자신만의 문법과 구문으로 새 언어를 설계해야한다.
(앞에서 전부 본 내용으로 자세한 설명 생략)
(앞에서 전부 본 내용으로 자세한 설명 생략)
예제 도메인
주어진 시장에 주식가격을 모델링하는 순수 자바 빈즈
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;
}
@Override
public String toString() {
return String.format("Stock[symbol=%s, market=%s]", symbol, market);
}
}
주어진 양의 주식을 사거나 파는 거래
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;
}
@Override
public String toString() {
return String.format("Trade[type=%s, stock=%s, quantity=%d, price=%.2f]", type, stock, quantity, 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();
}
@Override
public String toString() {
String strTrades = trades.stream().map(t -> " " + t).collect(Collectors.joining("\n", "[\n", "\n]"));
return String.format("Order[customer=%s, trades=%s]", customer, strTrades);
}
}
메서드 호출 체인으로 거래 주문을 정의할 수 있다.
// 주문 객체를 포함하고 메서드 체인 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 Order end() {
return order;
}
// 주식을 사는 트레이더 빌더를 만든다.
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);
}
// 주문에 주식을 추가
private MethodChainingOrderBuilder addTrade(Trade trade) {
order.addTrade(trade);
return this;
}
// 트레이드 빌더
// 주문을 가지고 StcokBuilder를 생성한다.
public static 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);
}
}
// 스톡 빌더
// 주식의 시장을 지정하고, 거래에 주식을 추가하고, 최종 빌더를 반환하는 on메서드 정의
public static 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 static 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);
}
}
}
// 이용
Order order = forCustomer("BigBank")
.buy(80).stock("IBM").on("NYSE").at(125.00)
.sell(50).stock("GOOGLE").on("NASDAQ").at(375.00)
.end();
이렇게 빌드를 구현하여 메소드 체인 패턴을 사용함으로써 사용자는 따로 생성자를 통해 객체를 만들 수 없게 구현한다.
즉, 사용자는 forCustomer
메서드만을 통해 MethodChainingOrderBuilder
를 만들 수 있고 사용자는 미리 지정된 절차에 따라 플루언트 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 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.setMarket(market);
return stock;
}
public static String on(String market) {
return market;
}
}
public void nestedFunction() {
Order order = order("BigBank",
buy(80,
stock("IBM", on("NYSE")),
at(125.00)),
sell(50,
stock("GOOGLE", on("NASDAQ")),
at(375.00))
);
}
메서드 체인에 비해 함수의 중첩 방식이 도메인 객체 계층 구조에 그대로 반영된다.
그러나 더 많은 괄호를 사용해야하고, 인수 목록을 정적 메서드에 넘겨줘야 한다.
이 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;
}
public void forCustomer(String customer) {
order.setCustomer(customer);
}
// TradeBuilder Consumer를 인수로 받는다.
// 즉, 이 메서드가 불리면 Consumer인자에 들어오는 람다의 전략대로
// TradeBuilder를 초기화 한다.
public void buy(Consumer<TradeBuilder> consumer) {
trade(consumer, Trade.Type.BUY);
}
public void sell(Consumer<TradeBuilder> consumer) {
trade(consumer, Trade.Type.SELL);
}
// TraderBuilder의 Type을 결정하고 람다의 전략대로 TradeBuilder를 수정하고 order에 trade를 더한다.
// 즉, 일종의 템플릿 메소드 패턴이다.
private void trade(Consumer<TradeBuilder> consumer, Trade.Type type) {
TradeBuilder builder = new TradeBuilder();
builder.trade.setType(type);
consumer.accept(builder);
order.addTrade(builder.trade);
}
// TradeBuilder Trade 인스턴스를 가지고 있고, 이를 설정할 수 있는 메서드를 가지고 있다.
public static class TradeBuilder {
private Trade trade = new Trade();
public void quantity(int quantity) {
trade.setQuantity(quantity);
}
public void price(double price) {
trade.setPrice(price);
}
// 위 buy, sell 메서드와 비슷하게 StockBuilder를 람다의 전략대로 바꾸고 trade의 stock을 설정한다.
public void stock(Consumer<StockBuilder> consumer) {
StockBuilder builder = new StockBuilder();
consumer.accept(builder);
trade.setStock(builder.stock);
}
}
public static class StockBuilder {
private Stock stock = new Stock();
public void symbol(String symbol) {
stock.setSymbol(symbol);
}
public void market(String market) {
stock.setMarket(market);
}
}
}
public void lambda() {
Order order = LambdaOrderBuilder.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 자체가 자바 8 람다 표현식 문법에 의한 잡음의 영향을 받는다.
한 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 buy) {
TradeBuilder builder = new TradeBuilder();
builder.trade.setType(buy);
consumer.accept(builder);
return builder;
}
// 메소드 체인 패턴 사용됨
public static 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 static class StockBuilder {
private final TradeBuilder builder;
private final Trade trade;
private final Stock stock = new Stock();
private StockBuilder(TradeBuilder builder, Trade trade, String symbol) {
this.builder = builder;
this.trade = trade;
stock.setSymbol(symbol);
}
public TradeBuilder on(String market) {
stock.setMarket(market);
trade.setStock(stock);
return builder;
}
}
}
public void mixed() {
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)));
}
이 패턴은 여러 장점이 모두 들어가있지만, 사용자가 DSL을 배우기까지 시간이 오래걸린다.
주식 거레 모델의 최종값에 세금을 추가해 최종값을 계산하는 기능을 추가해보자.
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 class TaxCalculator {
// 주문 값에 적용된 모든 세금을 계산하는 함수
public DoubleUnaryOperator taxFunction = d -> d;
public TaxCalculator with(DoubleUnaryOperator f) {
// 새로운 세금 계산 함수를 얻어서 현재 함수와 함친다.
taxFunction = taxFunction.andThen(f);
return this;
}
public double calculateF(Order order) {
// 전달되어있는 함수를 계산한다.
return taxFunction.applyAsDouble(order.getValue());
}
}
value = new TaxCalculator().with(Tax::regional)
.with(Tax::surcharge)
.calculateF(order);
패턴 이름 | 장점 | 단점 |
---|---|---|
메서드 체인 | 메서드 이름이 키워드 인수 역할을한다. | 구현이 장황하다 |
선택형 파라미터와 잘 동작한다. | 빌드를 연결하는 접착 코드가 필요하다 | |
DSL 사용자가 정해진 순서로 메서드를 호출하도록 강제할 수 있다. | 들여쓰기 규칙으로만 도메인 객체 계층을 정의한다. | |
정적 메서드를 최소화하거나 없앨 수 있다. | ||
문법적 잡음을 최소화한다. | ||
중첩 함수 | 구현의 장황함을 줄일 수 있다. | 정적 메서드의 사용이 빈번하다. |
함수 중첩으로 도메인 객체 계층을 반영한다. | 이름이 아닌 위치로 인수를 정의한다. | |
선택형 파라미터를 처리할 메서드 오버로딩이 필요하다. | ||
람다를 이용한 함수 시퀀싱 | 선택형 파라미터와 잘 동작한다. | 구현이 장황하다. |
정적 메서드를 최소화하거나 없앨 수 있다. | 람다 표현식으로 인한 문법적 잡음이 DSL에 존재한다. | |
람다 중첩으로 도메인 객체 계층을 반영한다. | ||
빌더의 접착 코드가 없다. |