Schemathesis 첫 실행, 실패 결과 분류하는 법

Schemathesis 첫 실행, 실패 결과 분류하는 법

3줄 요약

  • 오퍼레이션 5개짜리 API에 Schemathesis 4.25.2를 처음 돌리니 2.5초 만에 667건을 만들어 실패 10건을 냈고, 그중 서버가 실제로 죽은 것은 2건이었습니다
  • 나머지 8건은 코드가 아니라 스펙이 현실을 못 따라간 것이었고, 스펙을 정확하게 고치자 실패는 10건에서 3건으로 줄었지만 실행 시간은 2.5초에서 5.9초로 늘었습니다
  • 처음 붙이신다면 --generation-deterministic을 켜고 --phases coverage,fuzzing으로 0.8초짜리 관문부터 만드시면 됩니다

저는 스펙 기반 자동 생성이 테스트를 대신 써 주는 물건이라고 알고 있었습니다. OpenAPI 문서가 이미 있으니 거기서 케이스를 뽑아내면, 적어도 "요청을 보냈을 때 500이 나지 않는다" 정도는 손을 대지 않고 얻을 수 있다고 여겼습니다. 그 생각이 틀렸다는 것을 첫 실행 5초 만에 알았습니다.

사내 재고 API를 흉내 낸 FastAPI 앱을 하나 만들어 붙였습니다. 경로 4개, 오퍼레이션 5개, OpenAPI 문서 3,963바이트. 결함은 일부러 심되 "있을 법한 실수"만 넣었습니다. 리포트에 실패 10건이 떴고, 하나씩 열어 보니 8건은 코드 이야기가 아니었습니다.

1. 5초 만에 나온 실패 10건을 하나씩 뜯었습니다

설치와 첫 실행은 두 줄입니다. 별도 설정 파일도, 테스트 코드도 쓰지 않았습니다.

# Python 3.10.12 / FastAPI 0.141.1 / hypothesis 6.167.1 환경입니다
pip install schemathesis                       # 4.25.2 (PyPI 업로드 2026-08-25 04:15 KST)
st run http://127.0.0.1:8000/openapi.json --url http://127.0.0.1:8000

돌아온 요약은 이랬습니다. 오퍼레이션 5개 전부 테스트했고, 1,189건을 만들어 10건의 고유 실패를 찾는 데 5.27초가 걸렸습니다. 단계는 넷으로 나뉘어 있었고 시간 배분이 고르지 않았습니다. Examples 0.11초(스펙에 예시가 없어 5건 전부 건너뜀), Coverage 0.83초, Fuzzing 0.39초, 그리고 Stateful이 3.91초였습니다. 마지막 단계 하나가 전체 5.27초 중 74.2%를 썼습니다.

실패 10건을 성격별로 갈라 보면 이렇습니다.

분류 건수 내용 고쳐야 할 곳
서버 오류 (5xx) 2 limit=0에서 0으로 나누기, 거대한 정수 price 입력 시 크래시 코드
문서화 안 된 상태 코드 6 404·400을 실제로 반환하는데 스펙에는 200·422만 선언 스펙
스펙 위반 요청을 수용 1 {"stock": false}를 201로 받아들임 코드 또는 스펙
잘못된 Allow 헤더 1 OPTIONS 응답의 Allow에 DELETE가 빠짐 프레임워크

서버가 실제로 죽은 것은 2건입니다

GET /search?q=&limit=0이 500을 냈습니다. 페이지 크기를 100 // limit으로 계산하는 코드였고, limit에 하한을 걸지 않은 탓입니다. 손으로 케이스를 쓸 때 0을 넣어 보는 사람은 드뭅니다. 1과 10과 100을 넣지 0을 넣지는 않습니다. 이런 종류를 찾아 주는 것이 이 도구의 값어치입니다.

