외부활동/우아한테크코스 [프리코스]

테스트주도 개발 시작하기 - 챕터 7. 대역

softmoca__ 2025. 11. 6. 13:41
목차

https://product.kyobobook.co.kr/detail/S000001248962

 

테스트 주도 개발 시작하기 | 최범균 - 교보문고

테스트 주도 개발 시작하기 | 작동하는 깔끔한 코드를 만드는 데 필요한 습관 - JUnit 5를 이용한 테스트 주도 개발 안내 - 테스트 작성과 설계를 위한 대역 - 테스트 가능한 설계 방법 안내 - 유지

product.kyobobook.co.kr

 

1. 대역의 필요성

1-1. 왜 굳이 “대역”이 필요한가?

테스트 작성 → 통과할 만큼 구현 → 리팩터링 → 반복

그런데 아래 같은 외부 요인에 의존하는 코드가 나오면, 이 리듬이 깨짐

  • 파일 시스템 접근
  • DB 조회/저장
  • 외부 HTTP 서버 통신 (결제사 API, 카드사 API, FCM, S3 등)

예를 들면:

  • 자동이체 등록 기능에서 카드 유효성 검사를 외부 업체 API로 호출
  • 정상 카드 / 도난 카드 / 유효기간 만료 카드 등 다양한 경우를 테스트해야 함
  • 그런데 이 카드 정보는 외부 업체가 테스트용 번호를 제공해 줘야만 테스트 가능

문제는:

  • 외부 업체가 테스트 카드 번호를 안 줌 → TDD 자체가 막힘
  • 테스트 카드의 유효기간이 한 달 뒤라면,
    • 오늘은 테스트 통과
    • 한 달 뒤에는 유효기간 만료로 테스트 실패 → 테스트의 신뢰성 붕괴

즉, 외부 요인은 “테스트 코드 작성”도 어렵게 만들고, “테스트 결과 예측 가능성”도 망가뜨린다.

이 문제를 해결하기 위해 도입하는 게 대역(test double) 이고, 영어 문서에서 보는 “test double”의 “double”이 바로 ‘대역’

2. 대역을 이용한 테스트 – AutoDebit 예제

2-1. 기본 구조

class AutoDebitRegister {
    private final CardNumberValidator validator;          // 외부 API 호출
    private final AutoDebitInfoRepository repository;     // DB 저장

    public RegisterResult register(AutoDebitReq req) {
        CardValidity validity = validator.validate(req.getCardNumber());
        if (validity != CardValidity.VALID) {
            return RegisterResult.error(validity);
        }

        // DB에 자동이체 정보 저장
        // ...
        return RegisterResult.success();
    }
}

여기서 CardNumberValidator 는 외부 카드사 API를 호출한다고 가정.

  • 이 상태에서 바로 테스트를 짜면
    • 테스트 돌릴 때마다 진짜 카드사 서버를 치고,
    • 카드사 서버 장애나 네트워크 문제에 따라 테스트가 깨지고,
    • 다양한 카드 상태(도난, 만료 등)를 만들기도 어려움.

2-2. 스텁으로 외부 API를 갈아끼우기

그래서 실제 구현 대신, 스텁(Stub) 을 하나 새로 만들어서 끼운다.

class StubCardNumberValidator extends CardNumberValidator {

    private String invalidNo;
    private String theftNo;

    public void setInvalidNo(String invalidNo) {
        this.invalidNo = invalidNo;
    }

    public void setTheftNo(String theftNo) {
        this.theftNo = theftNo;
    }

    @Override
    public CardValidity validate(String cardNumber) {
        if (invalidNo != null && invalidNo.equals(cardNumber)) {
            return CardValidity.INVALID;
        }
        if (theftNo != null && theftNo.equals(cardNumber)) {
            return CardValidity.THEFT;
        }
        return CardValidity.VALID;
    }
}
  • 실제로 카드사 서버에 가는 게 아니라, 테스트에서 “이 번호는 INVALID, 저 번호는 THEFT” 라고 미리 지정해두고, 그에 맞는 enum을 리턴해 줄 뿐인 단순 구현.
