그래픽 수정자란?
그래픽 수정자 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 그래픽 수정자 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Compose에는 Canvas 컴포저블 외에도 맞춤
developer.android.com
위 문서를 보게되면 그래픽 수정자가 무엇인지에 대해 알 수 있습니다.
그리기 수정자
modifier에서 제공하는 위 세 가지 매서드를 그리기 수정자 라고 부르며 각각 그래픽 레이어에 특별한 특징을 갖고 UI를 그리는 역할을 담당합니다.
drawWithContent
Column(
modifier = Modifier
.fillMaxSize()
.drawWithContent {
// 여기부터는 content 아래에 그리기
drawRect()
...
drawContent() // Column에 해당하는 content 그리기
// 여기부터는 content 위에 그리기
drawPath()
...
}
) {
// drawContent()에서 그려질 UI
}
drawWithContent는 해당 컴포저블의 실제 content의 앞뒤로 그래픽을 그리는 기능을 수행합니다.
drawWithContent의 수정자 안에는 drawContent()가 반드시 포함되어야 하고 전후로 적절한 drawing 매서드와 로직을 넣어줌으로써 content의 앞뒤에 UI를 추가로 그릴 수 있습니다.
drawBehind
Text(
"Hello Compose!",
modifier = Modifier
.drawBehind {
drawRoundRect(
Color(0xFFBBAAEE),
cornerRadius = CornerRadius(10.dp.toPx())
)
}
.padding(4.dp)
)
해당 Modifier가 붙어있는 컴포저블의 뒷배경에 그래픽을 그리는 역할을 합니다.
사실상 drawWithContent의 drawContent() 위쪽에 들어가는 코드와 동일하게 동작합니다.
그 이유는 다음 drawWithCache의 구현부를 보면 이해하실 수 있습니다.
drawWithCache
Text(
"Hello Compose!",
modifier = Modifier
.drawWithCache {
val imageSize = IntSize(width = size.width.toInt(), height = size.height.toInt())
val imageSrc = ImageBitmap.imageResource(R.drawable.traveling_android_bot)
val brush = Brush.linearGradient(
listOf(
Color(0xFF9E82F0),
Color(0xFF42A5F5)
)
)
onDrawBehind {
drawRoundRect(
brush,
cornerRadius = CornerRadius(10.dp.toPx())
)
}
onDrawWithContent {
drawImage(image = imageSrc, dstSize = imageSize)
}
}
)
해당 매서드 내부에서 생성된 객체를 리컴포지션마다 초기화하지 않고 캐시된 상태로 유지합니다.
Text 컴포저블이 리컴포지션 될 때 마다 Modifier역시 매번 재호출 되지만, drawWithCache 안에 있는 imageSize, imageSrc, brush 객체들은 새로 초기화되지 않고 유지됨으로써 효율적인 리컴포지션을 구현할 수 있습니다.
캐시되는 객체들은 draw 영역의 크기가 변경되거나, 객체자체의 상태가 변경될 때에만 새로 초기화 됩니다.
Canvas의 불편한 진실
보통 Compose를 통해 앱개발을 할 때 비트맵이나 벡터이미지를 화면에 그리기 위해서 Canvas라는 컴포저블을 많이 사용할 것입니다.
그런데 이 Canvas는 사실 별 기능이 없는 껍데기일 뿐이라는것 알고 계셨나요?

