Today's

길을 나서지 않으면 그 길에서 만날 수 있는 사람을 만날 수 없다

모바일 앱(안드로이드)

🧪 테스트 시나리오 | AiAutoSelector 단위 테스트 실패 → 가중치 조정 및 외부 설정 리팩터링

Billcorea 2025. 12. 13. 15:56
반응형

🧪 테스트 시나리오 | AiAutoSelector 단위 테스트 실패 → 가중치 조정 및 외부 설정 리팩터링

앱 테스트 이미지

개요 (Intro)

  • 오늘의 목표 / 배경: 단위 테스트 실패 원인 분석 및 최소한의 수정으로 테스트 통과시키기
  • 해결하고자 한 문제: `AiAutoSelector`의 모델 우선순위 로직으로 인해 특정 테스트가 실패함 (GPT5MINI 대신 GPT4O가 선택됨)
  • 사용한 기술 스택: Kotlin, Gradle, JUnit (Android unit tests)

📅 날짜: 2025-12-13
🎯 목표: 실패 테스트 수정 + 가중치 외부 설정 리팩터링 + 테스트 커버리지 확장 + 회고 정리
🧰 기술: Kotlin, Gradle, JUnit, Properties(설정 외부화)

문제 정의 (Problem / Motivation)

작업 중 `:app:testDebugUnitTest`를 실행하면 `AiAutoSelectorTest`의 한 테스트가 실패했습니다. 실패 케이스는 테스트 생성(testGeneration)과 중간 복잡도(MEDIUM) 조합에서 GPT5MINI를 1순위로 기대했지만, 실제로는 GPT4O가 선택되어 assertion이 깨졌습니다.

재현 명령(터미널):

cd C:\Users\nari4\AndroidStudioProjects\Health501_private
./gradlew :app:testDebugUnitTest --stacktrace --info --no-configuration-cache --no-build-cache --no-parallel

실패 메시지(요약):

AiAutoSelectorTest > test generation medium complexity elevates gpt5mini FAILED
    java.lang.AssertionError: expected:<GPT5MINI> but was:<GPT4O>

관련 테스트(발췌):

// app/src/test/java/com/billcoreatech/health501/ai/AiAutoSelectorTest.kt
@Test
fun `test generation medium complexity elevates gpt5mini`() {
    val ctx = TaskContext(complexity = Complexity.MEDIUM, testGeneration = true)
    val sel = AiAutoSelector.select(ctx)
    // 기대: GPT5MINI가 primary여야 함
    assertEquals(AiModel.GPT5MINI, sel.primary)
}

해결 과정 (How I Solved It)

  1. 문제 재현: 위의 Gradle 명령으로 실패를 재현하고 테스트 리포트를 확인했습니다 (경로: app/build/reports/tests/testDebugUnitTest/index.html).
  2. 원인 분석: AiAutoSelector의 가중치 설정 중 테스트 생성 관련 가중치가 GPT4O에 비교적 높게 부여되어 GPT5MINI보다 우선되었음.
  3. 수정 전략: 최소 변경 원칙(부작용 최소화)에 따라 GPT4O의 test-generation 가중치만 낮추기로 결정했습니다. 이는 로직 구조를 바꾸지 않고 가중치 파라미터 하나만 조정하는 안전한 접근입니다.
  4. 수정 적용: AiAutoSelector.kt에서 W_TEST_GEN_GPT4O 값을 2.5 → 1.0으로 낮춤.
  5. 검증: 단위 테스트를 다시 실행하여 전체 통과 확인.

핵심 변경 코드 (설명 포함)

원리: 모델 점수는 가중치 합으로 계산되며, 테스트 생성 시 GPT5MINIGPT4O에 보너스가 더해집니다. GPT4O의 보너스가 너무 커서 GPT5MINI보다 우선되었으므로 GPT4O의 보너스를 조정했습니다.

// 변경 전 (요약)
private const val W_TEST_GEN_GPT5MINI = 3.5
private const val W_TEST_GEN_GPT4O = 2.5  // 이 값이 GPT4O를 너무 우대
private const val W_TEST_GEN_GPT41 = 2.0

