Eternity's Chit-Chat

aeternum.egloos.com



도메인 특화 언어와 단위 테스트 - 4부(上) Software Quality


4부 소스코드 planet-part-4.zip

테스트 도메인에 특화된 언어

지금까지 살펴 본 것처럼 픽스처로 사용할 객체의 구조가 복잡하고 그로 인해 테스트의 결과를 예측하기 어려울 경우 테스트 케이스를 작성하려는 개발자의 의지는 좌절된다. 테스트를 생성하기 위해 미로처럼 복잡한 픽스처의 내부 구조를 이해해야 할 경우 테스트 케이스의 작성을 미루는 경향이 있다. 실패한 테스트 케이스를 열어 보았을 때 픽스처 설정 부분이 길고 복잡해서 실패의 원인을 파악하기 어려운 경우 테스트 실행 목록에서 해당 테스트 케이스를 제외하거나 테스트가 실패하지 않도록 코드의 구조를 왜곡시킨다. 결국 테스트는 상환하지 못 할 기술 부채(technical debt)를 누적시키는 원흉으로 전락하고 만다.


픽스처로 사용할 객체의 생성자를 직접 호출하는 테스트 케이스는 생성자의 시그니처 변경에 매우 취약하다. 객체의 생성자에 파라미터가 추가되거나 제거될 경우 해당 객체를 픽스처로 사용하는 테스트 케이스 전체가 변경으로 몸살을 앓게 된다. 결국 과도한 양의 테스트 케이스 수정에 지친 개발자들은 객체지행에서 지향하는 풍부한 관계를 도메인 클래스의 작성과 리팩토링을 꺼리게 된다. 또한 테스트와 무관한 정보들로 인해 중요한 정보가 가려지는 불투명한 테스트 케이스는 테스트의 의도를 이해하고 결과를 예측하기가 어렵게 만든다. 결과적으로 복잡한 픽스처 생성 로직을 가진 테스트 케이스는 지친 개발자의 발목을 잡고 시스템의 유지보수와 확장을 방해하는 걸림돌이 되고 만다.

OBJECT MOTHER 패턴은 이와 같은 픽스처 생성과 관련된 문제를 해결하기 위해 사용할 수 있는 한 가지 패턴이다. OBJECT MOTHER는 픽스처 생성을 위한 오퍼레이션을 제공하는 일종의 FACTORY 로 도메인 객체의 생성자 호출을 FACTORY 내부로 캡슐화함으로써 생성자 변경에 의한 파급효과를 OBJECT MOTHER 내부로 제한한다. 그러나 테스트에 필요한 픽스처의 상태 조합에 따라 오퍼레이션 수가 폭발적으로 증가하기 때문에 중복 코드를 양산하고, 구현과 유지보수가 복잡해지며, 변경에 취약하다는 단점을 가진다.

FACTORY 패턴을 기반으로 하는 OBJECT MOTHER 패턴과 달리 TEST DATA BUILDER 패턴은 BUILDER 패턴을 기반으로 한다. TEST DATA BUILDER 패턴의 기본적인 접근 방법은 커맨드-쿼리 인터페이스를 제공하는 도메인 계층 위에 픽스처 생성이라는 제한된 범위를 지원하기 위해 유창한 인터페이스(Fluent Interface)의 언어 계층을 얹는 것이다. 즉, TEST DATA BUILDER 패턴의 핵심은 테스트를 위한 도메인-특화 언어(Domain-Specific Language, DSL)를 창조하는 것이다.


TEST DATA BUILDER 패턴의 중심에는 연쇄적인 메서드 호출을 통해 유창한 인터페이스를 제공하는 메서드 체이닝(METHOD CHAINING)이 위치한다. 커맨드-쿼리 인터페이스를 기반으로 하는 GOF의 BUILDER 패턴과 달리 TEST DATA BUILDER 패턴은 오퍼레이션의 결과로 반환되는 객체(호스트 객체라고 부른다)에 대한 연쇄적인 메서드 호출을 제공함으로써 픽스처 생성 코드를 유창하고 간결하게 만든다.

