-
테스트 커버리지 11개 프로젝트에 도입하기 — baseline-lock + 외부 wrapper 제외 전략IT 2026. 4. 28. 23:30
왜 또 이걸 하나
내 홈 서버에는 개인 프로젝트 11~12개가 돌아간다. 지난 며칠간 차례로:
- ruff로 린트 경고를 0으로
- mypy로 타입 에러를 0으로
- 둘 다 Stop/pre-commit hook에 걸어서 재발 방지
그다음 자연스럽게 떠오른 질문이 있었다. "테스트는 얼마나 충실히 쓰고 있나?"
지금까지 나는 프로젝트마다 pytest로 테스트를 쓰고 있었다. 총 500~600건. 각각 초록불이 떠야만 기능 개발을 이어갔다. 그런데 정작 어디까지 실제로 실행됐는지는 한 번도 측정하지 않았다. coverage 도구(
pytest-cov,coverage.py)가 전부 설치조차 되어 있지 않았다.직관적으로는 "적당히 커버하고 있을 것 같다". 하지만 직관은 틀릴 때가 많다. 그래서 확인해보기로 했다.
1단계 — 일단 수치를 본다
전역에
pytest-cov를 설치하고, 11개 프로젝트 각각에 간단한 설정을 넣었다:[tool.pytest.ini_options] addopts = "--cov --cov-report=term-missing --cov-fail-under=<N>" [tool.coverage.run] source = ["."] branch = true omit = ["tests/*", ".venv/*"]여기서 중요한 선택이 하나 있다.
source = ["."]을 명시한 것. 이 한 줄이 없으면 pytest가 import한 파일만 측정된다. 테스트가 건드리지 않은 모듈은 카운트에서 통째로 빠진다. 낙관적인 숫자가 나올 수밖에 없다.source를 명시하면 프로젝트 전체가 분모에 들어간다. 정직한 수치가 나온다. 초기 측정 결과:프로젝트 성격 baseline 작은 aiohttp 서버 프로젝트 A (단일 파일) 75% 작은 aiohttp 서버 프로젝트 B (관측/대시보드) 74% aiohttp 프록시 프로젝트 C (로컬 LLM) 60% LangGraph 에이전트 프로젝트 D 49% 이미지 처리 파이프라인 프로젝트 E 43% 음성 전사 파이프라인 프로젝트 F 34% 텔레그램 게이트웨이 프로젝트 G 31% 사진 분석 프로젝트 H (외부 API 의존) 20% 블로그 자동 발행 프로젝트 I (Playwright) 18% RAG 시스템 프로젝트 J (Qdrant/vLLM) 14% 뒤쪽 프로젝트 몇 개를 보고 이런 생각이 들었다. "이 숫자가 정말 우리 코드의 건강도인가?"
2단계 — 숫자가 거짓말을 하고 있었다
프로젝트 I(Playwright 기반 블로그 자동화) 18%를 자세히 들여다보니:
publisher.py— 423줄, 커버리지 4%. Playwright로 티스토리를 조작하는 브라우저 자동화 코드.thumbnail_renderer.py— 36줄, 22%. SVG를 PNG로 렌더하는 Playwright 코드.
이 두 파일의 합이 459줄. 전체 875줄 중 절반을 차지한다. 그리고 이 파일들은 테스트할 방법이 명확하지 않다. mock으로 "Playwright 호출이 일어났다"를 검증해봐야 mock이 호출됐음을 확인하는 것 외에 아무 의미가 없다. 실제 브라우저 자동화 성공 여부는 스모크 테스트로만 보장 가능하다.
RAG 시스템(프로젝트 J) 14%도 마찬가지였다. Qdrant 클라이언트, vLLM 임베딩, Ollama LLM 래퍼 — 이들의 총합이 전체의 60%를 넘었다.
결론: 외부 서비스와 직접 맞닿는 파일은 커버리지 측정에서 빼야 한다. mock으로 시늉하는 테스트를 쓰는 건 문서만 늘리고 가치는 없다. 대신 우리가 짠 로직에만 커버리지를 집중시킨다.
omit 대상 — 판단 기준
각 프로젝트의 파일을 하나씩 살펴보며 omit 목록을 만들었다:
제외한다 유지한다 외부 API 클라이언트 (Immich/Ollama/Qdrant 래퍼) 설정·검증·변환 로직 Playwright 브라우저 조작 코드 CLI 디스패처·파일 선정 로직 ffmpeg/weasyprint subprocess 래퍼 템플릿 렌더링 로직 (순수) GPU 모델 래퍼 (torch 로딩) 파이프라인 오케스트레이션 판단 기준 한 줄: "이 파일을 mock으로 테스트해봐야 mock이 호출됐다는 사실 외에 검증할 게 없다면 제외한다."
11개 프로젝트에 omit을 추가한 뒤 재측정했다:
프로젝트 외부 포함 외부 제외 (first-party) 변화 J (RAG) 14% 45% +31 I (블로그 자동화) 18% 30% +12 H (사진 분석) 20% 27% +7 E, D 등 43, 49% 43, 52% +0~3 숫자가 움직인 건 단순히 계산 방식을 바꿨을 뿐이다. 코드 자체는 한 글자도 수정하지 않았다. 그런데 이 숫자가 훨씬 정직하다. "우리가 짠 로직"이 얼마나 테스트되고 있는지를 보여준다.
3단계 — 큰 0% 파일 몇 개에 테스트 추가
외부 wrapper를 걷어내고 나니, 남은 0% 짜리 파일들이 더 선명하게 보였다. 외부 의존 없이 순수 로직인데 테스트가 전혀 없는 파일들. 이런 건 ROI가 극명하다:
프로젝트 J의
validator.pyRAG 청크 유효성 검증 모듈. 202줄. 전부 정규식 + 문자열 처리. 외부 의존 0개. 그런데 커버리지 0%였다. 47개의 테스트를 새로 쓰고 91%를 달성했다.
def test_sync_stub_rejected(): text = ( "# 일정 제목\n" "*Synced from Google Calendar*\n" "[원본 링크](https://calendar.google.com/event/abc)\n" ) r = validate_chunk(text, strict=True) assert not r.valid assert "sync_stub" in r.reasons이런 테스트 47개. 한 번 앉아서 쓸 수 있는 분량이고, 이걸로 프로젝트 J 전체가 45% → 55%가 됐다.
프로젝트 I의
selector.pyvault 노트 스캔/필터링/정렬 로직. 53줄, 0%.
unittest.mock.patch로 config 경로를tmp_path에 넣고 가짜 md 파일을 뿌려서 테스트했다. 12개 테스트, 0% → 96%.작은 것들
비슷한 방식으로:
- 프로젝트 J
preprocessor.py: 9% → 90% - 프로젝트 J
metadata.py: 15% → 100% - 프로젝트 D
parser.py(ReAct 텍스트 파서): 0% → 100% - 프로젝트 H
onedrive_sha1_verify.py(순수 함수 파트): 0% → 40% - 프로젝트 H
xlsx_to_mdtable.py(변환 로직): 0% → 38%
모든 테스트는 외부 서비스를 전혀 건드리지 않는다. subprocess는 mock, 파일 시스템은
tmp_path, config는patch.object. 실행은 수 초 내로 끝난다.4단계 — baseline-lock 전략
숫자를 올리기 시작하니 또 다른 고민이 왔다. "목표 기준을 얼마로 잡지?"
커뮤니티의 전형적인 권장은:
- Google 내부 가이드: 60% acceptable / 75% commendable / 90% exemplary
- 오픈소스 관행: 80%가 사실상의 표준
- Martin Fowler: "80~90%가 합리적, 단 수치 자체에 집착 말 것"
하지만 내 상황에 그대로 적용하기엔 문제가 있었다. 프로젝트마다 커버리지 현황이 30%부터 75%까지 널뛰는데, 전부 "80% 이상"으로 맞추려면 30%짜리 프로젝트엔 수십 시간 분량의 테스트 작성이 필요하다. 그리고 그 시간이 그만한 가치가 있나를 물어보면, 솔직히 아니다. 이건 개인 프로젝트이고 대부분은 외부 서비스 통합이 본업이다.
그래서 다른 전략을 택했다. baseline-lock.
각 프로젝트의 현재 커버리지를 floor로 잡는다. 증가는 장려하되 강제하지 않는다. 그 이하로 떨어지면 차단한다.
구체적으론 각
pyproject.toml에 이렇게 박아 넣었다:addopts = "--cov --cov-fail-under=53" # 프로젝트별로 2%p 버퍼를 둔 숫자이 한 줄이면
pytest가 실행될 때마다 커버리지가 53% 이하면 exit non-zero. 그 이상이면 통과.왜 2%p 버퍼인가? 측정 노이즈 때문이다. skip 테스트 하나 추가/제거되면 퍼센트가 1~2 움직일 수 있다. 버퍼 없이 정확한 baseline을 박으면 작은 변동에도 훅이 빨간 불을 켠다. 2%p는 "의미 있는 후퇴"와 "노이즈"를 구분하는 완충이다.
5단계 — Hook이 지킨다
커버리지 기준을
pyproject.toml에만 두면 개발자가 까먹을 수 있다. "그냥 pytest가 실패했네, skip하자"는 유혹도 있다. 그래서 ruff/mypy와 동일한 패턴으로 Hook을 붙였다.파이프라인은 네 개의 훅 이벤트에 걸린다:
# ~/scripts/hooks/pipelines/coverage.py TRACKED_PROJECTS = {...11개...} def track_edit(file_path, data): # 대상 프로젝트 .py 편집 감지 → dirty 세트에 등록 ... def track_bash(cmd, data): # pytest 실행이 exit 0으로 끝나면 dirty 해제 # (addopts의 --cov-fail-under가 자연 검증) ... def pre_commit(cmd, data): # git commit 직전, dirty 프로젝트가 커맨드에 포함돼 있으면 # pytest 재실행. threshold 미달이면 commit 차단. ... def check_completion(state): # 세션 종료(Stop) 시점, dirty 프로젝트마다 pytest 실행. # threshold 미달이면 Stop 차단. ...동작 흐름은 간단하다:
.py를 수정했다 → 해당 프로젝트가 "더러움"으로 찍힌다 → 내가 직접 pytest를 돌려 통과시키거나 그냥 세션을 끝내려 하면 훅이 pytest를 대신 돌려본다 → 커버리지가 baseline 밑이면 빨간 불.프로젝트마다 자체
.venv가 있는 경우(GPU/외부 라이브러리 때문에) 훅이 자동으로 알맞은 인터프리터를 고른다:VENV_PROJECTS = {"F", "G", "J"} # 자체 venv 필요한 프로젝트 def _python_for(project): if project in VENV_PROJECTS: return [str(PROJECTS_ROOT / project / ".venv" / "bin" / "python3")] return ["python3"]최종 현황
11개 프로젝트의 first-party 커버리지 baseline:
프로젝트 baseline fail_under A 75% 73 B 74% 71 C 60% 58 J 55% 53 D 53% 51 E 43% 41 F 36% 34 H 33% 31 (나머지) 30~32% 28~30 평균 ~47%. 상위 3개(A, B, C)는 커뮤니티 기준의 "편안한 구간"에 들어와 있다. 중간 4개(J, D, E, F)는 50% 전후에서 안정화. 하위 4개는 30% 전후에 머문다. 하위 4개는 대부분 외부 서비스 통합이 본업인 프로젝트라 이 숫자가 "부끄러운 낮음"이 아니라 "정직한 수치"다.
왜 여기서 멈추나
이제부터 숫자를 더 올리려면 투자 대비 수익이 급격히 떨어진다. 남은 미커버 코드는 대부분:
- 무거운 mocking이 필요한 오케스트레이션 코드
- aiohttp HTTP 라우트 핸들러 (test client 셋업 비용)
- 특수한 조건에만 실행되는 에러 처리 분기
이런 영역에 몇 시간을 투자해서 각 프로젝트를 10%p씩 올리는 것보다, 실제로 쓰는 기능을 개선하거나 새 기능을 만드는 게 훨씬 남는 장사다. 개인 프로젝트의 테스트는 "내가 회귀를 일으키지 않도록 지켜주는" 수단이지 문서화도, 경쟁사 차별점도 아니다.
대신 이 단계에서 확보한 것이 있다. baseline-lock이 자동으로 걸려 있으므로 이 수치에서 절대 뒤로 가지 못한다. 내가 실수로 테스트를 지우거나, 테스트 없이 새 모듈을 추가하면, 훅이 commit/Stop을 막는다. 이게 핵심이다.
정리 — 세 가지 교훈
1. source 명시 없이 pytest-cov를 쓰면 숫자가 거짓말을 한다. "import된 것만 센다"는 기본값은 낙관적이지만 부정직하다.
source = ["."]가 필수.2. 외부 wrapper는 과감히 omit한다. 커버리지 측정의 목적은 "테스트되지 않은 로직 찾기"지 "mock 호출 세기"가 아니다. Playwright/외부 API 클라이언트 같은 건 측정 자체를 안 하는 게 정직하다.
3. "80% 일괄 목표"보다 "현재에서 후퇴 금지"가 실제로 지켜진다. 엄격해 보이는 규칙이 실제로는 아무도 안 지켜 무력화되는 것보다, 느슨해 보여도 매번 강제되는 규칙이 훨씬 가치 있다. baseline-lock + Hook 조합이 이걸 제공한다.
린트에서도, 타입 체크에서도, 이제 커버리지에서도 같은 패턴을 반복했다. "현재 수준을 floor로 잡고, 훅이 조용히 지킨다". 이 루프가 돌기 시작하는 순간이 "완료"의 정의였다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
Android Skills 해부 — 에이전트에게 전문성을 주입하는 방법 (1) 2026.04.30 Android CLI — Agent를 위한 SDK라는 새로운 패러다임 (0) 2026.04.30 Sphinx — Python API 문서를 코드에서 자동으로 뽑아내는 표준 (0) 2026.04.29 docstring 완전 가이드 — PEP 257부터 LLM 에이전트 context까지 (0) 2026.04.29 개인 프로젝트 12개에 보안 스캔 훅 달기 — pip-audit·bandit·gitleaks 3중 방어 (0) 2026.04.28 mypy로 파이썬 프로젝트 5개에 타입 에러 0 만들기 — 단계적 도입과 Hook 강제 (0) 2026.04.28 shellcheck로 bash 스크립트 정리하기 — 그리고 Hook으로 재발 방지까지 (0) 2026.04.28 ruff로 10개 프로젝트 린트 통과시키기 — 246 → 0, 그리고 실제 버그 2건 발견 (1) 2026.04.28 하네스 엔지니어링 9단계 베스트 프랙티스 #8. 측정과 회고 (0) 2026.04.27 하네스 엔지니어링 9단계 베스트 프랙티스 #7. 감사 로그 (0) 2026.04.27