목차
30 장

타입체커 설정과 CI 통합

mypy/pyright/ruff 설정과 충돌 회피, pre-commit으로 로컬에서 막기, GitHub Actions로 PR 단계 막기까지.

5부 (운영·패키징·테스트)의 시작입니다. 4부까지가 “기능을 만드는 법” 이었다면 5부는 “그 코드를 운영 가능하게 다듬는 법"입니다. 본 챕터는 그 첫 단계 — 타입 검사를 자동화 하는 것입니다.

본 책 전체가 타입힌트 우선으로 작성됐습니다. 9장 typing · Generic · Protocol, 20장 typing 고급에서 본 표현력은 도구가 강제하지 않으면 시간이 지나며 풀려버립니다. 본 챕터는 그 결심을 도구로 강제하는 법을 다룹니다.

정적 검증이 왜 필요한가 #

테스트만으로는 못 잡는 것들이 있습니다.

  • 호출되지 않는 코드 경로의 타입 오류 — 테스트가 도달하지 못한 분기.
  • None의 누락Optional[X]를 받아 .foo()를 호출하는 실수.
  • API 변경 후 호출부의 잔존 — 라이브러리 버전을 올렸을 때 사라진 인자가 남아 있는 경우.
  • 잘못된 dict key / 잘못된 enum 값 — 런타임에만 터지는 typo.

정적 분석은 코드를 실행하지 않고 이런 문제를 모든 분기에 대해 잡습니다. 단위 테스트의 보완재이지 대체재가 아닙니다.

도구 분담 — 무엇이 무엇을 하는가 #

도구역할비고
rufflinting + formatting + import 정렬flake8 + black + isort 통합. Rust로 작성, 매우 빠름
mypy타입 검사 (참조 구현)가장 오래됨, 가장 호환성 좋음
pyright타입 검사 (Microsoft)mypy보다 빠르고 추론이 강함. VS Code의 Pylance가 같은 엔진
pre-commit로컬 커밋 단계에서 자동 실행위 도구들을 묶어주는 프레임워크
GitHub ActionsPR 단계에서 같은 검증 재실행로컬을 건너뛴 변경을 막는 안전망

핵심 원칙은 로컬과 CI에서 같은 도구·같은 버전·같은 설정을 쓰는 것입니다. 로컬에서 통과한 게 CI에서 실패하면 신뢰가 무너집니다.

mypy와 pyright — 무엇을 골라야 하는가 #

둘 다 PEP 484 타입힌트를 검사하는 정적 분석기인데 성격이 다릅니다.

mypy

  • Python의 참조 구현. 새 PEP가 가장 먼저 반영됩니다.
  • 추론이 약합니다 — 변수를 일단 한 번 타입으로 좁혀도 다음 분기에서 다시 넓힐 때가 있습니다.
  • 라이브러리 호환성이 더 넓습니다 — 오래된 stub도 잘 읽습니다.

pyright

  • Microsoft가 만든 도구. VS Code의 Pylance가 같은 엔진을 씁니다.
  • 추론이 매우 강력합니다 — 좁혀진 타입을 잘 유지합니다.
  • 엄격 모드 (strict)가 매우 엄격해서 처음 켜면 오류가 폭발합니다.
  • mypy보다 빠릅니다.

실전 추천: 둘 중 하나만 골라야 하면 pyright입니다. 에디터와 CLI가 같은 엔진을 쓰니 일치성이 좋습니다. 다만 외부 라이브러리 stub이 부족할 때는 mypy가 더 너그럽기도 합니다.

본 책은 pyright를 기준으로 설명합니다.

ruff — linting · formatting · import 정렬을 한 도구로 #

ruff가 등장하기 전엔 flake8 + black + isort + pyupgrade를 따로 설치하고 각자 설정 파일을 두고 pre-commit에 4개 hook을 등록했습니다. 이제는 이 역할이 ruff 하나로 통합됐고, Rust로 작성돼 이전보다 훨씬 빠릅니다.

설치 #

uv add --dev ruff

pyproject.toml 설정 #

pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py314"
src = ["src", "app", "tests"]

[tool.ruff.lint]
select = [
  "E",    # pycodestyle errors
  "W",    # pycodestyle warnings
  "F",    # pyflakes
  "I",    # isort
  "B",    # flake8-bugbear (흔한 버그 패턴)
  "C4",   # flake8-comprehensions
  "UP",   # pyupgrade (Python 버전 맞춰 문법 업그레이드 제안)
  "RUF",  # ruff 자체 룰
]
ignore = [
  "E501",  # line too long — formatter 가 처리
]

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"]  # 테스트에서는 assert 허용

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

select가 활성화할 룰 카테고리입니다. 처음엔 위 정도로 시작하고 익숙해지면 N (네이밍), D (docstring), ANN (어노테이션 강제) 등을 추가합니다.

