[Effective Unit Testing] Chap9. 테스트 속도 개선 |
-
좋은 단위 테스트는 수행 시간도 짧다.
그러나 테스트 스위트의 덩치가 커질수록 피드백 주기가 길어지는 건 피할 수 없다.
-
테스트 속도 개선과 빌드 속도 개선 두 가지 측면을 볼 수 있다.
테스트 속도 개선은 테스트를 빠르게 해줄 실마리를 찾기 위해 코드를 파해치는 작업이다.
빌드 속도 개선과 관련해서는, 빌드 스크립트가 테스트를 실행하는 방식을 봐야 한다. 구체적으로는 고성능 컴퓨터나 다수 컴퓨터를 이용한 병렬 실행을 통해 빌드 시간 단축을 볼 수 있다.
9.1. 속도 개선을 위해서
9.1.1. 더 빠르게!
-
테스트가 빨리 끝나야 하는 이유는 피드백이 늦어질수록 피해가 더 커지기 때문이다.
좁게 보면 개발자는 작업을 마무리하지 못하고 검증 결과를 기다리게 된다.
넓게 보면 빌드가 너무 늦어져서 개발자는 일부 테스트만 수행한 후 나머지는 빌드 서버에 맡긴 채 다음 작업을 시작하게 된다.
빌드 서버가 뒤늦게 문제를 발견하면 개발자는 하던 일을 멈추고 이전 작업을 다시 검토해야 한다.
결과적으로 집중력을 잃고 생산성도 떨어진다.
9.1.2. 상황 속으로
-
느린 테스트와 느린 빌드에 대한 접근 방식은 기본적으로 같다.
무엇이 문제라고 미리부터 가정하지 말고 테스트가 어떻게 실행되는지 분석하여 정확한 상황 데이터부터 구하자.
주요 병목지점을 찾은 다음 해결해야 한다.
9.1.3. 빌드 프로파일링하기
-
빌드를 느리게 하는 가장 큰 원인은 거의 언제나 테스트다.
9.1.4. 테스트 프로파일링하기
-
프로파일러를 사용해서 병목구간을 찾아야 한다.
보고서의 Time 칼럽은 특정 패키지, 클래스, 개별 테스트 메서드가 몇 초나 걸렸는지 보여준다.
가장 느린 테스트를 찾기에 제일 효과적인 방법은 아닐지라도, 필요한 정보는 볼 수 있다.
9.2. 테스트 코드 속도 높이기
9.2.1. 피곤하지 않다면 잠들지 말라
-
테스트를 빠르게 유지하려면 테스트가 필요 이상으로 멈춰있게 해서는 안 된다.
Thread.sleep() 을 사용하는 대신 동기화 객체를 사용하여 잠자는 달팽이 냄새를 만들지 말자.
9.2.2. 덩치 큰 기반 클래스를 경계하라.
-
흔히 볼 수 있는 테스트 속도 저하의 원인 중에는 개발자가 만든 기반 클래스도 포함되어 있다.
이들 기반 클래스는 보통 공통의 셋업과 티어다운 메서드, 그리고 여러 가지 유틸리티 메서드를 제공한다.
그래서 테스트 작성자에게는 편리하지만 숨겨진 대가를 치러야만 한다.
하위 클래스의 모든 테스트가 이런 유틸리티 기능 전부를 사용하는 경우는 거의 없다.
따라서 기능을 필요로 하지 않는 테스트 케이스에서도 쓸데없이 반복 수행되어 성능을 떨어뜨리지는 않는지 잘 살펴야 한다.
-
Junit 이 계층 구조를 다루는 방식 때문에 테스트 클래스의 계층 구조가 깊어질수록 셋업과 티어 다운 메서드가 누적되어 테스트를 점점 느려지게 한다.
JUnit 이 테스트를 실행하는 방식은 이렇다.
우선 리플렉션을 이용해 java.lang.Object 에 닿을 때까지 전체 클래스 계층 구조를 훑으며 모든 @BeforeClass 를 찾아낸다.
그리고 상위 클래스부터 시작하여 찾아낸 @BeforeClass 메서드를 차례로 실핸한다.
각각의 @Test 메서드를 실행하기 전에는 방금 서술한 일을 @Before 메서드에 대해서도 똑같이 반복한다.
즉, 모든 상위 클래스를 훑으며 발견한 @Before 메서드를 모두 실행한다.
테스트 완료 후에는 마찬가지로 모든 @After 메서드를 찾아 실행하고, 한 클래스 안의 테스트가 모두 끝나면 마지막으로 @AfterClass 메서드를 찾아 빠짐없이 실행한다.
-
다른 클래스를 상속하면 그 클래스의 셋업과 티어다운을 매번 실행한다.
꼭 필요한 기능인지 아닌지는 상관없이 말이다.
그래서 계층이 깊어질수록 필요 없는 셋업과 티어다운의 수도 많아질 것이고 계층을 오르내리는 데 낭비되는 CPU 시간도 길어질 것이다.
-
이런 낭비를 피하려면 상속보다는 컴포지션을 애용하라.
그리고 자바의 static 임포트를 사용하고 유틸리티 메서드나 셋업/티어다운을 위해서는 JUnit 의 @Rule 기능을 활용해보자.
9.2.3. 불필요한 셋업과 티어다운을 경계하라.
-
JUnit 을 익힐 때 가장 먼저 배우는 것 중 하나는 여러 테스트에 산재된 공통 로직을 셋업과 티어다운으로 옮기는 일이다.
이들은 @Before 와 @After 라는 에너테이션이 붙은 특수한 메서드다.
아주 유용한 기능이지만 성능 면에서는 방해가 될 수도 있다.
-
@Before 와 @After 메서드는 테스트 하나당 한 번씩 실행된다.
무시할 만큼 작은 오버헤드일 수도 있지만, 어떤 경우엔 제법 많은 시간을 낭비할 때도 있고, 그 시간은 계속 누적된다.
-
셋업을 한 번만 실행해도 되는 테스트 클래스라면 해결책은 아주 간단하다.
@Before 를 @BeforeClass 로 바꿔주면 끝이다.
-
@Before 와 @After 의 가치는 말로 표현할 수 없을 정도로 크다.
그렇지만 빌드가 느려진다면 셋업과 티어다운에서 시간을 얼마나 잡아먹는지, 그리고 이유 없이 실행되고 있는지는 않은지 확인해볼 필요는 있다.
9.2.4. 테스트에 초대할 손님은 까다롭게 선택하라.
-
테스트 시간을 단축하려면 테스트가 수행하는 코드의 양을 줄여라.
더 정확하게는 테스트와 관련 없는 협력 객체를 잘라내어 테스트 대상의 범위를 가능한 한 좁혀주면 된다.
-
각 테스트에서 중요하지 않은 부분을 스텁으로 교체함으로써 지루한 빌드 시간을 단축할 수 있다.
계산량이 많은 컴포넌트를 쏜살같은 테스트 더블로 교체하면 CPU 가 처리해야 할 명령어가 줄어들어 테스트가 훨씬 빨리 끝나게 된다.
-
연산의 수가 아니라 연산의 형태가 중요할 때도 있다.
흔한 예로는 원격 시스템이나 인터넷으로 장거리 통화를 시도하는 협력 객체를 들 수 있다.
테스트를 가능한 로컬(local)하게 유지해야 하는 이유다.
9.2.5. 로컬하게, 그리고 빠르게 유지하라.
-
메모리에서 몇 바이트를 읽는 데는 0.001ms 도 걸리지 않는다.
같은 데이터라도 클라우드 서비스에서 가져오려면 지역 변수를 읽는 것보다 10만 배는 더 걸린다.
테스트를 가능한 로컬하게 유지하고 네트워크 사용을 배제해야 하는 이유가 이것이다.
-
로컬하지 않은 것들은 테스트 더블로 교체할 수 있다.
그럼 테스트를 격리할 수 있고 불확실한 외부 요소가 사라져 신뢰성이 높아진다.
더불어 네트워크 장애나 웹 서비스 타임아웃 같은 특수 상황도 시뮬레이션 할 수 있게 된다.
9.2.6. 데이터베이스의 유혹을 뿌리쳐라.
-
파일 시스템에 접근하는 경우도 느려 터졌다.
파일 핸들을 열고 닫는 일은 메모리를 읽는 것에 비해 1000배는 족히 더 걸릴 것이다.
-
되도록 테스트 대상 코드와 가까운 협력 객체를 가짜 객체로 교체하는 것이 좋다.
테스트 대상 코드와 협력 객체 사이에서 일어나는 일만 확인하면 되는 것이지, 협력 객체와 도 다른 협력 객체 사이에서는 무슨 일이 일어나건 상관없기 때문이다.
나아가 가장 가까운 협력 객체를 대체해야 테스트 대상과 협력 객체 간 상호작용을 일관된 수준의 추상화 용어로 서술하기 유리하다.
-
데이터베이스는 되도록 사용하지 않는 것이 좋다.
데이터베이스 접근은 시간이 오래 걸리고, 그곳에 저장할 데이터도 단위 테스트의 검사 내용과는 대부분 무관하기 때문이다.
잘 저장되는지도 분명 확인해야겠지만, 이는 통합 테스트와 같은 별도의 테스트에 맡기면 된다.
9.2.7. 파일 I/O 보다 느린 I/O 는 없다.
-
테스트를 빠르게 유지하려면 파일시스템 접근을 최소화해야 한다.
테스트가 사용하는 파일 I/O 와 대상 코드에서 사용하는 파일 I/O 둘 다 봐야 한다.
기능 수행에 꼭 필요한 파일 I/O 도 있지만, 꼭 필요하지는 않거나 오히려 테스트하려는 동작을 방해하는 경우도 많이 있다.
-
테스트 코드에서 어쩔 수 없이 파일을 사용해야 하는 경우라면 처음 읽은 데이터를 캐싱해서 딱 한 번만 읽고 더 이상 읽지 않도록 하자.
-
제품 코드에서 파일시스템을 읽고 쓰는 가장 대표적인 예는 로깅이다.
거의 모든 경우에 로깅 기능 자체도 쉽게 꺼버릴 수 있으니 누구나 간단히 사용할 수 있는 빌드 성능 최적화 기법 중 하나다.
9.3. 빌드 속도 높이기
-
최적화 대상이 빌드 관련이더라도 핵심은 변하지 않는다.
병목지점을 파악하는 것이다.
-
빌드 성능을 제한하는 근본 요소는 CPU 아니면 I/O 이다.
CPU 병목이면, 더 빠른 CPU 를 사용하거나, 더 많은 코어를 사용하거나, 더 많은 컴퓨터를 사용할 수 있다.
I/O 병목이면, 더 빠른 디스크를 사용하거나, 더 많은 스레드를 사용하거나, 더 많은 디스크를 사용할 수 있다.
-
실무에 적용할 만한 네가지 전략은 아래와 같다.
디스크 작업을 가속하기 위해 빠른 디스크 사용
더 많은 CPU 와 스레드를 활용하기 위한 빌드 병렬화
더 빠른 CPU 를 위한 클라우드로의 선회
복수의 컴퓨터에 빌드 작업 분산 실행
9.3.1. 램 디스크를 활용한 초고속 I/O
-
고가의 하드 드라이브를 새로 사지 않고도 빠른 디스크를 얻을 방법이 있다.
컴퓨터의 랩에 파일시스템을 구현한 가상 디스크를 활용하면 된다. (램 디스크)
메모리 일부를 가상 디스크 파티션으로 할당하면 그 위에 파일을 읽고 쓸 수 있다.
일반 디스크와 전혀 다를 게 없다.
그러나 성능은 훨씬 빨라진다.
-
일부 유닉스 계열 시스템은 이와 같은 파일시스템을 기본으로 제공한다.
그 이름은 tmpfs 다.
설정도 어렵지 않다.
-
리눅스에서는 tmpfs 로 128MB 의 파일시스템을 생성하려면 아래와 같이 하면 된다.
$ mkdir ./my_ram_disk
$ mount -t tmpfs -o size=126M, mode=777 tmpfs ./my_ram_disk
-
tmpfs 를 지원하지 않는 OSX 에서는 hdid 라는 도구를 이용해 유사한 메모리 기반 파일 시스템을 생성할 수 있다.
$ RAMDISK=`hdid -nomount ram://256000`
$ newfs_hfs $RAMDISK
$ mkdir ./my_ram_disk
$ mount -t hfs $RAMDISK ./my_ram_disk
-
다만, 이렇게 만들어진 램 디스크의 성능도 최신의 SSD 와 비교하면 약간 더 빠른 수준이다.
그리고 요즘 OS 들은 자체적으로 파일을 메모리에 매핑하여 디스크 I/O 의 성능을 끌어올리고 있는데, 아직 이러한 전통적인 하드디스크 방식은 랩 디스크와는 현격한 차이를 좁히지 못하고 있다.
9.3.2. 빌드 병렬화하기
-
어떠한 빌드라도 언제나 컴퓨터의 능력 전부를 끌어 쓸 수는 없다.
9.3.3. 고성능 CPU 에 짐 떠넘기기
-
일반적인 빌드는 CPU 를 기다리며 대부분의 시간을 보낸다.
신각한 병목 원인은 대부분 CPU 이고, CPU 만 업그레이드하면 곧바로 빌드 시간 단축으로 이어질 가능성이 높다.
하지만 CPU 를 내 맘대로 교체할 수 있는 것도 아니니.. 다른 방법을 찾아야 한다.
-
AWS(Amazon Web Services)를 사용하면 좋다.
가상 서버를 할당하고, 빌드 스크립트는 rsync 를 이용하여 개발자의 코드베이스를 그 서버와 동기화한 후, ssh 로 빌드 명령을 서버에 전달한다.
이렇게 하면 개발자 CPU 는 거의 아무런 일도 하지 않는다.
아마존의 가장 강력한 컴퓨팅 옵션으로 빌드하면 엄청 빠른 빌드를 경험할 수 있다.
9.3.4. 빌드 분산하기
-
대다수의 빌드가 빠른 CPU 의 덕을 본다.
그러니 빠른 걸로 한 16개쯤 달면 좋을 것 같다.
그렇지만 컴퓨터 여러 대와 그 각각에 설치된 모든 코어에 작업을 분산하는 방법도 진지하게 고민해볼 필요가 있다.
-
한 개의 빌드를 기준으로 테스트 스위트를 조각내어 원격 컴퓨터들에 맡길 수 있다.
가장 어려운 것은 컴파일 단계의 분산이다. 서로의 dependency 들이 있기 때문에 함부로 나누어 병렬로 컴파일 시킬 수는 없다.
-
분산 컴퓨팅과 관련하여 좋은 소식은 이것이 더는 새로운 일이 아니란 것이다.
컴퓨터 간의 커뮤니케이션을 개선하기 위한 소프트웨어가 많다.
GridGain 이 그 중 하나이다. 이는 작업을 네트워크로 연결된 다른 컴퓨터, 즉 그리드(grid)로 분산해주는 오픈 소스 미들웨어다.
사무실의 로컬 네트워크상에 AWS 와 유사한 클라우드 컴퓨팅 환경을 구축할 수 있다.
-
GridGain 의 좋은 소식은 JUnit 테스트를 분산해주는 특화 기능을 제공한다는 것이고, 나쁜 소식은 그 기능을 사용하려면 테스트 스위트에 몇 가지를 더 설정해줘야 한다는 것이다.
9.4. 요약
-
가장 먼저 해야 할 일은 병목 구간을 빠르게 찾아내는 것이다.
여기서 기억해둬야 할 핵심 단어는 “프로파일링”이다.
전체 빌드에서 가장 긴 시간이 허비되는 구간을 찾아야 한다.
-
테스트 코드에서의 최적화 요소 중 가장 흔한 성능 저하 요인은 아래와 같다.
필요보다 오래 기다리는 스레드
복잡한 계층 구조로 인한 불필요한 코드 수행
적절히 격리되지 못한 테스트
네트워크를 이용하는 테스트
파일 시스템을 읽고 쓰는 테스트
-
빌드 방식을 바꿔서 속도를 높이는 방법이 있다.
하드디스크를 SSD 나 랩 디스크로 바꾸어 I/O 속도를 개선하거나,
속도의 주범의 CPU 라면 테스트 스위트를 병렬화하여 가용 가능한 모든 CPU 코어를 활용해주면 큰 효과를 볼 수 있다.
한 걸음 더 나아가 원격 컴퓨터에까지 테스트를 분산 실행할 수 있다.
'프로그래밍 놀이터 > 안드로이드, Java' 카테고리의 다른 글
[Effective Unit Testing] Appendix B. JUnit 확장하기 (0) | 2019.03.29 |
---|---|
[Effective Unit Testing] Appendix A. JUnit 기초 (0) | 2019.03.20 |
[Effective Unit Testing] Chap8. 제 2의 JVM 언어를 활용한 테스트 작성 (0) | 2019.03.18 |
[Effective Unit Testing] Chap7. 테스트 가능 설계 (0) | 2019.03.17 |
[Effective Unit Testing] Chap6. 신뢰성 (0) | 2019.03.15 |
댓글