본문 바로가기
자바

[모던 자바 인 액션] 람다를 이용한 도메인 전용 언어

by __Minnie_ 2025. 5. 2.
도메인 전용 언어(DSL)

DSL은 특정 비지니스 도메인의 문제를 해결하려고 만든 언어이다. DSL은 특정 도메인에 국한되기 때문에 오직 눈 앞에 놓인 문제를 어떻게 해결할지에만 집중하면 된다. 이를 이용하면 특정 도메인의 복잡성을 더 잘 다룰 수 있고, 저수준 구현을 캡슐화하여 사용자 친화적인 DSL을 만들 수 있다. 

 

DSL의 장점?

간결함, 가독성, 유지보수, 높은 수준의 추상화, 집중, 관심사의 분리 등이 있다.

 

DSL의 단점?

설계의 어려움, 개발 비용, 추가 우회 계층, 새로 배워야 하는 언어, 호스팅 언어 한계가 있다. 

 

내부 DSL, 다중 DSL, 외부 DSL

 

1. 내부 DSL 

내부 DSL이란 기존 호스팅 언러를 기반으로 구현하는 DSL을 말한다. 이 책에서는 java를 활용해서 구현한 DSL을 의미하며, 앞에서 배운 Stream API 역시 내부 DSL이다. 자바는 원래 문법적 제약이 많기 때문에 DSL을 만들기에는 적합하지 않았으나 동작파라미터화와 람다를 활용할 수 있게 되면서 제약이 많이 사라졌다. 내부 DSL을 사용하게 되면 다음과 같은 장점이 있다. 기존 호스팅 언어를 사용하기 때문에 새로운 패턴과 기술을 배우는 노력이 줄어든다. DSL을 다른 코드들과 함께 컴파일을 할 수 있따. 기존 자바의 기능들을 그대로 사용할 수 있다. 

 

2. 다중 DSL

요즘 스칼라, 그루비처럼 VM에서 실행될 수 있는 언어는 100개가 넘는다. 이런 JVM에서 실행되는 언어들을 기반으로 만들어진 다중DSL이라고 한다. 특히 스칼라는 커링, 임의 변환 등 DSL에 필요한 여러 기능들을 갖춰서 DSL을 개발에 굉장히 유연하게 대응이 가능하다. 하지만 다중 DSL을 만드려면 해당 언어를 이미 알고 있는 사람이 있거나 새롭게 배워야 한다. 또한 두개 이상의 언어를 사용한 것이기 때문에 빌드 과정을 개선해야 한다. 또한, JVM에서 실행되는 언어 대부분이 자바와의 호환을 지원하지만 완벽하지 않을 때도 있다. 

 

커링이란??

여러개의 인자들 나눠서 연쇄적으로 전달받을 수 있는 특징을 의미한다. 

// 기본 구조
def add(a: Int, b: Int): Int = a + b

add(1, 2) // 결과: 3

// 커링
def add(a: Int)(b: Int): Int = a + b

val plusOne = add(1)_   // 괄호 하나만 채우고, 함수로 리턴
plusOne(10)             // 결과: 11

def http(method: String)(handler: => Unit): Unit = {
  println(s"HTTP METHOD: $method")
  handler
}

http("GET") {
  println("Handling request")
}

// 결과
// HTTP METHOD: GET
// Handling request

 

임의 변환이란? 

// 예제1
implicit def stringToInt(s: String): Int = s.toInt

val x: Int = "123" // 자동으로 stringToInt 호출됨


// 예제2
class RichString(base: String) {
  def /(next: String): String = s"$base/$next"
}

implicit def stringToRich(s: String): RichString = new RichString(s)

val path = "user" / "123" / "info"
println(path) // 출력: user/123/info

임의 변환이 적용되기 위해서는 조건이 있는데, 먼저 기대되는 타입과 맞지 않아야 하며, 적절한 implicit함수가 범위 내에 선언되어 있어야 하고, 한번의 변환으로 타입이 맞춰질 수 있어야 한다. 

 

3. 외부 DSL

외부 DSL은 자신만으니 문법과 구문으로 새 언어를 설계하여 만드는 것을 말한다. 우리가 데이터 베이스 조작시 일반적으로 사용하는 SQL이 대표적이다. 

 

 

 

자바의 DSL

 

1. 스트림 API

스트림 API는 컬렉션을 필터, 정렬, 변환 그룹화하는 작지만 강력한 DSL이다. 컬렉션을 필터, 정렬, 변환, 그룹화 할 때 스트림 API를 사용하게 되면 굉장히 쉽고 간결하게 구현할 수 있다. 스트림 API의 특성인 메서드 체인을 보통 플루언트 스타일이라고 부른다. 

 

2. Collectors