Canvas의 구현체를 보면 이렇게 생긴게 전부입니다.
그냥 가장 기본이 되는 컴포저블인 Spacer에 drawBehind만 딸랑 붙여놓은 모습 보이시나요?
drawBehind란 해당 컴포저블이 그려지는 범위 아래에 drawBehind 내의 UI를 그리는 drawScope 입니다. 따라서 Canvas는 아무것도 없는 컴포저블에 배경을 그림으로써 '도화지'의 역할처럼 보이게 만드는 컴포넌트인 것입니다.
그래픽 수정자 : Modifier.graphicsLayer
안드로이드에서는 UI를 렌더링 하기위해 UI스레드와 Render스레드가 활용됩니다. 흔히 'UI스레드에서만 UI 관련 로직을 작성해야한다' 정도로 이해하고 있을텐데 (저역시도 그랬고 Render Thread는 이번에 새로 알게된 사실입니다 ㅎ), 사실 UI스레드에서는 UI를 그리기 위한 CPU 연산 작업이 들어가고 Render스레드에서 실제 렌더링 관련 GPU연산이 들어가게 됩니다.
https://charlezz.com/?p=34935
안드로이드 View가 렌더링 되는 과정 | 찰스의 안드로이드
안드로이드 View가 렌더링 되는 과정 XML로 작성한 View가 어떻게 최종적으로 화면에 렌더링 되는지 알아보자. 좋은 퍼포먼스를 내기 위해서는 내부의 동작 방식이나 원리를 잘 알고있어야 한다.
charlezz.com
위 블로그를 보시면 안드로이드에서 UI가 어떤 과정으로 렌더링되는지 이해하기 쉽게 설명되어 있습니다.
UI Thread에서 일어나는 일
먼저 UI Thread(메인 스레드)는 실제 픽셀을 전혀 그리지 않습니다.
대신 “어떤 컴포저블이 어디에, 어떤 모습으로 놓여야 하는가”를 결정한 뒤 그 정보를 스크립트처럼 정리해 RenderThread에 넘깁니다.
- Recomposition
상태(State)가 바뀌면 Composable들이 다시 호출되고 Modifier 체인이 새로 만들어집니다. Modifier.graphicsLayer { … } 같은 것도 이때 객체화됩니다.
각 Composable들을 그리기 위한 LayoutNode들이 만들어집니다. - Layout & Measurement
생성된 LayoutNode 들이 measure() -> layout() 과정을 거쳐 폭, 높이, 좌표를 확정합니다.
이를 화면에 그리는 작업은 하지 않고 숫자만 계산합니다. - Display List 만들기
Compose 내부 래퍼들은 방금 확정된 정보, Modifier 값(알파·회전·블러 등)을 모아 RenderNode 로 직렬화 합니다.
이 안에 drawText, drawPath, drawBitmap 같은 명령이 순서대로 쌓입니다.
RenderThread에서 일어나는 일
이제 RenderThread(OS가 따로 만든 전용 스레드)가 V-Sync (수직동기화, 화면을 그리는 매 프레임) 마다 RenderNode들을 읽어 GPU에게 명령수행을 시킵니다.
- 레이어 여부 결정
각 RenderNode 에 “독립 레이어가 필요할까?” 플래그가 들어 있습니다.
알파가 1이고, 변형‧블렌드‧이펙트가 모두 기본값이면 → 레이어를 만들지 않고 부모 표면에 직접 그립니다.
반대로 투명도, 블러, graphicsLayer { compositingStrategy = Offscreen } 같은 설정이 있으면 → 오프스크린 버퍼(텍스처 한 장)부터 만듭니다. - 오프스크린 버퍼(FBO)에 래스터라이즈
레이어가 필요하다면 GPU 메모리에 RGBA 텍스처를 잡고 오프스크린 버퍼 (Hardware Screen 이외의 추가적인 렌더링 버퍼)에 UI를 그립니다.
drawText, drawPath 같은 명령이 이 텍스처에 실제 픽셀을 찍는 단계가 바로 여기입니다. - 텍스처 한 장으로 부모 표면에 합성
오프스크린 버퍼에 텍스처가 완성되면 RenderThread는 “이 이미지를 45도 돌리고, 알파 0.7을 곱해서 저 위치에 붙여”와 같이 GPU에 지시합니다.
결과적으로 부모 표면은 텍스처 한 장만 복사 받으니 애니메이션 중에도 빠릅니다. - 루트까지 반복 → 최종 프레임 완성
모든 자식 노드가 끝나면 루트 RenderNode 의 픽셀이 디바이스 화면 버퍼에 기록되고, 그 프레임이 사용자의 눈에 보입니다.
graphicsLayer의 역할
결국 화면에 UI가 그려지는 원리는 컴포지션이 일어날 때 마다 UI Thread에서 각각의 컴포넌트에 대한 크기, 너비, 위치 등을 계산하고, RenderThread에서는 이러한 정보를 바탕으로 직접 화면에 한 장의 Screen으로 그리는 작업을 하게 됩니다.
즉 우리가 아무리 Modifier에서 z축을 설정하고, 계층적 구조를 통해 이미지뷰위에 특정 이미지를 또 올리거나 하더라도, RenderThread에서 연산을 거치고 나면 Screen에 올라갈 한 장의 이미지만 나오게 되는 것입니다.
기본적으로 모든 Compsoable들은 하나의 오프스크린 버퍼에 합쳐서 RenderNode로써 그려지게 됩니다.
graphicsLayer는 컴포저블 하나(혹은 하위 트리 전체)를 GPU 텍스처 한 장으로 캡처해 두고, 그 텍스처에 각종 변형·효과·합성 규칙을 적용하라고 RenderThread에게 지시하는 선언형 스위치입니다.
즉, 하나의 오프스크린 버퍼에 합쳐지기 이전에 RenderThread에 전달할 명령들을 분리하는 역할을 합니다.
주로 오프스크린 버퍼를 분리하거나 복잡한 애니메이션을 분리하여 렌더링 효율을 향상시키는데 사용하는 옵션입니다.
"애니메이션을 외부에서 적용할 수 도 있는데 graphicsLayer를 사용하면 뭐가좋나요?
동작은 동일하게 보여질지 모르지만, 외부에서 애니메이션을 적용하게 외면 위치와 크기 계산 등이 UI Thread에서 CPU 연산을 통해 이루어지게 됩니다. 반대로 graphicsLayer를 통해 애니메이션을 지정하면 위치와 크기 계산 등이 RenderThread에서 GPU 연산을 통해 이루어집니다. 기본적으로 크기와 터치영역을 계산하는 measure() 연산은 UIThread에서 일어나기 때문에 grapihcsLayer에서 이루어지는 애니메이션의 경우 일부 버전에서는 터치영역이 애니메이션이 이루어지기 이전 영역으로 고정되는 현상이 발생할 수 도 있습니다. 그러나 GPU를 활용한다는 점에서 CPU 오버헤드가 줄어드는 장점이 있습니다.
따라서 단발적인 애니메이션, 사용자와의 상호작용이 중요한 컴포넌트의 경우 UI Thread에서 일어나는 애니메이션을 활용하고
리스트와 같은 수십~수백개의 아이템에 애니메이션을 걸거나 복잡한 형태의 애니메이션이 필요한 경우 CPU 오버헤드를 줄일 수 있는 graphicsLayer를 활용한 애니메이션이 좋은 선택지가 될 것입니다.
ScratchRevealCard 기능구현
이 기능구현에 필요한 개념들을 위에서 먼저 설명했는데, 말로만 들으면 이해가 잘 안되죠?
grpahicsLayer를 왜 써야 하는지 코드를 보면서 이해해 봅시다.
해당 기능을 구현하기 위해 먼저 이미지를 두 계층의 레이어로 나눴습니다.
- mainContent : 긁어서 드러내야 하는 실제 이미지
- overlayContent : 긁어내야 하는 커버 이미지
mainContent위에 overlayContent를 그리고, overlayContent에 긁어서 없애는 로직을 추가하여 mainContent가 드러날 수 있도록 구현하려고 합니다.
따라서 Modifier를 다음과 같이 3개의 역할로 분리하였습니다.
- mainContent를 그리는 modifier
- overlayContent를 그리고, 긁어서 지워질때마다 리컴포지션이 되는 modifier
- 사용자 입력을 받아 긁어내는 path를 추적하는 modifier
/* ───────── 레이어 1: 완료된 부분 캐시 ───────── */
val finishedLayer = Modifier.drawWithCache {
val imageSize = IntSize(width = size.width.toInt(), height = size.height.toInt())
val imageSrc = mainContent
// 이 블록은 finishedPath가 바뀌면 다시 실행 → 새로운 캐시 Bitmap 생성
onDrawBehind {
drawImage(image = imageSrc, dstSize = imageSize)
}
}
mainContent를 그리는 modifier입니다. drawWithCache안에 선언된 imageSize와 imageSrc는 캐싱되기 때문에 Canvas의 크기가 변경되거나 회전되지 않는 한 효율적으로 메모리를 활용할 수 있습니다.
/* ───────── 레이어 2: 진행 중인 선 실시간 ───────── */
val workingLayer = Modifier
.drawWithContent {
val imageSize = IntSize(width = size.width.toInt(), height = size.height.toInt())
drawContent() // ① 레이어
drawImage(image = overlayContent, dstSize = imageSize)
if (!drawingPath.isEmpty) {
drawPath(
path = drawingPath,
color = Color.Red,
style = Stroke(width = brushSize, cap = StrokeCap.Round, join = StrokeJoin.Round),
blendMode = BlendMode.Clear
)
}
if (!drawnPaths.isEmpty()) {
drawnPaths.forEach { path ->
drawPath(
path = path,
color = Color.Red, // 어차피 지워지는 영역이므로 색상은 상관없습니다.
style = Stroke(
width = brushSize,
cap = StrokeCap.Round,
join = StrokeJoin.Round
),
blendMode = BlendMode.Clear
)
}
}
}
overlayContent를 그리고 실시간으로 지워지는 path까지 업데이트하기 위한 modifier입니다.
drawContent() 위에 overlay 이미지를 그리도록 했고 아래에서 실시간으로 업데이트될 drawPath를 이용해 지워지고 있는 영역을 함께 그리게 구현하였습니다.
중요한점은 이전 포스팅처럼 그리고있는 영역을 보여주는게 아니라 '지워지는 영역'을 보여주어야 하기 때문에 blendMode 속성을 추가해 주었습니다.
BlendMode란 Porter-Duff 합성법을 Enum클래스로 나타낸 정수값으로써 path가 화면에 그려질 때 다른 그래픽 요소들과 어떤 방식으로 합성될지 결정하는 파라미터로써 작용합니다.
저는 Clear 모드를 줬기 때문에 사용자가 그리고 있는 drawingPath 영역에 대해서는 모든 픽셀이 지워지는 모습으로 그려지게 됩니다.
따라서 실제로 이미지를 손가락으로 '긁어서' 제거하는 듯한 효과를 내게 되는 것입니다.
/* ───────── 레이어 3: 유저 입력 ───────── */
val pointerInput = Modifier.pointerInput(Unit) {
detectDragGestures(
onDragStart = { start ->
drawingPath.reset()
point = start
drawingPath.moveTo(start.x, start.y)
},
onDrag = { _, dragAmount ->
point += dragAmount
drawingPath.lineTo(point.x, point.y)
drawingPath = drawingPath
},
onDragEnd = {
drawnPaths.add(drawingPath.copy())
onScratchProgress(1f)
},
)
}
마지막으로는 유저의 입력을 받아서 현재 지우고 있는 경로와 지워진 경로를 업데이트 하는 로직입니다.
이 부분은 이전 포스팅을 참고하면 이해하실 수 있습니다.
graphicsLayer의 필요성
이렇게 해서 동작시켜보면 다음과 같은 결과가 나옵니다.