// 변경 후
private const val W_TEST_GEN_GPT5MINI = 3.5
// GPT4O는 여전히 테스트 생성에 유용하지만, GPT5MINI가 우선되도록 가중치를 낮춤
private const val W_TEST_GEN_GPT4O = 1.0
private const val W_TEST_GEN_GPT41 = 2.0

코드의 의도(주석으로 설명):

// AiAutoSelector는 상황(context)에 따라 모델별 점수를 누적합니다.
// 예: testGeneration=true 면 GPT5MINI, GPT4O, GPT41에 각각 보너스가 추가됨.
// 아래는 점수 산출의 핵심 부분(요약)이며, 실제 파일에는 안전한 초기화 및 정렬 로직이 포함되어 있습니다.

// 테스트 생성 상황에서 GPT5MINI를 우선하도록 보너스를 크게 줌
this[AiModel.GPT5MINI] = this[AiModel.GPT5MINI]!! + W_TEST_GEN_GPT5MINI
// GPT4O의 보너스는 줄여 GPT5MINI가 우선되게 만듦
this[AiModel.GPT4O] = this[AiModel.GPT4O]!! + W_TEST_GEN_GPT4O

결과 (Result)

  • 단위 테스트 결과: 전체 테스트 통과. (7 tests, 0 failed)
  • 검증 커맨드: ./gradlew :app:testDebugUnitTest --no-configuration-cache --no-build-cache --no-parallel -S
  • 테스트 리포트 위치: app/build/reports/tests/testDebugUnitTest/index.html
✅ 수정 사항으로 인해 실패하던 테스트가 통과했습니다. (가중치만 변경해 기능/인터페이스 변경은 없음)

리팩터링 확장 (Weight Externalization)

초기 해결(내부 상수 조정) 후 AiAutoSelector의 하드코딩된 가중치를 Properties 파일로 분리하여 설정 중심 구조로 전환했습니다.

  • 파일 경로: app/src/main/resources/ai_weights.properties
  • 로딩 시점: 객체 초기화 시 ClassLoader로 로드 → 실패 시 기본값 fallback
  • 장점: 실험(AB 테스트), 모델 성능 지표 반영, 배포 없이 조정 가능
  • 안전성: null / 파싱 실패 → 기본값 유지 (의도치 않은 비정상 점수 방지)
# ai_weights.properties (요약)
W_TEST_GEN_GPT5MINI=3.5
W_TEST_GEN_GPT4O=1.0
W_TEST_GEN_GPT41=2.0
W_COMPLEXITY_MEDIUM_GENERAL=1.5
W_COMPLEXITY_COMPLEX_GPT41=5.0
W_COMPLEXITY_COMPLEX_CLAUDE=4.0
W_SPEED_GROK_FAST1=4.0
W_SPEED_GPT4O=2.0
W_SPEED_GPT5MINI=1.0
W_BASE_GENERAL_GPT4O=1.5
OVERRIDE_MULTIMODAL_GEMINI_BOOST=100.0
// 외부 가중치 로드 핵심 (AiAutoSelector.kt 발췌)
private val DEFAULTS = mapOf("W_TEST_GEN_GPT4O" to 1.0 /* ...생략... */)
private val weights: Map<String, Double> = loadWeights()
private fun loadWeights(): Map<String, Double> {
    val props = java.util.Properties()
    val map = mutableMapOf<String, Double>()
    try { AiAutoSelector::class.java.classLoader.getResourceAsStream("ai_weights.properties")?.use { props.load(it) } } catch (_: Exception) {}
    DEFAULTS.forEach { (k, def) -> map[k] = props.getProperty(k)?.toDoubleOrNull() ?: def }
    return map
}
private fun w(key: String) = weights[key] ?: DEFAULTS[key]!!

핵심 아이디어: 도메인 정책(모델 선택 기준)을 코드 로직에서 분리해 설정 중심으로 이동함으로써 향후 동적 업데이트(예: 서버에서 내려받은 weight 반영) 준비를 쉽게 함.

추가 테스트 커버리지

기존 테스트(7) + 신규 AiWeightsLoadTest (6) ⇒ 총 13 테스트로 주요 분기 커버.

