이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Spring AI 기능을 어떤 테스트 층으로 나눠야 하는가?
  • 비결정적인 모델 응답을 어떻게 regression test할 수 있는가?
  • structured output, RAG, tool calling은 각각 무엇을 검증해야 하는가?
  • prompt/model/index 변경 전에 어떤 gate를 둬야 하는가?

개요

AI 기능은 일반 로직처럼 exact match만으로 테스트하기 어렵다. 하지만 테스트를 포기하면 prompt, model, index, tool schema 변경이 운영 장애로 바로 이어진다.

Spring Boot 테스트 전략과 AI eval을 함께 써야 한다. 빠른 unit test, adapter test, provider smoke test, eval regression을 나눠야 한다.

원리

테스트 층은 다음처럼 나눈다.

  • Service unit test: fake gateway로 업무 정책 검증
  • Adapter test: prompt 조립, 예외 변환, validation 검증
  • Contract test: DTO, tool input/output, API response 검증
  • RAG test: retrieval hit, source id, forbidden leak 검증
  • Provider smoke test: 실제 provider 최소 호출
  • Eval regression: 대표 질문 세트로 품질 속성 검증

이 구조가 있어야 느리고 비싼 provider 테스트에 모든 검증을 맡기지 않는다.

실습

fake gateway로 service를 테스트한다.

class FakeSupportAiGateway implements SupportAiGateway {
 
    @Override
    public AiAnswer answer(AiAnswerCommand command) {
        if (command.question().contains("timeout")) {
            throw new AiServiceException(AiErrorType.TIMEOUT, "simulated timeout");
        }
        return new AiAnswer("테스트 답변입니다.", List.of("doc-1"), "fake");
    }
}

eval case는 exact answer보다 속성을 본다.

record EvalCase(
        String id,
        String question,
        Set<String> requiredSources,
        List<String> forbiddenPhrases,
        Duration latencyBudget
) {
}
 
record EvalResult(String id, boolean passed, String reason) {
}

eval runner 예시는 다음과 같다.

@Service
class SupportAnswerEval {
 
    private final SupportAnswerService supportAnswerService;
 
    SupportAnswerEval(SupportAnswerService supportAnswerService) {
        this.supportAnswerService = supportAnswerService;
    }
 
    EvalResult run(EvalCase evalCase) {
        long started = System.nanoTime();
        AiAnswer answer = supportAnswerService.answer("eval-tenant", "eval-user", evalCase.question());
        Duration elapsed = Duration.ofNanos(System.nanoTime() - started);
 
        if (elapsed.compareTo(evalCase.latencyBudget()) > 0) {
            return new EvalResult(evalCase.id(), false, "latency budget exceeded");
        }
        if (!answer.sourceIds().containsAll(evalCase.requiredSources())) {
            return new EvalResult(evalCase.id(), false, "required source missing");
        }
        boolean forbidden = evalCase.forbiddenPhrases().stream()
                .anyMatch(phrase -> answer.text().toLowerCase(Locale.ROOT).contains(phrase));
        if (forbidden) {
            return new EvalResult(evalCase.id(), false, "forbidden phrase detected");
        }
        return new EvalResult(evalCase.id(), true, "passed");
    }
}

성공 경로는 prompt/model/index 변경 전후에 같은 eval set을 돌려 차이를 확인하는 것이다.

자주 나는 오류는 eval을 너무 크게 시작하는 것이다. 처음에는 20개 이하 대표 질문으로 시작해도 충분하다.

운영에서는 eval 결과를 prompt version, model profile, index version과 함께 저장한다.

실전 판단

테스트 대상별 기준은 다르다.

  • Structured output: schema valid, Bean Validation, fallback
  • Tool calling: permission, idempotency, audit log, output masking
  • RAG: source hit, 권한 필터, stale document, no-answer
  • Observability: metric tag, trace id, error type
  • Security: prompt injection, forbidden tool execution, 개인정보 로그

실전 팁

  • exact string assertion은 최소화한다.
  • fake gateway로 대부분의 service test를 빠르게 유지한다.
  • provider smoke test는 적은 수로 nightly나 manual profile에서 실행한다.
  • eval case에는 실패해야 하는 질문도 넣는다.
  • invalid output rate와 retrieval miss rate를 release note에 남긴다.

위험 신호!

  • AI 기능 테스트가 실제 provider 호출뿐이다.
  • prompt 변경에 regression gate가 없다.
  • RAG source id를 검증하지 않는다.
  • tool side effect를 테스트에서 실제 실행한다.
  • eval 실패를 “모델이 원래 그렇다”로 넘긴다.

확인 질문

확인 질문

  • AI 테스트를 여러 층으로 나누는 이유는 무엇인가?
    • 속도, 비용, 비결정성, 업무 정책 검증 범위가 다르기 때문이다.
  • exact match 대신 어떤 속성을 검증할 수 있는가?
    • schema valid, required source, forbidden phrase, latency, safety, escalation flag를 검증할 수 있다.
  • provider smoke test는 왜 적게 유지하는가?
    • 비용, latency, quota, 비결정성 때문에 모든 테스트를 provider에 의존하면 불안정해지기 때문이다.
  • eval result에 version을 남기는 이유는 무엇인가?
    • prompt, model, index 변경 중 무엇이 품질 변화를 만들었는지 추적하기 위해서다.

학습 연결

참고 문서