분명 BlendMode.Clear를 줘서 path 영역이 지워지게 코드를 짰는데 검정색 선이 그려지는 버그가 발생합니다.
근데 잘 생각해보면 이건 버그가 아니고 잘 동작한다는것을 알 수 있습니다.
저는 drawPath 매서드에 color를 Red로 줬지 Black으로 준 적이 없습니다! 따라서 위 영상은 검정색 선이 그려지는게 아니라 'path의 영역이 지워져서 검정색으로 아무것도 안보이는것' 이라고 볼 수 있습니다!
왜 이런 현상이 발생하는 걸까요?

코드를 짤 때에는 Image 컴포넌트를 두 개를 활용해서 overlay 이미지를 위에 올렸지만, 실제 렌더링 과정에서 Render Thread는 이 두 장의 이미지를 하나의 오프스크린 레이어로 합쳐 버립니다.
사용자의 드로잉 입력을 받아 overlay 이미지에 리컴포지션이 유도된다면 다음과 같은 과정이 발생합니다.
- 위에 배치된 overlay 이미지에 리컴포지션이 유도된다.
- 매 리컴포지션마다 사용자입력을 받은 path 정보를 overlay 이미지에 덧그린다
- BlendMode.Clear 플래그로 인해 path 에 해당하는 부분이 지워져야 한다는 명령이 발생한다.
- UI Thread가 계산한 이미지의 크기와 그리기 정보, BlendMode에 해당하는 지우기 명령은 RenderNode로 직렬화되어 RenderThread로 전달된다.
- RenderThread는 오프스크린 버퍼에 RenderNode에 해당하는 그리기 명령을 모두 수행한다
- V-Sync 프레임마다 오프스크린 버퍼에 있는 화면 정보가 Screen으로 옮겨져 실제 픽셀에 그려진다.
여기서 굵게 표시된 4번과 5번 과정때문에 위 현상이 발생하게 됩니다. 우리가 개발을 하다보면 뷰 컴포넌트들 간에는 수많은 계층구조가 발생합니다. 이러한 계층마다 오프스크린 버퍼를 각각 할당하여 렌더링한다면 너무많은 메모리자원을 사용하게 되기 때문에 RenderThread에서는 기본적으로 모든 계층의 컴포넌트를 하나의 오프스크린 버퍼에 할당하여 하나의 화면으로 그리게 됩니다.
따라서 overlay 이미지와 main 이미지가 하나의 오프스크린 버퍼에서 겹쳐진 상태로 path부분이 지워져야 한다는 렌더링 명령이 수행되기 때문에 아래 이미지까지 모두 지워져서 아예 '빈 화면' 인 검정색 죽은 픽셀만 보이게 되는 것입니다.
따라서 우리는 모든 화면이 아닌 overlay 이미지만 지워질 수 있도록 overlay 이미지에 해당하는 오프스크린 버퍼를 하나 더 추가로 할당하는 작업을 거쳐야 합니다.

