Compose는 엄격하게 정렬된 순방향 흐름의 세 단계로 프레임을 실행합니다.

- 컴포지션:
@Composable함수를 실행하여 UI 트리를 빌드하고 업데이트합니다. - 레이아웃: 하위 요소를 측정한 후 배치합니다.
- 그리기: 캔버스 그리기 명령어를 실행하여 화면에 픽셀을 렌더링합니다.
어느 단계에서든 Compose State를 읽을 때마다 Compose는 해당 상태와 상응하는 단계 간의 종속 항목을 자동으로 기록합니다.
'역방향' 쓰기란 무엇인가요?
역방향 쓰기는 상태가 읽힌 단계보다 이후 단계(또는 다운스트림 범위, 즉 동일한 컴포지션 패스 내에서 순서상 나중에 실행되는 컴포저블 범위)에서 수정될 때마다 발생하며, 이로 인해 Compose는 이전 단계 또는 컴포저블이 다시 실행되도록 예약하여 재구성합니다. 역방향 쓰기는 최적화되지 않은 재구성 루프입니다.
역방향 쓰기의 결과
역방향 쓰기가 반드시 나쁜 것은 아니며 항상 비정상 종료를 트리거하는 것도 아니지만, 비효율적이며 다음과 같은 여러 가지 방식으로 앱 성능에 악영향을 미칠 수 있습니다.
- 추가 프레임 렌더링 및 프레임 드롭: 역방향 쓰기로 인해 Compose는 연속된 프레임에서 중복 컴포지션 패스를 실행하여 CPU 및 GPU 리소스를 낭비하고 버벅거림을 유발할 수 있습니다.
- 첫 번째 프레임 정확성 문제: 구성요소에서 최종 크기나 상태를 확인하기 위해 역방향 쓰기가 필요한 경우 첫 번째 프레임은 잘못되었거나 기본값이거나 불안정한 데이터 (예: 크기 0 또는 잘못된 오프셋)로 렌더링됩니다. 이로 인해 두 번째 프레임이 렌더링될 때 시각적 팝핑 또는 레이아웃 깜박임이 발생합니다.
- 무한 리컴포지션 루프: 상태 변경으로 인해 레이아웃 크기가 변경되고 레이아웃 크기가 새 값을 상태에 지속적으로 다시 쓰는 경우 화면이 안정화되지 않고 프레임마다 지속적으로 리컴포지션되는 무한 프레임 루프가 생성될 수 있습니다.
단계를 통한 순방향 흐름
상태 변경은 항상 다음 단계를 통해 순차적으로 진행되어야 합니다.
| 읽기 단계 | 컨텍스트 작성 | 허용 가능 여부 | 이유 |
|---|---|---|---|
레이아웃 (Modifier.offset { }) |
구도 | 예 | 컴포지션이 상태를 업데이트함 → 레이아웃이 리컴포지션 없이 동일한 프레임에서 나중에 상태를 읽음 |
무승부 (graphicsLayer { }, drawBehind { }) |
구도 | 예 | 컴포지션이 상태를 업데이트하고 → 그리기에서 마지막 단계에 상태를 읽습니다. 컴포지션과 레이아웃은 완전히 건너뜁니다. |
| 그리기 | 레이아웃 | 예 | 레이아웃이 상태를 업데이트하고 그리기가 상태를 읽는 유효한 흐름입니다. |
| 구도 | 운전 상태 변경을 유도하는 이벤트 콜백 (onClick, onValueChange) 참고: 레이아웃 콜백은 이벤트로 계산되지 않습니다. |
예 | 이벤트는 컴포지션을 구동하는 데 사용되는 상태를 변경합니다. 이벤트가 프레임 외부 (컴포지션, 레이아웃 또는 그리기 아님)에서 발생하는 경우 유효합니다. |
| 구도 | 코루틴 (LaunchedEffect) |
예 - 주의 필요 | 수명 주기/이벤트에 대한 응답으로 상태를 비동기식으로 업데이트합니다. 효과에서 쓰기는 유효할 수 있지만 비효율적인 상태 레이어링을 가리킬 수 있습니다. 가능하면 사용하지 않는 것이 좋습니다. |
| 게재위치 (레이아웃) | 측정 (레이아웃) | 예 | Layout에서는 상태를 업데이트한 후 배치에서 해당 상태를 읽는 것이 허용됩니다. |
| 측정 (레이아웃) | 게재위치 (레이아웃) | 아니요 - 역방향 쓰기 | 배치에서 상태에 쓰기를 한 후 읽기 상태에서 추가로 쓰기를 하면 재측정 루프가 발생합니다. |
| 구도 | 레이아웃 (onSizeChanged, LayoutModifier) |
아니요 - 역방향 쓰기 | 레이아웃이 컴포지션을 무효화 → 리컴포지션 루프 |
| 구도 | 무승부 (drawWithContent, Canvas) |
아니요 - 거꾸로 쓰기 | 그리기는 컴포지션 → 리컴포지션 루프를 무효화합니다. |
단계 조합: 역방향과 정방향
다음은 Compose 내에서 역방향 쓰기의 예와 이를 해결하는 방법입니다.
역방향: 컴포지션에서 읽기, 레이아웃에서 쓰기
- 결과: 컴포지션은
componentHeight를 읽어 내보낼 UI를 결정합니다. 프레임의 뒷부분에서 레이아웃 단계는 뷰를 측정하거나 배치하고componentHeight에 새 값을 씁니다 (예:onSizeChanged,onGloballyPositioned또는 맞춤LayoutModifier사용). - 결과: Layout에서
componentHeight를 수정하면 방금 완료된 Composition 단계가 무효화됩니다.onSizeChanged는 레이아웃 측정 단계가 완료된 후 크기를 보고합니다. 업데이트된 상태 값이 다음 패스에서 안정화되면 추가 프레임 하나 후에 리컴포지션이 중지될 수 있습니다. 그러나 새 값이 계속 크기를 변경하면 무한 프레임 루프가 발생합니다. 또한onGloballyPositioned는 레이아웃과 배치 후에 실행되므로 내부의 상태 쓰기가 연속 프레임에서 지속적인 리컴포지션 및 레이아웃 재구성 루프에 더 취약해집니다.
// ❌ BAD: Read in Composition, Written in Layout (onSizeChanged) @Composable fun BadAspectRatioImage(painter: Painter) { var calculatedHeight by remember { mutableStateOf(0.dp) } val density = LocalDensity.current // State read during COMPOSITION: Image( painter = painter, contentDescription = "Dynamic Image", modifier = Modifier .fillMaxWidth() .height(calculatedHeight) .onSizeChanged { size -> // State write during LAYOUT phase! // Triggers backwards write and recomposition pass val aspectRatio = 16f / 9f val widthDp = with(density) { size.width.toDp() } calculatedHeight = widthDp / aspectRatio } ) } // ✅ GOOD: Measure and calculate aspect ratio height in Phase 2 (Layout) without recomposition @Composable fun GoodAspectRatioImage( painter: Painter, aspectRatio: Float = 16f / 9f, modifier: Modifier = Modifier ) { Layout( content = { Image( painter = painter, contentDescription = "Dynamic Image" ) }, modifier = modifier ) { measurables, constraints -> val width = constraints.maxWidth val height = (width / aspectRatio).toInt() // Illustrative, you can use Modifier.aspectRatio() val imageConstraints = constraints.copy( minWidth = width, maxWidth = width, minHeight = height, maxHeight = height ) val placeable = measurables.first().measure(imageConstraints) layout(width, height) { placeable.placeRelative(0, 0) } } }
역방향: 컴포지션에서 읽기, 그리기에서 쓰기
- 결과: 상태는 컴포저블 본문 (컴포지션 단계)에서 읽지만
Modifier.drawWithContent,Modifier.drawBehind또는Canvas(그리기 단계) 내에서 변경됩니다. - 결과: 그리기 단계에서 상태를 변경 → 컴포지션이 무효화됨 → 무한 루프
// ❌ BAD: Read in Composition, Written in Draw () @Composable fun BadBackwardsWriteDraw() { var componentHeight by remember { mutableStateOf(0.dp) } // State read during COMPOSITION: Text( text = "Height is: $componentHeight", modifier = Modifier.drawBehind { // State write during the DRAW phase! // Invalidates Composition -> triggers recomposition loop! componentHeight = size.height.dp } ) }
역방향: 컴포지션에서 읽기, 컴포지션에서 쓰기 (동일한 단계)
- 발생하는 상황: 컴포저블 함수에서
count를 읽고, 읽은 후 다른 컴포저블 콘텐츠 슬롯에서count를 직접 수정합니다. - 결과: 스냅샷 시스템은 동일한 컴포지션 패스 내에서 읽기와 후속 쓰기를 기록하여 현재 범위를 즉시 무효화합니다.
// ❌ BAD: Direct write in Composable body after read @Composable fun BadCounter() { var count by remember { mutableIntStateOf(0) } Text("Count: $count") // State read in Composition Button(onClick = {}) { count++ // State write in Composition (Backwards write!) } } // Acceptable - but error-prone as someone may add a read before the write : Direct write in Composable body before read @Composable fun OkCounter() { var count by remember { mutableIntStateOf(0) } Button(onClick = {}) { count++ // State write in Composition } Text("Count: $count") // State read in Composition }
역방향 쓰기를 방지하기 위한 주요 규칙
- 컴포지션에서 상태를 읽는 경우
onGloballyPositioned,onSizeChanged,onPlaced,onLayoutRectChanged,LayoutModifier의 상태에 쓰지 마세요. 첫 번째 프레임 정확성 문제가 발생하기 때문입니다.- 맞춤 그리기에만 레이아웃 좌표나 크기가 필요한 경우 그리기 또는 레이아웃 단계에서 직접 읽습니다 (예:
Modifier.drawWithCache또는Modifier.layout사용). - 창 수준 크기 조절 (
WindowWidthSizeClass): 크기 관찰을 창 수준으로 호이스트합니다. 로컬 측정이 발생하기 전에 창 크기 클래스에서 컴포지션이 분기됩니다. - 컴포지션 균일성 유지: 컴포지션에 사용된 상태를 재구성하거나 변경하지 않고 2단계에서 측정 및 배치를 조정하는 단일 맞춤 레이아웃 또는
FlowRow나LazyVerticalGrid와 같은 구성요소를 사용합니다. - 하위 컴포지션 사용: 하위 컴포저블이 로컬 너비 또는 높이에 따라 분기되어야 하는 경우
BoxWithConstraints또는SubcomposeLayout를 사용합니다. 주의: 하위 구성은 성능 비용을 수반하며 일반적으로 피할 수 있습니다. - 최후의 수단: 첫 번째 프레임이 잘못되도록 허용하고
onSizeChanged에 크기를 저장하여 두 번째 프레임 재구성을 트리거합니다. 이로 인해 눈에 보이는 레이아웃 팝핑, 버벅거림이 발생하고 무한 루프가 발생할 수 있습니다.
- 맞춤 그리기에만 레이아웃 좌표나 크기가 필요한 경우 그리기 또는 레이아웃 단계에서 직접 읽습니다 (예:
- 컴포지션에서 처음 읽은 후 상태를 변경하지 않음:
SideEffect외부에서 컴포지션 중에MutableState객체에 안전하게 쓸 수 있지만 컴포지션에서 이전에 읽었을 수 있는 상태에 쓰지 않도록 각별히 주의해야 합니다. 컴포지션에서 상태 쓰기가 필요한 경우rememberUpdatedState을 사용하는 것이 좋습니다. 다른 방식으로 컴포지션 중에 상태에 쓰는 것은 일반적으로 누락된 효과 또는 부적절하게 설계된 상태나 컴포저블의 신호입니다. 컴포지션은 낙관적이며 항상 최신 상태 값으로 실행되므로 리컴포지션에서 모든 상태 변경사항이 표시되지 않을 수 있습니다. UI 업데이트는 일회성 이벤트를 처리하는 방법으로 사용해서는 안 됩니다. 따라서 재구성의 결과로 상태 값을 업데이트해야 하는 경우는 흔하지 않습니다.- 컴포지션 외부에서 관찰되는 상태 (예:
ViewModel필드 또는isVisible플래그)를 변경하지 마세요. 컴포지션에 영향을 미치는 상태 쓰기는 이벤트 람다 (onClick), 코루틴(LaunchedEffect) 또는 부작용 (SideEffect)에 속합니다.rememberUpdatedState는@Composable본문에서만 사용되는 상태를 변경하도록 설계되었으므로 예외입니다.
- 상태 읽기를 최대한 늦은 단계로 연기:
- 그리기 (
Modifier.graphicsLayer { alpha = ... }) 또는 레이아웃 (Modifier.offset { IntOffset(...) })에서 상태를 읽으면 변경사항이 2단계 또는 3단계만 무효화하고 1단계 (컴포지션)를 완전히 건너뜁니다.
- 그리기 (