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)
- 스텁(Stub)
- “단순한 구현으로 대체”
- 테스트에 필요한 값만 리턴하면 됨
- 예: 카드 유효성 검사 결과를 간단하게 enum으로만 돌려주는 StubCardNumberValidator
- 가짜(Fake)
- 실제 동작은 하지만, 프로덕션에는 쓰기 애매한 간단 구현
- 예: 진짜 DB 대신 Map<K,V> 를 사용하는 메모리 리포지토리
- 스파이(Spy)
- “무엇이 호출됐는지 기록”하는 대역
- 기록된 내용(메서드 호출 여부, 파라미터)을 가지고 결과를 검증
- 예: 이메일 발송 기능이 실제로 불렸는지 확인하는 이메일 발송 스파이
- 모의 객체(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, 메일 서버 등 직접 제어하기 힘든 요소가 있으면 테스트에서 상황 구성/결과 확인이 어렵다.
- 그럼 어떻게 해야 하냐?
- 제어하기 힘든 외부 상황을 별도 타입으로 분리
- 예: 카드사 API 호출 로직을 CardNumberValidator 인터페이스로 분리
- 예: 메일 발송 로직을 EmailNotifier 인터페이스로 분리
- 테스트 코드에서, 이 별도 타입의 대역을 생성
- Stub / Fake / Spy / Mock 중 적절한 것 선택
- 대역을 생성자의 의존성으로 주입
- 생성자/세터/DI 컨테이너 등을 활용해 주입
- 대역을 이용해 상황 구성 + 결과 확인
- 스텁: 상황을 고정
- 스파이: 결과를 기록해서 검증
- 가짜: 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 + 통합 테스트 조합을 쓰는 게 실용적
'외부활동 > 우아한테크코스 [프리코스]' 카테고리의 다른 글
| 테스트주도 개발 시작하기 - 챕터 9. 테스트 범위와 종류 (0) | 2025.11.06 |
|---|---|
| 테스트주도 개발 시작하기 - 챕터 8. 테스트 가능한 설계 (0) | 2025.11.06 |
| 테스트주도 개발 시작하기 - 챕터 6. 테스트 코드 구성 (0) | 2025.11.05 |
| 테스트주도 개발 시작하기 - 챕터 5. JUnit (0) | 2025.11.05 |
| 테스트주도 개발 시작하기 - 챕터 4. TDD, 기능명세, 설계 (0) | 2025.11.05 |