pytest timeout, signal과 thread 방식 차이 정리

pytest-timeout signal vs thread 방식 차이 정리

3줄 요약

  • 멈춘 테스트를 끊는 방법은 pytest-timeout의 signal·thread와 pytest 내장 faulthandler 세 갈래인데, pytest-timeout 공식 문서는 thread를 "가장 확실한 방법"이라고 적고 있습니다.
  • 행 11종을 상한 2초로 돌려 보니 signal 9/11, thread 10/11, faulthandler(exit=true) 11/11이었고 signal과 thread가 놓친 항목이 하나도 겹치지 않았습니다. 정규식 백트래킹은 thread만 놓쳤고, SIGALRM을 건드린 테스트는 signal만 놓쳤습니다.
  • xdist로 병렬 실행 중이면 thread, 단일 프로세스면 signal을 기본값으로 두고 느린 테스트만 @pytest.mark.timeout으로 예외 처리하시면 됩니다.

CI 잡 하나가 여섯 시간째 초록불도 빨간불도 아닌 상태로 매달려 있습니다. 로그 마지막 줄은 collected 412 items이고 그 아래로는 아무것도 없습니다. 취소 버튼을 누르면 러너는 회수되지만 어느 테스트에서 멈췄는지는 끝내 모릅니다. 다음 날 같은 브랜치를 다시 돌리면 통과합니다.

이 상황을 두 번 겪고 나서 --timeout을 붙였습니다. 그런데 붙이는 순간 갈림길이 하나 더 생깁니다. signal과 thread 중 무엇을 쓸 것인가. 공식 문서는 thread 쪽을 밀지만, 11종의 행을 만들어 양쪽에 먹여 보니 그 권고가 항상 맞지는 않았습니다.

1. 먼저 판단 표

행의 성격과 실행 환경에 따라 답이 갈립니다. 아래가 실측에서 나온 결론이고, 나머지 절은 이 표의 각 행이 어디서 나왔는지를 채웁니다.

조건 권고 이유
pytest-xdist로 병렬 실행 중 thread 워커만 죽고 컨트롤러가 worker crashed로 기록합니다. 스위트가 살아남습니다
단일 프로세스 + JUnit XML을 CI가 읽음 signal thread는 리포트 파일 자체를 남기지 않습니다
정규식·C 확장이 오래 도는 코드가 있음 signal thread의 타이머는 GIL을 못 받으면 멈춥니다
시그널을 직접 다루는 코드를 테스트함 thread 테스트가 SIGALRM을 가로채면 signal은 무력화됩니다
테스트별 상한을 다르게 주고 싶음 pytest-timeout (둘 다) 내장 faulthandler에는 마커가 없습니다
타임아웃이 얼마나 비싼지 걱정됨 아무거나 60개 스위트에서 세 방식 차이가 0.04초, 측정 노이즈 안입니다

환경은 Python 3.10.12 · pytest 9.1.1 · pytest-timeout 2.4.0(PyPI 게시 2025-05-05T19:44:33Z = KST 05-06 04:44) · pytest-xdist 3.8.0 · Linux aarch64 4코어입니다.

2. 행 11종에 2초 상한을 물렸습니다

테스트 네 개짜리 파일을 만들고 두 번째를 의도적으로 멈추게 했습니다. 멈추는 방식만 11가지로 바꿔 가며 각 조합을 별도 프로세스로 띄웠고, 8초 안에 프로세스가 끝나지 않으면 바깥에서 강제 종료해 "미탈출"로 기록했습니다.

# 행 유형은 환경변수 하나로 고릅니다. 각 유형이 GIL과 시그널을 다르게 다룹니다
HANG = os.environ.get("HANG", "")

def test_b_hang(marker):
    if HANG == "sleep":        time.sleep(60)                   # GIL 해제, 시그널로 깨어남
    elif HANG == "lock":       lk = threading.Lock(); lk.acquire(); lk.acquire()
    elif HANG == "subprocess": subprocess.run(["sleep", "60"])  # 자식 프로세스 대기
    elif HANG == "regex":      re.match(r"(a+)+$", "a" * 40 + "b")   # C 코드가 GIL 유지
    elif HANG == "sigmask":    signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGALRM})
                               # ↑ 차단해 두고 sleep
    elif HANG == "handler":    signal.signal(signal.SIGALRM, lambda *a: None)
                               # ↑ 핸들러를 덮어쓰고 sleep

비교 대상은 네 가지입니다. pytest-timeout의 --timeout-method=signal과 thread, 그리고 pytest 9.1.1에 내장된 faulthandler_timeout을 덤프만 하는 기본 상태와 faulthandler_exit_on_timeout=true를 켠 상태로 나눴습니다.

