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

테스트주도 개발 시작하기 - 챕터 8. 테스트 가능한 설계

softmoca__ 2025. 11. 6. 21:14
목차

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:1로 연동하는 타입을 하나 만든다.
    • 예: AuthService 인터페이스 + ExternalAuthService 구현 (내부에서 AuthUtil 호출)
  2. 애플리케이션 코드는 이 인터페이스만 의존하게 만든다.
  3. 테스트에서는 이 인터페이스의 대역(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(헥사고날 아키텍처) 의 작은 버전

핵심정리

“테스트하기 어려운 코드”의 공통점은의존성을 바꿀 수 없고, 실행 결과가 환경/시간에 휘둘린다는 것.

  1. 테스트를 어렵게 만드는 패턴들
    • 하드 코딩된 경로 / IP / 포트
    • 의존 객체를 코드 안에서 직접 new
    • 정적 메서드 남발
    • 현재 시간, Random 등에 직접 의존
    • 여러 책임이 한 메서드/클래스에 뒤섞인 코드
    • 소켓 통신, 콘솔 IO, final/외부 클래스 직접 호출
  2. 테스트 가능한 설계로 바꾸는 5가지 리팩토링
    • 하드 코딩된 상수 → 생성자/세터/파라미터로 주입
    • 의존 대상 → 생성자/세터 DI로 교체 가능하게
    • 한 메서드에 섞인 역할 → 테스트하고 싶은 로직을 별도 타입/메서드로 분리
    • 시간/임의 값 → 인터페이스로 추상화(Clock, RandomGenerator 등)
    • 외부 라이브러리 → 감싸는 어댑터 타입을 만들고, 인터페이스를 대역으로 사용
  3. 이 모든 것의 공통 원칙
    • “교체 가능하게, 제어 가능하게 만들어라.”
    • 즉, 테스트에서 마음대로 상황을 만들고, 결과를 관찰할 수 있는 구조로 설계하라는 것.