| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- OkHttp Interceptor
- 66챌린지
- Kotlin FCM
- DataBinding
- 습관만들기
- android recyclerview
- 코틀린 코루틴
- 영어독립365
- Android Jetpack
- MVP Architecture
- 안드로이드 카카오 로그인
- 알고리즘 자바
- Android ViewPager2
- 안드로이드 갤러리 접근
- 안드로이드
- Android 12 대응
- Kotlin
- Android Interceptor
- Android Navigation
- scope function
- Android
- 영어공부
- Android 12
- 프로그래머스 알고리즘
- Android WebView
- coroutine
- Android ProgressBar
- Java
- 카카오 알고리즘
- WebView
- Today
- Total
주맨의 개발노트
[AI] 하네스 엔지니어링 - CLAUDE.md와 Detekt로 MVI 아키텍처 강제하기 본문
Claude Code를 프로젝트에 적극적으로 활용하면서 개발 속도는 확실히 빨라졌습니다. 새 화면의 기본 구조를 만들고, 반복적인 보일러플레이트를 작성하고, 유사한 패턴을 이어가는 작업에서 특히 효과가 컸습니다.
그런데 속도가 빨라진 만큼 새로운 문제가 생겼습니다. 코드 리뷰에서 MVI 패턴 위반이 발견되기 시작했습니다. CLAUDE.md에 MVI 규칙을 상세히 적어뒀는데도, Agent가 생성한 코드가 실제로 그것을 따랐는지 보장할 수 없었습니다.
이 글은 그 문제를 해결하기 위해 마틴 파울러의 Harness Engineering 개념을 참고하고, Detekt 커스텀 룰을 Sensor로 도입한 경험을 정리한 기록입니다.
Agent가 MVI 규칙을 어겼다
제 프로젝트의 feature 모듈은 MVI 패턴을 표준으로 사용합니다. ViewModel은 반드시 NeveraViewModel을 상속해야 하고, 상태 변경은 applyMutation을 통해서만 이루어져야 하며, *Content 컴포저블은 *UiState와 함수 타입 파라미터만 받아야 합니다.
Claude Code로 새 기능을 빠르게 만들다 보면, 다음과 같은 위반이 반복적으로 발생했습니다.
private fun onRefreshClicked() = intent { reduce { state.copy(isLoading = true) } // applyMutation 밖에서 직접 호출 }
class SettingsViewModel @Inject constructor( private val settingsRepository: SettingsRepository, ) : ViewModel() { // NeveraViewModel 대신 ViewModel() 직접 상속 ... }
@Composable fun LoginContent( email: String, // UiState 필드를 개별 파라미터로 분해 password: String, emailValidation: EmailValidationResult, onIntent: (LoginIntent) -> Unit, )
각 위반이 미치는 영향은 다릅니다. reduce를 applyMutation 밖에서 호출하면 상태 변경이 여러 곳에 흩어져 추적이 어려워집니다. ViewModel()을 직접 상속하면 프로젝트에서 정의한 예외 처리, 표준 컨테이너 설정이 빠집니다. Content에 UiState 외 파라미터가 들어오면 Screen과 Content의 역할 경계가 무너집니다.
한두 번은 코드 리뷰에서 잡을 수 있었습니다. 그런데 기능 개발 속도가 빨라질수록 같은 패턴의 지적이 반복됐고, 리뷰어와 개발자 모두 피로를 느꼈습니다. 문서에 더 상세한 설명을 추가하거나, 예시 코드를 보강해봤지만 근본적으로 해결되지 않았습니다.
그때 마틴 파울러의 Harness Engineering 글을 읽으면서 문제의 구조가 보였습니다.
Harness Engineering — Agent를 제어하는 구조
마틴 파울러는 AI 코딩 에이전트를 이렇게 정의합니다.
Agent = Model + Harness
Harness는 AI 모델을 제외한, 에이전트가 올바르게 작동하도록 제어하는 시스템 전체를 가리킵니다. 그리고 Harness를 구성하는 요소를 두 가지로 나눕니다.
Guide는 피드포워드 제어입니다. 에이전트가 코드를 생성하기 전에 맥락을 제공하고, 좋은 결과를 처음부터 만들어낼 확률을 높입니다. CLAUDE.md가 여기 해당합니다. MVI 패턴 정의, 금지 패턴 예시, 설계 원칙을 Claude가 작업을 시작하기 전에 읽도록 두는 것입니다.
Sensor는 피드백 제어입니다. 에이전트가 코드를 생성한 후에 그 결과를 관찰하고, 규칙을 어겼다면 자기 수정을 유도합니다. Guide가 "이렇게 해달라"는 부탁이라면, Sensor는 "실제로 그렇게 했는지"를 검사합니다.
이 구조로 보면 반복되던 문제의 원인이 명확해집니다. Guide는 있었지만 Sensor가 없었습니다. CLAUDE.md는 Claude가 규칙을 이해하는 데 도움을 주지만, 그것은 어디까지나 해석의 영역입니다. 작업이 길어지고 컨텍스트가 쌓이면 초반에 읽었던 규칙이 점점 밀려납니다. 사람도 온보딩 문서를 꼼꼼히 읽은 뒤 시간이 지나면 세부 규칙을 잊듯이, Claude도 마찬가지입니다.
Claude가 규칙을 알고 있더라도 긴 작업 중 컨텍스트가 밀리면 실수가 발생합니다. 문제는 코드 리뷰에서 뒤늦게 발견됩니다.
Guide는 생성 단계에서 올바른 선택 확률을 높이고, Sensor는 결과물을 독립적으로 검증합니다. 예방과 교정이 함께 동작합니다.
이 프로젝트에서 세 가지 MVI 규칙을 Sensor로 만들기로 했습니다. 다음 질문은 어떤 도구로 Sensor를 구현할 것인가였습니다.
Computational Sensor — 왜 Detekt인가
마틴 파울러는 Sensor를 두 종류로 나눕니다. Computational Sensor는 결정론적이고 빠릅니다. 린터, 타입 체커, 정적 분석이 여기 해당합니다. Inferential Sensor는 LLM 기반의 의미 분석으로, 비용이 높고 결과가 비결정론적입니다.
MVI 규칙 검증은 "이 클래스가 특정 타입을 상속하는가", "이 함수 호출이 특정 스코프 안에 있는가"처럼 코드 구조를 기계적으로 확인하면 되는 문제입니다. Computational Sensor로 충분했고, 빠르고 신뢰할 수 있다는 점에서 더 적합했습니다.
Kotlin/Android 생태계에서 고려한 선택지들입니다.
| 도구 | 판단 |
|---|---|
| ktlint | 포매팅과 스타일 전문. 클래스 계층이나 함수 호출 컨텍스트를 분석하는 API가 없다 |
| Android Lint | 커스텀 룰 작성에 Detector, Registry 등 보일러플레이트가 무겁다. Android 리소스·API 검사에 더 적합한 설계다 |
| 컴파일러 플러그인 | 가장 강력하지만 구현 난이도가 압도적으로 높다. 클래스 계층 분석처럼 Detekt로 충분한 케이스에서 선택할 이유가 없다 |
| Detekt | ✅ 선택. Kotlin PSI 기반 의미론적 분석, 단순한 커스텀 룰 API, Gradle Convention Plugin 친화적 |
Detekt를 선택한 가장 중요한 이유는 PSI 기반 분석입니다. PSI(Program Structure Interface)는 Kotlin 컴파일러가 소스 코드를 파싱한 트리 구조입니다. 텍스트 패턴 매칭과 달리, list.reduce와 MVI의 reduce를 구분하거나, 함수 호출이 특정 스코프 안에 있는지 확인하는 것처럼 의미론적인 판단이 가능합니다.
그리고 커스텀 룰 API가 단순합니다. Rule을 상속하고, 탐지하고 싶은 노드 유형의 visit*() 메서드를 오버라이드하면 됩니다. Sensor를 추가하는 비용이 낮다는 것은, 규칙이 늘어날 때 부담 없이 확장할 수 있다는 의미이기도 합니다.
Sensor를 코드로 — Detekt 커스텀 룰
Detekt 커스텀 룰의 최소 구조는 다음과 같습니다. Rule을 상속하고, issue를 정의한 뒤 원하는 PSI 노드의 visit*()를 오버라이드합니다. 위반이 발견되면 report(CodeSmell(...))로 보고합니다.
class MyRule(config: Config) : Rule(config) { override val issue = Issue( id = "MyRule", severity = Severity.Error, description = "...", debt = Debt.FIVE_MINS, ) override fun visitCallExpression(expression: KtCallExpression) { super.visitCallExpression(expression) // 위반 감지 → report(CodeSmell(issue, Entity.from(expression), message)) } }
룰을 작성한 뒤에는 RuleSetProvider를 구현해서 룰을 묶고, META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider 파일에 구현체 클래스명을 등록해야 합니다. JVM의 ServiceLoader가 이 파일을 읽어 런타임에 룰셋을 자동으로 발견합니다. 이 파일이 없으면 커스텀 룰은 실행되지 않습니다.
이 프로젝트에서 구현한 세 가지 룰을 살펴보겠습니다.
NeveraViewModelInheritanceRule
feature 패키지에 속한 ViewModel이 NeveraViewModel 대신 ViewModel()을 직접 상속하면 위반입니다. 탐지 로직은 세 단계 필터로 구성됩니다.
override fun visitClass(klass: KtClass) { super.visitClass(klass) // 1단계: 클래스 이름이 *ViewModel로 끝나는가 if (!klass.name.orEmpty().endsWith("ViewModel")) return // 2단계: feature 패키지 소속인가 val fqName = klass.fqName?.asString() ?: return if (!fqName.contains(".feature.")) return // 3단계: ViewModel()을 직접 상속하는가 val directViewModelInheritance = klass.superTypeListEntries.any { entry -> (entry.typeReference?.typeElement as? KtUserType) ?.referencedName == "ViewModel" } if (directViewModelInheritance) { report(CodeSmell(issue, Entity.from(klass), issue.description)) } }
2단계 패키지 필터가 핵심입니다. core:mvi 모듈 안의 NeveraViewModel 자체나, 도메인 레이어의 다른 클래스들이 오탐되지 않도록 feature 패키지 소속만 검사합니다.
ReduceOutsideApplyMutationRule
Orbit MVI의 reduce는 applyMutation 내부에서만 호출해야 합니다. 이 규칙에서 가장 까다로운 부분은 오탐 처리입니다. Kotlin 표준 라이브러리의 list.reduce { ... }도 탐지될 수 있기 때문입니다.
override fun visitCallExpression(expression: KtCallExpression) { super.visitCallExpression(expression) if (expression.calleeExpression?.text != "reduce") return // list.reduce 오탐 제거 — 수신 객체가 있으면 MVI reduce가 아니다 if (expression.parent is KtDotQualifiedExpression) return // 호출 컨텍스트가 applyMutation 스코프인지 탐색 var parent = expression.parent while (parent != null) { if (parent is KtNamedFunction) { if (parent.name == "applyMutation") return // 정상 break } parent = parent.parent } report(CodeSmell(issue, Entity.from(expression), issue.description)) }
KtDotQualifiedExpression 체크가 핵심입니다. list.reduce { ... }는 PSI 트리에서 KtDotQualifiedExpression의 자식으로 존재합니다. 반면 MVI의 reduce { ... }는 수신 객체 없이 단독으로 호출됩니다. 이 차이를 이용해 오탐을 제거합니다.
이후 while 루프로 부모 노드를 타고 올라가며 가장 가까운 KtNamedFunction을 찾습니다. 그것이 applyMutation이라면 정상, 다른 함수라면 위반입니다.
ContentComposableParameterRule
*Content 이름의 @Composable 함수는 *UiState와 함수 타입 파라미터만 받아야 합니다. Modifier도 허용합니다.
override fun visitNamedFunction(function: KtNamedFunction) { super.visitNamedFunction(function) if (!function.name.orEmpty().endsWith("Content")) return if (function.annotationEntries.none { it.shortName?.asString() == "Composable" }) return function.valueParameters .filter { param -> !isAllowedType(param.typeReference?.typeElement) } .forEach { param -> report(CodeSmell(issue, Entity.from(param), issue.description)) } } private fun isAllowedType(typeElement: KtTypeElement?): Boolean { // Type? 형태 언래핑 — KtNullableType에서 실제 타입을 추출 val unwrapped = if (typeElement is KtNullableType) typeElement.innerType else typeElement return when (unwrapped) { is KtFunctionType -> true // () -> Unit, (Intent) -> Unit 등 허용 is KtUserType -> { val name = unwrapped.referencedName ?: return false name == "Modifier" || name.endsWith("UiState") } else -> false } }
KtNullableType 언래핑이 있는 이유는, modifier: Modifier = Modifier처럼 기본값이 있는 경우와 onDismiss: (() -> Unit)?처럼 nullable 함수 타입이 오는 경우를 모두 처리하기 위해서입니다. PSI 트리에서 Modifier?는 KtNullableType으로 감싸진 KtUserType으로 표현되기 때문에 먼저 벗겨내야 실제 타입에 접근할 수 있습니다.
세 룰을 하나의 RuleSetProvider로 묶어 등록합니다.
class NeveraMviRuleSetProvider : RuleSetProvider { override val ruleSetId: RuleSetId = RuleSetId("NeveraMviRules") override fun instance(config: Config): RuleSet = RuleSet( ruleSetId, listOf( NeveraViewModelInheritanceRule(config), ReduceOutsideApplyMutationRule(config), ContentComposableParameterRule(config), ), ) }
ruleSetId의 값("NeveraMviRules")은 detekt.yml의 최상위 키와 정확히 일치해야 합니다.
NeveraMviRules: active: true NeveraViewModelInheritanceRule: active: true ReduceOutsideApplyMutationRule: active: true ContentComposableParameterRule: active: true
파이프라인에 연결하기
룰을 작성하고 등록하면, 이후는 단순합니다. CI에서 ./gradlew detekt --continue를 실행하고 위반이 발견되면 빌드가 실패하도록 설정합니다. PR 머지 조건에 이 검사를 포함시키면 Sensor가 완성됩니다.
./gradlew detekt 실행 — 모든 feature 모듈 검사
도입 후 달라진 것
Sensor를 추가한 뒤 MVI 관련 리뷰 코멘트가 눈에 띄게 줄었습니다. 위반이 생기면 리뷰어가 발견하기 전에 CI가 먼저 잡아줬습니다. 리뷰에서 "이 부분은 applyMutation을 통해야 해요"와 같은 반복적인 코멘트가 사라지고, 실제 설계 논의에 집중할 수 있게 됐습니다.
Guide와 Sensor의 역할이 다르다는 점도 다시 한번 체감했습니다. CLAUDE.md 덕분에 Claude가 처음부터 올바른 패턴을 선택하는 비율이 높아졌고, Detekt 덕분에 그렇지 않은 경우도 머지 전에 차단됩니다. 둘 중 하나만 있었다면 지금과 같은 결과를 얻기 어려웠을 것입니다.
한계도 분명히 있습니다. PSI 분석은 코드 구조를 검사할 뿐, 런타임 동작을 보장하지 않습니다. 그리고 규칙이 의도한 패턴을 완전히 포착하지 못하면 오탐이 발생할 수 있습니다. 실제로 ContentComposableParameterRule에서는 외부 라이브러리 화면 컴포저블을 래핑한 경우 오탐이 생겼고, 룰 조건을 한 차례 다듬어야 했습니다. 규칙을 설계할 때는 허용해야 하는 케이스를 미리 충분히 검토하는 것이 중요합니다.
더 나아가서 — Hook을 이용한 실시간 Sensor
지금 구성은 PR 단계라는 상대적으로 느린 피드백 루프에서 Sensor가 동작합니다. Claude Code의 PostToolUse 훅을 활용하면 코드 작성 시점에 즉시 Detekt를 실행해, Agent가 위반을 실시간으로 감지하고 같은 세션 안에서 수정하는 더 촘촘한 피드백 루프를 만드는 것도 기술적으로 가능합니다. 이 방향은 충분히 경험해본 뒤 별도 글로 다룰 예정입니다.
정리하며
Agent와 함께 개발할 때 문서만으로는 부족합니다. Guide는 Agent가 처음부터 올바른 선택을 할 확률을 높여주지만, 그것이 실제로 지켜졌는지는 별도로 검증해야 합니다.
Harness Engineering의 관점에서 보면, CLAUDE.md는 피드포워드 제어이고 Detekt는 피드백 제어입니다. 둘은 역할이 다르고, 그래서 함께 있을 때 비로소 Harness가 완성됩니다. 어느 하나가 다른 하나를 대체할 수 없습니다.
Detekt 커스텀 룰을 만드는 것 자체는 생각보다 어렵지 않았습니다. PSI가 낯설게 느껴질 수 있지만, 어떤 코드 패턴을 탐지하고 싶은지 명확하다면 그에 맞는 visit*() 메서드를 찾고 조건을 작성하는 것은 의외로 직관적입니다. 오히려 시간이 걸리는 것은 오탐 없이 정확히 원하는 케이스만 탐지하도록 조건을 다듬는 과정이었습니다.
- Agent = Model + Harness. Harness는 Guide(피드포워드)와 Sensor(피드백)로 구성됩니다.
- CLAUDE.md는 Guide입니다. 코드를 생성하기 전 컨텍스트를 제공하지만, 결과를 보장하지 않습니다.
- Detekt는 Computational Sensor입니다. 결정론적이고, 빠르고, PSI 기반으로 의미론적 분석이 가능합니다.
- 오탐 처리가 핵심입니다.
list.reduce와 MVIreduce를 구분하는 것처럼, 허용 케이스를 먼저 충분히 정의해야 합니다. - Guide와 Sensor는 역할이 다릅니다. 하나가 다른 하나를 대체하지 않습니다. 둘이 함께 있을 때 Harness가 완성됩니다.
'AI' 카테고리의 다른 글
| [AI] Claude Code 훅으로 디자인 시스템 위반 자동 감지하기 (0) | 2026.06.03 |
|---|---|
| [AI] Claude Code Hook으로 작업 완료 알림 받기 (0) | 2026.03.31 |
| [AI] Claude Code Skill 사용 경험 - 왜 user-invocable: false로 설계했나 (1) | 2026.03.30 |
| [AI] Claude Code Skill로 커밋 메시지 추천 자동화 (1) | 2026.03.27 |
| [AI] Claude Code Skills 설정 가이드 — SKILL.md 구조부터 Frontmatter까지 (0) | 2026.03.20 |