AI 에이전트 시대의 테스트: QA가 붕괴되는 것을 어떻게 막았는가
Source: Dev.to
AI 에이전트가 내 개발 속도를 하룻밤 사이에 바꾸어 놓았습니다. 이제 하루에 한 주 동안 작성하던 코드보다 더 많은 코드를 배포할 수 있게 되었고, 처음엔 멋져 보였지만 작은 엣지 케이스 하나가 전체 흐름을 무너뜨릴 때는 상황이 달라집니다.
이런 속도에서는 QA가 경쟁력의 핵심이 되거나 끊임없는 화재 훈련이 됩니다. 저는 첫 번째 옵션을 선택했고, d:\Coding\Company\Ochestrator 안에서 코드 양에 비례해 확장 가능한 소수의 테스트 설계 기법을 중심으로 테스트 방식을 재구성했습니다:
- TDD
- EP‑BVA (동등 분할 + 경계값 분석)
- Pairwise (조합 테스트)
- 상태 전이 테스트
왜 나는 “테스트 설계”가 필요했는지, 단순히 “더 많은 테스트”가 아니라
코드 양이 늘어날수록 문제는 커버리지만이 아니다. 실제 문제는 가능한 입력과 상태의 공간이 내 시간보다 더 빠르게 증가한다는 것이다.
그래서 나는 더 이상 묻지 않았다:
- “이 함수에 대한 테스트를 작성했는가?”
그리고 이렇게 물었다:
- “실제로 실패 표면을 나타내는 테스트 케이스를 선택했는가?”
그러한 사고방식은 나를 구조화된 테스트‑설계 기법으로 이끌었다.
TDD: 처음부터 테스트 가능성 설계
원칙: TDD(테스트 주도 개발)는 전통적인 “코드를 작성하고, 그 다음 테스트한다” 워크플로우를 뒤집습니다. 이는 Red‑Green‑Refactor 사이클을 따릅니다:
| Phase | Description |
|---|---|
| Red | 새로운 요구사항에 대한 테스트를 작성하고 실패하도록 합니다. 이는 테스트가 실제로 무언가를 검증하고 있으며, 요구사항이 아직 충족되지 않았음을 확인해 줍니다. |
| Green | 테스트를 통과시키기 위해 최소한의 코드를 작성합니다. 이 단계에서는 “과도한 설계”를 피합니다. |
| Refactor | 테스트가 여전히 통과하는지 확인하면서 코드를 정리합니다. |
Orchestrator에서:
AI 에이전트가 복잡한 비즈니스 로직을 빠르게 생성할 수 있기 때문에, 나는 TDD를 사용하여 로직이 설계 단계부터 테스트 가능하도록 했습니다. 예를 들어, 우리 Temporal 워크플로우용 RetryPolicy를 구현할 때, 정책 로직을 한 줄도 작성하기 전에 지수 백오프에 대한 테스트 케이스부터 시작했습니다.
# Simplified TDD Example for Retry Logic
def test_retry_interval_calculation():
policy = ExponentialRetry(base_delay=1.0, max_delay=10.0)
# 1st attempt: 1.0 s
assert policy.get_delay(attempt=1) == 1.0
# 2nd attempt: 2.0 s
assert policy.get_delay(attempt=2) == 2.0
# Capped at 10.0 s
assert policy.get_delay(attempt=10) == 10.0
이것은 지연 계산을 재시도 실행과 분리하도록 강제했으며, 시스템을 모듈화하고 견고하게 만들었습니다.
EP‑BVA: 수학적 선택을 통한 효율성
원칙
- 동등 분할 (Equivalence Partitioning, EP): 입력 영역을 시스템이 동일하게 동작할 것으로 기대되는 그룹(분할)으로 나눕니다. 각 그룹에서 대표값 하나만 테스트합니다.
- 경계값 분석 (Boundary Value Analysis, BVA): 버그는 종종 이러한 분할의 “경계”에 숨어 있습니다. 정확한 경계값과 그 안·밖의 값을 테스트합니다.
Orchestrator에서:
사용자가 업로드하는 파일을 처리할 때, 우리는 엄격한 크기 제한을 두고 있습니다(예: 1 MB ~ 10 MB).
| 분할 | 설명 |
|---|---|
| Invalid | 10 MB |
BVA 포인트: 0.99 MB, 1.0 MB, 1.01 MB, 9.99 MB, 10.0 MB, 10.01 MB.
제가 실제로 적용한 중요한 사례는 bcrypt의 72바이트 제한입니다. 많은 개발자들이 bcrypt가 72번째 바이트 이후의 문자를 무시한다는 사실을 인식하지 못합니다.
# apps/backend/tests/test_auth_service.py
def test_password_length_boundaries(self, auth_service):
# Boundary: 72 bytes
p72 = "a" * 72
h72 = auth_service.get_password_hash(p72)
# Just above the boundary: 73 bytes
p73 = p72 + "b"
# Bcrypt will treat p73 the same as p72 if only the first 72 bytes are used
assert auth_service.verify_password(p73, h72) is True
이러한 특정 포인트에 집중함으로써 수백 개의 잠재적 테스트 케이스를 단 6‑10개의 매우 효과적인 테스트로 줄일 수 있었습니다.
Pairwise: 조합 폭발 억제
원칙: 대부분의 버그는 단일 입력 파라미터이거나 두 파라미터 간의 상호작용 때문에 발생합니다. Pairwise Testing은 모든 가능한 입력 파라미터 쌍을 최소 한 번씩 테스트하도록 보장하는 조합 방법입니다. 이는 테스트 케이스 수를 크게 줄이면서 높은 결함 탐지율을 유지합니다.
Orchestrator에서:
우리 AI 추론 엔진은 여러 구성 축을 가지고 있습니다:
| Axis | Options |
|---|---|
| Execution Provider | CUDA, CPU, OpenVINO |
| Model Size | Small, Medium, Large |
| Quantization | INT8, FP16, FP32 |
| Async Mode | Enabled, Disabled |
전체 조합: (3 \times 3 \times 3 \times 2 = 54) 경우.
Pairwise를 사용하면 두 설정 간의 모든 상호작용을 대략 12‑15 케이스로 커버할 수 있습니다.
# Using allpairspy to generate the matrix
from allpairspy import AllPairs
parameters = [
["CUDA", "CPU", "OpenVINO"],
["Small", "Medium", "Large"],
["INT8", "FP16", "FP32"],
["Enabled", "Disabled"]
]
for i, combo in enumerate(AllPairs(parameters)):
print(f"Test Case {i}: {combo}")
이렇게 하면 매 PR마다 전체 54‑케이스 스위트를 실행하지 않아도 하드웨어 호환성 매트릭스에 대한 높은 신뢰도를 유지할 수 있습니다.
상태 전이 테스트: 프로세스의 생명 주기 매핑
원칙: 이 기법은 시스템의 동작이 현재 상태와 발생하는 이벤트에 따라 달라질 때 사용됩니다. 상태 전이 다이어그램을 그려서 다음을 확인합니다:
- 모든 유효한 전이가 가능한지.
- 모든 무효한 전이가 거부되거나 정상적으로 처리되는지.
Orchestrator에서:
단순화된 주문 처리 워크플로를 Created → Approved → Shipped → Delivered 상태로 가정합니다. approve, ship, deliver, cancel 같은 이벤트가 전이를 트리거합니다. 각 상태/이벤트 쌍을 열거함으로써, 정상 흐름과 오류 처리(예: 아직 Created 상태인 주문을 ship 하려는 경우)를 검증하는 간결한 테스트 매트릭스를 생성합니다.
# Example state‑transition test matrix
states = ["Created", "Approved", "Shipped", "Delivered", "Cancelled"]
events = {
"approve": {"Created": "Approved"},
"ship": {"Approved": "Shipped"},
"deliver": {"Shipped": "Delivered"},
"cancel": {"Created": "Cancelled", "Approved": "Cancelled"}
}
def test_state_transitions():
for event, mapping in events.items():
for src, dst in mapping.items():
assert transition(src, event) == dst
# Verify invalid transitions raise an error
invalid_src = set(states) - set(mapping.keys())
for src in invalid_src:
with pytest.raises(InvalidTransition):
transition(src, event)
상태 공간을 체계적으로 커버함으로써, 특정 순서의 동작 후에만 나타나는 버그를 잡을 수 있습니다—이는 순수 단위 테스트 커버리지만으로는 놓치기 쉬운 부분입니다.
상태 전이에 대한 부정 테스트
- 시스템이 올바른 최종 상태에 도달하는지 확인합니다.
Orchestrator에서
KYC(고객 신원 확인) 검증 워크플로는 복잡한 상태 머신입니다. 사용자의 문서는 다음과 같이 이동합니다:
PENDING → UPLOADING → PROCESSING → VERIFIED or REJECTED
나는 REJECTED 문서가 PROCESSING을 다시 거치지 않고 VERIFIED로 바로 전이될 수 없도록 테스트를 구현했습니다.
# apps/backend/tests/test_integration_kyc_workflow.py
def test_invalid_state_transitions(workflow_engine):
workflow_engine.set_state(ImageStatus.REJECTED)
# This should be blocked by the business logic
with pytest.raises(IllegalStateError):
workflow_engine.transition_to(ImageStatus.VERIFIED)
이는 논리를 “우회”하려는 AI 에이전트에 대해 매우 중요합니다. 상태 머신을 엄격히 테스트함으로써 전체 비즈니스 프로세스의 무결성을 보장합니다.
결론
AI‑에이전트 시대에 코드는 저렴하지만 신뢰는 그렇지 않다.
내 QA가 무너지지 않게 한 것은 더 많은 테스트를 작성하는 것이 아니라 규모에 맞는 테스트 설계 기법을 채택한 것이다:
- TDD: 빠른 피드백과 안전한 리팩터링을 위해
- EP‑BVA: 경계 사례를 체계화하기 위해
- Pairwise: 조합 폭증을 억제하기 위해
- State Transition Testing: 실제 워크플로우를 검증하기 위해
코드 양이 계속 증가함에 따라 내가 계속 사용할 테스트 도구들이다.