실행 #

검사
uv run ruff check .
자동 수정
uv run ruff check . --fix
포맷
uv run ruff format .

검사와 포맷이 분리돼 있다는 점이 중요합니다. ruff check는 “이 코드는 잘못됐다"를 알리고 ruff format은 “이 코드의 모양을 통일한다"를 합니다.

pyright — 타입 검사 설정 #

설치 #

uv add --dev pyright

pyproject.toml 설정 #

pyproject.toml
[tool.pyright]
include = ["src", "app", "tests"]
exclude = ["**/__pycache__", "**/node_modules", "build", "dist"]
pythonVersion = "3.14"
typeCheckingMode = "standard"  # off / basic / standard / strict

# 점진적 채택 — 모듈마다 다르게 켜기
executionEnvironments = [
  { root = "app/core", reportMissingTypeStubs = "error" },
  { root = "app/services", reportMissingTypeStubs = "error" },
  { root = "tests", reportMissingTypeStubs = "none", reportPrivateUsage = "none" },
]

# 개별 룰 조정
reportUnusedImport = "warning"
reportUnusedVariable = "warning"
reportMissingTypeStubs = "warning"
reportImplicitOverride = "error"

typeCheckingMode가 핵심입니다.

  • off — 켜져 있어도 사실상 검사 안 함
  • basic — 명백한 오류만
  • standard — 합리적 기본값 (권장 시작점)
  • strict — 모든 검사 활성화. 한꺼번에 켜면 큰 코드베이스는 수백 오류

실행 #

uv run pyright

특정 파일만 검사하려면 uv run pyright app/services/.

pre-commit — 로컬 커밋 단계에서 막기 #

pre-commit은 Git hook 프레임워크입니다. git commit 직전에 등록한 검사를 실행하고 실패하면 커밋을 막습니다.

설치 #

uv add --dev pre-commit

설정 파일 #

.pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-toml
      - id: check-added-large-files
        args: [--maxkb=500]
      - id: check-merge-conflict

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.8.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: https://github.com/RobertCraigie/pyright-python
    rev: v1.1.390
    hooks:
      - id: pyright
        additional_dependencies:
          - fastapi
          - pydantic
          - sqlalchemy

pyright는 외부 라이브러리의 타입 stub을 보기 위해 그 라이브러리가 같은 환경에 설치돼 있어야 합니다. pre-commit은 격리된 venv에서 hook을 돌리니 additional_dependencies로 명시합니다.

설치와 첫 실행 #

git hook 설치
uv run pre-commit install
전체 파일에 한 번 돌려보기
uv run pre-commit run --all-files

이후 git commit 할 때마다 변경된 파일에 대해 hook이 자동 실행됩니다. 실패하면 커밋이 막힙니다.

hook을 일시적으로 건너뛰기 #

긴급 hotfix 같은 예외 상황엔 git commit --no-verify로 건너뛸 수 있습니다. 다만 PR 단계에서 같은 검증이 다시 막을 거라 결국 고쳐야 합니다.

GitHub Actions — PR 단계 안전망 #

로컬 hook을 건너뛴 변경, 또는 다른 환경에서 만들어진 변경을 잡는 두 번째 그물입니다.

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint-and-typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v4
        with:
          enable-cache: true

      - name: Set up Python
        run: uv python install 3.14

      - name: Install dependencies
        run: uv sync --frozen

      - name: Ruff check
        run: uv run ruff check .

      - name: Ruff format check
        run: uv run ruff format --check .

      - name: Pyright
        run: uv run pyright

  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - uses: astral-sh/setup-uv@v4
        with:
          enable-cache: true

      - run: uv python install 3.14
      - run: uv sync --frozen

      - name: Run tests
        env:
          DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/test
        run: uv run pytest -v --cov=app --cov-report=xml

      - uses: codecov/codecov-action@v5
        with:
          files: ./coverage.xml

몇 가지 패턴:

  • concurrency — 같은 PR에 push가 연속되면 이전 실행을 자동 취소. CI 시간 절약.
  • uv sync --frozenuv.lock과 정확히 일치하는 환경. lock이 안 맞으면 실패.
  • 두 job 분리 — lint/typecheck는 빠르게 끝나니 test와 병렬로 돌립니다. 빠른 피드백.

점진적 타이핑 채택 전략 #

기존 코드베이스에 strict를 한꺼번에 켜면 수백 오류가 쏟아져 절대 못 끕니다. 단계로 풀어야 합니다.

1단계 — basic으로 시작

[tool.pyright]
typeCheckingMode = "basic"

명백한 오류 (None 누락, attribute 오타)만 잡습니다. 이미 타입힌트가 있다면 거의 통과합니다.

2단계 — standard로 승격

typeCheckingMode = "standard"

