ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 의존성 끊기와 단위 테스트 – 2부 [끝]
    [옛날 글들] 설계 이야기 2024. 5. 30. 14:09
    728x90

     

    이전 글 : 의존성 끊기와 단위 테스트 - 1부

    의존성 끊기와 설계

    의존성 끊기 게임의 효과는 단위 테스트만으로 국한되지 않는다. 일반적으로 클래스 간의 의존성을 제어하여 테스트 용이성을 향상시킬 경우 결과적으로 시스템의 설계를 개선시킬 수 있는 가능성이 높아 진다.

    앞에서 예로 든 통계 서비스의 경우 일간 통계뿐만 아니라, 주간, 월간 단위의 통계 데이터 역시 사용자에게 제공하고 있다. 의존성을 끊기 전에는 <그림 6>에서 볼 수 있는 것처럼 일간, 주간, 월간 작업을 실행하는 클래스 모두가 JobConf와 JobClient에 의존하고 있었으며 입력 경로나 출력 경로 값 이외에 <리스트 1>에서 보인 대부분의 코드가 중복되어 있었다. 따라서 일간, 주간, 월간 통계를 테스트하기 어려웠을 뿐만 아니라 절차적인 방식의 설계와 코드 중복으로 인해 코드의 확장및 유지보수하기가 어려운 구조를 가지고 있었다.

    <그림 6> 의존성을 끊기 전의 일간, 주간, 월간 통계 구조

     
    그러나 앞에서 살펴본 바와 같이 의존성 제어를 통해 테스트 가능하도록 구조를 개선한 후에는 <그림 7>과 같이 코드 중복이 없으며 코드 수정 없이도 새로운 기간의 통계 처리를 추가할 수 있는 OCP(Open-Closed Principle)를 만족하는 구조를 얻게 되었다.

    <그림 7> 테스트 용이한 구조로 개선한 후의 OCP를 만족하는 구조

     

    지금까지 살펴본 바와 같이 테스트 용이성을 향상시키기 위해 의존성 끊기 게임을 수행할 경우 얻을 수 있는 장점은 크게 두 가지로 나눌 수 있다. 첫 째, 목적 자체가 테스트를 용이하게 하는 것이기 때문에 의존성 끊기 게임의 결과 단위 테스트 작성이 용이한 구조를 얻을 수 있다. 단위 테스트라는 안전망을 통해 지켜지는 코드는 더 빠른 피드백을 제공하고 문제 없이 전진할 수 있다는 용기를 심어 준다. 둘 째, 의존성 끊기 게임의 결과 전체적으로 응집도가 높고 결합도가 낮은 설계를 얻게 된다. 그 결과 가능한 한 객체-지향 원칙을 지키는 품질 높은 코드를 얻을 수 있다.

    단위 테스트 가능한 코드를 목적으로 할 때 설계가 향상될 가능성이 높다는 사실을 알게 되었다. 다음 호에는 SEAM 의 개념을 소개하고 단위 테스트를 통한 의존성 끊기와 설계와의 관계를 좀 더 구체적으로 살펴 볼 것이다.

    프로그램과 의존성

    프로그램을 바라보는 프로그래머의 관점은 프로그램을 작성하고 수정하는 행위에 영향을 미친다. 프로그램을 텍스트의 목록으로 바라보는 관점에서 프로그램 작성과 수정은 단순하게 텍스트를 편집하는 작업으로 요약할 수 있다. 새로운 행위가 필요하면 텍스트를 추가하고, 행위를 변경하기 위해서는 텍스트를 수정한다. 프로그램은 텍스트의 목록이기 때문에 전체적인 문맥 흐름에 문제만 없다면 텍스트를 추가하거나 수정하는데 아무런 제약도 없다.

     

    불행하게도 프로그램에 대한 텍스트 메타포는 높은 응집도와 낮은 결합도, 캡슐화와 같은 훌륭한 설계가 갖추어야 하는 기본 덕목을 설명하기에는 적절하지 않다. 애플리케이션 설계를 적절한 추상화의 발견과 복잡한 의존성의 제어라고 요약할 때, 프로그램을 다른 각도에서 바라볼 경우 의존성을 제어하기 위한 체계적인 접근 방법을 적용할 가능성이 높아진다.

     

    개인적으로 단위 테스트 관점에서 프로그램을 바라보게 되면서 훌륭한 설계란 어떠해야 하는지에 대해 심사숙고 하게 되었다. 어떤 클래스는 간단하게 단위 테스트 할 수 있기 때문에 작동하는 가장 단순한 방법으로 구현하면 된다. 어떤 클래스는 의존성의 사슬에 묶여 옴짝달싹하지 못하기 때문에 의존성을 끊을 수 있는 설계를 요구한다. 너무 많은 의존성을 가진 클래스는 SRP(Single Responsibility Principle)를 위반한다는 증거다. EXTRACT CLASS를 적용하라. 내부에서 직접 생성하는 클래스로 인해 단위 테스트를 실행할 수 없다면  DEPENDENCY INJECTION을 통해 외부에서 객체를 조립하도록 변경하거나 DEPENDENCY LOOKUP을 통해 생성되는 인스턴스를 변경할 수 있는 여지를 제공하라. 인프라스트럭처에 대한 의존성을 가진 코드가 여기저기에 흩어져 있어 단위 테스트를 수행하기가 어렵다면 인프라스트럭처에 의존하는 모든 코드를 단일 클래스나 패키지 내부로 캡슐화하고 이를 사용하는 클래스에서는 STRATEGY 패턴을 적용하라.

     

    단위 테스트를 작성하다 보면 수많은 설계 이슈들의 아우성 소리가 들려온다. 단위 테스트를 작성하기 시작하면 프로그램은 더 이상 단순한 텍스트의 집합이 아니다. 프로그램은 이야기를 담고 있지만 그 이야기는 평면적이지 않다. 프로그램이 들려주는 이야기는 완결된 스토리를 가지고 있지만 그렇다고 해서 완전히 고정된 것은 아니다. 단위 테스트는 소프트웨어가 좀 더 소프트해지기를 요구한다. 결국 프로그램은 유연해져야 하며 유연한 설계는 훌륭한 프로그램의 필요 조건이다.

     

    단위 테스트는 훌륭한 설계를 작성할 수 있도록 우리를 인도한다. 이것이 이번 컬럼의 주제다.

    SEAM과 의존성 제어

    의존성을 제어한다는 것은 의존하고 있는 코드를 다른 코드로 대체한다는 것을 의미한다. 그러나 의존성을 대체하기 위해 테스트 플래그를 이용하는 TEST HOOK을 적용할 필요는 없다.
    전통적으로 PROCEDURAL TEST STUB은 필요한 코드가 준비되기 전까지 디버깅을 진행 하기 위해서 사용되었다. PROCEDURAL TEST STUB은 실행 시간에 교체할 수 없으며, 대부분의 절차적 프로그래밍 언어에서 이를 교체하는 것은 매우 어려운 작업이다. 프로덕션 코드에 테스트 코드를 추가하는 것에 대해 거부감을 느끼지만 않는다면 SUT(System Under Test) 네에 if testing then … else와 같은 TEST HOOK을 사용하는 PROCEDURAL TEST STUB을 구현할 수 있다.

    Gerard Meszaros, xUnit Test Patterns
    TEST HOOK은 코드를 대체하기 어려운 절차형 언어에서 테스트를 용이하게 하기 위해 사용하는 방법이다. 현재 주류를 이루는 객체지향 언어에서는 TEST HOOK을 사용하지 않고도 의존성을 대체하는 것이 가능하다. 이를 위해서는 SEAM의 개념을 이해하는 것이 필요하다.

    Michael Feathers는 그의 저서 “Working Effectively with Legacy Code”에서 SEAM을 다음과 같이 정의하고 있다.

    SEAM이란 코드를 수정하지 않고도 프로그램의 행위를 변경할 수 있는 지점을 의미한다.


    Michael Feathers, Working Effectively with Legacy Code

     

    단위 테스트 관점에서 SEAM이란 테스트 대상 코드를 수정하지 않고도 테스트 실행 중에 다른 코드로 대체할 수 있는 부분이라고 정의할 수 있다. SEAM은 코드를 대체하는 시점에 따라 컴파일 단계에서 행위를 대체하는 PREPROCESSING SEAM, 링크 단계에서 행위를 대체하는 LINK SEAM, 실행 시에 행위를 대체하는 OBJECT SEAM의 3가지로 분류할 수 있다. 대부분의 객체 지향 언어의 경우에는 OBJECT SEAM을 사용하는 것이 적절하다.

     

    OBJECT SEAM은 객체 지향 프로그램 상에서 메소드 호출부가 존재할 때, 실제로 어떤 메소드가 호출되는 지는 실행 시간에 결정된다는 특성을 사용한다. <리스트 3>은 이전 컬럼에서 예제로 들었던 통계 코드에서 발췌한 것이다. 

    String [] inputPathes = jobConfiguration.resolveInputPath();

    <리스트 3> 통계 코드에서의 메소드 호출 예제


    <리스트 2>에서 resolveInputPath() 메소드는 <그림 8>에 표시된 3개의 클래스 중 어떤 클래스의 메소드를 호출하는 것일까?

    <그림 8> JobConfiguration 인터페이스의 계층도

     

    jobConfiguration이 어떤 객체를 가리키는 지 알지 못한 다면 어떤 메소드가 호출되는 지를 알 수는 없다. jobConfiguration은 DailyJobConfiguration의 인스턴스일 수도, WeeklyJobConfiguration의 인스턴스일 수도, MonthlyJobConfiguration의 인스턴스일 수도 있다.  실제로 어떤 객체를 가리키는 지는 코드의 전후 관계 또는 실행 시의 설정을 살펴보아야만 한다.

    객체 지향 프로그램의 실행 구조는 소스 코드 구조와 일치하지 않는 경우가 종종 있다. 코드 구조는 컴파일 시점에 확정되는 것이고 이 구조에는 고정된 상속 클래스 관계들을 포함한다. 그러나 프로그램의 런타임 시 구조는 교류하는 객체들에 따라서 달라질 수 있다. 즉, 이 두 구조는 전혀 다른 별개의 독립성을 갖는다. 하나로부터 다른 하나를 이해하려는 것은 생태계의 동적인 성질을 식물과 동물과 같은 정적 분류 구조를 바탕으로 이해하려는 것과 똑같다.

    GOF, Design Patterns

     

    객체 지향의 이러한 특성을 사용해서 jobConfiguration이 가리키는 객체를 변경할 수 있다면 해당 호출 부분은 OBJECT SEAM이 될 수 있다. 그러나, 모든 메소드 호출 부분이 OBJECT SEAM인 것은 아니다. <리스트 4>의 경우 jobConfiguration이 가리키는 객체를 변경하는 것이 불가능하기 때문에 SEAM이라고 할 수 없다. 소스 코드 구조와 실행 구조가 동일할 경우 SEAM의 개념을 이용하는 것이 불가능하다. 

    public void resolvePathes() {
      ...
      JobConfiguration jobConfiguration = new DailyJobConfiguration();
      ...
      String [] inputPathes = jobConfiguration.resolveInputPath();
      ...
    }

    <리스트 4> SEAM이 존재하지 않는 경우


    코드를 수정하지 않고도 해당 코드의 행위를 변경하기 위해서는 행위를 선택할 수 있는 수단을 제공해야 한다. 이를 ENABLING POINT라고 한다.

    모든 SEAM에는 특정 행위를 선택할 수 있는 코드 상의 지점인 ENABLING POINT가 존재한다.

    Michael Feathers, Working Effectively with Legacy Code

     

    <리스트 4>에는 행위를 선택할 수 있는 ENABLING POINT가 존재하지 않는다. 따라서 만약 DailyJobConfiguration이 인프라스트럭처에 대해 의존하고 있을 경우 해당 클래스에 대한 단위 테스트를 실행하기가 쉽지 않다. 의존성을 제어하기 위해서는 <리스트 5>와 같이 ENABLING POINT를 제공해야 한다. 

    public void setJobConfiguration(JobConfiguration jobConfiguration) {
      this.jobConfiguration = jobConfiguration;
    }
    
    public void resolvePathes() {
      ...
      String [] inputPathes = jobConfiguration.resolveInputPath();
      ...
    }


    <리스트 5> ENABLING POINT가 존재하도록 리팩토링

     

    <리스트 5>를 테스트하기 위해서는 JobConfiguration 인터페이스를 상속받는 새로운 TEST STUB을 만들고 이를 setJobConfiguration에 전달하면 된다. ENABLING POINT를 통해 코드 상에 OBJECT SEAM을 추가함으로써 단위 테스트 가능한 코드를 얻게 되었다. 그리고 OBJECT SEAM이 존재하는 코드는 일반적으로 유연하고 응집도가 높은 설계를 가지게 된다.

    SEAM과 유연한 설계, 그리고 단위 테스트

    앞의 예제를 통해 OBJECT SEAM을 사용할 경우 코드가 유연해 진다는 사실을 알게 되었다. OBJECT SEAM은 런 타임 시에 객체를 변경할 수 있도록 하기 때문에 객체 간에 낮은 결합도를 유지할 수 있도록 해 준다. 단위 테스트는 실제로 OBJECT SEAM이 필요한 위치에 대한 정보를 제공한다.

     

    단위 테스트와 동시에 코드를 작성할 경우 코드가 유연해지고 설계가 향상된다. 따라서 테스트 주도 개발(Test-Driven Development, TDD) 방식으로 개발된 코드가 그렇지 않은 코드보다 더 훌륭한 방식으로 설계될 확률이 높다. 의존성 끊기 게임의 목적은 단위 테스트를 가능하게 함으로써 설계를 향상시키는 것이다.

    TDD는 테스트에 관한 것이 아니다. 프로그래밍과 설계에관한 것이다. TDD는 더 단순하고, 더 깔끔하고, 더 견고한 코드를 작성하는 일과 관련된 것이다!(물론, 부수 효과로 작성된 단위 테스트 케이스는 매우 중요하다.)

    Jimmy Nilsson, Applying Domain-Driven Design and Patterns
     

    코드를 단순히 평면적인 텍스트의 집합으로 보지 않고 그 안에서 적절한 SEAM을 식별하는 방법을 익히는 것이 코드의 테스트 용이성을 향상시키는 최선의 방법이다. 그리고 테스트 용이성을 목표로 할 때 훌륭한 설계를 얻을 수 있다. 코드를 작성하기 전에 테스트를 작성하는 TDD 방식은 다양한 방식으로 OBJECT SEAM을 강요하기 때문에 자연스럽게 훌륭한 설계에 도달할 수 있도록 한다.

    대부분의 경우 테스트 가능한 애플리케이션을 목표로 할 경우 훌륭한 애플리케이션 코드를 작성하게 된다.

    Rod Johnson, Expert One-On-One J2EE without EJB

     

    앞서 설명한 것처럼 테스트 가능한 애플리케이션을 작성하는 방법은 테스트와 코드를 함께 작성하는 것이다. 그러나 테스트와 함께 코드를 작성하더라도 훌륭한 설계에 대한 감각을 기르지 않는다면 최적의 설계에 이를 수 없다. 테스트 용이성을 향상시킬 수 있는 다음과 같은 지침이 설계를 향상시키기 위해서도 도움이 될 것이라 생각한다.

    • 구현이 아닌 인터페이스에 따라 프로그래밍하라
      인터페이스에 대해 프로그래밍을 하면 할수록, 실행 시간이나 테스트 시점에 서로 다른 구현체를 좀 더 자유롭게 플러그 인할 수 있다. 이것은 구현 세부 사항을 변경할 수 있는 OBJECT SEAM을 손쉽게 추가할 수 있는 아키텍처적인 유연성을 제공한다. 인터페이스에 따라 프로그래밍함으로써 테스트에 적합한 TEST STUB이나 MOCK OBJECT를 구현할 수 있으며 인프라스트럭처에 대한 의존성을 보다 쉽게 제어할 수 있다.
    • 상속보다는 합성을 사용하라
      객체 지향 프로그래밍 환경에서 기능을 재사용하는 방법은 상속(Inheritance)과 합성(Composition)의 두 가지 방식이 존재한다. 상속은 클래스와 클래스 간의 정적인 관계를 통해 기능을 재사용하며 두 클래스 간의 관계가 컴파일 시점에 고정된다. 합성은 객체와 객체 간의 동적인 관계를 통해 기능을 재사용하며 두 객체 간의 관계가 고정적이지 않고 실행 시간에 변경 가능하다.
      일반적으로 상속은 캡슐화를 저해한다. 부모 클래스를 수정할 경우 모든 서브 클래스가 영향을 받게 된다. 따라서 변경에 대한 파급효과를 줄이기 위해서는 상속보다는 합성을 사용하는 것이 유리하다.
      단위 테스트 관점에서 상속보다 합성이 선호되는 이유는 무엇인가? 상속 계층을 다룰 경우 테스트 하니스 상에서 객체를 생성하기가 어려워질 수 있기 때문이다. 부모 클래스가 생성자의 파라미터로 거대한 객체 그래프를 취하는 경우를 생각해 보자. 서브 클래스의 일부 기능을 테스트하기 위해 전체 객체 그래프가 필요하지 않음에도 불구하고 테스트를 위해서는 해당 객체 그래프를 부모 클래스의 생성자로 전달해야 한다. 또한 부모 클래스가 인프라스트럭처와 강하게 결합되어 있을 경우에도 하위 클래스를 단위 테스트하는 것이 어렵다.
      상속의 경우 컴파일 타임에 관계가 고정된다는 사실을 기억하자. 따라서 상속을 사용하기 위해서는 실행 시점이 아닌 컴파일 시점에 ENABLING POINT를 제공해야 한다는 제약이 따른다. 따라서 OBJECT SEAM을 추가하고자 한다면 가능하면 상속 보다는 합성을 선택하고 객체와 객체를 조합하는 위치에 ENABLING POINT를 제공하도록 하자. 이를 위한 가장 훌륭한 방법은 STRATEGY 패턴을 이용하는 것이다.
    • static 메소드와 SINGLETON의 사용을 피하라
      static 메소드와 SINGLETON 패턴은 클라이언트 코드를 구체적인 클래스와 강하게 결합시킨다. static 메소드와 SINGLETON이 단위 테스트 하니스 상에서 인스턴스를 생성하거나 실행시키기 어려운 대상에 의존할 경우 인스턴스를 변경할 수 있는 static setter 를 이용하거나 최악의 경우 바이트 코드를 갱신시키는 기법을 사용하지 않는 한 이를 대체하기가 거의 불가능하다.
      물론 static 메소드와 SINGLETON을 사용하는 것이 무조건 잘못 된 것은 아니다. 클래스 계층 구조에 섞여 있는 유틸리티성 메소드를 외부로 빼내거나 Thread Local을 사용하기 위해서는 static 메소드가 유용하다. 하나의 인스턴스가 필요한 경우에는 SINGLETON을 사용하는 것이 유용하다. 그러나 전역 접근이나 DEPENDENCY LOOKUP을 위해 SINGLETON을 사용하는 것은 시스템 전체적인 결합도를 높이는 것이다. 가능하면 구체적인 클래스에 강하게 얽매이는 코드를 작성하지 마라. 잘못 사용된 static 메소드와 SINGLETON은 SEAM에 대한 안티 패턴이다.
      결론적으로 static 메소드와 SINGLETON의 경우 SEAM을 식별하는 것이 어렵기 때문에 이를 인스턴스 메소드와 인터페이스 기반의 클래스로 변경하는 것이 좋다. 
    • 의존성을 고립시켜라
      이전 컬럼에서는 예제로 사용한 통계 애플리케이션을 테스트 가능하도록 만들기 위해 Apache Hadoop에 관련된 코드를 다른 코드로부터 분리시켰다. 이렇게 함으로써 Apache Hadoop에 의존하지 않는 코드에 대해 단위 테스트를 실행할 수 있었다.
      일반적으로 테스트하기 어려운 프레임워크, 표준 라이브러리, 인프라스트럭처에 관련된 코드를 개별적인 클래스로 고립시키는 것이 좋다. 이것은 높은 응집성과 낮은 결합도를 달성할 수 있는 가장 간단한 방법인 동시에 기본적인 캡슐화 원칙을 지키는 설계 방법이다. 별도로 고립시킨 클래스에 대한 인터페이스를 제공함으로써 이를 사용하는 클라이언트에 대해 SEAM을 제공할 수 있다. SEAM을 지원하기 위해 ENABLING POINT를 작성하는 가장 간단한 방법은 DEPENDENCY INJECTION을 사용하는 것이다.
    • 의존성을 요청하지 말고 주입하라
      static 메소드와 SINGLETON을 통해 알아본 것처럼 의존성이 하드코딩되어 있으면 SEAM을 제공하기 어렵다. 구체적인 클래스가 아닌 인터페이스에 대해 프로그래밍하고 구체적인 클래스는 이를 사용하는 클래스가 아닌 외부에서 설정하도록 하라. DEPENDENCY LOOKUP의 경우에는 사용하려는 객체에 대한 의존성은 제거하지만 객체 탐색 메커니즘에 대한 의존성은 여전히 존재한다. DEPENDENCY INJECTION이 OBJECT SEAM에 대한 ENABLING POINT를 제공하기 위한 가장 간단하면서도 최적의 방법이다.
      현재 Spring을 위시로 한 많은 프레임워크들이 DEPENDENCY INJECTION을 제공하고 있으므로 이를 적극 활용하도록 한다. DEPENDENCY INJECTION을 사용할 경우 자연스럽게 STRATEGY 패턴, 인터페이스에 대한 프로그래밍, 상속보다는 합성의 원칙을 준수하게 된다.

    <그림 9>는 이전 연재에서 의존성 끊기 게임의 결과로 얻게 된 코드의 구조를 나타낸 것이다. 구체적인 클래스 대신 JobConfiguration 인터페이스를 사용하고, LogAnalyzeJob과 JobConfiguration을 합성 관계로 구성하며, Apache Hadoop에 대한 의존성은 모두 LogAnalyzeJob 내부로 고립시키고, LogAnalyzeJob에서 JobConfiguration으로의 의존성은 DEPENDENCY INJECTION에 의해 외부에서 주입된다. 리팩토링된 코드는 단위 테스트 가능하며, 확장 가능한 동시에 유연한 설계를 가지게 되었다.

    <그림 9> 테스트 가능한 코드를 목표로 할 경우 설계가 개선된다.

    의존성 끊기와 단위 테스트

    시스템은 상호 관계를 맺고 있는 수 많은 객체로 구성되어 있다. 단위 테스트의 목적은 이렇게 얽히고 설킨 객체를 고립시켜 예상한 대로 동작하는 지를 테스트하는 것이다. 단위 테스트를 실행하기 위해서는 적절한 위치에서 의존성을 끊어야 한다.

    평면적인 텍스트의 목록이 아닌 SEAM의 관점에서 프로그램 코드를 살펴 보는 것은 효과적으로 단위 테스트를 수행할 수 있는 코드가 무엇인지에 관한 통찰을 제공한다. SEAM을 고려해서 작성된 코드는 높은 응집도와 낮은 결합도를 가지고 있으며 적절한 책임을 지닌 작은 객체들로 구성된 캡슐화가 잘 된 코드다. 

    SEAM을 고려한 코드는 단위 테스트가 가능한 코드이며, 단위 테스트가 가능한 코드는 훌륭하게 설계된 코드다. 훌륭한 코드를 작성하는 개발자로 성장하고 싶은가? 그렇다면 테스트 가능한 코드를 작성하기 위해 노력하라. 테스트 가능한 코드가 곧 훌륭한 코드일 가능성이 높다.

     

    728x90
Designed by Tistory.