LLM 호출을 안정적으로 만들기: 재시도, 세마포어, 캐시, 배치
출처: Dev.to
TestSmith이 --llm 옵션으로 테스트를 생성할 때, 처리 중인 각 소스 파일의 모든 public 멤버마다 LLM을 호출합니다. 파일이 20개이고 각각 5개의 public 함수가 있다면 한 번의 실행에 최대 100번의 API 호출이 발생합니다. 이는 오류가 발생할 수 있는 표면적이 매우 넓다는 뜻이죠.
우리가 만든 신뢰성 스택을 레이어별로 살펴보겠습니다.
1. 재시도 미들웨어
LLM API는 일시적으로 실패할 수 있습니다. 레이트 제한, 타임아웃, 가끔 발생하는 5xx 응답 등—이 모든 상황은 기다렸다가 재시도하면 복구할 수 있습니다. 우리는 어떤 Provider든 감싸는 재시도 미들웨어를 구현했습니다.
type RetryProvider struct {
inner Provider
maxRetries int
}
func (r *RetryProvider) Complete(ctx context.Context, req CompletionRequest) (CompletionResponse, error) {
var lastErr error
for attempt := 0; attempt < r.maxRetries; attempt++ {
if attempt > 0 {
wait := time.Duration(math.Pow(2, float64(attempt))) * 100 * time.Millisecond
select {
case <-time.After(wait):
case <-ctx.Done():
return CompletionResponse{}, ctx.Err()
}
}
resp, err := r.inner.Complete(ctx, req)
if err == nil {
return resp, nil
}
lastErr = err
}
return CompletionResponse{}, fmt.Errorf("after %d attempts: %w", r.maxRetries, lastErr)
}
MaxRetryAttempts는 기본값이 3이며, 지수 백오프를 적용합니다: 1번째 시도는 즉시, 2번째는 200 ms 대기, 3번째는 400 ms 대기합니다. 호출당 최악의 대기 시간은 1초 미만으로, 백그라운드 도구에 충분히 허용 가능한 지연입니다.
2. 세마포어로 동시 호출 제한
최대 100개의 호출을 동시에 만들면, 고루틴 팬아웃이 가장 직관적인 방법입니다. 하지만 100개의 동시 요청을 LLM API에 보내면 레이트 제한에 바로 걸립니다. 이를 방지하기 위해 세마포어를 사용해 진행 중인 호출 수를 제한합니다.
type SemaphoreProvider struct {
inner Provider
sem chan struct{}
}
func NewSemaphoreProvider(inner Provider, maxConcurrent int) *SemaphoreProvider {
return &SemaphoreProvider{inner: inner, sem: make(chan struct{}, maxConcurrent)}
}
func (s *SemaphoreProvider) Complete(ctx context.Context, req CompletionRequest) (CompletionResponse, error) {
select {
case s.sem <- struct{}{}:
defer func() { <-s.sem }()
case <-ctx.Done():
return CompletionResponse{}, ctx.Err()
}
return s.inner.Complete(ctx, req)
}
MaxConcurrentCalls의 기본값은 5입니다. 각 재시도 시도마다 자체 세마포어 슬롯을 획득합니다—이는 중요한 포인트입니다. 재시도 로직이 대기 중에 슬롯을 잡고 있으면 다른 고루틴이 불필요하게 차단되기 때문입니다. 재시도 래퍼가 외부 레이어이고, 세마포어가 내부 레이어입니다.
3. 미들웨어 스택 조립
팩토리에서 만든 스택은 다음과 같습니다.
retry → semaphore → raw provider
4. 콘텐츠 주소 기반 캐시
많은 테스트 생성 실행이 동일한 파일을 반복해서 다룹니다—특히 워치 모드가 그 극단적인 경우죠. 같은 소스 코드를 두 번 호출하는 것은 낭비입니다. 이를 방지하기 위해 콘텐츠 주소 기반 캐시를 도입했습니다.
type ResultCache struct {
mu sync.RWMutex
entries map[string][]BodyGenResult
hits int
misses int
}
func cacheKey(req BodyGenRequest) string {
h := sha256.New()
fmt.Fprintf(h, "%s\n%s\n%s\n%s", req.Language, req.MemberName, req.SourceCode, req.Framework.Name)
return hex.EncodeToString(h[:])
}
키는 언어, 멤버 이름, 소스 코드, 프레임워크 이름을 조합해 만든 SHA‑256 해시입니다. 소스 파일이 바뀌면 해시도 바뀌어 캐시 미스가 발생하므로, 변경된 코드에 대해서는 항상 최신 결과를 얻을 수 있습니다.
실행이 끝난 뒤 --verbose 옵션을 주면 캐시 통계를 출력합니다.
LLM cache — hits: 12 misses: 8 entries: 8
5. 배치 생성
팬아웃 방식은 public 멤버당 하나의 API 호출을 합니다. 파일에 함수가 10개라면 10번 호출하는 것이죠. 배치 생성을 사용하면 이를 하나의 호출로 압축할 수 있습니다.
func (g *LLMBodyGenerator) GenerateBatchBodies(
ctx context.Context,
reqs []BodyGenRequest,
) ([]BodyGenResult, error) {
prompt := buildBatchPrompt(reqs)
resp, err := g.provider.Complete(ctx, CompletionRequest{
SystemPrompt: batchSystemPrompt,
UserPrompt: prompt,
Model: g.model,
MaxTokens: g.maxTokens * len(reqs), // 요청 수에 맞게 스케일
Temperature: g.temperature,
ResponseFormat: "json_object", // 구조화된 출력
})
// ...
}
OpenAI의 response_format: {"type": "json_object"}를 이용해 구조화된 출력을 받습니다. 모델은 멤버당 하나의 엔트리를 담은 JSON 객체를 반환합니다.
{
"tests": [
{"name": "ProcessPayment", "code": "func TestProcessPayment(t *testing.T) { ... }"},
{"name": "RefundPayment", "code": "func TestRefundPayment(t *testing.T) { ... }"}
]
}
우리는 기본 JSON 파서를 사용하고, 구조화된 출력을 지원하지 않는 공급자에 대해서는 구분자‑정규식 파서로 폴백합니다.
파이프라인은 BatchBodyGenerator 인터페이스 구현 여부를 타입 어설션으로 확인합니다. 구현돼 있으면 배치 모드를 사용하고, 그렇지 않거나 드라이버가 명시적으로 배치를 비활성화하면 개별 호출을 위한 고루틴 팬아웃으로 돌아갑니다. 이렇게 하면 인터페이스가 옵트인 방식이면서도 이전 버전과 호환됩니다.
6. 캐시 통계 보고 인터페이스
모든 작업이 백그라운드에서 진행될 때, 실제로 무엇이 실행됐는지 알면 좋습니다. cacheStatsReporter 인터페이스를 통해 CLI가 LLM 패키지를 직접 임포트하지 않고도 통계를 조회할 수 있습니다.
// In cmd/testsmith/generate.go — avoids importing internal/llm from the CLI layer
type cacheStatsReporter interface {
CacheStats() (hits, misses, size int)
}
func printCacheStats(bg domain.BodyGenerator) {
if !verbose {
return
}
if r, ok := bg.(cacheStatsReporter); ok {
hits, misses, size := r.CacheStats()
fmt.Printf("LLM cache — hits: %d misses: %d entries: %d\n", hits, misses, size)
}
}
이는 인터페이스 분리 원칙이 적용된 사례입니다. CLI는 파이프라인에 필요한 domain.BodyGenerator와 통계 출력을 위한 cacheStatsReporter만 알면 됩니다. LLM 구현 세부 사항을 알 필요가 없죠.
7. 실제 효과
중간 규모 Go 프로젝트(소스 파일 40개, 평균 6개의 public 함수)에서의 실제 수치는 다음과 같습니다.
| 상황 | API 호출 수 | 예상 소요 시간 (5 동시) |
|---|---|---|
| 배치 미사용 | 240 | 약 4분 |
| 배치 사용 | 40 | 약 45초 |
| 두 번째 실행 (캐시 히트) | 거의 즉시 | 변경되지 않은 파일은 즉시 반환 |
캐시와 배치 생성을 결합하면, “커피 한 잔 만들기” 수준이던 작업을 흐름을 끊지 않고도 수행할 수 있게 됩니다.
다음 주제
다음 글에서는 TestSmith 자체를 위한 AI 에이전트와, 여러분 프로젝트의 테스트를 생성하는 LLM을 위한 컨텍스트 구조화에 대해 다룰 예정입니다.