추가 검사 (return 타입 누락, Optional 처리 등)가 켜집니다.

3단계 — 모듈 단위 strict

[[tool.pyright.executionEnvironments]]
root = "app/core"
typeCheckingMode = "strict"

[[tool.pyright.executionEnvironments]]
root = "app/services"
typeCheckingMode = "strict"

새로 작성한 / 중요한 모듈부터 strict로 올립니다.

4단계 — 전체 strict, 예외 목록 관리

typeCheckingMode = "strict"

[[tool.pyright.executionEnvironments]]
root = "app/legacy"
typeCheckingMode = "standard"  # legacy 만 풀어두기

기본은 strict, 풀린 곳은 명시적으로 예외 등록. “풀린 영역의 크기"가 곧 기술 부채의 크기가 됩니다.

5단계 — strict 100%

legacy가 비워질 때까지 꾸준히 모듈을 옮겨갑니다.

흔한 함정 #

외부 라이브러리에 type stub이 없음 #

error: Skipping analyzing "some_lib": module is installed, but missing library stubs or py.typed marker

세 가지 옵션이 있습니다.

  1. **types-some_lib**가 PyPI에 있는지 확인 (uv add --dev types-some_lib)
  2. 직접 stub 작성 (stubs/some_lib.pyi)
  3. 해당 import만 무시 (# pyright: ignore[reportMissingTypeStubs])

pyright와 mypy의 결과가 다름 #

두 도구의 추론 알고리즘이 다릅니다. 둘 다 엄격한 기준으로 동시에 운영하기보다 한쪽을 기준 도구로 정하고 다른 쪽은 IDE 보조 정도로 사용합니다. 본 책은 pyright를 기준으로 삼겠습니다.

Any의 전염 #

한 함수가 Any를 반환하면 그 결과를 받은 모든 곳이 Any가 됩니다. reportUnknownReturnType, reportAny 같은 strict 룰이 이를 잡아주지만, 시작은 “외부 라이브러리 경계에서만 Any, 우리 코드 내부엔 Any 금지"입니다.

ruff와 formatter의 충돌 #

ruff가 lint와 format을 모두 맡으면 충돌이 없습니다. 다만 외부 black을 함께 두면 규칙이 어긋날 수 있습니다. 이 책에서는 ruff format 하나로 통일합니다.

pre-commit hook의 버전이 오래됨 #

.pre-commit-config.yamlrev:가 고정되니 주기적으로 올려야 합니다.

uv run pre-commit autoupdate

이 명령이 모든 repo의 rev:를 최신으로 갱신합니다. 분기에 한 번 정도.

29장 종합 실습에 적용하기 #

29장 종합 실습 — TODO API 완성하기에서 만든 프로젝트에 본 챕터 설정을 그대로 얹으면 완성도가 한 단계 올라갑니다.

cd todo-api/
uv add --dev ruff pyright pre-commit
# 위 pyproject.toml / .pre-commit-config.yaml / .github/workflows/ci.yml 추가
uv run pre-commit install
uv run pre-commit run --all-files  # 첫 통과
git add . && git commit -m "chore: setup typecheck and CI"

이제 main 브랜치는 통과한 타입 검사와 lint, 포맷, 테스트를 거친 코드만 들어옵니다.

연습문제 #

  1. ruff의 select 확장 — 본 챕터의 기본 selectN (네이밍), D (docstring)를 추가해 보세요. 처음엔 어떤 오류가 가장 많이 나오나요? per-file-ignores로 테스트 폴더만 일부 룰을 풀려면 어떻게 작성하나요?
  2. pyright executionEnvironments — 본인의 프로젝트 (없으면 29장 종합 실습)에서 한 디렉터리만 typeCheckingMode = "strict"로 올려 보세요. 어떤 오류가 가장 많이 나오나요? Optional 누락, Any 전염, return 타입 누락 중 무엇이 가장 흔한가요?
  3. CI 캐시 최적화 — 위 GitHub Actions workflow의 lint-and-typecheck job과 test job이 매번 의존성을 새로 설치합니다. astral-sh/setup-uv@v4enable-cache: true가 어떻게 동작하는지 확인하고, uv.lock이 바뀌지 않은 PR의 두 번째 run이 첫 run 대비 얼마나 빨라지는지 비교해 보세요.
노트
한 줄 요약 — 정적 검증은 ruff (lint·format) + pyright (타입) 두 도구로 충분합니다. pre-commit으로 로컬에서, GitHub Actions로 PR 단계에서 같은 검증을 두 겹으로 막아야 코드베이스의 타입 안전성이 시간이 지나도 유지됩니다.

다음 챕터는 logging과 관측성입니다. 정적 검증으로 “코드의 정확성"을 막았다면, 다음 단계는 “운영 중 무엇이 일어나는지 보이게 만드는 법"입니다.

X