개발팀과 도메인 전문가가 이해할 수 있는 코드는 생산성과 직결되기 때문에 코드는 읽기 쉽고 이해하기 쉬워야 한다.
- 도메인 전용 언어(DSL)는 특정 도메인을 대상으로 만들어진 프로그래밍 언어로 이 문제를 해결할 수 있다.
도메인 전용 언어(domain-specific languages, DSL)
DSL은 범용 프로그래밍 언어가 아니며, 특정 비즈니스 도메인의 문제를 해결하려고 만든 언어다.
- 특정 도메인에만 국한되므로 오직 자신의 문제를 어떻게 해결할지에만 집중할 수 있고 특정 도메인의 복잡성을 잘 다룰 수 있다.
DSL을 개발하기 위한 두가지 필요성
- 의사 소통의 왕: 프로그래머가 아닌 사람도 이해할 수 있도록 코드의 의도가 명확히 전달되어야 한다.(코드가 비즈니스 요구사항에 부합하는지 확인할 수 있다.)
- 한 번 코드를 구현하지만 여러번 읽는다: 가독성은 유지보수의 핵심이므로 항상 동료가 쉽게 이해할 수 있도록 코드 구현해야 한다.
DSL의 장점과 단점
DSL은 코드의 비즈니스 의도를 명확하게 하고 가독성을 높인다는 점에서 약이되지만 DSL 구현은 코드이므로 올바로 검증하고 유지보수해야 하는 책임이 따른다.
장점
- 간결함: 비즈니스 로직을 간편하게 캡슐화하므로 반복을 피하고 코드를 간결하게 만들 수 있다.
- 가독성: 도메인 영역의 용어를 사용하므로 비 도메인 전문가도 쉽게 이해 가능하기 때문에 다양한 조직 구성원 간에 코드와 도메인 영역이 공유될 수 있다.
- 유지보수: 잘 설계된 DSL로 구현한 코드는 쉽게 유지보수하고 변경 가능하다.
- 높은 수준의 추상화: DSL 은 도메인과 같은 추상화 수준에서 동작하므로 도메인의 문제와 직접적으로 관련되지 않은 세부 사항을 숨긴다.
- 집중: 비즈니스 도메인의 규칙을 표현할 목적으로 설계된 언어이므로 프로그래머가 특정 코드에 집중 가능하여 생산성이 향상된다.
- 관심사 분리(Separation of concerns): 애플리케이션의 인프라구조와 관련된 문제와 독립적으로 비즈니스 관련된 코드에서 집중 하기가 용이해 유지보수가 향상된다.
단점
- DLS 설계의 어려움: 간결하게 제한적인 언어에 도메인 지식을 담기가 어렵다.
- 개발 비용: 코드에 DSL을 추가하는 작업은 초기 프로젝트에 많은 비용과 시간이 소모되며, DSL 유지보수와 변경은 프로젝트에 부담을 주는 요소이다.
- 추가 우회 계층: DSL은 추가적인 계층으로 도메인 모델을 감싸며 계층을 최대한 작게 만들어 성능 문제를 회피한다.
- 새로 배워야 하는 언어: DSL을 프로젝트에 추가하면서 팀이 배워야 하는 언어가 한 개 더늘어나는 부담이 있으며, 개별DSL을 사용하는 상황이라면 이들을 유기적으로 동작하도록 합치는 일은 쉬운일이 아니다.
- 호스팅 언어 한계: 자바 같은 범용 프로그래밍 언어는 장황하고 엄격한 문법을 가지므로 사용자 친화적 DSL 만들기 힘들다. 람다 표현식을 이용해 최대한 해결할 수 있다.
내부 DSL
내부 DSL(임베디드 DSL이라고 불린다.)은 순수 자바 코드같은 기존 호스팅 언어를 기반으로 구현한다.
- 자바 문법 때문에 읽기 쉽고 간단한 DSL을 만드는데 한계가 있었지만 람다 표현식의 등장으로 어느정도 해결되었다.
- 익명 내부 클래스에는 신호대비 잡음이 있을 수 있는데 람다 표현식 또는 메소드참조로 해결할 수 있다.
List<String> numbers = Arrays.asList("one", "two", "three");
numbers.forEach(new Consumer<String>() {
@Override
public void accept(String s) {
System.out.println(s);
}
});
//메소드 참조
numbers.forEach(System.out::println);
순수 자바로 DSL을 구현할 때 얻는 장점
- 자바 언어를 이용하면 외부 DSL에 비해 새로운 패턴과 기술을 배워 DSL을 구현하는 노력이 줄어든다.
- 다른 언어의 컴파일러를 이용하거나 외부 DSL을 만드는 도구를 사용하지 않고, 나머지 자바 코드와 함께 DSL을 컴파일 할 수 있다.
- DSL 사용자는 기존의 자바 IDE를 이용해 자동완성, 자동 리팩터링 같은 기능을 그대로 사용가능하다.
- 여러 도메인을 대응하기 위한 추가 DSL을 쉽게 기존 코드와 합칠 수 있다.
다중 DSL
제약을 줄이고, 간단한 문법을 지향하도록 설계된 JVM 기반 프로그래밍 언어는 같은 자바 바이트코드를 이용하기 때문에 DSL 합침 문제를 해결할 수 있다.
JVM 기반 언어는 DSL 친화적이지만 다음과 같은 단점들이 존재한다.
- 새로운 프로그래밍 언어를 배우거나 누군가 해당 기술을 가지고 있어야 한다.
- 두 개 이상의 언어가 혼재하므로 여러 컴파일러로 소스를 빌드하도록 빌드 과정을 개선해야 한다.
- JVM에서 실행되지만 자바와 호환성이 완벽하지 않을 때가 많아서 성능이 손실될 때도 있다.
외부 DSL
스탠드어론(standalone)이라 불리는 외부 DSL은 호스팅 언어와는 독립적으로 자체의 문법을 가진다.
장점
- 외부 DSL이 제공하는 무한한 유연성을 가진다.
- 필요한 특성을 완벽하게 제공하는 언어로 설계할 수 있다.
- 언어를 제대로 설계하면 비즈니스 문제를 묘사하고 해결하는 가독성 좋은 언어를 얻을 수 있다.
- 자바로 개발된 인프라구조 코드와 외부 DSL로 구현한 비즈니스 코드를 명확하게 분리 가능하다.
단점
- 일반적인 작업이 아니며 쉽게 기술을 얻을 수 없다.
- 자신만의 문법과 구문으로 새 언어를 설계해야 하며 언어 파싱하고, 파서의 결과 분석, 외부 DSL 실행할 코드를 만들어야 한다.
- ANTLR 같은 자바 기반 파서 생성기를 이용하면 도움을 받을 수 있지만 작업이 복잡하고 제어 범위를 쉽게 벗어날 수 있으며 처음 설계한 목적을 벗어나는 경우가 많다.
- 코드 분리로 인해서 DSL과 호스트 언어 사이에 인공 계층이 생김
최신 자바 API의 작은 DSL
- 자바의 새로운 기능의 장점을 적용한 첫 API는 네이티브 자바 API 자신이다.
- 람다 표현식과 메소드 참조를 이용해 DSL의 가독성, 재사용성, 결합성이 높아졌다.
스트림 API는 컬렉션을 조작하는 DSL
Stream은 컬렉션의 항목을 필터, 정렬, 변환, 그룹화, 조작하는 작지만 강력한 DSL이다.
List<String> errorList = Files.lines(Paths.get(fileName))
.filter(str -> str.startsWith("error"))
.limit(40)
.collect(Collectors.toList());
- Files.lines는 정적 유틸리티 메서드로 Stream<String>을 반환하기때문에 Stream 인터페이스를 이용해 함수형으로 쉽고 간결하게 코드 작성이 가능하다.
- 스트림 API의 플루언트 형식은 잘 설계된 DSL의 특징 중 하나이다.(중간 연산은 게으르며 다른 연산으로 파이프라인 형태, 최종 연산은 적극적이며 전체 파이프라인이 계산을 일으킨다.
데이터를 수집하는 DSL인 Collectors
Stream 인터페이스 ➡️ 데이터 리스트를 조작하는 DSL
Collector 인터페이스 ➡️ 데이터 수집을 수행하는 DSL
//자동차를 브랜드별 그리고 색상별로 그룹화
Map<String, Map<Color, List<Car>>> carsByBrandAndColor =
cars.stream().collect(grouping(Car::getBrand, groupingBy(Car::getColor)));
// 두 Comparator를 플루언트 방식으로 연결
Comparator<Person> comparator =
comparing(Person::getAge).thenComparing(Person::getName);
// Collectors를 중첩해서 다중 수준 Collector
Collector<? super Car, ?, Map<Brand, Map<Color, List<Car>>>> carGroupingCollector =
groupingBy(Car::getBrand, groupingBy(Car::getColor));
- Comparator 인터페이스 ➡️ 다중 필드 정렬을 지원하도록 합쳐질 수 있다.
- Collectors ➡️ 다중 수준 그룹화를 달성할 수 있도록 합쳐질 수 있다.
Fluent API (fluent interface)란?
- 메소드 체이닝에 상당 부분 기반한 객체 지향 API 설계 메소드
메소드 체이닝(Method)
OOP에서 여러 메소드를 이어서 호출하는 문법 메소드가 객체(this)를 반환하여 여러 메소드를 순차적으로 선언할 수 있도록 한다. 모든 메서드를 함께 연결하여 단일 명령문을 형성할 수 있는 일반적인 기술이다.
소스 코드의 가독성을 산문과 유사하게 만드는 것이 목적
개발자들이 읽기 편하고, 라인 줄 줄이고, 간단하게 기술할 수 있다는 매력을 갖고 있다. 특히 인터페이스 안에 도메인 특화 언어(DSL)를 작성
자바로 DSL을 만드는 패턴과 기법
간단한 도메인 모델을 정의하고 DSL 패턴을 살펴보자
1. 주어진 시장에 주식 가격을 모델링하는 순수 자바 빈즈
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;
jOOQ DSL을 이용해 위 질의를 다음처럼 구현할 수 있다.
create.selectFrom(BOOK)
.where(BOOK.PUBLISHED_IN.eq(2016))
.orderBy(BOOK.TITLE)
jOOQ DSL은 스트림 API 와 조합해서 사용할 수 있다.
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();
}
}
'책 > 모던 자바 인 액션' 카테고리의 다른 글
12. 새로운 날짜와 시간 API (0) | 2023.08.17 |
---|---|
11. null 대신 optional 클래스 (0) | 2023.08.11 |
9. 리팩터링, 테스팅, 디버깅 (0) | 2023.08.02 |
8. 컬렉션 API 개선 (0) | 2023.07.28 |
7. 병렬 데이터 처리와 성능 (0) | 2023.07.26 |