서버 개발을 시작하고나서 지금까지도 가장 어려워하는게 있다면 그건 단연 TDD이다.
NestJS를 배울 때 .spec.ts 파일을 처음 봤고 강의에서는 테스트 코드 작성을 위한 파일이라고 했다. 원티드 프리온보딩을 참여할 때도 TDD에 대한 언급과 쓰면 좋아요!는 있었지만 깊이있게 다루지는 않았다.
사실 NestJS TDD라고 검색해도 글이 적을 뿐더러 있더라도 개념과 짧은 예제만 소개하는 글이 많아, 핵심적인 키를 잡기는 어려웠다.
그렇게 한창 성장에 목말라 있을때 항해 플러스 커리큘럼에 TDD가 있는 것을 보고 이거다 싶었다. 물론 대규모 처리도 궁금했다.
그렇다고 이 글이 TDD를 완벽하게 설명하고 알려준다는건 아니지만, 내가 항해플러스를 하면서 경험하고 느낀 TDD에 대해서 말하고자 한다.
TDD
개념
TDD(Test Driven Driven)는 개발해야하는 기능을 검증하는 테스트 코드를 먼저 작성하고, 그에 맞게 기능을 개발해 테스트를 통과한 후 테스트 코드의 보호 아래 리팩토링을 진행해 코드를 개선해 나가는 소프트웨어 설계 방법론이다.
요구사항을 분석해 예측 가능한 성공 케이스와 실패 케이스를 테스트로 작성하고, 테스트 통과 만을 위한 코드를 작성한다. Controller에 모든 코드를 작성 해서라도 일단은 돌아가게 만드는거다. 이 과정을 거치고나면 내 코드는 요구사항을 만족하고 파란색의 테스트 결과를 보게된다. 이때부터 코드를 Service로 분리하고 나누면서 리팩토링을 거친다. 혹시나 잘못된 리팩토링이 있었다면 테스트 코드가 소리를 지르면서 알려줄거다.
- 테스트 코드 작성
- 테스트 패스를 위한 코드 작성
- 리팩토링
- 무한 반복
간혹 DDD와 TDD를 둘 중 하나를 선택하는 것처럼 생각하는 경우가 있는데, 전혀 관계없이 별개로 적용 가능한 개념이라는 점을 알아두자.
근데 왜 필요할까?
서비스와 코드의 규모가 커지고 유저가 많아지면서 예측하기 힘든 케이스의 장애가 발생하기 시작한다. 성장 할 수록 유지보수와 장애 대응의 비용이 점점 커져가는 것이다. 또한 새로운 기능을 추가할때마다 기존 기능에 문제가 없는지 검증도 필요할거다.
문제가 없는지 테스트하기 위해 실패 케이스를 우선시 해야한다. 예측했던 케이스의 장애와 서비스 중 발생하는 케이스를 추가하면서, 현재의 기능을 만족하면서도 새로운 기능을 추가할 수 있도록 유연하게 코드를 작성해야한다. 즉 Testable한 코드를 작성해야 한다는 이야기다.
Testable한 코드
테스트 가능한 코드를 작성하기 위한 몇가지 특징이 있는데, 결과적으로는 코드가 의존성을 줄이고 최소 단위로 나뉘어서 명확한 기능을 해야한다. 아래 규칙들이 지켜지지 않으면 테스트 코드를 작성하고 검증하기에 어려움이 있고, 새로운 기능을 추가하기 어려워질거다.
- 낮은 결합도
- 코드는 서로 독립적으로 작동해야한다.
- 높은 응집도
- 관련된 기능들이 하나의 모듈 또는 클래스에 모여있어야 한다.
- 의존성 주입
- 의존성을 외부에서 주입 받도록 하면 mock 객체를 통해 테스트 할 수 있다.
- Repository 패턴을 통해 service는 DB나 외부 API 호출을 추상화해 사용한다.
- 단일 책임 원칙
- 각 클래스나 함수는 예측 가능하도록 하나의 책임만 가져야 한다.
- 개방-폐쇄 원칙
- 새로운 기능을 추가할때 기존 코드를 수정하지 않고 확장할 수 있어야 한다.
- 인터페이스 분리 원칙
- 사용하지 않는 메소드에까지 의존받지 않도록 인터페이스를 분리해야한다.
- 의존성 역전 원칙
- Service가 다른 Serive를 호출하거나 상위 레이어를 호출하는 등의 문제가 발생하면 안된다. (클린 아키텍처나 레이어드 아키텍처를 통해 지켜지도록 한다.)
적용 경험
일단 무슨 기능이 필요한지 알아야 한다.
당연한 이야기 같지만 하나의 API를 구성하기 위해서 어떤 기능들이 있어야 하는지 입력은 뭐고 출력은 뭔지 먼저 정의가 필요하다. 이게 참 어려운게, 코드를 작성하다보면 어? 이거 있어야 겠네 하는 순간들이 많기 때문이다.
의식의 흐름으로 코드를 작성하지 않고, 차분하게 API를 구성하는 세부 기능들을 먼저 생각하고 하나씩 구현해 나가자.
Given-When-Then 패턴
테스트 코드내의 순서를 정해서 작성하는 방식이다. 무조건 따라야한다는건 아니지만 코드를 작성할때나, 나중에 다시 읽을 때 정리가 되어있어서 상당히 도움이 되었다.
- Given : 테스트 상황을 구성할 변수나 결과로 예측하는 값을 입력하는 단계
- When : 메소드를 실행하는 단계
- Then : 결과가 예측한 것과 일치하는지 검증하는 단계
|
|
Repository 패턴으로 분리
위에 소개한 원칙 중 하나인 의존성 주입을 위해서는 Repository 패턴 도입이 필요했다. 코드에 DB 조작이 포함되면 실제 DB를 변경하지 않도록 Mocking 작업을 해야하는데, Service에 모든 코드가 포함되면 이에 어려움이 있다.
특히나 TypeORM이나 Prisma를 사용하면 내장 함수를 Mocking하기 어려움이 있기 때문에, Repository로 분리하고 테스트 코드에서는 MockRepository를 오버라이딩해 사용해야한다.
이 과정에서 Impl 같은 인터페이스 구현체가 나오는데, 기능 구현을 위해서 라기보단 mockRepostiroy를 작성하기 용이하기 위해서라고 느낀다.
|
|
어려웠던 점
의존성 주입
Facade 레이어를 테스트 한다고 했을때, MockModule에서 사용하는 모든 Service를 주입시켜야 한다. 그 얘기는 Facade 레이어를 테스트하기 전에 모든 Service 기능이 구현되어있어야 한다는 뜻이다. Controller부터 Repository까지 Top-Down 방식으로 코드를 작성했던 나로서는 상당히 골치 아픈 부분이었다.
왜 괜히 코치님이 Bottom-Up으로 작성한다고 하는지 느낀 부분…
DB Mocking
API 대부분이 DB를 이용한 내용이 많아, 대부분의 기능을 Mocking 해주거나 Test용 DB를 사용해야 하는 부분이 어려웠다. 단순히 Test 환경을 만드는 것 뿐만 아니라, 테스트 코드에서도 사전 데이터를 입력해주는 과정이 가장 난해 했었다.
Unit 테스트를 위한 기능 분리
유저가 가입을 할때 DB에 추가하고 환영 이메일을 전송한다는 기능이 있다고 해보자. 여기서 기능은 2가지로 유저 생성과 이메일 전송이다. 이 두 기능을 각각 메소드로 작성하고 Facade에서 모아서 사용하는 방식으로 코드를 작성 해야했다.
사실 TestCode 때문만은 아니고 각 기능을 분리함으로서 코드 재사용성을 높이는 건데, 항상 하나의 Service에 모든 코드를 작성하던 나로서는 기능 분리에 어려움이 있었다.
마치며
솔직히 TDD를 적용하는건 상당히 귀찮은 작업이다. mockRepository 같이 의존성을 위한 mock 코드도 작성하지 않아도 되고, 아웃소싱 업무를 하고 있는 내 입장에서 적용할 필요성이 낮을 뿐더러, 공수가 늘어 개발 비용이 증가한다.
그럼에도 불구하고 에러 상황을 기억에만 의존하지 않고 코드로 남길 수 있다는 부분에서 상당히 의미가 있다고 생각한다.
TDD와 클린 아키텍처, 대용량 트래픽 등을 자격요건 또는 우대사항으로 적어두는 회사가 꽤 있는 것 같다. 제대로 배우기도 어렵고 작은 프로젝트에서는 경험하기 어려운 부분이라 난감한데 마땅한 교육이 없었던 것 같다.
뛰어난 코치님들과 같은 꿈을 꾸는 동료들을 알게되고 서로 응원해가는 모습이 상당히 좋았던 기억이다. 프론트엔드 코스도 있고, 최근 AI 코스도 생겨서 새로운 학습의 기회도 생겼다. 성장에 욕심이 있는 개발자라면 항해 플러스를 추천한다.
아래 추천 코드를 지원페이지에 입력하면, 등록금 20만 원을 할인 받을 수 있습니다.
추천인 코드 : QdEIWZ