ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 도메인 특화 언어와 단위 테스트 - 6부
    [옛날 글들] 설계 이야기 2024. 5. 31. 10:37
    728x90

    이전  글 : 도메인 특화 언어와 단위 테스트 - 5부

    내부 DSL(Internal DSL)

    내부 DSL은 호스트 언어가 가진 제약 내에서 DSL을 구축한다. 호스트 언어에 대한 의존성은 양날의 검과 같다. 별도의 파서나 도구를 개발하지 않고도 호스트 언어가 제공하는 컴파일러만 있으면 쉽게 DSL을 구축할 수 있다. 그러나 DSL의 표현력이 호스트 언어의 표현력에 의해 제약을 받기 때문에 외부 DSL에 비해 언어의 유창함과 간결함이 상대적으로 떨어지는 경향이 있다. 또한 호스트 언어에서 사용 가능한 어떤 표현이라도 DSL 내에서 적법한 표현이기 때문에 범용 언어의 자유로움 속에서 DSL의 단순함을 잃어버릴 여지가 있다. 또한 언어에 대한 설계와 언어를 사용하는 사고 방식이 호스트 언어의 틀 안에 구속될 수 밖에 없다.

    내부 DSL을 구축할 때 사용하는 가장 중요한 추상화 메커니즘은 함수(또는 프로시저)다. 전통적인 커맨드-쿼리 인터페이스의 설계가 함수를 기반으로 하는 것처럼 유창한 인터페이스의 설계 역시 함수를 기반으로 한다. 차이점은 함수가 조합되는 방식에 있다. 커맨드-쿼리 인터페이스의 설계자는 각 함수가 독립적으로 사용될 것이라고 가정한다. 유창한 인터페이스의 설계자는 관련된 함수들이 더 커다란 문맥 안에서 조합되어 사용된다고 가정한다.

    이런 가정은 오퍼레이션의 이름에도 영향을 미친다. 커맨드-쿼리 인터페이스의 경우 개별 오퍼레이션이 독립적으로 사용되더라도 그 의도를 명확하게 전달할 수 있는 이름을 선택한다. 이에 비해 유창한 인터페이스를 구성하는 각각의 오퍼레이션 이름은 홀로 놓고 보면 그 의도를 명확하게 이해하기가 어렵다. 특정한 문맥 내에서 다른 오퍼레이션들과 함께 사용될 경우에만 의도와 의미가 명확해 진다.


    이제 지구 검증 소프트웨어의 도메인 모델을 의미 모델(SEMANTIC MODEL)로 삼아 함수 기반의 내부 DSL을 구현할 수 있는 몇 가지 패턴을 살펴 보기로 하자. 여기에서는 구현 방법보다는 코드 상에 나타나는 인터페이스의 형태에 초점을 맞추어 각 패턴의 차이점을 논의한다. 구현과 관련된 자세한 사항은 Martin Fowler의 저서인 “Domain-Specific Languages”를 참고하기 바란다. 각 패턴의 실제 구현 방법은 첨부된 소스 파일을 참고하기 바란다.

    메서드 체이닝(METHOD CHAINING)

    메서드 체이닝(METHOD CHAINIG)은 유창한 인터페이스 구현 방식 중에서 가장 대중적이고 널리 사용되는 방식이다. 쉽게 접할 수 있는 유명한 라이브러리나 프레임워크에서 설정 정보를 구성하는 API에 메서드 체이닝 방식을 적용하고 있기 때문에 대부분의 사람들은 유창한 인터페이스라고 하면 머릿속으로 메서드 체이닝 방식을 떠올린다.

    메서드 체이닝 방식은 상태를 변경하는 커맨드(COMMAND) 메서드의 반환 값으로 호스트 객체를 반환함으로써 호스트 객체의 메서드를 연쇄적으로 호출할 수 있도록 한다. 따라서 하나의 표현식 안에 다수의 메서드 호출을 일련의 흐름으로 통합할 수 있다. <리스트 1>은 행성을 생성하는 코드를 메서드 체이닝 방식으로 구현한 내부 DSL 코드를 나타낸 것이다. <리스트 1>의 전체 예제가 하나의 표현식이라는 점에 주목하라.


    <리스트 1> 메서드 체이닝 방식의 내부 DSL

    planet()
            .atmosphere()
                    .element("N", 0.8)
                    .element("O", 0.2)
                    .price(Money.wons(5000))
            .continent()
                    .name("아시아")
                    .nation()
                            .name("대한민국")
                            .price(1000)
                    .nation()
                            .name("일본")
                            .price(1000)
            .continent()
                    .name("유럽")
                    .nation()
                            .name("영국")
                            .price(1000)
                    .nation()
                            .name("프랑스")
                            .price(1000)
            .ocean()
                    .name("태평양")
                    .price(Money.wons(1000))
            .ocean()
                    .name("대서양")
                    .price(Money.wons(1000))
            .end();

     

     

    메서드들이 반환하는 객체가 의미 모델의 객체가 아니라는 점(즉, 도메인 모델의 객체가 아니라는 점)에 주의하라. 커맨드-쿼리 인터페이스로 작성된 의미 모델에 유창한 인터페이스를 혼합할 경우 인터페이스의 일관성이 무너지기 때문에 의미 모델 상의 객체를 메서드 체이닝의 대상으로 사용해서는 안 된다. 따라서 커맨드 메서드들이 반환하는 객체는 유창한 인터페이스 방식으로 구현되는 독립적인 표현식 빌더다.

     

    각 표현식 빌더의 커맨드 메서드는 다음의 호출에 사용될 수 있는 표현식 빌더를 반환한다. 예를 들어 위 예제에서 planet() 메서드는 PlanetBuilder의 인스턴스를 반환하고 연이어 호출되는 PlanetBuilder의 atmosphere() 메서드는 AtmosphereBuilder 인스턴스를 반환한다. element() 메서드는 다시 AtmosphreBuilder 인스턴스를 반환함으로써 뒤 이은 element() 메서드가 연쇄적으로 호출될 수 있도록 한다.

     

    메서드 체이닝은 커맨드-쿼리 분리(Command-Query Separation) 원칙을 위반한다. 즉, 상태를 변경하는 커맨드가 값을 반환한다. 또한 반환 값에 대한 메서드 호출을 허용하기 때문에 데메테르 법칙(Law of Demeter)을 위반한다. 따라서 메서드 체이닝 방식으로 작성된 코드는 많은 문헌에서 조롱의 대상이 되고는 하는 ‘열차 충돌(train wreck)’의 형태를 나타낸다. 일반적인 커맨드-쿼리 인터페이스에서는 데메테르 법칙의 위반이 (꼭 그런 것은 아니지만) 인터페이스 설계에 결함이 있을 수도 있음을 암시하지 DSL이라는 문맥에서는 인터페이스의 유창함을 만드는 핵심적인 구조를 제공한다.

     

    메서드 체이닝의 가장 큰 단점은 컨텍스트 변수를 관리하기가 어렵다는 점이다. <리스트 1>에서는 2개의 대륙을 생성하기 위해 2번의 continent() 메서드를 호출한다. 여기서 문제는 continent() 메서드가 호출될 때마다 기존에 생성 중이던 대륙 정보를 컨텍스트 변수에 보관한 후 새로운 대륙을 생성할 수 있도록 내부적으로 상태를 초기화해야 한다는 점이다. 생성하려는 의미 모델이 복잡하면 복잡할수록 다양한 객체를 생성하기 위해 관리해야 하는 컨텍스트 변수의 복잡도 역시 급격하게 증가한다.

     

    메서드 체이닝의 두 번째 문제는 다양한 메서드가 다양한 위치에서 호출될 수 있다는 점이다. <리스트 1>에서 Continent를 생성하기 시작하는 continent() 메서드는 Atmosphere를 생성하는 도중에서도, 다른 Continent를 생성하는 도중에도, Nation을 생성하는 도중에도 호출될 수 있다. 따라서 continent() 메서드는 AtmosphereBuilder에도, ContinentBuilder에도, NationBuilder에도 추가되어야 한다. 이것은 각 표현식 빌더의 인터페이스를 오염시키고 표현식 빌더 간의 코드 중복과 의존성을 관리하기 어렵게 만든다.

     

    메서드 체이닝의 세 번째 문제는 의미 모델의 생성을 언제 중단할 것인가를 결정하기가 어렵다는 점이다. 가장 명시적인 방법은 <리스트 1>에서와 같이 마지막에 end() 메서드와 같은 종료 메서드를 추가하는 것이다. 그러나 end() 메서드는 대륙, 국가, 대양, 대기, 행성 등의 다양한 위치에서 호출될 수 있기 때문에 모든 표현식 빌더에 end() 메서드를 구현하고 컨텍스트 변수에 빌더 간의 연관 관계를 관리해야 한다는 단점이 있다.

    함수 시퀀스(FUNCTION SEQUENCE)

    객체를 이용해 메서드를 연결하는 메서드 체이닝과 달리 함수 시퀀스(FUNCTION SEQUENCE) 방식은 상호 독립적인 함수의 나열을 통해 내부 DSL을 구현한다. 함수 시퀀스 방식으로 작성된 <리스트 2>의 코드를 보면 함수 간에 호출 순서 이외의 직접적인 연관성이 없음을 알 수 있다. 결과적으로 함수 호출 사이의 관계는 내부적인 파싱 데이터를 이용해 암묵적으로 관리되므로 함수 시퀀스는 다른 방식보다 상대적으로 많은 양의 컨텍스트 변수를 관리해야 한다.

    <리스트 2> 함수 시퀀스 방식의 내부 DSL

    planet();
            atmosphere();
                    element("N", 0.8);
                    element("O", 0.2);
                    price(5000);
            continent();
                    name("아시아");
                    nation();
                            name("대한민국");
                            price(1000);
                    nation();
                            name("일본");
                            price(1000);
            continent();
                    name("유럽");
                    nation();
                            name("영국");
                            price(1000);
                    nation();
                            name("프랑스");
                            price(1000);
            ocean();
                    name("태평양");
                    price(1000);
            ocean();
                    name("대서양");
                    price(1000);

     

    함수 시퀀스는 불필요한 표현 상의 잡음을 줄이기 위해 전역 함수의 형태를 취한다. 전통적으로 전역 함수는 두 가지 문제점을 가진다. 첫 째, 전역 함수는 전역 이름 공간(global namespace)에 위치하므로 모든 곳에서 호출될 수 있다. 둘 째, 전역 함수는 정적 파싱 데이터(static parsing data)를 필요로 한다. 두 가지 방식 모두 예측 불가능한 부수 효과(side effect)로 인해 프로그램을 불안정한 상태로 내몰 수 있다.

    함수 시퀀스의 문제점을 해결할 수 있는 가장 좋은 방법은 객체를 이용해서 전역 함수와 전역 데이터의 범위를 객체 내부로 제한하는 것이다. 뒤에서 살펴 볼 객체 범위(OBJECT SCOPING) 기법은 전역 함수와 전역 데이터를 상위 클래스에 두고 함수 시퀀스를 포함하는 스크립트는 서브 클래스에 둠으로써 함수 시퀀스의 전역 가시성 문제를 해결한다.

    내포 함수(NESTED FUNCTION)

    내포 함수(NESTED FUNCTION)는 다른 함수 호출 결과를 함수의 인자로 취함으로써 다수의 함수들을 조합하는 방식이다. 메서드 체이닝이나 함수 시퀀스와 달리 내포 함수는 의미 모델의 계층 구조를 코드 상에 자연스럽게 표현할 수 있다. 내포 함수에서 외부 함수가 생성하는 객체는 내부 함수가 생성하는 객체를 계층적으로 포함한다.

    <리스트 3> 
    내포 함수 방식의 내부 DSL

    planet(
            atmosphere(
                    price(5000),
                    element("N", 0.8),
                    element("O", 0.2)
            ),
            continents(
                    continent(
                            name("아시아"),
                            nation(
                                    name("대한민국"),
                                    price(1000)
                            ),
                           nation(
                                    name("일본"),
                                    price(1000)
                            )
                    ),
                    continent(
                            name("유럽"),
                            nation(
                                    name("영국"),
                                    price(1000)
                            ),
                            nation(
                                    name("프랑스"),
                                    price(1000)
                           )
                    )
            ),
            oceans(
                    ocean(
                            name("태평양"),
                            price(1000)
                    ),
                    ocean(
                            name("대서양"),
                            price(1000)
                    )
            )
    );

     

    메서드 체이닝과 함수 시퀀스는 컨텍스트 변수를 이용해 파싱 데이터를 관리한다. 이에 비해 내포 함수는 함수 호출 결과를 함수의 인자로 취하기 때문에 중간 결과를 저장하기 위한 컨텍스트 변수가 필요 없다. 그러나 포함 관계에 따라 왼쪽에서 오른쪽으로 자연스럽게 읽히는 메서드 체이닝과 함수 시퀀스 방식과 달리 내포 함수는 생성 순서에 따라 안에서 밖으로 읽어야 하기 때문에 코드의 가독성이나 이해도가 상대적으로 떨어진다.

     

    내포 함수 방식의 또 다른 단점은 함수 인자의 기본값을 지정할 수 없는 호스트 언어에서는 모든 함수 인자를 반드시 전달해야 한다는 것이다. 메서드 체이닝과 함수 시퀀스는 값이 불필요한 경우 메서드 호출을 생략함으로써 해당 값을 무시할 수 있지만 함수 시퀀스는 모든 인자값을 전달하지 않을 경우 컴파일 에러가 발생한다. 따라서 내포 함수는 모든 인자가 필수적인 경우에 적합하며 메서드 체이닝과 함수 시퀀스는 인자가 선택적인 경우에 적합하다.

     

    내포 함수 역시 전역 함수의 형태를 취하므로 함수 시퀀스와 유사한 전역 가시성의 문제에 직면하게 된다. 최선의 해결 방법은 함수 시퀀스의 경우처럼 객체 범위 기법을 사용하는 것이다.

    객체 범위(OBJECT SCOPING)

    객체 범위(OBJECT SCOPING) 기법은 함수 시퀀스와 내포 함수의 전역 가시성 문제를 해결하기 위해 사용된다. 객체 범위는 전역 함수의 이름 공간(namespace)로 사용할 클래스를 추가하고 전역 데이터의 범위를 객체 내부로 제한함으로써 전역 가시성의 문제를 해결한다.

    객체 범위 기법은 클래스 상속에 기반한다. 새로운 추상 클래스를 추가하고 전역 함수와 전역 데이터를 클래스의 멤버로 선언한다. 앞에서 함수 시퀀스 또는 
    내포 함수 방식으로 작성된 클래스들을 추상 클래스의 서브 클래스로 만든다. 이제 DSL 스크립트에서 사용하는 함수와 데이터는 전역 범위가 아니라 슈퍼 클래스의 인스턴스 범위로 제한된다.

    앞에서 살펴 본 
    내포 함수 예제에 객체 범위 기법을 적용해 보자. 먼저 DSL에서 사용할 모든 함수를 포함할 슈퍼 클래스를 정의한다(내포 함수 방식을 사용함으로써 별도의 컨텍스트 변수가 불필요하지만 함수 시퀀스 방식을 사용한다면 컨텍스트 변수 역시 함께 선언해야 할 것이다).

    <리스트 4> 객체 범위 기법을 위해 추가한 슈퍼 클래스

    public abstract class PlanetBuilder {
            protected Money price(long price) {
                    return Money.wons(price);
            }
       
            protected Element element(String name, double ratio) {
                    return Element.element(name, Ratio.of(ratio));
            }
    
            protected Atmosphere atmosphere(Money price, Element ... elements) {
                    return new Atmosphere(price, elements);
            }
    
            protected String name(String name) {
                    return name;
            }
    
            protected Nation nation(String name, Money price) {
                    return new Nation(name, price);
            }
    
            protected List<Ocean> oceans(Ocean ... oceans) {
                    return Arrays.asList(oceans);
            }
    
            protected Ocean ocean(String name, Money price) {
                    return new Ocean(name, price);
            }
    
            protected List<Continent> continents(Continent ... continents) {
                    return Arrays.asList(continents);
            }
    
            protected Continent continent(String name, Nation ... nations) {
                    return new Continent(name, nations);
            }
    
            protected Planet planet(Atmosphere atmosphere,
                    List<Continent> continents, List<Ocean> oceans) {
                    return new Planet(atmosphere, continents, oceans);
            }
    }

     

     

    DSL 스크립트를 포함하는 클래스를 PlanetBuilder의 서브 클래스로 선언한다. 이제 슈퍼 클래스에 정의된 메소드를 사용할 수 있으므로 전역 이름 공간의 눈치를 볼 필요가 없다.

    <리스트 5> 슈퍼 클래스에 정의된 메서드를 사용하는 
    내포 함수 방식의 DSL

    public class EarthBuilder extends PlanetBuilder {
            public Planet build() {
                    return planet(
                                        atmosphere(
                                                price(5000),
                                                element("N", 0.8),
                                                element("O", 0.2)
                                        ),
                                        continents(
                                                continent(
                                                        name("아시아"),
                                                        nation(
                                                                name("대한민국"),
                                                                price(1000)
                                                        ),
                                                        nation(
                                                                name("일본"),
                                                                price(1000)
                                                        )
                                                ),
                                               continent(
                                                        name("유럽"),
                                                        nation(
                                                                name("영국"),
                                                                price(1000)
                                                        ),
                                                        nation(
                                                                name("프랑스"),
                                                                price(1000)
                                                        )
                                               )
                                         ),
                                        oceans(
                                                ocean(
                                                        name("태평양"),
                                                        price(1000)
                                                ),
                                                ocean(
                                                        name("대서양"),
                                                        price(1000)
                                                )
                                        )
                               );
            }
    }

    내부 DSL 방식 조합하기

    메서드 체이닝, 함수 시퀀스, 내포 함수, 객체 범위 방식은 상호 배타적인 것이 아니며 각각의 장점을 취하기 위해 혼합해서 사용할 수 있다. <리스트 6>은 4가지 내부 DSL패턴을 모두 사용해서 두 개의 행성을 생성하는 DSL 코드를 나타낸 것이다. 두 개의 독립적인 planet() 함수 호출은 함수 시퀀스의 형태를 취한다. planet() 함수는 필수 값들을 인자로 전달하는 내포 함수를 사용하고 있으며 내포 함수에 전달할 인자들을 생성하기 위해서는 메서드 체이닝 기법을 사용한다. 여러 개의 대륙(continent()의 반환 값)과 대양(ocean()의 반환 값)을 목록으로 변환하는 continents()와 oceans()는 슈퍼 클래스인 PlanetBuilder으로부터 상속을 받는다. 따라서 <리스트 6>은 전역 범위의 함수와 데이터를 내부적으로 은폐하기 위해 객체 범위 방식을 따른다.

    <리스트 6> 다양한 내부 DSL 패턴의 조합

    public class BuilderCombination extends PlanetBuilder {
            public void build() {
                    planet(
                            atmosphere()
                                    .elements(element("N", 0.8), element("O", 0.2))
                                    .price(5000),
                            continents(
                                    continent().name("아프리카")
                                            .nations(
                                                    nation().name("이집트").price(1000),
                                                    nation().name("콩고").price(1000)),
                                    continent().name("북아메리카")
                                            .nations(
                                                    nation().name("캐나다").price(1000),
                                                    nation().name("미국").price(1000))),
                            oceans(
                                    ocean().name("남극해").price(1000),
                                    ocean().name("북극해").price(1000)));
           
                    planet(
                            atmosphere()
                                    .elements(element("N", 0.8), element("O", 0.2))
                                    .price(5000),
                            continents(
                                    continent().name("아시아")
                                           .nations(
                                                   nation().name("대한민국").price(1000),
                                                   nation().name("일본").price(1000)),
                                    continent().name("유럽")
                                           .nations(
                                                   nation().name("영국").price(1000),
                                                   nation().name("프랑스").price(1000))),
                                    oceans(
                                            ocean().name("태평양").price(1000),
                                            ocean().name("대서양").price(1000)));
                    ......
            }
    }

     

    <리스트 6>과 같이 여러 방식을 조합할 경우의 문제점은 DSL의 사용 방법이 복잡해질 수 있다는 것이다. 어떤 부분에는 메서드 체이닝 기법이 사용되고 어떤 부분에는 내포 함수 기법이 사용되는 지를 예측하기 어려울 경우 전체적인 DSL의 유용성이 저하된다. DSL 방식을 조합할 경우 일정한 규칙에 따라 사용 방식을 쉽게 예측 가능하도록 언어를 설계해야 한다. 빠른 속도로 정보를 처리할 수 있는 DSL의 유창함은 DSL로 작성된 코드를 읽는 사람들뿐만 아니라 DSL을 이용해 코드를 작성하는 사람에게도 중요하다.

    지금까지 내부 DSL에 관한 짧지만 중요한 몇 가지 개념을 살펴 보았다. 이제 앞에서 살펴본 픽스처 생성 문제를 해결하기 위해 DSL을 적용하는 문제로 돌아가 OBJECT MOTHER 패턴을 대체할 수 있는 TEST DATA BUILDER 패턴에 관해 살펴보기로 하자. TEST DATA BUILDER 패턴의 핵심은 테스트 픽스처 생성이라는 제한된 도메인에 특화된 언어를 내부 DSL의 형태로 구현하는 것이다.

     

    다음 글 : 도메인 특화 언어와 단위 테스트 - 7부

    728x90
Designed by Tistory.