어느 날, 프로덕션 서버의 서비스 오류 알람이 우수수 올라왔다.
백업 서버와 프로덕션 서버의 배치가 중복으로 실행되는, 즉, 예약작업이 두 번 실행되는 사고가 발생했다.
원인은 백업 서버용 코드 변경과 데브옵스 팀에서 백업 환경 변경을 동시에 진행해서였다. 인프라 개선 중에 삭제되지 않은 백업 서버 인스턴스가 있었고, 해당 인스턴스는 코드 변경 사항이 적용되지 않았다. 이에 따라 방어 코드가 정상적으로 동작하지 않았고, 백업 서버에서 외부 실 서버로 요청을 보냈다.
그 장애가 다른 내부 서비스들에 전파가 되었고, 데이터를 원상 복구하기 위해서 영향받은 팀별로 수 시간에 걸친 복구 작업을 해야 했다. 심지어 일부 엔드 유저 경험까지 영향을 미쳐서 후속 처리까지 포함하여 수일의 복구시간이 들었다.
...
그렇게 몇 주 정도 뒤에, 이번에는 특정 시간 동안 전송한 모든 요청이 실패했다.
원인을 분석하니, 타임아웃 설정의 단위가 잘못 지정되어, 초 단위로 동작해야 하는 코드가 ms 단위로 동작하는 바람에 발생했다.
원래대로라면 백업 서버와 개발 서버에서 검증할 수 있던 오류였지만, 상술한 사고로 인해 임시 비활성화가 되는 바람에 검증할 수가 없었다.
다행히도 이번에는 엔드 유저 경험이 손상되지는 않았지만, 마찬가지로 수 시간 동안 복구작업을 해야 했다.
장애 수습 이후 개발자끼리 모여서 시스템 점검과 회의를 진행했다.
회의 후 결론은 하나로 결정되었다.
"테스트코드 있어요?"
"아니 없어요."
"아 있었는데?"
"아니 없어요 그냥"
테스트 코드도 없이 어떻게 개발했냐고 하면, 로컬서버에서 개발 DB를 바라보게 하고, 개발한 기능을 수동으로 하나하나 테스트했다.
물론 첫 번째 사고 이후로 놀고만 있던 것은 아니었다. 방어 코드가 덕지덕지 붙었고, 각종 예외 처리까지 되어있었지만, 테스트 환경에서는 검증할 수 없는 오류들도 있어서 몇몇 케이스에 대해서 알 수가 없었다.
그렇다고 검증되지도 않은 방어 코드를 확인하자고 라이브 서비스에서 테스트하다가는 또 다른 사고를 만들어 낼 수도 있는 상황이어서 난감한 상황이었다.
그래서 이참에 테스트 시스템을 새로 도입하기로 하고, 테스트 시스템이 구현되었을 때의 모습, 즉 목표를 설정했다. ( * 가 붙은 항목은 테스트 도입 중에 추가되었다.)
- 테스트의 실행만으로 기능의 동작을 보장할 수 있다.
- *테스트가 외부 환경에 영향받지 않고 독립적으로 실행된다.
- 테스트는 배포 전 자동으로 실행되며, 개발환경에서도 직접 실행시킬 수 있다.
- 테스트의 버전 관리가 가능하다.
- 전체 테스트가 수 분 안에 완료된다.
그 다음, 테스트의 방법을 결정하기 위해서, 우리 서비스의 주요 특징을 나열했다.
- 프론트 페이지가 존재하지 않고, 각종 보안 검사를 하는 비공개 REST API만 존재하며, API의 변경과 추가가 자주 일어나지 않는다.
- 서비스는 무중단으로 동작해야 하며, 기능상의 변경이 있을 경우 하위 호환을 위한 듀얼 라이트가 필수이다.
- 특정한 기능을 실행하기 위한 방법과 API 들에 대해 정리된, 관리되는 문서가 존재한다.
- 우리 서비스에 의존하는 다른 내부 서비스가 여러 개이며, 서비스별로 정책을 다르게 설정하여, 동일 기능도 다르게 동작한다.
- 우리 서비스 구동에 필수적인 외부 서비스가 여러 개지만, 그중 일부에 장애가 발생하더라도, 직접적인 연관이 없는 기능들은 정상 동작해야 한다.
- 일부 기능은 특성상 멱등성이 보장되지 않으며, 여러번 실행해서는 안되는 기능 또한 존재한다.
- API와 별개로 정기 배치가 동작하며, 위 특징들이 동일하게 적용된다.
위 특징들을 나열해 두고, 어떤 테스트 방식을 적용할지 치열한 논의를 거치며 두 가지 의견으로 정리되었다.
유닛테스트로 충분하다는 의견과, 유닛테스트만으로는 충분하지 않고, 상위 레벨의 테스트가 필요하다는 의견이 충돌했다.
주요 논점들은 다음과 같았다.
알다시피 유닛테스트는 각 기능이 정상으로 동작하면, 전체의 동작을 보장할 수 있다는 개념이다. 이것만으로 충분하다.
최근 발생한 장애와 온콜을 보면 대부분이 외부 서비스 장애와 환경문제에서 발생했다. 이것들은 유닛테스트에서 검증하기 어렵다. API 레벨에서 테스트를 시행하면 실제로 각 기능을 검증하게 되므로, 추측에 의한 검증이 아니라 동작을 보장할 수 있다.
유닛테스트는 작성/유지보수하기 쉽다. 테스트 정책도 명확하며, 현재 팀원뿐만 아니라 차후에도 대부분 유닛테스트 작성 경험이 있을 것이므로 적응 기간 또한 매우 짧을 것이다.
API 테스트가 작성/유지보수가 쉽지 않은 것은 인정한다.
하지만 우리 코드 베이스에는 하위 호환 지원을 위해서 만들어두었던 코드들이 존재하고, 그중 일부는 지금도 제거되지 않은 채 코드 베이스에 남아있다. 해당 코드들에까지 테스트를 작성한다면, 오히려 악영향을 미치게 될 것이다. 알다시피, 하위호환 코드의 제거는 영향도 평가가 필수적이다. 차라리 블랙박스 테스트로 어느 정도의 커버리지 확보해서 데드코드를 걸러낸 이후에 하는 게 낫다.
다행인 것은, 우리는 API와 동작 원리에 대한 문서가 존재하므로, 생각보다 작성과 관리가 어렵지 않을 것이다.
우리 비즈니스는 엔드포인트 하나를 호출한다고 완료되지 않고, 여러 개의 엔드포인트를 순차 실행해야 그 결과를 얻을 수 있다. 따라서 API 테스트 시에는 시나리오 기반의 테스트가 필수적일 텐데, 발견하지 못한 일부 시나리오가 누락되는 경우에는 시스템의 신뢰성이 떨어질 수 있다.
어차피 다른 테스트를 짜더라도 동일하게 케이스를 놓칠 수 있다. 애초에 지금은 테스트가 없는데 시나리오 한두 개 놓치는 미리 걱정을 하는 게 의미가 없다. 놓친 시나리오를 그대로 테스트에 추가하면 다음부터는 놓치지 않을 수 있으니 나쁘지 않다.
상위 레벨 테스트는 실행하는 데 시간이 너무 오래 걸린다. 사내 다른 프로젝트도 통합테스트를 도입했다가 너무 긴 실행시간으로 인해 테스트를 동작시키지 않다가, 어느 순간부터 테스트가 망가졌다. 신규로 도입하려는 테스트 시스템도 그러지 않다는 보장이 없다.
통합 테스트의 실행이 오래 걸리는 것은 인정하고, API 테스트도 동일하다. 하지만 유닛테스트도 테스트 개수가 많으면 오래 걸리는 것은 동일하다. 테스트가 오래 걸리지 않게 하는 건 주요 목표에도 있으니 어떻게든 방법을 찾아보겠다.
이외에도 다양한 논점들이 있었지만, 논의 결과, 우선으로 API 테스트를 도입하기로 결정했다.
(논의와 결정에만 2주가 넘게 걸렸다)
Contract Test 등도 고려되었으나, 타 팀과의 의사소통과 개발 비용의 문제로 반려되었다.
당시 프로덕트는 Maven+Java+SpringBoot
를 사용했으므로, Java+JUnit5+MockMVC
를 기반으로 하기로 했다.
테스트에서만 Kotlin+kotest
, groovy+Spock
등의 도입도 검토해봤지만, 프로젝트 설정과 충돌이 나서 적용하기에 무리가 있었고, 무엇보다 빠르게 도입하기 위해서 러닝 커브를 줄이고 싶었다.
(차후에 피드백을 받으니 적용한 기술 스택도 정책 문제로 인해 적응하기 쉽지 않았다고 한다.)
Postman
또한 검토해봤지만 보안정보와 버전관리 문제로 제외되었다. 팀원 전부 JUnit은 사용해봤기 때문에, 결정은 별로 어렵지 않았다.
기본 프레임워크를 정했으니, 이제 테스트의 목표를 달성하기 위해서 이제 어떠한 기술을 사용할지 결정할 차례였다.
우리 서비스의 기능 검증을 위해서는 크게 3가지가 확보되어야 했다.
- 우리 프로덕트 코드의 무결성
- 각종 환경 문제로부터의 내성
- 다른 서비스 장애로부터의 내성
- 고가용성의 확보는 데브옵스에서 처리해주니, 우리 팀은 일시적인 장애에 대한 내성을 확보하면 적절하다고 판단했다.
테스트를 통해 프로덕트 코드의 무결성을 확보하기 위해서, 테스트만을 위한 로직이 프로덕트 코드에 존재해서는 안되며, 테스트가 프로덕트 코드를 그대로 통과 해야 한다
는 정책을 사용했다. 물론 컴포넌트들을 mock 처리하는 것도 허용하지 않았고, 기존에 존재하던 테스트 전용 로직은 제거되었다. (차후 회복성 확보의 목적으로 부활하기는 했다.)
각종 환경 문제로부터 내결함성이 확보되었는지를 확인하기 위해서는 오히려 각종 환경 문제를 발생시킬 수 있어야 한다. 가장 자주 일어나던 환경 문제는 네트워크 연결 문제였고, 특히 타임아웃이 95% 이상을 차지했다. 이 문제만 확실하게 잡을 수 있어도 꽤 성공적이라고 판단했다.
가장 쉽게 재현하는 법은 우리 서버의 타임아웃 시간을 극단적으로 줄이고 외부 서버에 연결을 시도하는 것이지만, 상술한 정책에 의해 프로덕트 코드에서 테스트만을 위한 타임아웃 시간을 지정할 수 없었다. 또한, 일부 외부 서비스는 실행 전 타임아웃 설정값을 확인하여 일정 시간 미만이면 아예 실행 거부를 하기 때문에, 테스트를 통한 기능 검증이 확실하지 않아 다른 방법을 찾기로 했다.
마찬가지로 다른 서비스 장애로부터의 내결함성을 확인하기 위해서는, 우리가 직접 다른 서비스들에 장애를 발생시킬 수 있어야 하는데, 현실적으로 말이 되지 않는다. 물론 다른 서비스들이 장애를 테스트 할 수 있는 환경을 제공하고는 있었지만, 일부 케이스는 오직 프로덕션 환경에서만 재현이 되어서 테스트가 불가능한 경우들이 있었다.
환경문제와 다른 서비스 장애. 두 가지를 시뮬레이팅 하기 위해서 방법에 대한 논의를 거쳤고, 개발 부담을 고려하더라도 Fake 서비스를 만들기로 결정했다.
주요 목표 중 하나인 외부 환경으로부터 독립을 구체화하면 크게 2가지였다.
- 테스트 실행을 위해 내/외부 서비스(개발 서버 포함)에 접근하지 않는다.
- 환경 및 외부 서비스 장애 내결함성 확보를 위해 정책이 정해졌다.
- 테스트 실행을 위해 개발 DB를 사용하지 않는다.
Fake 외부 서비스가 특정한 IP를 가지고 어딘가의 서버에 떠 있는 경우, 해당 서버에게 문제가 발생하면 테스트가 실패하는 괴현상이 생길 수 있다. 또한, Fake 서버에서 상태 관리를 해야 하는데, 서로 다른 사람이 테스트를 실행 시킬 수도 있으므로, 동시 처리가 가능한 Fake 서버를 짜야 하는, 너무 비싼 작업을 해야 했다.
따라서 테스트 시작 시에 "생성"되고, 종료 시에 "휘발"되는 fake 서비스
가 필요했다. 이때 사용한 것이 MockServer
다.
조금 설정이 귀찮기는 하지만, 서버를 밑바닥부터 만드는 것보다는 단순했고, Fake 서버를 만들기에 적절한 수준의 기능을 제공했다.
필요했던 타임아웃 강제 발생이나 외부 서비스 장애 등을 충분히 재현할 수 있었고, 테스트 추가 과정에서 몇몇 버그를 발견하여 수정할 수 있었다.
이후 테스트를 조금 작성하고 실행시켜보니 치명적인 문제가 발생했다.
테스트 시스템이 개발 DB를 사용하다 보니 실제로는 외부 서비스에 전송되지 않은 데이터들이 개발 DB에 추가되었고, 이는 추적 불가능한 데이터가 되어 혼란을 가져왔다. (정기 무결성 검사 테스트 알람이 발생했다)
심지어 일부 데이터가 중복으로 생성되어, 제약조건을 충족하지 못해 테스트가 실패하기도 했다.
따라서 Fake 서비스처럼, DB 또한 테스트 시작 시에 생성된 이후 테스트 종료 시에 휘발되어야 했다.
DB의 분리를 위해 가장 먼저 시도해본 방법은 H2를 사용하는 방법이었다. H2를 사용하도록 이것저것 설정을 하고, 테스트를 하나 작성했다. DB 관련 에러가 뜨면서 실패한다. 확인해보니 프로덕트 로직에 특정 DB에 종속적인 코드가 존재한다.
해당 코드를 걷어내는 것을 검토해봤으나, 프로젝트 구조 변경과 대규모 리팩토링이 필요했다. 당장 할 수 있는 수준의 코드 변경이 아니어서 다른 대안을 찾아야 했고, 이때 사용한 게 TestContainers
다.
어차피 DB뿐만 아니라 Redis, 큐 등도 분리해야 했기 때문에 결정은 어렵지 않았다. 몇 가지 설정을 거치고 테스트를 해보니 매끄럽게 동작했다.
덤으로 이전에는 DB 변경이 필요해 개발 DB를 수정한 경우, 개발 DB를 사용하는 모든 서버(로컬 포함)의 코드 업데이트가 필수였는데, 환경 격리가 되어 테스트 DB만 수정하면 되니, 더 이상 그럴 필요가 없어졌다.
테스트를 어느 정도 작성하고, 실행이 오래 걸리는 시나리오가 추가되다 보니(특히 타임아웃) 테스트 완료까지 심각하게 오래 걸리기 시작했다.
이때 적용한 것이 테스트를 병렬로 실행하는 방법이었다. Junit5는 테스트 병렬성
을 지원하기 때문에 설정 변경 자체는 어렵지 않았다. 다만 예상하지 못한 문제들이 너무 많이 발생했다.
우선 @Order
가 정상적으로 적용되지 않을 수 있다는 문제점이 있다. 이를 해결하기 위해서 특정 기능을 테스트하기 위해 선행되어야 하는 기능이 있다면, 이 또한 해당 테스트 내에서 작성해야 한다는 정책을 만들 수밖에 없었다.
또한 로그가 엉망진창으로 찍혀 특정 테스트에 대한 로그를 정확히 확인할 수 없는 문제도 존재한다. 이는 별도 설정을 해야만 해결할 수 있었다.
병렬성을 높이니 MockServer의 처리가 문제가 되었다. 분석해보니 동시에 실행되는 테스트 케이스에 비해 MockServer의 처리량이 모자라서 발생하는 문제였다. 이는 MockServer의 기본 이벤트 쓰레드 수 설정의 문제로 적절히 변경하여 해결하였다.
이벤트 쓰레드 수를 늘리니 이번에는 빌드/테스트 서버에서 too many open files
문제가 발생했다. 확인해보니 테스트 중에 수천~수만개의 fd가 열리는데, 마땅한 방법이 없어서 병렬 처리 수를 조정하고, fd의 수를 늘리는 것으로 처리했다. (현재도 아주 가끔 오류가 발생한다 ㅜㅜ)
테스트를 추가하다 보니 100+개 가량의 테스트가 작성되었다. 이쯤에서 테스트 구조와 관리에 대한 문제가 발생하기 시작했다.
처음 테스트 이름을 지을 때 Given-When-Then
을 기반으로 작성했는데, 초반에 단순한 테스트를 작성할 때는 문제가 없었지만, 복잡한 시나리오를 작성하다 보니 문제가 생겼다.
단일 테스트 내에서 선행조건도 모두 작성하다 보니 given, when이 매우 많아졌고, 마찬가지로 검증해야 하는 게 많아지니 테스트 메소드의 말도 안 되게 길어졌다. (200자, 250자가 넘는 테스트들이 여러 개 생겼다)
단순히 이름만 길어지는 거면 괜찮을 수도 있겠지만, 선행조건 작성 정책으로 인해 Given과 When의 구분도 애매하다는 문제도 발생했다.
아무튼 계속해서 이름을 짓다 보니, 테스트 조건 차이가 작아 이름이 비슷한 테스트들은, 그 길이로 인해 이름만으로는 무슨 테스트인지 알아보기 어려운 수준에 도달했고, 테스트의 실패 시에 빠른 식별이 쉽지 않았다.
결국 우리가 테스트하는 것은 시나리오, 즉 사용자의 행동을 따라 하는 것에 가깝다. 또한, 테스트의 결과는 거의 직접적으로 사용자 경험이 된다. 따라서 테스트에서 사용자 행동과 경험을 기반으로 이름을 지어보는 게 어떻겠냐는 의견이 나왔다.
최종 사용자 경험이 긍정적이면 성공, 부정적이면 실패로 단순화하고, 이후 테스트에서 확인하고 싶은 영역과 사용자 행동을 실행 순서별로 나열한다. 마지막으로 사용자가 얻는 경험을 순차로 나열하는 방식을 적용해보았다.
네이밍 규칙을 변경하니 테스트 이름이 많이 짧아졌고, 무엇보다 해당 테스트에서 무엇을 확인하는지 명확히 알 수 있게 되어, 한눈에 어떤 테스트가 성공/실패하는지 쉽게 알아볼 수 있게 되었다. @DisplayName
을 통해 한글 명칭을 관리하고 있었는데, 한글 명칭 또한 나열 순서만 다를 뿐 동일한 정책을 사용하니 나쁘지 않았다.
최종적으로는 다음과 같은 형태의 명명 규칙을 사용하게 되었다.
(S|F)_(USE_CASE...)_(USER_EXP...)
(성공|실패)_(유저경험..)_(유저행동..)
선행조건을 모두 작성해야 하는 정책으로 인해 단일 테스트의 길이가 상당히 길었다. (대부분 200줄을 초과했다)
시나리오 기반 테스트의 특성상 대부분의 테스트 코드는 동일하고 조건만 조금 수정하는 경우가 많았다. 이 두 가지가 섞이니 말도 안 되는 수준의 중복 코드가 발생하기 시작했다. 결국 테스트의 공통 영역들을 Thread-safe 하게 추출하는 과정을 거쳐야 했다. 쉽지 않은 작업이었지만, 테스트의 작성 자체가 편해져 팀원들에게서 꽤 좋은 반응을 얻을 수 있었다.
대 주제가 같은 영역은 테스트 클래스 하나에서 메서드 수준으로 구분하여 테스트를 관리하고 있었는데, 테스트가 많아지다 보니 수십 개가 되는 테스트 뭉치에서 특정한 테스트를 찾는 것이 쉽지 않았다. (테스트 클래스 하나가 1000라인이 쉽게 넘어갔다)
@Nested
를 사용해 중 주제별로 구분을 해보았지만 출력만 보기 좋아질 뿐 관리 자체는 여전히 쉽지 않았다.
테스트의 구조화와 관리성을 동시에 잡기 위해서 여러 가지 시도를 거친 끝에, 테스트를 정의하는 클래스와 실행하는 클래스를 분리하는 괴상한 형태가 갖추어졌는데, 의외로 나쁘지는 않았다.
정의 클래스에서는 문자 그대로 테스트의 조건과 결과 검증을 정의한다. 이때 정의 클래스의 모든 메서드는 Thread-Safe 하다.
실행 클래스에서는 테스트를 정의한 클래스를 상속하는 클래스를 @Nested
로 선언하여 호출하고, 테스트에 필요한 의존성을 주입한다.
다만 테스트의 정의 클래스에서 개별 테스트를 직접 실행할 수가 없다는 치명적인 단점이 있었지만, 관리 비용보다 저렴하여 감수하기로 했다,
(이 항목은 기억나는 대로 추가 작성합니다,)
MockMVC의 시간 값을 검증하는 부분에서 나노초 단위의 오차로 인해 테스트가 실패하는 현상이 있었다. 시간 값의 비교 정밀도를 조정하기 위해서 Result Matcher를 따로 작성하여 등록했다.
QA 팀과의 테스트 시나리오 공유를 해야 할 일이 있었다. 다행히 변경한 네이밍 컨벤션이 사용자 행동 기반이라 어렵지 않게 시나리오 문서를 주고받을 수 있었다.
테스트 시스템을 구축한 지 수개월이 지난 지금, 목표를 얼마나 달성했을까 점검해보았다.
- 테스트의 실행만으로 기능의 무결성을 보장할 수 있다.
- 알려진 대부분의 상황에 대해서 기능 동작 보장이 가능하다.
- 다만, 이 결과는 API 테스트 단독으로 얻은 것이 아닌, 유닛테스트를 조합했을 때의 결과이다.
- 테스트가 외부 환경에 영향받지 않고 독립적으로 실행된다.
- 테스트는 배포 전 자동으로 실행되며, 개발환경에서도 직접 실행시킬 수 있다.
- 테스트의 버전 관리가 가능하다.
- 전체 테스트가 수 분 안에 완료된다.
- 현재 수백개의 테스트가 동작하고 있지만, 빌드 시간을 포함하여 N분 이내로 완료된다.
최초 기획에는 이 API 테스트의 도입만으로 모든 기능의 전체 검증이 가능할 것으로 생각했지만, 실제 도입을 해보니 그렇지는 않았다.
특정한 설정의 변경이나, 외부에 노출되지 않는 내부 코드 변경 등은 API 테스트만으로는 검증할 수 없었고, 이를 확인하기 위한 별도의 유닛 테스트를 반드시 작성해야만 했다. (이는 작성한 코드에 대한 테스트 코드가 없으면 PR을 거부한다는 정책에서 비롯되었다,)
테스트의 한계점이 명확하게 존재하지만, 현재는 수백개의 테스트 시나리오를 통해 API 테스트만으로 커버리지가 80%에 도달할 정도로 고도화가 진행되었다. 이를 기반으로 데드코드를 꽤 많이 제거할 수 있었고, 커버리지와 영향도 평가가 가능하니 대규모 리팩토링과 메이저 피쳐 개발도 부담 적게 진행할 수 있었다.
다만 테스트의 작성이 쉽지 않고, 관리비용조차 비싸서 문서화나 API 명세가 명확한 프로젝트에서 효과가 좋을 것이라는 확신이 생겼다. 그러나 차후에도 내부 코드가 명확하지 않은 시스템의 테스트를 작성해야할 때는 한 번쯤은 검토해볼 계획이다.
I have read this article and i must say that this is the best Article i have ever read, thanks for sharing. MyAARPMedicare
좋은 글이네요. 공유해주셔서 감사합니다.