DSL의 관점에서 TEST DATA BUILDER 패턴의 의미 모델(SEMANTIC MODEL)은 애플리케이션의 도메인 객체다. 도메인 객체는 커맨드와 쿼리를 명확하게 구분하는 커맨드-쿼리 인터페이스(Command-Query Interface)를 제공한다. TEST DATA BUILDER는 도메인 객체를 의미 모델로 사용하고 오퍼레이션 호출을 결합해 픽스처로 생성할 도메인 객체를 생성하는 표현식 빌더(EXPRESSION BUILDER)다. 따라서 TEST DATA BUILDER는 테스트 케이스 작성자에게 쉽고 간결하게 픽스처를 생성할 수 있는 유창한 인터페이스(Fluent Interface)를 제공한다.

일반적으로 TEST DATA BUILDER는 아래와 같은 절차에 따라 구현한다. 빌더 클래스나 메서드의 이름은 강제사항은 아니지만 개인적인 경험에 따르면 아래에 명시한 명명 규칙을 따르는 것이 코드의 가독성과 유지보수성을 향상시킨다는 것을 알 수 있었다.
  1. 빌더 클래스를 추가한다. 빌더 클래스의 이름은 “애플리케이션 클래스 이름 + Builder”의 형식으로 명명한다. 예를 들어 애플리케이션 클래스가 Nation이라면 빌더 클래스의 이름은 NationBuilder가 된다.
  2. 빌더 자체의 인스턴스를 생성해서 반환하는 정적 메서드를 추가한다. 정적 메서드의 이름은 “a + 애플리케이션 클래스 이름” 또는 “an + 애플리케이션 클래스 이름”의 형식을 따른다. NationBuilder의 경우 정적 메서드의 이름은 aNation()으로 한다.
  3. 애플리케이션 클래스의 모든 속성을 빌더 클래스에 추가한다.
  4. 속성 선언 시에 반드시 기본값을 할당한다. 기본값은 테스트 케이스에서 픽스처를 생성할 때 테스트와 관련 없는 정보를 표시하지 않아도 무방하도록 만들기 위해 사용한다.
  5. 속성을 설정할 수 있는 커맨드(Command) 메서드를 추가한다. 커맨드 메서드의 이름은 set으로 시작하는 일반적인 JavaBeans 관례를 따르지 않고 with로 시작하는 관례를 따른다. 또한 반환값이 없는 전통적인 커맨드 메서드와 달리 빌더의 커맨드는 메서드 체이닝을 지원하기 위해 빌더 자신을 반환한다. 따라서 각 커맨드 메서드의 마지막 문장은 return this로 끝나야 한다.
  6. build() 메서드를 추가한다. build() 메서드의 용도는 픽스처로 사용할 애플리케이션 객체를 생성한 후 반환하는 것으로 메서드 체이닝이 완결되었다는 것을 명시적으로 표현한다.

빌더를 구현하는 방법을 자세히 살펴 보기 위해 지구 검증 소프트웨어의 Nation 객체를 위한 빌더를 구현해 보자. 국가의 개념을 추상화하는 Nation 클래스는 이름(name)과 제조 가격(price)을 속성으로 포함하는 간단한 도메인 객체다.

<리스트 1> 이름과 제조 가격을 속성으로 포함하는 Nation 클래스

public class Nation {
  private String name;
  private Money price;
  ...
}
Nation 의 인스턴스를 생성하는 빌더의 이름은 NationBuilder로 정한다. NationBuilder는 Nation의 name과 price의 값을 변경하기 위한 SETTER 메서드인 withName()과 withPrice()를 포함한다. build() 메서드는 설정된 속성 값을 이용해 Nation 객체를 생성한 후 반환한다.

<리스트 2> Nation을 생성하는 NationBuilder