여덟 종은 셋 다 잡습니다

sleep, threading.Event().wait(), 소켓 accept, 자식 프로세스 대기, 순수 파이썬 무한 루프, 락 이중 획득, 빈 파이프 read, 끝나지 않는 스레드 join. 여덟 종은 signal 2.08~2.13초, thread 2.07~2.20초, faulthandler(exit) 2.06~2.08초로 전부 상한 직후에 끊겼습니다. 이 범위 안에서는 무엇을 골라도 됩니다.

덤프만 하는 기본 faulthandler는 11종 전부 미탈출이었습니다. 이름이 timeout이라 끊어 줄 것 같지만 스택을 찍고 그대로 둡니다. 끊으려면 faulthandler_exit_on_timeout=true를 따로 켜야 합니다.

thread가 놓친 하나

re.match(r"(a+)+$", "a" * 40 + "b") 한 줄에서 갈렸습니다. signal은 2.08초에 끊었고 thread는 8초 강제 종료 시점까지 살아 있었습니다. 출력에는 점 하나만 찍혀 있었고 pytest-timeout이 평소 남기는 +++ Timeout +++ 배너조차 없었습니다. 타이머 스레드가 깨어나긴 했는데 한 줄도 실행하지 못한 것입니다.

signal이 놓친 둘

반대쪽입니다. 테스트가 pthread_sigmask로 SIGALRM을 차단했거나 signal.signal(SIGALRM, ...)으로 핸들러를 덮어쓰면 signal 방식은 알람을 보내고도 아무 일이 일어나지 않습니다. 두 경우 모두 8초까지 미탈출이었고, thread는 2.07초에 정상적으로 끊었습니다.

이건 억지 사례가 아닙니다. 자체 타임아웃을 signal.alarm으로 구현한 레거시 클라이언트, 시그널 핸들러를 검증하는 테스트, 시그널을 쓰는 서드파티 라이브러리가 있으면 그대로 재현됩니다.

행 유형 signal thread faulthandler(덤프만) faulthandler(exit)
sleep / event / socket / subprocess 2.10~2.13초 2.07~2.08초 미탈출 2.06~2.08초
cpu 루프 / lock / readpipe / join 2.08~2.11초 2.07~2.20초 미탈출 2.07~2.08초
regex (파국적 백트래킹) 2.08초 미탈출 미탈출 2.07초
sigmask (SIGALRM 차단) 미탈출 2.07초 미탈출 2.06초
handler (SIGALRM 덮어쓰기) 미탈출 2.07초 미탈출 2.07초
합계 9 / 11 10 / 11 0 / 11 11 / 11

숫자만 보면 thread가 10으로 앞섭니다. 그런데 signal이 놓친 둘을 thread가 잡고 thread가 놓친 하나를 signal이 잡으므로, 한쪽이 다른 쪽을 포함하지 않습니다. 공식 README가 thread를 "가장 확실하고 이식성 있는 방법"이자 "언제나 동작한다"고 적은 것은 이 지점에서 어긋납니다.

3. 타이머 스레드는 결국 파이썬입니다

왜 thread가 정규식 앞에서 멈추는지는 플러그인 소스를 열면 바로 보입니다.

# pytest_timeout.py 의 timeout_timer — 순수 파이썬 코드입니다
def timeout_timer(item, settings):
    terminal = item.config.get_terminal_writer()      # 파이썬 호출 = GIL 필요
    capman = item.config.pluginmanager.getplugin("capturemanager")
    terminal.sep("+", title="Timeout")
    dump_stacks(terminal)                             # sys._current_frames() 호출
    ...
    os._exit(1)                                       # 542행. 여기까지 와야 죽습니다

프로세스를 실제로 끝내는 os._exit(1)은 542행에 있고, 그 앞에 캡처 중단·스택 덤프가 전부 파이썬으로 놓여 있습니다. 메인 스레드가 GIL을 쥔 채 C 코드 안에 들어가 있으면 타이머 스레드는 첫 줄에 도달하지 못합니다.

0.275초와 1.097초

따로 떼어 재 봤습니다. 0.3초 뒤에 깨어나 시각을 기록하는 워치독 스레드를 띄우고, 메인 스레드에서 백트래킹을 돌렸습니다.

def watchdog():
    time.sleep(0.3)                                  # 0.3초 뒤 깨어나기로 약속
    fired.append(("스레드 진입", round(time.monotonic() - T0, 3)))
    sys._current_frames()                            # 파이썬 호출 = GIL 필요
    fired.append(("파이썬 호출 성공", round(time.monotonic() - T0, 3)))
