CHAPTER10 - 람다를 이용한 도메인 전용 언어(1)

2025. 4. 1. 22:00카테고리 없음

목차.

1. 도메인 전용 언어(DSL)란 무엇이며 어떤 형식으로 구성되는가?

2. DSL을 API에 추가할 때의 장단점

3. JVM에서 활용할 수 있는 자바 기반 DSL을 깔금하게 만드는 대안

4. 최신 자바 인터페이스와 클래스에 적용된 DSL에서 배움

 

 

애플리케이션의 핵심 비즈니스를 모델링하는 소프트웨어 영역에서 읽기 쉽고, 이해하기 쉬운 코드는 아주 중요하다. 도메인 전문가는 소프트웨어 개발 프로세스에 참여할 수 있고 비즈니스 관점에서 소프트웨어가 제대로 되었는지 확인할 수 있다.

 

도메인 전용 언어(DSL)로 애플리케이션의 비즈니스 로직을 표현함으로 이 문제를 해결할 수 있다. DSL은 특정 도메인을 대상으로 만들어진 특수 프로그래밍 언어다.

 

 

1. 도메인 전용 언어

 

DSL은 특정 비즈니스 도메인의 문제를 해결하려고 만든 언어다. 예를 들어 회계 전용 소프트웨어 애플리케이션을 개발한다고 가정하자. 이 상황에서 비즈니스 도메인에는 통장 입출금 내역서, 계좌 통합 같은 개념이 포함된다. 이런 문제를 표현할 수 있는 DSL을 만들 수 있다. 자바에서는 도메인을 표현할 수 있는 클래스와 메서드 집합이 필요하다. DSL이란 특정 비즈니스 도메인을 인터페이스로 만든 API라고 생각할 수 있다.

 

DSL은 범용 프로그래밍 언어가 아니다. DSL에서 동작과 용어는 특정 도메인에 국한되므로 다른 문제를 걱정할 필요가 없고 오직 자신의 앞에 놓인 문제를 어떻게 해결할지에만 집중할 수 있다.

 

DSL은 평문 영어가 아니며 도메인 전문가가 저수준 비즈니스 로직을 구현하도록 만드는 것이 아니다.

DSL은 다음 두 가지 필요성을 생각하면서 개발해야 한다.

 

- 의사 소통의 왕 : 코드의 의도가 명확히 전달되어야 하며 프로그래머가 아닌 사람도 이해할 수 있어야 한다.

- 한 번 코드를 구현하지만 여러 번 읽는다 : 가독성은 유지보수의 핵심이다. 항상 쉽게 이해할 수 있게 코드를 구현해야 한다.

 

 

1.1 DSL의 장점과 단점

 

*장점

 

- 간결함

API는 비즈니스 로직을 간편하게 캡슐화하므로 반복을 피할 수 있고 코드를 간결하게 만든다.

 

- 가독성

도메인 영역의 용어를 사용하므로 비 도메인 전문가도 코드를 쉽게 이해할 수 있다. 결과적으로 다양한 조직 구성원 간에 코드와 도메인 영역이 공유 될 수 잇다.

 

- 유지보수

잘 설계된 DSL로 구현한 코드는 쉽게 유지보수하고 바꿀 수 있다. 유지보수는 비즈니스 관련 코드 즉 가장 빈번히 바뀌는 애플리케이션 부분에 특히 중요하다.

 

- 높은 수준의 추상화

DSL은 도메인과 같은 추상화 수준에서 동작하므로 도메인의 문제와 직접적으로 관련되지 않은 세부 사항을 숨긴다.

 

- 집중

비즈니스 도메인의 규칙을 표현할 목적으로 설계된 언어이므로 프로그래머가 특정 코드에 집중할 수 있다.

 

- 관심사분리

지정된 언어로 비즈니스 로직을 표현함으로 애플리케이션의 인프라구조와 관련된 문제와 독립적으로 비즈니스 관련된 코드에서 집중하기가 용이하다.

 

*단점

 

- DSL 설계의 어려움

간결하게 제한적인 언어에 도메인 지식을 담는 것은 어렵다.

 

