내 정신과 코드베이스를 살린 단 하나의 TDD 습관

발행: (2026년 6월 6일 AM 12:36 GMT+9)
10 분 소요
원문: Dev.to

출처: Dev.to

내 정신과 코드베이스를 구원한 단 하나의 TDD 습관

간단한 배경 (왜 이 글을 쓰게 되었는가)

이런 일이 있었어요: 저는 TDD를 제대로 하고 있다고 생각했어요. 테스트를 작성하고, 실패하는 걸 확인한 뒤, 최소한의 코드를 추가해 초록색이 되게 만들고, 이를 반복했죠. 교과서적인 흐름이죠?

그런데 몇 달 전, 서비스 클래스를 리팩터링한 뒤에만 나타나는 버그를 잡으려고 오후 내내 애썼어요. 모든 테스트는 통과했지만, 프로덕션에서는 NullReferenceException이 발생했거든요. 완전히 충격이었어요. 모든 것이 초록색인데도 왜 깨졌을까?

결국 저는 코드 내부를 테스트하고 있었고, 외부 세계에 실제로 어떤 영향을 주는지를 테스트하지 않았다는 걸 깨달았어요. 이 깨달음은 마치 트럭이 들이받는 듯했으며, 제 TDD 접근 방식을 완전히 바꾸어 놓았습니다.

구현이 아니라 동작을 테스트하라

테스트가 private 필드, 내부 데이터 구조, 혹은 메서드가 목표를 달성하는 정확한 방식을 직접 참조한다면, 중요한 것을 테스트하고 있는 것이 아니라 “오늘 내가 이렇게 구현했음”을 테스트하고 있는 겁니다. 나중에 성능을 개선하거나 의존성을 교체하거나 변수명을 바꾸면, 이유 없이 테스트가 실패하기 시작하죠. 결국 가치를 전달하기보다 테스트를 고치는 데 더 많은 시간을 쓰게 되고, 테스트 스위트가 깨지기 쉬워 보이면서 신뢰를 잃게 됩니다.

그 보상은? 코드 변경 시 불안이 아니라 신뢰를 주는 테스트 스위트입니다. 테스트는 계약만을 확인합니다: 주어진 입력에 대해 시스템은 특정 출력이나 부수 효과를 보여야 한다는 것만을 검증하므로, 안심하고 리팩터링할 수 있습니다.

작지만 현실적인 예시: PasswordValidator 서비스

// PasswordValidator.cs
public class PasswordValidator
{
    private readonly IRegexProvider _regex; // 테스트 가능성을 위해 주입

    public PasswordValidator(IRegexProvider regex)
    {
        _regex = regex;
    }

    public bool IsValid(string password)
    {
        // 나중에 바꿀 수도 있는 구현
        return _regex.IsMatch(password, @"^(?=.*[A-Z])(?=.*\d).{8,}$");
    }
}

구현 중심 테스트 버전

// PasswordValidatorTests.cs – the "implementation‑focused" version
[TestClass]
public class PasswordValidatorTests
{
    private Mock _regexMock;
    private PasswordValidator _sut;

    [TestInitialize]
    public void Setup()
    {
        _regexMock = new Mock();
        _sut = new PasswordValidator(_regexMock.Object);
    }

    [TestMethod]
    public void IsValid_ShouldCallRegexWithCorrectPattern()
    {
        // Arrange
        var password = "Abcdef12";
        _regexMock.Setup(r => r.IsMatch(password, @"^(?=.*[A-Z])(?=.*\d).{8,}$"))
                  .Returns(true);

        // Act
        var result = _sut.IsValid(password);

        // Assert
        Assert.IsTrue(result);
        _regexMock.Verify(r => r.IsMatch(password,
                     @"^(?=.*[A-Z])(?=.*\d).{8,}$"), Times.Once);
    }
}

문제는 무엇일까요?
테스트가 정확한 정규식 패턴을 알고 있습니다.
또한 내부 의존성(IRegexProvider)이 그 패턴으로 호출되는지를 검증하고 있죠.

만약 검증 로직을 강화해 특수문자를 추가하거나 정규식을 상수로 옮기면, 관찰 가능한 동작(강력한 비밀번호를 받아들이는가?)은 변하지 않음에도 테스트는 실패합니다.

동작 중심 테스트 버전

// PasswordValidatorTests.cs – the behavior‑focused version
[TestClass]
public class PasswordValidatorTests
{
    private PasswordValidator _sut;

