코딩 에이전트가 Android 렌더러 MCP에 Jetpack Compose 지원을 추가하다. 나 같으면 이 작업은 시작조차 못 했을 것이다.
요약
코딩 에이전트가 Android UI Renderer MCP에 Jetpack Compose 지원을 추가하는 과정을 다룬 기술 기사입니다. 기존 XML 기반 렌더링 방식과 달리, Compose는 명확한 진입점이 없어 별도의 연구 문제가 필요했습니다. 필자는 에이전트의 초기 제안(어댑터 작성)을 거절하고, MCP가 자체적으로 Kotlin/Compose에 적응하도록 하는 방향으로 접근하여 자동화된 UI 검사 및 테스트 루프를 구축하는 데 성공했습니다.
핵심 포인트
- Jetpack Compose 렌더링은 명확한 진입점 부재로 인해 별도의 연구 문제가 필요함.
- MCP는 단순한 테스트 프레임워크가 아닌, 에이전트의 자율적 UI 검사/테스트 루프를 지원해야 함.
- 개발자가 어댑터를 직접 작성할 필요 없이 MCP 자체가 Compose에 적응하는 것이 핵심 가치임.
Android UI Renderer MCP는 코딩 에이전트가 에뮬레이터나 장치 없이 Android UI를 렌더링할 수 있게 해주는 로컬 MCP 서버입니다. 이 서버는 PNG 이미지와 함께 경계, 텍스트 및 상태를 질의할 수 있는 컴포넌트 트리를 반환합니다.
기존에는 클래식한 Android UI—XML/View, RecyclerView, Activity + Fragment, 오버레이, 다양한 화면 구성을 처리했습니다. XML의 경우 명확한 진입점이 있습니다: 레이아웃 리소스를 로드하고, 픽스처(fixture)를 적용하고, 측정(measure)하고, View 계층을 배치(layout)하며, 그리는 후, 트리를 직렬화합니다.
XML layout → inflate → fixture with data → measure/layout → PNG + View tree
Jetpack Compose에는 이에 상응하는 진입점이 없습니다. inflate할 파일이 존재하지 않습니다. 대신 Kotlin 함수가 있습니다:
@Composable
fun BookItem(
book: Book,
...
)
이를 렌더링한다는 것은 해당 함수의 시그니처(signature)를 이해하고, 모든 매개변수(열거형(enums), Nullable 타입, 데이터 클래스, 컬렉션, 콜백 등)에 대한 올바른 Kotlin 값을 구축하며, 앱의 테마를 해결하고, 비로소 Compose가 실제로 그릴 수 있는 환경 내에서 컴포저블을 호출하는 것을 의미합니다. 실제 앱에서는 ViewModel, DI(Dependency Injection), 네비게이션, 데이터 로딩 등이 바로 옆에 나타납니다.
이것은 단순히 '하나 더 UI 형식을 지원한다'는 문제가 아닙니다. 별도의 연구 문제입니다. 그리고 이것이야말로 제가 보통 시작조차 못 했던 종류의 문제였습니다—해결 불가능해서가 아니라, 접근하는 방법을 알아내는 데 드는 비용이 기능 자체의 가치를 쉽게 초과할 수 있기 때문입니다.
이번에는 이 연구 문제를 코딩 에이전트에게 맡겼습니다. 이어지는 내용은 그것이 무엇을 구축했고, 어디서 실패했으며, 제가 결과를 신뢰하기 전에 무엇을 확인해야 했는지에 대한 내용입니다.
저는 에이전트의 첫 번째 제안을 거절했습니다
가장 안전한 옵션은 명백했습니다: 모든 Compose 프로젝트가 자체 렌더러 진입점을 제공하도록 하는 것입니다. 즉, 필요한 상태를 구축하고, 의존성을 연결하며, 컴포저블을 호출하는 방법을 이미 알고 있는 함수를 만들고, MCP는 단지 준비된 어댑터를 호출하기만 하면 되는 방식이었습니다.
그것은 작동했을 겁니다. 하지만 이 도구의 핵심 가치를 없애버렸을 것입니다.
렌더러를 이용한 자동화된 Compose UI 검사 및 테스트 과정 설명
렌더러는 프로젝트에 단순히 추가되는 또 다른 테스트 프레임워크가 아닙니다. 이는 수동 준비 작업 없이 기존 프로젝트에서 에이전트(agent)가 실행하는 루프를 지원하기 위해 존재합니다:
UI 변경 → 렌더링 → PNG 및 구조 검사 → 수정
만약 개발자가 모든 화면에 대해 먼저 어댑터(adapter)를 직접 작성해야 한다면, 에이전트의 자율성은 실제 UI가 시작되는 지점에서 정확히 끝납니다. 따라서 "어떻게 컴포저블(composable)을 렌더링할까?"라고 묻기보다는 결과물에 제약 조건을 설정했습니다:
MCP는 자체적으로 Kotlin/Compose에 적응해야 합니다. 프로젝트가 특별한 렌더러 진입점(renderer entry point) 코드를 작성할 필요가 없어야 합니다.
저는 이를 어떻게 구현해야 할지 몰랐습니다. 그래서 결과물이 가져야 하는 속성을 지정하고, 나머지 연구와 구현은 에이전트에게 맡겼습니다.
render_compose의 역할
MCP는 새로운 도구를 얻었습니다. XML 레이아웃 대신, 에이전트는 최상위 컴포저블(top-level composable)의 완전한 자격 이름(fully qualified name)과 명명된 JSON 인자(named JSON arguments)를 전달합니다:
{
"function": "io.github.example.ComposeBookItem",
"arguments": {
...
이후 렌더러는 다음 작업을 수행합니다:
- 대상 최상위
@Composable가 있는 Kotlin 파일을 찾습니다. - 함수의 매개변수(parameters)를 구문 분석합니다.
- 추가되거나 누락된 인자를 확인합니다.
- JSON으로부터 타입이 지정된 Kotlin 값을 구축합니다.
- 컴포저블을 호출하는 일반적인 Kotlin 호출을 생성합니다.
- 누락된 콜백(callback)에 대해 no-op 람다를 대체합니다.
- 프로젝트 레벨의
AppTheme을 찾거나, 명시적으로 전달된 래퍼(wrapper)를 사용합니다. - 임시 Kotlin 프로브(probe)를 생성합니다.
- Gradle/Robolectric을 통해 이를 실행합니다.
ComposeView를 PNG로 그립니다.
프로젝트 자체의 소스 코드는 건드리지 않습니다. 임시 프로브는 .android-ui-renderer에 존재합니다. Nullable 값, 열거형(enum), 데이터 클래스(data class), 가변 속성(mutable properties), List, 그리고 Set 모두 지원되므로, 에이전트는 앱 내부에 renderForMcp() 함수를 미리 구축할 필요가 없습니다.
이것이 단순히 장난감 같은 컴포저블에서만 작동하는 것이 아니라는 것을 확인하기 위해, 저는 에이전트에게 오픈 소스 Jetpack Compose 샘플 프로젝트를 처음부터 끝까지 클론하도록 요청했습니다. 이제 이 저장소에는 동일한 책 라이브러리와 동일한 일곱 가지 시나리오(책 목록 행, fontScale = 1.3의 긴 제목, 목록, 마스터-디테일, 전체 화면, 로딩 상태, 러시아어 다크 테마)를 공유하는 두 개의 동등한 앱(sample/(XML/View)와 sample-compose/(Compose))이 있습니다.
| XML/View | Jetpack Compose |
|---|---|
![]() | ![]() |
같은 시나리오, 두 가지 다른 UI 스택이며, 둘 다 240 dpi의 1920×1200 px 캔버스에 캡처되었습니다. 이것은 XML과 Compose 간의 픽셀 단위 비교는 아니며, 렌더러가 두 가지 다른 메커니즘을 통해 동일한 캔버스에서 동등한 상태를 재현한다는 것을 보여줍니다.
첫 번째 PNG는 작동했지만, 도구의 계약(contract)은 여전히 그렇지 않았다.
가장 유용한 실패 사례는 거의 즉시 나타났습니다. 실제 Compose 컴포넌트의 경우, 에이전트는 정상적이고 올바르게 보이는 PNG를 받았습니다. 스크린샷 도구 기준으로 이것은
하지만 그 수정이 이루어진 후에야 비로소 도구의 실제 계약(contract)을 통과할 수 있었다. 단순히 에이전트가 눈으로 확인하는 이미지가 아니라 이미지와 구조 모두를 통해서였다.
Compose 자체와는 무관한 두 번째 실패
렌더러는 Gradle init 스크립트를 통해 임시 테스트 의존성을 주입한다. 새로운 Compose 데모 프로젝트에서 이 과정은 엄격한 repositoriesMode에 부딪혔다. 즉, 프로젝트의 Gradle 설정이 해당 방식으로 Robolectric 의존성을 추가하는 것을 금지했기 때문이다.
따라서 UI 렌더링 자체는 이미 작동했지만, 재현 가능한 공개 데모가 빌드되지 않았다. 에이전트는 sample-compose의 repositories mode를 PREFER_PROJECT로 변경하고, 렌더러가 왜 임시 테스트 의존성을 추가해야 하는지 설명하는 주석을 남겼다. 이는 에이전트 작업에서 흔히 볼 수 있는 패턴이다: 'Compose 지원 추가'라는 목표는 Compose API에만 그치지 않는다. 에이전트는 Compose 시맨틱스와 아무 관련 없는 Gradle 정책까지 포함하여, 재현 가능한 결과에 이르는 전체 경로를 탐색해야 한다.
타임라인: 제약 조건에서 작동하는 데모까지 두 시간 미만
세션 로그에 따르면: 12:26, 기술적 문제에 대한 첫 기록. 12:41, 나는 필수적인 프로젝트 측 진입점(entry point)을 거부했다. ~13:00, 최초의 실제 Compose 렌더링 — PNG는 성공했지만, 시맨틱스 트리 직렬화는 실패했다. 이후 두 번째 성공적인 실행, Gradle 정책 수정, 테스트, 그리고 일곱 가지 모든 시나리오가 완료되었다. 14:05, 메인 커밋이 main 브랜치에 반영되었다: Compose 지원, 테스트, Compose 데모. 14:09, 내가 비교 이미지 두 개가 서로 다른 캔버스 크기에서 캡처된 것이라고 지적한 후속 커밋이었다.
제약 조건을 명시하는 것부터 수정되고 공개된 결과까지: 약 1시간 43분이 걸렸다.
제가 이걸 '사람이 N일 걸릴 것 같다'는 식으로 말하진 않겠습니다. 이게 얼마나 걸렸을지 모르겠어요. 솔직히 말하면 저는 아마 시작조차 안 했을 겁니다. 임의의 composable을 프로그래밍 방식으로 호출하는 방법을 따로 배워야 하고, Compose 컴파일러 변환(transformations)이 그것에 어떻게 영향을 미치는지, 시맨틱스 트리(semantics tree)를 얻는 방법, 이 모든 것을 Robolectric에서 실행하는 방법, 그리고 프로젝트가 특별한 테스트 진입점들의 더미 더미더미 쌓이는 걸 피하는 방법을 배워야 했거든요. 이런 것들은 모두 연구할 수 있는 영역입니다. 그 연구의 비용이 실제적인 중단 요인이었고, 이것이 바뀐 것입니다. 코드를 작성하는 속도가 아니라, 어떤 아이디어를 시도해 볼 가치가 있는지 결정하는 경제학적 측면이 바뀌었습니다.
에이전트가 한 것과 제가 한 것
diff만 놓고 보면 제 부분이 작아 보입니다. 에이전트는 render_compose를 설계하고 구현했으며, 타입 지정된 Kotlin 호출 생성기(typed Kotlin call generator)를 만들었고, Compose 프로브와 시맨틱스 트리(semantics tree)를 구축했으며, enum/data class/mutable state/콜백에 대한 단위 테스트(unit tests)를 추가했고, 실제 Compose 컴포넌트와 검증했으며, 일곱 가지 재현 가능한 시나리오가 있는 오픈 sample-compose 프로젝트를 만들었고, 위에서 언급된 두 가지 실패 사례 모두를 수정했습니다.
저는 그 코드를 작성하지 않았습니다. 제가 한 것은 다음과 같습니다:
- 필수적인 프로젝트 측 어댑터(project-side adapter)를 거부하고 실제 제약 조건(MCP가 적응하고, 프로젝트는 일반 프로젝트로 유지)을 설정했습니다;
- 장난감 같은 composable 대신 실제로 작동하는 컴포넌트와 검증하도록 요구했습니다;
- 첫 번째 '성공' 이후 XML/Compose 비교에서 캔버스 크기 불일치(canvas-size mismatch)를 잡아냈습니다.
이것은 제가 시간을 쓰는 방식에 실질적인 변화입니다. 이전의 과정은 낯선 영역 연구 → 접근 방식 찾기 → 구현 → 디버깅 → 테스트 → 데모 → 검증 이었습니다. 새로운 과정은 필요한 속성 진술 → 제약 조건 설정 → 에이전트가 연구/구현/테스트/데모/수정 실행하도록 함 → 실제로 제가 필요로 하는 결과인지 검증 입니다. 기술적 복잡성이 사라진 것은 아닙니다. 여전히 코드 안에 있습니다. 저는 단지 아이디어가 가치가 있는지 결정하기도 전에 전체 연구 과정을 직접 걸어 다닐 필요가 없어졌을 뿐입니다.
명확히 밝히는 한계점
현재 구현은 의도적으로 범위가 좁습니다. 이 렌더러는 프로덕션 내비게이션 플로우가 아닌 최상위 composable과 함께 작동하며, DI(Dependency Injection)나 실제 데이터 로딩 과정이 없고, 시각적 상태는 인자(arguments)로 명시적으로 전달됩니다. 콜백은 no-op 람다로 대체되었기 때문에 가짜 사용자 상호작용도 없습니다. Kotlin 시그니처 파싱은 Kotlin 컴파일러 API가 아닌 커스텀 정규표현식 기반의 파서이므로, 복잡한 오버로드(overloads), 타입 별칭(type aliases), 특이한 어노테이션(annotations) 등은 추가 작업이 필요할 수 있습니다. Compose는 Android View 트리가 아닌 시맨틱스 트리(semantics tree)를 반환하므로 식별자(identifiers)가 합성적입니다(synthetic). 또한, 실제 기기와의 픽셀 단위 일치 여부는 검증하지 않았으며, 이를 주장하는 것은 아닙니다.
제가 실제로 필요했던 작업에 대해서는 이것만으로 충분합니다. 즉, 특정 시각적 상태를 가져와서 실제 Compose UI를 렌더링하고, 어댑터(adapter)를 프로젝트 내부에 미리 작성할 필요 없이 PNG 이미지와 기계가 읽을 수 있는 구조체를 얻는 것입니다.
저에게 있어 실제 결과물은 Compose 지원이 아닙니다
공식적인 결과는 간단합니다. 이 렌더러가 이제 XML/View와 Jetpack Compose를 모두 지원하며, 두 가지 동등한 샘플 앱과 나란히 배치된 출력물을 제공한다는 것입니다.
하지만 여기서 얻을 만한 교훈은 제가 기능을 평가하는 방식의 변화입니다. 이전에는
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기
