Post

테스트 짜다가 코드를 고치기

테스트 짜다가 코드를 고치기

지난 시간

지난 시간에 캐시 히트 테스트를 짜봤습니다.
통과했는데 아무것도 안 지키고 있었고, 이리저리 코드를 박살내고 나서야 무언가 잘못된걸 알았습니다.

이번에는 반대쪽인 캐시 미스를 볼 차례입니다.
근데 이거 짜다가 결국 QueryVectorService 코드를 고치게 됐습니다. 그 얘기까지 해보겠습니다.

캐시 미스

getVector의 else를 봅시다.

1
2
3
4
5
} else {
    normTovector = embeddingClient.embedQuery(norm);
    QueryVectorCache cache = new QueryVectorCache(norm, voyageModel, normTovector, LocalDateTime.now());
    queryVectorCacheRepository.save(cache);
}

이걸 글로 설명하면
캐시에 값 없음 -> API 호출 + 받은 값 리턴 -> 결과 저장.

확인할게 2개인데, API를 부르는지, 그리고 저장을 하는지.

저장을 하는지 안하는지가 제일 중요합니다.
API를 부르는지 아닌지는 바로 테스트에서 확인이 가능한데, save를 빼먹어도 앱은 잘 돌아가고 결과도 잘 나오거든요.
근데 저장을 안하면, 캐시가 영영 안 채워져서 매번 미스가 납니다. 캐시를 만든 의미가 통째로 사라지는데 화면으로는 알 수가 없는 찐빠가 발생합니다.

when? verify?

getVector가 일꾼들을 부르는 호출이 딱 3개인데, 규칙을 정리하자면,

호출하는 일쓰는 것
findByNormalizedQueryAndModel값을 받아온다when
embedQuery값을 받아온다when
save시키기만 한다verify

값을 받아오는 호출은 when으로 일감을 줍니다. 안 주면 일꾼이 빈손으로 오니깐요.
반대로 save는 리턴값을 안 쓰니깐 일감을 줄 이유가 없고, 일을 했나 안 했나만 확인하면 되겠죠.

캐시 미스 짜보기.

처음 짠 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
void 캐시미스시_API_호출_및_저장() {

    EmbeddingClient embeddingClient = mock(EmbeddingClient.class);
    QueryVectorCacheRepository queryVectorCacheRepository = mock(QueryVectorCacheRepository.class);

    when(queryVectorCacheRepository.findByNormalizedQueryAndModel(any(), any()))
            .thenReturn(Optional.of(null));                                      // 1번

    QueryVectorService queryVectorService = new QueryVectorService(embeddingClient, queryVectorCacheRepository);

    float[] result = queryVectorService.getVector("테스트");

    when(embeddingClient.embedQuery(any())).thenReturn(new float[]{0.1f, 0.1f}); // 2번

    assertThat(result).containsExactly(0.1f, 0.1f);
    verify(queryVectorCacheRepository).save(any());
}

1번, Optional.of(null)

캐시가 비어있는 상황을 만들려고 null을 넣었는데 NPE가 났습니다.

지난 6편에서 Optional을 값과 있음/없음을 같이 전달한다고 했었는데,
그런데 of()는 있음 전용이라 이 값은 확실히 있다고 선언하는 거라, null을 넣으면 있다며? 하고 바로 터집니다.
애초에 Optional이 null을 몰아내려고 만든 물건인데 null을 담아주면 앞뒤가 안 맞는 상황이 되는거죠.

그래서, 진짜로 없음을 만들려면 파라미터를 아예 안 받는 Optional.empty()를 써야 했습니다.

2번, when이 실행 아래에 있음

이건 좀 어이없는 실수인데, when을 getVector 호출 뒤에 썼습니다.

일꾼한테 일감을 퇴근하고 나서 준거죠.
getVector가 돌아가는 그 순간에 일꾼은 아직 일감이 없어서 빈손으로 오고, result는 null이 됩니다.

when으로 시작하는 줄은 전부 실행보다 위에 있어야 합니다.

두 개 고치니 통과했습니다.

참고로 0.1f, 0.1f라는 값은 별 의미가 없습니다. 진짜 Voyage API가 뭘 돌려줄지랑도 별 상관없고요.
중요한건 캐시 히트 테스트에서 쓴 1.0f, 0.0f랑 다르다는 것 뿐입니다.
result에서 0.1f, 0.1f가 나왔으면 API를 거쳐서 나왔다는 증거가 됩니다.

저장은 했는데, 뭘 저장함?

1
verify(queryVectorCacheRepository).save(any());

이 줄은 save가 불렸다까지만 봅니다. any()가 뭐가 들어오든 다 통과시키거든요.

그런데 이런 버그를 생각해봅시다.

1
2
QueryVectorCache cache = new QueryVectorCache(query, voyageModel, normTovector, LocalDateTime.now());
//                                            norm이 아니라 원본 query