따라서 이 구조로 만들어서 overlay 이미지 에서만 지워지는 그리기 작업이 진행되고, V-Sync 프레임 시점에서 Screen에 합쳐져서 렌더링 되도록 구현해야 제대로된 기능을 구현할 수 있습니다.
이 과정을 위해 Modifier.graphicsLayer를 활용하게 됩니다.
/* ───────── 레이어 2: 진행 중인 선 실시간 ───────── */
val workingLayer = Modifier
// 이부분이 새로 추가
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
val imageSize = IntSize(width = size.width.toInt(), height = size.height.toInt())
drawContent() // ① 레이어
drawImage(image = overlayContent, dstSize = imageSize)
if (!drawingPath.isEmpty) {
drawPath(
path = drawingPath,
color = Color.Red,
style = Stroke(width = brushSize, cap = StrokeCap.Round, join = StrokeJoin.Round),
blendMode = BlendMode.Clear
)
}
if (!drawnPaths.isEmpty()) {
drawnPaths.forEach { path ->
drawPath(
path = path,
color = Color.Red,
style = Stroke(
width = brushSize,
cap = StrokeCap.Round,
join = StrokeJoin.Round
),
blendMode = BlendMode.Clear
)
}
}
}
첫 기능구현에서 작성했던 2번 modifier 레이어를 리팩토링 한 코드입니다.
graphicsLayer에 CompositingStrategy를 OffScreen 속성으로 할당해주었기 때문에 이제 해당 Modifier가 적용되는 시점부터는 새로운 오프스크린 버퍼를 할당받게 됩니다.
CompositingStrategy 는 다음과 같은 속성들을 갖습니다.
- Auto : 기본값으로 적용되며 오프스크린 버퍼를 추가로 할당할지 스스로 판단합니다. 판단 기준에는 graphicsLayer 내에 선언된 속성값들이 영향을 미치게 되며 그 예시로 alpha 값이 1 미만이면 오프스크린 버퍼를 추가로 할당하게 됩니다.
- OffScreen : graphicsLayer 내에 선언된 속성값들과 관계없이 항상 오프스크린 버퍼를 추가로 할당합니다. BlendMode.Clear를 사용할 때 이 속성을 활용해야한다고 주석에 친절하게 적혀있습니다.
- ModulateAlpha : 모든 그리기 명령에 전역적으로 알파값을 곱하도록 수정합니다. 이는 오프스크린 버퍼를 추가로 할당하지 않기 때문에 알파를 적용하기 위해 레이어를 생성하는 Auto 전략보다 메모리 효율적입니다. 그러나 이미지가 겹쳐져있는 경우 모든 이미지에 알파값이 곱해지면서 다른 결과를 가져올 수 있으므로 보통 하나의 이미지에 알파값을 적용할 때 메모리를 효율적으로 사용하기 위하여 선언하는 속성입니다.
Auto 설명을 보면 아시겠지만 OffScreen 으로 전략을 선언하지 않고 Auto인 상태에서 alpha 값만 1보다 작게 줘도 오프스크린 버퍼가 추가로 할당되기 때문에 완전히 동일한 기능을 구현할 수 있습니다.
왼쪽이 CompositingStrategy를 OffScreen으로 할당한 버전이고, 오른쪽이 CompositingStrategy는 Auto인 상태에서 overlay 이미지에 alpha값을 0.6f 준 버전입니다.


이제 잘 되죠?
'라이브러리 개발일기' 카테고리의 다른 글
| [ScratchRevealCard] 2. 그림판 구현으로 기술적 검증을 해보자. (1) | 2025.05.31 |
|---|---|
| [ScratchRevealCard] 1. 라이브러리 개발할 때 의존성 세팅방법 (implementation vs api) (1) | 2025.05.15 |