언어 독립 코드 생성: 드라이버 플러그인 모델

발행: (2026년 5월 24일 AM 01:29 GMT+9)
8 분 소요
원문: Dev.to

Source: Dev.to

TestSmith은 Go, Python, TypeScript, Java, C# 다섯 가지 언어에 대한 테스트 스캐폴드를 생성합니다. 각 언어마다 프로젝트 구조 관례, 테스트 프레임워크, import 방식, 코드 패턴이 다릅니다. 가장 단순한 구현은 코드베이스 전체에 큰 switch 문을 두는 것이었겠지만, 우리는 플러그인 모델을 선택했습니다.
코드베이스가 여러 곳에서 언어를 switch 하게 되면, 새로운 언어를 추가할 때마다 모든 분기 지점을 수정해야 합니다. 하나라도 놓치면 새로운 언어가 적용되지 않은 기본 동작으로 빠져들어 조용한 버그가 발생합니다. 이는 전형적인 Open‑Closed 원칙 위반이며, 확장을 위해 기존 코드를 수정해야 하는 상황입니다.

TestSmith의 모든 언어는 하나의 인터페이스를 구현합니다:

type LanguageDriver interface {
    // Detection
    DetectProject(dir string) (*ProjectContext, error)
    FileExtensions() []string

    // Analysis
    AnalyzeFile(path string, ctx *ProjectContext) (*SourceAnalysis, error)
    ClassifyDependency(dep ImportInfo, ctx *ProjectContext) DependencyCategory
    DeriveTestPath(sourcePath string, ctx *ProjectContext) (string, error)
    DeriveModulePath(sourcePath string, ctx *ProjectContext) (string, error)

    // Generation
    GenerateTestFile(analysis *SourceAnalysis, opts GenerateOpts) (*GeneratedFile, error)
    GenerateFixture(dep string, analysis *SourceAnalysis, opts GenerateOpts) (*GeneratedFile, error)
    GenerateBootstrap(plan *GenerationPlan, ctx *ProjectContext) (*GeneratedFile, error)

    // Framework config
    GetTestFrameworkConfig() TestFrameworkConfig
    SelectAdapter(ctx *ProjectContext) TestAdapter

    // LLM integration
    LLMContext(ctx *ProjectContext) map[string]string
    LLMVocabulary() map[string]string

    // Migration and validation
    ListMigrators() []Migrator
    ValidateFile(path string, ctx *ProjectContext) ([]ValidationIssue, error)
}

생성 파이프라인, CLI 명령, 그리고 워치 모드 모두 이 인터페이스를 기준으로 동작합니다. 특정 드라이버 패키지를 직접 import 하지 않죠.

testsmith generate를 실행하면 가장 먼저 현재 프로젝트가 어떤 언어인지 판단합니다. 레지스트리는 등록된 드라이버를 차례로 시도합니다:

func Detect(dir string) (domain.LanguageDriver, error) {
    for _, d := range drivers {
        ctx, err := d.DetectProject(dir)
        if err == nil && ctx != nil {
            return d, nil
        }
    }
    return nil, domain.ErrProjectNotFound
}

각 드라이버의 DetectProject는 시작 디렉터리에서 위로 올라가면서 자신만의 프로젝트 마커를 찾습니다—Go는 go.mod, Python은 pyproject.toml 혹은 setup.py, TypeScript는 package.json, Java는 pom.xml 혹은 build.gradle, C#은 .csproj 혹은 .sln.

여기서 미묘한 요구사항이 하나 있습니다. 드라이버는 자신과 다른 언어의 상위 프로젝트를 차지해서는 안 됩니다. 예를 들어 Go 레포 안에 있는 예제 프로젝트에서 TestSmith을 실행했을 때, Python 드라이버가 Go 프로젝트의 .git 경계 너머까지 올라가서 레포 루트를 차지해서는 안 됩니다. 이를 해결하기 위해 VCS 중지 마커(.git, .hg, .svn)를 조상 디렉터리에서만 검사하고, 시작 디렉터리 자체에서는 검사하지 않습니다. 정상적인 프로젝트 루트는 프로젝트 마커와 .git 디렉터리를 동시에 가질 수 있기 때문입니다.

