
Manifest Android Interview 책을 읽고 Practical Questions 에 대한 답변을 작성해보고, 카테고리 내에 특정 개념에 대한 딥다이브 및 스터디 시간에 얘기 나누면 좋을 내용들을 적어보는 글입니다.
답변 정리는 LLM의 도움을 받았습니다.
Q) 26. Modifier란 무엇인가?
Modifier는 Jetpack Compose에서 UI 요소의 크기, 배치, 배경, 클릭 처리 등 다양한 속성을 데코레이터 패턴으로 연속적으로 적용할 수 있는 객체
Modifier는 컴포저블의 외관과 동작을 선언적으로 조합하는 핵심 수단
Q) Modifier의 순서가 왜 중요한가? 순서를 바꾸면 동작이 달라지는 예를 들어서 설명하여라
Modifier는 선언된 순서대로 적용되기 때문에, 순서를 바꾸면 결과가 달라짐
// 1. 패딩 후 배경색 적용
Modifier.padding(16.dp).background(Color.Red)
// -> 배경색이 패딩(여백) 안쪽에만 칠해짐
// 2. 배경색 후 패딩 적용
Modifier.background(Color.Red).padding(16.dp)
// -> 전체 배경에 색이 칠해진 뒤, 그 안에 여백이 생김(여백 부분은 배경색이 없음)
Q) 27. Layout이란 무엇인가?
Layout은 자식 컴포저블의 측정, 배치, 위치 계산을 직접 제어하는 저수준 컴포저블
Row, Column 등은 Layout의 구현체이며, Layout을 직접 사용하면 복잡한 커스텀 배치가 가능
Q1) Row나 Column 같은 표준 컴포넌트 대신 Layout 컴포저블을 사용해야 하는 상황은 언제인가?
나의 경우 이런 표를 그리기 위해 필요했음
코드
@Composable
fun BandalartChart(
bandalartData: BandalartUiModel,
bandalartCellData: BandalartCellEntity,
onHomeUiAction: (HomeUiAction) -> Unit,
modifier: Modifier = Modifier,
) {
// BoxWithConstraints를 사용하여 부모 컨테이너의 크기에 따라 레이아웃 조정
BoxWithConstraints {
// 좌우 패딩(15dp씩)을 제외한 실제 사용 가능한 너비 계산
val paddedMaxWidth = remember(maxWidth) { maxWidth - (15.dp * 2) }
// 4개의 서브 셀(목표 영역) 정의
// SubCell(행수, 열수, 행위치, 열위치, 데이터)
val subCellList = persistentListOf(
SubCell(2, 3, 1, 1, bandalartCellData.children[0]), // 상단 서브셀
SubCell(3, 2, 1, 0, bandalartCellData.children[1]), // 우측 서브셀
SubCell(3, 2, 1, 1, bandalartCellData.children[2]), // 하단 서브셀
SubCell(2, 3, 0, 1, bandalartCellData.children[3]), // 좌측 서브셀
)
// 커스텀 레이아웃을 사용하여 반다라트 차트 구성
Layout(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp)), // 전체 차트에 둥근 모서리 적용
content = {
// 4개의 서브 셀(목표 영역) 생성
for (index in subCellList.indices) {
Box(
modifier = Modifier
.layoutId(stringResource(Res.string.home_layout_id, index + 1)) // 레이아웃 ID 설정
.clip(RoundedCornerShape(12.dp)) // 서브셀에 둥근 모서리 적용
.background(color = Gray300), // 기본 배경색 설정
) {
// 각 서브셀 내부의 그리드 생성
BandalartCellGrid(
bandalartData = bandalartData,
subCell = subCellList[index],
rows = subCellList[index].rowCnt,
cols = subCellList[index].colCnt,
onHomeUiAction = onHomeUiAction,
)
}
}
// 중앙의 메인 목표 셀
Box(
modifier = Modifier
.layoutId(stringResource(Res.string.home_main_id)) // 메인 셀 레이아웃 ID
.clip(RoundedCornerShape(10.dp)) // 메인 셀에 둥근 모서리 적용
.background(color = bandalartData.mainColor.toColor()), // 메인 색상 적용
) {
BandalartCell(
cellType = CellType.MAIN,
bandalartData = bandalartData,
cellData = bandalartCellData,
onHomeUiAction = onHomeUiAction,
)
}
},
) { measurables, constraints ->
// 각 컴포넌트의 측정 가능한 요소들을 layoutId로 찾기
val sub1 = measurables.first { it.layoutId == "Sub 1" } // 상단 서브셀
val sub2 = measurables.first { it.layoutId == "Sub 2" } // 우측 서브셀
val sub3 = measurables.first { it.layoutId == "Sub 3" } // 하단 서브셀
val sub4 = measurables.first { it.layoutId == "Sub 4" } // 좌측 서브셀
val main = measurables.first { it.layoutId == "Main" } // 중앙 메인셀
// 전체 차트의 너비 계산 (5x5 그리드 기준)
val chartWidth = paddedMaxWidth.roundToPx()
val mainWidth = chartWidth / 5 // 기본 셀 하나의 크기
val padding = 1.dp.roundToPx() // 셀 간 간격
// 각 영역의 크기 제약 조건 설정
val mainConstraints = Constraints.fixed(width = mainWidth, height = mainWidth) // 1x1 크기
val sub1Constraints = Constraints.fixed(width = mainWidth * 3 - padding, height = mainWidth * 2 - padding) // 3x2 크기
val sub2Constraints = Constraints.fixed(width = mainWidth * 2 - padding, height = mainWidth * 3 - padding) // 2x3 크기
val sub3Constraints = Constraints.fixed(width = mainWidth * 2 - padding, height = mainWidth * 3 - padding) // 2x3 크기
val sub4Constraints = Constraints.fixed(width = mainWidth * 3 - padding, height = mainWidth * 2 - padding) // 3x2 크기
// 각 컴포넌트 측정
val mainPlaceable = main.measure(mainConstraints)
val sub1Placeable = sub1.measure(sub1Constraints)
val sub2Placeable = sub2.measure(sub2Constraints)
val sub3Placeable = sub3.measure(sub3Constraints)
val sub4Placeable = sub4.measure(sub4Constraints)
// 전체 레이아웃 크기 설정 (정사각형)
layout(width = chartWidth, height = chartWidth) {
// 각 컴포넌트를 지정된 위치에 배치
// 반다라트 차트의 전형적인 3x3 + 중앙 레이아웃
mainPlaceable.place(x = mainWidth * 2, y = mainWidth * 2) // 중앙 (2,2) 위치
sub1Placeable.place(x = 0, y = 0) // 좌상단 (0,0) 위치
sub2Placeable.place(x = mainWidth * 3 + padding, y = 0) // 우상단 (3,0) 위치
sub3Placeable.place(x = 0, y = mainWidth * 2 + padding) // 좌하단 (0,2) 위치
sub4Placeable.place(x = mainWidth * 2 + padding, y = mainWidth * 3 + padding) // 우하단 (2,3) 위치
}
}
}
}
Q2) LazyVerticalGrid로 구현할 수 없는 staggered grid 레이아웃을 만들어야 한다면, Layout을 어떻게 활용하겠는가?
Layout을 직접 구현하여 각 아이템의 높이에 따라 위치를 동적으로 계산하고, 각 열의 Y 오프셋을 별도로 관리해 Staggered Grid 레이아웃을 만듬
Q) 28. Box란 무엇인가?
Box는 자식 컴포저블을 겹치거나, 한 레이어에 여러 컴포저블을 쌓을 수 있는 레이아웃
Q1) 어떤 상황에서 Column이나 Row 대신 Box를 사용하는 것이 더 적합한가? 그리고 Box는 자식 컴포저블들을 어떻게 다르게 처리하는가?
Q2) Box에서 사용하는 contentAlignment 파라미터는 Modifier.align()과 어떤 차이가 있으며, 두 가지를 함께 사용할 수 있는가?
contentAlignment는 Box 전체의 기본 정렬 기준을 설정
Modifier.align()은 각 자식별로 개별 정렬을 지정
두 가지를 함께 사용할 수 있으며, align()이 있으면 해당 자식에는 그것이 우선 적용
Q) 29. Arrangement와 Alignment의 차이점은 무엇인가?
Arrangement: Row/Column에서 자식들 간의 “분배” 방식(Spacing, SpaceBetween 등)
Alignment: Row/Column/Box에서 자식들의 “정렬” 기준(Top, Center 등)
Q1) 화면 전체 너비에 자식들을 균등하게 배치하면서, 위쪽에 정렬되도록 하려면 Row에서 어떤 Arrangement와 Alignment 조합을 사용하겠는가? 그리고 그 이유는 무엇인가?
Arrangement.SpaceEvenly, verticalAlignment = Alignment.Top
이유: SpaceEvenly가 수평 분배, Alignment.Top이 수직 정렬을 담당
Q) 왜 Row에서는 horizontalAlignment가 작동하지 않지만, Column에서는 작동하는가? Compose의 레이아웃 규칙 중 어떤 것이 이러한 동작을 야기하는가?
Row는 수평 방향이 주축이므로 verticalAlignment만 지원
Column은 수직이 주축이므로 horizontalAlignment만 지원
Compose의 레이아웃 시스템이 주축/교차축 개념을 따르기 때문
Q) 30. Painter란 무엇인가?
Painter는 비트맵, 벡터, 커스텀 드로잉 등 다양한 이미지를 추상화해 그릴 수 있는 객체
Q) Jetpack Compose에서 커스텀 Painter를 직접 구현해 본 적이 있는가? 있다면, 어떤 용도로 사용했으며, 그릴 때 어떤 로직을 구현하였는가?
예: ProgressBar, 그래프, 맞춤 아이콘 등
onDraw에서 Canvas API로 도형, 텍스트, 효과 등을 직접 그림
Q) 31. 네트워크에서 이미지를 어떻게 로드하는가?
Q) Jetpack Compose에서 이미지를 로드하기 위해 어떤 서드파티 라이브러리를 사용해보았는가? 그리고 각각의 트레이드오프(장단점)는 무엇이라고 생각하는가?
Coil, Landscapist
장점: 간단한 API, 다양한 포맷 지원(JPEG, PNG, WebP, GIF, SVG), 이미지 뿐만 아니라 Video Frame도 지원, KMP 지원, 메모리 및 디스크 캐싱 설정이 쉬움
단점: 고급 애니메이션 미지원
장점:
사실 coil과 같이 쓰는 라이브러리이기 때문에, coil의 장점을 모두 포함함
coil 뿐만 아니라 다른 이미지 로드 라이브러리도 지원(사실 coil밖에 안씀)
고급 애니메이션 기능 지원(ex. skeleton(shimmer), Blur)
Palette API 지원
이미지 컴포저블을 restartable skippable로 판정받도록 하여 성능 개선 가능