한 글자 차이로 정규화 안 된 문자열이 저장됩니다.
저장은 됐으니 위 테스트는 통과하고요.

근데 다음번에 조회할 때는 정규화된 문자열로 찾잖아요?
“ AI 투자 “로 저장해놓고 “ai 투자”로 찾으니 영영 안 맞습니다. 캐시 히트가 절대 안 나는 상태가 되는거죠.
6편에서 공들여 만든 정규화가 통째로 무의미해집니다.

그래서 저장했다가 아니라 뭘 저장했는지를 봐야 합니다.

ArgumentCaptor

save에 넘어간 객체를 붙잡는 도구가 있습니다.

1
2
3
ArgumentCaptor<QueryVectorCache> captor = ArgumentCaptor.forClass(QueryVectorCache.class);  // 그물 준비
verify(queryVectorCacheRepository).save(captor.capture());                                  // any() 자리에 그물
QueryVectorCache saved = captor.getValue();                                                 // 잡힌거 꺼내기

capture()가 any() 자리를 대신합니다.
여전히 아무거나 통과시키면서, 지나가는 놈을 몰래 기록해두는거죠.
이제 saved는 서비스가 실제로 save에 넘긴 그 객체입니다.

다이어트 성공?

이제 saved.getNormalizedQuery()로 저장된 문자열을 볼 수 있는데, 문제가 하나 있었습니다.

지금 테스트는 getVector(“테스트”)로 부르고 있거든요.
저번에 정규화를 할때 어떻게 되도록 했냐면,

1
NFC 정규화 -> 앞뒤 공백 제거 -> 연속 공백 한 칸으로 -> 소문자

“테스트”에는 자를 공백도, 줄일 연속 공백도, 소문자로 바꿀 대문자도 없습니다.
정규화를 하든 안 하든 결과가 똑같아요.

이러면 저장된 값이 “테스트”인걸 확인해봤자 아무 의미가 없습니다.
다이어트 선언하고 70kg에서 70kg으로 다이어트 성공한 셈이죠.

그래서 입력을 좀 분별 가능한걸로 바꿨습니다.

1
" QVC  API  테스트 "   ->   "qvc api 테스트"

앞뒤 공백, 연속 공백, 대소문자 세가지가 한번에 검증됩니다.

굳이 불편하게 왜?

아니 정규화 메소드 있는데 왜 안씀? 이라고 물으실 수 있겠는데,

1
assertThat(saved.getNormalizedQuery()).isEqualTo(normalizeQuery(" QVC  API  테스트 "));

안 됩니다. 양쪽이 같은 메서드를 쓰니깐요.

normalizeQuery에서 실수로 toLowerCase가 빠졌다고 치면,
왼쪽도 소문자가 안 되고, 오른쪽도 소문자가 안 됩니다. 그래서 똑같고, 통과하겠죠.
버그가 있는데 초록불이에요. 지난 시간의 embedDocuments랑 똑같은 함정입니다.

기대값은 코드가 아니라 사람 머리에서 나와야 합니다.

1
assertThat(saved.getNormalizedQuery()).isEqualTo("qvc api 테스트");

하드코딩이 좀 찜찜한데, 테스트에서는 이게 맞다고 합니다.
테스트는 코드가 이렇게 동작한다를 적는 곳이 아니라, 코드가 이렇게 동작해야 한다를 알려주는 곳이니깐요.

생각해보면 1편에서 코사인 유사도 테스트 할 때도 비슷했죠.
코드를 다 짜고 테스트를 짰는데, 1024차원은 손으로 못 푸니깐 2차원으로 줄인 다음에 테스트 했었죠.
같은 방향이면 1, 다른 방향이면 -1.
그거랑 같은 방식입니다. 다만 normalizeQuery가 private이라 직접 못 부르니 captor로 잡은 객체를 통해 간접적으로 볼 뿐이죠.

그런데 모델 이름이 null?

정규화까지 통과했으니 모델 이름도 확인해보기로 했습니다.

1
assertThat(saved.getModel()).isEqualTo("아무모델");

근데 값이 null이네요?

1
2
@Value("${voyage.model}")
private String voyageModel;

@Value는 스프링이 빈을 만들면서 값을 꽂아주는 건데,
테스트에서는 제가 new로 직접 만들었잖아요? 스프링은 이 객체가 있는지도 모르겠죠.
아무도 안 꽂아줬으니 String 필드 초기값인 null이 그대로 남은거죠.

지금까지 안 터진건 조회할 때 any()로 뭉개고 있었기 때문입니다.

여기서 같은 클래스 안에 있는 세 개가 갈리는데,

필드어떻게 들어오나테스트에서
embeddingClient생성자내가 넣으면 됨
queryVectorCacheRepository생성자내가 넣으면 됨
voyageModel@Value 필드 주입손댈 방법 없음

