🧪 테스트 시나리오 | 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)
- 문제 재현: 위의 Gradle 명령으로 실패를 재현하고 테스트 리포트를 확인했습니다 (경로:
app/build/reports/tests/testDebugUnitTest/index.html). - 원인 분석:
AiAutoSelector의 가중치 설정 중 테스트 생성 관련 가중치가 GPT4O에 비교적 높게 부여되어 GPT5MINI보다 우선되었음. - 수정 전략: 최소 변경 원칙(부작용 최소화)에 따라 GPT4O의 test-generation 가중치만 낮추기로 결정했습니다. 이는 로직 구조를 바꾸지 않고 가중치 파라미터 하나만 조정하는 안전한 접근입니다.
- 수정 적용:
AiAutoSelector.kt에서W_TEST_GEN_GPT4O값을 2.5 → 1.0으로 낮춤. - 검증: 단위 테스트를 다시 실행하여 전체 통과 확인.
핵심 변경 코드 (설명 포함)
원리: 모델 점수는 가중치 합으로 계산되며, 테스트 생성 시 GPT5MINI와 GPT4O에 보너스가 더해집니다. 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 기본값으로 문제 격리 (런타임 안전성 향상)
커스터마이징 & 실험 방법
- 속도 실험:
W_SPEED_GROK_FAST1값을 4.0 → 5.0으로 조정 후 테스트 재실행하여 primary 변화를 관찰. - 테스트 생성 민감도:
W_TEST_GEN_GPT5MINI를 상향(예: 4.0)하면 다른 플래그 동시 활성 시에도 안정적 우선 확보. - 복잡 작업 분리:
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 파일 로드 → 선택 로그 집계
회고 (추가)
"테스트 한 개 실패" → "설계 정책과 실행 로직 사이의 불일치" → 설정 외부화로 구조적 해법을 적용한 흐름은 작은 문제를 장기 개선 방향으로 연결한 좋은 사례였습니다. 테스트는 기능 검증뿐 아니라 우선순위 정책 정의서 역할을 하며, 이를 설정화함으로써 조직적 튜닝·실험 역량을 키울 수 있게 되었습니다.
'모바일 앱(안드로이드)' 카테고리의 다른 글
| 🕹️ Android | Wear Compose UI 레이아웃 정리와 중앙정렬, 문자열 리소스화 (0) | 2025.12.17 |
|---|---|
| 🔍 프로젝트 진단 | Health501 아키텍처 & 코드 품질 개선 로드맵 (1) | 2025.12.15 |
| 🦾 Android | 워치앱 빌드 오류 수정과 UI/국제화 개선 정리 (1) | 2025.12.11 |
| 🐾 Android | Kalman vs EMA — 고도/센서 데이터 필터링 비교와 적용기 (2) | 2025.12.09 |
| 🧭 Android | 위치 수신·히스토리 저장 및 Vico 그래프 NaN 크래시 대응 (1) | 2025.12.07 |