입력 길이 백트래킹 소요 워치독이 기록한 시각
n=22 0.070초 기록 없음 (백트래킹이 먼저 끝남)
n=24 0.275초 0.275초
n=26 1.097초 1.097초

0.3초에 깨어나기로 한 스레드가 0.275초와 1.097초에 기록했습니다. 둘 다 백트래킹이 끝난 바로 그 시각입니다. 타이머 스레드는 그전까지 GIL을 한 번도 받지 못했습니다.

faulthandler는 C 워치독입니다

같은 조건을 내장 faulthandler에 걸면 결과가 다릅니다. faulthandler.dump_traceback_later(1.0, exit=True)를 켜고 40자짜리 백트래킹을 돌리니 1.008초에 종료했습니다. 이쪽 워치독은 C 레벨에서 도는 별도 스레드라 GIL을 기다리지 않습니다. 그래서 매트릭스에서 혼자 11/11이 나온 것입니다.

시간과 관련된 도구가 테스트 환경을 조용히 비틀어 놓는 문제는 이번이 처음이 아닙니다. 시간 모킹 라이브러리를 비교하면서도 단조 시계를 고정해 버리는 바람에 asyncio.wait_for(0.4초)가 8초 강제 종료 시점까지 만료되지 않는 사례를 겪었습니다. 타임아웃을 붙이는 쪽과 시간을 고정하는 쪽이 같은 스위트 안에 있으면 서로를 무력화합니다.

4. 끊은 다음에 남는 것

잡느냐 못 잡느냐만 보면 thread와 faulthandler가 우세해 보입니다. 끊고 난 뒤를 보면 순서가 바뀝니다.

리포트 파일이 아예 없습니다

--junitxml을 붙이고 같은 스위트를 돌렸습니다. 테스트 60개 중 하나가 행입니다.

항목 signal thread faulthandler(exit)
JUnit XML 파일 4,487바이트 생성 안 됨 생성 안 됨
XML 안의 testcase 60개 (failure 1) — —
행 뒤 테스트 실행 (-n 0) 59 passed 0 0
픽스처 teardown 1회 실행 0회 0회
자식 프로세스 잔존 0 1 1
exit code 1 1 1

thread와 faulthandler는 os._exit()로 프로세스를 끝내므로 pytest가 리포트를 쓸 기회를 얻지 못합니다. CI 화면에는 테스트 결과 탭이 비어 있고 exit code만 1로 남습니다. 취소된 잡이 아무 리포트도 남기지 않아 원인을 못 찾는 상황은 매트릭스 잡이 fail-fast로 취소될 때와 증상이 똑같습니다. 실패 사실은 아는데 어디서 실패했는지를 모릅니다.

자식 프로세스가 남습니다

subprocess.run(["sleep", "60"])에서 멈춘 경우를 재 봤습니다. signal 방식은 예외가 전파되면서 subprocess.run이 자식을 정리해 잔존 프로세스가 0이었습니다. thread와 faulthandler는 부모를 즉사시키므로 sleep 60이 그대로 살아남았습니다.

러너에 따라 이 고아 프로세스가 잡 종료를 막습니다. 테스트 프로세스는 죽었는데 잡이 계속 도는 현상의 원인이 여기인 경우가 있습니다.

xdist가 판단을 뒤집습니다

지금까지의 thread 단점은 전부 "프로세스를 죽인다"에서 나왔습니다. 그런데 병렬 실행이면 죽는 것이 워커입니다.

# 60개 테스트 중 1개가 행. -n 0 과 -n 4 를 같은 조건으로 비교했습니다
pytest t3 -q --timeout=2 --timeout-method=thread        # → 프로세스 사망, 리포트 없음
pytest t3 -q --timeout=2 --timeout-method=thread -n 4   # → 1 failed, 59 passed in 2.28s

-n 4에서는 thread도 faulthandler도 1 failed, 59 passed로 끝났습니다. xdist 컨트롤러가 worker 'gw0' crashed while running ...을 실패로 기록하고 나머지를 다른 워커로 넘깁니다. signal도 같은 조건에서 1 failed, 59 passed였으니 병렬 실행 중이라면 셋 다 결과가 같습니다.

그러면 남는 기준은 2절의 매트릭스뿐입니다. 정규식이나 C 확장이 오래 도는 코드가 있으면 signal, 시그널을 직접 다루는 코드가 있으면 thread입니다.

비용은 기준이 아닙니다

행이 없는 60개 스위트를 각 5회 돌려 최솟값을 비교했습니다.

설정 소요
타임아웃 없음 0.530초
signal 0.523초
thread 0.504초
faulthandler(exit) 0.491초

