Unit Testing 6장 : 단위 테스트 스타일

    들어가기 전

    이 글은 단위 테스트 6장, 단위 테스트 스타일을 공부하며 작성한 글입니다. 


    6. 단위 테스트 스타일

    이번 장에서는 단위 테스트에서 주로 볼 수 있는 테스트 스타일을 살펴보고, 각각이 어떤 특성을 가지고 있는지, 어떤 특성을 선택하면 좋을지를 공부해보고자 한다.


    6.1. 단위 테스트의 세 가지 스타일

    단위 테스트는 세 가지 스타일이 있다.

    • 출력 기반 테스트
    • 상태 기반 테스트
    • 통신 기반 테스트 

    하나의 단위 테스트에서 하나만 사용할 수도 있고, 세 가지를 동시에 사용할 수도 있다. 


    6.1.1 출력 기반 테스트 정의

    • 출력 기반 테스트는 테스트 대상 시스템(SUT)에 입력을 넣고 생성되는 출력을 점검하는 방식이다. 전역 상태나 내부 상태를 변경하지 않는 코드에만 적용되므로 반환값만 검증하면 된다. 예를 들면 아래 같은 타입이다.
    • 이런 타입의 테스트는 사이드 이펙트(상태 변화)가 없고, 출력값만 변한다. 
    • 출력 기반 단위 테스트 스타일은 함수형이라고도 한다.
      • 사이드 이펙트 없는 코드 선호를 강조하는 프로그래밍 방식인 함수형 프로그래밍에 뿌리를 둠.
    public class NameNormalizer {
        public int normalizeNameLength(String name) {
            return Math.min(name.length(), 50);
        }
    }
    
    
    @Test
    void test() {
        // given
        String name = "hello";
        NameNormalizer nameNormalizer = new NameNormalizer();
    
        // when
        int result = nameNormalizer.normalizeNameLength(name);
    
        // then
        assertThat(result).isEqualTo(name.length());
    }

    6.1.2 상태 기반 스타일 정의

    • 상태 기반의 단위 테스트는 연산이 끝난 후 시스템 상태를 확인하는 것이다. 
    • 시스템 상태는 SUT, 협력자, DB나 파일 시스템 같은 외부 의존성의 상태를 의미한다. 
    • 아래 테스트 코드를 예시로 살펴볼 수 있다. 
    class ShopTest {
    
        @Test
        void test() {
            // given
            Product product = new Product();
            Shop sut = new Shop();
    
            // when
            sut.addNewProduct(product);
    
            // then
            assertThat(sut.getItemList().size()).isEqualTo(1);
        }
    
    }
    
    public class Shop {
    
        private List<Product> itemList = new ArrayList<>();
        
        public void addNewProduct(Product product) {
            itemList.add(product);
        }
    
        public List<Product> getItemList() {
            return itemList;
        }
    }
    
    
    public class Product {
    }

     


    6.1.3. 통신 기반 스타일의 정의 

    • 통신 기반 스타일은 Mock을 이용해서 SUT와 협력자 간의 통신을 검증하는 테스트 스타일을 의미한다. 
    • SUT의 협력자를 Mock으로 대체하고, SUT가 협력자를 정상적으로 호출하는지를 검증하는 테스트 스타일이다. 
    @Test
    void test() {
        // given
        UpstreamServer upstream = Mockito.mock(UpstreamServer.class);
        EmailSender emailSender = new EmailSender(upstream);
    
        // when
        emailSender.sendEmailToUpstreamServer();
        
        // then
        Mockito.verify(upstream, Mockito.times(1)).send();
    }
    
    public class EmailSender {
    
        private UpstreamServer upstreamServer;
    
        public EmailSender(UpstreamServer upstreamServer) {
            this.upstreamServer = upstreamServer;
        }
    
        public void sendEmailToUpstreamServer() {
            upstreamServer.send();
        }
    }
    
    public class UpstreamServer {
    
        public void send() {
            // ...
        }
    
    }

    6.2 단위 테스트 스타일 비교

    단위 테스트 스타일을 비교하기전에 먼저 좋은 단위 테스트에 필요한 4가지 요소를 생각해보자. 그리고 아래 요소를 고려해서 각 단위 테스트 스타일을 비교해보자.

    • 회귀 방지
    • 리팩토링 내성
    • 빠른 피드백
    • 유지 보수성 

    6.2.1 회귀 방지와 피드백 속도 지표로 스타일 비교하기 

    회귀 방지 지표는 아래 세 가지 요소를 통해서 결정된다. 

    • 테스트 중에 실행되는 코드의 양
    • 코드 복잡도
    • 도메인 유의성 

    실행되는 코드의 양이 적든 많든 원하는 대로 테스트를 작성할 수 있다. 코드 복잡도, 도메인 유의성도 마찬가지다. 따라서 어떤 스타일의 '단위 테스트'를 작성하느냐에 영향을 받지 않는다. 

    테스트 스타일과 테스트 피드백 속도 역시 관련이 없다. 테스트가 프로세스 외부 의존성과 떨어져서 테스트 된다면, 모든 테스트 스타일은 거의 동일한 테스트 피드백 속도를 제공할 것이다.


    6.2.2 리팩토링 내성 지표로 스타일 비교하기 

    리팩토링 내성은 리팩토링 시에 발생하는 거짓 양성과 관련된 지표다. 리팩토링 내성은 단위 테스트의 스타일에 따라서 많은 영향을 받는다. 

    • 출력 기반 테스트
      • 테스트가 테스트 대상 메서드에만 결합되기 때문에 리팩토링 내성 관점에서 우수하다.
      • 출력 기반 테스트가 구현 세부 사항과 결합하는 경우는 테스트 대상 메서드가 구현 세부 사항일 경우 뿐이다.
    • 상태 기반 테스트
      • 리팩토링 내성이 상대적으로 약하다. 
      • 테스트 대상 메서드 외에도 현재 인스턴스의 상태와 함께 작동한다. 이것은 확률적으로 구현 세부 사항과 결합할 가능성이 높아진다. 
      • 상태 기반 테스트는 노출 영역이 큰 API에 의존하기 때문에 구현 세부 사항과 결합할 가능성이 더 높다. 
    • 통신 기반 테스트
      • Stub가 상호작용하는 것을 검증하는 테스트 코드라면 가장 깨지기 쉽다. 
      • 외부 의존성 프로세스와의 작용이 바깥에서도 보일 때, Mock을 이용해 테스트하고 검증하는 것이 추천된다. 

    6.2.3 유지 보수성 지표로 스타일 비교하기 

    유지 보수성을 평가하는 지표는 두 가지다.

    • 테스트를 이해하기 얼마나 어려운가?
    • 테스트를 실행하기 얼마나 어려운가? (테스트와 관련된 의존성들)

    테스트가 크고 의존성이 많은 경우 유지보수가 어렵다. 아래에서 단위 테스트 스타일에 따른 유지보수성을 평가해본다. 

    • 출력 기반 테스트의 유지 보수성
      • 가장 유지보수가 용이함. 대부분 짧고 간결하므로 유지보수가 쉽다. 실행 및 검증이 두 줄이면 완료되기 때문이다. 
      • 출력 기반 테스트의 대상이 되는 메서드는 전역 상태, 내부 상태를 바꾸지 않을 것이기 때문에 가장 유지보수가 편리하다. 
    • 상태 기반 테스트의 유지 보수성
      • 출력 기반 테스트 대비 유지보수 비용이 많이 든다. 상태 검증은 출력 검증 테스트보다 많은 검증이 필요하기 때문이다. 
      • 많은 상태를 확인해야하는 검증 코드가 길어지기 때문에 유지보수 비용이 증가한다.
      • 개선방법은 다음과 같다. 
        1. 코드를 숨기고 테스트를 단축하는 헬퍼 메서드를 이용할 수 있다. → 이것 역시 비용이다. 
        2. 검증 대상 클래스의 동등 멤버를 정의할 수 있다. → 클래스가 값에 해당하고 값 객체로 변환할 수 있을 때만 효과적.
    • 통신 기반 테스트의 유지 보수성
      • 테스트 대역(Mock)과 상호 작용 검증을 설정해야하며, 이는 작성해야 할 코드량이 많다는 것을 의미한다.
      • Mock이 사슬 형태로 있는 경우 유지보수성은 더욱 악화된다. 

    6.2.4 스타일 비교하기 : 결론

    단위 테스트 스타일에 따른 특성을 다음 표에 정리했다. 먼저 표를 살펴보자.

      출력 기반 상태 기반 통신 기반
    리팩터링 내성에 필요한 노력 낮음 중간 중간
    유지 보수 비용 낮음 중간 높음

    출력 기반 테스트는 특정 메서드 하고만 결합하기 때문에 구현 세부 사항과 결합할 확률도 적어서 리팩토링 내성도 좋고, 외부 프로세스 의존성도 적기 때문에 코드를 작성하는데도 편리하다. 따라서 가급적이면 출력 기반 테스트 코드를 작성하도록 선호하도록 한다.

    하지만 출력 기반 테스트는 함수형 아키텍쳐로 만들어진 코드에만 적용이 가능하고, 대부분의 객체지향 언어에는 해당되지 않는다는 문제점이 있다. 


    6.3 함수형 아키텍쳐 이해

    객체 지향 프로그래밍에서는 대부분의 테스트 코드가 상태 기반 / 통신 기반의 테스트가 된다. 출력 기반 테스트 스타일을 사용하기 위해서 객체 지향 프로그래밍을 모두 함수형 아키텍쳐로 바꾸는 것이 도움이 된다. 이 절에서는 함수형 프로그래밍으로 바꾸는 방법론에 대해서 이야기 한다. 


    6.3.1 함수형 프로그래밍이란?

    함수형 프로그래밍은 순수 함수(수학적 함수)를 사용한 프로그래밍이다. 순수 함수는 숨은 입출력이 없는 함수다. 수학적 함수는 호출 횟수에 상관없이 주어진 입력에 대해 항상 동일한 출력을 생성한다. 숨은 입출력이 있으면 테스트 코드를 작성하는데 어려움을 야기시키는데, 아래에서 숨은 입출력과 순수 함수에 대해서 살펴보고자 한다. 

     

    순수함수

    순수 함수는 수학에서 말하는 함수와 동일하다. 다른 숨은 입출력이 하나도 존재하지 않고, 오로지 입력에 대해서 출력을 생성하는 함수를 이야기한다. 따라서 이런 함수는 동일한 입력값에 대해서 언제 실행해도 동일한 출력값을 나타낸다. 아래 코드는 순수 함수의 예시다. 

    public int normalizeNameLength(String name) {
        return Math.min(name.length(), 50);
    }

     

    숨은 입출력

    함수 내부에 숨은 입출력이 있다면, 이 함수를 테스트 하는 코드를 작성하기가 어려워진다. 숨은 입출력은 어떤 것들이 있을까? 

    • 사이드 이펙트 : 메서드 선언부에 표시되지 않은 출력이다. 예를 들어 클래스 인스턴스의 상태를 변경하고, 디스크의 파일을 업데이트함.
    • 예외 : 메서드가 예외를 던지면, 메서드 선언과는 전혀 반대의 프로그램 흐름을 제공한다. 즉, 새로운 경로를 만든다.
    • 내외부 상태에 대한 참조 : DateTime.Now()와 같이 시스템 상태를 참조하는 경우 같은 것들.

    예를 들면 아래 함수는 순수 함수인 것처럼 보이지만 순수 함수가 아니다. 왜냐하면 사이드 이펙트(다른 인스턴스의 상태를 수정)하기 때문이다. itemCount라는 값에 호출될 때 마다 1을 더한다. 따라서 이 함수는 호출자 입장에서는 알 수 없는 사이드 이펙트를 생성한다.

    public boolean isExistedInShop(Product product) {
        itemCount++; 
        return itemList.contains(product);
    }

     

    6.3.2 함수형 아키텍쳐란? 

    사이드 이펙트를 사용하지 않은 어플리케이션을 생성하는 것은 사실상 불가능하다. 왜냐하면 장바구니 추가 같은 모든 것들이 사이드 이펙트(인스턴스의 상태를 변경하는) 형태로 만들어지기 때문이다. 함수형 아키텍쳐의 목표는 사이드 이펙트를 사용하지 않는 것이 아니라 사이드 비즈니스 로직과 사이드 이펙트를 분리하는 것이 목표다. 분리하는 방법은 비즈니스 로직을 수행한 뒤, 사이드 이펙트가 실행되도록 하는 것이다. 아래와 같이 분리한다! 

    • 결정을 내리는 코드 : 사이드 이펙트가 필요없기 때문에 수학적 함수를 사용해 작성할 수 있다. 
    • 결정에 따라 작용하는 코드 : 수학적 함수에 의해 만들어진 결정을 DB 변경, 메세지 버스로 메세지 전송, 인스턴스 상태 변경 등의 가시적인 부분으로 변환한다. 

    함수형 아키텍쳐에서는 이 부분을 이렇게 나눈다고 한다. 

    함수형 아키텍쳐는 두 가지로 구성된다.

    • 함수형 코어 : 비즈니스 로직을 수행. 결정을 생성
    • 가변 쉘 : 함수형 코어에 전달할 입력 준비. 결정에 따라 동작. 

    그리고 함수형 코어와 가변 쉘은 다음과 같이 협력해서 목표를 이뤄낸다.

    • 가변 쉘은 함수형 코어에 필요한 충분한 입력을 모은다
    • 함수형 코어는 입력을 받아 결정만 생성한다.
    • 가변 쉘은 다시 결정을 받아서, 필요한 사이드 이펙트를 작업한다. 

    함수형 아키텍쳐를 잘 구현해둔다면, 비즈니스 로직이 모여있는 메서드(함수형 코어)를 출력 기반 테스트로 간략히 테스트를 할 수 있게 되는 것이다. 

     

    6.3.3 함수형 아키텍쳐와 육각형(헥사고날) 아키텍쳐의 비교

    함수형 아키텍쳐와 육각형 아키텍쳐는 관심사 분리라는 아이디어를 기반으로 한다는 점, 의존성 간의 단방향 흐름을 가진다는 점이 유사하다.

    • 함수형 아키텍쳐에서는 결정을 내리는 부분 / 사이드 이펙트를 실행하는 부분으로 나누어진다. 육각형 아키텍쳐에서는 도메인 영역(비즈니스 로직)과 어플리케이션 서비스 영역(외부 어플리케이션과의 통신)을 담당하는 영역으로 나누어진다. 
    • 가변 쉘만 함수형 코어에 의존하고, 함수형 코어는 가변 쉘에 의존하지 않는다. 어플리케이션 서비스 영역만 도메인 영역에 의존하고, 도메인 영역은 어플리케이션 서비스 영역에 의존하지 않는다. 

    둘의 차이점은 사이드 이펙트를 처리하는 방식에서 달라진다.

    • 함수형 아키텍쳐는 모든 사이드 이펙트를 함수형 코어에서 가변 쉘로 밀어낸다. 
    • 헥사고날 아키텍쳐는 도메인 계층 내에서의 사이드 이펙트를 허용한다. 또한 도메인 계층 내의 사이드 이펙트가 외부와 통신이 필요한 경우라면 어플리케이션 계층을 통한다. 즉, 사이드 이펙트는 분리되지 않는다. 

    6.4 함수형 아키텍처와 출력 기반 테스트로의 전환

    이 절에서는 샘플 코드를 함수형 아키텍처로 리팩토링 하는 것을 보여준다. 두 단계에 걸쳐서 리팩토링이 실행된다.

    • 프로세스 외부 의존성에서 Mock으로 변경
    • Mock에서 함수형 아키텍처로 변경 

     

     

    6.4.1 전체 코드와 요구사항

    • 이 시스템은 가장 최근 파일의 마지막 줄에 방문자 이름과 방문 시간을 추가한다. 추가하는 형태는 방문자 이름 + ";" + 방문 시간이 된다. 
    • 파일이 가질 수 있는 한계 라인에 도달하면, 인덱스를 증가시키고 새로운 파일을 작성한다. 
    public class AuditManager {
    
        private readonly int _maxEntriesPerFile;
        private readonly string _directoryName;
    
        public AuditManager(int maxEntriesPerFile, string directoryName){
            _maxEntriesPerFile = maxEntriesPerFile;
            _directoryName = directoryName;
        }
    
        public void addRecord(string visitorName, DateTime timeOfVisit) {
            string[] filePaths = Directory.GetFiles(_directoryName);
            (int index, string path)[]sorted = SortByIndex(filePaths);
    
            string newRecord = visitorName + ";" + timeOfVisit;
    
            if (sorted.length == 0) {
                string newFile = Path.Combine(_directoryName, "audit_1.txt");
                File.WriteAllTexts(newFile, newRecord);
                return;
            }
    
            (int currentFileIndex, string currentFilePath) =sorted.Last();
            List<string> lines = File.ReadAllLines(currentFilePath).ToList();
    
            if (lines.Count < _maxEntriesPerFile) {
                lines.add(newRecord);
                string newContent = string.Join("\r\n", lines);
                File.WriteAllText(currentFilePath, newContent);
            } else {
                int newIndex = currentFileIndex + 1;
                string newName = $ "audit_{newIndex}.txt";
                string newFile = Path.Combine(_directoryName, newName);
                File.WriteAllText(newFile, newRecord);
            }
        }
    }

    AuditManager 테스트 코드 관점으로 바라봤을 때 다음과 같은 문제점이 있다.

    • AuditManager는 파일 시스템과 강하게 결합해있다. 메서드는 파일 시스템의 특정 파일을 참조한다. 특정 파일은 단위 테스트 간의 공유 의존성이 될 수 있다. 
    • 특정 파일은 파일 시스템이다. 즉, 어플리케이션의 외부 시스템을 의미한다. 이것은 단위 테스트가 아니라 통합 테스트 범주에 속한다. 
    • 그림으로 살펴보면 다음과 같다.
    • 테스트를 하기 위해 파일 시스템 / 감사 시스템을 생성해야 함. 
    • 파일 시스템은 감사 시스템에 입력을 넣어줘야 함. 
    • 감사 시스템은 출력 파일 시스템으로 보내주고, 테스트는 이것을 검증해야함. 

    따라서 AuditManager와 파일 시스템은 분리해줘야한다. 

     

    6.4.2 테스트를 파일 시스템에서 분리하기 위한 Mock 사용

    테스트가 외부 시스템과 밀접하게 결합된 문제는 파일 시스템을 Mock으로 처리해서 해결할 수 있다. 파일과 관련된 모든 연산을 별도의 클래스(IFileSystem)으로 추출하고, AuditManager에 생성자로 해당 클래스를 주입해서 처리할 수 있다. 그리고 연산의 결과로 파일 시스템에 데이터를 작성하는데, 메서드가 호출하는지 verify 하는 형식이 된다. 그렇게 작성할 경우, 테스트는 다음과 같이 실행해 볼 수 있게 된다. 

    아래에 각 코드를 작성했다. 

    IFileSystem 인터페이스 추가

    public interface IFileSystem {
        String[] getFiles(String directoryName);
        void WriteAllText(String filePath, String content);
        List<String> ReadAllLines(String filePath);
    }
    • 먼저 FileSystem과 커뮤니케이션하는 부분을 클래스로 만들었다.
    • 이 클래스는 FileSystem과 커뮤니케이션 하는 부분을 추상화했다.
    • 이 클래스를 도입하면서, AuditManager가 테스트를 하는 시점에 Mock 객체를 넣어서 테스트 격리하는데 도움을 줄 수 있다. 

     

    AuditManager

    public class AuditManager {
    
        private readonly int _maxEntriesPerFile;
        private readonly string _directoryName;
        private reaonly IFileSystem _fileSystem;
    
        public AuditManager(int maxEntriesPerFile, string directoryName, IFileSystem iFileSystem){
            _maxEntriesPerFile = maxEntriesPerFile;
            _directoryName = directoryName;
            _fileSystem = iFileSystem;
        }
    
        public void addRecord(string visitorName, DateTime timeOfVisit) {
    
            string[] filePaths = _fileSystem.getFiles(_directoryName); // 새로운 인터페이스 이용
            (int index, string path)[]sorted = SortByIndex(filePaths);
    
            string newRecord = visitorName + ";" + timeOfVisit;
    
            if (sorted.length == 0) {
                string newFile = Path.Combine(_directoryName, "audit_1.txt");
                _fileSystem.WriteAllTexts(newFile, newRecord); // 새로운 인터페이스 이용
                return;
            }
    
            (int currentFileIndex, string currentFilePath) = sorted.Last();
            List<string> lines = _fileSystem.ReadAllLines(currentFilePath).ToList(); // 새로운 인터페이스 이용
    
            if (lines.Count < _maxEntriesPerFile) {
                lines.add(newRecord);
                string newContent = string.Join("\r\n", lines);
                _fileSystem.WriteAllText(currentFilePath, newContent); // 새로운 인터페이스 이용
            } else {
                int newIndex = currentFileIndex + 1;
                string newName = $ "audit_{newIndex}.txt";
                string newFile = Path.Combine(_directoryName, newName);
                _fileSystem.WriteAllText(newFile, newRecord); // 새로운 인터페이스 이용
            }
        }
    }
    • AuditManager에서는 File 클래스를 fileSystem 객체를 이용하도록 변경했다.
    • fileSystem은 새롭게 도입한 IFileSystem 인터페이스 타입이다. 
      • 이 때, AuditManager가 IFileSystem 객체를 통해서 FileSystem과 분리된다. 즉, 공유 의존성이 사라지게 된다. 왜냐하면 Mock을 사용할 수 있께 되기 때문이다. 

    테스트 코드 작성

    class AuditManagerTest {
    
        @Test
        void test() {
            var fileSystemMock = new Mock<IFileSystem>();
            fileSystemMock
                    .Setup(x => x.getFiles("audits"))
            .Returns(new string[]
                            {
                                    @"audits\audit_1.txt",
                                    @"audits\audit_2.txt"
                            });
    
            fileSystemMock
                    .Setup(x => x.ReadAllLines(@"audits\audit_2.txt"))
            .return(new List<String>
            {
                "Peter; 2019-04-06T16:30:00",
                "Jane; 2019-04-06T16:40:00",
                "Jack: 2019-04-06T17:00:00"
            });
    
            var sut = new AuditManager(3, "audits", fileSystemMock);
    
            sut.addRecord("Alice", DateTime.Parse("2019-04-06T18:00:00"));
    
            fileSystemMock.Verify(
                    x => x.WriteAllText(
                            @"audits\audit_3.txt",
                            "Alice;2019-04-06T18:00:00"));
        }
    }

    테스트 코드의 실행은 다음과 같다.

    • AuditManager는 이전에는 파일 시스템과 인터페이스 하는 객체를 클래스 내부에서 직접 생성했다. 하지만 이제 어떤 객체를 이용해서 파일 시스템과 인터페이스를 할지를 결정할 수 있게 바뀌었기 때문에 Mock을 주입할 수 있다.
    • Mock에는 몇 가지 Stub을 구현한다. 
      • 이 부분은 구현 세부 사항이기 때문에 검증하지 않는다.
      • 대신 테스트 조건을 실행하기 위한 초기 값을 셋팅한다. 
    • AuditManager는 addRecord() 할 때, 3줄 이상인 경우 새로운 파일을 작성해서 audit을 남기는지를 테스트 한다. (sut)
    • AuditManager는 최종 사용자가 볼 수 있는 외부 파일을 생성한다고 가정하자. 그렇다면 이 결과는 외부에서 확인할 수 있는 사이드 이펙트, 즉 식별가능한 동작이다. 따라서 이 경우, Mock을 사용하는 것이 타당하다. Mock을 이용해 외부 시스템과의 통신 검증한다. 

     

    정리

    • 파일 시스템의 공유 의존성을 분리해주면서 Mock을 써서 테스트를 할 수 있게 되었다. 
    • 테스트는 더 이상 파일 시스템이 직접 접속하지 않기 때문에 테스트는 더욱 빠르게 실행된다. 
    • 더 개선이 필요한 부분은 복잡한 설정이 많기 때문에 평이한 입출력에 의존하는 테스트에 비해서 읽고 이해하기 어렵다는 점이다. 

     

    6.4.3 함수형 아키텍쳐로 리팩토링 하기

    이전 코드에서는 외부와 통신하는 것을 인터페이스로 생성했고, 이 인터페이스를 AuditManager에게 전달해서 AuditManager가 비즈니스 로직 + 사이드 이펙트를 모두 담당했었다. 하지만 사이드 이펙트와 비즈니스 로직을 둘다 분리해서 함수형 아키텍쳐로 접근해 볼 수도 있다. 

    함수형 코어와 가변 쉘을 다음과 같이 구성해 볼 수 있다.

    • AuditManager : input을 받아서 결정만 생성하도록 한다
    • Persister : 정보를 모아서 전달하고, 결정을 받아서 그에 대한 사이드이펙트 처리를 진행한다. 

     

    AuditManager

    • AuditManager는 FileSystem을 주입 받는 것이 아니라 이제는 FileContent를 주입 받는다. FileContent는 AuditManager가 비즈니스 로직을 처리하는데 필요한 모든 정보가 들어있다.
    • AuditManager는 FileContent 객체로 비즈니스 로직을 처리한다. 비즈니스 로직의 처리 결과로 '결정'을 의미하는 Fileupdate 객체를 생성하고 반환한다. 
    public class AuditManager {
    
        private readonly int _maxEntriesPerFile;
    
        public AuditManager(int maxEntriesPerFile){
            _maxEntriesPerFile = maxEntriesPerFile;
        }
    
        // 의사 결정 정보를 담은 FileUpdate를 돌려주면 됨.
        public FileUpdate addRecord(FileContent[] files, String visitorName, DateTime timeOfVisit) {
    
            (int index, FileContent file)[]sorted = SortByIndex(files);
    
            string newRecord = visitorName + ";" + timeOfVisit;
    
            if (sorted.length == 0) {
                return new FileUpdate("audit_1.txt", newRecord);
            }
    
            (int currentFileIndex, FileContent currentFile) = sorted.Last();
            List<string> lines = currentFile.Lines.ToList();
    
            if (lines.Count < _maxEntriesPerFile) {
                lines.add(newRecord);
                string newContent = string.Join("\r\n", lines);
                return new FileUpdate(currentFile.FileName, newContent);
            } else {
                int newIndex = currentFileIndex + 1;
                string newName = $ "audit_{newIndex}.txt";
                return new FileUpdate(newName, newRecord);
            }
        }
    }

     

    Persister

    • Persister는 AuditManager에게 전달할 input 정보를 모은 FileContent 객체를 생성한다. 
    • Persister는 AuditManager가 생성한 FileUpdate 객체를 받아서 업데이트를 진행한다. 
    public class Persister {
    
        public FileContent[] ReadDirectory(string directoryName){
            return Directory
                    .GetFiles(directoryName)
                    .Select(x => new FileContent(
                            Path.GetFileName(x),
                            File.ReadAllLines(x)))
                    .ToArray();
        }
    
        public void ApplyUpdate(string directoryName, FileUpdate update) {
            string filePath = Path.Combine(directoryName, update.Filename);
            File.WirteAllText(filePath, update.newContent);
        }
    }

     

    ApplicationService

    • ApplicationService는 AuditManager와 Persister를 연결해주는 역할을 한다.
    public class ApplicationService {
    
        private readonly string _directoryName;
        private readonly AuditManager _auditManager;
        private readonly Persister _persiste;
    
        public ApplicationService(string directoryName, int maxEntriesPerFile) {
            _directoyrName = directoryName;
            _auditManager = new AuditManager(maxEntriesPerFile);
            _persister = new Persister();
        }
    
        public void AddRecord(string visitorName, DateTime timeOfVisit) {
            FileContent[] files = _persister.ReadDirectory(_directdoryName);
            FileUpdate update = _auditManager.AddRecord(files, visitorName, timeOfVisit);
            _persister.ApplyUpdate(_directoryName, update);
        }
    }

     

    테스트 코드 

    • 복잡한 비즈니스 로직은 AuditManager 객체에게 모두 들어가있다. 그 외의 사이드 이펙트는 Persister로 분리되었다. 
    • 사용자 관점에서는 AuditManager를 출력 기반으로 검증해보면 된다. 
    @Test
    void test() {
    
        var sut = new AuditManager(3);
        var files = new FileContent[]
                {
                        new FileContent("audit_1.txt"), new string[0]),
                        new FileContent("audit_2.txt"), new string[]
                            {
                                    "Peter; 2019-04-06T16:30:00",
                                    "Jane; 2019-04-06T16:40:00",
                                    "Jack; 2019-04-06T17:00:00"
                            })
                };
    
        FileUpdate update = sut.addRecord(files, "Alice", DateTime.parse("2019-04-06T18:00:00"));
    
        assertThat("audit_3.txt").isEqualTo(update.FileName);
        assertThat("Alice;2019-04-06T18:00:00").isEqualTo(update.NewContent);
    }

     

    정리

    • 기존에는 AuditManager가 외부 시스템과 통신하는 인스턴스를 주입받아서 비즈니스 로직 + 사이드 이펙트를 모두 실행했다. 
    • 함수형 아키텍쳐로 리팩토링 한 후, AuditManager는 FileContent(모든 정보)를 입력받고 FileUpdate를 반환해준다. 이 때 FileUpdate는 결정 정보다. 
    • 함수형 아키텍쳐는 AuditManager(함수형 코어), Persister(가변 쉘)로 나누어 진다.
      • AuditManager에 복잡한 로직(If 분기 등)이 모두 포함된다.
      • Persister에는 If 분기 같은 것들이 존재하지 않는다. 즉, 간단해진다.
    • AuditManager는 비즈니스 로직 처리 + 결정 생성. Persister는 인풋 생성, 출력에 따른 사이드 이펙트 처리만 한다.
    • 이렇게 구조를 나누면서 모든 복잡한 로직은 AuditManager로 포함되게 되었고, 사이드 이펙트를 담당하는 Persister의 코드 복잡도는 매우 줄어들었다.
    • 함수형 코어와 가변 쉘을 연결 시켜 줄 하나의 클래스가 또 필요한데, 이것은 ApplicationService 계층을 이용해서 처리할 수 있다. 
    • 리팩토링 결과, 테스트 코드는 AuditManager에게 생성할 입력을 만들고, 출력을 검증하기만 하면 된다.

    6.4.4 예상되는 추가 개발 → 전달 객체의 재활용

    다음 세 가지 기능이 추가 개발된다고 가정해보자.

    • 작업 디렉토리가 비어있는 경우 새로운 파일 작성
    • 기존 파일에 새 레코드 추가
    • 현재 파일의 항목 수가 한도를 초과할 때 다른 파일 작성 

    예를 들어 특정 회원이 멘션된 모든 파일을 삭제 해야한다고 한다면, 아래와 같이 메서드 시그니쳐를 생성해 볼 수 있다.

    public FileUpdate[] DeleteAllMentions(
    	FileContent[] files, String visitorName);

    즉, 함수형 코어와 가변 쉘 사이를 연결하기 위해 만든 FileUpdate, FileContent를 계속 재활용해서 사용하는 방식으로 어플리케이션을 간단히 확장될 수 있도록 한다. 


    6.5 함수형 아키텍처의 단점 이해하기

    함수형 아키텍쳐는 만능이 아니다. 함수형 아키텍쳐의 장단점을 잘 이해하고 적재적소에 사용하는 방법을 길러야 한다. 


    6.5.1 함수형 아키텍처 적용 가능성 → 모든 곳에 적용 가능하진 않음. 

    순수 함수는 의사 결정을 한다. 만약 의사 결정을 하는 과정에 추가적으로 정보가 더 필요한 경우라면 함수형 아키텍쳐를 사용하기가 어려울 수 있다. 의사 결정을 하는 과정에 정보가 더 필요해서 DB와 통신을 해야한다면, 의사 결정 메서드에 DB와 통신을 하는 객체를 전달할 수 있다. 이것은 '숨은 입력'이 추가되는 것이다. '숨은 입력'이 추가되는 것은 순수함수가 아닌 것을 의미한다. 

    public FileUpdate AddREcord(FileContent[] files, String visitorName, DateTime timeOfVisit, IDatabase database);

    그림으로 살펴보면 다음과 같다. AuditManager가 한번 더 DB에 접근해서 정보를 가져온다. 이것이 처음에 넣어주지 않은 또 다른 숨은 입력이 있음을 의미한다. 

    순수 함수가 아니기 때문에 출력 기반 테스트를 할 수 없어진다. 이 때는 두 가지 해결책이 있다. 

    • 어플리케이션 서비스 시작 전에 일괄적으로 DB에서 모든 정보를 가져온다.
    • AuditManager는 IsAccessLevelCheckRequired()와 같은 새로운 메서드를 둔다. 어플리케이션 서비스에서 AddRecord() 전에 이 메서드를 호출하고, true를 반환하면 서비스는 DB에 접근해서 데이터를 가져온다.

    두 가지 방법은 장/단점이 명확하다. 첫번째 방법은 필요없는 경우에도 DB를 조회하기 때문에 성능이 떨어진다. 두번째 방법은 성능 향상을 위해 분리를 다소 완화하지만, DB를 호출할지에 대한 결정은 AuditManager → 어플리케이션 서비스로 넘어가게 된다. 즉, 함수형 아키텍쳐가 다소 망가진다. 


    6.5.2 성능 단점

    앞서 확인했듯이 함수형 아키텍쳐를 적용하기 위해서 일부 성능을 포기해야하는 부분이 있다. 함수형 아키텍쳐와 전통적인 아키텍처 사이의 선택은 성능과 코드 유지 보수성의 트레이드 오프다. 성능 영향이 그다지 눈에 띄지 않는 일부 시스템에서는 함수형 아키텍처를 사용해 유지 보수성을 향상시키는 편이 좋다. 


    6.5.3 코드베이스 증가

    함수형 아키텍처는 함수형 코어와 가변 쉘 사이를 명확하게 분리해야한다. 궁극적으로 코드 복잡도가 낮아지고 유지 보수성이 향상되지만 초기에 코딩이 더 필요하다. 초기 투자 비용이 필요하다는 것인데, 어떤 코드가 너무 단순하거나 비즈니스 관점에서 그다지 중요하지 않다면 이런 초기 투자를 할 필요가 없다. 

     

    함수형 방식에서 순수성에 많은 비용이 든다면 순수성을 따르지 말자. 대부분의 프로젝트에서는 모든 도메인 모델을 불변으로 할 수 없기 때문에 출력 기반 테스트에만 의존할 수 없다. 대부분의 경우 출력 기반 스타일, 상태 기반 스타일을 조합하게 되며 통신 기반 스타일을 약간 섞어도 괜찮다. 


    6장 요약

    • 출력 기반 테스트는 SUT에 입력을 주고, 출력을 확인하는 테스트 스타일이다. 이 테스트는 SUT에는 숨은 입출력이 없다고 가정하는 것이다
    • 상태 기반 테스트는 작업이 완료된 후의 시스템 상태를 확인한다.
    • 통긴 기반 테스트는 목을 사용해서 테스트 대상 시스템과 협력자 간의 통신을 검증한다. 
    • 출력 기반 테스트가 품질이 가장 좋다.
    • 상태 기반 테스트는 안정성을 위해 더 신중해야 한다. 상태 기반 테스트는 출력 기반 테스트보다 크기가 큰 편이므로 유지보수가 쉽지 않다.
    • 통신 기반 테스트도 신중해야한다. 어플리케이션 경계를 넘어서 외부 환경에 사이드 이펙트가 보이는 통신만 검증해야 한다. 
    • 함수형 프로그래밍은 수학적 함수로 된 프로그래밍이다.
    • 수학적 함수는 숨은 입출력이 없는 함수다. 사이드 이펙트와 예외가 숨은 출력에 해당된다. 내부 상태 또는 외부 상태에 대한 참조는 숨은 입력이다. 
    • 함수형 프로그래밍의 목표는 비즈니스 로직과 사이드 이펙트를 분리하는 것이다
    • 함수형 아키텍쳐는 사이드 이펙트를 비즈니스 연산의 가장자리로 밀어내 분리를 이루는데 도움이 된다.
    • 함수형 아키텍쳐는 모든 코드를 함수형 코어와 가변 셀이라는 두 가지 범주로 나눈다. 가변 셸은 입력 데이터를 함수형 코어에 공급하고, 코어가 내린 결정을 사이드 이펙트로 변환한다.
    • 함수형 아키텍처와 육각형 아키텍처의 차이는 사이드 이펙트의 처리에 있다.
    • 육각형 아키텍처는 도메인 계층에만 한정되어있다면, 도메인 계층에 의해 만들어진 사이드 이펙트도 괜찮다. 
    • 함수형 아키텍처와 전통적인 아키텍처 사이의 선택은 성능과 코드 유지 보수성 사이의 절충이며, 함수형 아키텍처는 유지 보수성 향상을 위해 성능을 희생한다.
    • 모든 코드베이스를 함수형 아키텍처로 전환할 수는 없다. 

    댓글

    Designed by JB FACTORY