나홀로 무작정 TDD 도입해보기 (with Kotlin Spring Boot)

오현석·2025년 5월 27일
post-thumbnail

느린 시작이 빠른 완성을 부른다.

TDD는 테스트 코드를 먼저 작성하고, Red, Green, Refactor 단계를 거쳐 코드를 완성하는 방법이다.
TDD를 도입했을 때의 장점은 테스트를 통해 코드의 동작을 확인할 수 있기 때문에 코드 품질을 향상시킬 수 있다는 장점이 있다고 한다.

또한, 개발을 마친 시점에서 테스트 코드를 추가로 작성하는 경험은 쉽지 않을 것이다. 어차피 다 잘 돌아가는데? 라는 생각에 굳이 귀찮음을 감수하려하지 않을 것이다.

이런 이유로 Test Code를 먼저 작성하는 방식인 TDD를 사용하게 되면 어떻게 될까? 그리고 코드 품질 보장이라는 게 잘 와닿지 않는데, 어떤 부분에서 코드 품질이 보장된다는걸까? 라는 궁금증이 생겨 새로 시작하는 프로젝트에 TDD를 도입해보았다.

거기에 도입하면서 느낀 경험과 불편함을 개선하여 나만의 방식을 구축해보았다.

환경 설정 및 도입 배경

Github Repository

깃허브 레포 보러가기

이 프로젝트에서는 Kotlin을 사용하여 Spring Boot 서버를 구축했다. Kotlin으로는 구현해보진 않았지만, Kotlin을 사용하면 코드 가독성이 높아진다는 말을 듣고 어떤 점에서 가독성이 높아지는걸까? 라는 호기심이 생겨 도전해보았다.

우선 테스트 클래스 별로 독립적인 DB를 보장하기 위해 MySQL과 Redis에 TestContainer를 적용시켰다. 메서드 별로도 분리해서 독립된 환경을 보장할 수도 있지만, 그렇게 되면 컨테이너가 켜지고 내려가는 시간이 너무 오래 걸려서 테스트 시간이 길어진다는 단점이 있기에 클래스 단위로 환경을 분리시켰다.

이런 이유로 클래스에서 @BeforeEach, @AfterEach로 JPA Repository의 deleteAll()을 사용하여 컨테이너를 다시 띄우지 않고도 독립적인 환경을 보장할 수 있도록 했다.

이런 상황은 DB에 들어가는 엔티티의 ID값을 DB에 맡겨버리는 상황에 곤란해질 수 있다.
DB 컨테이너 자체는 종료되지 않았지만 JPA Repository가 데이터를 다 지운다고 해도 ID 값 자체가 Serial하게 증가한다면, 어느 한 메서드에서 기본 유저의 ID를 1로 정의해두어도 다음 메서드에서 새로 추가된 유저의 ID는 2가 될 수 있기 때문에 주의해야 한다.

이런 상황에서 해당 프로젝트에서는 최대한 ID값에 종속되지 않도록 코드를 구성하고 테스트 시에도 최대한 사용하지 않고 테스트했지만, 다른 프로젝트에서는 @AfterEach에서 테이블 자체를 삭제했다가 다시 만드는 식으로 구성하는 등 여러 방식으로 해결해볼 수 있다.

초기 설계

내가 아는 바로는 테스트 코드만 먼저 작성한 후에(Red), 빠르게 이를 성공시킬 수 있는 코드를 막 작성한 후에 리팩토링을 시행해야 하는데, TDD를 처음 사용해본 초보자이기 때문에 테스트 코드만 보고 전체적인 코드의 구조를 잡아가기 쉽지 않았다.

그래서 DDD의 구조처럼 도메인 단위로 패키지를 나눠두고 시작했다.

먼저 테스트 클래스에 필요한 요구사항들을 모두 정의해두었다. 예를 들어,

@Test
@DisplayName("챌린지 인증에 실패했다면, 인증에 실패하였음을 클라이언트에게 전달한다.")
void failChallengeCertificate() {
    // given
  
    // when
  
    // then
}