타임아웃을 켠 쪽이 오히려 빠르게 나온 구간이 있습니다. 차이 0.04초는 측정 노이즈이고, 붙이는 비용이 사실상 없다는 뜻이기도 합니다. 비용을 근거로 타임아웃을 미룰 이유는 없습니다.

5. 상한을 하나로 두면 생기는 대가

여기서 pytest 내장 faulthandler가 갈라져 나갑니다. 11/11로 가장 잘 잡는데도 기본값으로 권하지 않는 이유가 있습니다.

빠른 테스트 6개, 정상적으로 3초 걸리는 테스트 1개, 영원히 멈추는 테스트 1개로 스위트를 만들었습니다.

설정 소요 결과
pytest-timeout 전역 2초 + 마커 6초 5.183초 1 failed, 7 passed. XML 정상
faulthandler 2초 2.157초 정상인 3초 테스트를 죽임. 행은 도달조차 못 함
faulthandler 4초 7.168초 행을 4초에 잡음. 스위트 중단

내장 faulthandler에는 테스트별 상한이 없습니다. 그래서 상한을 가장 느린 정상 테스트에 맞춰야 하고, 그만큼 행 탐지가 늦어집니다. E2E가 5분 걸리는 스위트라면 행 하나에 5분을 내주게 됩니다. pytest-timeout은 @pytest.mark.timeout(600)으로 그 테스트만 예외 처리할 수 있습니다.

setup이 느린 경우

기본 설정은 픽스처 준비 시간까지 상한에 포함합니다. DB 마이그레이션에 3초 걸리는 픽스처를 붙이고 본문은 0.5초짜리로 두니 상한 2초에서 ERROR가 났습니다. timeout_func_only=true를 켜면 본문만 재므로 3.52초에 통과합니다. 준비가 무거운 통합 테스트에서 위양성이 나면 이 옵션을 먼저 보시면 됩니다.

--session-timeout은 행을 못 잡습니다

이름 때문에 헷갈리기 쉬운데 이건 다른 물건입니다. --session-timeout=1을 걸고 행이 든 스위트를 돌리니 37개를 찍고 그대로 멈춰 바깥에서 강제 종료해야 했습니다. 문서대로 테스트와 테스트 사이에서만 검사하므로 진행 중인 테스트를 끊지 않습니다. 스위트 전체가 길어지는 것을 막는 용도이지 행 대책이 아닙니다.

6. 우리 팀에 넣는다면 무엇부터

QA 4~6명에 자동화 초기 단계라면 세 줄이면 시작할 수 있습니다. 도입 난이도는 설정 파일 한 줄이고, 유지보수 비용은 마커를 붙이는 테스트가 늘어날 때만 생기며, 학습곡선은 "왜 이 테스트만 상한이 다른가"를 주석으로 남기는 정도입니다.

# pyproject.toml 또는 pytest.ini
[tool.pytest.ini_options]
timeout = 120                    # 팀에서 가장 느린 정상 테스트의 3~4배
timeout_method = "thread"        # xdist를 쓰면 thread, 단일 프로세스면 signal
timeout_func_only = true         # 무거운 픽스처 준비 시간은 상한에서 뺍니다

그다음 순서입니다.

  1. 상한을 넉넉하게 시작합니다. 처음부터 조이면 위양성이 나고 팀이 옵션을 꺼 버립니다.
  2. @pytest.mark.timeout(0)으로 예외를 만들 수 있다는 것을 팀에 알립니다. 0은 상한 해제입니다.
  3. 한 달 뒤 JUnit XML에서 Timeout (> 문자열이 붙은 실패를 세어 보고, 상한을 내릴지 그 테스트를 고칠지 정합니다.
  4. 정규식이나 시그널을 다루는 모듈이 있으면 2절 매트릭스로 방식을 다시 판단합니다.

한계도 적어 둡니다. 이 수치는 Linux aarch64 4코어 단일 머신 한 곳에서 나온 것이고 Windows는 재현하지 않았습니다. 행 11종은 실무에서 자주 보는 형태를 고른 것이지 전수가 아닙니다. regex 케이스의 백트래킹 시간은 입력 길이에 지수적으로 반응하므로 n을 조금만 바꿔도 결과가 달라집니다. 그리고 SIGALRM을 가로채는 시나리오 두 종은 제가 테스트 코드로 재현한 것이지 실제 라이브러리에서 관찰한 것은 아닙니다.


댓글

이 블로그의 인기 게시물

옵시디언 3기기 동기화, 유료 결제 없이 구글 드라이브로 끝내기

최저가 주유소, 얼마나 돌아가면 손해일까?

회의시간 줄이는 법, 개수보다 배치 먼저

유류세 인하 2개월 연장, 주유소 가격 실제 인하폭 계산

국민연금 추납 조회, 미루면 얼마나 더 낼까?