앞의 둘은 문제가 없었습니다. 생성자로 받으니깐 제가 직접 넣을 수 있었거든요.
voyageModel만 스프링한테 의존하고 있었던겁니다.

생성자 주입으로

그래서 그냥 voyageModel도 생성자 주입으로 바꿨습니다.
embeddingClient랑 repository는 생성자로 받는데 voyageModel만 다른 방식이었어요.

사실 기존 코드를 안 건드리는 방법도 있긴 있습니다. ReflectionTestUtils라고, private 필드에 강제로 값을 꽂아넣는 도구가 있는데,
근데 그러면 필드 이름을 문자열로 적어야 해서, 나중에 이름 바꾸면 컴파일러가 못 잡아내고 실행할 때 터집니다.

사실 다른 이유가 더 중요한데, 테스트가 불편하다고 알려준 지점이 실제로 설계가 약한 지점이었기 때문이죠.

테스트는 어떻게 설계의 결함을 알려주는가 (클릭하여 펼치기)

현 코드에서는 QueryVectorService는 스프링 하나에서만 사용합니다. 즉, 스프링에 맞춰져 있어서 문제가 없어 보이죠.

반면, 이걸 테스트를 끌고 온다면? 사용층이 하나 더 생기는거죠. 스프링이랑 테스트로. 테스트에서 QueryVectorService가 스프링에 종속되어 있다는걸 발견한거죠.

1
2
3
@Value("${voyage.model}")
private String voyageModel;
// 스프링 없이는 불완전
1
2
public QueryVectorService(..., @Value("${voyage.model}") String voyageModel)
// 생성하려면 voyageModel이 필요한데, 누가 주든 상관 X

추후에 확장하다 보면 발생 할 수 있는 문제를 테스트에서 드러낸겁니다.
즉, 테스트가 불편하면, 그 클래스를 다른 데서 쓰기도 불편하다. 이겁니다.

근데, 늘 그런건 아닙니다. 저 처럼 테스트 도구를 잘 몰라서 불편 할 수도 있겠죠. 또, 갑과 을이 뒤바뀌는, 테스트 코드를 위해서 기존 코드를 뜯어 고치는 경우도 발생 할 수 있습니다.

그러면 어떻게?

그래서, 위 처럼 불편한 신호를 받았으면 먼저 확인을 해봐야 합니다. 내가 도구를 모르는 원시인인지, 아니면 진짜 설계 문제인지.

이번 건은, 같은 클래스 안에서 비교 대상이 명확하게 있었기 때문에 쉽게 찾을 수 있었습니다.

 embeddingClientrepositoryvoyageModel
이 클래스가 굴러가는 데 필요한가필요필요필요
앱 켤 때 정해지고 안 바뀌나그렇다그렇다그렇다
스프링이 채워주나채워준다채워준다채워준다
주입 방식생성자생성자필드
테스트에서문제없음문제없음막힘

보시면 공통점이 있는데, 차이점이 있죠. 이거 때문에 쉽게 찾을 수 있었습니다.

고친 코드

어노테이션은 필드뿐 아니라 생성자 파라미터에도 붙습니다.

1
2
3
4
5
6
7
8
9
private final String voyageModel;                    // @Value 떼고 final

public QueryVectorService(EmbeddingClient embeddingClient,
                          QueryVectorCacheRepository queryVectorCacheRepository,
                          @Value("${voyage.model}") String voyageModel) {
    this.embeddingClient = embeddingClient;
    this.queryVectorCacheRepository = queryVectorCacheRepository;
    this.voyageModel = voyageModel;
}

이러면 세 개가 전부 같은 방식이 됩니다.
스프링은 빈을 만들 때 이걸 보고 설정 파일에서 가져오라는 거구나 하고 채워주고, 테스트에서는 그냥 문자열을 넘기면 되는거고요.

VoyageEmbeddingClient도 모델 이름을 같은 식으로 받고 있어서 같이 고쳤습니다.
apiKey는 아직 필드 주입인데, 그 클래스를 테스트할 때 연습겸 해서 같이 옮기려고 남겨놨습니다.

AI가 추천하는 @InjectMocks는 여기서 안 됩니다 (클릭하여 펼치기)

중복을 줄이는 방법을 물어보면 @ExtendWith(MockitoExtension.class) + @Mock + @InjectMocks 조합을 추천받을 가능성이 높습니다.

그런데 이 클래스에는 안 맞습니다.
생성자에 String voyageModel이 있는데 @InjectMocks는 String을 못 채워서 null을 넣습니다.
방금 만든 모델 이름 검증이 도로 깨지는거죠.

그래서 @BeforeEach로 직접 만드는 쪽을 택했습니다.

정리하고 나니