@BeforeEach
void setUp() {
    stubValidator = new StubCardNumberValidator();
    stubRepository = new StubAutoDebitInfoRepository(); // 이것도 대역
    register = new AutoDebitRegister(stubValidator, stubRepository);
}

@Test
void invalidCard() {
    stubValidator.setInvalidNo("111122223333");

    AutoDebitReq req = new AutoDebitReq("user1", "111122223333");
    RegisterResult result = register.register(req);

    assertEquals(CardValidity.INVALID, result.getValidity());
}

효과:

  • 외부 업체가 카드를 어떻게 관리하든 상관없이, 테스트 내부에서 모든 상황을 마음대로 구성할 수 있음.
  • 네트워크, 시간, 카드사 장애에 영향 없이 TDD 리듬을 유지할 수 있음.

3. 대역의 종류 (Stub / Fake / Spy / Mock)

  1. 스텁(Stub)
    • 단순한 구현으로 대체
    • 테스트에 필요한 값만 리턴하면 됨
    • 예: 카드 유효성 검사 결과를 간단하게 enum으로만 돌려주는 StubCardNumberValidator
  2. 가짜(Fake)
    • 실제 동작은 하지만, 프로덕션에는 쓰기 애매한 간단 구현
    • 예: 진짜 DB 대신 Map<K,V> 를 사용하는 메모리 리포지토리
  3. 스파이(Spy)
    • 무엇이 호출됐는지 기록”하는 대역
    • 기록된 내용(메서드 호출 여부, 파라미터)을 가지고 결과를 검증
    • 예: 이메일 발송 기능이 실제로 불렸는지 확인하는 이메일 발송 스파이
  4. 모의 객체(Mock)
    • 스텁 + 스파이의 개념을 합친 것
    • 기대한 방식으로 상호작용했는지”를 검증
    • 기대와 다르면 예외를 던지거나 테스트를 실패시킴
    • Mockito 같은 라이브러리로 많이 사용

더미(Dummy)는 크게 강조하진 않지만, “그냥 파라미터 자리를 채우기 위한, 쓰이지 않는 객체” 정도로 이해

4. 예제별 대역 사용

4-1. 약한 암호 확인 기능에 스텁 사용

4-1-1. 요구사항 요약

  • 회원 가입 시, 비밀번호가 너무 단순하면 “약한 암호”로 판단해서 실패시켜야 함.
  • 나중에 실제 구현에서는 외부 암호 강도 서비스 호출, 혹은 관련 라이브러리 사용 등을 할 수도 있음.

하지만 TDD 단계에선:

  • “이 암호는 약하다 / 아니다”만 알면 됨
  • 아직 진짜 암호 강도 검사를 구현할 필요는 없음.

4-1-2. StubWeakPasswordChecker

그래서 암호 강도 체크를 위한 스텁을 하나 만듭니다.

class StubWeakPasswordChecker implements WeakPasswordChecker {

    private boolean weak;   // 이 스텁이 어떤 대답을 할지 설정

    public void setWeak(boolean weak) {
        this.weak = weak;
    }

    @Override
    public boolean isWeak(String password) {
        return weak;   // 진짜 로직 없이 설정된 값 반환
    }
}
@Test
void weakPassword_thenRegisterFail() {
    StubWeakPasswordChecker checker = new StubWeakPasswordChecker();
    checker.setWeak(true);                      // 이 테스트에선 약한 암호라고 응답

    UserRegister register = new UserRegister(checker, ...);

    assertThrows(WeakPasswordException.class, () ->
            register.register("id", "pw", "email"));
}

핵심 포인트:

  • 이 시점에는 “약한 암호 판정 알고리즘”은 나중 문제.
  • 지금 중요한 건 “약한 암호라고 판단되면 회원 가입이 막힌다” 라는 도메인 규칙을 TDD로 고정

4-2. 리포지토리를 가짜 구현(Fake)으로 사용

