
LLM 테스트 작성은 정답 문자열을 통째로 비교하는 방식으로는 되지 않습니다. 같은 프롬프트를 열 번 넣어도 문장이 매번 조금씩 달라지는 비결정적 출력을 다뤄야 하기 때문에, 구조 검증·LLM 채점·골든셋 회귀라는 세 가지 방식을 겹쳐 써야 실제로 쓸모 있는 검증이 됩니다. 이 글에서는 각 방식을 동작하는 코드와 함께 정리하고, 어떤 상황에서 어느 방식이 무너지는지도 함께 살펴봅니다.
이 글은 Python 기반 pytest 테스트 환경과 Node 기반 CLI 도구를 함께 쓰는 상황을 전제로 합니다. 사내 챗봇이나 RAG 답변 생성기처럼, 출력이 자유 문장이면서도 최소한의 규칙(형식·의도·정확성)은 지켜야 하는 앱을 기준으로 설명합니다.
비결정적 출력이 LLM 테스트 작성을 어렵게 만드는 이유
일반 함수 테스트는 입력 A에 항상 출력 B가 나온다는 전제 위에 서 있습니다. assert result == "환불 규정은 7일입니다" 같은 코드가 그대로 통하는 이유입니다.
LLM은 이 전제를 깨뜨립니다. temperature를 0으로 낮추면 변동 폭이 줄어들지만, 실무에서는 그래도 매번 완전히 동일한 문장이 나오지 않는 경우를 자주 보게 됩니다. 모델 버전이 조용히 바뀌거나, 같은 프롬프트라도 토큰 샘플링 과정에서 표현이 미세하게 갈리는 식입니다.
그래서 “정확히 이 문장이 나와야 한다”가 아니라 “이 조건을 만족해야 한다”로 검증 기준을 바꿔야 합니다. 이 조건을 어떻게 정의하느냐에 따라 검증 방식이 세 갈래로 나뉩니다. 소프트웨어 테스트의 전통적인 정의 자체가 “예상 결과와 실제 결과의 비교”인데, LLM 앱에서는 이 “예상 결과”를 무엇으로 잡을지부터 다시 설계해야 합니다.
구조적 검증부터 걸어 두세요
가장 저렴하고 안정적인 검증부터 시작하는 편이 좋습니다. 문장 내용이 아니라 출력의 형식·타입·필수 필드가 지켜지는지만 확인하는 방식입니다.
LLM이 JSON을 반환하도록 프롬프트를 설계했다면, Pydantic 같은 데이터 검증 라이브러리로 스키마를 강제할 수 있습니다. Pydantic 공식 문서에 나온 model_validate_json 메서드를 쓰면 파싱과 검증을 한 번에 처리합니다.

from pydantic import BaseModel, ValidationError
class AnswerSchema(BaseModel):
intent: str
confidence: float
action: str
def test_llm_response_has_valid_schema(llm_client):
raw = llm_client.chat("환불 절차를 알려줘")
try:
parsed = AnswerSchema.model_validate_json(raw)
except ValidationError as e:
assert False, f"스키마 검증 실패: {e}"
assert 0 <= parsed.confidence <= 1
assert parsed.action in {"refund", "escalate", "faq"}
이 테스트는 문장 표현이 매번 달라져도 통과합니다. 대신 "형식은 맞는데 내용이 틀린 답변"은 걸러내지 못한다는 한계가 뚜렷합니다. action이 refund로 정확히 분류됐어도, 환불 가능 기간을 3일이라고 잘못 말했는지는 이 단계에서 알 수 없습니다.
LLM-as-judge로 답변 품질을 채점하는 방법
내용의 정확성이나 지시 준수 여부는 다른 LLM에게 채점을 맡기는 방식으로 검증합니다. 이를 LLM-as-judge라고 부르며, 오픈소스 평가 프레임워크인 DeepEval의 GEval 메트릭이 대표적인 구현입니다.
from deepeval import assert_test
from deepeval.metrics import GEval
from deepeval.test_case import LLMTestCase, LLMTestCaseParams
correctness = GEval(
name="Correctness",
criteria="답변이 질문의 핵심 요구사항을 실제로 충족하는지 평가합니다.",
evaluation_params=[LLMTestCaseParams.INPUT, LLMTestCaseParams.ACTUAL_OUTPUT],
threshold=0.7,
)
def test_refund_answer_quality():
query = "주문한 지 3일 됐는데 환불 가능한가요?"
test_case = LLMTestCase(
input=query,
actual_output=llm_client.chat(query),
)
assert_test(test_case, [correctness])
deepeval test run 명령으로 실행하면 pytest처럼 통과·실패가 리포트됩니다. DeepEval 저장소와 사용법은 confident-ai/deepeval 공식 저장소에서 확인할 수 있습니다.
다만 이 방식에는 트레이드오프가 있습니다. 채점을 맡은 LLM 자체도 비결정적이기 때문에, threshold를 애매하게 잡으면 같은 답변인데도 어떤 실행에서는 통과하고 다음 실행에서는 실패하는 일이 생깁니다. 채점용 API 호출 비용도 매 테스트마다 발생하므로, CI에서 매 커밋마다 돌리기보다는 PR 단위나 야간 배치로 돌리는 쪽이 현실적입니다.
골든셋 회귀 테스트, 무엇을 놓치기 쉬울까요?
프롬프트를 수정하거나 모델 버전을 올릴 때, 예전에는 잘 답하던 질문에 대한 답변 품질이 조용히 떨어지는 경우가 있습니다. 이를 잡으려면 실제 사용자 질문을 모아 둔 골든셋을 기준으로 배포 전후 결과를 비교하는 회귀 테스트가 필요합니다.
Node 기반 CLI 도구인 promptfoo를 쓰면 프롬프트·모델·검증 조건을 하나의 설정 파일로 관리할 수 있습니다.