- 개발 비용

코드에 DSL을 추가하는 작업은 초기 프로젝트에 많은 비용과 시간이 소모되는 작업이다. 또한 유지보수와 변경은 프로젝트에 부담이 된다.

 

- 추가 우회 계층

DSL은 추가적인 계층으로 도메인 모델을 감싸며 이 때 계층을 최대한 작게 만들어 성능 문제를 회피한다.

 

- 새로 배워야 하는 언어

DSL을 추가하면서 팀은 새로운 언어를 배워야 한다. 만약 여러 비즈니스 도메인을 다루는 개별 DSL을 사용하는 상황이라면 이들을 유기적으로 동작하도록 합치는 일은 어려운 일이다. 개별 DSL이 독립적으로 진화할 수 있기 때문이다.

 

- 호스팅 언어 한계

자바 같은 범용 프로그래밍 언어는 장황하고 엄격한 문법을 가졌다. 이런 언어로는 사용자 친화적 DSL을 만들기 어렵다.(람다 표현식은 이 문제를 해결할 수 있다.)

 

위에 적힌 장점과 단점만 가지고 DSL 개발 여부를 결정하기 어렵다. 자바 8을 이용해 DSL을 개발하는데 필요한 패턴과 전략을 살펴보기 전에 다른 해결책은 없는지 살펴보자.

 

 

1.2 JVM에서 이용할 수 있는 다른 DSL 해결책

 

1절에서는 DSL의 카테고리를 배운다. DSL을 자바가 아닌 다른 언어로 구현하는 방법도 배우나. 이 절의 후반부에서는 자바 기능으로 DSL을 구현하는 방법도 배운다.

 

DSL의 카테고리를 구분하는 가장 흔한 방법은 내부 DSL과 외부 DSL을 나누는 것이다. 내부 DSL은 순수 자바 코드 같은 기존 호스팅 언어를 기반으로 구현하는 반명, 외부 DSL은 호스팅 언어와는 독립적으로 자체의 문법을 가진다.

 

더욱이 JVM으로 인해 내부 DSL과 외부 DSL의 중간 카테고리에 해당하는 DSL이 만들어질 가능성도 있다. 스칼라나 그루비 처럼 자바가 아니지만 JVM에서 실행되며 더 유연하고 표현력이 강력한 언어도 있다. 이를 세 번째 카테고리로 정한다.

 

내부 DSL

자바 언어를 기준으로 내부 DSL은 자바로 구현한 DSL을 의미한다. 자바는 유연성이 떨어지는 문법 때문에 읽기 쉽고, 간단하고, 표현력 있는 DSL을 만드는데 한계가 있었다. 람다 표현식이 등장하면서 이 문제가 어느정도 해결될 수 있다. 람다를 적극적으로 활용하면 익명 내부 클래스를 사용해 DSL을 구현하는 것보다 장황함을 크게 줄여 신호 대비 잡음 비율을 적정 수준으로 유지하는 DSL을 만들 수 있다.

 

List<String> numbers = List.of("one", "two", "three", "four");
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을 만드는 도구를 사용할 필요가 없으므로 추가로 비용이 들지 않는다.

- 개발 팀이 새로운 언어를 배우거나 또는 익숙하지 않고 복잡한 외부 도구를 배울 필요가 없다.

- DSL 사용자는 기존의 자바 IDE를 이용해 자동 완성, 자동 리팩터링 같은 기능을 그대로 사용할 수 있다.

- 한 개의 언어로 한 개의 도메인 또는 여러 도메인을 대응하지 못해 추가로 DSL을 개발해야 하는 상황에서 자바를 이용한다면 추가 DSL을 쉽게 합칠 수 있다.

 

 

다중 DSL

JVM에서 실행되는 언어는 100개가 넘는다. 자바보다 젊고 제약도 적은 언어들은 간결한 DSL을 만드는 데 필요한 특성들을 가졌다.

예제를 통해 스칼라를 어떻게 활용할 수 있는지 보자

 