단점:
plugin 형식으로 추가하는 방식이 낯설다면, 사용법을 익히는데 어느정도 시간이 필요할 수 있음
주의)
애니메이셔 관련 plugin을 사용하려면 별도의 라이브러리 의존성을 추가해야 함
dependencies {
implementation("com.github.skydoves:landscapist-animation:$version")
}
자세한 내용은 공식문서 참고)
https://skydoves.github.io/landscapist/animation/
Q) 32. 수백 개의 항목을 리스트로 효율적으로 렌더링하면서 UI 지연(jank)을 피하려면 어떻게 해야 하는가?
Q1) 실시간 메시지를 포함하는 채팅 화면을 구축한다고 가정해보자. 부드러운 스크롤과 최소한의 recomposition 오버헤드를 보장하기 위해 레이아웃을 어떻게 구성하겠는가?
채팅 등 실시간 UI:
LazyColumn, rememberLazyListState, derivedStateOf, snapshotFlow로 스크롤 위치/상태 추적, recomposition 최소화
Q2) LazyColumn 또는 LazyGrid에서 key를 사용하는 것이 리스트 업데이트 시 UI 성능과 안정성을 어떻게 유지하는 데 도움이 되는가?
Key의 역할:
리스트 항목의 identity를 보장해 효율적 diff, 애니메이션, 상태 보존 가능
Q) 33. Lazy 리스트에서 페이징을 어떻게 구현하는가?
Q1) 더 많은 항목을 로드해야 할 시점을 감지하기 위해 어떤 API나 상태 관리 메커니즘을 사용하겠는가?
Q2) 페이징에서 LazyListState는 어떤 역할을 하며, derivedStateOf와 snapshotFlow는 데이터 로딩 로직을 어떻게 최적화할 수 있도록 돕는가? 이 흐름에서 distinctUntilChanged()는 왜 중요한가?
LazyListState와 최적화:
Q3) 사용자가 리스트를 빠르게 스크롤할 때, 중복된 네트워크 호출이나 데이터 로딩을 어떻게 방지하겠는가?
Q) 34. Canvas란 무엇인가?
Q) Canvas를 사용하여 사용자 지정(Custom) 애니메이션 원형 프로그레스 바를 어떻게 구현하겠는가?
원형 프로그레스바 예시:
Q) 35. graphicsLayer Modifier를 사용해본 적이 있는가?
나는 Composable 를 ImageBitmap으로 변환하여(캡처) 이를 공유하는 기능을 구현할때 사용한 적이 있음
Q1) 70% 투명도와 1.2배 스케일이 적용된 원형 클리핑 이미지를 어떻게 구현하겠는가?
Image(
painter = ...,
contentDescription = null,
modifier = Modifier
.graphicsLayer(alpha = 0.7f, scaleX = 1.2f, scaleY = 1.2f, shape = CircleShape, clip = true)
)
Q2) graphicsLayer의 목적은 무엇이며, scale, rotate, alpha와 같은 다른 Modifier 대신 이걸 사용해야 하는 이유는 무엇인가? 또한, graphicsLayer가 렌더링 성능 및 컴포저블의 독립성에 어떤 영향을 주는가?
graphicsLayer 사용 이유와 영향:
하드웨어 가속: GPU를 활용한 고성능 그래픽 처리
독립 레이어: 별도 레이어에서 렌더링하여 성능 최적화
복합 효과: 여러 변환을 한 번에 효율적으로 적용
개별 Modifier 대신 사용하는 이유:
// 비효율적 - 여러 번 리컴포지션 발생
Modifier
.alpha(0.5f)
.scale(1.2f)
.rotate(45f)
// 효율적 - 한 번에 처리
Modifier
.graphicsLayer(
alpha = 0.5f,
scaleX = 1.2f, scaleY = 1.2f,
rotationZ = 45f
)
성능 향상:
GPU 가속: CPU 대신 GPU에서 변환 처리
배치 처리: 모든 변환을 한 번에 적용해 오버헤드 감소
캐싱: 변환된 결과를 레이어에 캐시하여 재사용
독립성 향상:
레이어 분리: 다른 컴포저블과 독립적으로 렌더링
리컴포지션 최적화: 해당 레이어만 업데이트, 부모/자식에 영향 없음
Z-order 제어: translationZ로 레이어 순서 정밀 제어
결론: 복잡한 시각 효과나 애니메이션이 필요할 때 graphicsLayer를 사용하면 성능과 독립성 모두 확보 가능
Q) 36. Jetpack Compose에서 시각적 애니메이션을 어떻게 구현하는가?
Q1) 콘텐츠에 따라 크기가 달라지는 텍스트 블록에 대해, 부드러운 확장/축소 효과를 어떻게 구현하겠는가?
텍스트 블록 크기 애니메이션:
Q2) Canvas, Painter, 애니메이션을 활용하여 로딩 플레이스홀더에 쉐이머(shimmer) 애니메이션 효과를 어떻게 구현하겠는가?
shimmer 애니메이션:
Q) 37. 화면 간 탐색을 어떻게 구현하는가?
Navigation 라이브러리 또는 직접 상태/이벤트로 관리
Q1) Navigation 라이브러리를 사용하지 않고, 여러 화면으로 구성된 Compose 앱에서 화면 간 내비게이션을 관리하고 각 화면의 상태를 어떻게 보존할 수 있을까?
직접 구현: 현재 화면 상태를 remember, 각 화면별 상태 별도 저장(예: SaveableStateHolder)
https://developer.android.com/reference/kotlin/androidx/compose/runtime/saveable/SaveableStateHolder
@Composable
fun <T : Any> Navigation(
currentScreen: T,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit,
) {
// create SaveableStateHolder.
val saveableStateHolder = rememberSaveableStateHolder()
Box(modifier) {
// Wrap the content representing the `currentScreen` inside `SaveableStateProvider`.
// Here you can also add a screen switch animation like Crossfade where during the
// animation multiple screens will be displayed at the same time.
saveableStateHolder.SaveableStateProvider(currentScreen) { content(currentScreen) }
}
}
Column {
var screen by rememberSaveable { mutableStateOf("screen1") }
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
Button(onClick = { screen = "screen1" }) { Text("Go to screen1") }
Button(onClick = { screen = "screen2" }) { Text("Go to screen2") }
}
Navigation(screen, Modifier.fillMaxSize()) { currentScreen ->
if (currentScreen == "screen1") {
Screen1()
} else {
Screen2()
}
}
}
Q2) Jetpack Compose Navigation에서 NavHost와 NavController 시스템은 백 스택과 ViewModel 생명주기를 어떻게 관리하는가?
화면 전환:
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") { HomeScreen() }
composable("detail/{id}") { backStackEntry ->
DetailScreen(id = backStackEntry.arguments?.getString("id"))
}
}
백스택 유지:
각 destination을 스택 구조로 관리
뒤로가기 시 이전 화면으로 복원
화면 상태(스크롤 위치, 입력값 등) 자동 보존
백스택 관리:
// 스택에 추가
navController.navigate("detail/123")
// 특정 화면까지 팝
navController.popBackStack("home", inclusive = false)
// 백스택 교체 (홈으로 돌아갈 때 중간 화면들 제거)
navController.navigate("main") {
popUpTo("login") { inclusive = true }
}
ViewModel 생명주기 관리:
composable("profile") { backStackEntry ->
val viewModel: ProfileViewModel = hiltViewModel(backStackEntry)
// 이 ViewModel은 profile 화면이 백스택에 있는 동안만 유지
}
화면 진입: ViewModel 생성 및 초기화
다른 화면으로 이동: ViewModel 유지 (백스택에 남아있음)
뒤로가기로 복귀: 기존 ViewModel과 상태 그대로 복원
백스택에서 완전 제거: ViewModel 소멸 (onCleared() 호출)
// NavGraph 레벨에서 공유
composable("step1") { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry("wizardFlow")
}
val sharedViewModel: WizardViewModel = hiltViewModel(parentEntry)
}
composable("step2") { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry("wizardFlow")
}
val sharedViewModel: WizardViewModel = hiltViewModel(parentEntry) // 같은 인스턴스
}
BackStackEntry 구조:
val backStackEntry = navController.currentBackStackEntry
backStackEntry?.let {
val arguments = it.arguments
val savedStateHandle = it.savedStateHandle
val lifecycle = it.lifecycle
}
상태 보존 메커니즘:
SavedStateHandle: 화면 회전, 프로세스 종료 시에도 데이터 유지
rememberSaveable: 백스택에서 화면이 사라져도 상태 보존
Lifecycle.State: 화면별로 독립적인 생명주기 상태 관리
주요 특징 요약:
자동 상태 관리: 개발자가 수동으로 ViewModel을 관리할 필요 없음
메모리 효율성: 백스택에서 제거된 화면의 ViewModel은 자동 정리
상태 복원: 뒤로가기 시 이전 상태 완벽 복원
유연한 범위: 화면별, NavGraph별로 ViewModel 범위 조절 가능
Q) 38. Preview는 어떻게 작동하며, 어떻게 다루는가?
컴파일 타임 처리:
@Preview
@Composable
fun MyComponentPreview() {
MyComponent()
}
어노테이션 프로세싱: 컴파일 시 @Preview 어노테이션을 스캔
메타데이터 생성: Preview 함수의 위치, 설정 정보를 메타데이터로 저장
별도 실행 환경: IDE가 독립적인 Compose 런타임 환경 생성
렌더링 엔진: Android Studio의 Layout Inspector와 유사한 렌더링 시스템 사용
실시간 업데이트 메커니즘:
파일 감시: IDE가 Kotlin 파일 변경사항 모니터링
증분 컴파일: 변경된 Composable만 다시 컴파일
Hot Reload: 앱 재시작 없이 Preview 업데이트
격리된 환경: 실제 앱과 독립적으로 실행
Q) @Preview 어노테이션은 개발 워크플로우를 어떻게 향상시키며, 그 안에서 사용해본 주요 구성(다크 테마, 화면 크기, 다중 프리뷰 애노테이션 등)은 무엇인가?
빠른 피드백 루프:
@Preview(showBackground = true)
@Composable
fun ButtonPreview() {
MyButton(text = "Click me") // 즉시 시각적 피드백
}
디바이스 없는 개발:
에뮬레이터나 실제 기기 없이도 UI 개발 가능
빌드/배포 시간 단축 (초 단위 vs 분 단위)
네트워크나 권한 없이도 UI 테스트 가능
Q) 39. Jetpack Compose UI 컴포넌트 또는 화면에 대한 단위 테스트를 어떻게 작성하는가?
Q1) 컴포저블이 올바른 UI 요소를 표시하는지 단위 테스트로 어떻게 검증하는가?
컴포저블 UI 요소 검증 방법:
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun myComposable_displaysCorrectElements() {
// Given: 컴포저블 설정
composeTestRule.setContent {
MyTheme {
UserProfile(
user = User(name = "John Doe", email = "john@example.com")
)
}
}
// When & Then: UI 요소 검증
composeTestRule
.onNodeWithText("John Doe")
.assertExists()
composeTestRule
.onNodeWithText("john@example.com")
.assertIsDisplayed()
composeTestRule
.onNodeWithContentDescription("Profile Image")
.assertExists()
}
다양한 선택자로 UI 요소 찾기
@Test
fun userCard_hasCorrectStructure() {
composeTestRule.setContent {
UserCard(user = mockUser)
}
// 텍스트로 찾기
composeTestRule.onNodeWithText("John Doe").assertExists()
// Content Description으로 찾기
composeTestRule.onNodeWithContentDescription("User Avatar").assertExists()
// Tag로 찾기
composeTestRule.onNodeWithTag("user_card").assertExists()
// 조건으로 찾기
composeTestRule.onNode(hasText("John Doe") and hasClickAction()).assertExists()
// 여러 요소 찾기
composeTestRule.onAllNodesWithText("Button").assertCountEquals(3)
}
상태별 UI 검증
@Test
fun loginForm_showsCorrectStateBasedOnInput() {
var isLoading by mutableStateOf(false)
var errorMessage by mutableStateOf<String?>(null)
composeTestRule.setContent {
LoginForm(
isLoading = isLoading,
errorMessage = errorMessage,
onLogin = { /* mock */ }
)
}
// 초기 상태 검증
composeTestRule.onNodeWithText("Login").assertIsEnabled()
composeTestRule.onNodeWithTag("loading_indicator").assertDoesNotExist()
// 로딩 상태로 변경
isLoading = true
composeTestRule.waitForIdle()
// 로딩 상태 검증
composeTestRule.onNodeWithText("Login").assertIsNotEnabled()
composeTestRule.onNodeWithTag("loading_indicator").assertExists()
// 에러 상태로 변경
isLoading = false
errorMessage = "Invalid credentials"
composeTestRule.waitForIdle()
// 에러 상태 검증
composeTestRule.onNodeWithText("Invalid credentials").assertIsDisplayed()
}
Q2) performClick()을 사용해 사용자 동작을 어떻게 시뮬레이션하고, assertExists() 또는 assertTextEquals() 같은 단언으로 결과를 어떻게 검증하는가?
클릭 동작과 상태 변화 검증
@Test
fun counterButton_incrementsOnClick() {
composeTestRule.setContent {
CounterScreen()
}
// 초기 상태 확인
composeTestRule.onNodeWithText("Count: 0").assertExists()
// 클릭 수행
composeTestRule
.onNodeWithText("Increment")
.performClick()
// 결과 검증
composeTestRule.onNodeWithText("Count: 1").assertExists()
composeTestRule.onNodeWithText("Count: 0").assertDoesNotExist()
}
테스트 입력과 검증
@Test
fun searchField_filtersResultsOnTextInput() {
val items = listOf("Apple", "Banana", "Cherry")
composeTestRule.setContent {
SearchScreen(items = items)
}
// 텍스트 입력
composeTestRule
.onNodeWithText("Search...")
.performTextInput("App")
// 필터링 결과 검증
composeTestRule.onNodeWithText("Apple").assertExists()
composeTestRule.onNodeWithText("Banana").assertDoesNotExist()
composeTestRule.onNodeWithText("Cherry").assertDoesNotExist()
// 텍스트 지우기
composeTestRule
.onNodeWithText("App")
.performTextClearance()
// 모든 아이템 다시 표시 확인
composeTestRule.onAllNodesWithText("Apple", "Banana", "Cherry")
.assertCountEquals(3)
}
스크롤과 제스처 테스트
@Test
fun lazyList_scrollsAndDisplaysItems() {
val items = (1..100).map { "Item $it" }
composeTestRule.setContent {
LazyItemList(items = items)
}
// 첫 번째 아이템 확인
composeTestRule.onNodeWithText("Item 1").assertIsDisplayed()
composeTestRule.onNodeWithText("Item 50").assertDoesNotExist()
// 스크롤 수행
composeTestRule
.onNodeWithTag("lazy_list")
.performScrollToNode(hasText("Item 50"))
// 스크롤 후 상태 검증
composeTestRule.onNodeWithText("Item 50").assertIsDisplayed()
}
복합 동작 시나리오 테스트
@Test
fun todoApp_addAndCompleteTask() {
composeTestRule.setContent {
TodoApp()
}
// 새 할일 추가
composeTestRule
.onNodeWithText("Add new task")
.performTextInput("Buy groceries")
composeTestRule
.onNodeWithText("Add")
.performClick()
// 추가된 할일 확인
composeTestRule
.onNodeWithText("Buy groceries")
.assertExists()
// 완료 체크박스 클릭
composeTestRule
.onNode(hasText("Buy groceries"))
.onSibling()
.onChildren()
.filterToOne(hasContentDescription("Mark as complete"))
.performClick()
// 완료 상태 확인
composeTestRule
.onNode(hasText("Buy groceries") and hasTestTag("completed_task"))
.assertExists()
}
비동기 동작 테스트
@Test
fun dataLoading_showsProgressThenContent() {
composeTestRule.setContent {
DataScreen()
}
// 로딩 상태 확인
composeTestRule
.onNodeWithContentDescription("Loading")
.assertExists()
// 데이터 로딩 완료까지 대기
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule
.onAllNodesWithText("Data loaded")
.fetchSemanticsNodes()
.isNotEmpty()
}
// 로딩 완료 후 상태 확인
composeTestRule
.onNodeWithContentDescription("Loading")
.assertDoesNotExist()
composeTestRule
.onNodeWithText("Data loaded")
.assertIsDisplayed()
}
Q) 40. 스크린샷 테스트란 무엇이며, 개발 중 UI 일관성을 어떻게 보장하는가?
회귀: 이전 상태로 되돌아가다, 즉 "원래 잘 되던 것이 다시 안 되는 현상"을 의미
예시1: 레이아웃 깨짐
// 원래 잘 되던 코드
@Composable
fun UserCard() {
Row {
Image(...)
Column {
Text("사용자명")
Text("이메일")
}
}
}
누군가 다른 기능을 추가하다가:
// 실수로 padding을 추가해서 레이아웃이 깨짐
@Composable
fun UserCard() {
Row(modifier = Modifier.padding(1000.dp)) { // 😱 실수!
Image(...)
Column {
Text("사용자명")
Text("이메일")
}
}
}
예시 2: 색상/테마 문제
// 어제까지 잘 보이던 버튼
Button(
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.Blue
)
) { Text("확인") }
// 오늘 테마를 수정하다가 실수로 색상이 바뀜
Button(
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.White // 😱 흰 배경에 흰 글씨로 안 보임!
)
) { Text("확인", color = Color.White) }
예시 3: 텍스트 잘림
// 원래 잘 되던 코드
Text(
text = "사용자 이름",
maxLines = 1
)
// 폰트 크기를 키우다가 텍스트가 잘리기 시작
Text(
text = "사용자 이름",
maxLines = 1,
fontSize = 50.sp // 😱 너무 커서 잘림!
)
@Test
fun userCard_baselineScreenshot() {
// 정상적인 UserCard의 스크린샷을 저장
composeTestRule.setContent {
UserCard()
}
composeTestRule.onRoot()
.captureToImage()
.assertAgainstGolden("user_card_baseline") // 기준 이미지 저장
}
// 개발자가 코드를 수정한 후 테스트 실행
@Test
fun userCard_afterChanges() {
composeTestRule.setContent {
UserCard() // 수정된 코드
}
// 기존 기준 이미지와 자동 비교
composeTestRule.onRoot()
.captureToImage()
.assertAgainstGolden("user_card_baseline")
// ❌ 다르면 테스트 실패! "UI가 바뀌었어요!"
}
실제 개발에서 일어나는 회귀 시나리오
시나리오 1: 다른 화면 작업 중 실수
개발자 A: "로그인 화면 수정 중..."
→ 공통 테마 파일 수정
→ 의도치 않게 모든 화면의 버튼 색상 변경
→ 홈 화면, 프로필 화면 등이 이상해짐 😱
시나리오 2: 라이브러리 업데이트 부작용
"Compose 버전 업데이트했더니..."
→ 일부 컴포넌트 기본 스타일 변경
→ 기존에 잘 되던 UI들이 미묘하게 달라짐 😱
시나리오 3: 리팩토링 중 실수
"코드 정리하다가..."
→ Modifier 순서 변경
→ padding과 background 순서가 바뀌어서 시각적으로 다름 😱
// 이런 실수들을 자동으로 잡아냄
@Test
fun allScreens_visualRegression() {
// 홈 화면
testScreen("home") { HomeScreen() }
// 프로필 화면
testScreen("profile") { ProfileScreen() }
// 설정 화면
testScreen("settings") { SettingsScreen() }
}
fun testScreen(name: String, content: @Composable () -> Unit) {
composeTestRule.setContent { content() }
composeTestRule.onRoot()
.captureToImage()
.assertAgainstGolden(name)
}
결과:
✅ 의도한 변경: 스크린샷 업데이트
❌ 의도하지 않은 변경: 테스트 실패로 즉시 알림
이렇게 "원래 잘 되던 UI가 다시 망가지는 것"을 회귀라고 하고, 스크린샷 테스트로 이를 자동으로 감지할 수 있습니다!
Q) 팀의 워크플로우에서 스크린샷 테스트를 사용한 적이 있는가? 사용하면서 개발 또는 코드 리뷰 과정이 어떻게 향상되었고, 어떤 구체적인 이점을 경험했는가?
아직 없습니다... 드로이드나이츠를 참고해봐야겠음...
기본 스크린샷 테스트 설정
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun userProfile_matchesScreenshot() {
composeTestRule.setContent {
MyTheme {
UserProfile(user = mockUser)
}
}
// 스크린샷 캡처 및 비교
composeTestRule
.onRoot()
.captureToImage()
.assertAgainstGolden("user_profile_light_theme")
}
다양한 상태의 스크린샷 테스트
class UserCardScreenshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun userCard_lightTheme_matchesScreenshot() {
composeTestRule.setContent {
MyTheme(darkTheme = false) {
UserCard(user = mockUser)
}
}
composeTestRule.onRoot()
.captureToImage()
.assertAgainstGolden("user_card_light")
}
@Test
fun userCard_darkTheme_matchesScreenshot() {
composeTestRule.setContent {
MyTheme(darkTheme = true) {
UserCard(user = mockUser)
}
}
composeTestRule.onRoot()
.captureToImage()
.assertAgainstGolden("user_card_dark")
}
@Test
fun userCard_loadingState_matchesScreenshot() {
composeTestRule.setContent {
MyTheme {
UserCard(user = null, isLoading = true)
}
}
composeTestRule.onRoot()
.captureToImage()
.assertAgainstGolden("user_card_loading")
}
}
Q) 41. Jetpack Compose에서 접근성을 어떻게 보장하는가?
Q1) semantics Modifier의 목적은 무엇인가?
Q2) 여러 UI 요소가 하나의 접근성 노드처럼 동작하게 하려면 Compose에서 어떻게 구현하는가?
Compose Navigation Argument를 ViewModel의 SavedStateHandle로 전달받을 수 있는 이유