public class NationBuilder {
  private String name = "대한민국";
  private Money price = Money.wons(1000);

 
public NationBuilder withName(String name) {
    this.
name = name;
    return this;
  }
    
 
public NationBuilder withPrice(Money price) {
   
this.price = price ;
   
return this;
  }
    
 
public Nation build() {
   
return new Nation(name, price);
  }
}

<리스트 2>는 DSL과 관련된 TEST DATA BUILDER 패턴의 특징 몇 가지를 잘 보여준다. 첫 째, 인터페이스는 일반적인 커맨드-쿼리 인터페이스 스타일이 아닌 메서드 체이닝 기반의 유창한 인터페이스 스타일을 따른다. NationBuilder의 상태를 변경하는 커맨드 메서드인 withName()과 withPrice()는 빌더 자신을 반환한다(return this). 이것은 커맨드-쿼리 분리(Command-Query Separation) 원칙을 위반하지만 <리스트3>과 같이 메서드의 연쇄적인 호출을 통해 자연스러운 흐름으로 오퍼레이션들이 조합될 수 있도록 한다.

<리스트 3> 빌더의 생성자를 직접 호출하는 코드

Nation nation = new NationBuilder()
                  .withName(
"대한민국")
                  .withPrice(Money.wons(1000))
                  .build();

둘 째, 커맨드 메서드의 명칭은 set으로 시작하는 전통적인 JavaBeans 컨벤션과 다르게 with로 시작한다. with로 시작하는 이유는 메서드의 이름이 독립적으로 사용되는 것이 아니라 다른 메서드와 연결도어 함께 사용되는 문맥을 강조하기 위해서이다.

사실 <리스트 2>는 DSL의 간결함과 유창함이라는 측면에서 좀 더 개선할 여지가 있다. 메서드 체이닝을 시작하기 위해 NationBuilder를 생성할 때 현재는 new NationBuilder()와 같이 직접 빌더의 생성자를 호출해야 한다. 큰 문제는 아니지만 클래스의 인스턴스를 생성하기 위해 new라는 키워드를 사용하는 것은 자연스러운 문장의 흐름을 방해한다. 빌더의 생성자를 호출하는 부분을 aNation()이라는 이름을 가진 정적 메서드로 대체함으로써 DSL의 흐름을 깔끔하게 만들 수 있다.

<리스트 4> 빌더를 생성하는 정적 메서드 추가

public class NationBuilder {
  public static NationBuilder aNation() {
   
return new NationBuilder();
  }
  ...
}

자바의 정적 임포트(static import) 기능을 이용해 불필요한 클래스 정보를 생략하면 다음과 같이 전반적인 생성 코드의 흐름을 좀 더 자연스럽게 만들 수 있다.

<리스트 5> 정적 임포트를 이용한 메서드 흐름의 개선

Nation nation = aNation()
                  .withName("대한민국")
                  .withPrice(Money.wons(1000))
                  .build();

<리스트 1>에서 NationBuilder의 이름(name)과 가격(price) 속성을 선언할 때 기본 값으로 “대한민국”과 Money.wons(1000)을 할당하는 것에 주목하라. 빌더의 속성에 기본 값을 할당해 놓으면 테스트 케이스 작성 시 테스트 결과와 관련이 없는 불필요한 정보를 생략해도 NullPointerException이 발생하지 않도록 할 수 있다. 따라서 속성의 기본값은 관련 없는 정보가 테스트 케이스에 과도하게 보여지지 않도록 정보를 숨김으로써 애매한 테스트가 되는 것을 방지한다.

만약 Nation의 속성 값과 상관없이 인스턴스의 존재만이 중요하다면 다음과 같이 단순한 코드만으로도 목적을 달성할 수 있다.

<리스트 6> 기본 값을 가지도록 Nation 인스턴스 생성

Nation nation = aNation().build();

테스트 코드에서 이름(name) 속성의 값은 중요하지 않고 제조 가격(price) 속성의 값만 중요하다면 다음과 같이 픽스처 생성 시 제조 가격만을 설정하고 이름은 기본 값을 따르도록 할 수 있다. 빌더의 이러한 특성은 별도의 코드 수정이나 새로운 오퍼레이션 추가 없이도 다양한 상태의 픽스처를 조합할 수 있도록 한다.