시나리오 검증 핵심 Primary 기대
Medium + testGeneration 외부 가중치 적용 / 점수 합산 GPT5MINI
Multimodal + speed + test Override 우선순위 GEMINI
SpeedPriority + Simple 속도 가중치 반영 GROK_CODE_FAST1
Complex + ArchitectureRefactor 복잡/리팩터 가중치 균형 GPT41
Simple 기본 Tie-break 순서 검증 GPT4O
Medium + testGeneration (점수 구조) 가중치 합 = medium + test GPT5MINI
// 점수 구성 검증 테스트 (발췌)
val ctx = TaskContext(complexity = Complexity.MEDIUM, testGeneration = true)
val sel = AiAutoSelector.select(ctx)
val scores = sel.scores
val weightsField = AiAutoSelector::class.java.getDeclaredField("weights").apply { isAccessible = true }
val weights = weightsField.get(AiAutoSelector) as Map<*, *>
val expected = (weights["W_COMPLEXITY_MEDIUM_GENERAL"] as Double) + (weights["W_TEST_GEN_GPT5MINI"] as Double)
assertEquals(expected, scores[AiModel.GPT5MINI]!!, 1e-9)

결과 (Result) – 업데이트

  • 전체 테스트: 13 테스트 모두 통과 (외부 설정 적용 후 회귀 없음)
  • 리팩터링 영향 범위: 퍼블릭 API 변화 없음, 내부 점수 계산만 설정 기반으로 변경
  • 확장 용이성: 새 weight 키 추가 시 properties 파일 + select 로직 내 w("KEY") 호출만 추가
✅ 외부화 후 테스트 재실행 결과 안정성 확보
🧩 구성 변경은 코드 수정 없이 ai_weights.properties만 편집하면 됨
🛡️ 실패 시 fallback 기본값으로 문제 격리 (런타임 안전성 향상)

커스터마이징 & 실험 방법

  1. 속도 실험: W_SPEED_GROK_FAST1 값을 4.0 → 5.0으로 조정 후 테스트 재실행하여 primary 변화를 관찰.
  2. 테스트 생성 민감도: W_TEST_GEN_GPT5MINI를 상향(예: 4.0)하면 다른 플래그 동시 활성 시에도 안정적 우선 확보.
  3. 복잡 작업 분리: W_COMPLEXITY_COMPLEX_CLAUDE를 GPT41와 근접(4.0→5.0) 시 클로드 선택 확률 증가.

수정 후 재검증 명령:

# 가중치 수정 후 테스트 재실행
./gradlew :app:testDebugUnitTest --no-configuration-cache --no-build-cache --no-parallel

트러블슈팅 노트

  • 가중치 파일 누락: 리소스 로드 실패 시 기본값으로 동작 → 커스터마이징 반영 안 되면 경로/파일명 확인.
  • 잘못된 숫자: 파싱 실패(예: "abc") → 기본값 fallback. 장기적으로 로그 추가 검토.
  • 극단적 override: OVERRIDE_MULTIMODAL_GEMINI_BOOST가 너무 낮아지면 GEMINI 우선권 상실 → override 의도 유지 위해 다른 값 대비 충분히 큰지 확인.

향후 개선 계획

  • 핫 리로드: 앱 내 설정 변경 즉시 반영 (파일 타임스탬프 감시)
  • 동적 헬스 지표: 모델 응답 지연/실패율을 가중치에 실시간 반영
  • 검증 레이어: 범위 밖 값(음수/1000 이상) 자동 조정 + 경고 로그
  • 실험 파이프라인: A/B 그룹별 별도 properties 파일 로드 → 선택 로그 집계

회고 (추가)

"테스트 한 개 실패" → "설계 정책과 실행 로직 사이의 불일치" → 설정 외부화로 구조적 해법을 적용한 흐름은 작은 문제를 장기 개선 방향으로 연결한 좋은 사례였습니다. 테스트는 기능 검증뿐 아니라 우선순위 정책 정의서 역할을 하며, 이를 설정화함으로써 조직적 튜닝·실험 역량을 키울 수 있게 되었습니다.


 

반응형