@Test
@DisplayName("카테고리 리스트와 챌린지 리스트를 정상적으로 조회한다")
fun queryCategoryListAndChallengeList() {
     // given
       
     // when

     // then
       
}

이런 식으로 @DisplayName에 어떤 상황에 대한 테스트인지 작성해두었다. 여기서 테스트 메서드에 대한 설명들은 내가 아닌 다른 코드 리뷰를 하는 서버 개발자들이 한번에 알아볼 수 있는 설명이어야 하고, 심지어 서버 개발자가 아닌 일반 사용자 및 기획자가 보더라도 바로 이해할 수 있는 내용으로 쓰면 좋다.

이렇게 되면 이 테스트 코드 자체가 하나의 문서로써 기능할 수 있게 된다. 테스트 결과를 추출하거나 내친김에 코드 커버리지 자체를 jacocoTestReport로 뽑이낸다면 현재 우리 서버에서 어떤 상황에서 이러한 결과를 내는구나! 를 한눈에 파악할 수 있게 된다.

구현

고민사항

이제는 어느 한 도메인에 대해 작성한 테스트 클래스를 만족시키기 위해 코드를 작성하기 시작했다. 미리 작성해뒀던 DDD 스타일의 패키지에 적절한 UseCase, Service 코드들을 넣고 테스트를 통과하기 위한 코드들을 작성하기 시작했다.
위에서 본 예시 코드는 예시를 위해 챌린지 도메인과 인증 도메인 메서드를 하나씩 가져왔다.

초기에는 빠른 테스트 통과를 위해 "통과를 위한 코드"를 마구잡이로 작성했는데, 이렇게 되니까 요구사항을 여러 개 작성해둔 상태에서 코드가 마구잡이로 작성되니 리팩토링 단계에서 시간을 오래 쓰게 되었다.

그래서 오히려 요구사항을 여러 개 작성해둔 상태에서는 통과를 위한 코드를 작성하다보면 걷잡을 수 없이 코드가 복잡해질 수 있다는 것을 체감했다. 내가 통제할 수 있는 범위를 벗어난다는 생각에 이를 해결할 방법을 모색하기 시작했고, 이런 고민을 통해 문제를 타개한 방법은 오히려 DDD를 따라간 구조 덕분에 해결할 수 있었다.

지향 설계는 확실히 편의성에 기반한 방법론이기에 여러 방법론을 조합하면 좋은 방향성을 가질 수 있는 것 같다.

해결

도메인 별로 패키지를 구성했고, 테스트 요구사항과 시나리오도 도메인 별로 나뉘어진 클래스 단위로 작성했다. 즉, 이는 테스트 클래스의 특정 하나의 메서드를 완성하고 나면 다른 테스트 메서드는 먼저 구현했던 메서드를 조금씩 변형해서 만족시켜나가면 된다는 것이었다.

이렇게 되면 테스트 없이 개발했을 때는 경계값에 대한 처리나 다양한 상황에 대한 케이스를 고려하지 못하고 로직을 개발하는 반면, 테스트 요구사항이 먼저 작성되니 로직을 구현할 때 놓칠 수 있는 예외 사항을 최대한 방지할 수 있다는 장점이 있다.

또한, 코드 작성 시 가장 신기했던 점은 테스트를 항상 염두해두기 때문에 UseCase나 Service의 응답 객체에 대해 신경쓰게 되고, 메서드로 들어가는 인자 하나하나에 대해 의존성을 낮추기 위한 고민을 진행했다는 것이다.

예를 들어, 현재 시간과 예약 시간을 비교해야 한다고 하면 현재 시간을 메서드 안에서 LocalDateTime.now() 등으로 넣는 것이 아닌 필요한 현재 시간을 메서드에서 인자로 전달받아 사용하게 하면 테스트 상에서는 원하는 시간대에서 적절한 동작이 이루어지는 지를 확인할 수 있게 된다.