테스트 두 개에 mock 만드는 코드가 똑같이 들어가 있길래 @BeforeEach로 뺐습니다.
@BeforeEach가 붙은 메서드는 각 테스트 직전에 매번 실행됩니다.

필드로 올리면 테스트끼리 값이 섞이지 않냐 싶은데, 안 섞입니다.
JUnit5는 테스트 메서드마다 클래스 인스턴스를 새로 만들거든요. 게다가 @BeforeEach가 매번 mock을 새로 할당하니 이전 테스트의 when 설정도 사라지고요.

최종 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class QueryVectorServiceTest {

    private static final String MODEL = "테스트 모델";
    private EmbeddingClient embeddingClient;
    private QueryVectorCacheRepository queryVectorCacheRepository;
    private QueryVectorService queryVectorService;

    @BeforeEach
    void setUp() {
        embeddingClient = mock(EmbeddingClient.class);
        queryVectorCacheRepository = mock(QueryVectorCacheRepository.class);
        queryVectorService = new QueryVectorService(embeddingClient, queryVectorCacheRepository, MODEL);
    }

    @Test
    void 캐시히트시_API_호출_안함() {
        QueryVectorCache cached = new QueryVectorCache("테스트", "모델1", new float[]{1.0f, 0.0f}, LocalDateTime.now());
        when(queryVectorCacheRepository.findByNormalizedQueryAndModel(any(), any())).thenReturn(Optional.of(cached));

        float[] result = queryVectorService.getVector("테스트");

        assertThat(result).containsExactly(1.0f, 0.0f);
        verify(embeddingClient, never()).embedQuery(any());
    }

    @Test
    void 캐시미스시_API_호출_및_저장() {
        ArgumentCaptor<QueryVectorCache> argumentCaptor = ArgumentCaptor.forClass(QueryVectorCache.class);
        when(queryVectorCacheRepository.findByNormalizedQueryAndModel(any(), any())).thenReturn(Optional.empty());
        when(embeddingClient.embedQuery(any())).thenReturn(new float[]{0.1f, 0.1f});

        float[] result = queryVectorService.getVector(" QVC  API  테스트 ");

        assertThat(result).containsExactly(0.1f, 0.1f);
        verify(queryVectorCacheRepository).save(argumentCaptor.capture());

        QueryVectorCache saved = argumentCaptor.getValue();
        assertThat(saved.getNormalizedQuery()).isEqualTo("qvc api 테스트");
        assertThat(saved.getModel()).isEqualTo(MODEL);
    }
}

이 두 개가 잡아내는 버그입니다.

망가뜨리는 방법어느 테스트가 잡나
캐시 조회를 아예 안 함캐시 히트
두 분기를 뒤바꿈둘 다
save를 빼먹음캐시 미스
정규화 안 된 문자열을 저장캐시 미스
모델 이름을 엉뚱하게 저장캐시 미스

전부 앱은 멀쩡히 돌아가는데 API 요금만 새는 종류입니다.

배운 점

  • 테스트를 연습해볼 겸 시작했는데, 다 하고 나니 프로덕션 코드가 바뀌어 있더라고요. 테스트하기 불편하다는게 설계가 약하다는 신호일 때가 있는거 같네요. ReflectionTestUtils로 우회할 수도 있다고 하는데, 그러면 그 신호를 못 본 척 하는 셈이었고요.
  • 기대값은 검증할 코드로 만들면 안 됩니다. 양쪽이 같이 틀려서 무조건 통과하거든요. 손으로 계산해서 박아넣는 하드코딩이 여기서는 오히려 맞습니다.

아직 한참 남음

아직 많은게 남았습니다.

캐시 히트 테스트를 보면 findByNormalizedQueryAndModel(any(), any())라서 조회할 때 뭘 넘겼는지는 안 따져요.
저장은 정규화를 검증했는데 조회는 안 한거죠. 저장은 “qvc api 테스트”로 하고 조회는 “ QVC API 테스트 “로 하면 캐시가 영영 안 맞을테니깐요.

그리고 캐시 미스 테스트가 지금 네가지를 검증하고 있습니다. 리턴값, save 호출, 정규화, 모델 이름.
근데 테스트 이름에는 정규화 얘기가 없어요. 이름이랑 내용이 어긋나 있는겁니다.

Voyage API가 터졌을 때 예외가 밖으로 잘 나가는지도 아직 안 봤고요.

차차 해결해 나가도록 합시다.

다음 시간

이제 진짜로 메모리 캐시를 얹을 차례입니다.
메모리 -> 미스 -> DB -> 미스 -> Voyage API 이 구조요.

다음 시간에 LRU라는걸 직접 만들어 볼까 하는데, 스프링에 붙이기 전에 순수 자바 클래스로 먼저 만들고 테스트부터 짜보려고 합니다.

This post is licensed under CC BY 4.0 by the author.