Collectors는 데이터를 수집하는 DSL이다. Collectors는 스트림의 항목을 수집, 그룹화, 파이션한다. Collector는 플루언트 형식을 사용자히 않고 정적 메서드를 중첩하여 사용한다. 이 두형식의 차이점은 무엇일까? 평가 순서와 논리적 순서의 차이다. 풀루언트 형식을 사용하는 경우 평가순서와 논리적 순서가 동일하게 구성할 수 있지만, 정적 메서드를 중첩하여 사용하는 경우 평가 순서와 논리적 순서가 달라지게 된다.

Collector<? super Car, ?, Map<Brand, Map<Color, List<Car>>>> collector =
	groupingBy(Car::getBarnd, groupingBy(Car::getColor));

위 코드의 경우 정적 메소드를 중첩하여 사용한 경우인데, 이 경우 실제 평가되는 것은 color기준이 먼저 그룹화 되고 brand가 평가되기 때문에 color -> brand 순서이나, 우리가 코드에서 확인할 수 있는 논리적 순서는 brand -> color 순서이다. 

 

위 순서를 맞춰주기 위해서는 빌더를 커스텀하여 생성해야 한다. 아래의 경우, 평가순서와 , 논리적 결과가 color -> brand로 동일한 것을 알 수 있다. 

public static class GroupingBuilder<T, D, K> {
    private final Collector<? super T, ?, Map<K, D>> collector;

    public GroupingBuilder(Collector<? super T, ?, Map<K, D>> collector) {
        this.collector = collector;
    }

    public Collector<? super T, ?, Map<K, D>> get() {
        return this.collector;
    }

    public <J> GroupingBuilder<T, Map<K, D>, J> after(Function<? super T, ? extends J> classifier) {
        return new GroupingBuilder(Collectors.groupingBy(classifier, this.collector));
    }

    public static <T, D, K> GroupingBuilder<T, List<T>, K> groupOn(Function<? super T, ? extends K> classifier) {
        return new GroupingBuilder(Collectors.groupingBy(classifier));
    }
}

Collector<? super Car, ?, Map<Brand, Map<Color, List<Car>>>> c = groupOn(Car::getColor).after(Car::getBrand).get();

 

자바로 DSL을 만드는 패턴과 기법1. 메서드 체인

 

메서드 체이닝을 사용하게 되면 사용자가 미리 지정된 순서에 따라서 거래를 올바르게 설정할 수 있도록 강제한다. 또한 이 경우, 메서드의 이름이 인수의 이름을 대신하게 되어 가독성이 높아지는 이점이 있다. 메서드 체이닝에는 상위 수준의 빌더와 하위 수준의 빌더가 존재하고 이를 접착하는 코드가 많이 필요하다는 단점이 있다. 

Order order = forCustomer("BigBank")
    .buy(80)
    .stock("IBM")
    .on("NYSE")
    .at(125.0)
    .sell(50)
    .stock("GOOGLE")
    .on("NASDAQ")
    .at(375.0)
    .end();

public class MethodChainingOrderBuilder {
    public final Order order = new Order();

    private MethodChainingOrderBuilder(String customer) {
        this.order.setCustomer(customer);
    }

    public static MethodChainingOrderBuilder forCustomer(String customer) {
        return new MethodChainingOrderBuilder(customer);
    }

    public Order end() {
        return this.order;
    }

    public TradeBuilder buy(int quantity) {
        return new TradeBuilder(this, Type.BUY, quantity);
    }

    public TradeBuilder sell(int quantity) {
        return new TradeBuilder(this, Type.SELL, quantity);
    }

    private MethodChainingOrderBuilder addTrade(Trade trade) {
        this.order.addTrade(trade);
        return this;
    }

    public static class StockBuilder {
        private final MethodChainingOrderBuilder builder;
        private final Trade trade;
        private final Stock stock;

        private StockBuilder(MethodChainingOrderBuilder builder, Trade trade, String symbol) {
            this.stock = new Stock();
            this.builder = builder;
            this.trade = trade;
            this.stock.setSymbol(symbol);
        }

        public TradeBuilderWithStock on(String market) {
            this.stock.setMarket(market);
            this.trade.setStock(this.stock);
            return new TradeBuilderWithStock(this.builder, this.trade);
        }
    }

    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) {
            this.trade.setPrice(price);
            return this.builder.addTrade(this.trade);
        }
    }

    public static class TradeBuilder {
        private final MethodChainingOrderBuilder builder;
        public final Trade trade;

        private TradeBuilder(MethodChainingOrderBuilder builder, Trade.Type type, int quantity) {
            this.trade = new Trade();
            this.builder = builder;
            this.trade.setType(type);
            this.trade.setQuantity(quantity);
        }

        public StockBuilder stock(String symbol) {
            return new StockBuilder(this.builder, this.trade, symbol);
        }
    }
}

 

 

