ABOUT ME

-

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

    단위 테스트 딜레마

    XP를 위시로 한 애자일 진영이 소프트웨어 커뮤니티에 미친 가장 큰 영향은 소프트웨어의 품질을 좌우하는 핵심적인 설계 기법으로서 단위 테스트(Unit Test)의 지위가 격상되었다는 점이다. 단위 테스트는 개발자 관점에서 코드의 안전성과 정확성을 보장할 수 있는 최고의 실행지침이다. 실패하는 단위 테스트 없이 코드를 작성하지 말라는 테스트 주도 개발(Test-Driven Development)의 기본 원칙은 리팩토링(Refactoring), 지속적인 통합(Continuous Integration)과 같은 피드백 기반의 실행지침과 결합됨으로써 안정적이고 신뢰도 높은 소프트웨어 개발을 가능하게 한다.
    단위 테스트(Unit Test)란 무엇인가

    소프트웨어 개발 커뮤니티에서 사용되는 대부분의 용어와 마찬가지로 단위 테스트 역시 다양한 문맥에서 다양한 의미로 사용되고 있다. 개인적으로 가장 선호하는 단위 테스트의 정의는 Michael Feathers의 것이다. Michael Feathers의 정의에 따라 테스트가 아래에 나열한 조건을 하나라도 만족한다면 그 테스트는 단위 테스트가 아니다.

    • 데이터베이스에 접근한다.
    • 네트워크를 통해 통신한다.
    • 파일 시스템을 이용한다.
    • 테스트 실행을 위해 환경에 대해 (설정 파일 수정과 같은) 특별한 작업을 해야 한다.
    이 정의가 절대적인 것은 아니다. 실용적인 관점에서 DAO나 REPOSITORY처럼 데이터베이스에 접속해서 응집도 높은 기능을 수행하는 단일 모듈을 테스트하는 경우에도 단위 테스트의 범주에 포함시키기도 한다.
    테스트의 지위가 격상됨에 따라 ‘레거시 코드(Legacy Code)’의 의미 역시 테스트라는 관점에 알맞도록 조정되었다. 전통적인 관점에서 레거시 코드란 이해하기 어렵고 유지보수가 까다로운 과거의 코드 베이스를 의미한다. 그러나 리팩토링을 보조하기 위한 단위 테스트의 중요성이 강조됨에 따라 레거시 코드라는 용어는 ‘테스트가 존재하지 않는 코드’를 의미하는 것으로 확장되었다. 이 정의에 따르면 방금 작성된 따끈따끈한 코드라고 하더라도 단위 테스트가 존재하지 않는다면 그 코드는 레거시 코드라고 볼 수 있다.
    업계에서 레거시 코드라는 용어는 우리가 이해할 수 없고 변경하기 까다로운 코드를 가리키는 속어로 사용된다. 그러나 몇 해 동안 코드 상의 심각한 문제를 해결하는 지원 업무를 담당하면서 개인적으로 이와는 다른 정의를 사용하게 되었다. 내게 있어, 레거시 코드는 단순히 테스트가 존재하지 않는 코드다. ... 테스트가 존재하지 않는 코드는 형편 없는 코드다. 코드가 얼마나 잘 작성되었는지는 중요하지 않다. 또한 코드가 얼마나 깔끔한가, 객체 지향적인가, 잘 캡슐화 되어 있는 지 역시 중요하지 않다. 테스트가 존재한다면 코드를 빠르게 수정할 수 있고 문제가 발생했는지 여부를 확인할 수 있다. 테스트가 없다면 코드의 품질이 향상되었는지 아니면 저하되었는지를 알 수 있는 방법이 존재하지 않는다.

    Michael Feathers, Working Effectively with Legacy Code

     

    그러나 단위 테스트의 장점에도 불구하고 테스트를 아무렇게나 작성할 경우 프로젝트의 진행을 방해하고 소프트웨어의 품질을 저하시키는 걸림돌로 전락할 수 있다. 이해하기 어렵고 복잡한 테스트 케이스는 설계를 개선하려는 개발자의 의지를 꺾는다. 어디서, 어떤 이유로 실패하는지 원인조차 파악하기 어려운 테스트 케이스를 다루어야 하는 개발자는 마치 살얼음판을 걷는 기분으로 코드를 수정하고 기능을 추가할 수 밖에 없다. 실패하는 테스트 케이스를 고치지 못 해 무시하거나 아예 삭제해버리는 경우 운영 환경에서의 장애로 이어질 수도 있다

    올바르게 작성된 테스트 케이스는 신선하고 상쾌한 공기를 제공하는 공기청정기와 같다. 깨끗한 공기가 사람들의 몸과 마음을 맑고 활기차게 만드는 것처럼 잘 작성된 단위 테스트는 코드를 깔끔하고 유연하게 만든다.
    테스트 케이스를 방치하지 마라. 테스트 커버리지를 높일 목적으로 너저분한 테스트 케이스를 덕지덕지 바르지 마라. 클린 코드(clean code)를 작성하고 싶다면 먼저 클린 테스트 코드(clean test code)에 집중해야 한다.

    테스트를 개발하는 히치하이커를 위한 안내서

    슬라티바트패스트는 마그라테아 행성에 거주하고 있는 초공간 엔지니어다. 이 엔지니어는 이름 따위는 중요하지 않다는 신념을 가지고 있으며 삶, 우주, 그리고 모든 것에 관한 해답을 구하기 위해 개발된 지구라는 이름을 가진 컴퓨터의 개발에 참여했던 독특한 이력을 지니고 있다. 불행하게도 지구는 삶, 우주, 그리고 모든 것에 대한 해답을 내놓기 바로 직전에 항성계를 관통하는 초공간 고속도로를 건설 중인 보고인들에 의해 순식간에 파괴되고 말았다.
    원래 슬라티바트패스트는 은하계에 불어 닥친 경제 공황으로 행성 개발 의뢰가 줄어들자 경제가 다시 활성화될 때까지 기다릴 요량으로 수만 년 동안 동면 상태에 들어가 있었던 중이었다. 불행하게도 동면에 들어가 있던 동안 지구가 산산조각 나버렸고(모든 것이 보고인때문이다) 이로 인해 화가 난 생쥐들에 의해 강제로 동면에서 깨어나 새로운 지구를 개발해야 하는 골치 아픈 상태에 놓이고 말았다. 우리가 할 일은 불쌍한 이 엔지니어를 도와 새롭게 건설된 두 번째 지구가 생쥐들의 요구사항을 만족하는 지를 검증하는 소프트웨어를 개발하는 것이다.
     
    슬라티바트패스트에 따르면 지구는 대기, 대륙, 대양의 3가지 핵심 요소로 이루어져 있으며 생쥐들은 다음의 4가지 요구사항을 반드시 만족시켜줄 것을 요구하고 있다고 한다.
    • 대기는 원래의 지구와 유사하게 80%의 질소와 20%의 산소로 구성되어야 한다.
    • 새로운 지구는 원래의 지구와 동일한 개수의 대륙과 대양을 포함해야 한다.
    • 대기, 대륙, 대양 각각에는 제조가격이 책정되며 행성의 가격은 이 3가지 요소의 총합과 같다.
    • 예산을 초과해서는 안 된다.

    <그림 1> 새로운 지구를 건조 중인 마그라테아 행성 중심부

    지구 검증 소프트웨어 도메인

    우선 행성 검증이라는 도메인을 구성하는 대기, 대륙, 대양, 행성이라는 4가지 핵심적인 도메인 개념을 코드로 표현해 보기로 하자.

    대기(Atmosphere)

    대기(Atmosphere)는 산소와 질소의 두 가지 원소로 구성되어 있으며 두 원소는 8:2의 비율로 혼합되어야 한다. 따라서 대기를 만들기 위해서는 대기를 구성하는 원소(Element)를 구현해야 한다. 원소는 이름(name)과 비율(ratio)의 두 가지 속성을 가져야 한다.

    public class Element {
        private String name;
        private Ratio ratio;
    
        public static Element element(String name, Ratio ratio) {
            return new Element(name, ratio);
        }
    
        private Element(String name, Ratio ratio) {
            this.name = name;
            this.ratio = ratio;
        }
    
        public Ratio getRatio() {
            return ratio;
        }
    }

     

     

    대기는 구성 원소의 목록(elements)과 가격 정보(price)를 포함한다. 대기는 자신을 구성하는 원소의 개수가 2개 이상이어야 하고 모든 원소의 비율을 합한 값이 반드시 1이 되어야 한다는 제약 조건을 포함한다(즉, 질소 0.8, 산소 0.2와 같이 모든 비율의 합은 1이어야 한다). 이 제약 조건은 대기의 불변식(invariant)의 일부이므로 생성시에 제약 조건을 만족하는 지 여부를 확인해야 한다.

    public class Atmosphere {
        private Money price;
        private List<Element> elements = new ArrayList<Element>();
    
        public Money getPrice() {
            return price;
        }    
    
        public Atmosphere(Money price, Element ... elements) {
            this.price = price;
            this.elements = Arrays.asList(elements);
            assertElements();
        }
    
        private void assertElements() {
            checkElementSize();       
            checkElementRatio();
        }
    
        private void checkElementSize() {
            if (elements.size() < 2) {
                 throw new IllegalArgumentException("대기는 적어도 2개 이상의 원소로 구성되어야 합니다.");
            }
        }
    
        private void checkElementRatio() {
            if (!accumulateRatio().isOne()) {
                throw new IllegalArgumentException("전체 원소의 비율 총합은 1이어야 합니다.");
            }
        }
    
        private Ratio accumulateRatio() {
            Ratio result = Ratio.of(0);
            for(Element element : elements) {
                result = result.plus(element.getRatio());
            }
            return result;
        }
    }

     

     

    대륙(Continent)
    지구의 육지는 하나 이상의 대륙(Continent)으로 구성되어 있으며 대륙에는 국가(Nation)가 존재할 수 있다. 국가는 이름(name)과 제조 가격(price)을 속성으로 가지는 간단한 클래스로 추상화할 수 있다.

    public class Nation  {
        private String name;
        private Money price;
    
        public Money getPirce() {
            return price;
        }
    
        public Nation(String name, Money price) {
            this.name = name;
            this.price = price;
        }
    }

     

     

    대륙은 이름(name)과 국가의 목록(nations)을 속성으로 포함한다. 남극 대륙과 같이 국가가 존재하지 않는 대륙도 존재할 수 있으므로 nations 는 크기가 0인 빈 컬렉션일 수도 있다. 대륙의 제조 가격은 대륙 안에 존재하는 국가(Nation) 전체의 제조 가격을 더한 금액과 동일하다.

    public class Continent {
        private String name;
        private List<Nation> nations = new ArrayList<Nation>();
    
        public Money getPirce() {
            Money result = Money.wons(0);
            for(Nation each : nations) {
                result = result.plus(each.getPirce());
            }
            return result;
        }
    
        public Continent(String name) {
            this.name = name;
        }
       
        public Continent(String name, Nation ... nations) {
            this.name = name;
            this.nations = Arrays.asList(nations);
        }
    }

     

    대양(Ocean)
    지구의 2/3를 차지하고 있는 바다는 대양(Ocean)들의 집합으로 추상화할 수 있다. 대양은 이름(name)과 제조 가격(price)을 속성으로 가지는 단순한 객체다.

    public class Ocean {
        private String name;
        private Money price;
    
        public Money getPirce() {
            return price;
        }
    
        public Ocean(String name, Money price) {
            this.name = name;
            this.price = price;
        }
    }

     

    행성(Planet)
    행성(Planet)은 개발 중인 소프트웨어의 가장 핵심적인 도메인 개념이다. 생쥐들의 요구사항에 부합하는 지 여부를 검증할 지구는 행성의 인스턴스로 표현된다. 행성은 대기(atmosphere), 대륙의 집합(continents), 대양의 집합(oceans)으로 구성된다. 3가지 속성은 반드시 존재해야 하는 필수 속성이며 행성 대륙 대양을 각각 최소 1개 이상 포함해야 한다.

    public class Planet {
        private Atmosphere atmosphere;
        private List<Continent> continents;
        private List<Ocean> oceans;
       
        public Planet(Atmosphere atmosphere, List<Continent> continents, List<Ocean> oceans) {
            this.atmosphere = atmosphere;
            this.continents = continents;
            this.oceans = oceans;
            assertValid();
        }
    
        private void assertValid() {
            if (continents.size() < 1) {
                throw new IllegalArgumentException("대륙은 1개 이상 존재해야 합니다.");       
            }
           
            if (oceans.size() < 1) {
                throw new IllegalArgumentException("대양은 1개 이상 존재해야 합니다.");   
            }
        }
    
        public Collection<Continent> getContinents() {
            return Collections.unmodifiableCollection(continents);
        }
    
        public Collection<Ocean> getOceans() {
            return Collections.unmodifiableCollection(oceans);
        }
    }

     

    행성의 제조 가격은 대기, 대륙, 대양의 제조 가격을 모두 더한 값이다. 따라서 Planet의 getPrice() 메서드는 다음과 같이 구현할 수 있다.

    public class Planet {
        public Money getPrice() {
            return atmosphere.getPrice().plus(getContinentsPrice()).plus(getOceansPrice());
        }
    
        private Money getOceansPrice() {
            Money result = Money.wons(0);
            for(Continent each : continents) {
                result = result.plus(each.getPirce());
            }
            return result;
        }
    
        private Money getContinentsPrice() {
            Money result = Money.wons(0);
            for(Ocean each : oceans) {
                result = result.plus(each.getPirce());
            }
            return result;
        }
    }

     

    <그림 2>는 지금까지 구현한 지구 검증 소프트웨어의 최종적인 도메인 레이어 클래스들을 다이어그램으로 표현한 것이다.

     

    <그림 2> 지구 검증 소프트웨어의 도메인 모델
     

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

    728x90
Designed by Tistory.