개발공부/인프라 · DevOps

[Troubleshooting] OpenAI를 부르는 클래스의 테스트 커버리지가 0%였던 이유

Grogrammer 2026. 10. 20. 23:06

OpenAI를 실제로 부르는 클래스 SpringAiLlmGateway의 테스트 커버리지가 CI에서 0%로 나왔다. 테스트를 분명히 작성했고 로컬에서는 통과했는데도 그랬다. 원인은 "테스트는 있었지만 CI에서는 한 번도 실행되지 않았다"는 것이었고, OpenAI와 통신하는 부품만 Mockito mock으로 바꾼 단위 테스트를 추가해 16줄 중 16줄을 덮었다.

배경

팀 프로젝트 Zelo(AI가 채팅으로 장보기를 대신해 주는 제로클릭 서비스)에서 AI·LLM 파트를 맡아서 LLM을 부르는 기반을 까는 작업을 하고 PR을 올렸었다.

  • 기술: Java 21, Spring Boot 4.1.1, Spring AI 2.0.1, OpenAI gpt-5-mini
  • LlmGateway: LLM에게 묻는 창구 인터페이스
  • SpringAiLlmGateway: Spring AI의 ChatClient로 실제 OpenAI를 부르는 구현
  • FakeLlmGateway: 테스트와 장애 시연용 가짜 구현 (정상 / 지연 / 실패 모드)
  • zelo.llm.mode 설정 하나(real / fake)로 둘 중 하나만 빈으로 올라간다

PR을 올리자 SonarCloud가 SpringAiLlmGateway의 커버리지를 0%로 표시했다.

실제 CI 오류 화면..

 

CI의 작동원리

CI는 스스로 기준을 만들지 않고 우리가 써 둔 테스트를 실행하고, 테스트가 실제로 실행한 코드만 "덮었다"고 센다.

우리 팀의 .github/workflows/build.yml은 PR과 main push마다 아래 한 줄을 실행한다.

./gradlew build sonar

이 명령은 순서대로 네 단계를 거친다.

단계 하는 일  실패 조건
1. 컴파일 src/main, src/test 코드를 빌드 문법 오류
2. 테스트 src/test의 테스트를 전부 실행 하나라도 실패하면 실패
3. JaCoCo 테스트가 실행한 줄을 기록해 리포트 생성 실패시키지 않음
4. Sonar 커버리지와 코드 품질을 SonarCloud에 업로드 빌드는 실패시키지 않음. PR 화면에 별도 검사로 표시

여기서 알아야 할 개념은 네 가지다.

  • 테스트 통과 조건: 작성된 테스트가 전부 성공해야 한다. 테스트가 0개면 실패할 것도 없으니 통과한다.
  • 건너뛴 테스트(Skipped): 실패로 치지 않는다. 대신 실행되지 않았으니 커버리지에도 아무것도 남기지 않는다.
  • 커버리지: JaCoCo가 "테스트 실행 중 실제로 지나간 줄"을 센다. SonarCloud는 그중 이번 PR에서 새로 추가한 코드의 비율을 본다. 기본 기준은 80%다.
  • dummy 키: CI에는 진짜 OpenAI 키가 없다. build.yml이 시크릿이 없으면 dummy-key-for-ci를 넣도록 되어 있다.

참고로 common, global 패키지와 *Application.java는 build.gradle에서 커버리지 계산 대상에서 빠져 있다.


원인: 유일한 테스트가 CI에서 항상 건너뛰어졌다

SpringAiLlmGateway를 실행하는 테스트는 진짜 OpenAI를 부르는 테스트 하나뿐이었고, 이 테스트는 CI에서 절대 실행되지 않도록 만들어져 있었다.

@SpringBootTest(properties = "zelo.llm.mode=real")
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = "sk-.+")
class SpringAiLlmGatewayTest { ... }

@EnabledIfEnvironmentVariable은 환경변수 OPENAI_API_KEY가 sk-로 시작할 때만 테스트를 실행한다.

