본문 바로가기
Dev Log/Java

Modern Java In Action 정리 - 10장 람다를 이용한 도메인 전용 언어

by 삽질하는큐 2020. 6. 22.

도메인 전용 언어는 특정 비즈니스 도메인의 문제를 해결하려고 만든 언어다.

기존 자바 언어로 비즈니스 로직을 설명하는 것은 읽는 사람으로 하여금 이해하기 어려운 과정을 만들 수 있다. 코드로 이어지는 의사소통을 좀 더 원활하게 하기 위해서 자바가 담을 수 있는 여러가지 기능들을 이용해서 초심자도 쉽게 이해할 수 있는 코드를 만들기 위한 노력을 다루고 있다.

 

DSL의 장단점

Strengths Weaknesses
간결함
가독성
유지보수성
높은 수준의 추상화
집중
관심사 분리
DSL설계의 어려움
개발비용
추가 우회 계층
새로 배워야 하는 언어
호스팅 언어의 한계

 

최신 자바 API의 작은 DSL

최신 자바는 람다와 메소드 참조가 생기면서 DSL을 활용하기 더욱 좋은 환경이 되었다.

Collections.sort(persons, new Comparator<Person>() {
    @Override
    public int compare(Person o1, Person o2) {
        return o1.getAge() - o2.getAge();
    }
});

 

위와 같은 코드를 람다 표현식을 이용해서 나타낼 수 있다.

Collections.sort(persons, (p1, p2) -> p1.getAge() - p2.getAge());

 

Comparator.comparing 메소드를 사용하면 다음과 같이 코드를 변경할 수 있다.

Collections.sort(persons, comparing(p -> p.getAge()));
Collections.sort(persons, comparing(Person::getAge));

 

thenComparing을 통해서 같은 나이를 이름 순으로 재정렬할 수도 있다.

Collections.sort(persons, comparing(Person::getAge).thenComparing(Person::getName));

 

 

List 인터페이스에 추가된 sort 메소드를 이용하면 이마저도 더 간략해진다.

persons.sort(comparing(Person::getAge).thenComparing(Person::getName));

 

스트림 API는 컬렉션을 조작하는 DSL

log파일을 읽어서 "ERROR"라는 단어로 시작하는 파일의 첫 40행을 수집하는 작업을 한다고 했을 때

List<String> errors = new ArrayList<>();
int errorCount = 0;
String fileName = "someFile.txt";
BufferedReader bufferedReader = new BufferedReader(new FileReader(fileName));

String line = bufferedReader.readLine();
while (errorCount < 40 && line != null) {
    if (line.startsWith("ERROR")) {
        errors.add(line);
        errorCount++;
    }

    line = bufferedReader.readLine();
}

예전 같은 방식이라면 이렇게 파일을 읽어서 errors를 가져오고 처리를 했을 것이다. 굉장히 장황하고 읽기힘들며, 유지보수하기 어려워질 것이다.

Stream 인터페이스를 이용해 함수형으로 이를 개선해보자.

Files.lines(Paths.get(fileName)) // 파일을 열어서 문자열 스트림을 만듦
        .filter(l -> l.startsWith("ERROR")) // ERROR로 필터링
        .limit(40) // 40행으로 제한
        .collect(Collectors.toList()); // 결과를 리스트로 수집

스트림 API의 플루언트 형식은 잘 설계된 DSL의 또 다른 특징이다.

 

데이터를 수집하는 DSL인 Collectors

차를 브랜드와 색상으로 그룹화하는 로직이 있다고 했을 때

// 중첩형식
Map<String, Map<Color, List<Car>>> carsByBrandAndColor = cars
        .stream()
        .collect(groupingBy(Car::getBrand,
                groupingBy(Car::getColor)));
                
// 플루언트 방식
Comparator<Person> comparator = comparing(Person::getAge).thenComparing(Person::getName);

 

조합할 대상이 많은 경우에는 보통 플루언트 형식이 중첩 형식에 비해 가독성이 좋다.

 

다음과 같이 GroupingBuilder를 만들면 groupingBy 팩터리 메서드에 작업을 위임하여 유연하게 문제를 대처할 수 있다.

public class GroupingBuilder<T, D, K> {