<리스트 7> 제조 가격만을 강조하고 싶은 경우

Nation nation = aNation()
                  .withPrice(Money.wons(1000))
                  .build();

TEST DATA BUILDER의 속성은 DSL 관점에서 문맥 변수(Context Variable)에 해당한다. 문맥 변수에 기본 값을 할당함으로써 공유 문맥에 의존하는 여러 오퍼레이션들 호출 간에 전달해야 하는 불필요한 정보의 양을 줄일 수 있다. 또한 문맥 변수의 현재 값을 기반으로 다양하게 오퍼레이션 호출을 조합할 수 있기 때문에 API 사용성의 측면에서 유연성이 높아진다. TEST DATA BUILDER가 제공하는 이러한 유연성은 새로운 상태를 가진 픽스처가 필요한 경우 매번 새로운 오퍼레이션을 추가해야 하는 OBJECT MOTHER 패턴과는 대조적이다.

TEST DATA BUILDER 조합하기

Continent를 생성하는 ContinentBuilder 역시 동일한 방식으로 구현할 수 있다. Continent는 이름(name)과 국가 목록(nations)을 속성으로 가진다. nations 속성에 할당할 기본 값을 생성하기 위해 앞에서 구현한 NationBuilder를 재사용하는 것에 주목하라.

<리스트 8> Continent를 생성하는 ContinentBuilder
public class ContinentBuilder {
  private String name = "아시아";
  private List<Nation> nations = Arrays.asList(
        aNation().with("대한민국").with(wons(1000)).build(),
        aNation().with("일본").with(wons(1000)).build());
    
  public static ContinentBuilder aContinent() {
   
return new ContinentBuilder();
  }
    
 
public ContinentBuilder withName(String name) {
   
this.name = name;
   
return this;
  }
    
 
public ContinentBuilder withNations(Nation ... nations) {
   
this.nations = Arrays.asList(nations);
   
return this;
  }
    
 
public Continent build() {
   
return new Continent(name , nations.toArray(new Nation[0]));
  }
}
NationBuilder에서 설명한 것과 동일하게 인스턴스의 존재 여부만이 중요한 경우에는 aContinent.build()와 같이 간략한 방식으로 사용할 수 있다. 만약 테스트 케이스에서 대륙의 이름은 중요하지 않고 포함된 국가의 이름과 개수만 중요하다면 <리스트9>와 같이 두 개의 정보만을 이용해 픽스처를 생성할 수 있다. withNations()에 전달할 Nation 인스턴스를 생성하기 위해 앞에서 구현한 NationBuilder를 사용한다.

<리스트 9> 국가 정보만 중요한 경우의 Continent 생성 코드

Continent continent = aContinent()
                        .withNations(aNation().withName("캐나나").build(),
                                     aNation().withName("미국").build())
                        .build();
<리스트 9>의 코드를 자세히 살펴 보면 withNations() 메소드 안에 aNation() 메서드를 호출하는 코드가 들어 있다는 것을 알 수 있다. 이 경우 두 메서드에 “Nation”이라는 단어가 중복되어 나타난다(withNations()에서 한 번, aNation()에서 한 번). 두 메서드 이름 모두에 “Nation”이라는 단어가 포함되는 것은 불필요한 중복이며 결과적으로 DSL의 흐름을 방해한다. 간결함과 유창함을 향상시키는 한 가지 방법은 withNations() 메서드를 간단히 with()로 변경하고 뒤이어 호출되는 aNation() 메서드의 반환 타입을 이용해 컴파일러가 어떤 타입의 파라미터를 받는 with() 메서드 인지를 판단하도록 하는 것이다. 마찬가지로 withName() 메서드 역시 String을 인자로 받는 with()로 변경할 수 있다. 두 메서드의 이름이 같아도 무방한 것은 메서드의 인자 타입이 하나는 String, 하나는 Nation의 배열로 다르기 때문이다. 만약 동일한 타입의 속성이 하나 이상 존재할 경우 앞의 방식처럼 ‘with+속성명’의 형식을 따라 메서드 이름을 지어야 한다.