두 번째는 price에 21자리 정수를 넣었을 때였습니다. 실무에서 그런 값이 들어올 일은 없지만, 들어왔을 때 500이 나느냐 422가 나느냐는 다른 문제입니다. 500은 스택 트레이스가 로그에 쌓이고 알림이 울립니다.

리포트가 실무에서 쓸 만했던 이유는 재현 방법을 같이 주기 때문입니다. 실패마다 그대로 붙여 넣을 수 있는 curl 한 줄과 st replay <케이스 ID> 명령이 따라옵니다. 0으로 나누기 건은 수백 자짜리 무작위 입력에서 시작했을 텐데 최종 리포트에는 ?q=&limit=0만 남았습니다. 실패를 찾으면 최소 입력까지 줄여 주는 동작이 기본으로 켜져 있고, 급할 때는 --no-shrink로 끌 수 있습니다. 티켓에 재현 절차를 옮겨 적는 시간이 사실상 0이 됩니다.

문서화 안 된 상태 코드 6건은 스펙이 낡은 것입니다

여섯 건 모두 같은 모양이었습니다. 코드는 404를 반환하는데 스펙에는 200과 422만 적혀 있었습니다. FastAPI가 response_model만 보고 스펙을 만들기 때문에, raise HTTPException(404)를 아무리 써도 문서에는 나타나지 않습니다.

이걸 버그로 볼지 말지가 첫 갈림길입니다. 서비스는 정상 동작합니다. 다만 이 API를 붙이는 프런트엔드 개발자가 스펙만 보고 404 처리를 빼먹을 수 있습니다. 클라이언트 코드를 스펙에서 생성한다면 더 직접적인 문제가 됩니다.

나머지 2건은 성격이 다릅니다

{"name": "", "price": 0, "stock": false}가 201로 통과했습니다. 스펙상 stock은 정수인데 불린이 들어왔고, pydantic이 false를 0으로 조용히 바꿔서 받았습니다. price: true도 1로 통과합니다. 파이썬에서 불린은 정수의 서브클래스라 벌어지는 일이고, 손으로 쓴 테스트에서는 거의 잡히지 않습니다.

OPTIONS 응답의 Allow 헤더 건은 프레임워크가 만든 것입니다. Starlette가 라우팅되지 않은 메서드에 405를 내면서 문서에 선언된 DELETE를 헤더에 넣지 않았습니다. 우리 코드로는 고칠 수 없고, 소규모 팀이 지금 다룰 문제도 아닙니다.

2. 이 도구가 보는 것은 "코드가 스펙과 같은가"입니다

첫 실행 결과를 정리하면서 분류가 한쪽으로 쏠린 이유를 알았습니다. Schemathesis의 검사 13종은 대부분 응답을 스펙과 대조하는 것입니다. 공식 문서의 검사 목록을 보면 상태 코드 대조, 콘텐츠 타입 대조, 응답 스키마 대조, 응답 헤더 대조가 핵심을 이루고, max_response_time 하나만 기본 비활성입니다.

즉 이 도구는 스펙을 정답지로 씁니다. 정답지에 없는 규칙은 채점 대상이 아닙니다.

스펙에 없는 규칙은 통과합니다

이 성질을 눈으로 보려고 실험을 하나 더 했습니다. 스펙을 정확하게 고친 v2를 만들면서, 스펙을 전혀 위반하지 않는 결함 하나를 남겨 뒀습니다. 새 항목의 ID를 max(DB) + 1로 정하는 코드입니다.

# 스펙을 한 글자도 위반하지 않는 결함 — ID 재사용
curl -X POST -d '{"name":"마우스","price":15000,"stock":5}' .../items
# → {"id":3,"name":"마우스",...}
curl -X DELETE .../items/3                       # → 204
curl -X POST -d '{"name":"허브","price":9000,"stock":1}' .../items
# → {"id":3,"name":"허브",...}   ← 방금 지운 3번을 다시 씁니다
curl .../items/3                                  # → 허브. 마우스를 참조하던 쪽은 조용히 다른 물건을 봅니다

