-
의존성 끊기와 단위 테스트 - 1부[옛날 글들] 설계 이야기 2024. 5. 30. 00:45728x90
단위 테스트 표류기
최근 몇 년 동안 소프트웨어 개발 방식은 혁신적인 전환점을 맞이하게 되었다. 과거의 무겁고 형식적인 프로세스 중심의 개발 방식을 벗어나, 점차 소프트웨어와 사람에 초점을 맞추는 기민하고 적응적인 개발 방식을 채택하는 조직이 늘어나고 있다. 이와 함께 소프트웨어를 개발하는 방식 역시 커다란 변화를 맞이하게 되었는데 그 중 가장 주목할만한 점은 단위 테스트(Unit Test)가 소프트웨어 개발 프로세스의 핵심 요소로 자리를 잡았다는 점이다.
단위 테스트의 핵심 아이디어는 소프트웨어를 구성하는 개별적인 클래스를 고립시킨 상태에서 테스트하는 것이다. 문제는 테스트를 수행하기 위해 클래스를 고립시킨다는 것이 말처럼 간단하지 않다는 점이다. 객체-지향 시스템 안에서 숨쉬고 있는 객체들은 다른 객체들과의 긴밀한 협력 관계를 통해 자신의 역할을 수행한다. Order 객체를 테스트하기 위해서는 Order 객체와 연관 관계를 맺고 있는 Customer 객체, OrderLineItem 객체, Product객체가 필요하다. 끝없는 연관 관계의 미로 속을 헤매다 정신을 차려 보면 작은 단위 테스트 안에 커다란 객체 그래프를 몰골 사납게 꾸겨 넣은 채 끙끙거리고 있는 자신을 발견하게 된다.
그림 1 객체 간의 의존성은 얽히고 설킨 사슬과 같다그렇다면 단위 테스트에서 클래스를 고립시키는 것이 중요한 이유가 무엇일까? 일반적으로 커다란 객체 그래프를 대상으로 수행되는 테스트는 다음과 같은 문제점을 지니고 있다.
-
에러 위치 확인(Error Localization) - 테스트가 실패할 경우 거대한 객체 그래프의 어디에서 에러가 발생했는지 확인하기 어렵다. 여러 클래스를 가로지르는 실행 경로를 살펴 보면서 입력 값과 출력 값을 추적하는 기나긴 여정 속에서 테스트에 대한 애정이 조용히 사라지는 것을 느끼게 된다. 클래스를 고립시키면 상대적으로 제한된 부분만 확인하면 되므로 에러의 원인을 파악하기가 쉽다.
-
실행 시간(Execution Time) - 당연한 이야기지만 거대한 객체 덩어리를 테스트하는 것은 소수의 객체를 테스트하는 것보다 오랜 시간이 소요된다. 만약 내부의 특정 객체가 데이터베이스와 같은 외부 리소스에 의존하고 있다면 수행 시간은 기하급수적으로 늘어난다. 테스트 실행 시간이 길어지면 길어질수록 개발자들이 테스트를 수행하는 횟수가 줄어들며 결과적으로 건강한 피드백 루프의 장점이 손상된다.
-
테스트 커버리지(Coverage) - 거대한 객체 그래프의 특정 코드를 수행하기 위해 필요한 입력 값을 찾는 것보다는 특정 클래스에 포함된 단일 메소드를 실행하기 위해 필요한 입력 값을 찾는 것이 쉽다. 따라서 테스트 대상 클래스를 고립시킬수록 높은 테스트 커버리지를 얻을 수 있다.
단위 테스트의 효과를 극대화시키기 위해서는 클래스를 고립시켜야 한다. 따라서 클래스와 클래스 간의 의존성을 끊기 위한 기법이 필요하다.
의존성 끊기(Breaking Dependency) 게임
의존성(dependency)이란 “두 요소 간의 관련성으로 한 요소에 대한 변경이 다른 요소가 필요로 하는 정보를 제공하거나 다른 요소가 제공하는 정보에 영향을 주는 관계”를 의미한다. 즉, 어떤 요소의 변경이 다른 요소에 영향을 미친다면 두 요소 간에 의존성이 존재한다고 말한다. 클래스가 다른 클래스의 인스턴스를 속성으로 포함하는 경우, 메소드의 파라미터로 사용하는 경우, 메소드 내부에서 지역적으로 인스턴스를 생성하는 경우, 다른 클래스를 상속받는 경우 모두 두 클래스 간에 의존 관계의 형성된다.
앞에서 살펴본 바와 같이 단위 테스트의 핵심은 개별 클래스를 고립시키는 것이다. 단위 테스트를 수행하기 위해 Order 클래스의 인스턴스를 생성해야 하는데, Order 클래스가 Customer, OrderLineItem, Product에 의존하고 있다면 의존하고 있는 모든 객체들을 포함하는 거대한 객체 그래프를 생성해야 할까?
결국 단위 테스트란 의존성 끊기 게임이다. 단위 테스트의 가장 큰 적은 클래스 간의 의존성이다. 개별 클래스를 단위 테스트하기 위해서는 객체 그래프 상의 적절한 위치에서 의존성을 제어할 필요가 있다.그림 2 단위 테스트를 위해 최소한의 의존성만을 남겨둔 채 객체를 고립시켜라작년 초에 함께 일하던 분이 다른 팀으로 전배를 가게 되어 그 분이 담당하고 있던 통계 서비스를 맡게 되었다. 하루 동안 수집된 로그 데이터를 매일 밤 배치로 처리하여 분석한 다양한 통계 결과를 데이터베이스에 저장하는 기능이었다. 대용량 데이터를 다루는 작업이기 때문에 통계 로그 데이터를 분산 파일 시스템인 HDFS(Hadoop Distributed File System)에 저장하며 대용량 분산 처리를 효율적으로 처리하기 위해 프레임워크로 Map-Reduce 기술을 기반으로 하는 Apache Hadoop을 사용하고 있었다.
코드를 처음 보았을 때 받은 첫 느낌은 코드 전체적으로 Apache Hadoop이라는 인프라스트럭처에 너무 단단하게 결합되어 있었다는 점이다. 따라서 Apache Hadoop이 실행되지 않는 분산 클러스터 외부에서는 특정 클래스의 인스턴스를 생성하거나 메소드를 실행하기가 불가능했다. 결과적으로 단위 테스트 작성이 거의 불가능한 수준이었으며 테스트 케이스가 작성되었다고 하더라도 인프라스트럭처에 의존성을 가지지 않는 유틸리티 성 클래스로 그 영역이 제한될 수 밖에 없었다.
통계 코드를 테스트하는 유일한 방법은 Apache Hadoop이 구동된 의사(pseudo) 분산 환경에 테스트용 로그 데이터를 저장한 후 통합 테스트를 수행하여 데이터베이스의 변경 사항을 체크하는 것뿐이었다. 이와 같은 환경에서는 앞서 살펴본 바와 같이 복잡한 실행 경로의 어떤 위치에서 오류가 발생했는지를 추적하기가 어렵고, 로그 파일을 읽고 데이터베이스에 저장해야 하므로 실행 시간이 오래 걸리며, 다양한 실행 경로를 테스트하기 위한 테스트 데이터를 준비하기가 쉽지 않기 때문에 높은 테스트 커버리지를 기대하기도 어렵다.
결국 코드 전반적으로 테스트 커버리지를 높이고 안정적인 코드 품질을 유지하기 위해서는 의존성 끊기 게임을 시작할 수 밖에 없었다.
<리스트 1>은 일간 통계 작업을 수행하는 실제 프로젝트 코드를 발췌한 것이다.
import org.apache.hadoop.mapred.JobClient; import org.apache.hadoop.mapred.JobConf; ... public class LogDailyAnalyzeJob { public void analyze(String[] parameters) throws Exception { JobConf conf = new JobConf(LogDailyAnalyzeJob.class); ... conf.setJobName("LogDailyAnalyzer : " + parameters[0] + " for " + parameters[1]); conf.setMapperClass(BasicMapper.class); conf.setCombinerClass(BasicCombiner.class); conf.setReducerClass(analyzer.getReducerClass()); ... String year = parameters[1].substring(0,4); String month = parameters[1].substring(4,6); String day = parameters[1].substring(6); String path = "/"+year+"/"+month+"/"+day; conf.setInputPath(new Path("/user/statlogs" + path)); conf.setOutputPath(new Path("/user/result" + path + "/" + parameters[0] + "/daily")); ... JobClient.runJob(conf); } }
<리스트 1> Apache Hadoop에 대해 강한 의존 관계를 가진 클래스
Apache Hadoop에 대한 사전 지식이 없다고 하더라도 전반적인 주제를 이해하는데 큰 무리는 없을 것이라고 생각된다. 여기에서 중요한 것은 JobConf, JobClient와 같이 테스트 하니스에서 생성하거나 실행하기 어려운 객체에 의존성을 가지고 있는 경우 의존성을 끊어야 한다는 것이다.<리스트 1>의 코드에서 단위 테스트와 관련된 가장 큰 문제점은 위 코드가 Apache Hadoop에서 제공하는 JobConf와 JobClient에 직접적으로 의존한다는 점이다. JobConf와 JobClient는 Apache Hadoop 클러스터 환경이 정상적으로 실행 중이어야만 인스턴스 생성 및 실행이 가능하다. 따라서 단위 테스트를 작성하기 위해서는 먼저 테스트 대상 기능으로부터 JobConf와 JobClient에 대한 의존성을 끊어야 한다.
그림 3 단위 테스트 작성을 어렵게 만드는 인프라에 대한 의존성
의존성을 끊기 위해서는 현재 무엇을 테스트하고자 하는 지를 생각해야 한다. <리스트 1>에서 수행하고 있는 주된 작업은 Map-Reduce 작업을 수행할 분산 파일 시스템 상의 입력 파일과 출력 파일의 경로를 구하는 것이다. 따라서 입력 파일과 출력 파일의 경로를 구하는 로직을 인프라스트럭처에 대해 독립적으로 구성하면 된다.
우선 LogDailyAnalyzeJob의 인프라스트럭처에 대한 의존성을 제거하자. Apache Haddop에 대한 의존성을 끊는 가장 간단한 방법은 JobConf와 JobClient에 대한 의존성을 특정한 클래스 내부로 고립시키는 것이다. JobConf와 JobClient를 다루는 모든 코드를 별도의 인터페이스와 클래스로 추출하여 LogDailyAnalyzeJob이 오직 인터페이스에 대해서만 의존하도록 코드를 리팩토링한다.
그림 4 인프라에 대한 의존성을 클래스 내부로 고립시켜 의존성 끊기JobRunnable에서 JobConf에 설정할 값들을 제공하기 위해 JobConfiguration 인터페이스를 추가한다. JobConfiguration 인터페이스는 <리스트 1>에서 JobConf에 설정된 다양한 값들을 계산하여 반환하는 역할을 수행한다. 일간 작업과 관련된 모든 로직이 DailyJobConfiguration으로 이동되었으며 LogDailyAnalyzeJob의 이름은 좀 더 일반적인 LogAnalyzeJob으로 변경한다.
그림 5 테스트 가능하도록 의존성이 끊긴 클래스 구조도이제 분산 파일 시스템 상의 입출력 경로를 확인하는 단위 테스트를 작성하는 것이 매우 간단해졌다. DailyJobConfiguration은 JobConf, JobClient에 대한 의존성을 가지고 있지 않기 때문에 간단하게 테스트 하니스 상에서 픽스처를 생성한 후 테스트를 실행하기만 하면 된다.
@Test public void checkInputPath() throws Exception { DailyJobConfiguration jobConfiguration = new DailyJobConfiguration( new String [] {"CafeAllBase", "20091001"}); String expectedInputPath = LogAnalyzeJob.LOG_PATH_PREFIX + "/2009/10/01"; assertEquals(expectedInputPath, jobConfiguration.resolveInputPath()[0]); }
<리스트 2> DailyJobConfiguration에 대한 단위 테스트
다음 글 : 의존성 끊기와 단위 테스트 – 2부 [끝]
728x90'[옛날 글들] 설계 이야기' 카테고리의 다른 글
프레임워크 - 1부 (0) 2024.05.30 의존성 끊기와 단위 테스트 – 2부 [끝] (0) 2024.05.30 명령-쿼리 분리(Command-Query Separation, CQS) 원리 (0) 2024.05.29 DAO와 REPOSITORY 논쟁 (0) 2024.05.28 단일 접근 원칙(Uniform Access Principle)을 통한 캡슐화 2부[끝] (0) 2024.05.27 -