<리스트 10> withName()과 withNations()를 with()로 수정

public class ContinentBuilder {
 
public ContinentBuilder with(name) {
   
this.name = name;
   
return this;
  }
   
 
public ContinentBuilder with(Nation ... nations) {
   
this.nations = Arrays.asList(nations);
   
return this;
  }
}
NationBuilder 역시 동일한 규칙에 따라 with 메서드들의 이름을 좀 더 간략하게 줄일 수 있다.

<리스트 11> with 메서드의 이름을 수정한 NationBuilder

public class NationBuilder {
 
public NationBuilder with(String name) {
   
this.name = name;
   
return this;
  }
   
 
public NationBuilder with(Money price) {
   
this.price = price;
   
return this;
  }
  ...
}
이제 <리스트 9>의 코드는 <리스트 12>와 같이 간소화되어 DSL 특유의 간결함과 유창함이 향상되었다는 것을 느낄 수 있다. 이 예에서 알 수 있는 것처럼 문맥 상의 연결성 측면에서 오퍼레이션의 이름을 정할 경우 간결하고 유창한 DSL을 얻을 수 있다.

<리스트 12> 국가 정보만 중요한 경우의 Continent 생성 코드

Continent continent = aContinent()
                        .with(aNation().with("캐나나").build(),
                              aNation().with("미국").build())
                        .build();
<리스트 12>에서 한 가지 눈에 거슬리는 부분은 Nation을 생성할 때마다 매번 NationBuilder의 build() 메서드를 호출하는 부분이다. 만약 생략해도 의미 전달에 지장이 없다면 정보를 제거하는 것이 DSL의 간결함과 유창함을 향상시키는 또 다른 방법이다.

외부에서 직접 build()를 호출하지 않아도 되도록 Nation이 아닌 NationBuilder를 인자로 전달 받도록 ContinentBuilder의 with 메서드를 수정하자. ContinentBuilder는 Nation을 생성하기 위해 전달받은 NationBuilder의 build() 메서드를  내부적으로 호출한다.

<리스트 13> NationBuilder를 인자로 취하는 ContinentBuilder
public class ContinentBuilder {
 
public ContinentBuilder with(NationBuilder ... nations) {
   
this.nations = new ArrayList<Nation>();
       
    for(NationBuilder each : nations) {
       
this.nations.add(each.build());
    }

   
return this;
  }
  ...
}

이제 Continent를 생성하는 코드에서 불필요한 build() 메서드 호출을 생략할 수 있다.

<리스트 14> build() 메서드 호출을 제거한 Continent 생성 코드

Continent continent = aContinent()
                        .with(aNation().with("캐나나"),
                              aNation().with("미국"))
                        .build();

PlanetBuilder와 AtmosphereBuilder, OceanBuilder 역시 동일한 방식으로 구현할 수 있다. 전체 코드는 첨부된 소스 코드를 참고하기 바란다.

<리스트 14>는 다양한 내부 DSL 설계 기법을 조합해서 사용함으로써 DSL로 작성된 스크립트의 가독성과 사용성을 향상시킬 수 있음을 보여준다. 여기에서 각각의 빌더는 픽스처의 속성을 설정하는 과정을 유창하게 만들기 위해 메서드 체이닝을 이용한다. 빌더가 다른 빌더에 의존할 경우 내포 함수(NESTED FUNTION)를 이용한다. 따라서 TEST DATA BUILDER 패턴은 메서드 체이닝을 통해 전체적인 인터페이스의 명확성과 유연성을 향상시키는 한편 내포 함수를 통해 복잡한 객체의 생성에 필요한 문맥 변수의 양을 감소시킨다. 하나 이상의 대륙이 필요한 경우 다수의 ContinentBuilder()를 호출하는 함수 시퀀스(FUNCTION SEQUENCE) 방식을 조합할 수 있다.