응답은 전부 스키마에 맞고 상태 코드도 선언된 그대로입니다. 노이즈 성격의 검사 2종을 끄고 v2를 돌리면 실패 0건이 나옵니다. 1,372건을 4.17초 동안 던졌는데도 이 결함은 보고되지 않습니다.

할인 계산도 같은 자리에 있었습니다. v1에서 rate=200을 넣으면 final_price가 −39,000이 됩니다. 음수 가격은 명백한 업무 규칙 위반인데, 스펙에 rate의 상한이 없으니 도구 입장에서는 정상 응답입니다. 이 결함은 코드를 고쳐서가 아니라 스펙에 ge=0, le=100을 적어서 사라졌습니다. 규칙을 스펙에 적는 순간 도구가 그것을 채점하기 시작합니다.

그래서 판정은 사람이 합니다

정리하면 이 도구가 잡는 것과 못 잡는 것이 꽤 선명하게 갈립니다.

결함 유형 자동으로 잡히는가 이유
처리되지 않은 예외로 인한 5xx 잡힘 상태 코드만 봐도 판정 가능
입력 검증 누락 (경계값·타입) 잡힘 스펙의 제약을 뒤집어 던져 봄
응답이 선언한 스키마와 다름 잡힘 응답 본문을 스키마로 검증
스펙에 안 적힌 상태 코드 반환 잡힘 다만 대개 스펙 쪽 문제
계산 결과가 틀림 못 잡음 정답을 스펙이 모름
식별자 재사용, 권한 경계, 순서 의존 못 잡음 업무 규칙이라 스펙 밖

아래 두 줄이 남는 한, 이 도구는 손으로 쓴 시나리오 테스트를 대체하지 않습니다. 겹치지 않는 층을 하나 더 까는 것에 가깝습니다.

3. 스펙을 고치니 실패는 줄고 실행 시간은 2.3배가 됐습니다

v2에서는 404 응답을 명시하고, 할인 응답에 스키마를 붙이고, limit과 rate에 범위를 넣었습니다. 스펙은 3,963바이트에서 4,897바이트로 24% 늘었습니다.

# v1 — 404를 던지지만 스펙에는 나타나지 않습니다
@app.get("/items/{item_id}", response_model=Item)
def get_item(item_id: int): ...

# v2 — 404 응답과 파라미터 범위를 스펙에 적습니다
@app.get("/items/{item_id}", response_model=Item, responses={404: {"model": Err}})
def get_item(item_id: int): ...

@app.get("/search", response_model=List[Item])
def search(q: str = Query(...), limit: int = Query(10, ge=1, le=100)):
    page_size = 100 // limit          # ge=1 이 스키마로 강제되어 0 나눗셈이 사라집니다

결과는 이렇게 갈렸습니다. 같은 조건(--seed 1 --generation-deterministic)에서 잰 값입니다.

항목 v1 (스펙 방치) v2 (스펙 정정)
생성한 테스트 케이스 667건 1,961건
고유 실패 10건 3건
벽시계 시간 2.55초 5.86초
Stateful 링크 커버리지 0 covered / 8 10 covered / 10
Stateful 시나리오 수 271 633

실패가 줄었는데 시간이 늘어난 것이 처음에는 이상해 보였습니다. 이유는 단순합니다. v1은 초반에 실패가 쏟아져서 탐색이 일찍 끝났고, v2는 막히는 데가 없으니 끝까지 파고들었습니다. 스펙을 고치는 일은 리포트를 조용하게 만드는 동시에 CI 시간을 늘립니다.

링크 커버리지 쪽이 더 인상적이었습니다. 우리 스펙에는 links 선언이 한 줄도 없는데 도구가 8개를 추론해 냈고, v1에서는 그중 0개를 실제로 통과시켰습니다. POST가 만든 ID를 GET에 넘기려면 응답이 스키마와 맞아야 하는데 v1은 거기서 계속 걸렸기 때문입니다. v2에서는 10개 중 10개가 커버됐습니다. 스펙 품질이 곧 상태 기반 테스트의 도달 범위였습니다.