    [TestInitialize]
    public void Setup()
    {
        // 정규식 제공자를 모킹할 필요가 없습니다. 구현이 결정적이고 빠르기 때문입니다.
        // 만약 느리다면 가짜 객체를 주입하면 됩니다.
        _sut = new PasswordValidator(new RegexProvider());
    }

    [TestMethod]
    public void IsValid_ReturnsTrue_ForPasswordThatMeetsPolicy()
    {
        // Arrange
        var password = "Abcdef12";

        // Act
        var result = _sut.IsValid(password);

        // Assert
        Assert.IsTrue(result, "대문자, 숫자, 최소 8자를 포함한 비밀번호를 허용해야 합니다.");
    }

    [TestMethod]
    public void IsValid_ReturnsFalse_ForMissingUppercase()
    {
        // Arrange
        var password = "abcdef12";

        // Act
        var result = _sut.IsValid(password);

        // Assert
        Assert.IsFalse(result);
    }

    [TestMethod]
    public void IsValid_ReturnsFalse_ForTooShort()
    {
        // Arrange
        var password = "A1";

        // Act
        var result = _sut.IsValid(password);

        // Assert
        Assert.IsFalse(result);
    }
}

변화를 확인하세요:

  • 내부 협력자를 모킹하지 않습니다(외부 DB나 HTTP 클라이언트처럼 진짜 외부와 통신하는 경우를 제외).
  • 검증은 공개 메서드의 반환값만을 확인합니다.
  • 테스트 이름은 내부 단계가 아니라 우리가 관심 있는 동작을 설명합니다.

나중에 정규식을 루프 기반 검사기로 교체하거나 설정 파일에서 패턴을 읽어오더라도, 외부 계약이 유지되는 한 이 테스트들은 전혀 깨지지 않습니다.

경험담

리팩터링 버그를 잡는 데 3시간을 허비했습니다. 테스트는 “모두 정상”이라고 말했지만, 시스템은 조용히 계약을 위반하고 있었거든요. 동작 중심 TDD로 전환하고 나서 두 가지가 즉시 일어났습니다.

  1. 신뢰도가 상승했습니다. 변수명을 바꾸고, 메서드를 추출하고, 서드파티 라이브러리를 교체해도 “잘못된 이유”로 테스트가 실패할 걱정이 없어졌습니다.
  2. 피드백이 명확해졌습니다. 테스트가 실패하면, 관찰 가능한 결과가 바뀌었기 때문이라는 것을 바로 알 수 있었고, 버그를 고치거나 요구사항을 조정하는 데 필요한 정보를 바로 얻을 수 있었습니다.

물론 트레이드오프가 있습니다. 외부 API나 하드웨어처럼 느리거나 비결정적인 의존성이 있다면 여전히 모킹이나 페이크가 필요합니다. 하지만 그 경우에도 시스템이 외부와 맞닿는 경계에서만 모킹하고, 내부 헬퍼 메서드마다 모킹하지는 않습니다.

가장 큰 이점

테스트 스위트가 살아있는 문서가 됩니다. 새 팀원이 테스트를 읽기만 하면 코드가 무엇을 해야 하는지 즉시 이해할 수 있고, 복잡한 private 필드나 헬퍼 메서드를 파헤칠 필요가 없습니다.

한 가지 핵심 메시지

테스트는 “시스템이 무엇을 하는가를 검증하고, 어떻게 하는가를 검증하지 말아야 합니다.
기능을 시작하기 전에 “이 코드를 실행했을 때 호출자는 무엇을 보거나 경험해야 하는가?” 라는 질문을 스스로에게 던지고, 그 기대만을 단언하는 테스트를 작성하세요. 더 이상, 더 적게.

다음 작은 작업에서 한 번 시도해 보세요. 공개된 출력이나 부수 효과만을 검증하는 테스트를 작성하고, 실패를 확인한 뒤 코드를 수정해 통과시키세요. 리팩터링 후에도 테스트가 초록색을 유지할 때 느끼는 만족감을 직접 경험해 보시길 바랍니다.

당신이 현재 프로젝트에서 가장 먼저 동작 중심 TDD를 적용할 곳은 어디인가요? 여러분이 발견한 이야기를 듣고 싶습니다. 🚀

0 조회
Back to Blog

관련 글

더 보기 »

모바일 한여름 열풍

!Cover image for Mobile Midsommer Madnesshttps://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploa...