TDD

신예찬·2025년 9월 17일
post-thumbnail

정보처리기사 시험을 볼때나 전공 과목 소프트웨어공학을 수강중에 듣게된 TDD란 개념은 TDD에 대해 학습해보기 전까지는 그냥 단순히 테스트를 작성하는 개발 방식이라면 전부 TDD라고 오해하기가 쉽다. 하지만 TDD는 단순히 테스트코드를 작성하는 행위가 아닌 테스트 코드를 통한 소프트웨어 개발 방법론이다.

개발 방법론인 만큼 무조건적으로 지켜야하는 기술적 요건이나 규약같은 느낌은 아니다. 하지만 TDD에대해 직접 배워보고, 이를 실제로 개발 과정에서 적용해보며 요즘과같은 바이브코딩이 만연한 시대에 TDD가 얼마나 유용한지에 대해 느끼게 되어 기록을 남겨보려한다.


Test Driven Development

TDD(Test Driven Depelopment)는 소프트웨어를 개발하는 과정에서 요구사항을 통해 테스트를 도출하고, 해당 요구사항을 만족하는 테스트를 작성하면서 기능 구현을 이어가는 개발 방법론이다. Kent Bec이 제안한 XP(Extreme Programming)에 등장하는 개발 방법의 구체적인 실천 예시중 하나다. XP에서 설명하기로는 TDD를 짧게 설명하자면 'Test-First'라고 표현한다. 일단 요구사항에 해당하는 테스트 코드를 먼저 작성하는것을 시작으로 개발을 진행하게 된다.

이부분은 XP에서 제안하는 충족사항을 꽤나 만족하는 개발 방법이다. 의사소통, 단순성, 피드백, 용기, 존중이라는 키워드로 정보처리기사 실기 시험 준비할때는 외웠었던거 같지만 간단하게 설명하자면 아래의 원칙을 준수하자는 방법론이다.

  1. 조금씩 자주 업데이트하자
  2. 간단하게 만든다
  3. 테스트코드 먼저 만든다
  4. 파트너와 함께 만든다
  5. 스펙에 없는건 만들지 않는다
  6. 안될 계획은 세우지 않는다
  7. 가능한 최대한 리팩터링한다
  8. 사이클 돌린다
  9. 야근하지마라(?)

이를 TDD를 순차적으로 설명하며 XP의 방향성이 TDD에 어떻게 투영되어 있는 개발 방법인지 설명해보려 한다.


TDD 개발 순서

TDD의 순서는 생각보다 꽤 간단하다.

  1. 요구사항을 충족하는 실패하는 테스트를 작성한다.
  2. 테스트를 통과하기 위한 운영 코드를 작성한다. 이 과정에서 요구사항을 만족하는 최소한의 구현만 진행한다.
  3. 테스트를 실행하고 성공을 확인한다. 실패하면 2.로 돌아간다.

여기서 요구사항에 의미를 이해할 필요가 있다. 여기서 말하는 요구사항은 단순히 서비스에 사용자가 필요로하는 요구사항만을 말하는것이 아니다. 어떤 의미냐하면, 우리가 개발하는 과정에서 작성하는 아주 작은 단위(class)에 대해서도 요구사항이라는것이 존재할 수 있기 때문에 이에 대해서도 테스트가 필요한 경우도 있을거라는 의미다.

테스트코드는 아주 작은 단위부터 시스템 전체를 아우러 가장 첫 Client가 될 수 있어야한다. 실제 사용자가 사용하기전 검증을 해주는 최초의 유저가 된다. 실제 사용자가 사용하다가 문제가 발생하는것만큼 끔직한일이 없다. 그렇기에 이 테스트코드를 작성하는 절차가 명확해야한다.

그리고 이 절차를 진행하면서 하나의 절차가 더 존재한다.

3-1. 테스트를 작성하는 과정에서 요구사항에 문제점이나 추가되어야할 항목이 발견된다면 테스트 시나리오 항목을 추가한다.

개인적으로 TDD에서 굉장히 큰 도움을 받는 부분이라고 생각한다. 기능 요구사항도 결국 사용자의 입장에서 확실히 짚고 넘어가야할 부분을 '사람'이 요구사항을 정리한다. 사람이 하는 작업인 만큼 허점이 발생하는것은 굉장히 자연스러운 일이라고 생각한다. 그렇기 떄문에 그 허점을 인지조차 하지 않고 넘어가는거 보다는 테스트코드를 작성하면서 확인되어야할 부분을 반드시 확인하고 갈 수 있도록 개발 방법론적으로 이를 강제하는것이다.