남은 3건 중 2건은 끄는 편이 낫습니다

v2에 남은 실패는 셋입니다. 불린이 정수로 통과하는 건, Allow 헤더 건, 그리고 문서화되지 않은 쿼리 파라미터를 그냥 받아들인다는 건입니다. 마지막 것은 additionalProperties 위반으로 잡히는데, 알 수 없는 쿼리 파라미터를 무시하는 것은 대부분의 API가 의도적으로 하는 동작입니다.

--exclude-checks negative_data_rejection,allow_header_conformance로 두 검사를 끄면 실패가 0건이 되고 시간도 5.86초에서 4.17초로 줄어듭니다. 다만 이때 첫 번째 항목, 즉 불린이 정수로 통과하는 진짜 결함도 같이 사라집니다. 검사 단위로 끄면 그 검사가 잡던 유효한 지적까지 함께 나갑니다. 스펙에 예외를 적어 두는 쪽이 나은 이유입니다.

4. CI에 넣기 전에 재 본 초 단위 비용

무인으로 도는 물건이라면 시간과 재현성이 판단 기준이 됩니다. 둘 다 실측했습니다.

단계를 골라 쓰면 0.78초짜리 관문이 됩니다

각 단계를 따로 돌려 봤습니다. 매번 서버를 새로 띄우고 --seed 1로 고정한 값입니다.

실행한 단계 생성 케이스 찾은 고유 실패 벽시계
coverage 65건 2건 0.52초
fuzzing 52건 5건 0.66초
stateful 635건 6건 2.95초
coverage + fuzzing 96건 7건 0.78초
전체 (기본값) 667건 10건 2.55초

앞의 두 단계만으로 10건 중 7건을 0.78초에 잡습니다. stateful이 나머지를 채우지만 시간의 대부분도 가져갑니다. 커밋마다 도는 관문에는 앞의 두 단계를, 야간 실행에는 전체를 두는 배치가 자연스럽습니다.

--max-examples를 낮추는 방법도 있습니다. 기본값은 오퍼레이션당 100건입니다.

-n 값 생성 케이스 찾은 고유 실패 벽시계
5 208건 7건 1.29초
10 429건 9건 1.87초
25 534건 9건 2.14초
50 485건 10건 2.00초
100 (기본) 667건 10건 2.54초

10으로 낮춰도 10건 중 9건을 73% 시간에 찾습니다. 다만 이 표는 오퍼레이션 5개짜리 장난감 API의 값이라, 실제 API에서 그대로 성립한다고 보시면 안 됩니다. 각자 환경에서 같은 방식으로 재 보시는 편이 안전합니다.

--seed만으로는 재현되지 않았습니다

이것이 실측 중에 가장 의외였던 부분입니다. --seed 1을 고정하고 서버까지 매번 새로 띄워 세 번 돌렸는데 생성 케이스가 898건, 652건, 882건으로 제각각이었습니다. 찾아낸 실패 10건은 매번 같았지만 실행량이 흔들렸습니다.

# 시드만 고정 — 서버를 새로 띄워도 생성량이 898 / 652 / 882 로 흔들립니다
st run <스펙> --url <주소> --seed 1

# 결정론 모드 — 세 번 모두 정확히 667건, 실패 10건
st run <스펙> --url <주소> --seed 1 --generation-deterministic

--generation-deterministic을 붙이자 세 번 모두 667건으로 딱 맞았습니다. CI에서 "어제는 통과했는데 오늘 깨졌다"를 판단하려면 실행량부터 고정돼 있어야 합니다. 이 플래그 없이 쓰면 실패가 늘었을 때 코드가 나빠진 것인지 그날 더 많이 던진 것인지 구분할 수 없습니다.

