서비스 학습
SOURCE · be/tests/test_config.py · 2026-09-06

잘못된 설정은 배포가 아니라
시작 순간에 걸러야 한다

두 개의 테스트가 여덟 가지 잘못된 값을 확인한다. 모두 “조용히 통과하면 나중에 진단하기 어려운” 종류다.

이 파일을 만든 이유

설정 오류는 증상이 늦게 나타난다. APP_INFERENCE_BASE_URL=http://127.0.0.1:8080/v1처럼 /v1을 붙여 두면 서버는 정상적으로 뜨고, /healthz도 200을 돌려준다. 문제는 실제 생성 요청이 /v1/v1/chat/completions로 나갈 때에야 드러난다. 그때 보이는 것은 404 하나뿐이라 원인이 설정이라고 짐작하기 어렵다.

이 파일은 그런 값들을 객체를 만드는 순간 거부하게 만들어 둔 뒤, 그 거부가 실제로 일어나는지 확인한다. 검증 규칙 자체는 config.pyfield_validatorField 제약에 있다.

전체 코드

import math

import pytest
from pydantic import ValidationError

from app.core.config import Settings


def test_inference_url_must_be_a_server_root() -> None:
    invalid_urls = (
        "http://127.0.0.1:8080/v1",
        "http://user:password@127.0.0.1:8080",
        "http://127.0.0.1:8080?model=gemma",
        "http://127.0.0.1:8080#health",
    )
    for url in invalid_urls:
        with pytest.raises(ValidationError):
            Settings(_env_file=None, inference_base_url=url)


def test_inference_timeouts_are_positive_and_finite() -> None:
    invalid_values = (0, -1, math.inf, math.nan)
    for value in invalid_values:
        with pytest.raises(ValidationError):
            Settings(_env_file=None, inference_probe_timeout=value)

import 해설

import math

math.infmath.nan을 만들기 위해서다. 이 둘은 Python에서 정상적인 float이라 isinstance(value, float)를 통과한다. 그래서 별도로 막지 않으면 timeout 값으로 들어갈 수 있다.

import pytest

pytest.raises를 쓰기 위해서다. “예외가 나는 것이 정답”인 테스트를 표현하는 도구다.

from pydantic import ValidationError

Pydantic이 검증에 실패했을 때 던지는 예외 type이다. 어떤 예외든 나기만 하면 통과시키는 대신 type을 지정했다. 그래야 오타로 인한 NameError가 “검증이 동작했다”로 오해되지 않는다.

테스트 1 — URL은 서버 root여야 한다

def test_inference_url_must_be_a_server_root() -> None:
    invalid_urls = (
        "http://127.0.0.1:8080/v1",
        "http://user:password@127.0.0.1:8080",
        "http://127.0.0.1:8080?model=gemma",
        "http://127.0.0.1:8080#health",
    )
    for url in invalid_urls:
        with pytest.raises(ValidationError):
            Settings(_env_file=None, inference_base_url=url)
거부하는 값무엇이 문제인가
…:8080/v1경로는 Adapter가 붙인다. 설정에도 있으면 /v1/v1/…로 중복된다
user:password@…자격 증명이 설정 문자열과 log에 남는다. 인증은 별도 계층의 일이다
…?model=gemmamodel은 inference_model 설정이 담당한다. 두 곳에서 지정되면 어느 쪽이 이기는지 불분명해진다
…#healthfragment는 서버로 전송되지도 않는다. 적어 둔 사람의 의도와 실제 동작이 다르다
_env_file=None이 왜 필요한가

이것이 없으면 Settings()개발자 컴퓨터의 be/.env 파일을 읽는다. 그러면 .env가 있는 사람과 없는 사람의 테스트 결과가 달라진다. None을 주어 파일 읽기를 끄고, 오직 인수로 준 값만 검증 대상이 되게 한다.

with pytest.raises(ValidationError)

블록 안에서 그 예외가 반드시 나야 통과한다. 예외가 나지 않으면 Failed: DID NOT RAISE로 실패한다. 즉 “거부되어야 할 값이 통과했다”를 잡아낸다.

for 반복인가

네 값이 모두 같은 규칙 하나(require_server_root)를 시험한다. 다만 주의할 점이 있다. 앞쪽 값에서 실패하면 뒤쪽 값은 실행되지 않아, 한 번의 실행으로는 어느 값까지 확인됐는지 알기 어렵다. pytest.mark.parametrize를 쓰면 값마다 별도 테스트로 갈라져 실패 지점이 바로 보인다. 지금은 값이 넷뿐이고 규칙이 하나라 단순한 반복을 택했다.

테스트 2 — timeout은 양수이고 유한해야 한다