마지막으로

  1. 선택적으로 리팩터링하여 구현 설계를 개선한다.

이과정에서 얻을 수 있는 효과로는 소프트웨어의 지속적 개발 효율을 높힌다는것이다. 리팩터링을 통해 중복 코드 최소화, 불필요한 코드 제거, 읽기 좋은 코드를 만드는 과정은 단순히 코드의 가독성을 높히기 위한 작업이 아니다. 이 코드를 사용하는 또다른 Client, 즉 동료와의 작업 효율을 증대시킨다. 복잡성이 증대한 코드는 2명이서 2인분의 작업을 하는데에 방해한다는 사실을 명심하자.

그리고 이과정이 재밌는게 테스트코드로 인한 이점을 얻을 수 있다는거다. 클라이언트가 있기 때문에 외부 인터페이스는 영향이 없는 상황에서 내부적으로 코드를 개선하는 과정인 리펙터링은 검증해줄 테스트코드가 있는 상황에서 진행된다. 그렇기때문에 검증된 리팩터링을 진행할 수 있다. 테스트코드가 없이 리팩터링을 진행해본 경험이 있는 사람들이라면 알 수 있겠지만 리팩터링한 이후에 테스트 결과가 달라지는 경험은 굉장히 불쾌하지 않을 수 없다.

게다가 이런 테스트코드는 AI로 작성되는 코드가 늘어나는 요즘같은 시대에 더욱 효율적이다. AI로 작성된 코드에 대해서는 더더욱 검증이 필요하기 때문이다.

거짓 양성과 거짓 음성

거짓 양성과 거짓 음성은 본래 통계적 실험에서 사용되는 용어다. 거짓 양성은 주어진 조건이 존재하지 않을 떄 존재한다고 결과가 나타나는 경우다. 거짓 음성은 조건이 유지되지 않음을 잘못 나타내는 경우다. 소프웨어 테스트에도 거짓 양성과 거짓 음성이 존재한다.

위 표를 질병 진단에 대입하여 본다면 이해하기가 굉장히 쉽다.

암 검진을 예로 들어보자.

암 검진 결과가 양성인 경우에 사실 환자가 암이 아니었다고 가정해보자. 이상황에서 환자는 걸리지도 않은 암을 위해 많은 돈을 사용해야하고 필요하지 않은 치료로 인해 몸에 부담을 줄 수 있다.

반대로 암에 걸린사람이 검진 결과 음성이 나왔다고 생각해보자. 이전보다 더 큰 문제다. 암을 치료할 시기를 놓쳐 암이 해결 불가능한 수준으로 전이된다면 크게 문제가 될 수 있다.

소프트웨어 관점에서 거짓 양성은 문제가 되긴 하지만 인지가 가능하다. 양성 신호(test failure)를 확인한다면 처리 불가능한 문제가 아니다. 하지만 거짓 음성의 경우에는 치명적일 수 있다. 테스트가 작성되었지만 green sign이 있다면 소프트웨어 개발자는 문제점을 확인할 수 없다. 이는 곧바로 사용자에게 문제가 있는 서비스를 제공하게되고 원인 분석도 쉽지않다.




이런 상황을 검증하기 위해서는 테스트 코드를 어떻게 짤것인지에 대한 고민도 필요하다고 생각한다.

예시를 통해 테스트코드를 어떻게 짤것인지에 대해 생각해보자.

토큰발급 요구사항 예시

사용자가 로그인을 통해 토큰을 발급받으려 하는 상황을 예시로 들어보자.

아래와같은 요구사항이 주어졌고, 이를 테스트 시나리오로 지정하려한다.

잘못된 비밀번호가 사용되면 400 Bad Request 상태코드를 반환한다

이제 이 요구사항을 충족하기 위한 테스트를 작성한다.

@Test
void 잘못된_비밀번호가_사용되면_400_Bad_Request_상태코드를_반환한다(
    @Autowired TestRestTemplate client
){
    // Arrange
    var email = generateEmail();
    var wrongPassword = generatePassword();
    var password = generatePassword();

    client.postForEntity(
        "/shopper/signUp",
        new CreateShopperCommand(email, generateUsername(), password),
        Void.class
    );

    // Act
    ResponseEntity<AccessTokenCarrier> response = client.postForEntity(
        "/shopper/issueToken",
        new IssueShopperToken(email, wrongPassword),
        AccessTokenCarrier.class
    );

    // Assert
    assertThat(response.getStatusCode().value()).isEqualTo(400);
}