이렇게 만든 이유는 세 가지였다.

  1. 호출할 때마다 요금이 나간다.
  2. LLM은 매번 다르게 답해서 결과가 일정하지 않다.
  3. CI의 dummy-key-for-ci로 호출하면 401로 실패해 빌드가 깨진다. 그래서 "키가 있으면"이 아니라 "진짜 키 형식이면"으로 조건을 걸었다.

결과적으로 CI에서는 이렇게 됐다.

테스트 CI에서 AiLlmGateway 실행
FakeLlmGatewayTest 실행 안 함 (Fake만 검사)
ZeloApplicationTests 실행 안 함 (test 프로필이라 Fake가 뜨고, 빈도 만들어지지 않음)
SpringAiLlmGatewayTest 건너뜀 안 함

셋 중 누구도 SpringAiLlmGateway를 실행하지 않았고, 그래서 어느 줄도 "덮인 줄"로 세지 않았다. 로컬에서 테스트가 통과한 것은 내 IntelliJ에 진짜 키가 있었기 때문이다.

즉 "CI에서 진짜 OpenAI를 부르지 않는다"는 올바른 결정이, "그 클래스를 검사하는 테스트가 CI에 하나도 없다"는 결과를 낳은 것이다.


해결 방법 검토: OpenAI와 통신하는 부품만 mock으로 바꾸기로 했다

먼저 SpringAiLlmGateway가 책임지는 일을 세 가지로 나눠 봤다.

  1. 요청 만들기: 우리 요청을 system 메시지와 user 메시지로 담는다.
  2. 실제 통신: 키, 모델, 타임아웃 설정으로 OpenAI와 HTTP 통신을 한다.
  3. 응답 꺼내기: 응답에서 문장, 모델명, 토큰 수를 꺼내 LlmReply에 담는다.

2번을 실제로 처리하는 것은 우리 코드가 아니라 Spring AI와 OpenAI SDK다. 우리 코드에서 2번에 해당하는 건 .call() 한 줄뿐이다. 이 구분을 기준으로 네 가지 방법을 비교했다.

방법 검사 범위 장점 단점 선택
GitHub 시크릿에 진짜 키 등록 1, 2, 3 코드 변경 없이 실제 호출까지 검사 OpenAI 장애·지연·429로 상관없는 PR이 실패, 호출마다 비용, 결과가 매번 달라 느슨한 검사뿐 ✗
Sonar 커버리지 계산에서 제외 없음 가장 간단 운영 코드를 측정에서 빼는 것일 뿐 검사는 그대로 0 ✗
가짜 OpenAI 서버 (WireMock) 1, 2, 3 실제 HTTP 경로와 타임아웃 설정까지 검사 라이브러리 추가(팀 합의), 무거움 나중에
ChatModel mock 단위 테스트 1, 3 빠르고 비용 0, 키 불필요, 팀 규칙(Mockito)과 일치 2는 검사하지 못함 ✓

mock 테스트가 2를 못 본다는 건 분명한 한계다. 그래서 두 테스트의 역할을 나눴다.

mock 단위 테스트 실제 호출 테스트
검사하는 것 내 코드가 요청·응답을 맞게 다루는가 OpenAI 연결이 살아 있는가
실행 시점 CI에서 항상 로컬에서 가끔 (키를 바꿨거나 버전을 올렸을 때)
비용 0 호출마다 요금

하나 더 발견한 패턴은

가짜 ChatModel은 ChatClient를 직접 쓰는 클래스에만 쓴다. 그 위의 클래스(채팅 오케스트레이터, 장애 대응 래퍼)는 LlmGateway 인터페이스만 Mockito로 바꾸면 된다. LlmGateway를 만든 이유가 이것이기도 하다.


해결

ChatModel만 mock으로 바꾼 테스트

핵심은 ChatClient는 진짜를 쓰고, 그 끝에서 OpenAI와 통신하는 ChatModel만 mock으로 바꾸는 것이다. 그러면 우리 코드가 ChatClient를 쓰는 방식은 실제와 똑같이 검사된다.