def test_inference_timeouts_are_positive_and_finite() -> None:
    invalid_values = (0, -1, math.inf, math.nan)
    for value in invalid_values:
        with pytest.raises(ValidationError):
            Settings(_env_file=None, inference_probe_timeout=value)
거부하는 값그대로 두면 생기는 일
0요청이 시작하자마자 timeout으로 실패한다. 서버가 정상이어도 항상 실패
-1음수 deadline은 의미가 없다. library에 따라 예외 또는 즉시 만료로 갈린다
math.inf영원히 기다린다. 추론 서버가 응답하지 않으면 연결이 풀에 잡힌 채 반환되지 않는다
math.nan모든 비교가 거짓이 된다. nan > 0nan < 0도 거짓이라 조건문 검사로는 못 잡는다

math.nan이 이 목록에 있는 이유가 중요하다. if timeout <= 0: raise 같은 손으로 쓴 검사는 nan을 그냥 통과시킨다. config.pyField(gt=0, allow_inf_nan=False)를 쓴 것은 이 함정을 피하기 위해서다.

세 timeout 중 inference_probe_timeout 하나만 시험한다. 셋이 같은 Field 제약을 공유하므로 대표 하나로 규칙이 붙어 있음을 확인하는 방식이다. 다만 이는 나머지 둘에 제약을 빠뜨려도 이 테스트는 통과한다는 뜻이기도 하다.

실제 실행 결과

cd be
.venv/bin/python -m pytest tests/test_config.py -v
tests/test_config.py::test_inference_url_must_be_a_server_root        PASSED
tests/test_config.py::test_inference_timeouts_are_positive_and_finite PASSED

2 passed

거부되는 모습을 직접 보고 싶다면 예외 메시지를 출력해 본다.

cd be
.venv/bin/python -c "
from app.core.config import Settings
import math
for bad in ['http://127.0.0.1:8080/v1', 'http://127.0.0.1:8080#health']:
    try:
        Settings(_env_file=None, inference_base_url=bad)
    except Exception as error:
        print(bad, '->', error.errors()[0]['msg'])
try:
    Settings(_env_file=None, inference_probe_timeout=math.nan)
except Exception as error:
    print('nan ->', error.errors()[0]['msg'])
"
http://127.0.0.1:8080/v1 -> Value error, Use a server root URL without /v1, credentials, query or fragment
http://127.0.0.1:8080#health -> Value error, Use a server root URL without /v1, credentials, query or fragment
nan -> Input should be a finite number

앞의 두 메시지는 config.py에 사람이 적은 문장이고, 마지막은 Pydantic이 allow_inf_nan=False로부터 자동으로 만든 문장이다.

자주 발생하는 오류

증상원인진단
DID NOT RAISEconfig.py에서 검증 규칙이 빠지거나 약해짐validator와 Field 제약을 확인한다. 테스트가 아니라 설정 코드가 잘못된 경우다
로컬에서만 실패_env_file=None을 빠뜨려 be/.env를 읽음인수로 파일 읽기를 끈다
ValidationError가 아닌 다른 예외값 자체가 잘못돼 Pydantic 이전에 터짐error.errors()를 출력해 어느 단계인지 확인한다
서버는 뜨는데 생성만 404/v1이 붙은 URL이 다른 경로로 들어감환경변수·.env·compose 파일을 모두 확인한다. 이 테스트는 코드 경로만 지킨다

설계 선택과 대안

왜 설정 검증을 테스트까지 만들어 지키는가?

검증 규칙은 “당연해 보여서” 리팩터링 중에 지워지기 쉽다. 특히 field_validator는 호출하는 코드가 없어 참조를 찾아도 아무도 안 쓰는 것처럼 보인다. 테스트가 있으면 지웠을 때 즉시 빨간불이 켜진다.

올바른 값도 함께 시험해야 하지 않나?

정상 값은 나머지 45개 테스트가 전부 사용하므로 이미 간접적으로 검증된다. 이 파일은 거부해야 할 값에 집중한다. 다만 “http://127.0.0.1:8080http://127.0.0.1:8080/은 통과한다”는 긍정 사례를 한 줄 넣으면 규칙이 지나치게 엄격해지는 것도 막을 수 있다.

parametrize로 바꾸면 무엇이 좋아지나?

값마다 독립된 테스트가 되어 여덟 개의 이름이 생긴다. 하나가 깨져도 나머지는 계속 실행되고, 실패 목록만 봐도 어떤 값이 문제인지 알 수 있다. 값이 더 늘어난다면 바꿀 만하다.

이전 단계와 다음 파일

여기서 시험한 규칙의 정의는 config.py에 있다. 그 설정이 실제로 HTTP client와 Adapter로 흘러가는 과정은 main.py에서, 설정값이 응답에 나타나는지 확인하는 테스트는 test_health.py에서 이어진다.

← 개요Backend 검증 목록다음 →Readiness 연결 분리 검증