Compose Beyond UI - Building your own declarative library using the Compose Runtime
이번 글에선 기존 Molecule 분석 글에서 언급했던 기똥찬(?) Compose Runtime 활용 사례들에 대해 조금 더 자세히 소개해볼까 한다.

활용 사례들을 살펴보기 이전에, Compose Runtime에 대해 먼저 짚고 넘어갈 필요가 있다.
다만, 이번 글이 Compose Runtime을 딥다이브하는 글은 아니기 때문에 각각의 구성 요소들의 역할만 설명하도록 하겠다.
설명을 위해 해당 발표 영상의 자료를 인용하고자 한다. 가능하면 풀 영상 시청을 추천

Composition 클래스는 Composer와 Node 트리를 관리하고, Applier에게 지시를 내리는 역할을 수행한다.
첨부된 발표 자료에서는 Composition 트리를 관리한다고 명세가 되어있는데, Composition 트리는 Node로 구성된 실제 트리 구조를 의미한다. 네이밍이 Composition으로 같기 때문에 헷갈림 주의!

Composable을 실행하며 Composition 트리 구조를 결정한다. Composer의 결정에 따라 Composition 클래스가 Applier에게 Node 배치를 지시한다.

State 값을 캡쳐한 Snapshot의 업데이트를 감시하고, Recomposition을 처리하는 역할을 수행한다.

이번 글의 핵심 구성 요소인 Applier 는 실제 Node 트리를 조작, Node를 배치하는 역할을 수행하여 Composition 트리를 구체화 한다.
각각의 도메인별 구현체가 존재하며, Compose UI는 UiApplier를 사용하여 LayoutNode 기반의 트리를 구축하고, VectorPainter는 VectorApplier를 사용하여 VNode 기반의 트리를 구축한다.
Node를 트리에 삽입하는 방식은 TopDown, BottomUp 두 가지 방식이 있는데 두 Appiler는 BottomUp 방식으로 동일하다. 예전엔 달랐던거 같은데...
Jetpakc Glance에서 사용하는 Applier는 TopDown 방식으로 구현되어 있다.
interface Applier<N> { // N = Node 타입
val current: N // 현재 위치한 노드
fun insertTopDown(index: Int, instance: N) // Node 삽입
fun remove(index: Int, count: Int) // Node 제거
fun move(from: Int, to: Int, count: Int) // Node 이동
fun down(node: N) // 자식으로 내려감
fun up() // 부모로 올라감
}
참고로 Compose Runtime엔 Applier의 Interface만 정의 되어있고, Applier interface의 구현체인 UiApplier는 Compose UI 에 구현 되어있다.

https://threadreaderapp.com/thread/1390928660862017539.html
Compose UI는 Compose Runtime의 Client일 뿐이다.
Composable 함수는 UI를 직접 그리는 역할을 수행하지 않고, 무엇을 출력할지 선언만 한다. Composer가 이를 실행하며 구조를 결정하고, Composition이 Applier에게 지시하여 Node 트리를 구축한다.
실제 출력은 각 도메인의 Node에 의해 수행된다.
지금까지의 내용을 통해 알 수 있는 점은, Appiler 구현체와 Node를 직접 입맛에 맞게 구현하여 자신만의 독자적인 선언형 라이브러리를 만들 수 있다는 것이다.
다음부터 본격적으로 다양한 도메인의 Compose Runtime 활용 사례들을 하나씩 소개하도록 하겠다.
https://github.com/JakeWharton/mosaic
Compose Runtime 활용 사례의 원조격인 라이브러리로, Composable 함수를 통해 Terminal 텍스트 기반 UI를 렌더링한다.

