단위테스트로 설계 품질과 리팩토링 안전망 만들기
단위테스트의 단위 경계, 테스트 더블, 인자실오 기법과 CI 게이팅 운영 기준을 실무 관점에서 정리합니다.
2026-08-14 · 최초 발행 2025-12-14
코드 변경을 안전하게 만드는 최소 검증 단위
단위테스트는 테스트 가능한 최소 모듈 내부에서 결함을 찾고 기능을 검증하는 활동이다. 개발자가 코드의 내부 로직을 확인하는 화이트박스 테스트이며, 대상은 순수 함수, 메서드, 클래스, 작은 도메인 서비스처럼 독립 실행할 수 있는 단위가 된다.
경계를 정할 때는 외부 시스템과의 I/O, 네트워크, DB를 분리한다. 이런 의존성은 테스트 더블로 격리하고, 검증 대상 모듈의 입력과 출력에 집중한다. 이 과정은 코드의 가시성을 높이고 모듈의 명세를 분명하게 하며, 리팩토링 시 회귀를 막는 안전망이 된다. 결함을 이른 시점에 발견해 수정 비용을 줄이는 효과도 함께 기대할 수 있다.
테스트 코드가 설계에 주는 피드백
테스트 케이스는 내부 로직, 분기, 경계값을 기준으로 설계한다. 잘 작성된 테스트 코드는 실행 가능한 명세(Executable Specification)로 기능하며, 구현과 기대 동작 사이의 계약을 드러낸다.
테스트하기 쉬운 코드는 대체로 순수 함수화, 의존성 주입(DI), 인터페이스 분리(ISP)를 요구한다. 반대로 테스트가 어려운 지점은 결합도가 지나치게 높거나 응집도가 낮고, 복잡도가 커졌다는 신호일 수 있다. 실패한 테스트는 이런 설계 문제를 비교적 이르게 드러낸다.
외부 의존성은 Mock, Stub, Fake, Spy 같은 테스트 더블로 대체할 수 있다. 격리된 환경에서는 실행 결과를 결정론적으로 유지하기 쉽고, 피드백 루프도 빨라진다. 커밋을 계기로 테스트를 실행하고 병렬화와 캐시를 활용하면 CI 파이프라인에서 실패한 빌드를 차단하거나 커버리지 기준 미달을 경고 또는 실패 규칙으로 연결할 수 있다.
커버리지에는 라인·브랜치·조건 커버리지와 복잡도 지표를 함께 본다. 라인 커버리지는 70~80% 이상을 권장 기준으로 삼을 수 있고, 핵심·위험 로직의 브랜치는 100%를 목표로 둔다. 다만 커버리지 수치만 높이는 방식으로는 충분하지 않다.
인자실오로 테스트 대상을 빠뜨리지 않기
인자실오는 단위테스트 케이스를 검토할 때 사용할 수 있는 관점이다.
- 인터페이스 테스트(인)는 공개 메서드와 함수 시그니처 준수, 사전·사후 조건을 확인한다. 계약 기반(Design by Contract)을 전제로 입력 유효성과 출력 형식을 검증한다.
- 자료구조 테스트(자)는 컬렉션, 맵핑, 도메인 엔티티의 불변식과 정렬·해시 동작을 다룬다. 빈 값, 한계치, 대용량 같은 경계값과 직렬화·역직렬화 호환성도 점검한다.
- 실행경로 테스트(실)는 조건, 분기, 예외 경로를 포괄한다. Cyclomatic Complexity를 기준으로 케이스를 설계하고 Feature Flag와 구성 옵션별 경로도 포함한다.
- 오류처리 테스트(오)는 예외, 재시도, 타임아웃, 폴백 동작을 검증한다. 로깅 레벨, 에러 코드, 사용자 메시지의 일관성도 확인 대상이다.
AAA 흐름으로 실행하고 CI에서 확인한다
로직 경계가 뚜렷한 곳에서의 활용
백엔드에서는 수수료, 한도, 라운딩 규칙이나 통화·세금 계산 로직을 독립적으로 검증할 수 있다. 저장소와 외부 API는 Mock으로 처리하고 경계값과 오류 경로를 함께 포함한다.
프론트엔드에서는 포맷터, 검증기(validator), 상태 리듀서처럼 DOM 의존성을 제거할 수 있는 순수 로직이 대상이 된다. 순수 함수화는 빠른 실행에도 도움이 된다.
데이터 파이프라인에서는 파서와 변환 로직을 대상으로 스키마 유효성, 누락·이상치 처리, 타임존·로케일 변환을 검증한다. 대용량 샘플과 퓨즈(시간 제한)를 적용해 결정론을 보장할 수 있다.
레거시 코드에는 Characterization Test로 현재 동작을 먼저 고정한 뒤 리팩토링을 진행한다. 의존성을 점진적으로 분리하고 커버리지를 넓혀가는 방식이 보호망을 만드는 데 적합하다.
pytest로 도메인 로직과 의존성 분리 검증하기
전제조건은 Python 3.11+, pytest 7.4+이며 설치 명령은 다음과 같다.
pip install pytest
도메인 로직 예시:
# billing.py
def calc_fee(amount: int, tier: str) -> int:
if amount < 0:
raise ValueError("amount must be non-negative")
rates = {"STANDARD": 0.015, "VIP": 0.005}
rate = rates.get(tier, 0.02)
fee = int(round(amount * rate))
return max(fee, 100) # 최소 수수료 100원 보장
단위테스트:
# test_billing.py
import pytest
from billing import calc_fee
@pytest.mark.parametrize("amount,tier,expected", [
(0, "STANDARD", 100), # 최소 수수료
(10_000, "STANDARD", 150), # 1.5%
(10_000, "VIP", 50), # 최소 수수료 적용 전 라운딩 -> 50, 이후 max로 100
(1_000_000, "UNKNOWN", 20000), # 기본 2%
])
def test_calc_fee_normal(amount, tier, expected):
assert calc_fee(amount, tier) == expected
def test_calc_fee_negative_amount():
with pytest.raises(ValueError):
calc_fee(-1, "STANDARD")
외부 서비스 호출을 가정한 의존성은 다음처럼 분리할 수 있다.
# discount.py
class TierService:
def get_tier(self, user_id: str) -> str: # 외부 서비스 호출 가정
raise NotImplementedError
def discounted_price(price: int, user_id: str, svc: TierService) -> int:
tier = svc.get_tier(user_id)
return int(price * (0.9 if tier == "VIP" else 1.0))
# test_discount.py
from unittest.mock import Mock
from discount import discounted_price
def test_discounted_price_with_mock():
svc = Mock()
svc.get_tier.return_value = "VIP"
assert discounted_price(10000, "u-1", svc) == 9000
svc.get_tier.assert_called_once_with("u-1")
이 예시는 AAA 패턴으로 입력 준비, 실행, 검증을 구분한다. 외부 의존성은 Mock으로 격리하고, 경계값·예외·분기를 포괄하며 명세화 목적의 테스트 명명 규칙을 적용한다.
단위·통합·E2E 테스트의 역할 차이
| 항목 | 단위테스트 | 통합테스트 | E2E 테스트 |
|---|---|---|---|
| 결함 위치 추적 용이성 | 매우 높음 | 중간 | 낮음 |
| 실행 속도/비용 | 매우 빠름/낮음 | 보통 | 느림/높음 |
| 격리 수준 | 매우 높음 | 중간 | 낮음 |
| 신뢰 범위 | 로직 수준 | 컴포넌트 간 상호작용 | 사용자 시나리오 |
| 운영 편의 | 병렬·캐시 용이 | 환경 구성 필요 | 환경·데이터 복잡 |
단위테스트는 결함 위치를 추적하기 쉽고 실행 속도와 비용 측면에서 유리하다. 반면 컴포넌트 간 상호작용은 통합테스트, 사용자 시나리오는 E2E 테스트가 담당한다.
품질 지표를 운영 규칙으로 연결하는 방법
단위 결함은 6080% 사전 제거를 기대할 수 있다. PR 단위로 13분 내 피드백 루프를 구현하고, 실패 테스트의 재현성을 확보하면 평균 복구 시간(MTTR)을 20~40% 단축할 수 있다.
운영에서는 단위 경계를 정의한 뒤 테스트 더블 전략을 세우고, AAA 패턴으로 케이스를 작성한다. 이후 CI 자동화와 게이팅을 연결하고 커버리지·품질 지표를 점검한다.
라인 커버리지는 75%+, 핵심 경로 브랜치는 100% 목표로 설정할 수 있다. 커버리지 미달 규칙은 경고에서 차단으로 단계적으로 도입한다. 테스트 명명과 구조는 given_when_then 형식으로 표준화하고, 한 테스트당 한 가지 단언 원칙을 준수한다.
시간, 랜덤, 동시성에 의존하는 Flaky 테스트는 허용하지 않는다. Clock/Random 주입 패턴으로 이런 의존성을 제거하고, 데이터 픽스처는 최소화한다. 필요하면 Test Data Builder나 Factory를 적용한다. 이런 규칙은 명세화와 가독성을 높이고 신규 인력의 온보딩을 돕는 한편, 안전한 리팩토링과 기술부채 축소, DI·SRP·낮은 결합도의 정착으로 이어진다.