게다가, 서비스 메서드의 응답이 엔티티로 정의되었을 때, A 도메인 서비스에서 B 도메인 서비스로 엔티티가 넘어가는 경우를 경계하다보니 DTO를 적극적으로 활용할 수 있게 되었고, 이는 곧 DDD를 비롯한 결합도 감소, 테스트 시 엔티티 생성이 아닌 DTO로도 테스트를 할 수 있게 되는 등의 장점을 가져올 수 있었다.

테스트

이렇게 되면 코드를 구성한 후에 어느 테스트 부분에서 문제가 생기는 지를 한눈에 알 수 있고, 코드 리뷰를 진행하기 위해 PR을 올린다고 가정하면 어느 테스트가 통과하는 지를 바로 알 수 있기 때문에 코드의 유효성, 즉 로직에 대한 안정성보다 코드의 가독성이나 성능같은 다른 요소들에 집중할 수 있어 리뷰어에게 친화적인 코드 리뷰 환경을 제공할 수 있다.

여기서는 혼자 진행한 프로젝트이기 때문에 효과를 보진 못했지만, 개인적인 만족감과 안도감, 안정성이 매우 향상됨을 느꼈다. 하하하

주의사항

  1. 혼자 하는 TDD는 요구사항이 제한적이다.

    내가 진행한 방식은 전형적인 TDD는 아니지만, 어쨌든 모든 요구사항과 테스트 케이스를 작성하여 최대한 예외 케이스에 대해 미리 정의하고 이에 대한 핸들링을 완벽하게 함을 목표로 했다. 그러나, 혼자 생각하고 혼자 구현했기 때문에 내 머리로는 미처 생각하지 못한 케이스가 존재할 수도 있다.
    여러 명이 참여한 케이스 작성이 아니기 때문에 분명 예외가 존재할 수도 있고, 이런 이유로 테스트가 100% 통과한다고 해서 비즈니스 로직이 100% 성공적으로 동작한다는 보장을 완전히 할 수 없었다.

    물론, 테스트 코드가 없이 진행하는 것보다는 훨씬 낫긴 하다. 여담이지만 테스트 코드를 경험한 후에 진행한 프로젝트에서는 테스트가 코드 품질을 보장해주지 못하여 얼추 봐서는 비즈니스 로직에 문제가 없어보이지만 다양한 사례에서 예외가 발생하여 여러 번 코드가 수정되는 문제가 생긴 경험도 있다.

    이런 경우보다야 분명 코드 품질을 보장할 순 있지만 "완벽"이라는 목표에 도달하기에는 분명 한계치가 존재하는 것 같다.

  2. 테스트 컨테이너의 실행, 종료 시간을 단축시킬 것인가? 독립된 환경을 더욱 편하게 보장할 것인가?
    앞서 환경설정에서 테스트 컨테이너를 사용했다고 했다. 그러나, JUnit5에서는 테스트들의 순서를 보장하지 못한다. 이는 곧, 어떤 메서드가 먼저 실행되느냐에 따라서 다른 테스트들의 결과에 영향을 미칠 수 있다는 것을 의미한다.

    예를 들어, 회원 탈퇴 테스트 메서드가 회원 로그인 테스트 메서드보다 먼저 실행된다면 회원 로그인 로직이 제대로 구현되어있더라도 테스트가 실패하게 될 것이다.

    이런 이유로 테스트 컨테이너를 사용해서 사용하는 DB 자체를 로컬이나 프로덕트 환경과 분리한다면 테스트를 좀 더 자유롭게 사용할 수 있고, 심지어 클래스나 메서드 별로 테스트 컨테이너를 따로 사용하여 DB를 독립적으로 사용한다면 이런 테스트 메서드 간의 간섭이 사라질 것이다.

    그러나, 테스트 컨테이너는 말 그대로 도커 컨테이너가 내려갔다가 올라가는 다운타임이 존재한다. 이 시간은 길면 수 초가 걸리기도 하는 등 테스트가 많아질 수록 무시할 수 없는 수준의 시간 지연을 제공하기 때문에 여러 고민이 필요하다.

    특히, 빠른 배포가 필요한 시점에서는 파이프라인에서 테스트를 제외하는 등의 방법을 통해 최적화를 진행하거나 최대한 같은 컨테이너를 재활용하고 DB가 아닌 테이블을 삭제했다가 다시 생성하는 등의 방식으로 해결하는 것 같다.

  3. 테스트 코드를 온전히 믿을 수 있는가?
    이건 1번 주의사항과 연결되는 점 같다. 먼저 앞서 말한 것처럼 내가 적은 테스트 케이스가 서비스 케이스의 전부가 아닐 수 있다는 점이 있을 수 있다.
    또한, 통합 테스트를 사용했다고 한들, Controller에게 전달되는 요청이 내가 기대한 요청 형식대로 도착하지 않을 수 있다. 이 문제는 서버 자체의 문제는 아니지만, 프론트와의 연결 작업을 진행할 때 소통이 필요해질 수 있는 점이다.

    아무리 API 명세서나 문서 작업이 잘 되어있더라도 클라이언트에서 주기 편하거나 받기 편한 형식으로의 변경이 생길 수도 있고, 원하는 대로의 요청이 도착하지 않아 테스트에서는 발견되지 않은 문제가 연결 시에 발생할 수 있다.

    사실, 테스트 코드를 완벽히 작성해두면 프론트와의 연결 작업에서 부담을 덜 느끼는 것은 맞지만, 아예 손을 놓는다거나 독립된 작업이라고 생각해서는 안된다는 것이다.
    테스트 코드를 완벽히 작성해두면 내 구현이 끝난 후에는 신경쓰지 않아도 되겠지? 라는 필자의 안일한 마인드를 되풀이 하지 않았으면 좋겠다...

    언제든지 요구사항에는 변경이 생길 수 있고, 개발 구현 시에 타협하거나 예상치 못한 네트워크 문제 등이 발생할 수 있기 때문에 Controller에서 사용되는 DTO나 핸들러들은 이런 변경사항에 유연하게 대처할 수 있도록 초기에 대비하는 것이 좋다.