suspend fun main() = runMosaic {
var count by remember { mutableIntStateOf(0) }
Text("The count is: $count")
LaunchedEffect(Unit) {
for (i in 1..20) {
delay(250)
count = i
}
}
}
Mosaic 만의 Applier와 Node의 구현 방식은 아래와 같다.
draw() 함수를 통해 자식 노드들을 순회하며 TextCanvas 생성print() 함수를 통해 터미널에 출력동작 흐름
Text("hello") → TextNode 생성 → TextApplier가 트리 구축 → 렌더링 과정에서 ANSI 코드로 변환 → 터미널 출력
internal suspend fun runMosaicComposition(
terminal: Terminal,
rendering: Rendering,
content: @Composable () -> Unit,
) {
val clock = BroadcastFrameClock()
val mosaicComposition = MosaicComposition(
coroutineContext = coroutineContext + clock,
onDraw = { rootNode ->
// MosaicNode 트리 → ANSI 코드 변환 → 터미널에 ANSI 코드 문자열 출력
print(rendering.render(rootNode).toString())
},
terminal = terminal,
)
mosaicComposition.setContent(content)
mosaicComposition.scope.launch {
while (true) {
clock.sendFrame(nanoTime())
// "1000 FPS should be enough for anybody"
// We need to yield in order for other coroutines on this dispatcher to run, otherwise
// this is effectively a spin loop. We do a delay instead of a yield since dispatchers
// are not required to support yield, but reasonable delay support is almost a guarantee.
delay(1)
}
}
mosaicComposition.awaitComplete()
}
public suspend fun runMosaic(
onNonInteractive: NonInteractivePolicy = Exit,
content: @Composable () -> Unit,
): Boolean = withTerminal(onNonInteractive) { terminal ->
// 렌더링 방식 결정 (디버깅 모드 vs ANSI 코드)
val rendering = if (env("MOSAIC_DEBUG_RENDERING") == "true") {
DebugRendering(terminal.capabilities)
} else {
// ANSI 이스케이프 코드 생성
AnsiRendering(terminal.capabilities)
}
runMosaicComposition(terminal, rendering, content)
}
https://github.com/fgiris/composePPT
ComposePPT는 그 이름에서 알 수 있듯, Composable 함수를 통해 PowerPoint 프레젠테이션을 생성하는 라이브러리이다.
Mosaic에 영감을 받았다고 README에 언급이 되어있다.

runComposePPT {
Slide(title = "ComposePPT") {
Text(
text = "ComposePPT is a UI toolkit to create PowerPoint " +
"presentations with Compose 🤩"
)
}
}
마찬가지로 ComposePPT의 Appiler와 Node를 확인해보면 다음과 같다.
ComposePPTNode (추상 클래스)
├─ PresentationNode (루트 노드, 프레젠테이션 전체)
├─ SlideNode (슬라이드)
├─ TextNode (텍스트)
└─ ListNode (리스트)
display() 함수를 통해 Apache POI로 변환 -> .pptx 저장 동작 흐름
Slide { Text("...") } → SlideNode + TextNode 생성 → Apache POI로 .pptx 생성
// runComposePPT에서 ComposePPTDisplay 클래스의 display 함수 호출
private fun display(
rootNode: ComposePPTNode,
presentationFileName: String
) {
val composePPTDisplay = ComposePPTDisplay(presentationFileName)
val canvas = ComposePPTCanvas()
composePPTDisplay.display(rootNode.render(canvas))
// ↑ ComposePPTDisplay가 ComposePPTCanvasContent → Apache POI 변환
}
display 내에서 각 컨텐츠 타입별로 분기 처리하여 해당하는 Apache POI 작업을 수행한다.
override fun display(content: ComposePPTCanvasContent) {
when (content) {
is ComposePPTCanvasContent.TextContent -> {
doDisplay(content)
}
is ComposePPTCanvasContent.ListContent -> {
content.contentList.forEach {
display(it)
}
}
is ComposePPTCanvasContent.SlideContent -> {
createSlide(content)
clearPlaceholdersText()
setSlideTitle(content)
display(content.content)
}
is ComposePPTCanvasContent.PresentationContent -> {
content.slides.forEach {
display(it)
}
}
}
}
https://github.com/usuiat/Koruri
사실 이 라이브러리 소개하려고 글을 작성했다.
이거 보여주려고 어그로 끌었다
Composable 함수를 통해 AudioTrack 기반 오디오 신호를 생성하고 처리하는 라이브러리이다.
Chain {
SquareWave(
frequency = { frequency * vibrato },
pulseWidht = { pulseWidth + modulation }
)
VolumnEnvelope(
attack = { attack },
decay = { decay },
sustain = { sustain },
release = { release },
gate = { gate }
)
if (lpfEnabled) {
LowPassFilter(
cutoff = { lpfCutOff },
resonance = { lpResonance }
)
}
}
다음과 같이 메인테이너분의 Koruri 개발 관련 발표 영상이 있으니 참고하면 좋을 듯 하다.

