LLM 유닛 테스트가 필요한 이유: 모델 업데이트와 성능 회귀를 같은 문제로 봐야 하는 이유
LLM, 유닛테스트, 모델업데이트라는 키워드를 함께 놓고 보면 핵심은 하나입니다. 모델을 교체하거나 버전을 올렸을 때, 기존에 잘 되던 응답 품질이 조용히 무너지는 순간을 배포 전에 잡아내는 것입니다. OpenAI도 평가 체계를 모델 업그레이드나 프롬프트 변경 전후의 차이를 확인하는 핵심 절차로 설명하고 있고, 생성형 시스템은 같은 입력에도 결과가 달라질 수 있기 때문에 전통적인 단정형 테스트만으로는 부족하다고 안내합니다.
실무에서는 특히 이런 상황에서 문제가 잘 드러납니다. 응답 자체는 멀쩡해 보이는데 JSON 필드 하나가 빠지거나, 예전에는 지키던 금지 문구를 새 모델이 슬쩍 어기거나, 분류 기준이 애매한 샘플에서 판정이 흔들리는 경우입니다. 겉으로 보기에는 “성능이 더 좋아진 것 같은데?”라는 인상이 있을 수 있지만, 서비스 관점에서는 기존 계약을 깨는 회귀일 수 있습니다. Anthropic도 에이전트 품질을 안정적으로 운영하려면 실제 실패를 보이게 만드는 평가 루프가 필요하다고 설명합니다.
LLM 유닛 테스트를 어떻게 이해하면 좋은가
여기서 말하는 유닛 테스트는 자바나 TypeScript에서 하던 함수 단위 테스트와 완전히 같지는 않습니다. 다만 생각 방식은 비슷합니다. 입력이 있고, 기대 조건이 있고, 그 조건을 통과하는지 자동으로 확인한다는 점은 같습니다. 차이는 “정답이 하나로 고정되지 않는 출력”을 다뤄야 한다는 데 있습니다. 그래서 문자열 완전 일치만 보는 방식보다, 구조 준수 여부, 금지 규칙 위반 여부, 핵심 의미 포함 여부, 기준 점수 이상 통과 여부처럼 조건형 검증을 많이 사용합니다.
정리하면 LLM 유닛 테스트는 다음 질문에 답하는 장치입니다. 같은 사용자 요청을 넣었을 때 새 모델이 예전 수준을 유지하는가, 특정 기능은 더 나아졌는가, 반대로 포맷 안정성이나 안전 규칙은 깨지지 않았는가입니다. 이 관점으로 보면 모델업데이트 검증은 단순한 성능 비교가 아니라, 서비스 계약을 지키는 회귀 테스트에 가깝습니다.
모델 업데이트 전에 반드시 고정해야 하는 테스트 기준
가장 먼저 해야 할 일은 “무엇이 깨지면 실패로 볼 것인가”를 팀 안에서 명확히 정하는 것입니다. 이 기준이 없으면 평가 결과를 봐도 해석이 계속 흔들립니다. 특히 LLM 시스템은 단순 정확도 하나로 끝나지 않는 경우가 많아서, 기능별 계약을 먼저 나누는 것이 좋습니다.
1. 형식 안정성
응답이 JSON이어야 하는지, 특정 키를 반드시 포함해야 하는지, 마크업이나 태그 형식이 깨지면 안 되는지부터 정의합니다. 이 영역은 비교적 기계적으로 판정할 수 있어서 가장 먼저 자동화하기 좋습니다.
2. 의미 정확성
분류, 요약, 추출, 답변 생성처럼 의미 판단이 필요한 기능은 기준 샘플셋을 만들고 정답 또는 허용 범위를 둡니다. 완전 일치보다 핵심 슬롯이 맞는지, 필수 사실이 포함되었는지, 금지된 해석이 들어가지 않았는지를 보는 방식이 더 잘 맞습니다. OpenAI의 평가 가이드도 데이터셋, 채점기, 평가 하니스의 조합으로 접근할 것을 권합니다.
3. 안전 규칙과 거절 정책
모델업데이트에서 자주 놓치는 부분입니다. 새 모델이 더 똑똑해 보여도 거절 방식이나 안전 필터링 성향이 달라질 수 있습니다. 특히 도구 호출형 에이전트나 외부 시스템과 연결된 구조라면, 안전 규칙 회귀는 기능 회귀만큼 중요하게 다뤄야 합니다.
4. 비용보다 먼저 볼 재현성
테스트 비용을 아끼는 것도 중요하지만, 먼저 결과 비교가 가능한 상태를 만들어야 합니다. 프롬프트 버전, 모델 이름, temperature, system 메시지, few-shot 예제, 후처리 로직까지 같이 고정하지 않으면 모델 차이와 설정 차이가 섞여서 결과를 해석하기 어려워집니다.
LLM 유닛 테스트 데이터셋은 어떻게 만들어야 하나
테스트 데이터셋은 많이 모으는 것보다 잘 나누는 쪽이 더 중요합니다. 처음부터 수천 건을 만들 필요는 없습니다. 대신 실패했을 때 영향이 큰 사례를 우선 고릅니다. 예를 들면 분류 경계가 애매한 입력, 출력 포맷이 자주 깨지는 요청, 길이가 긴 문서, 다국어 입력, 금지 요청 유도 문장 같은 것들입니다.
구성을 나눠보면 보통 세 묶음이 실용적입니다. 첫 번째는 정상 케이스입니다. 대부분의 사용자가 흔히 넣는 입력입니다. 두 번째는 경계 케이스입니다. 해석이 갈릴 수 있는 입력이나 길이가 긴 입력이 여기에 들어갑니다. 세 번째는 회귀 케이스입니다. 과거에 실제로 실패했던 샘플입니다. OpenAI의 실시간 평가 가이드도 운영 중 발견한 실패를 다시 테스트셋에 넣어 평가 루프를 강화하는 방식을 설명합니다.
이 부분은 팀 협업에서 차이가 크게 납니다. 개발자가 보기에는 사소한 출력 차이여도, 운영자나 기획자 입장에서는 치명적일 수 있습니다. 그래서 테스트셋에는 기술적으로 어려운 샘플만 넣지 말고, 실제 사용자 기대를 대표하는 예제도 반드시 포함하는 편이 좋습니다.
TypeScript로 보는 간단한 LLM 유닛 테스트 예시
아래 예시는 모델업데이트 전후에 분류 결과와 JSON 포맷을 함께 검증하는 아주 단순한 형태입니다. 실제 서비스에서는 여기에 다건 실행, 기준 점수, 결과 저장, 이전 실행과의 diff 비교가 붙는다고 보면 됩니다.
type TestCase = {
name: string;
input: string;
expectedCategory: "billing" | "refund" | "technical";
};
type ModelResult = {
category: string;
reason: string;
};
const testCases: TestCase[] = [
{
name: "결제 실패 문의",
input: "카드 결제는 됐는데 이용권이 활성화되지 않았어요.",
expectedCategory: "billing",
},
{
name: "환불 요청",
input: "어제 결제한 상품을 취소하고 싶어요.",
expectedCategory: "refund",
},
];
async function callModel(model: string, input: string): Promise<ModelResult> {
const responseText = await llmClient.generate({
model,
system: "반드시 JSON으로만 응답하세요.",
prompt: input,
temperature: 0,
});
return JSON.parse(responseText);
}
async function runEval(model: string) {
const results = [];
for (const tc of testCases) {
try {
const result = await callModel(model, tc.input);
const passed = result.category === tc.expectedCategory;
results.push({
name: tc.name,
passed,
expected: tc.expectedCategory,
actual: result.category,
});
} catch (error) {
results.push({
name: tc.name,
passed: false,
expected: tc.expectedCategory,
actual: "INVALID_JSON",
});
}
}
return results;
}
이 예제의 핵심은 테스트가 화려하냐가 아닙니다. 모델이 바뀌었을 때 최소한 무엇이 깨졌는지는 자동으로 드러나야 한다는 점입니다. 처음에는 JSON 파싱 성공 여부, 필수 필드 존재 여부, 분류 일치 여부 정도만 잡아도 충분합니다. 이 정도만 해도 “새 모델이 더 똑똑해 보인다”는 인상과 “기존 기능 계약을 지켰다”는 검증을 분리할 수 있습니다.
모델업데이트 검증에서 자주 하는 실수
샘플 수만 늘리고 기준은 애매하게 두는 경우
테스트 케이스가 많다고 좋은 평가가 되는 것은 아닙니다. 무엇을 통과로 볼지 불분명하면 결과 해석이 늘 사람 손으로 돌아갑니다. 차라리 30개의 샘플이라도 왜 넣었는지 설명 가능한 구성이 더 낫습니다.
프롬프트와 후처리 변경을 모델 차이와 섞는 경우
실무에서는 이 부분을 자주 헷갈립니다. 모델만 바꾼다고 생각했는데 system prompt도 손봤고, 응답 파서도 같이 수정했다면 결과 차이를 모델 탓으로 볼 수 없습니다. 비교 실험에서는 바뀌는 요소를 하나씩만 두는 편이 해석이 쉽습니다.
좋아진 케이스만 보고 배포하는 경우
새 모델은 특정 영역에서 더 잘할 수 있습니다. 다만 기존 강점이 사라졌는지는 별도로 봐야 합니다. OpenAI의 회귀 탐지 예시도 성능 향상 여부만이 아니라, 이전 버전 대비 어떤 프롬프트에서 퇴행했는지를 확인하는 흐름으로 설명합니다.
운영 실패를 테스트셋에 반영하지 않는 경우
한 번 실제로 틀린 사례는 가장 가치 있는 자산입니다. 그 실패를 다시 테스트에 넣지 않으면 같은 실수를 다른 모델에서도 반복하게 됩니다. Promptfoo 문서에서도 과거 실패 케이스를 다시 테스트에 편입해 회귀 방지 루프를 강화하는 방식을 소개합니다.
CI에 붙일 때는 무엇부터 자동화하면 좋은가
처음부터 모든 평가를 CI 파이프라인에 넣을 필요는 없습니다. 오히려 오래 걸리고 해석이 어려운 테스트를 한꺼번에 넣으면 금방 꺼지게 됩니다. 보통은 3단계로 나누는 편이 좋습니다.
첫 번째는 빠른 검증입니다. 응답이 파싱되는지, 포맷이 맞는지, 필수 키가 있는지처럼 수 초 안에 끝나는 테스트입니다. 두 번째는 핵심 회귀 검증입니다. 대표 샘플셋에 대해 이전 모델 대비 통과율이 떨어졌는지 확인합니다. 세 번째는 주기 평가입니다. 더 큰 테스트셋이나 사람 검토가 필요한 항목은 배포 시점이 아니라 야간 배치나 주기 실행으로 분리하는 방식이 유지하기 편합니다.
이렇게 나누면 CI는 개발 흐름을 막지 않으면서도, 모델업데이트 직후 위험한 회귀는 빠르게 드러낼 수 있습니다. 평가 도구 자체는 직접 구현해도 되고, OpenAI Evals 가이드처럼 데이터셋과 채점기를 중심으로 하니스를 만들거나, Promptfoo 같은 도구를 이용해 모델·프롬프트 조합을 비교하는 방식도 가능합니다.
결국 중요한 것은 정답률보다 계약 유지입니다
LLM 유닛 테스트를 도입할 때 가장 중요한 기준은 “새 모델이 더 좋아 보이는가”가 아닙니다. 기존 기능 계약을 유지하면서 업데이트했는가입니다. 답변 문장이 조금 더 자연스러운지는 부차적일 수 있습니다. 반면 JSON이 깨지거나, 분류 기준이 흔들리거나, 금지 정책을 어기면 서비스 입장에서는 명확한 실패입니다.
그래서 모델업데이트 검증은 평가 문화의 일부로 가져가는 것이 좋습니다. 테스트셋을 작게 시작하고, 실제 실패 사례를 계속 추가하고, 통과 기준을 팀이 합의 가능한 형태로 정리하면 됩니다. 그렇게 쌓인 테스트는 단순한 실험 기록이 아니라, 다음 모델 교체 때도 그대로 재사용할 수 있는 품질 기준이 됩니다.
LLM 유닛 테스트는 모델을 불신해서 만드는 절차가 아니라, 모델이 바뀌어도 서비스 동작이 흔들리지 않게 만드는 최소한의 안전망입니다. 특히 모델 업데이트가 잦은 팀이라면, 이 과정을 나중에 붙이는 것보다 처음부터 작은 형태로라도 갖춰두는 편이 훨씬 낫습니다.
'IT 테크 > AI' 카테고리의 다른 글
| [AI] 데이터 드리프트(Data Drift) 감지: 사용자 질문 패턴 변화 대응법 (0) | 2026.03.29 |
|---|---|
| [AI] CI/CD 파이프라인에 AI 모델 평가 자동화 단계 추가하기 (0) | 2026.03.28 |
| [AI] 프롬프트 버전 관리(Version Control): 코드로 관리하는 프롬프트 생태계 (0) | 2026.03.26 |
| [AI] AI 서비스도 모니터링이 필요하다: LangSmith와 Arize Phoenix 도입기 (0) | 2026.03.25 |
| [AI] 가성비 좋은 소형 모델(SLM)의 부상: 특정 도메인에서의 반란 (0) | 2026.03.23 |
