왜 문제가 되는가

테스트 스위트가 커지면 CI 파이프라인의 병목은 대부분 테스트 실행 시간이다. 순차 실행으로 20분이 걸리면 개발자는 머지 큐 앞에서 대기하고, 하루에 돌릴 수 있는 실험 횟수가 줄어든다. 병렬화는 이 시간을 코어 수에 비례해 줄여주지만, 두 가지 부작용을 동반한다. 첫째, 공유 자원(DB, 포트, 파일, 전역 상태)에 대한 경합이 드러난다. 둘째, 원래 존재하던 플래키(flaky) 테스트—같은 코드에서 성공과 실패를 오가는 테스트—가 병렬 환경의 타이밍 변화로 더 자주 터진다. 결과적으로 "빨간불이 코드 문제인지 인프라 문제인지" 구분이 안 되고, 팀은 재시도 버튼에 의존하기 시작한다.

병렬화의 두 축: 프로세스와 샤딩

병렬화는 크게 두 층위로 나뉜다. 한 러너 안에서 여러 워커 프로세스로 나누는 프로세스 병렬화와, 여러 CI 머신으로 테스트를 쪼개 분산하는 샤딩이다. 둘은 배타적이지 않고 함께 쓴다. 핵심은 테스트가 서로 독립적이라는 전제이며, 이 전제가 깨지는 지점에서 플래키가 발생한다.

방식단위적합한 경우주의점
프로세스 병렬화워커/코어CPU 바운드, I/O 대기 많음DB·포트 격리 필요
샤딩CI 머신전체 스위트가 큰 경우샤드 간 부하 불균형
둘의 조합머신×워커수천 개 테스트자원 이름 충돌 관리

자원 격리: 테스트마다 독립 네임스페이스

병렬 실행에서 가장 흔한 실패 원인은 공유 DB다. 워커별로 별도 스키마나 데이터베이스를 부여하면 상호 간섭이 사라진다. pytest-xdist에서 워커 ID를 받아 DB를 분리하는 예시다.

import os
import pytest

@pytest.fixture(scope="session")
def db_url(worker_id):
    # worker_id: "master"(단일) 또는 "gw0","gw1"...
    suffix = "0" if worker_id == "master" else worker_id
    base = os.environ["DATABASE_URL"]
    return f"{base}_test_{suffix}"

@pytest.fixture(scope="session")
def db(db_url):
    create_database(db_url)   # 워커별 독립 DB 생성
    run_migrations(db_url)
    yield connect(db_url)
    drop_database(db_url)

포트, 임시 파일, 캐시 키도 같은 원칙으로 워커 ID나 랜덤 UUID를 접미사로 붙인다. "테스트가 서로의 상태를 볼 수 없다"를 물리적으로 보장하는 것이 목표다.

샤딩과 시간 기반 분할

테스트를 단순히 파일 개수로 나누면 무거운 통합 테스트가 한 샤드에 몰려 불균형이 생긴다. 이전 실행의 소요 시간(JUnit 리포트 등)을 기준으로 분할하면 샤드별 완료 시간이 고르게 맞는다. GitHub Actions의 matrix로 4개 샤드를 돌리는 설정이다.

jobs:
  test:
    strategy:
      fail-fast: false        # 한 샤드 실패가 나머지를 죽이지 않게
      matrix:
        shard: [1, 2, 3, 4]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: |
          pytest --splits 4 --group ${{ matrix.shard }} \
                 --durations-path .test_durations \
                 -n auto --dist loadscope \
                 --junitxml=report-${{ matrix.shard }}.xml

--dist loadscope는 같은 모듈의 테스트를 한 워커에 묶어 fixture 재생성 비용을 줄인다. fail-fast: false는 전체 스위트 결과를 봐야 하는 CI에서 중요하다.

플래키 격리: 재시도로 감추지 말고 분리하라

플래키를 무조건 재시도로 통과시키면 실제 회귀까지 숨겨진다. 권장 흐름은 세 단계다. 첫째, 알려진 플래키에 태그를 붙여 별도 잡으로 격리한다. 둘째, 격리된 잡의 실패는 파이프라인을 막지 않되 대시보드에 집계한다. 셋째, 실패율 임계치를 넘으면 티켓을 발행해 근본 원인을 고친다.

# 플래키로 격리된 테스트만 별도 실행, 실패해도 머지 차단 안 함
pytest -m flaky --reruns 2 --reruns-delay 1 \
       --junitxml=flaky-report.xml || true

# 본 스위트는 flaky 제외하고 엄격하게
pytest -m "not flaky" -n auto

재시도는 격리된 테스트에만 허용한다. 일반 스위트에 전역 재시도를 걸면 새로 유입되는 플래키를 놓친다.

플래키 탐지 자동화

격리 대상을 사람이 감으로 고르면 누락된다. CI 결과를 저장해 최근 실행에서 성공/실패가 섞인 테스트를 자동 집계하는 편이 정확하다.

-- 최근 100회 실행에서 결과가 엇갈린 테스트 = 플래키 후보
SELECT test_id,
       COUNT(*)                                   AS runs,
       SUM(CASE WHEN status='fail' THEN 1 ELSE 0 END) AS fails
FROM   test_results
WHERE  run_id IN (SELECT id FROM runs ORDER BY created_at DESC LIMIT 100)
GROUP  BY test_id
HAVING SUM(CASE WHEN status='fail' THEN 1 ELSE 0 END) > 0
   AND SUM(CASE WHEN status='pass' THEN 1 ELSE 0 END) > 0
ORDER  BY fails DESC;

같은 커밋 SHA에서 성공과 실패가 함께 나온 경우만 세면 코드 회귀와 확실히 구분된다.

주의점

몇 가지를 놓치면 병렬화가 오히려 신뢰도를 떨어뜨린다. 테스트 순서 의존성은 병렬화 전에 무작위 순서(-p randomly 등)로 미리 드러내야 한다. 워커 수를 코어 수보다 과하게 올리면 컨텍스트 스위칭과 DB 커넥션 고갈로 되레 느려지므로 -n auto 부근에서 실측한다. 격리 잡은 반드시 만료 정책을 둔다—무기한 격리는 죽은 테스트를 방치하는 것과 같다. 마지막으로 병렬화는 결정론적 테스트라는 토대 위에서만 작동한다. 시간·랜덤·외부 네트워크 의존을 고정(freeze/mock)하지 않으면 워커를 아무리 격리해도 플래키는 사라지지 않는다.