시간이 없는 바쁜 현대인들을 위한 LilysAI 요약본
발표가 굉장히 알차며, Compose Runtime이 하는 역할에 대해서도 다시 한번 복습할 수 있기 때문에 요약본과 함께 발표 들어보는 것 강추!
참고로 Github README에서도 Koruri 기반 오디오 출력 영상을 확인할 수 있다.
마지막으로 Koruri의 Appiler와 Node를 확인해보면 아래와 같다.
SignalProcessor 보유(오디오 처리 로직)children 리스트 보유(자식 노드 리스트)process(): 입력 신호 + 자식 노드 → 처리된 신호 출력public interface SignalProcessor {
/**
* Processes the input audio signal and returns the processed result.
*
* @param input The input audio sample array.
* @param children The list of child audio processor nodes.
* @return The processed audio sample array.
*/
public fun process(input: FloatArray, children: List<AudioProcessorNode>): FloatArray
}
동작 흐름
SineWave(440f) → KoruriNode(SineWaveProcessor) 생성 → KoruriApplier가 트리 구축 -> 트리 순회하며 SignalProcessor 실행 → PCM FloatArray 반환 → AudioTrack.write()
발표 내용 중 음량을 서서히, 자연스럽게 감쇠시키기 위해 Compose Animation API를 사용하였다고 말씀하시는게 인상적이었다. 애니메이션을 UI가 아닌 다른 도메인에서, 다양한 목적으로 사용할 수 있을 줄이야...+_+
Compose Runtime의 구성 요소들의 역할을 복습하고, Compose Runtime을 활용한 여러 재미난 라이브러리들의 내부 구현 코드들을 살펴보며 Compose Runtime의 활용 가능성을 확인하고 Compose를 바라보는 시야를 넓힐 수 있었다.
또한 라이브러리마다 Appiler의 트리 조작 방식이 Top Down과 Bottom Up으로 다른 것을 확인할 수 있었는데, 언제 어떤 방식을 써야할지, 왜 Compose UI는 Bottom Up으로 Jetpack Glance는 Top Down 방식을 선택했는지 추후 알아보도록 해야겠다.
Mosaic: Composable → MosaicNodeApplier → 터미널 ANSI 코드 출력
ComposePPT: Composable → ComposePPTApplier → Apache POI로 .pptx 생성
Koruri: Composable → KoruriApplier → AudioTrack에 PCM 데이터 write
reference)
https://github.com/JakeWharton/mosaic
https://github.com/fgiris/composePPT
https://github.com/usuiat/Koruri
DroidKaigi 2025 참관 경험담
https://augustin26.tistory.com/entry/Composer%EC%99%80-Composition
https://cs.android.com/
https://en.wikipedia.org/wiki/ANSI_escape_code
https://ko.wikipedia.org/wiki/%ED%8E%84%EC%8A%A4_%EB%B6%80%ED%98%B8_%EB%B3%80%EC%A1%B0
https://github.com/compose-jindong/jindong
활용 사례 추가!