자바로 DSL을 만드는 패턴과 기법2. 중첩된 함수 사용

 

메서드 체인에서는 각 도메인의 계층 구조를 확인하기 어려운데, 중첩된 함수에서는 계층 구조가 그대로 코드에 적용된다는 장점이 있다. 하지만 메서드 체인에 비해서 굉장히 많은 괄호를 사용해야 한다는 문제점이 있다. 또한 인수의 의미가 위치에 의해서 정의가 되지 이름으로 정의되지 않는다.

Order order = order("BigBank",
	buy(80, stock("IBM", on("NIYE")), at(125.00)),
    sell(50, stock("GOOGLE", on("NASDAQ")), at(375.00))
);

public class NestedFunctionOrderBuilder {
    public NestedFunctionOrderBuilder() {
    }

    public static Order order(String customer, Trade... trades) {
        Order order = new Order();
        order.setCustomer(customer);
        Stream var10000 = Stream.of(trades);
        Objects.requireNonNull(order);
        var10000.forEach(order::addTrade);
        return order;
    }

    public static Trade buy(int quantity, Stock stock, double price) {
        return buildTrade(quantity, stock, price, Type.BUY);
    }

    public static Trade sell(int quantity, Stock stock, double price) {
        return buildTrade(quantity, stock, price, 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;
    }
}

 

 

자바로 DSL을 만드는 패턴과 기법3. 람다를 이용한 함수 시퀀싱

 

메서드 체인처럼 플루언트 방식으로 적용할 수 있으며, 중첩함수처럼 도메인의 계층 구조를 유지한다는 장점이 있다. 하지만 많은 설정 코드가 필요하다는 단점이 있다. 

Order order = LambdaOrderBuilder.order((o) -> {
    o.forCustomer("BigBank");
    o.buy((t) -> {
        t.quantity(80);
        t.price(125.0);
        t.stock((s) -> {
            s.symbol("IBM");
            s.market("NYSE");
        });
    });
    o.sell((t) -> {
        t.quantity(50);
        t.price(375.0);
        t.stock((s) -> {
            s.symbol("GOOGLE");
            s.market("NASDAQ");
        });
    });
});

public class LambdaOrderBuilder {
    private Order order = new Order();

    public LambdaOrderBuilder() {
    }

    public static Order order(Consumer<LambdaOrderBuilder> consumer) {
        LambdaOrderBuilder builder = new LambdaOrderBuilder();
        consumer.accept(builder);
        return builder.order;
    }

    public void forCustomer(String customer) {
        this.order.setCustomer(customer);
    }

    public void buy(Consumer<TradeBuilder> consumer) {
        this.trade(consumer, Type.BUY);
    }

    public void sell(Consumer<TradeBuilder> consumer) {
        this.trade(consumer, Type.SELL);
    }

    private void trade(Consumer<TradeBuilder> consumer, Trade.Type type) {
        TradeBuilder builder = new TradeBuilder();
        builder.trade.setType(type);
        consumer.accept(builder);
        this.order.addTrade(builder.trade);
    }

    public static class StockBuilder {
        private Stock stock = new Stock();

        public StockBuilder() {
        }

        public void symbol(String symbol) {
            this.stock.setSymbol(symbol);
        }

        public void market(String market) {
            this.stock.setMarket(market);
        }
    }

    public static class TradeBuilder {
        private Trade trade = new Trade();

        public TradeBuilder() {
        }

        public void quantity(int quantity) {
            this.trade.setQuantity(quantity);
        }

        public void price(double price) {
            this.trade.setPrice(price);
        }

