[모던 자바] DSL(도메인 전용 언어)이란?
DSL(Domain-specific Languages, 도메인 전용 언어)란?
DSL은 특정 비즈니스 도메인의 문제를 해결하려고 만든, 작은, 범용이 아닌 특정 도메인을 대상으로 만들어진 특수 프로그래밍 언어다.
예를 들어 쇼핑몰 플랫폼을 개발한다고 가정했을 때, 비지니스 도메인에는 상품을 결제하고 배송처리와 같은 개념이 포함된다.
DSL에란 특정 비즈니스 도메인을 인터페이스로 만든 API라고 생각할 수 있다.
(자바 8에서는 Stream, Collector 등 여러 가지 작은 DSL들이 추가되었다.)
자바에서는 도메인을 표현할 수 있는 클래스와 메서드 집합이 필요하다.
DSL에서 동작과 용어는 특정 도메인에 국한되므로 다른 문제는 걱정할 필요가 없어서 특정 도메인의 복잡성을 더 잘 다룰 수 있고, 저수준 구현 메서드는 클래스의 비공개로 만들어서 저수준 구현 세부 내용은 숨길 수 있어서 가독성이 좋아진다.
다음 두 가지 필요성을 생각하면서 DSL을 개발해야 한다.
DSL을 개발할 때는 아래 내용을 항상 생각해야 한다.
- 코드의 의도가 명확히 전달되어야 하며, 개발자가 아닌 사람도 이해할 수 있어야 하고, 비지니스 요구사항에 부합하는지 확인한다.
- 가독성은 유지보수의 핵심이다. 즉 항상 동료가 쉽게 이해할 수 있도록 코드를 구현해야 한다.
DSL의 장점과 단점
모든 SW 개발 기술, 솔루션이 그렇듯 DSL은 만병통치약이 아니다.
DSL을 도메인에 이용하면 약이 되거나 독이 될 수 있다.
DSL은 코드의 비즈니스 의도를 명활 하게 하고 가동성을 높인다는 점에서 약이 된다.
반면 DSL 구현을 올바로 검증하고 유지보수해야 하는 책임이 따른다.
따라서 프로젝트에 DSL을 추가하는 것이 투자대비 긍정적인 결과를 가져올 것인지 생각해봐야 한다.
장점
간결함
API는 비지니스 로직을 간편하게 캡슐화하므로 반복을 피할 수 있고 코드를 간결하게 만들 수 있다.
가독성
도메인 영역의 용어를 사용하므로 비 도메인 전문가도 코드를 쉽게 이해할 수 있다.
결과적으로 다양한 조직 구성원 간에 코드와 도메인 영역이 공유될 수 있다.
유지보수
잘 설계된 DSL로 구현한 코드는 쉽게 유지 보수할 수 있다.
유지보수는 비지니스 관련 코드 즉 가장 빈번히 바뀌는 애플리케이션 부분에 특히 중요하다.
높은 수준의 추상화
DSL은 도메인과 같은 추상화 수준에서 동작하므로 도메인의 문제와 직접적으로 관련되지 않은 세부 사항을 숨긴다.
집중
비지니스 도메인의 규칙을 표현할 목적으로 설계된 언어이므로 프로그래머가 특정 코드에 집중할 수 있어 결과적으로 생산성이 좋아진다.
관심사 분리
애플리케이션의 인프라구조와 관련된 문제와 독립적으로 비지니스 관련된 코드에서 집중하기가 용이해 결과적으로 유지보수가 쉬운 코드를 구현한다.
단점
DSL 설계의 어려움
간결하게 제한적인 언어에 도메인 지식을 담는 것이 쉬운 작업은 아니다.
개발 비용
코드에 DSL을 추가하는 작업은 초기 프로젝트에 많은 비용과 시간이 소모되는 작업이다.
또한 DSL 유지보수와 변경은 프로젝트에 부담을 주는 요소다.
추가 우회 계층
DSL은 추가적인 계층으로 도메인 모델을 감싸며 이때 계층을 최대한 작게 만들어 성능 문제를 회피한다.
새로 배워야 하는 언어
요즘에는 한 프로젝트에 여러 언어를 사용하는 추세다.
하지만 DSL을 프로젝트에 추가하면서 배워야 하는 언어가 한 개 더 늘어난다는 부담이 있다.
여러 비지니스 도메인을 다루는 개별 DSL을 사용하는 상황이라면 개별 DSL이 독립적으로 진화할 수 있기 때문에 유기적으로 동작하도록 합치는 일은 쉬운 일이 아니다.
호스팅 언어 한계
일부 자바 같은 범용 프로그래밍 언어는 장황하고 엄격한 문법을 가졌다.
이런 언어로는 사용자 친화적 DSL을 만들기 힘들다. 사실 장황한 프로그래밍 언어를 기반으로 만든 DSL은 성가진 문법의 제약을 받고 읽기가 어려워진다. Java8의 람다 표현식은 이 문제를 해결할 강력한 새 도구다.
자바로 DSL을 만드는 패턴과 기법
쇼핑몰에서 주문을 하는 간단한 예제로 DSL을 만드는 패턴을 알아보도록 하겠다.
@Getter
@Setter
@AllArgsConstructor
public class Product { // 상품
private String name;
private int price;
}
@Getter
@Setter
@AllArgsConstructor
public class OrderBasket { // 장바구니
private Product product;
private int orderCount;
public void countUp() { // 주문 상품 개수 Up
this.orderCount++;
}
public void countDown() { // 주문 상품 개수 Down
if (this.orderCount > 1) this.orderCount--;
}
}
@Getter
@Setter
@AllArgsConstructor
public class Order { // 주문
private List<OrderBasket> orderBaskets = new ArrayList<>();
private OrderStatus status;
public void addProduct(Product product, int orderCount) { // 상품 추가
this.orderBaskets.add(new OrderBasket(product, orderCount));
}
public void completed() { // 주문 완료
this.status = OrderStatus.COMPLETED;
}
}
위에 상품, 장바구니, 주문 클래스로 사용자가 특정 상품들을 장바구니에 담에 주문을 만드는 로직을 구현해보자.
Product product1 = new Product("청소기", 200_000);
Product product2 = new Product("냉장고", 1_200_000);
Coupon coupon = new Coupon("100,000원 할인 쿠폰", 100_000);
Order order = new Order();
// 청소기 1개 선택
OrderBasket orderBasket1 = new OrderBasket(product1, 1);
// 장바구니 추가
order.addOrderBasket(orderBasket1);
// 냉장고 1개 선택
OrderBasket orderBasket2 = new OrderBasket(product2, 1);
// 냉장고 2개 선택
orderBasket2.countUp();
// 냉장고 3개 선택
orderBasket2.countUp();
// 장바구니 추가
order.addOrderBasket(orderBasket2);
// 쿠폰 적용
order.applyCoupon(coupon);
// 주문 완료
order.completed();
지금은 꽤 간단하지만, 필드와 메서드가 많아진다면 가독성이 더욱 떨어져 직관적으로 코드를 이해하고, 검증을 할 수 없어질 것이다. 조금 더 직접적이고, 직관적으로 도메인 모델을 반영할 수 있는 DSL이 필요하다.
1. 메서드 체인을 이용한 DSL
메서드 체인을 이용한 DSL은 가장 흔한 방식 중 하나다.
인스턴스의 생성 단계를 Builder 클래스에 구현해서 사용자가 미리 저정된 절차에 따라 플루언트 API의 메서드를 호출하도록 강제할 수 있다.
public class MethodChainingOrderBuilder {
public final Order order;
public MethodChainingOrderBuilder() {
this.order = new Order();
}
public static MethodChainingOrderBuilder builder() {
return new MethodChainingOrderBuilder();
}
public OrderBasketBuilder choice() {
return new OrderBasketBuilder(this);
}
public MethodChainingOrderBuilder addOrderBasket(OrderBasket orderBasket) {
order.addOrderBasket(orderBasket);
return this;
}
public MethodChainingOrderBuilder applyCoupon(Coupon coupon) {
order.applyCoupon(coupon);
return this;
}
public Order completed() {
order.completed();
return order;
}
}
public class OrderBasketBuilder {
private MethodChainingOrderBuilder builder;
private Product product;
private int count;
public OrderBasketBuilder(MethodChainingOrderBuilder builder) {
this.builder = builder;
}
public OrderBasketBuilder product(Product product) {
this.product = product;
return this;
}
public OrderBasketBuilder count(int count) {
this.count = count;
return this;
}
public OrderBasketBuilder countUp() {
this.count++;
return this;
}
public OrderBasketBuilder countDown() {
if (this.count > 1) this.count--;
return this;
}
public OrderBasketBuilder and() {
builder.addOrderBasket(new OrderBasket(this.product, this.count));
return new OrderBasketBuilder(builder);
}
public MethodChainingOrderBuilder end() {
builder.addOrderBasket(new OrderBasket(this.product, this.count));
return builder;
}
}
주문 인스턴스를 생성하는 Builder를 통해 플루언트 방식으로 상품을 선택하고 주문 완료까지 한 번에 처리가 가능하다.
Order order = MethodChainingOrderBuilder.builder()
.choice()
.product(product1).count(1)
.and()
.product(product2).count(1).countUp().countUp()
.end()
.applyCoupon(coupon)
.completed();
이 방법은 메서드의 이름이 인수의 이름을 대신하도록 만듦으로 이런 형식의 DSL의 가독성을 개선하는 효과를 더하고, 분법적 잡음이 최소화된다.
하지만 빌더를 구현해야 한다는 것이 단점이다.
상위 수준의 빌더를 하위 수준의 빌더와 연결할 접착 많은 코드가 필요하다. 그리고 도메인 구조와 일치하도록 들여 쓰기를 강제하는 방법이 없다는 것도 단점이다.
2. 중첩된 함수 이용
중첩된 함수 DSL 패턴은 이름에서 알 수 있듯이 다른 함수 안에 함수를 이용해 도메인 모듈을 만든다.
public static class NestedFunctionOrderBuilder {
public static Order order(Coupon coupon, OrderBasket ... orderBaskets) {
Order order = new Order();
Stream.of(orderBaskets).forEach(order::addOrderBasket);
return order;
}
public static Product product(Product product) {
return product;
}
public static int orderCount(int orderCount) {
return orderCount;
}
public static OrderBasket choice(Product product, int orderCount) {
return new OrderBasket(product, orderCount);
}
}
Product product1 = new Product("청소기", 200_000);
Product product2 = new Product("냉장고", 1_200_000);
Coupon coupon = new Coupon("100,000원 할인 쿠폰", 100_000);
Order order = order(
coupon,
choice(
product(product1),
orderCount(1)
),
choice(
product(product2),
orderCount(1)
)
).completed();
메서드 체인에 비해 함수의 중첩 방식이 도메인 객체 계층 구조에 그대로 반영된다는 것이 장점이다.
하지만 DSL에 더 많은 괄호를 사용해야 하고, 인수 목록을 정적 메서드에 넘겨줘야 한다는 제약도 있다.
도메인 객체에 선택 사항 필드가 있으면 인수를 생략할 수 있으므로 이 가능성을 처리할 수 있도록 여러 매서드 오버라이드를 구현해야 한다.
3. 람다 표현식을 이용한 함수 시퀀싱
람다 표현식으로 정의한 함수 시퀀스를 사용한다.
public static class LambdaOrderBuilder {
private final Order order;
public LambdaOrderBuilder() {
this.order = new Order();
}
public static Order order(Consumer<LambdaOrderBuilder> consumer) {
LambdaOrderBuilder builder = new LambdaOrderBuilder();
consumer.accept(builder);
return builder.order;
}
public void choice(Consumer<OrderBasketBuilder> consumer) {
OrderBasketBuilder builder = new OrderBasketBuilder();
consumer.accept(builder);
order.addOrderBasket(builder.build());
}
public void applyCoupon(Coupon coupon) {
order.applyCoupon(coupon);
}
}
public static class OrderBasketBuilder {
private Product product;
private int orderCount;
public void product(Product product) {
this.product = product;
}
public void orderCount(int orderCount) {
this.orderCount = orderCount;
}
public OrderBasket build() {
return new OrderBasket(product, orderCount);
}
}
Product product1 = new Product("청소기", 200_000);
Product product2 = new Product("냉장고", 1_200_000);
Coupon coupon = new Coupon("100,000원 할인 쿠폰", 100_000);
Order order = order(b -> {
b.choice(b2 -> {
b2.product(product1);
b2.orderCount(1);
});
b.choice(b2 -> {
b2.product(product2);
b2.orderCount(1);
});
b.applyCoupon(coupon);
});
이 패턴은 이전 두 가지 DSL 형식의 두 가지 장점을 더한다.
메서드 체인 패턴처럼 플루언트 방식으로 거래 주문을 정의할 수 있다. 또한 중첩 함수 형식처럼 다양한 람다 표현식의 중첩 수준과 비슷하게 도메인 객체의 계층 구조를 유지한다.
하지만 많은 설정 코드가 필요하며 DSL 자체가 Java8 람다 표현식 문법에 의한 잡음의 영향을 받는다는 것이 단점이다.
어떤 DSL 형식을 사용할 것인지는 각자의 기호에 달렸다.
한 가지의 패턴만 사용하라는 법도 없다. 여러 패턴을 혼용해 가독성 있는 DSL을 만들 수도 있지만, 사용자가 보기엔 한 가지 패턴을 사용한 것보다 더욱 복잡하고 어렵게 느낄 수 있다.
자신이 만들려는 도메인 언어에 어떤 도메인 모델이 맞는지 찾으려면 실험을 해봐야 한다.
DSL에 메서드 참조 사용하기
위 상품을 주문하는 예제에 최종 금액을 계산하는 DSL을 만들어보자.
최종 주문 금액을 계산할 때 배송비를 포함하는지, 세금을 포함하는지에 따라 로직이 달라진다고 하자.
먼저 배송비와 세금을 더하는 로직의 정적 클래스들을 만들어보겠다.
public static class DeliveryCharge {
private static final int deliveryCharge = 2500;
public static int calculate(int resultPrice) {
return resultPrice + deliveryCharge;
}
}
public static class Tax {
private static final int taxPercent = 10;
public static int calculate(int resultPrice) {
return resultPrice + (resultPrice / 100 * taxPercent);
}
}
public class OrderPriceCalculator {
private boolean withDeliveryCharge = false;
private boolean withTax = false;
public OrderPriceCalculator withDeliveryCharge() {
this.withDeliveryCharge = true;
return this;
}
public OrderPriceCalculator withTax() {
this.withTax = true;
return this;
}
public int calculate(Order order) {
int totalOrderPrice = order.getOrderBaskets().stream()
.mapToInt(o -> o.getProduct().getPrice() * o.getOrderCount()).sum();
int discountCouponPrice = order.getCoupon() != null ? order.getCoupon().getDiscountPrice() : 0;
int resultPrice = totalOrderPrice - discountCouponPrice;
if (withDeliveryCharge) resultPrice = DeliveryCharge.calculate(resultPrice);
if (withTax) resultPrice = Tax.calculate(resultPrice);
return resultPrice;
}
}
int resultPrice = new OrderPriceCalculator()
.withDeliveryCharge()
.withTax()
.calculate(order);
최종 주문 금액을 계산할때 배송비, 세금 포함 여부를 확인한 후 위에서 만든 정적 클래스를 사용해서 최종 주문 금액을 도출해낸다.
위 기법은 주문 금액 계산할때 배송비와 세금을 추가해서 계산하는 것인지 명확하게 코드로 보여준다.
하지만 코드가 장황하고 도메인의 각 세금에 해당하는 불리언 필드가 필요하므로 확장성도 제한적이다.
자바의 함수형 기능을 이용하면 더 간결하고 유연한 방식으로 같은 가독성을 달성할 수 있다.
public class OrderPriceCalculator {
public IntUnaryOperator calFunction = d -> d;
public OrderPriceCalculator with(IntUnaryOperator after) {
calFunction = calFunction.andThen(after);
return this;
}
public int calculate(Order order) {
int totalOrderPrice = order.getOrderBaskets().stream()
.mapToInt(o -> o.getProduct().getPrice() * o.getOrderCount()).sum();
int discountCouponPrice = order.getCoupon() != null ? order.getCoupon().getDiscountPrice() : 0;
int resultPrice = totalOrderPrice - discountCouponPrice;
return calFunction.applyAsInt(resultPrice);
}
}
int resultPrice = new OrderPriceCalculator()
.with(DeliveryCharge::calculate)
.with(Tax::calculate)
.calculate(order);
위에서 메서드 참조를 사용했는데 메서드 참조는 읽기 쉽고 코드를 간결하게 만든다.
새로운 세금 함수를 Tax 클래스에 추가해도 OrderPriceCalculator를 바꾸지 않고 바로 사용할 수 있는 유연성도 제공한다.