TEST DATA BUILDER와 리팩토링

TEST DATA BUILDER 패턴을 소개할 때 가장 많이 받게 되는 질문은 픽스처로 사용될 애플리케이션 객체의 계층 구조와 빌더 계층 구조 간의 의미적인 결합으로 인해 객체의 리팩토링이 어려워지지 않느냐는 점이다. 빌더의 속성과 빌더 간의 관계가 애플리케이션 객체의 속성과 연관 관계를 따를 경우 도메인 객체의 연관 관계 변경은 곧 빌더의 연관 관계 변경을 의미하기 때문에 결과적으로 두 애플리케이션 객체를 픽스처로 사용하는 모든 테스트 케이스도 함께 리팩토링해야 하기 때문이다. 이로 인해 애플리케이션 객체의 수정으로 인해 영향을 받는 코드 양이 OBJECT MOTHER보다 많아 질 것이며 결과적으로 개발자들이 애플리케이션 객체의 리팩토링을 꺼리게 되지 않겠느냐는 것이 질문의 요지다. 그러나 기우와 달리 오히려 TEST DATA BUILDER 패턴을 사용할 경우 좀 더 쉽고 안정적으로 애플리케이션 객체를 리팩토링할 수 있다. 

Java와 같은 정적 언어에서 애플리케이션 객체의 연관 관계를 수정하면 관련된 빌더와 테스트 케이스 모두에서 컴파일 에러가 발생한다. 따라서 컴파일 에러를 기반으로 애플리케이션 객체를 수정할 때 어떤 테스트 케이스가 영향을 받는 지를 쉽게 파악할 수 있다(Michael Feathers는 컴파일러를 이용해 변경 지점을 파악하는 것을 LEANING ONT THE COMPILER라고 부른다). 결과적으로 코드 수정에 의한 변경 범위를 한 눈에 파악할 수 있으며 이것은 시스템의 캡슐화와 의존성 관리에 대한 힌트를 제공해준다.

이와 달리 OBJECT MOTHER는 픽스처 생성 부분을 생성 메서드 내부로 감추기 때문에 생성 메서드의 수정으로 인해 영향을 받는 테스트 케이스를 파악하기가 힘들다. 영향을 받는 테스트 케이스를 파악하는 유일한 방법은 변경으로 영향을 받는 생성 메서드를 호출하는 부분을 찾아 보는 것뿐이다. 물론 현대적인 IDE의 도움으로 이런 유형의 작업이 수월해 지기는 했지만 정적 언어를 사용하는 경우 컴파일러에 의존하는 것이 더 간편하고 정확하다. 결국 OBJECT MOTHER의 과도한 캡슐화는 오히려 코드 수정으로 인한 영향 정도를 파악하고 코드를 개선하는 작업을 방해한다.

경험에 따르면 TEST DATA BUILDER 패턴을 사용할 경우 수정해야 하는 테스트 케이스의 수가 생각보다 많지 않은데 테스트 결과와 직접적으로 관련이 없는 정보는 테스트 케이스에서 감춰지기 때문이다. 오히려 즉각적인 컴파일 에러와 적은 양의 픽스처 생성 코드로 인해 수정해야 하는 부분을 직관적으로 파악할 수 있기 때문에 리팩토링에 소요되는 시간과 노력이 상대적으로 적어진다. OBJECT MOTHER 는 수 많은 생성 메서드의 범람과 메서드 호출에 사용되는 인자의 증가로 인해 사용과 이해가 어렵고 유사 메서드 간에 중복 코드가 발생하기 쉽기 때문에 관리가 어렵다.

요약하면 다음과 같다. TEST DATA BUILDER 패턴을 사용할 경우 애플리케이션 객체의 변경으로 인해 영향을 받는 테스트 케이스를 쉽게 파악할 수 있으며 각 테스트 케이스에 꼭 필요한 연관관계와 속성만을 노출하고 무관한 정보는 감추기 때문에 애플리케이션 객체의 리팩토링에 대한 면역력이 강하다.


핑백