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는 스스로 기준을 만들지 않고 우리가 써 둔 테스트를 실행하고, 테스트가 실제로 실행한 코드만 "덮었다"고 센다.
우리 팀의 .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-로 시작할 때만 테스트를 실행한다.
이렇게 만든 이유는 세 가지였다.
- 호출할 때마다 요금이 나간다.
- LLM은 매번 다르게 답해서 결과가 일정하지 않다.
- CI의 dummy-key-for-ci로 호출하면 401로 실패해 빌드가 깨진다. 그래서 "키가 있으면"이 아니라 "진짜 키 형식이면"으로 조건을 걸었다.
결과적으로 CI에서는 이렇게 됐다.
| 테스트 | CI에서 | AiLlmGateway 실행 |
| FakeLlmGatewayTest | 실행 | 안 함 (Fake만 검사) |
| ZeloApplicationTests | 실행 | 안 함 (test 프로필이라 Fake가 뜨고, 빈도 만들어지지 않음) |
| SpringAiLlmGatewayTest | 건너뜀 | 안 함 |
셋 중 누구도 SpringAiLlmGateway를 실행하지 않았고, 그래서 어느 줄도 "덮인 줄"로 세지 않았다. 로컬에서 테스트가 통과한 것은 내 IntelliJ에 진짜 키가 있었기 때문이다.
즉 "CI에서 진짜 OpenAI를 부르지 않는다"는 올바른 결정이, "그 클래스를 검사하는 테스트가 CI에 하나도 없다"는 결과를 낳은 것이다.
해결 방법 검토: OpenAI와 통신하는 부품만 mock으로 바꾸기로 했다
먼저 SpringAiLlmGateway가 책임지는 일을 세 가지로 나눠 봤다.
- 요청 만들기: 우리 요청을 system 메시지와 user 메시지로 담는다.
- 실제 통신: 키, 모델, 타임아웃 설정으로 OpenAI와 HTTP 통신을 한다.
- 응답 꺼내기: 응답에서 문장, 모델명, 토큰 수를 꺼내 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를 몰라도 된다.
'개발공부 > 인프라 · DevOps' 카테고리의 다른 글
| [Troubleshooting] SonarCloud 자동 분석과 GitHub Actions CI 분석 충돌 해결하기 (0) | 2026.10.18 |
|---|---|
| [Troubleshooting] GitHub Actions에서 발생한 Hibernate Dialect 오류 해결하기 (0) | 2026.10.17 |
| [궁금증] NAT 게이트웨이 - 프라이빗 ip주소로 변환한 다음에는..? + 느낀점 (0) | 2023.07.22 |
| [궁금증] S3에서 객체 업로드 시 비용이 발생하지 않도록 하려면 어떻게 해야하나? (0) | 2023.07.21 |
| [궁금증] EC2에서 왜 OS를 지정할까? (0) | 2023.07.20 |