@ExtendWith(MockitoExtension.class)
class SpringAiLlmGatewayMockTest {

    @Mock
    private ChatModel chatModel;

    private SpringAiLlmGateway springAiLlmGateway;

    @BeforeEach
    void setUp() {
        given(chatModel.getOptions()).willReturn(ChatOptions.builder().build());
        springAiLlmGateway = new SpringAiLlmGateway(ChatClient.builder(chatModel));
    }

    @Test
    void 응답에서_문장과_모델명과_토큰_수를_꺼내_반환한다() {
        // given
        given(chatModel.call(any(Prompt.class)))
                .willReturn(chatResponse("김치와 밥이 필요해요.", "gpt-test", 12, 7));

        // when
        LlmReply reply = springAiLlmGateway.explain("너는 장보기 도우미야", "김치볶음밥 재료");

        // then
        assertThat(reply.text()).isEqualTo("김치와 밥이 필요해요.");
        assertThat(reply.model()).isEqualTo("gpt-test");
        assertThat(reply.promptTokens()).isEqualTo(12);
        assertThat(reply.completionTokens()).isEqualTo(7);
    }

    @Test
    void system_프롬프트와_사용자_말을_각각의_메시지로_전달한다() {
        // given
        given(chatModel.call(any(Prompt.class)))
                .willReturn(chatResponse("답변", "gpt-test", 1, 1));
        ArgumentCaptor<Prompt> promptCaptor = ArgumentCaptor.forClass(Prompt.class);

        // when
        springAiLlmGateway.explain("너는 장보기 도우미야", "김치볶음밥 재료");

        // then
        then(chatModel).should().call(promptCaptor.capture());
        Prompt prompt = promptCaptor.getValue();
        assertThat(prompt.getSystemMessage().getText()).isEqualTo("너는 장보기 도우미야");
        assertThat(prompt.getUserMessage().getText()).isEqualTo("김치볶음밥 재료");
    }
}
  • 첫 번째 테스트는 3번(응답 꺼내기)을 검사한다. promptTokens와 completionTokens를 바꿔 담는 실수를 잡는다.
  • 두 번째 테스트는 1번(요청 만들기)을 검사한다. ArgumentCaptor로 OpenAI 쪽에 실제로 넘어간 Prompt를 붙잡아 system과 user가 바뀌지 않았는지 본다.
  • 필요한 Spring AI 클래스와 생성자(ChatResponse, Generation, AssistantMessage, ChatResponseMetadata, DefaultUsage)는 2.0.1 jar를 javap로 열어 확인했다. 문서에 없는 부분이 많았다.

결과: SpringAiLlmGateway 16줄 중 16줄이 CI에서 실행된다

OpenAI 키를 지운 환경(CI와 같은 조건)에서 ./gradlew build를 돌린 결과다.

테스트 결과
SpringAiLlmGatewayMockTest 2개 통과
FakeLlmGatewayTest 3개 통과
ZeloApplicationTests 1개 통과
SpringAiLlmGatewayTest (실제 호출) 1개 건너뜀 (의도대로)

배운 점

  • "테스트가 있다"와 "CI에서 실행된다"는 다르다. 건너뛴 테스트는 빌드를 깨뜨리지 않지만 커버리지에도 아무것도 남기지 않는다. 로컬에서 초록불이어도 CI 환경(키, 프로필)에서는 달라질 수 있다.
  • 외부 API를 부르는 클래스는 "내 코드"와 "라이브러리가 하는 일"을 나눠서 테스트한다. 내 코드는 mock 단위 테스트로 CI에서 항상, 실제 연결은 로컬에서 가끔 확인한다.
  • mock은 경계에서 한 번만. ChatClient를 직접 쓰는 클래스는 ChatModel을, 그 위의 클래스는 LlmGateway를 mock한다. 인터페이스를 두면 위쪽 테스트가 Spring AI를 몰라도 된다.