func findRoot(startDir string) (string, error) {
    dir := startDir
    for {
        // 조상 디렉터리에서는 VCS 경계에서 먼저 멈춘다.
        if dir != startDir {
            for _, stop := range stopMarkers {
                if _, err := os.Stat(filepath.Join(dir, stop)); err == nil {
                    return "", domain.ErrProjectNotFound
                }
            }
        }
        // 그 다음 프로젝트 마커를 확인한다.
        for _, marker := range rootMarkers {
            if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
                return dir, nil
            }
        }
        parent := filepath.Dir(dir)
        if parent == dir {
            break
        }
        dir = parent
    }
    return "", domain.ErrProjectNotFound
}

한 언어 안에서도 여러 테스트 프레임워크가 존재할 수 있습니다. TypeScript는 Jest, Vitest, Mocha를, Java는 JUnit 4, JUnit 5, TestNG, Spring Boot Test 등을 지원합니다. 각 프레임워크마다 import 스타일, 목(mock) 라이브러리, 어설션 문법, 파일 명명 규칙이 다릅니다.

이를 위해 우리는 TestAdapter 인터페이스를 정의했습니다:

type TestAdapter interface {
    Name() string
    FileNamingConvention() FileNaming
    ImportStyle() ImportStyle
    MockLibrary() string
    AssertionStyle() string
    LLMVocabulary() map[string]string
}

각 드라이버는 어댑터 레지스트리를 가지고 있으며, SelectAdapter 메서드는 프로젝트 설정(또는 package.jsondevDependencies, pom.xml 의존성 등)을 읽어 올바른 어댑터를 선택합니다. LLM 프롬프트는 선택된 어댑터의 어휘(vocabulary)를 사용하므로, 모델은 Jest에서는 expect(x).toBe(y), Go의 testify에서는 assert.Equal(t, x, y)와 같이 적절한 코드를 생성합니다.

모든 것이 인터페이스를 통해 흐르기 때문에 새로운 언어 드라이버를 추가하는 작업은 완전히 격리됩니다:

  1. internal/drivers// 아래에 새 패키지를 만든다.
  2. domain.LanguageDriver를 구현한다—컴파일러가 누락된 부분을 정확히 알려준다.
  3. internal/registry/registry.go에 등록한다.
  4. (선택) internal/generation/verify.go에 검증 로직을 추가해 파일 작성 후 컴파일 검사를 수행한다.

다른 파일을 수정할 필요가 없습니다. 기존 드라이버는 그대로 두고, 파이프라인, CLI, 워치 모드는 자동으로 새 드라이버를 인식합니다.

플러그인 모델은 엄격한 의존성 방향을 강제합니다:

cmd → generation → domain ← drivers
                           ← llm

domain이 인터페이스를 정의하고, drivers가 이를 구현합니다. generation은 인터페이스를 통해 사용하며, generationdrivers도 서로를 import 하지 않습니다. 이는 **패키지 수준에서 적용된 의존성 역전 원칙(Dependency Inversion Principle)**이며, Go의 import cycle 검출기에 의해 강제됩니다.

새 드라이버를 추가하면 generation 파이프라인이나 LLM 레이어에 실수로 접근하는 일이 불가능합니다—컴파일 자체가 실패하기 때문이죠. 아키텍처가 스스로를 보호합니다.

다음 시리즈에서는 모든 소스 파일의 공개 멤버마다 LLM 호출을 수행할 때 신뢰성을 높이는 방법을 다룰 예정입니다.

0 조회
Back to Blog

관련 글

더 보기 »

내 스킬

프로젝트를 위한 AI 지시문을 만들고, 설치하고, 관리하세요 — 코딩이 필요 없습니다. CREATE 이름을 정하고, 카테고리를 선택하고, 원하는 것을 설명하세요 — 마법사가 자동으로 구성합니다.