4-2-1. 상황

  • 회원 가입 기능이 UserRepository에 회원 정보를 저장한다고 가정.
  • 진짜 구현은 JPA / MyBatis / JDBC 등으로 DB에 저장.
  • 그런데 단위 테스트에서 실제 DB를 매번 연결/초기화하는 건 무겁고 느림.

4-2-2. MemoryUserRepository

그래서 책은 가짜(Fake) 리포지토리를 추천합니다.

class MemoryUserRepository implements UserRepository {

    private final Map<String, User> users = new HashMap<>();

    @Override
    public User findById(String id) {
        return users.get(id);
    }

    @Override
    public void save(User user) {
        users.put(user.getId(), user);
    }
}
  • 이 구현은 실제 DB를 쓰진 않지만, 리포지토리 인터페이스가 제공해야 할 기능을 그대로 구현함.
  • 테스트할 때는 이 가짜 리포지토리를 주입
@Test
void duplicateId_thenFail() {
    MemoryUserRepository fakeRepo = new MemoryUserRepository();
    fakeRepo.save(new User("id", "name", "pw"));   // 이미 존재하는 사용자 셋업

    UserRegister register = new UserRegister(..., fakeRepo);

    assertThrows(DupIdException.class, () ->
            register.register("id", "name2", "pw2"));
}

장점:

  • DB 설정/마이그레이션/트랜잭션 등을 신경 안 써도 됨
  • 테스트 속도가 빠르고, 코드가 직관적
  • 특히 DAO/Repository 계열은 Mock보다 Fake가 관리 측면에서 더 낫다고 강조합니다.

4-3. 이메일 발송 여부를 확인하기 위해 스파이 사용

4-3-1. 상황

  • 회원 가입에 성공하면 이메일 안내 메일을 보내야 한다고 가정.
  • UserRegister가 내부에서 EmailNotifier를 사용해 메일을 발송.

테스트에서 검증하고 싶은 것:

  • “회원 가입이 성공했을 때, 정말로 이메일이 발송됐는지
  • 그리고 “어떤 이메일 주소로 발송됐는지”

4-3-2. SpyEmailNotifier

class SpyEmailNotifier implements EmailNotifier {
    private boolean called;
    private String email;

    @Override
    public void sendRegisterEmail(String email) {
        this.called = true;
        this.email = email;
    }

    public boolean isCalled() {
        return called;
    }

    public String getEmail() {
        return email;
    }
}
@Test
void whenRegisterSuccess_thenSendEmail() {
    SpyEmailNotifier spy = new SpyEmailNotifier();
    UserRegister register = new UserRegister(..., spy);

    register.register("id", "pw", "email@test.com");

    assertTrue(spy.isCalled());
    assertEquals("email@test.com", spy.getEmail());
}

  • 스파이는 “실제로 메일을 보내는 척만 하고, 호출 내역을 기록”함.
  • 나중에 isCalled(), getEmail() 같이 기록된 내용을 꺼내서 결과를 검증.

5. 모의 객체(Mock)로 스텁과 스파이 대체

EmailNotifier mockNotifier = mock(EmailNotifier.class);

UserRegister register = new UserRegister(..., mockNotifier);
register.register("id", "pw", "email@test.com");

// 호출 여부/파라미터 검증
verify(mockNotifier).sendRegisterEmail("email@test.com");

특징:

  • mock()이 내부적으로 스텁+스파이 역할을 하는 대역 객체를 만들어 줌.
  • when(...).thenReturn(...) 형식으로 스텁 역할을, verify(...)로 스파이/행위 검증 역할을 수행.
  • Mock은 “기대한 상호작용을 했는지” 검증하는 데 특히 유용하지만, 너무 남발하면 테스트가 “구현 디테일에 과도하게 결합”

