2025. 4. 8. 23:56ㆍ카테고리 없음
목차.
1. 효과적인 자바 기반 DSL을 구현하는 패턴과 기법
1. 자바로 DSL을 만드는 패턴과 기법
DSL은 특정 도메인 모델에 적용할 친화적이고 가독성 높은 API를 제공한다. 먼저 간단한 도메인 모델을 정의하면서 이 절을 시작하자.
예제 도메인 모델은 세 가지로 구성된다.
1. 주어진 시장에 주식 가격을 모델링하는 순수 자바 빈즈
2. 주어진 가격에서 주어진 양의 주식을 사거나 파는 거래
3. 고객이 요청한 한 개 이상의 거래의 주문
public class Stock {
private String symbol;
private String market;
public String getSymbol() { return symbol; }
public String getMarket() { return market; }
public void setSymbol(String symbol) { this.symbol = symbol; }
public void setMarket(String market) { this.market = 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 Stock getStock() { return stock; }
public int getQuantity() { return quantity; }
public double getPrice() { return price; }
public void setType(Type type) { this.type = type; }
public void setStock(Stock stock) { this.stock = stock; }
public void setQuantity(int quantity) { this.quantity = quantity; }
public void setPrice(double price) { this.price = price; }
}
public class Order {
private String customer;
private List<Trade> trades = new ArrayList<>();
public String getCustomer() { return customer; }
public void addTrade(Trade trade) { trades.add(trade); }
public void setCustomer(String customer) { this.customer = customer; }
public List<Trade> getTrades() { return trades; }
}
다음처럼 BigBank라는 고객이 요청한 두 거래를 포함하는 주문을 만들어 보자.
Stock stock = new Stock();
stock.setSymbol("IBM");
stock.setMarket( "NYSE");
Trade trade = new Trade();
trade.setType(Trade.Type.BUY);
trade.setStock(stock);
trade.setPrice(125.00);
trade.setQuantity(80);
Stock stock2 = new Stock();
stock2.setSymbol("MSFT");
stock2.setMarket( "NYSE");
Trade trade2 = new Trade();
trade2.setType(Trade.Type.BUY);
trade2.setStock(stock2);
trade2.setPrice(80.00);
trade2.setQuantity(100);
Order order = new Order();
order.setCustomer( "John");
order.addTrade(trade);
order.addTrade(trade2);
위 코드는 상당히 장황한 편이다. 비개발자인 도메인 전문가가 위 코드를 이해하고 검증하기를 기대할 수 없다. 더 직설적이고 직관적으로 도메인 모델을 반영할 수 있는 DSL이 필요하다. 이를 위한 다양한 방법과 장단점을 알아보자
1.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();
코드가 개선되어 비전문가도 코드를 쉽게 이해할 수 있다. 위 처럼 플루언트 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) {
// 주식을 사는 TradeBuilder 생성
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;
}
}
주문 빌더의 buy(), sell() 메서드는 다른 주문을 만드어 추가할 수 있도록 자신을 만들어 반환한다.
빌더를 계속 이어가려면 Stock 클래스의 인스턴스를 만드는 TradeBuilder의 공개 메서드를 이용해야 한다.
public class TradeBuilder {
private final MethodChainingOrderBuilder builder;
public final Trade trade = new Trade();
protected 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();
protected 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;
protected 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의 메서드를 호출하도록 강제한다.
장점.
1. 덕분에 사용자가 다음 거래를 설정하기 전에 기존 거래를 올바로 설정하게 된다.
2. 이 접근 방법은 주문에 사용한 파라미터가 빌더 내부로 국한된다는 다른 잇점도 제공한다.
3. 이 접근 방법은 정적 메서드 사용을 최소화하고 메서드 이름이 인수의 이름을 대신하도록 만듦으로 이런 형식의 DSL의 가독성을 개선하는 효과를 더한다.
4. 마지막으로 이런 기법을 적용한 플루언트 DSL에는 분법적 잡음이 최소화된다.
단점.
1. 빌더를 구현해야 한다.
2. 상위 수준의 빌더를 하위 수준의 빌더와 연결할 접착 많은 접착 코드가 필요하다.
3. 도메인의 객체의 중첩 구조와 일치하게 들여쓰기를 강제하는 방법이 없다는 것도 단점이다.
object.method1()
.nestedObject.method2()
.method3()
.method4();
object.method1()
.nestedObject
.method2()
.method3()
.method4();
// 도메인의 객체의 중첩 구조와 일치하게 들여쓰기를 강제할 수 없음
1.2 중첩된 함수 이용
중첩되 함수 DSL 패턴은 다른 함수 안에 함수를 이용해 도메인 모델을 만든다.
Order order = order("BigBlank",
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 type) {
Trade trade = new Trade();
trade.setQuantity(quantity);
trade.setStock(stock);
trade.setPrice(price);
trade.setType(type);
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을 구현하는 코드는 메서드 체인에 비해 간단하다. 메서드 체인에 비해 함수의 중첩 방식이 도메인 객체 계층 구조에 그대로 반영된다는 것이 장점이다.
이 방식에도 문제점이 있다.
1. 결과 DSL에 더 많은 괄호를 사용해야 한다는 것이다.
2. 인수 목록을 정적 메서드에 넘겨줘야 한다는 제약도 있다.
3. 도메인 객체에 선택 사항 필드가 있으면 인수를 생략할 수 있으므로 이 가능성을 처리할 여러 메서드 오버라이드를 구현애야 한다.
4. 마지막으로 인수의 의미가 이름이 아니라 위치에 의해 정의되었다.
NestedFunctionOrderBuilder의 at(), on() 메서드에서 했던 것처럼 인수의 역할을 확실하게 만드는 여러 더미 메서드를 이용해 4번 문제를 조금 완화할 수 있다.
1.3 람다 표현식을 이용한 함수 시퀀싱
이번 DSL 패턴은 람다 표현식으로 정의한 함수 시퀀스를 사용한다. 이형식의 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을 만들려면 람다 표현식을 받아 실행해 도메인 모델을 만들어 내는 여러 빌더를 구현해야 한다. DSL 구현해서 했던 방식과 마찬가지로 이들 빌더는 메서드 체인 패턴을 이용해 만들려는 객체의 중간 상태를 유지한다. 메서드 체인 패턴에는 주문을 만드는 최상위 수준의 빌더를 가졌지만 이번에는 Consumer 객체를 빌더가 인수로 받음으로 DSL 사용자가 람다 표현식으로 인수를 구현할 수 있게 했다.
public class LambdaOrderBuilder {
// 빌더로 주문을 감쌈
private Order order = new Order();
public static Order order(Consumer<LambdaOrderBuilder> consumer) {
LambdaOrderBuilder builder = new LambdaOrderBuilder();
// 주문 빌더로 번달된 람다 표현식 실행
consumer.accept(builder);
//OrderBuilder 의 Consumer 를 실행해 만들어진 주문을 반환
return builder.order;
}
public void forCustomer(String customer) {
// 주문을 요청한 고객 설정
order.setCustomer(customer);
}
public void buy(Consumer<TradeBuilder> consumer) {
// 주식 매수 주문을 만들도록 TradeBuilder 소비
trade(consumer, Trade.Type.BUY);
}
public void sell(Consumer<TradeBuilder> consumer) {
// 주식 매도 주문을 만들도록 TradeBuilder 소비
trade(consumer, Trade.Type.SELL);
}
private void trade(Consumer<TradeBuilder> consumer, Trade.Type type) {
TradeBuilder builder = new TradeBuilder();
builder.getTrade().setType(type);
// TradeBuilder 로 전달할 람다 표현식 실행
consumer.accept(builder);
// TradeBuilder 의 Consumer 를 싫애해 만든 거래를 주문에 추가
order.addTrade(builder.getTrade());
}
}
주문 빌더의 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.getStock());
}
protected Trade getTrade() {
return trade;
}
}
마지막으로 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);
}
protected Stock getStock() {
return stock;
}
}
장점.
1. 메서드 체인 패턴처럼 플루언트 방식으로 거래 주문을 정의할 수 있다.
2. 중첨 합수 형식처럼 다양한 람다 표현식의 중첩 수준과 비슷하게 도메인 객체의 계층 구조를 유지한다.
단점.
1. 많은 설정 코드가 필요하다
2. 자바의 람다 표현식 문법에 의한 잡음의 영향을 받는다.
1.4 조합하기
지금까지 살펴본 것처럼 세가지 DSL 패턴 각자가 장단점을 갖고 있다. 하지만 한 DS에 한 개의 패턴만 사용하란 법은 없다. 여러가지 패턴을 조합한 예제를 살펴보자.
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가 만든 각 거래는 아래에서 보여주는 것처럼 람다 표현식으로 구현된다.
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.getTrade()));
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.getTrade().setType(type);
consumer.accept(builder);
return builder;
}
}
마지막으로 헬퍼 클래스 TradeBuilder 와 StockBuilder는 내부적으로 메서드 체인 패턴을 구현해 플루언트 API를 제공한다. 이제 람다 표현식 바디를 구현해 간단하게 거래를 구현할 수 있다.
public class TradeBuilder {
private Trade trade = new Trade();
protected Trade getTrade() {
return 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();
public 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;
}
}
세 가지 DSL 패턴을 혼용해 가독성 있는 DSL을 만드는 방법을 보여준다. 여러 패턴의 장점을 이용할 수 있지만 이 기법에도 결점이 있다. 결과 DSL이 여러 가지 기법을 혼용하고 있으므로 한 가지 기법을 적용한 DSL에 비해 사용자가 DSL을 배우는데 오랜 시간이 걸린다는 것이다.
1.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.2;
}
public static double surcharge(double value) {
return value * 1.3;
}
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)
이 구현의 가독성은 별로 좋지 않다. 불리언 변수의 올바른 순서를 기억하기도 어려우며 어떤 세금이 적용되었는지도 파악하기 어렵다. 이 문제는 아래처럼 불리언 플래그를 설정하는 최소 DSL을 제공하는 TaxCalculator를 이용하는 것이 더 좋은 방법이다.
public class TaxCalculator {
private boolean useRegional;
private boolean useGeneral;
private boolean useSurcharge;
public TaxCalculator withTaxRegional() {
this.useRegional = true;
return this;
}
public TaxCalculator withTaxGeneral() {
this.useGeneral = true;
return this;
}
public TaxCalculator withTaxSurcharge() {
this.useSurcharge = true;
return this;
}
public double calculate(Order order) {
return Tax.calculate(order, useRegional, useGeneral, useSurcharge);
}
}
다음 코드처럼 사용해 가독성을 높일 수 있다.
double value = new TaxCalculator().withTaxRegional()
.withTaxSurcharge()
.calculate(order);
하지만 코드가 장황하다. 도메인의 각 세금에 해당하는 불리언 필드가 필요하므로 확장성도 제한적이다. 자바의 함수형 기능을 이용하면 더 간결하고 유연한 방식으로 같은 가독성을 보여줄 수 있다. 아래 코드처럼 TaxCalculator를 어떻게 리팩터링해보자
public class TaxCalculator {
// 주문값에 적용된 모든 세금을 계산하는 함수
public DoubleUnaryOperator taxFunction = price -> price;
public TaxCalculator with(DoubleUnaryOperator operator) {
// 새로운 세금 계산 함수를 얻어서 인수로 전달된 함수와 현재 함수를 합침
taxFunction = taxFunction.andThen(operator);
return this;
}
public double calculate(Order order) {
// 주문의 총 합에 세금 계산 함수를 적용해 최종 주문값을 계산
return taxFunction.applyAsDouble(order.getValue());
}
}
이 기법은 주문의 총 합에 적용할 함수 한 개의 필드만 필요로하며 TaxCalculator 클래스를 통해 모든 세금 설정이 적용된다. 이 함수의 시작값은 확인 함수다. 처음 시점에서는 세금이 적용되지 않았으므로 최종값은 총합과 같다. with() 메서드로 새 세금이 추가되면 현재 세금 계산 함수에 이 세금이 조합되는 방식으로 한 함수에 모든 추가된 세금이 적용된다. 마지막으로 주문을 calculate() 메서드에 전달하면 다양한 세금 설정의 결과로 만들어진 세금 계산 함수가 주문 합계에 적용된다.
double value3 = new TaxCalculator().with(Tax::regional)
.with(Tax::surcharge)
.calculate(order);