    private final Collector<? super T, ?, Map<K, D>> collector;

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

    public Collector<? super T, ?, Map<K, D>> get() {
        return collector;
    }
    
    public <J> GroupingBuilder<T, Map<K, D>, J> after(Function<? super T, ? extends J> classifier) {
        return new GroupingBuilder<>(groupingBy(classifier, collector));
    }
    
    public static <T, D, K> GroupingBuilder<T, List<T>, K> groupOn(Function<? super T, ? extends K> classifier) {
        return new GroupingBuilder<>(groupingBy(classifier));
    }
}

 

 

하지만 이를 사용하게 될 때 그룹화 함수를 반대로 써야 하므로 직관적이지 않은 문제가 있다.

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

 

자바로 DSL을 만드는 패턴과 기법

메서드 체인

Order order = forCustomer("BigBank")
                .buy(80)
                .stock("IBM")
                .on("NYSE")
                .at(12.00)
                .sell(0)
                .stock("GOOGLE")
                .on("NASDAQ")
                .at(375.00)
                .end();

여러 빌드 클래스를 통해서 사용자가 미리 지정된 절차에 따라 플루언트 APi의 메서드를 호출하도록 강제하고 있다. 코드가 비교적 우아하긴 하지만 빌더를 많이 구현해야 하는 것이 단점이다.

 

 

중첩된 함수

Order order = order("BigBand",
                            buy(80, stock("IBM", on("NYSE")), at(125.00)),
                            sell(0, stock("GOOGLE", on("NASDAQ")), at(375.00)));

함수 안에 다른 함수를 이용해서 도메인 모델을 만드는 방법이다. order, buy, stock, on, at이 모두 static 함수이고, static import되어서 이와 같이 쓸 수 있다. 하지만 인수 목록을 정적 메소드에 넘겨줘야 하는 단점이 있어서 인수가 많을 경우에는 이것 나름대로 장황해질 것이다.

 

 

람다 표현식을 이용한 함수 시퀀싱

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");
        });
    });
});

이런 형태를 만들려면 람다 표현식을 받아 실행해 도메인 모델을 만들어 내는 여러 빌더를 구현해야 한다. 다만 Consumer 객체가 빌더를 인수로 받아서 좀 더 간편해졌다. 하지만 아직도 많은 설정 코드가 필요하며 람다 자체의 코드 잡음이 많은 패턴이다.

 

위에서 나왔던 패턴들을 자유자재로 혼용하는 것도 하나의 방법이다.

 

 

솔직히 이번 장에서는 무엇을 말하고 싶은지 잘 모르겠다...

DSL의 주요 기능은 개발자와 도메인 전문가 사이의 간격을 좁히는 것이라고 하지만, 오늘날의 사회에서 도메인 전문가 들이라고 하면 보통 코드를 읽을 줄 모르거나 읽지 않는, 읽지 말아야 하는 역할이라 개발자 친화적이라기보단 외부의 사람들에게 친화적인 개념을 설명하는게 잘 와닿지가 않았다. 현업에 있다보면 오히려 도메인 지식은 방대하고 케이스들이 복잡하기 때문에 간결하고 아름다운 코드로 만들어내기에 어려운 점들이 많다. 이를 위해서 새로운 문법을 DSL로 만들게 되는 것인데, 그 문법이 사실 송두리째 바뀔 수 있는 도메인의 예외 조항들이 생기는 것도 많이 보았다.

그래서 불필요하게 예제를 모두 싣지도 않았다. DSL을 스스로 정의해서 실제 코드에 활용하는 것도 좋겠지만, 유지보수에 용이한 코드가 나올것인가에 대한 의문이 끊이지 않았기 때문이다. infix를 정의할 수 있는 더 자유로운 언어들에서 DSL을 정의하게 되면, 기존의 언어 문법과는 꽤나 상관없어 보이는 코드들이 굉장히 많이 생길 수 있다.

이번 장에서는 그냥 "이런 것도 있다" 정도로 넘어가는게 좋지 않을까 생각하고, 생각보다 저자들은 코드의 "가독성"을 굉장히 중요하게 생각한다는 것을 역으로 생각해 볼 수 있게 하는 장이라고 생각한다.