6. 상황/결과 확인을 위한 협업 대상(의존) 도출과 대역 사용

  • 외부 API, 메일 서버 등 직접 제어하기 힘든 요소가 있으면 테스트에서 상황 구성/결과 확인이 어렵다.
  • 그럼 어떻게 해야 하냐?
  1. 제어하기 힘든 외부 상황을 별도 타입으로 분리
    • 예: 카드사 API 호출 로직을 CardNumberValidator 인터페이스로 분리
    • 예: 메일 발송 로직을 EmailNotifier 인터페이스로 분리
  2. 테스트 코드에서, 이 별도 타입의 대역을 생성
    • Stub / Fake / Spy / Mock 중 적절한 것 선택
  3. 대역을 생성자의 의존성으로 주입
    • 생성자/세터/DI 컨테이너 등을 활용해 주입
  4. 대역을 이용해 상황 구성 + 결과 확인
    • 스텁: 상황을 고정
    • 스파이: 결과를 기록해서 검증
    • 가짜: DB 등 저장소 상태를 제어

이 패턴을 통해:

  • “테스트하기 어려운 외부 요소”가 곧 “인터페이스로 분리해야 할 협업 대상”이 된다는 시각을 강조

7. 대역과 개발 속도

실제 구현만으로 테스트하려고 하면 생기는 일들:

  • 도난 카드번호를 업체에서 받을 때까지 멍하니 대기
  • API가 비정상 응답을 주는 상황을 확인하려면, 업체가 환경을 바꿔 주기를 기다려야 함
  • 우편으로 가입 안내를 보내는 시스템이라면, “편지가 실제로 도착하는지”를 확인하려고 하루 이틀씩 기다릴 수도 있음
  • 약한 암호 검사 기능이 구현되기 전까지, 회원 가입 전체 기능 TDD가 막힘

이 모든 건 TDD의 “짧은 사이클”과 정면으로 충돌

대역을 쓰면,실제 구현이 없어도 다양한 상황을 시뮬레이션할 수 있고, 외부 팀/업체를 기다리지 않고 내 로컬에서 바로 테스트를 돌릴 수 있다.

  • 대역은 단순히 “테스트를 가능하게 한다”를 넘어, 협업 대기를 줄여서 개발 속도를 높이는 도구

8. 모의 객체를 과하게 사용하지 않기

8-1. Mock의 유혹

Mock 라이브러리는 정말 편하다

  • 클래스를 안 만들어도 되고,
  • mock() 한 줄이면 대역 생성 완료,
  • verify()로 호출 검증도 쉽게 가능.

그래서 “모든 의존성을 전부 Mock으로 때려버리고 싶은” 유혹이 강함.

8-2. 과도한 Mock 사용의 문제

책과 여러 블로그에서 공통으로 말하는 문제점들

  • 결과 검증 코드가 길고 복잡해진다.
    • 여러 mock을 설정하고 verify하다 보면 테스트 본문이 상호작용 정의 코드 덩어리가 되어버림.
  • 구현 세부 사항에 엄청 민감해진다.
    • 예: 서비스가 Repository A, B를 각각 한 번씩 호출하는 테스트를 만들었다면, 나중에 내부 구현을 리팩토링해서 호출 순서나 횟수가 살짝 바뀌어도 테스트가 깨짐.
  • 하나의 테스트에 여러 mock이 등장하면
    • 어떤 검증이 진짜 중요한지 파악하기 힘들고, 테스트 유지보수가 지옥이 된다.

DAO/Repository처럼 저장소 역할을 하는 의존성은Mock보다 메모리 기반 Fake 구현을 쓰는 게 훨씬 낫다.

8-3. 추천하는 방향

  • Mock은 “상호작용 자체가 중요”한 경우에만 조심스럽게 사용
    • 특정 메일이 꼭 발송돼야 하는지, 특정 이벤트, 메시지가 꼭 전송되는지 등.
  • DB Repository 캐시처럼 결과 상태가 더 중요한 의존성Fake(메모리 구현) 로 대체하는 것이 권장.
  • 단위 테스트에서 너무 많은 Mock을 쓰기보다, 소수의 핵심 Mock + 통합 테스트 조합을 쓰는 게 실용적