https://product.kyobobook.co.kr/detail/S000001248962
테스트 주도 개발 시작하기 | 최범균 - 교보문고
테스트 주도 개발 시작하기 | 작동하는 깔끔한 코드를 만드는 데 필요한 습관 - JUnit 5를 이용한 테스트 주도 개발 안내 - 테스트 작성과 설계를 위한 대역 - 테스트 가능한 설계 방법 안내 - 유지
product.kyobobook.co.kr
1. 테스트가 어려운 코드
1-1. 하드 코딩된 경로 / IP / 포트
- 이 테스트를 실행하려면 해당 OS·경로에 파일이 실제로 있어야 함.
- 로컬 경로가 다르면 바로 실패.
- CI 서버/다른 OS(리눅스, 맥)에서는 아예 그 드라이브가 없을 수도 있음.
- 비슷하게, IP, 포트를 코드에 박아두면 테스트 환경, 운영 환경, 로컬 환경을 다르게 구성하기 힘들어짐.
핵심: 환경에 따라 달라져야 할 값이 코드에 박혀 있으면 테스트하기 어렵다.
1-2. 의존 객체를 직접 생성 (new 박아두기)
- 이 코드를 테스트하려면 특정 클래스가 기대대로 동작하는 환경까지 다 준비해야 함.
- DB 연결 정보 설정
- 테이블 생성, 데이터 정리
- 테스트 돌릴 때마다 DB에 데이터 insert → 롤백/정리 안 하면 다음 테스트에서 충돌
- “단위 테스트”를 하고 싶은데, 이미 DB 통합 테스트 느낌이 돼버림.
결국 이건 설계 관점에서: “테스트 대상이 자기 의존성을 스스로 new 해서 꽁꽁 숨겨버렸다” = 대역으로 교체도 못 하고, 테스트가 DB에 종속되는 구조.
1-3. 정적 메서드 사용
public class LoginService {
private String authKey = "somekey";
private CustomerRepository customerRepo;
public LoginService(CustomerRepository customerRepo) {
this.customerRepo = customerRepo;
}
public LoginResult login(String id, String pw) {
boolean authorized = AuthUtil.authorize(authKey);
int resp = AuthUtil.authenticate(id, pw);
...
}
}
- AuthUtil이 인증 서버와 통신하는 유틸이라면, 테스트 시점마다 인증 서버가 떠 있어야 하고 서버 주소/포트도 시스템 프로퍼티 등으로 맞춰 줘야 함.
- 정적 메서드는 인터페이스로 뺄 수가 없어서 대역으로 바꾸기 더 힘듦.
- 정적 메서드를 뭔가 “편한 도구”처럼 쓰다 보면, 테스트 관점에서는 의존성이 굳어져서 교체 불가능한 블랙박스가 되기 쉽다.
1-4. 실행 시점에 따라 달라지는 결과 (시간 / Random 등)
public int calculatePoint(User u) {
Subscription s = subscriptionDao.selectByUser(u.getId());
if (s == null) throw new NoSubscriptionException();
Product p = productDao.selectById(s.getProductId());
LocalDate now = LocalDate.now(); // 실행 시점에 의존
int point = 0;
if (s.isFinished(now)) {
point += p.getDefaultPoint();
} else {
point += p.getDefaultPoint() + 10;
}
...
return point;
}
- 어제까지는 통과하던 테스트가 오늘부터 깨질 수 있음.
- 시간이 지나면 s.isFinished(now) 결과가 바뀌고, 포인트 계산 결과도 달라짐.
- Random/UUID도 실행할 때마다 다른 값을 만들어 내서 테스트가 예측불가능해짐.
테스트는 “언제, 어디서 돌려도 같은 결과”가 나와야 하는데 시각/랜덤에 직접 의존하면 그 특징이 깨짐
1-5. 역할이 섞여 있는 코드
- UserPointCalculator가
- SubscriptionDao에서 구독 정보 조회
- ProductDao에서 상품 조회
- 현재 시간으로 만료 여부 계산
- 등급별 추가 포인트 계산
- 결과 리턴
즉, 하나의 메서드 안에 “포인트 계산 로직” + “DB 조회” + “시간” 이 한데 뒤섞여 있음.
- “포인트 계산 로직이 맞는지”만 테스트하고 싶은데, 그 전에 SubscriptionDao, ProductDao에 대한 대역부터 만들어야 함.
- 지금은 포인트 계산이 이 두 Dao에 강하게 묶여 있어서,“포인트만 떼어서 테스트”하기가 어려운 구조.
“역할이 섞인 코드일수록 ‘일부 로직만 떼어내서 테스트’하기가 기하급수적으로 힘들어진다.”
1-6. 그 외 자주 보는 “테스트 빡센 코드”들
- 메서드 중간에 소켓 통신 코드가 들어 있음
- 콘솔 입력/출력을 직접 사용 (System.in, System.out.println)
- 테스트 대상이 사용하는 클래스/메서드가 final 이어서 대역으로 바꾸기가 어려움
- 소스를 내가 소유하지 않은 라이브러리를 직접 호출하고 있어서 수정/래핑이 어려운 경우
“테스트 코드 입장에서 보면, 제어할 수 없고 교체하기도 어려운 의존성”이 붙어 있는 코드들.
2. 테스트 가능한 설계 – 5가지 해결 패턴
공통 원인 한 줄 요약
“테스트가 어려운 주된 이유는 의존하는 코드를 교체할 수 있는 수단이 없기 때문이다.”
그래서 모든 해결책의 방향은 결국 하나“교체 가능하게 만들어라.”
2-1. 하드 코딩된 상수를 생성자·세터·파라미터로 받기
2-1-1. 문제: 경로가 코드에 박혀 있음
앞서 PaySync 예제처럼 경로가 코드에 박혀 있으면, 테스트 환경에 맞는 경로로 바꾸는 게 불가능.
2-1-2. 해결 1: 필드 + 세터로 바꾸기
public class PaySync {
private PayInfoDao payInfoDao = new PayInfoDao();
private String filePath = "D:\\\\data\\\\pay\\\\cp0001.csv";
public void setFilePath(String filePath) {
this.filePath = filePath;
}
public void sync() {
Path path = Paths.get(filePath);
...
}
}
PaySync paySync = new PaySync();
paySync.setFilePath("src/test/resources/pay/test.csv");
paySync.sync();
// 결과 검증
- 이제 테스트가 파일 경로를 원하는 값으로 주입할 수 있음.
2-1-3. 해결 2: 메서드 파라미터로 받기
public void sync(String filePath) {
Path path = Paths.get(filePath);
...
}
paySync.sync("src/test/resources/pay/test.csv");
- 테스트에서 사용하는 파일은 소스/리포지토리에 같이 커밋해야 여럿이 공유 가능.
2-2. 의존 대상을 주입받기 (DI)
2-2-1. 문제: new 박혀 있어서 대역으로 못 바꿔 끼움
public class PaySync {
private PayInfoDao payInfoDao = new PayInfoDao();
...
}
이 상태에서는 PayInfoDao를 InMemory 구현이나 Stub/Fake로 교체할 수 없음.
2-2-2. 해결 1: 생성자 주입
public class PaySync {
private final PayInfoDao payInfoDao;
public PaySync(PayInfoDao payInfoDao) {
this.payInfoDao = payInfoDao;
}
public void sync() { ... }
}
테스트에서는 원하는 구현체 전달
PayInfoDao fakeDao = new InMemoryPayInfoDao();
PaySync paySync = new PaySync(fakeDao);
2-2-3. 해결 2: 세터 주입 (레거시 대응)
레거시 코드에 생성자가 이미 많이 쓰이고 있다면, 기존 생성자는 유지하고 세터로만 대역 교체를 허용하는 방식도 가능.
public class PaySync {
private PayInfoDao payInfoDao = new PayInfoDao();
public void setPayInfoDao(PayInfoDao payInfoDao) {
this.payInfoDao = payInfoDao;
}
}
- 기존 코드는 그대로 사용.
- 테스트나 일부 구성 코드에서만 setPayInfoDao()로 바꿔 끼움.
“의존 대상을 new 하지 말고, 밖에서 넣게 만들어라.”
2-3. 테스트하고 싶은 코드를 분리하기 (역할 분리)
2-3-1. 문제: 도메인 로직 + 인프라가 뒤섞인 메서드
포인트 계산 예제에서:
- DB 조회 (SubscriptionDao, ProductDao)
- 현재 시간 조회 (LocalDate.now())
- 포인트 계산 규칙
전부 한 메서드 안에 섞여 있으면, 순수 포인트 계산만 테스트해 보고 싶어도 DB와 시간 의존까지 다 준비필요
2-3-2. 해결: 계산 로직만 별도의 타입/메서드로 추출
class UserPointCalculator {
private final SubscriptionDao subscriptionDao;
private final ProductDao productDao;
private final Clock clock; // 시간도 추상화했다고 가정
public int calculatePoint(User u) {
Subscription s = subscriptionDao.selectByUser(u.getId());
Product p = productDao.selectById(s.getProductId());
LocalDate now = clock.today();
return PointRule.calculate(s, p, now);
}
}
class PointRule {
static int calculate(Subscription s, Product p, LocalDate now) {
int point = ...
return point;
}
}
- PointRule.calculate() 만 순수하게 테스트할 수 있음 (Subscription/Product를 테스트용 인스턴스로 직접 생성)
- 혹은 UserPointCalculator에 대해 테스트하면서 SubscriptionDao, ProductDao, Clock는 각각 Stub/Fake로 주입.
“테스트하고 싶은 핵심 로직을 입출력이 명확한 작은 단위로 분리하면 테스트 준비(상황 셋업) 비용이 확 줄어든다.”
2-4. 시간이나 임의 값 생성 기능 분리하기
2-4-1. 문제: LocalDate.now(), new Date(), Random 등
- 이 값들은 호출 시점마다 결과가 달름
- 테스트가 “오늘은 통과, 내일은 실패” 같은 상태가 되어 버림.
2-4-2. 해결: Clock / RandomGenerator 인터페이스 추출
public interface Clock {
LocalDate today();
}
public class SystemClock implements Clock {
@Override
public LocalDate today() {
return LocalDate.now();
}
}
class UserPointCalculator {
private final Clock clock;
public UserPointCalculator(Clock clock) {
this.clock = clock;
}
public int calculatePoint(...) {
LocalDate now = clock.today();
...
}
}
class FixedClock implements Clock {
private final LocalDate fixed;
public FixedClock(LocalDate fixed) { this.fixed = fixed; }
public LocalDate today() { return fixed; }
}
- 테스트 시에는 new FixedClock(LocalDate.of(2025,1,1)) 등을 주입해서 시간을 고정해 버림.
- Random/UUID도 똑같이 RandomValueGenerator 인터페이스를 만들고, 실제 구현에서는 Random 쓰고, 테스트 구현에서는 고정된 값을 리턴.
2-5. 외부 라이브러리는 직접 사용하지 말고 감싸서 사용하기
2-5-1. 문제: 대역으로 바꾸기 어려운 외부 라이브러리
- 정적 메서드만 있는 외부 유틸 (AuthUtil.authorize(...))
- final 클래스나, 팩토리 메서드로만 객체 만드는 라이브러리
- 내가 소유하지 않은 코드라 수정도 불가능
2-5-2. 해결: “어댑터” 타입을 하나 둔다
- 외부 라이브러리와 1:1로 연동하는 타입을 하나 만든다.
- 예: AuthService 인터페이스 + ExternalAuthService 구현 (내부에서 AuthUtil 호출)
- 애플리케이션 코드는 이 인터페이스만 의존하게 만든다.
- 테스트에서는 이 인터페이스의 대역(Stub/Mock) 을 사용한다.
public interface AuthService {
int authenticate(String id, String pw);
}
public class ExternalAuthService implements AuthService {
public int authenticate(String id, String pw) {
return AuthUtil.authenticate(id, pw); // 외부 라이브러리
}
}
public class LoginService {
private final AuthService authService;
...
}
class StubAuthService implements AuthService {
private int result;
public void setResult(int result) { this.result = result; }
public int authenticate(String id, String pw) { return result; }
}
“내 코드(도메인/서비스) 와 외부 라이브러리 사이에 얇은 레이어(어댑터)를 두고, 그 인터페이스를 대역으로 바꿔라.”
이는 사실상 Ports & Adapters(헥사고날 아키텍처) 의 작은 버전
핵심정리
“테스트하기 어려운 코드”의 공통점은의존성을 바꿀 수 없고, 실행 결과가 환경/시간에 휘둘린다는 것.
- 테스트를 어렵게 만드는 패턴들
- 하드 코딩된 경로 / IP / 포트
- 의존 객체를 코드 안에서 직접 new
- 정적 메서드 남발
- 현재 시간, Random 등에 직접 의존
- 여러 책임이 한 메서드/클래스에 뒤섞인 코드
- 소켓 통신, 콘솔 IO, final/외부 클래스 직접 호출
- 테스트 가능한 설계로 바꾸는 5가지 리팩토링
- 하드 코딩된 상수 → 생성자/세터/파라미터로 주입
- 의존 대상 → 생성자/세터 DI로 교체 가능하게
- 한 메서드에 섞인 역할 → 테스트하고 싶은 로직을 별도 타입/메서드로 분리
- 시간/임의 값 → 인터페이스로 추상화(Clock, RandomGenerator 등)
- 외부 라이브러리 → 감싸는 어댑터 타입을 만들고, 인터페이스를 대역으로 사용
- 이 모든 것의 공통 원칙
- “교체 가능하게, 제어 가능하게 만들어라.”
- 즉, 테스트에서 마음대로 상황을 만들고, 결과를 관찰할 수 있는 구조로 설계하라는 것.
'외부활동 > 우아한테크코스 [프리코스]' 카테고리의 다른 글
| 테스트주도 개발 시작하기 - 챕터 10~11. 테스트 코드와 유지 보수 & 마무리 (0) | 2025.11.08 |
|---|---|
| 테스트주도 개발 시작하기 - 챕터 9. 테스트 범위와 종류 (0) | 2025.11.06 |
| 테스트주도 개발 시작하기 - 챕터 7. 대역 (0) | 2025.11.06 |
| 테스트주도 개발 시작하기 - 챕터 6. 테스트 코드 구성 (0) | 2025.11.05 |
| 테스트주도 개발 시작하기 - 챕터 5. JUnit (0) | 2025.11.05 |