리포트는 오퍼레이션 단위로 굴러갑니다

--report junit으로 JUnit XML을 뽑아 봤습니다. 4,071바이트짜리 파일이 나왔고 내용은 tests=6, failures=5였습니다. 콘솔이 말한 10건과 숫자가 다릅니다. XML은 오퍼레이션 하나를 테스트 케이스 하나로 묶고 그 안에 failure 요소를 여러 개 넣기 때문입니다.

CI 대시보드에는 "6개 중 5개 실패"로 뜹니다. 실패 건수를 지표로 삼으실 거라면 이 차이를 알고 계셔야 합니다. 종료 코드는 실패가 있으면 1이라 게이트로 쓰는 데는 문제가 없습니다.

5. 우리 팀이라면 어디까지 하고 멈출 것인가

QA 4~6명에 자동화가 초기 단계인 팀 기준으로 정리했습니다.

항목 평가
도입 난이도 낮음. 스펙 URL 하나면 첫 실행이 되고 테스트 코드를 쓸 필요가 없습니다
유지보수 비용 중간. 도구 설정이 아니라 스펙을 최신으로 유지하는 비용입니다. 스펙이 낡으면 리포트가 스펙 지적으로 뒤덮입니다
팀 학습곡선 초반은 완만하고 중반에 한 번 꺾입니다. 실행은 쉽지만 "이 실패가 코드 문제인가 스펙 문제인가"를 가르는 판정은 사람이 익혀야 합니다

첫 주에 할 일은 셋으로 충분해 보입니다. 스펙을 읽혀 한 번 돌리고, 나온 실패를 위의 4분류로 나누고, 5xx만 티켓으로 만드는 것입니다. 나머지는 스펙 정리 백로그로 보내면 됩니다. 처음부터 실패 0건을 목표로 삼으면 스펙 정정 작업에 몇 주가 통째로 들어가고, 그 사이 도구는 CI에 못 들어갑니다.

멈춰야 할 지점도 있습니다. 스펙이 손으로 관리되고 있고 코드와 자동으로 동기화되지 않는다면, 이 도구는 결함 탐지기가 아니라 문서 감사 도구가 됩니다. 그것도 가치가 있지만 기대를 바꿔서 도입해야 합니다. 반대로 코드에서 스펙이 자동 생성되는 환경이라면 첫날부터 값이 나옵니다.

그리고 이 도구가 통과했다는 것이 API가 올바르다는 뜻은 아닙니다. 실패 0건이 나온 v2 안에 ID 재사용 결함이 그대로 살아 있었습니다. 계약이 맞는지와 계산이 맞는지는 다른 질문입니다.

내일 바로 해볼 수 있는 순서

# 1) 30초 안에 끝나는 첫 실행. 아무것도 설정하지 않고 그냥 봅니다
pip install schemathesis
st run https://<우리-API>/openapi.json --url https://<우리-API> \
   --seed 1 --generation-deterministic

# 2) 5xx만 먼저 봅니다. 이것만이 무조건 코드 문제입니다
st run <스펙> --url <주소> --checks not_a_server_error --generation-deterministic

# 3) 커밋 관문용 — 앞 두 단계만, 실패 3건에서 조기 종료
st run <스펙> --url <주소> --phases coverage,fuzzing \
   --generation-deterministic --max-failures 3 --report junit

# 4) 야간 전체 실행 — stateful 포함, JUnit 리포트 보관
st run <스펙> --url <주소> --generation-deterministic --report junit

2번을 먼저 돌리시길 권합니다. 검사를 not_a_server_error 하나로 좁히면 리포트에 남는 것이 전부 코드 문제라 팀에 설명하기가 쉽습니다. 스펙 지적까지 한꺼번에 보여 주면 "이 도구는 노이즈가 많다"는 인상이 먼저 박힙니다. 첫인상을 관리하는 것도 도입의 일부입니다.


댓글

이 블로그의 인기 게시물

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

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

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

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

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