주어진 함수 f를 주어진 횟수만큼 반복 실행하는 유틸리티 함수를 구현한다고 가정하자.

def times(i: Int, f: => Unit): Unit = {
    f	// f 함수 실행
    if (i > 1) times(i - 1, f)	//횟수가 양수면 횟수를 감소시켜 재귀적으로 times 실행
}

 

스칼라에서는 i가 아주 큰 숫자라 하더라도 자바에서처럼 스택 오버플로 문제가 발생하지 않는다. 스칼라는 꼬리 호출 최적화를 통해 times 함수 호출을 스택에 추가하지 않기 때문이다. 이 함수를 이용해 "Hello World"를 세 번 반복 호출할 수 있다.

times(3, println("Hello World")

 

times 함수를 커링하거나 두 그룹으로 인수를 놓을 수 있다.

def times(i: Int)(f: => Unit): Unit = { f
    if (i > 1 times(i - 1)(f)
}

 

여러 번 실행할 명령을 중괄호 안에 넣어 같은 결과를 얻을 수 있다.

times(3) {
    println("Hello World")
}

 

마지막으로 스칼라는 함수가 반복할 인수를 받는 한 함수를 가지면서 Int를 익명 클래스로 암묵적 변환하도록 정의할 수 있다.

 

implicit def intToTimes(i: Int) = new {         //int를 무명클로스로 변환하는 암묵적 변환
    def times(f: => Unit): Unit = {             //다른 함수 f를 인수로 받는 times 함수 한 개 정의

        def times(i: Int, f: => Unit): Unit = { // 두 번째 times 함수는 가장 가까운 번주에서 정의
            f                                   // 한 두 개의 인수를 받는 함수를 이용
            if (i > 1) times(i - 1, f)
        }
    	times(i, f) //내부 times 함수 호출
    }
}

 

이런 방식으로 작은 스칼라 내장 DSL 구현 사용자는 다음처럼 "Hello World"를 세 번 출력하는 함수를 실행할 수 있다.

3 times {
    println("Hello World")
}

 

결과적으로 문법적 잡음이 전혀 없으며 개발자가 아닌 사람도 코드를 쉽게 이해할 수 있다. 여기서 숫자 3은 자동으로 컴파일러에 의해 클래스 인스턴스로 변환되며 i필드에 저장된다. 점 표기법을 이용하지 않고 times 함수를 호출햇는데 이때 반복할 함수를 인수로 받는다.

 

 DSL 친화적이지만 이 접근 방법은 아래와 같은 불편함도 초래한다.

- 새로운 프로그래밍 언어를 배워야 한다.

- 두 개 이상의 언어가 존재하므로 여러 컴파일러로 소스를 빌드하도록 하여야 한다.

- JVM 에서 실행되는 거의 모든 언어가 자바와 백 퍼센트 호환을 주장하지만 사실 완벽하지 않을 때가 많다.

 

 

외부 DSL

프로젝트에 DSL을 추가하는 세 번째 옵션은 외부 DSL을 구현하는 것이다. 그러려면 자신만의 문법과 구문으로 새 언어를 설계해야 한다. 새 언어를 파싱하고, 파서의 결과를 분석하고, 외부 DSL을 실행할 코드를 만들어야 한다.

 

외부 DSL을 개발하는 가장 큰 장점은 외부 DSL이 제공하는 무한한 유연성이다. 자바로 개발된 인프라구조 코드와 외부 DSL로 구현한 비즈니스 코드를 명확하게 분리한다는 것도 장점이다.

 

단점은 위 작업은 일반적인 작업이 아니며 매우 큰 작업이라는 것이다.

 

 

2.  최신 자바 API의 작은 DSL

자바의 새로운 기능의 장점을 적용한 첫 API는 네이티브 자바 API 자신이다. 자바 8 이전의 네이티브 자바 API는 이미 한 개의 추상 메서드를 가진 인터페이를 갖고 있었다. 하지만 무명 내부 클래스를 구현하려면 불필요한 코드가 추가되어야 한다. 람다와 메서드 참조가 등장하면서 달라졌다.

 

자바 8의 Comparator 인터페이스에 새 메서드가 추가되었다. 람다가 어떻게 네이티브 자바 API의 재사용성과 메서드 결합도를 높였는지 확인하자.

 

사람을 가리키는 객체 목록을 가지고 있는데 사람의 나이를 기준으로 객체를 정렬한다고 가정하자. 람다가 없으면 내부 클래스로 Comparator 인터페이스를 구현해야 한다.

Collections.sort(dishes, new Comparator<Dish>() {
    @Override
    public int compare(Dish o1, Dish o2) {
        return o1.getCalories() - o2.getCalories();
    }
});

 

앞에 람다 표현식에서 봤던 예제처럼 내부 클래스를 간단한 람다 표현식으로 바꿀 수 있다.

Collections.sort(dishes, (o1, o2) -> o1.getCalories() - o2.getCalories());

 

이 기법은 코드의 신호 대비 잡음 비율을 줄이는데 특히 유용하다. 자바는 Comparator 객체를 좀 더 가독성 있게 구현할 수 있는 정적 유틸리티 메서드 집합도 제공한다. 정적으로 Comparator.comparing 메서드를 임포트해 위 예제를 다음처럼 구현할 수 있다.

// 람다 표현식
Collections.sort(dishes, comparing(o1 -> o1.getCalories()));

// 메서드 참조
Collections.sort(dishes, comparing(Dish::getCalories));

// 역순 정렬
Collections.sort(dishes, comparing(Dish::getCalories).reversed());

// 같은 나이일 경우 이름 비교
Collections.sort(dishes, comparing(Dish::getCalories)
                            .thenComparing(Dish::getName));
                            
// List 인터페이스 추가된 새 sort 메서드를 이용해 코드를 깔끔하게 정리할 수 있다.
dishes.sort(comparing(Dish::getCalories)
            .thenComparing(Dish::getName));

 

 

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

 

Stream 인터페이스는 네이티브 자바 API에 작은 내부 DSL을 적용한 좋은 예다. Stream은 컬렉션의 항목을 필터, 정렬, 변환, 그룹화, 조작하는 작지만 강력한 DSL로 볼 수 있다. 로그 파일을 읽어서 "ERROR"라는 단어로 시작하는 파일의 첫 40행을 수집하는 작업을 수행한다고 가정하자.

List<String> collectLinesWithError(String filename, int count) throws IOException {
    List<String> errors = new ArrayList<>();
    int errorCount = 0;
    BufferedReader br = new BufferedReader(new FileReader(filename));
    String line = br.readLine();
    while (errorCount < count && line != null) {
        if (line.contains("ERROR")) {
            errors.add(line);
            errorCount++;
        }
        line = br.readLine();
    }
    br.close();
    return errors;
}

 

코드가 이미 장황해 의도를 한 눈에 파악하기 어렵다. 문제가 분리되지 않아 가독성과 유지보수성 모두 저하되었다. 같은 의무를 지닌 코드가 여러 행에 분산되어 있다.(파일을 읽는 로직)

 

- FileReader가 만들어짐

- 파일이 종료되었는지 확인하는 while 루프의 두 번째 조건

- 파일의 다음 행을 읽는 while 루프의 마지막행

 

마찬가지로 첫 40행을 수집하는 코드도 세 부분으로 흩어져있다.

 

- errorCount 변수를 초기화하는 코드

- while 루프의 첫 번째 조건

- "Error"을 로그에서 발견하면 카운터를 증가시키는 행

 

Stream 인터페이스를 이용해 함수형으로 코드를 구현하면 더 쉽고 간결하게 코드를 구현할 수 있다.

// 파일을 열어 문자열 스트림을 만듦
try (Stream<String> lines = Files.lines(Paths.get(filename))) {
    return lines
    	    // "ERROR" 로 시작하는 행을 필터링
            .filter(line -> line.contains("ERROR"))
            // 결과를 첫 40행으로 제한
            .limit(count)
            // 결과 문자열을 리스트로 수집
            .toList();
}

 

String은 파일에서 파싱할 행을 의미하며 files.lines는 정적 유틸리티 메서드로 Stream<String>을 반환한다. 파일을 한 행씩 읽는 부분의 코든느 이게 전부다. 마찬가지로 limit(40) 이라는 코드로 에러 행을 첫 40개만 수집한다.

 

스트림 API의 플루언트 형식은 잘 설계된 DSL의 또 다른 특징이다. 모든 중간 연산은 게으르며 다른 연산으로 파이프라인될 수 있는 스트림으로 반환된다. 최종 연산은 적극적이며 전체 파이프라인이 계산을 일으킨다.

 

 

2.2 데이터를 수집하는 DSL인 Collectors

 

Stream 인터페이스를 데이터 리스트를 조작하는 DSL로 간주할 수 있다. 마찬가지로 Collector 인터페이스는 데이터 수집을 수행하는 DSL로 간주할 수 있다. Collector 인터페이스를 이용해 스트림의 항목을 수집, 그룹화, 파티션이 가능하다. DSL 관점에서 어떻게 이들 메서드가 설계되었는지 확인해보자. 특히 Comparator 인터페이스는 다중 필드 정렬을 지원하도록 합쳐질 수 잇으며 Collectors는 다중 수준 그룹화를 달성할 수 있도록 합쳐질 수 있다. 아래처럼 자동차를 브랜드별 그리고 색상별로 그룹화해보자.

private static final List<Car> cars = List.of(
        new Ioniq(Color.BLUE),
        new Kona(Color.RED),
        new Odyssey(Color.RED),
        new Accord(Color.GRAY)
);

public static void main(String[] args) {
    Map<Brand, Map<Color, List<Car>>> carsByBrandAndColor =
        cars.stream().collect(groupingBy(Car::getBrand,
                                groupingBy(Car::getColor)));

    System.out.println(carsByBrandAndColor);
}

=>
{
    HYUNDAI={
        RED=[Kona],
        BLUE=[Ioniq]
    },
    HONDA={
        GRAY=[Accord],
        RED=[Odyssey]}
    }
},

 

두 Comparator를 연결하는 것과 비교할 때 무엇이 다를까? 두 Comparator를 플루언트 방식으로 연결해서 다중 필드 Comparator 를 정의해보자

Comparator<Person> comparator =
        comparing(Person::age).thenComparing(Person::name);

 

반면 Collectors API를 이용해 Collectors를 중첩함으로 다중 수준 Collector를 만들 수 있다.

// 중첩
Collector<? super Car, ?, Map<Brand, Map<Color, List<Car>>>> carGroupingByBrandAndColor =
        groupingBy(Car::getBrand, groupingBy(Car::getColor));


// 플루언트
// thenCollect 같은 API가 존재한다고 가정
Collector<? super Car, ?, Map<Brand, Map<Color, List<Car>>>> carGroupingByBrandAndColor =
        Collectors.groupingBy(Car::getBrand)
                .thenCollect(Collectors.groupingBy(Car::getColor));

 

특히 셋 이상의 컴포넌트를 조합할 때는 보통 플루언트 형식이 중첩 형식에 비해 가독성이 좋다.

사실 가장 안쪽의 Collector (groupingBy(Car::getColor)) 가 첫 번째로 평가되어야 하지만

논리적으로는 최종 그룹화에 해당하는 (groupingBy(Car::getBrand)) 가 최상위 기준이 된다.

 

  • 중첩 형식(Nested Style): groupingBy(groupingBy(...)) 형태로 표현되며 바깥쪽 groupingBy 먼저 실행
  • 플루언트 형식(Fluent Style): 메서드 체이닝으로 표현되며 안쪽(위쪽) groupingBy 먼저 실행

 

다음 예제에서 보여주는 것처럼 groupingBy 팩터리 메서드에 작업을 위임하는 GroupingBuilder를 만들면 문제를 더 쉽게 해결할 수 있다. GroupingBuilder는 유연한 방식으로 여러 그룹화 작업을 만든다.

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, 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>>>> carGroupingByBrandAndColor =
        groupOn(Car::getColor).after(Car::getBrand).get();