        public void stock(Consumer<StockBuilder> consumer) {
            StockBuilder builder = new StockBuilder();
            consumer.accept(builder);
            this.trade.setStock(builder.stock);
        }
    }
}

 

 

자바로 DSL을 만드는 패턴과 기법4. 조합하기

 

메서드 체인, 중첩된 함수, 람다를 이용한 함수 시퀀싱 모두를 합쳐서 가독성을 높일 수 있지만, 여러 패턴이 혼재되어 있으면 사용자가 배우는데 더 많은 시간이 소요된다. 

Order order = MixedBuilder.forCustomer("BigBank", 
	buy((t) -> {
    	t.quantity(80)
        .stock("IBM")
        .on("NYSE")
        .at(125.0);
	}), 
    sell((t) -> {
    	t.quantity(50)
        .stock("GOOGLE")
        .on("NASDAQ")
        .at(375.0);
	})
);

public class MixedBuilder {
    public 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, Type.BUY);
    }

    public static TradeBuilder sell(Consumer<TradeBuilder> consumer) {
        return buildTrade(consumer, 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 StockBuilder {
        private final TradeBuilder builder;
        private final Trade trade;
        private final Stock stock;

        private StockBuilder(TradeBuilder builder, Trade trade, String symbol) {
            this.stock = new Stock();
            this.builder = builder;
            this.trade = trade;
            this.stock.setSymbol(symbol);
        }

        public TradeBuilder on(String market) {
            this.stock.setMarket(market);
            this.trade.setStock(this.stock);
            return this.builder;
        }
    }

    public static class TradeBuilder {
        private Trade trade = new Trade();

        public TradeBuilder() {
        }

        public TradeBuilder quantity(int quantity) {
            this.trade.setQuantity(quantity);
            return this;
        }

        public TradeBuilder at(double price) {
            this.trade.setPrice(price);
            return this;
        }

        public StockBuilder stock(String symbol) {
            return new StockBuilder(this, this.trade, symbol);
        }
    }
}

 

 

DSL에 메서드 참조 사용

 

DSL에서도 메서드 참조를 사용하게 되면 읽기 쉽고 간단한 코드를 만들 수 있다. 

public class Tax {
    public 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) -> {
        return d;
    };

    public TaxCalculator() {
    }

    public TaxCalculator with(DoubleUnaryOperator f) {
        this.taxFunction = this.taxFunction.andThen(f);
        return this;
    }

    public double calculate(Order order) {
        return this.taxFunction.applyAsDouble(order.getValue());
    }
}

double value = new TaxCalculator().with(Tax::regional)
	.with(Tax::surcharge)
    .calculate(order);

 

 

DSL 생성 방식 비교
패턴 장점 단점
메서드 체인 1. 메서드 이름이 인수 역할을 수행
2. 선택형 파라미터와 잘동작함
3. DSL 사용자가 정해진 순서대로 메서드를 호출하도록 강제
4. 정적 메서드를 최소화하거나 없앨 수 있음.
5. 문법적 잡음 최소화
1. 구현이 장황함
2. 빌더를 연결하는 접착 코드 필요
3. 들여쓰기 규칙으로만 도메인 객체의 계층을 정의
중첩 함수 1. 구현의 장황함을 최소화
2. 함수 중첩으로 도메인 객체의 계층 반영
1. 정적 메서드의 사용이 빈번
2. 이름이 아닌 위치로 인수를 정의
3. 선택형 파라미터를 처리할 메서드 오버로딩이 필요
람다를 이용한 함수 시퀀싱 1. 선택형 파라미터와 잘동작함
2. 정적 메서드를 최소화하거나 없앨 수 있음
3. 람다의 중첩으로  도메인 객체의 계층 반영
4. 빌더의 접착 코드가 없음
1. 구현이 장황함
2. 람다 표현식으로 인해 문법적 잡음 존재

 

 

jOOQ

 

jOOQ는 SQL을 구현하는 내부적 DSL로 자바에 직접 내장된 형식 안전 언어이다. 데이터 스키마를 역공학하는 소스코드 생성기 덕분에 컴파일러가 복잡한 sql 구문을 확인할 수 있다. 

 

jOOQ는 아래와 같은 형식으로 사용한다. 우리는 jOOQ 결과에 스트림 API를 조합하여, 조회한 결과를 바로 스트림 API로 처리하는 것이 가능하다. 아래 형식을 보면 메서드 체이닝을 사용한 것을 볼 수 있는데, sql을 구현할 때는 선택적 파라미터를 허용해야 하고, 정해진 순서를 따르도록 하는 것이 중요하기 때문이다. 

create.selectFrom(BOOK)
    .where(BOOK.PUBLISH_IN.eq(2016))
    .orderBy(BOOK.TITLE)

 

 

큐컴버

 

큐컴버는 주어진 명령문을 실행할 수 있는 테스트 케이스로 반환한다. 큐컴버는 3가지 구분되는 개념을 사용한다. 전제 조건 정의(Given), 시험하려는 객체의 실질적 호출(When), 테스트 케이스의 결과를 확인하는 어설션(Then)이 있다. 큐컴버는 자유로운 형식으로 구현할 수 있는 외부 DSL을 사용한다. 

 

스프링 통합

 

스프링 통합은 유명한 엔터프라이즈 통합 패턴을 지원할 수 있도록 의존성 주입에 기반한 스프링 프로그래밍 모델을 확장한다. 여기서 엔터프라이즈 통합 패턴이란 분산 시스템 간의 메시지 기반 통신 방식을 의미한다. 스프링 통합은 채널, 엔드 포인트, 폴러, 채널 인터셉터 등 메세지 기반의 애플리케이션에서 필요한 공통 패턴을 모두 구현한다.