필자는 AAA패턴을 좋아해서 AAA 패턴을 사용해 코드를 작성했다. 요구사항에 맞게 요청 객체를 만들고, 이에 적절한 응답을 하는지 확인을 진행한다.

그리고 이 테스트케이스는 당연하게도 실패한다. 구현 코드가 아직 없기 때문이다.

이제 구현코드를 작성해 테스트 코드를 통과시킬 차례다.

@PostMapping("/shopper/issueToken")
ResponseEntity<?> issueToken(IssueShopperToken query) {
    return ResponseEntity.badRequest();
}

구체적인 구현코드는 없지만 대강 흐름을 살펴보자면 다음과 같다. 올바른 요청을 보냈지만 Bad Request를 응답한다. 뭔가 어색해보이지만 테스트코드를 통과시키기 위한 최소한의 코드를 작성한 모습이다.

이제 테스트코드를 실행시키면 테스트가 통과된다.

이제 여기서 개인적인 의문이 생겼다. 운영코드를 자세히보면 @RequestBody 어노테이션이 없다. json 요청으로 body를 받는것이 아닌 form 형태로 요청 데이터를 받게된다.

하지만 관계없다. 어찌되었든 400 Bad Request가 나왔고 green sign이 떴다. 의도된 요구사항을 충족했다면 어떤 형태라도 괜찮다.

중요한것은, 여기서 테스트코드를 추가로 작성하지 않는다면 문제가 되겠지만, 이를 통해 테스트 시나리오의 구멍이 있음을 확인할 수 있다.

요구사항을 추해보자면 다음과 같다.

올바르게 요청하면 200 OK 상태코드를 반환한다

이경우에 실제로 구현 코드를 통해 기능을 확장해야한다.

테스트코드를 먼저 작성한다.

@Test
    void 올바르게_요청하면_200_OK_상태코드를_반환한다(
        @Autowired TestRestTemplate client
    ) {
        // Arrange
        String email = generateEmail();
        String password = generatePassword();

        client.postForEntity(
            "/seller/signUp",
            new CreateSellerCommand(
                email,
                generateUsername(),
                password,
                generateEmail()
            ),
            Void.class
        );

        // Act
        ResponseEntity<AccessTokenCarrier> response = client.postForEntity(
            "/seller/issueToken",
            new IssueSellerToken(email, password),
            AccessTokenCarrier.class
        );

        // Assert
        assertThat(response.getStatusCode().value()).isEqualTo(200);
    }
@PostMapping("/seller/signUp")
    ResponseEntity<?> signUp(CreateSellerCommand command) {
        if (isCommandValid(command) == false) {
            return ResponseEntity.badRequest().build();
        }

       // 기타 필요한 구현

        return ResponseEntity.noContent().build();
    }

다만 여기서 테스트코드를 실행해보면 로직이 정상적일지라도 실패하게 된다. @RequestBody를 추가해보면 통과된다. 이렇듯, 요구사항상에 빈틈이 있더라도 이를 테스트 시나리오 강화를 통해 요구사항에 반영되어 단순히 소프트웨어 자체가 아니라 전반적인 서비스의 퀄리티도 높히는 방향으로 소프트웨어 개발과 기획운영간 빠른 피드백을 제공할 수 있다.

TDD를 써보고 나니..