prompts:
- "고객 문의에 답하세요: {{query}}"
providers:
- openai:gpt-4o-mini
tests:
- vars:
query: "환불 규정이 어떻게 되나요?"
assert:
- type: similar
value: "환불은 구매 후 7일 이내에 가능합니다."
threshold: 0.85
- type: llm-rubric
value: "답변이 환불 가능 기간을 명시하고 있어야 한다"
npx promptfoo eval로 실행하면 골든셋 전체에 대해 통과율과 실패한 케이스를 표로 보여줍니다. similar 타입은 임베딩 기반 의미 유사도를, llm-rubric은 규칙 기반 채점을 수행합니다. 자세한 옵션은 promptfoo 공식 문서에서 확인할 수 있습니다.
이 방식의 약점은 골든셋에 없는 새로운 질문 유형에서 발생하는 회귀는 애초에 잡히지 않는다는 점입니다. 사용자가 실제로 던진 질문을 주기적으로 골든셋에 추가하지 않으면, 시간이 지날수록 테스트가 현실과 멀어집니다. 이 개념은 한국정보통신기술협회(TTA) 정보통신용어사전에도 정리된 회귀 테스트(regression test)의 일반적인 한계와 크게 다르지 않습니다.
세 검증 방식이 잡아내는 문제와 놓치는 문제
세 방식은 서로 다른 층위를 검증하기 때문에, 한 가지만 골라서 쓰기보다는 계층처럼 쌓는 편이 낫습니다.
| 방법 | 확인하는 것 | 실행 비용 | 자주 놓치는 부분 |
|---|---|---|---|
| 구조적 검증 | 출력 형식·타입·필수 필드 | 거의 없음, 실행이 빠름 | 형식은 맞는데 내용이 틀린 경우 |
| LLM-as-judge | 의미적 정확성·톤·지시 준수 | 채점용 API 호출 비용 발생 | 채점자 모델도 비결정적이라 점수가 흔들릴 수 있음 |
| 골든셋 회귀 | 배포 전후 답변이 크게 달라졌는지 | 골든셋 구축·유지 비용 | 골든셋에 없는 새 질문 유형의 회귀는 못 잡음 |
실무에서는 구조적 검증을 매 커밋마다 돌려 빠르게 걸러내고, LLM-as-judge는 PR 단위로 핵심 시나리오만 돌리고, 골든셋 회귀는 배포 직전에 전체를 한 번 훑는 식으로 역할을 나누는 조합이 무난합니다.
지금 레포에 pytest 하나만 추가해도 시작할 수 있습니다
LLM 테스트 작성은 거창한 평가 파이프라인을 처음부터 갖추지 않아도 시작할 수 있습니다. 이미 응답을 JSON으로 받고 있다면 Pydantic 스키마 테스트 하나만 추가해도 형식 회귀는 바로 잡히기 시작합니다.
그다음 단계로 핵심 시나리오 5~10개를 골라 DeepEval의 GEval 테스트를 붙이고, 이 시나리오들을 promptfoo 설정 파일로 옮겨 배포 전 골든셋 검사로 굳히는 순서를 권합니다. 세 층위를 한 번에 갖추려 하기보다, 구조 검증부터 pytest에 올리는 것이 가장 빠르게 효과를 보는 첫걸음입니다.
