사실 이 글의 본제목은 "Testable Architecture" 였다. 주로 내가 하던 일이 software engineering/develop 이었기 때문에, 실제 서비스에 대해서 고민하기보다는 보다 안정성있는 소프트웨어를 만들기 위한 테스트 방안에 대해서만 고민했다. 토이 프로젝트에서의 테스트도 그러한 수준에서 마무리짓는 경우가 많았다.
하지만 실무를 하다 보면 그것만으로는 부족한 경우를 종종 만난다. 실제로 테스트하기 적합하지 않은 아키텍쳐를 가진 소프트웨어를 만나기도 하고, 테스트를 구동하는데 상당한 시간이 걸리거나, 테스트 자체가 부족한 경우도 종종 만난다. 이를테면 아주 특수한 케이스에만 발생하는, 날짜 관련 버그같은 경우는 API가 system dependent 하기 때문에 테스트하기도, 검증하기도 쉽지 않다.
그리고 근래에는 개발과정에서의 테스트 뿐만 아니라 실제 구동중인 상태에서의 테스트가 진행되기도 하고, 다양한 아키텍쳐의 도래 및 기존보다 훨씬 복잡한 MSA 아키텍쳐가 등장하며 테스트 관련하여 내가 몰랐던 내용들이 새롭게 많이 나오고 있는 것 같다. 이러한 점들에 대해서 전부 정리해 보았다.
테스트 적용에 있어서의 문제점과 최적화
테스트가 왜 필요한지는 자명하다. 새로 개발한 기능에 대해서 서비스 전에 확인을 하기 위함일수도 있고, 개발한 로직이 기존 기능에 영향을 끼치는지 확인하고 싶어서일 수도 있다 (Side-effect 방지를 위한 Regression test).
다만 테스트를 작성하는 것이 개발의 속도를 저해한다는 이야기를 하는 사람들이 종종 있다. 이는 명백히 틀린 말로서, 테스트를 작성하는 당장의 비용이 들지는 몰라도 이후에 회귀 테스트의 부재로 인해 버그를 찾는데 추가 시간을 소요하게 되는 것을 감안한다면 결과적으로 테스트는 비용 절감에도 큰 도움이 된다.
그리고 근래에는 TDD 개발론이 도래하면서 테스트를 통해 빠른 피드백을 얻는 효과를 많은 곳에서 추구하고 있다.
그렇다면 실제로 테스트를 적용하면 어떤 문제와 한계가 있을까? 그리고 이들을 최적화하기 위한 방법들을 나열해 보았다.
1. 테스트 구동 속도
테스트 구동 속도는 중요하다. 테스트가 생산성에 영향을 줄 정도로 느리면 결국 안 돌리게 된다. 테스트를 강제하고자 한다면 테스트의 속도 또한 받침이 되어야 한다.
보통 테스트 구동 속도가 느린 이유는 테스트 구동 그 자체가 느린 경우와, 테스트 케이스가 너무 많은 경우가 해당이 될 것 같다.
테스트 구동 그 자체가 느린 경우라면 Unit test와 같이 별도 테스트를 추려서 수행하도록 할 수 있을 것이다.
테스트의 양 자체가 많은 경우라면 선택적으로 테스트를 수행할 수 있을 것이다. 회귀 테스트의 3가지 기법에서 처럼, 모두 테스트를 수행하거나(Retest All) / Selective / Priority 에 따른 테스트를 필요에 따라 수행할 수 있다.
2. 자동화된 테스트
테스트를 직접 돌려볼 수 있는 환경도 중요하지만 테스트를 자동으로 돌려줄 수 있다면 더 좋을 것이다. (바쁘다고 테스트를 빼먹은 경험이 다들 한두번쯤...)
그리하여 자동화된 테스트를 수행할 수 있는 것 또한 안정화된 개발을 하는 데 있어 핵심 요건으로 자리잡았다 (요즘은 보통 CI/CD pipeline에 추가하는 추세이다)
경우에 따라 테스트 자체가 많이 무거워서 computing power의 부족이 발생할 수 있는데, 이마저도 근래에는 Docker/Kubernetes의 개발로 여러 컴퓨터를 묶어서 테스팅 머신으로 하여 해결할 수 있다. 테스트 하기 정말 좋아진 세상이다...
3. 효율적인 테스트 케이스 만들기
테스트케이스를 구성하는 방법을 알았다면 테스트케이스를 어떻게 만들어야 할 지도 알아볼 필요가 있다.
- pairwise 로 테스트 케이스 만들기
가능한 경우의 수가 많을 때 pairwise로 모든 경우를 커버할 수 있습니다. 다만 이 경우 과도하게 케이스가 많아질 수 있어서, 간단한 경우라면 EP-BVA로 테스트케이스 그룹화시켜 간략화 할 수 있습니다. - 상태 전이(state transition)을 통한 테스트 만들기
복잡한 테스트의 경우 가능한 모든 상태를 고려하여 만들기 위해 상태 전이 diagram을 만들어 볼 수 있다. - 테스트 케이스 레이블링
테스트 케이스를 분할하라는 말과 일맥상통한데, 테스트 케이스를 제대로 분류해놓지 않으면 테스트 오류가 발생하였을 때 찾기도 힘들고 테스트도 몽땅 다 돌려봐야하는 부수적인 문제가 발생할 수도 있다. - 공통 테스트 케이스 재사용
특정 테스트 케이스의 경우에는 모듈간 공통적으로 쓰이는 경우가 있을 수 있고, 재사용한다면 효율 측면에서 괜찮은 이득을 볼 수 있을 것이다.
개인적으로는 위 LINE에서처럼 State transition에 대해서 테스트 짜는 것이 tricky한 케이스 잡기에 제일 좋았다. input/output table을 그리는 것은 비교적 익숙한데, state에 대해서 생각해 본 경험이 많지 않아서인듯 하다.
4. 테스트 도입이 까다로운 상황
플랫폼에 따라 테스트 자체가 까다로운 경우가 존재하곤 한다. 이를테면, Web Frontend와 같은 경우는 테스트가 까다로운 상황 자체가 상당히 많다.
실제 서비스 도중인 경우에 테스트를 도입해야 할 수도 있다. 예를 들어 MSA 환경에서 보수적인 서비스 A를 운용중인 도중에 일부 기능을 개선한 서비스 B를 배포하고 싶을 수 있을 것이다. 하지만 보수적인 서비스 특성상 한번에 서비스를 바꾸기 어렵다면, 일부 트래픽에 한해서만 서비스 B를 타도록 루트를 짤 수 있을 것이다. 예) A/B test
5. 레거시 소프트웨어에서의 테스트의 어려움
레거시 소프트웨어의 경우는 Unit Test 짜기가 생각 이상으로 힘들다. 이는 코드가 가지고 있는 다른 모듈간의 종속성(dependency) 문제에서 비롯하는 경우가 많다. 모듈을 종속성을 주입시킬수 있는 (인자로 넘겨주는) 형태로 변환시키고, 종속성을 Mocking 하는 방향으로 로직을 바꾸면 테스트를 만들 수 있고, 더 나아가서 코드를 리팩토링 하는 부수적 효과도 얻을 수 있을 것이다.
6. 성능 테스트
테스트의 본질인 정합성만 신경쓰다 보면 의외로 놓치기 쉬운 것이 성능 그 자체의 테스트이다. 성능에 민감한 부분이 있다면 그 부분에 대한 성능 reporting을 남겨놓을 수 있도록 하고, 꾸준히 관리해야 할 것이다. (과도하게 성능이 떨어지는 경우에는 fail 처리 또한 고려할 수 있다)
Unit test
상반되는 개념의 Blackbox test(제품을 실제 구동하고 이를 테스트하는 것)와는 다르게 아래의 이점이 있다.
- 테스트가 저렴하고 빠르다
- 실제 production이 완전히 구동되지 않아도 되므로 테스트가 빠르다. 이거 작게 볼 이점이 아니다. 테스트가 작음으로서 빌드 pipeline에 넣어 항상 문제점을 확인하게 할 수도 있고, 애당초 테스트가 느려터지면 결국 아무도 안 돌리게 된다 (...)
- 리펙토링 시의 안정성 확보 보험 같은 것이다.
- 코드에 대한 문서로서 작용
- 테스트 본연의 역할 이외에도 documentation으로서 follow-up에 큰 도움이 될 수 있다.
- 페어 프로그래밍에 적합한 모델 (TDD + Pair Programming)
- 한명이 개발을 하면 다른 한명은 이를 토대로 테스트를 짤 수도 있을 것이다.
- 까다로운 로직을 쉽게 테스트 가능
- 개인적인 생각이지만, 명세 테스트(integration test / blackbox test)시에 도달하기 어려운 로직을 unit test를 통해서 직접 테스트 할 수 있는 점이 이점이다.
- 이를테면 SYSTEM DATE에 dependent한 기능 같은 경우는 명세 테스트로는 테스트가 거의 불가능하고, unit test로 확인이 수월하다.
하지만 Unit test를 적용하기 위해서는 제약 조건들이 있다.
- 테스트 유닛들이 독립적이어야 함
- 테스트가 가능한 형태의 개발 = 각 테스트 유닛들은 독립적으로 수행될 수 있어야 한다 = 의존성에서 자유로워야 한다 = 의존성 주입이 가능해야 한다.
- 그렇지 못하다면 리팩토링을 수행해야 할 필요가 있다. (애당초 bad smell인 코드일 가능성이 높다.)
- 구현부(Command)와 순수 로직(Queries)를 분리하거나, refactoring(feature extraction ...)도 이러한 작업에 도움이 될 것이다.
- 하나의 테스트에 하나의 기능만 검증할 것
- 종종 유닛 테스트의 본연을 잃고 명세 테스트가 되는 경우가 있다. 이는 여러 결국 기능을 동시에 검증하게 되는 것이다. 유닛 테스트가 너무 커지지 않도록 조심할 것!
- 레거시 코드라면, 수정한 부분부터 차근차근 고쳐나가자
- 하루아침에 레거시 코드의 모든 부분에 대해 유닛테스트 작성은 현실적으로 불가능하다. 기능 수정을 하거나 리팩토링 한 부분부터 시작하면 된다.
- 테스트는 가급적 주기적으로 돌아야 함
- 테스트를 방치하면 결국 테스트는 사장된다.
- 유닛 테스트의 속도에 신경쓸 것
- 유닛 테스트는 충분히 빠르게 돌 수 있어야 한다. 안 그러면 결국 방치되고 사장된다.
- 그럴 수 없다면 주기적으로 돌리거나, 별도 테스트를 돌릴 수 있는 전용 환경을 따로 구성해 놓아 분리하여 돌리는 방법을 생각할 수 있다.
그리고 Unit test에서 자주 쓰이는 기법이 몇 가지가 있다.
Mock과 Stub
앞서 말한 것처럼 유닛 테스트를 위해서는 의존성을 최대한 끊고, 하나의 테스트가 하나의 기능만 검증할 수 있어야 한다고 했다. 하지만 현실적으로 이는 쉽지 않은 일이다. 예를 들어서 메시지 핸들러 테스트하려고 한다고 가정하면, 메시지를 생성하기 위한 메시지 팩토리가 필연적으로 필요할 수 있다.
이렇게 테스트하고자 하는 것과 다른 객체는 가짜 객체(Mock)를 만들어서 테스트를 하게끔 하면 의존성 문제에서 자유로울 수 있다. 또한, 특정 행위에 대한 흉내를 내야할 수도 있을텐데, 예를 들면 메시지 팩토리에서 Mocking 한 객체를 반드시 반환해주도록 하게끔 하는 것이다. 이를 Stub라고 한다.
하지만 실질적으로 dependency를 깨는 것은 쉽지 않으며, legacy code라면 이는 더욱 어려워 진다. 이럴 때 프로그램의 수정 없이 동작을 변경할 수 있는 지점, 즉 Seam. Working Effectively With Legacy Code에서는 이렇게 정의하고 있다.
A seam is a place where you can alter behavior in your program without editing in that place.
Seam의 종류는 preprocessor seams, link seams, object seams 정도가 있다. object seams가 보통 쓰는 방식이겠지만,
여의치 않다면 언급한 다른 방식으로도 접근해도 나쁘지 않을 것이다.
Performance test
성능에 민감한 프로젝트들은 항상 퍼포먼스가 관건이다. 하지만 퍼포먼스는 테스트 하기가 쉽지가 않다. 유닛 테스트처럼 최소 단위로 잘라서 테스트 하는 것은 보통 큰 의미가 없고, 최소 서비스 테스트 단위여야 하고 보통 통합 테스트 기준으로 돌아가야 할 것이다. 그렇다면 어떤 식으로 구성할 수 있을까.
성능 테스트 요소
먼저 성능 테스트로 확인해야 할 요소에 대해서 짚고 넘어갈 필요가 있을 것이다.
- 시스템 자원 지표
- CPU, 메모리, Disk I/O, 프로세스 및 스레드 수, Swap, 열린 fd 수 등
- 소프트웨어 측면에서의 지표
- profiler (총 처리 시간, 함수별 수행 시간 및 호출 횟수 등)
- 내부 메모리 사용량 및 단편화 척도 (heap 사용 등)
- worker thread pool 사용량
- 접속 빈도량
- 발생하는 오류 및 로그 수, 로그 크기
위의 경우 정도가 보통 실제 운영시에 성능에 영향을 끼치는 요소가 될 것이다. 먼저 시스템 자원의 소모량은 당연히 들고 가야 하고, 함수 콜이나 기능에 대한 소요 시간 및 횟수 추적이 필요하다. 이외에도 메모리 과사용 및 사용 척도를 추적하기 위한 profiling과 더불어, 마지막으로 떨어지는 로그에 대한 정보도 들고 있어야 할 것이다 (가끔은 오류나 로그가 무자비하게 떨어져서 성능상 하자가 생기기도 한다...)
성능 테스트 시나리오
먼저 부하상태에 대해 기본적으로 짚고 넘어가면, 시스템에서 사용 가능한 리소스(CPU, RAM, Disk 등)의 한계치에 임박하는 상황이 도달했을 때이다.
부하상태를 기준으로 하여 성능 테스트를 위한 시나리오를 준비할 수 있을텐데, 크게 아래 유형으로 나눌 수 있을 것이다.
- 부하(load) 테스트
- 서버가 처리할 수 있는 최대 처리량(TPS), 응답시간을 테스트
- "부하상태" 그 자체를 구하기 위한 목적이 있다
- 스파이크(spike) 테스트
- 아주 높은 수치의 부하를 단시간동안 가하는 형태
- 메신저 앱을 예로 들자면, 1월 1일 자정이나 재난 상황과 같이 긴급하게 부하가 몰리는 때에 해당된다
- 스트레스 테스트
- 부하상태를 넘어가는 상황에서의 테스트
- 내구(endurance) 테스트
- 오랜 시간 동안 꾸준히 한계치에 가까운 부하가 가해지는 형태
모든 상황에서 버틸 수 있는 시스템이면 제일 효율적이지만, 그럴 수 없다면 서비스의 형태에 알맞는 부하 상황을 위주로 설계되어야 할 것이고, 이에 맞게 테스트가 되어야 한다.
자동화 시스템 구축
이 부분은 크게 부하를 주는 방법과, 부하의 리포팅 방법에 대해서 설계가 되어야 할 것이다.
먼저 부하 생성기는 툴을 사용할 수도 있고, 아니면 간단한 스크립트 언어로 직접 짤 수도 있을 것이다. 다만 한 가지 고려해야 할 것이 현재 시스템과의 호환성이다.
- 시스템이 요구하는 형태로 트래픽을 생성 가능한가?
- 원하는 형태로 결과 리포팅이 가능한가?
- GUI/CLI 인가?
많이 쓰이는 부하 생성기는 대략 아래 정도가 있는 것 같다.
그리고, 이러한 부하 생성기들은 보통 아래와 같은 정보들을 제공해준다.
- RPS(Requests Per second) / TPS(transactions per second)
- 처리량
- 지연 속도
- 응답 시간
- 리소스 소모량(RAM / CPU / Disk / fd / network)
등등...
이러한 자료들을 시각화해서 쉽게 확인할 수 있도록 해 주면 좋을 것이다.
추가로, 성능을 확인하고자 하는 시스템의 정보에 대해서도 철저한 확인이 필요하다.
- 기준점 파악: 시스템 환경 및 성능이 어떻게 되는가? - OS/libc 버전, CPU/RAM 성능 및 속도, 지원하는 instruction set 등
- 가상화 머신의 경우, CPU, RAM 성능이 예상만큼 나오는가?
- network 대역폭이 예상만큼 나오는가? - iPerf로 확인
해당 과정까지 마치면 Pipeline에 performance test를 추가해 볼 수 있을 것이다.
A/B test
사실 이건 다른 부류의 테스트라고 생각한다. 안정성과 성능이 중요한 소프트웨어 그 자체에 대한 테스트라기보다는 그룹간의 실험, 그리고 이를 위한 아키텍쳐 설계에 더 가까운 것 같다. 어찌되었든 서비스하는 입장에서는 중요한 테스트의 한 종류라고 보여져서 적어놓는다. 다만 나중에 소프트웨어 아키텍쳐를 다루게 될 때 다시 정리해보려고 한다.
해당 테스트는 A와 B 두 방식이 있을 때 어떤 것이 더 효과적인지를 조사하는 방법이다. A와 B 두 방식 사이에서 결정을 내리기 어려울 때 유용한 방법이라고 할 수 있으며, 얼마 정도 효과적인지에 대한 검증은 약간의 통계론도 들어갈 수 있다.
무엇보다도 실험 설계와 데이터 수집이 제일 핵심일테고, 전반적인 프로세스는 아래와 같다.
- (존재한다면) 기존 데이터 분석
- 목표 구체화
- 지표 선정
- 가설 수립
- 실험의 설계 및 수행
- 결과 분석
실제 아키텍쳐 상에서는 주로 A/B randomizer, 로그 데이터를 수집하는 pipeline 설계가 필요할 것이다. randomizer은 그룹간 분산을 가능하면 균등하게 해야 하고, 적은 비용으로 실험그룹을 정할 수 있어야 할 것이다. 로그 데이터베이스는 데이터의 양과 시스템 구성의 비용을 감안해서 분산파일 시스템부터 pub/sub 기반의 kafka까지 다양하게 구성할 수 있을 것이다. 통계는 실시간으로 해도 좋지만 실험 특성상 나중에 몰아서 해도 좋다면 아키텍쳐에서 배제해도 상관 없을 것이다.
MSA에서의 테스트
여러 어플리케이션이 맞물려 돌아가는 복잡한 구조를 가진 MSA 특성상 테스트가 까다롭다. 그래도 Service 내의 scope에서의 테스트는 비교적 속도가 빠르고 가벼워서 수월하다. Unit test와 각 서비스 단위의 Service test 정도에서는 세밀한 범주의 테스트로서 fast feedback이 가능하다. 통합 테스트부터는 복잡하고 feedback 받기도 어렵다. 통합 테스트(Integration test) / 엔드 투 엔드 테스트(End to end test)이 그러한 예들일 것이다. 물론 이러한 테스트들은 어떤 제품이든지 거쳐가야 하는 테스트지만, MSA의 경우에는 통합 테스트 이상의 스케일만이 interface / interaction defect를 검증할 수 있는 수단이 되기 때문에 더 중요하다.
위의 아키텍쳐에서 볼 수 있듯이, 유닛 테스트와 통합 테스트 및 컴포넌트 테스트의 scope가 다른 것을 알 수 있다. end-to-end test는 이러한 서비스들이 여러개 엮여서 훨씬 더 거대해진다.
Unit test와 같은 경우에는 빠르게 수행할 수 있지만, integration test와 같은 경우는 서비스가 온전히 실행되어야 수행 가능하기 때문에 비교적 오래 걸린다. 따라서, 테스트마다 주기를 다르게 하여 Unit test와 같은 경우에는 매 commit / push 마다 수행할 수 있을 것이고 이외 테스트는 테스트 서버에서 주기적으로 수행하여 확인할 수 있을 것이다.
아무리 그러더라도 end-to-end test는 쉽지 않은 일이기 때문에, 최소한의 테스트만으로 high coverage를 할 수 있도록 신경쓸 필요가 있다.
자동화된 테스트 솔루션
테스트의 중요성을 알았지만 테스트를 위한 세팅을 하는 것은 힘들다. 그래서 테스트 전용 세팅을 해놓은 서버/컨테이너를 구성한다 하더라도, 막상 테스트를 돌리려고 하면 귀찮고, 그렇게 되면 테스트는 죽는다. 그래서 근래에는 Automated CI/CD가 성행하고 있다. 커밋 때마다 Unit test를 돈다거나, 주기적으로 integrated test를 돌린다거나.
Automated CI/CD를 도입하여, 1~2주간 자동으로 테스트를 거치게 하여 안정화가 된 것이 확인된 버전의 경우에는 자동으로 deploy하도록 구현할 수 있다. 이렇게 하면 이전보다 문제가 발생할 빈도가 훨씬 적어질테니 자연스럽게 잔업/야근도 줄어들고, 개발에만 온전히 집중할 수 있어 효율도 좋다.
다만 언제나 그렇듯이 "자동화"를 성취하기는 쉬운 일이 아니다.
- 테스트 시간
- 실제 도입시 의외로 쉽게 겪는(?) 문제이기도 한데, 테스트가 느려지면 커밋이 부담되어 개발 자체에 지장을 주거나, 결국 테스트를 꺼버리는 사태가 초래되게 된다.
- 테스트 서버를 충분히 확충하여 문제를 해결할 수 있고, 테스트 자체가 오래 걸리지 않도록 신경써야 한다.
- 오래 걸리는 테스트는 별도 pipeline으로 주기적으로 돌리는 방식을 고려해 볼 수 있다.
- 테스트 시의 이슈 및 로그 수집
- 사실 실제 서비스를 고려하여 로그를 충실히 남겨놓도록 설계되었다면 이는 그렇게 어렵지 않을 수 있다.
- 성능과 같은 경우에는 지표를 꾸준히 수집하여 통계화시킬 수 있도록 추가적으로 구현이 필요할 수도 있다.
아래는 LINE에서의 테스트"만"을 위한 전체 아키텍쳐의 모습이다. 정말이지 아름답다 ㅎㅎ; 만드는 데 고생이야 하겠지만, 테스트 때문에 서로 눈치보고 진땀뺄 이유가 없다는 것 만으로도 상당히 멋지다.
그리고 보통 MSA에서의 테스트는 Deploy를 위한 프로비저닝 과정을 포함하게 된다. 따라서 자연스럽게 프로비저닝의 자동화 또한 이루어져야 할 수 있고, 이를 위해 코드형 인프라(IaC)가 동반될 수 있다. 이 부분은 나중에 별도 포스팅으로 다뤄보려고 한다...
소스 및 참조링크
책을 읽다가 본 구문들도 많고 인터넷에서 참조한 구문들도 많은데 원출처가 전부 다 기억나지가 않아서 일부밖에 적지 못했다...
- 테스트를 작성하고 활용하는 모든 과정 훑어보기
- 고객 경험을 개선하는 A/B 테스트 기반 모바일 앱 개발
- LINE에서 테스트를 최적화하는 방법
- 프론트엔드 영역에서 테스트가 어려운 이유
- 서버 사이드 테스트 자동화 여정
- 단위 테스트 작성의 필요성
- 단위 테스트 도입하기
- 마이크로서비스 아키텍쳐의 테스트, 마이크로 서비스에서의 테스트 환경 구축
- 개발자를 위한 A/B 테스트 해시 샘플링
- Testing strategies in a Microservice Architecture
- 책: Refactoring 2, Working with Legacy Code
'개발 > Engineering' 카테고리의 다른 글
모니터링 시스템에 대한 이야기 (0) | 2022.02.18 |
---|---|
MSA에서의 디자인 패턴 - 세부요소 및 Terminology (0) | 2022.02.14 |
Modern Language에서 확인하는 소프트웨어 디자인 패턴 (0) | 2022.02.09 |
오래된 Tech skill을 쓰는 회사가 위험한 이유 (0) | 2022.01.30 |
이력을 위한 토이 프로젝트 진행하기 (0) | 2022.01.23 |