+ Kotlin 사용 후기

Kotlin의 장점 중 가장 유명한 것은 NPE(Null Point Exception)을 컴파일 시에 확인할 수 있다는 점이다.
확실히 코드 작성 시에 IDE 수준에서도 충분히 NPE를 경고하기도 하고, 컴파일 시에도 NPE가 보장되지 않으면 에러를 반환하기 때문에 더욱 꼼꼼하게 코드를 작성하고 대비할 수 있었다. 확실히 말로 듣는 것과 직접 코드를 작성하며 느끼는 것은 다르기 때문에 Java만 사용해봤던 개발자 분들은 한번쯤 시도해보기를 권장하고 싶다. Java를 능숙히 사용한다면, 러닝 커브가 수월할테니...

또한, 가독성이 좋아진다는 것을 한번에 체감할 수 있었다.
Kotlin 자체에 익숙하지 못한 시기에는 이게 가독성이 좋은건가..? 싶다가도 언어에 익숙해지고 다시 Java로 돌아왔을 때, 타입을 다 지정해야 한다거나, 생성자를 직접 정의해줘야 하는 등 코드 자체가 간결하지 못하고 길어진다는 점을 체감하니 Kotlin 코드가 Java에 비해 확실한 가독성적인 우위를 가지는구나!를 직접 체감할 수 있었다.

역시 이런 장점들은 말로만 듣는 것보다 직접 경험해야 느낄 수 있는 부분들이 많은 것 같다. 또, 이벤트 기반 아키텍처에 대해 비동기적인 구현을 테스트하기 힘들어서 포기했던 코루틴 등과 같은 부분에 대해 프로덕션에서 직접 사용하지 못한 부분이 아쉬움으로 남는다. 추후에는 이런 부분들을 적극 활용하여 개선해보자!

profile
다함께 성장하는 개발자 세상을 꿈꾸는 MLOps 엔지니어입니다😁 작성 당시 제 생각의 흐름을 독자 모두가 공감하고 이해할 수 있게 적으려고 노력합니다. 조언이나 질문은 언제든 환영입니다!

0개의 댓글