TDD를 직접 적용해보고 개인적으로 느낀바는 다음과 같다.

  1. 염려한거만큼 느리지 않다.
    • 많은 개발자들이 TDD를 도입하면 개발 소요시간이 길어진다고 TDD를 기피하는 경향이 있다고 알고있다. 하지만 안정적인 코드를 작성하는것 만큼 빠른 개발 방법이 어디에 있겠는가? 게다가 운영 서비스는 아주 빠르게 요구사항이 변경된다. 변경되는 요구사항을 충족하지만 기존 요구사항을 충족함을 어떻게 확신할 수 있을까? 대부분의 경우에는 테스트코드를 작성하는데에 수초정도의 시간밖에 소요되지 않는다. 테스트코드 작성이 개발 속도를 늦춘다고 하는것은 미래에 작업할 코드에 불안한 요소를 미뤄두는것에 지나지 않는다. 어차피 문제가 발생하면 나와 팀원들이 고쳐야한다.
  2. 뇌 가용성이 늘어난다.
    • 사람의 기억력에는 한계가 있다. 작은 기능을 구현하는 경우에는 빠르고 간단하게 기능을 구현하는데에 어려움이 없다. 그 이유는 집중해야하는 영역의 크기가 작기 때문이다. 하지만 집중해야하는 영역이 큰 기능을 구현해야 한다면? 하나의 기능을 위해 명세서를 하나하나 작성하고 이를 구현한다? 비효율적 아닌가? 그것보다는 큰 작업을 작은 작업들로 나누고, 이 작업들이 충분히 검증되는것이 더 좋은 방향 아닐까 하는 생각. 테스트에서 필요한 부분을 검증하는것을 넘어서 내가 해당 기능이 무엇을 요구하는지 다시한번 정리하게되는 계기가 된다. 즉 해야할일을 테스트로 남겨두기 때문에 굳이 기억을 더듬어가며 구현 진행사항을 확인할 필요가 없어진다.
  3. 리팩터링이 쉬워진다.
    • 개인적으로 가장 좋아하는 부분이다. TDD의 순서를 살펴보면 하나의 기능을 만드는 과정에서 리팩터링을 시도해볼 수 있다. 여기서 가장 중요한것이 기능을 만드는 도중에 리팩터링을 진행한다는 것이다. 개발을 하다보면 어떤 상황에 리팩터링을 해야할지 모호한 경우가 발생한다. 하지만 TDD를 적용하면 현재 개발하고있는 기능이 동일하게 동작한다는 전제하에 리팩터링을 진행하고, 이를 뒷받침해줄 테스트가 있기에 리팩터링에대한 부담감을 꽤나 덜 수 있다. 게다가 접근 제어자에 대한 고민도 확실하게 할 수 있다. 개발하는 과정에서 대다수의 classpackage-private만으로도 충분히 제 기능을 동작하지만 큰 덩어리 형태의 코드를 작성하다보면 과연 내가 작성하고있는 메서드의 접근이 어느 수준까지 이루어져야 하는지에대해 고민하게 되기 때문이다. 하지만 테스트코드가 있다면 해당 테스트코드가 대상 운영코드의 API 사용자가 되기에 테스트코드에서 어느 영역까지 접근이 가능해야하는지 더 명확해진다.
  4. 설계에 집중할 수 있다.
    • 위 평가에 이어지는 생각이다. 공개하는 영역이 명확해질수록 필요한 패키지의 갯수는 그리 많지 않다는것을 알 수 있다. 이렇게되면 하나의 패키지가 라이브러리 사용하듯 사용되는 경험을 할 수 있게된다. 이러한 이점은 비단 패키지 구조 뿐만 아니라 모듈, 애플리케이션 아키텍처, 개인적인 생각으로는 시스템 아키텍처까지도 명확해진다고 생각한다. 코드는 라이브러리 덩어리처럼 생겼을것이고, 그런 라이브러리 몇개 추가한 서비스는 마치 chrome에 plugin을 추가해 사용하는것과 비슷한 모습이 될것이다. 가면 갈수록 요구사항을 처리하는 속도는 빨라질것이고 개발자는 늘어나는 서비스 볼륨에 따라 정형화된 기능 구현방식과 서비스 전반의 구조, 나아가 인프라 확장을 고려한 시스템 아키텍처에 더욱 집중할 수 있게된다고 생각한다.

AI와 TDD

AI 시대에서 TDD가 지니는 가치는 단순한 테스트 작성 기법을 넘어, 명세 기반 개발(Specification-First)로 확장되고 있다. 기존의 TDD가 테스트를 먼저 작성하고, 그 테스트를 만족하도록 구현한다는 흐름이었다면, 이제는 AI가 코드를 제안하거나 리팩터링을 하더라도 사람이 정의한 테스트가 품질 게이트로 작용하여 신뢰성을 보장한다. 이 과정에서 BDD나 ATDD와 같이 행동과 수용 기준을 예시로 명확히 제시하는 방법론은 AI와의 협업에 특히 유용하며, 테스트 자체가 일종의 프롬프트처럼 작동하기도 한다. 최근에는 Test + AI Driven Development(TADD)와 같은 용어도 등장하였고, Living Documentation처럼 테스트와 명세가 살아 있는 문서로 유지되면서, AI가 생성한 코드가 팀의 의도와 지속적으로 정렬되도록 돕는 흐름도 나타나고 있다. 결국 TDD는 AI와 협업하는 시대에도 여전히 의도한 대로 동작하는지 확인하는 안전망이자 인간과 기계가 공유하는 공통 언어로서 중요한 가치를 지닌다고 할 수 있다.

Spring Boot TDD - 입문부터 실전까지 정확하게
TADD: AI를 활용한 새로운 TDD 방법론

0개의 댓글