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;
}
}
2. 주어진 가격에서 주어진 양의 주식을 사거나 파는 거래(trace)
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;
}
}
마지막으로 고객이 요청한 한 개 이상의 거래의 주문
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();
}
}
getter/setter로 주문을 의미하는 객체를 만드는 코드는 귀찮을 뿐더러 비개발자인 도메인 전문가가 이해하고 검증하기 어려워 직관적으로 도메인 모델을 반영할 수 있는 DSL 이 필요하다.
메서드 체인
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로 도메인 객체를 만드는 몇 개의 빌더를 구현해야 한다.
최상위 수준 빌더를 만들고 주문을 감싼 다음 한 개 이상의 거래를 주문에 추가할 수 있어야 한다.
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; //주문 만들기를 종료하고 Order 객체를 반환
}
public TradeBuilder buy(int quantity) { //주식을 사는 TradeBuilder 만들기
return new TradeBuilder(this, Trade.Type.BUY, quantity);
}
public TradeBuilder sell(int quantity) { //주식을 파는 TradeBuilder 만들기
return new TradeBuilder(this, Trade.Type.SELL, quantity);
}
private MethodChainingOrderBuilder addTrade(Trade trade) {
order.addTrade(trade); //주문에 주식 추가
return this; //유연하게 추가 주문을 만들어 추가할 수 있도록 주문 빌더 자체를 반환
}
}
주문 빌더의 buy(), sell() 메서드는 다른 주문을 만들어 추가할 수 있도록 자신을 만들어 반환한다.
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 만들기
}
}
Stock 클래스의 인스턴스를 만드는 TradeBuilder의 public 메서드를 이용해야 한다.
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); //최종 builder 반환
}
}
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); //원래 주문 빌더를 반환
}
}
MethodChainingOrderBuilder가 끝날 때 까지 Builder가 바뀔 때마다 생성자를 통해서 현재까지 Builder를 그대로 전달하면서 이어나가며 각자의 Builder에서 공개 메서드를 통해 처리하는 것을 볼 수 있었다.
장점
여러 빌드 class를 따로 만들어 사용자가 미리 지정된 절차에 따라 플루어트 API의 메서드를 호출하도록 강제한다.
사용자가 다음 거래를 설정하기 전에 기존 거래를 올바로 설정하게 된다.
주문에 사용한 파라미터가 빌더 내부로 국한된다는 잇점도 제공한다.
정적 메서드 사용을 최소화하고 메서드 이름이 인수의 이름을 대신하도록 만들어 DSL의 가독성을 개선하는 효과를 더한다.
단점
빌더를 구현해야 함 - Lombok이 개발하기 정말 편하게 도와준다를 느끼는 시간,,,😅
상위 수준의 빌더를 하위수준의 빌더와 연결할 많은 접착 코드가 필요
도메인의 객체의 중첩 구조와 일치하게 들여쓰기를 강제하는 방법이 없다
중첩된 함수 이용
다른 함수 안에 함수를 이용해 도메인 모델을 만든다
Order order = order("BigBank",
buy(80,
stock("IBM", on("NYSE")),
at(125.00)),
sell(50,
stock("GOOGLE", on("NASDAQ")),
at(375.00))
);
static-import 했다는 가정으로 간결하게 작성 가능하다.
public class NestedFunctionOrderBuilder {
public static Order order(String customer, Trade... trades) { //가변 인수로 trade 여러개
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;
} //주식이 거래된 시장을 정의하는 더미 메서드
}
장점
메서드 체인에 비해 함수의 중첩 방식이 도메인 객체 계층 구조에 그대로 반영
주문은 한 개 이상의 거래를 포함(가변 인자를 통해서)하며 각 거래는 한 개의 주식(stock메서드 호출해서 인자로 넘겨줌)을 참조한다.
단점
결과 DSL에 더 많은 괄호를 사용하며 인수 목록을 정적 메서드에 넘겨줘야 한다는 제약
VO,DTO로 변환할 때 사용된다면 각각의 선택사항 필드가 있으면 인수를 생략할 수 있어 여러 Override메서드를 구현해야 한다.
인수의 의미가 이름이 아니라 위치에 의해 정의되었다.
인수의 역활을 확실하게 만들기 위해 at(), on() 더미 메서드를 통해서 문제를 조금 완화했다.
람다 표현식을 이용한 함수 시퀀싱
람다 표현식을 정의한 함수 시퀀스를 사용한다.
람다 표현식을 받아 실행해 도메인 모델을 만들어 내는 여러 빌더를 구현해야 한다.
Order order = LambdaOrderBuilder.order( o -> { //LambdaOrderBuilder
o.forCustomer("BigBank");
o.buy( t -> { //TradeBuilder
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");
});
});
});
메서드 체인 패턴을 이용해 만들려는 객체의 중간 상태를 유지해야 한다.
Consumer 객체를 빌더가 인수로 받음으로 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는 세 번째 빌더의 Consumer는 Consumer<StockBuilder> 람다 표현식으로 거래된 주식을 받는다.
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을 만드는 방법
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)));
중첩된 함수 패턴을 람다 기법과 혼용하여 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.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(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 만들기
주문의 총 합에 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 (useSurcharge) {
value = Tax.surcharge(value);
}
return value;
}
이 방법은 불리언 변수의 올바른 순서를 기억하기 어렵고, 어떤 세금이 적용되었는지도 파악하기 어렵다.
calculate(order, true, false, true);
적용할 세금을 유창하게 정의하는 세금 계산기
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 calculateF(Order order) {
// 주문의 총 합에 세금 계산 함수를 적용해 최종 주문값을 계산
return taxFunction.applyAsDouble(order.getValue());
}
}
주문의 총 합에 적용할 함수 한개의 필드만 필요로 하며 TaxCalculator 클래스를 통해 모든 세금 설정이 적용된다.
.with() 메서드로 새 세금이 추가되면 현재 세금 계산 함수에 조합되는 방식으로 한 함수에 모든 추가된 세금이 적용된다.
calculate() 메서드에 전달하면 다양한 세금 설정의 결과로 만들어진 세금 계산 함수가 주문의 합계에 적용된다.
value = new TaxCalculator().with(Tax::regional)
.with(Tax::surcharge)
.calculateF(order);
실생활의 자바 8 DSL
SQL 매핑도구, 동작 주도 개발 프레임워크, 엔터프라이즈 통합 패턴을 구현하는 세 가지 자바 라이브러리를 확인하고 지금까지 살펴본 패턴이 얼마나 사용되고 있는지 보자!
jOOQ
jOOQ는 SQL을 구현하는 내부적 DSL로 자바에 직접 내장된 형식 안전 언어다.
DB 스키마를 역공학하는 소스코드 생성기 덕분에 자바 컴파일러가 복잡한 SQL 구문의 형식을 확인할 수 있다.
SQL 문법을 흉내내려면 선택적 파라미터를 허용하고 정해진 순서대로 특정 메서드가 호출되어야 하기 때문에 메서드 체인 패턴의 특성이 필요하다.
SELECT * FROM BOOK
WHERE BOOK.PUBLISHED_IN = 2016
ORDER BY BOOK.TITLE;
Class.forName("org.h2.Driver");
try(Connection c =
getConnection("jdbc:h2:~/sql-goodies-with-mapping", "sa", "")) { //SQL DB 연결 만들기
DSL.using(c)
.select(BOOK.AUTHOR, BOOK.TITLE) //만들어진 DB 연결을 이용해서 JOOQ SQL문 시작
.where(BOOK.PUBLISHED_IN.eq(2016))
.orderBy(BOOK.TITLE)
.fetch() //SQL 문 정의
.stream() //DB에서 데이터 가져오기 JOOQ 문은 여기서 종료
.collect(groupingBy(
r -> r.getValue(BOOK.AUTHOR),
LinkedHashMap::new,
mapping(r -> r.getValue(BOOK.TITLE), toList())))
.forEach((author, titles) -> System.out.println(author + " is author of " + title));
}
큐컴버(cucumber)
동작 주도 개발(Behavior-driven development)은 TDD의 확장으로 확장으로 다양한 비즈니스 시나리오를 구조적으로 서술하는 도메인 전용 스크립팅 언어를 사용한다.
큐컴버는 BDD 프레임워크로 명령문을 실행할 수 있는 테스트 케이스로 변환하고 비즈니스 시나리오를 평문으로 구현할 수 있도록 도와준다.
//문장은 테스트 케이스의 변수를 캡쳐하는 정규 표현식으로 매칭되고 테스트 자체를 구현하는 메소드로 문장을 전달한다.
Feature: Buy stock
Senario: 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 {
private Map<String, Integer> stockUnitPrices = new HashMap<>();
private Order order = new Order();
@Given("^the price of a \"(.*?)\" stock is (\\d+)\\$$") // 시나리오의 전체 조건인 주식 단가 정의
public void setUnitPrice(String stockName, int unitPrice) {
stockUnitValues.put(stockName, unitPrice); //주식 단가 저장
}
@When("^I buy (\\d+) \"(.*?)\"$")
public void buyStocks(int quantity, String stockName) {
Trade trade = new Trade(); //적절하게 도메인 모델 도출
trade.setType(Trade.Type.BUY);
Stock stock = new Stock();
...
}
@Then("^the order value should be (\\d+)\\$$")
public void checkOrderValue(int expectedValue) { //예상되는 시나리오 결과 정의
assertEquals(expectedValue, order.getValue()); //테스트 어설션 확인
}
}
어노테이션을 제거하여 코드가 단순해지고 메서드가 무명 람다로 바뀌면서 메서드 이름을 찾는 부담이 없어진다.
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);
});
...
}
}
스프링 통합
스프링 통합은 유명한 엔터프라이즈 통합 패턴을 지원할 수 있도록 의존성 주입에 기반한 스프링 프로그래밍 모델을 확장한다.
스프링 통합 DSL 에서도 메서드 체인 패턴이 가장 널리 사용되고 있다.
핵심 목표
복잡한 통합 솔루션 모델을 제공한다.
비동기, 메시지 주도 아키텍처를 쉽게 적용할 수 있도록 돕는다.
@Configuration
@EnableIntegration
public class MyConfiguration {
@Bean
public MessageSource<?> integerMessageSource() {
MethodInvokingMessageSource source = new MethodInvokingMessageSource();
source.setObject(new AtomicInteger());
source.setMethodName("getAndIncrement");
return source;
}
@Bean
public DirectChannel inputChannel() {
return new DirectChannel //MessageSource에서 도착하는 데이터를 나르는 채널
}
@Bean
public IntegrationFlow myFlow() {
return IntegrationFlows
.from(integerSource::getAndIncrement, //integerSource를 integrationFlow의 입력으로 사용
c -> c.poller(Pollers.fixedRate(10))) //MessageSource를 폴링하면서 MessageSource가 나르는 데이터를 가져옴
.channel(inputChannel())
.filter((Integer p) -> p % 2 == 0)
.transform(Object::toString) //MessageSource에서 가져온 정수를 문자열로 변환
.channel(MessageChannels.queue()) //queueChannel을 IntegrationFlow의 결과로 설정
.get();
}
}