<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by NEON on Medium]]></title>
        <description><![CDATA[Stories by NEON on Medium]]></description>
        <link>https://medium.com/@neoself1105?source=rss-18d63a54716d------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*Yw7vUHN4a-wq58f21sBqOA.png</url>
            <title>Stories by NEON on Medium</title>
            <link>https://medium.com/@neoself1105?source=rss-18d63a54716d------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 16 May 2026 10:19:50 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@neoself1105/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[STM 32 데이터 통신 간 Race Condition 디버깅 과정]]></title>
            <link>https://medium.com/@neoself1105/stm-32-%EB%8D%B0%EC%9D%B4%ED%84%B0-%ED%86%B5%EC%8B%A0-%EA%B0%84-race-condition-%EB%94%94%EB%B2%84%EA%B9%85-%EA%B3%BC%EC%A0%95-e4a042b0aee1?source=rss-18d63a54716d------2</link>
            <guid isPermaLink="false">https://medium.com/p/e4a042b0aee1</guid>
            <dc:creator><![CDATA[NEON]]></dc:creator>
            <pubDate>Sat, 28 Mar 2026 06:06:42 GMT</pubDate>
            <atom:updated>2026-03-28T06:06:42.932Z</atom:updated>
            <content:encoded><![CDATA[<p>STM32 제어 보드에 데이터(운동강도)를 전송할 때, 값이 적용되지 않는 문제를 추적한 과정을 기록합니다.</p><h3><strong>1. 증상- 분명 보냈는데 운동강도가 계속 0에서 변경되지 않아요.</strong></h3><p>운동 시작 버튼을 누르면 아래 두 가지가 STM32 제어 보드로 전송되어야 합니다.</p><ol><li>운동강도 파라미터 패킷</li></ol><p>2. 운동 시작을 지시하는 상태 명령 패킷(state = 4)</p><p>하지만, Web 버전에서는 정상 동작하는 운동시작 데이터 전송 과정이, Android 이식 이후, 부하가 초기값인 0에서 변동되지 않고 운동이 시작되는 이슈가 있었습니다.</p><p>로그를 보면 두 패킷 모두 의도했던 50byte 크기로 전송되는 것을 확인할 수 있습니다. 전송 자체는 성공인데, STM이 부하를 인식하지 못하는 상황입니다.</p><pre>I/StmSerialManager: paramTransmitToSTM: 5.0, 5.0, 5.0, 5.0, 5.0, 99.0, ...<br>I/StmSerialManager: Sent 50 bytes<br>I/StmSerialManager: sendStateCommand: 4<br>I/StmSerialManager: Sent 50 bytes</pre><p>“프로토콜이 다른 건 아닐까?” 의심이 들어, 두 플랫폼 간 프로토콜을 바이트 단위로 비교한 결과, 두 패킷 모두 차이가 없어 원인이 아님을 확인했습니다.</p><h3><strong>2. 원인 추적: 비동기 전송과 Race Condition</strong></h3><p>그 이후, 운동 시작 간 2개의 패킷을 전송하는 것이기에, 보내는 시점 차이가 변수가 될 수 있다고 판단하였습니다.</p><h4><strong>2.1 scope.launch의 함정</strong></h4><p>Android의 USB Host api 내부 bulkTransfer는 응답을 기다려야 하는 블로킹 작업, 즉 호출한 스레드가 결과를 받을 때까지 멈춰 다른 일을 할 수 없는 작업입니다. 때문에 이를 메인스레드에서 호출할 경우, UI가 멈추는 freezing 현상으로 이어지기에 코루틴으로 백그라운드에서 처리합니다.</p><p><em>*여기서 코루틴은 suspend 및 resume이 가능한 경량 실행 단위입니다.</em></p><p>문제는 Kotlin의 scope.launch는 작업을 백그라운드 스레드풀에 맡기고 <strong>즉시 반환</strong>하며, 이로 인해 scope.launch를 여러번 호출하면 독립된 코루틴들이 동시에 실행되어 순서가 보장되지 않는 Race Condition이 발생할 수 있다는 것입니다.</p><pre>scope.launch { bulkTransfer(outEndpoint, packet1, 50, 1000) }  // 즉시 반환<br>scope.launch { bulkTransfer(outEndpoint, packet2, 50, 1000) }  // 즉시 반환</pre><h4>2.2 Race Condition 사례</h4><p><strong>Race Condition</strong>이란, 둘 이상의 작업이 공유 자원에 접근할 때 실행 순서에 따라 결과가 달라지는 버그입니다. 우리 코드에서는 이렇게 나타났습니다.</p><p><strong>운 좋을 때:</strong></p><blockquote><strong><em>코루틴A</em></strong><em>: 부하 패킷 전송 → 완료<br></em><strong><em>코루틴B</em></strong><em>: 상태 패킷 전송 ──────→ 완료<br>STM 수신: [부하=5.0] → [상태=Running] → 부하 5.0으로 모터 구동 ✅</em></blockquote><p><strong>운 나쁠 때:</strong></p><blockquote><strong><em>코루틴A</em></strong><em>: 부하 패킷 전송 ──────→ 전송 중…<br></em><strong><em>코루틴B</em></strong><em>: 상태 패킷 전송 ──→ 전송 완료!<br>STM 수신: [상태=Running] → 부하가 아직 안 왔는데 Running!</em></blockquote><blockquote>→ 기본값(0)으로 구동 ❌</blockquote><p>Web에서는 Node.js 기반 serialPort 라이브러리가 write 작업을 FIFO 큐로 직렬화 하고 있었으나, Android에서는 별도 라이브러리를 사용하지 않았기에 순서 보장 매커니즘이 부재한 것이었습니다.</p><h4>3. 고민하였던 동시성 제어 패턴들</h4><h4><strong>3.1 Mutex (상호 배제)</strong></h4><pre>Mutex = 화장실 열쇠<br><br>    코루틴A: &quot;열쇠 주세요&quot; → 획득 → [전송 중...] → 반납<br>                                                   ↓<br>    코루틴B:              &quot;열쇠 주세요&quot; → 대기... → 획득 → [전송 중...] → 반납</pre><p><strong>한 번에 하나의 코루틴만</strong> 공유 자원(USB 포트)에 접근할 수 있는 패턴입니다.</p><pre>private val mutex = Mutex()<br><br>scope.launch {<br>    mutex.withLock {<br>        bulkTransfer(packet)  // 이 안에서는 다른 코루틴이 끼어들 수 없음<br>    }<br>}</pre><p><strong>장점</strong>: 구현이 간단<br><strong>단점</strong>: 두 코루틴이 거의 동시에 launch되면, 어느 것이 먼저 lock을 잡을지는 여전히 불확정 -&gt; 순서 보장이 아니라 “상호 배제”만 제공.</p><h4>3.2 Channel(채널 기반 큐)</h4><p>이는 iOS에서 사용되는 DispathQueue와 유사합니다.</p><pre>Channel = 편의점 번호표 시스템<br><br>    손님A가 번호표 1번 뽑음 → [대기열: 1]<br>    손님B가 번호표 2번 뽑음 → [대기열: 1, 2]<br>                                     ↓<br>    직원이 1번 호출 → A 처리 → 2번 호출 → B 처리</pre><p>패킷을 Channel(큐)에 넣으면, <strong>단일 소비자 코루틴</strong>이 넣은 순서대로(FIFO) 하나씩 꺼내서 전송합니다.</p><pre>// 생산자: 패킷을 큐에 넣기만 함 (즉시 반환)<br>sendChannel.trySend(packet1)  // → [큐: packet1]<br>sendChannel.trySend(packet2)  // → [큐: packet1, packet2]<br><br>// 소비자: 단일 코루틴이 순서대로 처리<br>scope.launch {<br>    for (packet in sendChannel) {<br>        bulkTransfer(packet)  // packet1 먼저, 그다음 packet2<br>    }<br>}</pre><p><strong>장점</strong>: <strong>삽입 순서가 곧 처리 순서</strong>. FIFO 보장.<br><strong>단점</strong>: 채널 관리(생성, 소멸) 필요</p><h4><strong>3.3 순차 코루틴 (Sequential Coroutine)</strong></h4><pre>scope.launch {<br>    bulkTransfer(packet1)  // 완료될 때까지 대기<br>    delay(100)             // STM 처리 시간 확보<br>    bulkTransfer(packet2)  // 그 다음 실행<br>}</pre><p><strong>장점</strong>: 가장 직관적</p><p><strong>단점</strong>: 호출 측에서 매번 순서를 관리해야 함. 여러 곳에서 sendCommand를 호출하면 일관성 유지가 어려움.</p><h4><strong>3.4 왜 Channel을 선택했는가?</strong></h4><p>초기에는 순차 코루틴을 시도했습니다. 하지만, 호출하는 곳마다 2개의 명령어를 하나의 코루틴으로 묶는 작업을 직접 관리해야했습니다. 이는 호출부가 늘어날 수록 휴먼 에러로 이어질 확률이 높아진다고 판단했습니다.</p><p>하지만, <strong>Channel</strong>방식은 sendCommand 내부에서 큐에 넣는 작업으로 대체하기만 하면, 호출 위치 상관없이 순서 보장 및 상호배제가 이뤄지기 때문에 Channel을 통한 제어 패턴을 최종 선택하였습니다.</p><h3>4. 수정 적용</h3><h4><strong>변경 1: 전송 채널 선언</strong></h4><pre>// 수정 전:<br>private val scope = CoroutineScope(Dispatchers.IO)<br>private var readJob: Job? = null<br><br>// 수정 후:<br>private val scope = CoroutineScope(Dispatchers.IO)<br>private var readJob: Job? = null<br>private var sendJob: Job? = null<br>private val sendChannel = Channel&lt;ByteArray&gt;(Channel.UNLIMITED)</pre><h4><strong>변경 2: 단일 소비자 코루틴 시작 (</strong><strong>startSendLoop)</strong></h4><pre>fun initialize() {<br>    // ... (기존 코드) ...<br>    startSendLoop()    // ← 추가<br>    scanAndConnect()<br>}<br><br>private fun startSendLoop() {<br>    sendJob?.cancel()<br>    sendJob = scope.launch {<br>        for (packet in sendChannel) {<br>            val ep = outEndpoint<br>            if (ep == null) {<br>                Log.e(TAG, &quot;Send failed: no OUT endpoint&quot;)<br>                continue<br>            }<br>            val sent = usbConnection?.bulkTransfer(ep, packet, packet.size, 1000) ?: -1<br>            if (sent &lt; 0) {<br>                Log.e(TAG, &quot;Send failed: bulkTransfer returned $sent&quot;)<br>            } else {<br>                Log.d(TAG, &quot;Sent $sent bytes&quot;)<br>            }<br>        }<br>    }<br>}</pre><h4><strong>변경 3: sendCommand에서 직접 전송 → 채널 큐잉으로 교체</strong></h4><pre>// 수정 전:<br>private fun sendCommand(...) {<br>    // ... (패킷 구성) ...<br>    scope.launch {  // ← 매번 새 코루틴 = 순서 보장 안 됨<br>        usbConnection?.bulkTransfer(ep, packet, packet.size, 1000)<br>    }<br>}<br><br>// 수정 후:<br>private fun sendCommand(...) {<br>    // ... (패킷 구성) ...<br>    sendChannel.trySend(packet)  // ← 큐에 넣기만 함. 순서 보장됨.<br>}</pre><h3>5. 교훈</h3><p><strong>교훈 1: scope.launch는 “보내기”가 아니라 “부탁하기”입니다…</strong></p><pre>scope.launch { bulkTransfer(packet) }</pre><p>이 코드는 “패킷을 보내라”가 아니라 “<strong>패킷을 보내달라고 스레드풀에 부탁하는 것”</strong>입니다. 호출한 순서대로 처리된다고 가정하였으나, 스레드풀의 스케줄링에 따라 순서가 뒤바뀔 수 있음을 이번 디버깅 경험을 통해 깨달았습니다.</p><p><strong>교훈 2: Race Condition은 로그로 잡기 어렵다</strong></p><pre>I/StmSerialManager: paramTransmitToSTM: 5.0, 5.0, ...<br>I/StmSerialManager: sendStateCommand: 4<br>I/StmSerialManager: Sent 50 bytes<br>I/StmSerialManager: Sent 50 bytes</pre><p>교훈 1과 맥락을 같이하는 교훈인데요. 위 로그를 보면 실제 운동강도 전달 로직 (paramTransmitToSTM)이 먼저 실행된 것처럼 보이지만, 이는 실제 USB 버스에 올라간 시점이 아닌 호출 자체가 이뤄진 시점입니다. 때문에, Race Condition을 의심할 때는 코드 레벨에서 이를 검증해야합니다.</p><p>감사합니다.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e4a042b0aee1" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Android USB Host API로 STM32과 시리얼 통신 구현하기]]></title>
            <link>https://medium.com/@neoself1105/android-usb-host-api%EB%A1%9C-stm32%EA%B3%BC-%EC%8B%9C%EB%A6%AC%EC%96%BC-%ED%86%B5%EC%8B%A0-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-dd5ee366ceeb?source=rss-18d63a54716d------2</link>
            <guid isPermaLink="false">https://medium.com/p/dd5ee366ceeb</guid>
            <dc:creator><![CDATA[NEON]]></dc:creator>
            <pubDate>Sat, 28 Mar 2026 04:33:47 GMT</pubDate>
            <atom:updated>2026-03-28T04:33:47.143Z</atom:updated>
            <content:encoded><![CDATA[<p>운동 기구 제어 앱을 Windows Electron에서 Android 태블릿으로 이식하면서, USB Host API를 사용해 STM32 보드와 시리얼 통신을 직접 구현한 과정을 기록합니다.</p><p><em>*보안상 전체 코드는 따로 공유하고 있지 않은 점 양해 부탁드립니다.</em></p><h3>1. 배경- 우리가 만들고 있는 것</h3><p>저희가 담당하고 있는 프로젝트는 <strong>“운동 기구를 제어하는 태블릿 앱”</strong> 입니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/596/1*Gq-bCkXKSsEHOL6xfkQ6uA.png" /></figure><ul><li><strong>태블릿 앱</strong>: 사용자에게 운동 화면을 보여주고, 운동 시작/중지 명령을 보냄</li><li><strong>STM32 보드</strong>: 실제 모터를 제어하고, 페달 속도/힘/파워 데이터를 실시간으로 보내줌</li></ul><p>이 둘은 <strong>USB 케이블</strong>로 연결되어 50바이트짜리 데이터 패킷을 주고받습니다.</p><h4><strong>기존 웹 버전- serialport 한 줄이면 끝</strong></h4><p>원래 이 시스템은 <strong>Windows PC + Electron 웹앱</strong>으로 동작하고 있었습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/956/1*t1In84KGV34y-Q0b7j9Gvg.png" /></figure><p>Node.js의 serialport라이브러리를 사용해서 COM12 포트에 연결하면 끝이었습니다. 운영체제가 USB 디바이스를 “시리얼 포트”로 자동 인식해주기 때문에, 개발자는 복잡한 USB 프로토콜을 신경 쓸 필요가 없었습니다.</p><h4><strong>Android로 이식하기- 그리고 마주친 현실</strong></h4><p>기존 Windows PC + Electron 구성에는 두 가지 한계가 있었습니다.</p><ul><li><strong>설치/유지보수 부담</strong><br>- PC는 Windows 업데이트, 드라이버 관리, 장애 대응이 필요<br>- 태블릿은 앱 하나 설치하면 끝, 키오스크 모드로 잠그면 사용자가 건드릴 것도 없음</li><li><strong>사용자 경험<br></strong>- 드래그 동작에 대한 대응 미비<br>- 안드로이드의 경우, 기존 디자인 시스템 활용으로 플랫폼 확장에 용이</li></ul><p>그래서 <strong>Android 태블릿 전용 앱</strong>으로 만들기로 했습니다.</p><p>그런데 막상 이식을 시작하니, 웹에서는 없던 문제가 나타났습니다. Android에서는 Node.js의 serialport같은 편리한 라이브러리가 없고, 대신 <strong>Android USB Host API</strong>를 사용해서 직접 USB 디바이스와 통신해야 했습니다.</p><p>웹에서는 OS가 모든 복잡성을 처리해줬지만, Android에서는 앱이 직접 USB 프로토콜의 모든 단계를 처리해야 합니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/972/1*BF7bhrX9o7DeQSt9RgQbrw.png" /></figure><p>이 글에서는 Android USB Host API로 통신을 구현하는 전체 과정과, 그 과정에서 만난 이슈들을 다룹니다.</p><h3><strong>2. USB 통신의 기본 개념</strong></h3><p>구현에 앞서, USB 통신의 핵심 개념을 짚고 가겠습니다.</p><h4><strong>2.1 USB 디바이스 = 아파트 건물</strong></h4><p>USB 디바이스 하나를 빌라건물이라고 생각해볼까요??</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/482/1*zij4pHiMvyBwx6qrSc2sCw.png" /></figure><ul><li><strong>Interface</strong> = 빌라의 각 호수</li><li>각 호수에는 다른 기능이 하나씩 살고 있습니다 (시리얼 통신, 저장장치, 디버그 등)</li></ul><h4><strong>2.2 Endpoint = 우편함</strong></h4><p>각 호수(Interface)에는 <strong>우편함(Endpoint)</strong>이 있습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/482/1*9reBJvxqMEPsXqS_Sq4PXA.png" /></figure><ul><li><strong>IN Endpoint</strong>: 디바이스 → 호스트 (STM32 → 태블릿) 방향</li><li><strong>OUT Endpoint</strong>: 호스트 → 디바이스 (태블릿 → STM32) 방향</li></ul><p>여기서 IN/OUT은 <strong>호스트(태블릿) 관점</strong>입니다.<br>IN = “태블릿으로 들어오는”, OUT = “태블릿에서 나가는”</p><h4><strong>2.3 Claim = 열쇠 받기</strong></h4><p>아파트 호수를 사용하려면 <strong>열쇠(Claim)</strong>를 받아야 합니다.</p><blockquote><em>태블릿 앱: 3호(Interface 3) 쓰고 싶습니다<br>Android 운영체제: “네, 여기 열쇠요” (claimInterface → true)<br>태블릿 앱: (열쇠로 문 열고 우편함 사용)</em></blockquote><p><strong>만약 다른 누군가(커널 드라이버)가 이미 그 호수를 쓰고 있다면?</strong></p><blockquote><em>태블릿 앱: “3호 쓰고 싶습니다”<br>Android 운영체제: “안 됩니다, 이미 다른 사람이 쓰고 있어요” (claimInterface → false)<br>태블릿 앱: (우편함 사용 불가 = Send failed)</em></blockquote><h4>2.4 USB Class Code = 호수별 용도 표지판</h4><p>빌라 각 호수에는 용도 표시판이 붙어있습니다. 이는 USB 표준 기구(USB-IF)가 정한 국제 표준 번호로, 어떤 기기든 동일한 규칙을 따릅니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/860/1*VepWrxDABklY2V9rK8A6bg.png" /></figure><p>CDC ACM(Abstract Control Model) 시리얼 통신에서는 <strong>Class 2 + Class 10</strong>이 반드시 한 쌍으로 동작합니다.</p><ul><li><strong>Class 2 (CDC Communication)</strong>: “115200 baud로 통신하자”고 약속하는 제어 채널</li><li><strong>Class 10 (CDC Data)</strong>: 실제 데이터가 흐르는 데이터 채널</li></ul><p>이 Class 번호는 USB 규격서에 명시된 표준이기 때문에, STM32든 Arduino든 ESP32든 CDC ACM을 구현하면 항상 이 번호를 사용합니다.</p><p><em>*단, 제조사가 CDC 표준을 쓰지 않고 </em><strong><em>Class 255 (Vendor-Specific)</em></strong><em>로 자체 시리얼 프로토콜을 구현하는 경우도 있습니다. 이 경우에는 Class 10이 아예 없고, 제조사 문서를 봐야 합니다.</em></p><h4><strong>2.5 CDC ACM 드라이버 = 자동 번역기</strong></h4><p>CDC ACM 드라이버는 <strong>USB 시리얼 통신을 처리하는 운영체제의 커널 드라이버</strong>입니다. USB 디바이스가 연결되면, OS 커널이 “Class 2+10이 있네? CDC ACM이구나!” 하고 이 드라이버를 자동으로 붙여서 <strong>가상 시리얼 포트를 만들어줍니다.</strong></p><blockquote><em>1. USB 디바이스 연결</em></blockquote><blockquote><em>2. 커널: “Interface Class 2+10 발견 → CDC ACM이구나”</em></blockquote><blockquote><em>3. cdc_acm 드라이버 자동 로드</em></blockquote><blockquote><em>4. 가상 시리얼 포트 생성</em></blockquote><blockquote><em>- Windows: COM12<br>- Linux: /dev/ttyACM0<br>- Mac: /dev/tty.usbmodem…</em></blockquote><p>Windows PC에서 COM12로 편하게 통신할 수 있었던 건 이 드라이버 덕분이었습니다. 그렇다면 Android에서도 이 드라이버가 만든 /dev/ttyACM0을 직접 사용하면 되지 않을까요?</p><p>이는 Android의 권한 시스템 때문에 <strong>불가능합니다.</strong></p><p>루팅된 기기나 시스템 앱이 아닌 이상, 일반 앱은 이 파일에 접근할 수 없습니다.그래서 Android는 <strong>USB Host API</strong>라는 별도 경로를 제공합니다.</p><p><a href="https://developer.android.com/develop/connectivity/usb/host?hl=ko">USB 호스트 개요 | Connectivity | Android Developers</a></p><p><strong>커널 드라이버 경로 (일반 앱은 사용 불가)</strong></p><blockquote>앱 → /dev/ttyACM0 → cdc_acm 드라이버 → USB → STM32<br>❌ Permission Denied</blockquote><p><strong>USB Host API 경로 (우리가 사용하는 방법)</strong></p><blockquote>앱 → UsbManager → USB 권한 팝업 → 사용자 승인 → 직접 통신 → STM32<br>✅ 사용자가 권한 승인하면 OK</blockquote><p>USB Host API는 커널 드라이버를 우회해서 앱이 직접 USB 디바이스와 통신하는 방법입니다. 대신 커널 드라이버가 해주던 모든 것(Interface 탐색, claim, CDC 초기화 등)을 <strong>앱이 직접 구현해야</strong> 합니다.</p><p><strong>2.6 커널 드라이버 = 자동 입주 시스템</strong></p><p>Android(Linux)에는 <strong>자동 입주 시스템</strong>이 있습니다. USB 디바이스가 연결되면, 운영체제의 커널이 자동으로 “아, 이건 시리얼 디바이스구나!” 하고 해당 Interface에 드라이버를 자동으로 붙여버립니다 (마치 관리소가 빈 방에 자동으로 세입자를 넣는 것처럼).</p><p>여기서 문제가 하나 발생합니다. 커널의 드라이버 바인딩은 USB가 물리적으로 연결되는 순간 바로 발생하는데, USB Host api를 통한 claim 작업은 앱에서 openDevice()를 호출한 이후에나 진행됩니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/552/1*w-W_H9zI_UXpZMcMU_vlWg.png" /></figure><p>즉 커널 드라이버 바인딩과 openDevice() 사이에는 브로드캐스트 수신 + 권한 요청 + 사용자 승인이라는 여러 단계가 끼어 있어서, 커널이 항상 먼저 점유하게 됩니다.</p><h3><strong>3. 우리 STM32 디바이스의 구조</strong></h3><p>구현에 앞서 저희가 데이터 통신을 진행하게 될 STM32 제어보드의 구조를 파악하고자 했습니다. 디버깅 로그를 통해 확인한 우리 STM32 디바이스의 실제 구조입니다.</p><pre>USB Device: VID=0x0483 (STMicroelectronics), PID=0x374E<br><br>┌─── Interface 0 ──────────────────────────┐<br>│ Class: 255 (Vendor-Specific, 제조사 고유)   │<br>│ Endpoints: 3개                            │<br>│   EP0: BULK IN  (addr=0x81, 512 bytes)   │<br>│   EP1: BULK OUT (addr=0x01, 512 bytes)   │<br>│   EP2: BULK IN  (addr=0x82, 512 bytes)   │<br>│ → 제조사가 임의로 만든 인터페이스            │<br>└──────────────────────────────────────────┘<br><br>┌─── Interface 1 ──────────────────────────┐<br>│ Class: 8 (Mass Storage, USB 저장장치)      │<br>│ Endpoints: 2개                            │<br>│   EP0: BULK IN  (addr=0x83, 512 bytes)   │<br>│   EP1: BULK OUT (addr=0x03, 512 bytes)   │<br>│ → USB 메모리처럼 파일 저장용               │<br>│ → 우리와 무관                              │<br>└──────────────────────────────────────────┘<br><br>┌─── Interface 2 ──────────────────────────┐<br>│ Class: 2 (CDC Communication, 시리얼 제어)  │<br>│ Endpoints: 1개                            │<br>│   EP0: INTERRUPT IN (addr=0x84, 10 bytes)│<br>│ → 시리얼 포트의 &quot;제어 채널&quot;                │<br>│ → 보드레이트 설정, DTR/RTS 신호 등          │<br>└──────────────────────────────────────────┘<br><br>┌─── Interface 3 ──────────────────────────┐<br>│ Class: 10 (CDC Data, 시리얼 데이터)        │<br>│ Endpoints: 2개                            │<br>│   EP0: BULK OUT (addr=0x05, 512 bytes)   │<br>│   EP1: BULK IN  (addr=0x85, 512 bytes)   │<br>│ → 실제 시리얼 데이터가 오가는 곳 ✅         │<br>│ → 이 Endpoint를 사용해야 함                │<br>└──────────────────────────────────────────┘</pre><p>이 디바이스는 <strong>Composite USB Device</strong> (복합 USB 디바이스)입니다. 하나의 USB 장치 안에 여러 기능(시리얼, 저장장치, 제조사 고유 기능)이 함께 들어있습니다.</p><p>시리얼 통신에 필요한 것은 <strong>Interface 2 (CDC Comm, 제어) + Interface 3 (CDC Data, 데이터)</strong>조합입니다.</p><h3><strong>4. 구현 Step 1- 올바른 Interface와 Endpoint 찾기</strong></h3><h4><strong>첫 번째 시도: Interface 0번 고정</strong></h4><p>처음 작성한 코드는 아주 단순했습니다.</p><pre>// &quot;무조건 Interface 0번의 Endpoint를 사용한다&quot;<br>val intf = device.getInterface(0)<br>usbConnection?.claimInterface(intf, true)<br><br>// Interface 0에서 IN/OUT Endpoint를 찾는다<br>val endpoint = (0 until intf.endpointCount)<br>    .map { intf.getEndpoint(it) }<br>    .firstOrNull { it.direction == USB_DIR_OUT }</pre><p>이 코드는 Interface 0 (class=255, Vendor-Specific)의 Endpoint를 사용했습니다.</p><blockquote><em>태블릿 앱 &gt; [Interface 0, addr=0x01] &gt; STM32<br>❌ 이건 시리얼이 아님!</em></blockquote><p>비유하자면, <strong>시리얼 통신 우편함은 3호(Interface 3)에 있는데, 1호(Interface 0)의 우편함에 편지를 넣은 것</strong>입니다.</p><h4><strong>이슈: </strong><strong>bulkTransfer returned -1</strong></h4><p>Interface 0의 Endpoint로 데이터를 보내면, STM32는 이를 시리얼 데이터로 인식하지 않습니다.</p><p>결과는 Send failed: bulkTransfer returned -1</p><p>Interface 0(Vendor-Specific)에도 BULK IN/OUT Endpoint가 있어서 겉보기에는 비슷해 보이지만, 실제 용도가 다릅니다. <strong>Class Code를 확인해야 하는 이유</strong>입니다.</p><h4><strong>해결: Class Code 기반 동적 탐색</strong></h4><pre>// Interface를 분류한다<br>for (i in 0 until device.interfaceCount) {<br>    val intf = device.getInterface(i)<br>    if (intf.interfaceClass == 2) cdcCommInterface = intf   // 시리얼 제어<br>    if (intf.interfaceClass == 10) cdcDataInterface = intf  // 시리얼 데이터 ✅<br>    if (intf.interfaceClass == 255) vendorInterface = intf  // 제조사 고유<br>}<br><br>// CDC Data를 최우선으로 시도<br>val interfacesToTry = listOfNotNull(cdcDataInterface, vendorInterface)</pre><p>이제 올바른 우편함(Interface 3, addr=0x05/0x85)을 사용하게 되었습니다:</p><blockquote><em>태블릿 앱 &gt; [Interface 3, addr=0x05] &gt; STM32<br>✅ CDC Data = 시리얼 통신</em></blockquote><h3><strong>5. 구현 Step 2- 커널 드라이버 분리 및 Interface Claim</strong></h3><p>올바른 Interface를 찾았으니, 다음은 <strong>claim(열쇠 받기)</strong>입니다.</p><h4><strong>이슈: 모든 Interface에서 claim 실패</strong></h4><pre>Interface 0: class=255, endpoints=3, claimed=false<br>Interface 1: class=8,   endpoints=2, claimed=false<br>Interface 2: class=2,   endpoints=1, claimed=false<br>Interface 3: class=10,  endpoints=2, claimed=false  ← 여기도 실패!</pre><p><strong>모든 Interface에서 claim이 실패</strong>하고 있었습니다.</p><h4><strong>원인: 커널 드라이버 자동 점유</strong></h4><p>앞서 설명한 “자동 입주 시스템” 때문이었습니다. force=true는 “강제로 빼앗기”인데, 어떤 이유에서인지 강제 해제가 제대로 동작하지 않았습니다.</p><h4><strong>해결: ioctl로 커널 드라이버 직접 분리</strong></h4><p>Linux 커널에는 <strong>USBDEVFS_DISCONNECT</strong>라는 명령이 있습니다. 이 명령을 보내면 커널 드라이버를 강제로 분리할 수 있습니다. 따라서 아래와 같이 USB 디바이스의 모든 Interface 를 순회하면서, 각 Interface에 붙어있는 드라이버를 강제로 떼어내고, ioctl(input/output control) 시스템 콜을 통해 Linux에서 디바이스에게 드라이버를 분리하라는 고정된 명령코드(0x5516)로 구성된 시스템 콜을 실행시켰습니다.</p><pre>private fun tryDetachKernelDriver(conn: UsbDeviceConnection) {<br>    val fd = conn.fileDescriptor  // USB 디바이스의 파일 디스크립터<br><br>    for (i in 0 until device.interfaceCount) {<br>        // USBDEVFS_DISCONNECT (0x5516) ioctl 호출<br><br>        // ifaceBytes: 어떤 Interface에서 분리할지 (0, 1, 2, 3...)에 대한 범위<br>        val ifaceBytes = ByteBuffer.allocate(4)  // 4바이트 공간 확보<br>            .order(ByteOrder.LITTLE_ENDIAN) // 바이트 순서는 작은쪽부터<br>            .putInt(i) // Interface 번호를 삽입<br>            .array() // byte[] 배열로 변환<br>        Os.ioctl(fdObj, 0x5516, ifaceBytes)<br>    }<br>}</pre><p>ioctl은 커널 레벨 API라서 Kotlin의 Int를 그대로 못 넘기고, 커널이 기대하는 바이트 배열 형식으로 변환해야 합니다. Linux 커널은 Little Endian으로 정수를 읽으므로 Interface 3을 넘기는 경우 아래와 같이 인자가 구성되어 전달됩니다.</p><blockquote><em>Int 값: 3<br>Little Endian 바이트: [0x03, 0x00, 0x00, 0x00]</em></blockquote><h3><strong>6. 구현 Step 3- CDC 초기화 (SET_LINE_CODING)</strong></h3><p>Interface를 claim하고 Endpoint를 잡았으니, 마지막으로 <strong>CDC 시리얼 초기화</strong>가 필요합니다.</p><h4><strong>CDC ACM이란?</strong></h4><p>USB CDC ACM은 “USB로 시리얼 포트를 흉내내는” 표준 프로토콜입니다. 시리얼 포트에는 <strong>보드레이트(baud rate)</strong>라는 설정이 필요합니다.</p><blockquote><em>보드레이트 = 1초에 몇 비트를 주고받을지 약속하는 것<br>115200 = 1초에 115,200 비트 전송</em></blockquote><p>아래의 SET_LINE_CODING은 이 보드레이트를 설정하는 USB 제어 명령입니다.</p><pre>// &quot;나 115200 baud, 8데이터비트, 패리티 없음, 1정지비트로 통신할게&quot;<br>val lineCoding = ByteBuffer.allocate(7).apply {<br>    putInt(115200)  // 보드레이트<br>    put(0)          // 정지비트: 1개<br>    put(0)          // 패리티: 없음<br>    put(8)          // 데이터비트: 8개<br>}</pre><h4><strong>이슈: CDC 제어 전송 실패</strong></h4><pre>SET_LINE_CODING (iface=0): -1     ← 실패!<br>SET_CONTROL_LINE_STATE (iface=0): -1  ← 실패!</pre><h4><strong>원인: 잘못된 Interface 번호 (wIndex)</strong></h4><p>CDC 제어 명령에는 <strong>어떤 Interface에 대한 명령인지</strong> 알려주는 <strong>wIndex </strong>파라미터가 있습니다.</p><p><em>*wIndex는 word Index를 의미합니다. 일반적으로 word의 크기는 아키텍처마다 상이하나, USB 규격에서는 OS, 아키텍처와 무관하게 항상 16비트입니다.</em></p><pre>// 기존 코드: wIndex = 0 (Interface 0에 보냄)<br>conn.controlTransfer(0x21, 0x20, 0, 0, ...)</pre><p>CDC Communication Interface는 <strong>Interface 2</strong>인데, wIndex=0으로 보내고 있었습니다.</p><h4><strong>해결: 올바른 Interface 번호 사용</strong></h4><pre>// CDC Communication Interface의 실제 번호를 사용<br>conn.controlTransfer(0x21, 0x20, 0, cdcCommInterface.id, ...)<br>//                                   ↑ wIndex=2 ← 올바름!</pre><h4>최종 통신 흐름</h4><pre>[앱 시작]<br>  ↓<br>scanAndConnect()<br>  ↓<br>USB 디바이스 발견 (VID=0x0483, STM32)<br>  ↓<br>USB 권한 확인/요청<br>  ↓<br>openDevice() → USB 연결 열기<br>  ↓<br>ioctl(USBDEVFS_DISCONNECT) → 커널 드라이버 분리<br>  ↓<br>Interface 분류:<br>  - Interface 2 (CDC Comm) → 제어용<br>  - Interface 3 (CDC Data) → 데이터용<br>  ↓<br>claimInterface(3, force=true)<br>  ↓<br>Endpoint 선택: <br>  - IN: 0x85 (STM32 → 태블릿)<br>  - OUT: 0x05 (태블릿 → STM32)<br>  ↓<br>SET_LINE_CODING(115200 baud, wIndex=2)<br>SET_CONTROL_LINE_STATE(DTR+RTS, wIndex=2)<br>  ↓<br>startReading() → 50바이트 패킷 수신 시작<br>  ↓<br>[통신 준비 완료]<br>  ↓<br>sendCommand() → bulkTransfer(OUT, 50bytes) → STM32 제어</pre><h3><strong>9. 구현에서 배운 것들</strong></h3><p><strong>“연결됨”과 “통신 가능”은 다르다<br></strong>isConnected = true가 되었다고 해서 데이터를 주고받을 수 있는 건 아닙니다. USB 디바이스를 열고(openDevice), 올바른 Interface를 claim하고, 올바른 Endpoint를 선택하고, 필요한 초기화까지 모두 완료해야 비로소 통신이 가능합니다,,,</p><p><strong>Composite USB 디바이스는 함정이 많다<br></strong>우리 STM32 디바이스에는 4개의 Interface가 있었고, 그중 시리얼 통신에 사용해야 하는 것은 Interface 3(CDC Data)뿐이었습니다. Interface 0(Vendor-specific)에도 비슷하게 생긴 BULK Endpoint가 있어서 혼동하기 쉬웠습니다.</p><p><strong>디바이스마다 다르다<br></strong>같은 STM32 칩이라도 펌웨어 설정에 따라 USB 구조가 완전히 다를 수 있습니다. 또한 같은 Android 코드라도 MTK, Qualcomm, Samsung Exynos 등 SoC에 따라 USB 호스트 동작이 미묘하게 다를 수 있습니다. 때문에, <strong>하드코딩보다는 동적 탐색</strong>을 통해 이러한 차이를 대응하고자 했습니다.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=dd5ee366ceeb" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Modifier.Node로 마이그레이션하기: Slot Table부터 RenderNode까지]]></title>
            <link>https://medium.com/@neoself1105/modifier-node%EB%A1%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98%ED%95%98%EA%B8%B0-slot-table%EB%B6%80%ED%84%B0-rendernode%EA%B9%8C%EC%A7%80-8927068e969b?source=rss-18d63a54716d------2</link>
            <guid isPermaLink="false">https://medium.com/p/8927068e969b</guid>
            <dc:creator><![CDATA[NEON]]></dc:creator>
            <pubDate>Sun, 01 Mar 2026 13:50:11 GMT</pubDate>
            <atom:updated>2026-03-16T01:13:05.865Z</atom:updated>
            <content:encoded><![CDATA[<p><strong>UIAutomator를 통한 정확한 UI Test 환경 구축하기</strong></p><p>안녕하세요. 오늘은 compose 1.3.0부터 도입된 Modifier.Node 및 관련 API들을 활용하여 프로젝트에 범용적으로 사용되고 있는 CustomButton의 성능을 최적화한 과정을 공유드리고자 합니다.</p><h3>0. 서론: Jetpack Compose 내부 구성</h3><p>Jetpack Compose 프레임워크에서는 UI를 설계할때, 아래와 같이 각 api의 역할을 구분하고 있습니다.</p><p><strong>Composable 함수</strong>: <strong>무엇</strong>을 그리고자 할 것인지 명시<br><strong>Modifier</strong>: <strong>어떻게</strong> 컴포넌트를 <strong>꾸밀</strong> 것인지 명시</p><pre> Text(<br>      &quot;안녕하세요&quot;,<br>      modifier = Modifier<br>          .padding(16.dp)      // 여백 추가<br>          .background(Color.Red) // 배경색<br>          .clickable { }        // 클릭 가능<br>  )<br>// 컴파일러가 아래 순서의 연결된 Element로 변환<br>// [padding] → [background] → [clickable]</pre><p>여기서 Modifier는 체이닝(chaining) 구조로 각 Modifier들 간 .으로 연결되며, Jetpack 컴파일러는 이를 링크드 리스트처럼 순서대로 연결된 Element의 체인으로 변환합니다.</p><pre>// 실제 .padding Modifier의 내부구현<br>@Stable<br>fun Modifier.padding(all: Dp) = this then PaddingElement(<br>    start = all,<br>    top = all,<br>    end = all,<br>    bottom = all,<br>    rtlAware = true,<br>    inspectorInfo = {<br>        name = &quot;padding&quot;<br>        value = all<br>    }<br>)<br><br>private class PaddingElement(...) : ModifierNodeElement&lt;PaddingNode&gt;() {<br>    override fun create(): PaddingNode {}<br>    override fun update(node: PaddingNode) {}<br>    override fun hashCode(): Int {}<br>    override fun equals(other: Any?): Boolean {}<br>    override fun InspectorInfo.inspectableProperties() {<br>        inspectorInfo()<br>    }<br>}<br><br>private class PaddingNode() : LayoutModifierNode, Modifier.Node() {<br>    override fun MeasureScope.measure(<br>        measurable: Measurable,<br>        constraints: Constraints<br>    ): MeasureResult {<br>        val horizontal = start.roundToPx() + end.roundToPx()<br>        val vertical = top.roundToPx() + bottom.roundToPx()<br><br>        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))<br><br>        val width = constraints.constrainWidth(placeable.width + horizontal)<br>        val height = constraints.constrainHeight(placeable.height + vertical)<br>        return layout(width, height) {<br>            if (rtlAware) {<br>                placeable.placeRelative(start.roundToPx(), top.roundToPx())<br>            } else {<br>                placeable.place(start.roundToPx(), top.roundToPx())<br>            }<br>        }<br>    }<br>}</pre><p><strong>즉, </strong><strong>Modifier는 인터페이스이며, 실질적인 동작은 각 </strong><strong>Element가 수행합니다.</strong></p><p>그럼 여기서 Element 는 정확히 무엇일까요?</p><p>Modifier.Element는 Modifier 체인에서 하나의 <strong>기능 단위</strong>를 의미합니다.</p><blockquote><em>설령 위 Text 컴포저블의 경우, 패딩을 담당하는 Element, 배경을 담당하는 Element 그리고 클릭 동작을 담당하는 Element가 존재하는 것이죠. 이처럼 각 Element는 단일 책임을 갖습니다.</em></blockquote><h3>1. Composed Modifier (기존 Modifier 방식)</h3><p>Compose 초기에는 상태 관리가 필요한 Modifier들이 내부적으로 composed {} 패턴을 사용했습니다. 현재는 대부분 ModifierNodeElement 기반으로 마이그레이션되었지만, composed {}의 구조를 이해하는 것이 Modifier.Node의 도입 배경을 파악하는 데 도움이 됩니다.</p><pre>// 이전 pointerInput 내부 구현코드. <br>// 현재는 SuspendPointerInputElement의 Modifier.Node 방식으로 구현되어있습니다.<br>fun Modifier.pointerInput(key: Any?, block: suspend PointerInputScope.() -&gt; Unit): Modifier =<br>  composed {   // ← 이것이 핵심<br>      val scope = remember { ... }<br>      // ...<br>  }</pre><p><strong>composed {}</strong> 블록 내부에는 <strong>remember</strong>, <strong>LaunchedEffect</strong>와 같은 Composable 함수가 호출됩니다.</p><pre>@Composable<br>inline fun &lt;T&gt; remember(crossinline calculation: @DisallowComposableCalls () -&gt; T): T =<br>    currentComposer.cache(false, calculation)<br>// currentComposer = 컴파일러가 주입한 $composer 파라미터에 접근하는 프로퍼티<br><br>@Composable<br>@NonRestartableComposable<br>@OptIn(InternalComposeApi::class)<br>fun LaunchedEffect() {<br>    val applyContext = currentComposer.applyCoroutineContext<br>    remember(key1, key2, key3) { LaunchedEffectImpl(applyContext, block) }<br>}</pre><p>여기서 Composable 함수는 컴파일 시 숨겨진 <strong>$composer</strong> 파라미터가 주입되며, 이는 런타임이 Composition Phase에서 Composable 트리 순회 간 Group 마커 삽입, 비교 등에 활용됩니다.</p><pre>// 작성한 코드<br>@Composable<br>fun Greeting() {<br>    Text(&quot;Hello&quot;)<br>}<br><br>// 컴파일러 변환 후 (개념적)<br>fun Greeting($composer: Composer, $changed: Int) {<br>    $composer.startRestartGroup(1234567)   // ← Slot Table에 그룹 기록<br>    if ($changed == 0 &amp;&amp; $composer.getSkipping()) {<br>        $composer.skipToGroupEnd()<br>    } else {<br>        Text(&quot;Hello&quot;, $composer, ...)<br>    }<br>    $composer.endRestartGroup()?.updateScope { // ← recomposition 람다 저장<br>        Greeting($composer, $changed or 0b1)<br>    }<br>}</pre><p>즉 Composable 함수의 실행 자체가 Slot Table 위에서 동작하는 만큼, 렌더링 파이프라인의 Composition 단계가 필수적입니다.</p><p>- 렌더링 파이프라인 (<strong>Composition</strong>&gt;<strong>Layout</strong>&gt;<strong>Draw</strong>) 중 1번째 단계에서 실행<br>- Slot Table에 상태 슬롯을 할당</p><p>일반적인 Composable 함수는 파라미터가 변경되지 않았으면 skip될 수 있습니다. 하지만 composed {}로 생성된 ComposedModifier는 내부에 lambda를 감싸고 있어 의미 있는 equals() 구현이 불가능합니다.</p><pre>// ComposedModifier 내부 구조<br>  private class ComposedModifier(<br>      val factory: @Composable Modifier.() -&gt; Modifier<br>  ) : Modifier.Element<br>  // lambda는 참조 비교만 가능 → equals()가 항상 false</pre><p>Compose 런타임은 Recomposition 시 이전 Modifier와 새 Modifier를 equals()로 비교하여 변경 여부를 판단합니다. Composed Modifier는 equals()가 항상 false를 반환하므로, Recomposition이 발생할 때마다 composed 블록에 반드시 재진입하여 Slot Table을 순회해야 합니다. 내부<br> remember가 캐시된 값을 반환하더라도, Group 마커 비교와 Slot Table 커서 이동 비용은 매번 발생합니다.</p><h4><strong>여기서 Slot Table은 또 뭐죠..?</strong></h4><p>Compose 컴파일러가 Composable 함수를 컴파일할때, 함수 내부 <strong>모든 상태 및 컴포지션 정보</strong>를 저장하고자 사용하는 <strong>선형 배열 기반 자료구조</strong>입니다.</p><p>이 Slot Table에 들어가는 정보는 아래와 같습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/840/1*35WyUsF6IlZdBU7FpH0VFA.png" /><figcaption>Slot Table</figcaption></figure><p>- <strong>Group</strong>: @Composable 함수 호출 단위. 트리 구조를 선형 배열에 평탄화하여 저장<br>- <strong>Slot</strong>: Group 내부의 <strong>remember</strong>, <strong>mutableStateOf</strong> 등이 저장되는 칸</p><p>이 슬롯 테이블을 통해 상태를 관리하게 됨에 따라, 부모 컴포저블이 Recompose될때, Compose 런타임은 자식 컴포넌트의 슬롯 테이블 조회를 통해 자식 컴포넌트들 간 <strong>Recompose 범위를 판단</strong>하고, 함수 본체를 실행하지 않고 <strong>스킵</strong>할 수 있게 됩니다.</p><h4>Slot Table 방식의 구조적 한계</h4><p>이는 분명 함수 실행을 스킵해줄 수 있다는 성능상 이점이 있지만, Slot Table의 관리에 여전히 오버헤드는 존재합니다.</p><p><strong>비용 발생 지점</strong></p><ol><li><strong>슬롯 할당/조회 오버헤드<br></strong>Re-compose가 발생할 때마다, Compose 런타임은 슬롯을 커서로 순회하며 Read 연산을 수행하며, 파라미터로 들어온 값과 슬롯에 저장된 값이 다를 경우, 새 값으로 업데이트합니다.</li><li><strong>Gap Buffer 관리<br></strong>슬롯 테이블에는 새로운 컴포저블이 나타날 자리를 미리 비워두어, 삽입 시 기존 데이터를 일일이 밀어내 O(n)의 비용이 발생하지 않도록, 미리 Gap을 확보해놓습니다.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Nyj74hseyU2RVyA08r4vaQ.png" /></figure><p>이 Gap은 현재 작업 중인 위치인 Cursor에 맞춰 계속 이동되며, 이로 인해 연속적인 삽입과 삭제 발생 시, 메모리 재할당이나 데이터 대량 복사 빈도를 획기적으로 줄여줍니다. 하지만, 여전히 gap을 새로운 위치로 이동시키는 과정에서 <strong>데이터의 복사 비용이 발생</strong>합니다.</p><h3>2. Modifier.Node</h3><p><strong>튜닝 끝은 순정…</strong></p><p>Modifier.Node는 Slot Table 구조로 인한 오버헤드를 해결하기 위해 Compose 1.3에서 도입된 composed modifier의 대체제이며, Slot Table 대신, 일반 <strong>Kotlin 클래스 </strong>내부 속성으로 상태를 저장합니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/694/1*uezE8vl7nCyu-sLSVCyZnw.png" /></figure><p>Slot Table 자료구조를 사용하여 각 Composable의 생명주기와 별개의 생명주기를 유지했던 Composed Modifier과 달리 Modifier.Node는 힙 메모리에 상주함으로써, 컴포저블과 다른 생명주기를 갖습니다.</p><p>이러한 구조로 인해, Modifier.Node는 담당하는 UI 요소(LayoutNode)가 직접 참조하는 방식으로 리컴포지션이 발생해도, 객체를 새로 만들지 않고, 노드의 프로퍼티만 업데이트합니다.</p><blockquote><strong><em>즉 힙 메모리 점유율을 UI 데이터 저장에 할애하는 대신, CPU 연산과 리컴포지션 오버헤드를 아끼는 트레이드 오프를 갖습니다.</em></strong></blockquote><p>각 Node는 아래 생명주기를 갖습니다.</p><p>- Node 생성 → <strong>onAttach()</strong> : Layout Tree에 부착됨<br>- 파라미터 변경 시 → <strong>update()</strong> : Element가 새 값을 전달<br>- Node 제거 → <strong>onDetach()</strong> : Layout Tree에서 분리됨</p><h4>2.1 ModifierNodeElement</h4><p><strong>Node를 만들고 업데이트하는 “공장”</strong></p><p>Modifie.chain의 역할을 대체하기 위해선 ModifierNodeElement가 중개자로서, Modifier.Node를 Modifier 체인에 연결해줘야 합니다.</p><pre>// ModifierNodeElement: Node를 생성하고 업데이트하는 역할<br>data class CustomButtonElement(<br>    val transitionType: TransitionType,<br>    val isDisabled: Boolean,<br>    val action: () -&gt; Unit,<br>    // ...<br>) : ModifierNodeElement&lt;CustomButtonModifierNode&gt;() {<br><br>    // Node를 처음 만들 때 호출됨<br>    override fun create() = CustomButtonModifierNode(<br>        transitionType = transitionType,<br>        isDisabled = isDisabled,<br>        action = action,<br>    )<br><br>    // 파라미터가 바뀌었을 때 기존 Node를 업데이트<br>    override fun update(node: CustomButtonModifierNode) {<br>        node.transitionType = transitionType<br>        node.isDisabled = isDisabled<br>        node.action = action<br>    }<br>}</pre><p>Kotlin의 data class는 기본적으로 참조 비교를 수행하는 일반 클래스와 달리 객체의 데이터를 비교하는 equals 메서드를 자동으로 생성합니다. 이 equals 메서드를 활용하여 Compose 런타임은 이전 Element와 새 Element 간의 대조를 수행하며,</p><p>- 같으면 update() 메서드를 스킵<br>- 다르면, update(existingNode)를 호출하여, Node의 필드 갱신</p><p>을 수행합니다.</p><p>앞서 설명한 ComposedModifier가 lambda를 감싸고 있어 equals()가 항상 false였던 것과 대조적으로, ModifierNodeElement는 data class의 구조적 동등성(structural equality)을 활용하여 불필요한 업데이트를 건너뛸 수 있습니다.</p><h4><strong>2.2 </strong>LayoutModifierNode</h4><p><strong>RenderNode 기반 GPU 가속 변환</strong></p><p>Compose의 렌더링은 3단계 파이프라인를 거칩니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/584/1*WjU07X8W3UTSvlL7SSPCRw.png" /></figure><p>앞 단계가 실행되면, 뒤 단계는 무조건 실행됩니다.</p><p>LayoutModifierNode는 Layout 단계에서 자식의 측정과 배치를 제어합니다. 여기서 핵심은 placeWithLayer()입니다. 이 메서드로 자식을 배치하면 RenderNode 기반의 그래픽 레이어가 생성됩니다.</p><pre>class CustomButtonNode : Modifier.Node(), LayoutModifierNode {<br>    val scale = Animatable(1f)<br>    val backgroundAlpha = Animatable(0f)<br><br>    override fun MeasureScope.measure(<br>        measurable: Measurable,<br>        constraints: Constraints,<br>    ): MeasureResult {<br>        val placeable = measurable.measure(constraints)<br>        return layout(placeable.width, placeable.height) {<br>            placeable.placeWithLayer(0, 0) {<br>                // 이 람다 내부의 값 변경 → Layer invalidation만 발생<br>                // Composition, Layout 단계를 완전히 건너뜀<br>                scaleX = this@CustomButtonNode.scale.value<br>                scaleY = this@CustomButtonNode.scale.value<br>                alpha = 1f - this@CustomButtonNode.backgroundAlpha.value<br>                transformOrigin = TransformOrigin.Center<br>            }<br>        }<br>    }<br>}</pre><p>RenderNode는 Android의 하드웨어 가속 렌더링 시스템의 핵심 요소로, 자식 콘텐츠를 Display List로 캐싱합니다. placeWithLayer 람다 내에서 scaleX, alpha, rotationX 등의 속성이 변경되면, 캐싱된 콘텐츠를 다시 그리지 않고 <strong>GPU에서 변환 속성만 갱신</strong>합니다. 이는 Compose가 제공하는 .graphicsLayer {} 수정자와 동일한 내부 메커니즘입니다.</p><h4>2.3 DrawModifierNode</h4><p><strong>커스텀 드로잉</strong></p><p>Draw 단계에서 커스텀 드로잉 로직을 직접 구현할 수 있는 인터페이스입니다. LayoutModifierNode의 placeWithLayer가 기존 콘텐츠에 변환을 적용하는 것이라면, DrawModifierNode는 새로운 시각 요소를 직접 그리는 역할입니다.</p><pre>class CustomButtonNode : Modifier.Node(), DrawModifierNode {<br>    val backgroundAlpha = Animatable(0f)<br><br>    override fun ContentDrawScope.draw() {<br>        // 배경 라운드 렉트 그리기 (transform 전 원본 크기에 그려짐)<br>        val bgAlpha = backgroundAlpha.value<br>        if (bgAlpha &gt; 0f) {<br>            drawRoundRect(<br>                color = Gray.copy(alpha = bgAlpha),<br>                cornerRadius = CornerRadius(8.dp.toPx()),<br>            )<br>        }<br>        drawContent()  // 자식 콘텐츠 그리기<br>    }<br>}</pre><p>주의할 점은, scale/alpha/rotation과 같은 변환은 DrawModifierNode가 아닌 LayoutModifierNode의 placeWithLayer()로 처리해야 한다는 것입니다. DrawModifierNode의 draw() 내에서 Canvas API로 변환을 직접 구현하면 saveLayer 등의 비용이 발생하여 RenderNode의 Display List 캐싱 이점을 받을 수 없습니다. 이 부분은 7장에서 자세히 다루겠습니다.</p><h4>2.4 LayoutAwareModifierNode</h4><p><strong>크기 변화 감지</strong></p><pre>class CustomButtonModifierNode : Modifier.Node(), LayoutAwareModifierNode {<br>    var componentSize = IntSize.Zero<br><br>    override fun onRemeasured(size: IntSize) {<br>        // Layout 단계에서 크기가 결정되면 자동 호출됨<br>        componentSize = size<br>    }<br>}</pre><p>UI 요소의 크기가 변경되었을 때 호출하는 콜백을 정의하기 위해선, 기존에는 .onSizeChanged 수정자를 사용했습니다. LayoutAwareModifierNode는 Node의 인터페이스 메서드로 Layout 시스템이 직접 호출하며, 별도의 modifier 노드를 체인에 추가하지 않습니다.</p><h4><strong>2.5 </strong>DelegatingNode + SuspendingPointerInputModifierNode</h4><p>SuspendingPointerInputModifierNode는 Compose에서 제공하며 Modifier.Node를 상속하는 터치 감지(press/release)를 처리하는 독립적인 클래스입니다.</p><pre>class CustomButtonNode(/*...*/) :<br>    DelegatingNode(),          // 다른 Node를 위임할 수 있게 함<br>    LayoutModifierNode,        // placeWithLayer() 직접 구현<br>    DrawModifierNode,          // draw() 직접 구현<br>    LayoutAwareModifierNode    // onRemeasured() 직접 구현<br>{<br>    // 터치 처리를 담당하는 서브 Node를 생성하고 &quot;위임 등록&quot;<br>    val pointerInputNode = delegate(<br>        SuspendingPointerInputModifierNode {<br>            detectTapGestures(<br>                onPress = { offset -&gt;<br>                    // 프레스 애니메이션 시작<br>                },<br>                onTap = { action() }<br>            )<br>        }<br>    )<br>}</pre><p>DelegatingNode를 상속하면 delegate()를 통해 서브 노드를 등록할 수 있습니다. 이를 통해 LayoutModifierNode, DrawModifierNode, LayoutAwareModifierNode, SuspendingPointerInputModifierNode의 기능을 단일 노드에 통합할 수 있습니다. 기존에는 이들이 각각 별도의 modifier 노드로 체인에 추가되어 4~5개의 노드가 존재했지만, DelegatingNode를 통해 1개로 줄어듭니다.</p><h3>3. CustomButton 성능 최적화하기</h3><p>제가 현재 담당하고 있는 프로젝트에 가장 많이 사용되고 있는 버튼 공용 컴포넌트에 Modifier.Node API를 적용해보기로 했습니다.</p><p>기존 CustomButton의 구현</p><pre>object CustomButtonLegacy {<br>    @Composable<br>    operator fun invoke(<br>        modifier: Modifier = Modifier,<br>        action: () -&gt; Unit,<br>        transitionType: TransitionType = TransitionType.Shrink,<br>        isDisabled: Boolean = false,<br>        content: @Composable () -&gt; Unit<br>    ) {<br>        // ── Slot Table에 6개의 슬롯이 할당되는 구간 ──<br>        val scale = remember { Animatable(1f) }             // Slot 1<br>        val backgroundAlpha = remember { Animatable(0f) }   // Slot 2<br>        val tiltX = remember { Animatable(0f) }             // Slot 3<br>        val tiltY = remember { Animatable(0f) }             // Slot 4<br>        val coroutineScope = rememberCoroutineScope()        // Slot 5<br>        var componentSize by remember { mutableStateOf(IntSize.Zero) } // Slot 6<br><br>        Row(<br>            modifier = modifier<br>                .background(                                            // ⚠ Composition 스코프에서 State 읽기<br>                    Gray.copy(alpha = backgroundAlpha.value), ...<br>                )<br>                .onSizeChanged { componentSize = it }                   // 별도 modifier 노드<br>                .graphicsLayer {                                        // 별도 modifier 노드<br>                    alpha = 1f - backgroundAlpha.value<br>                    scaleX = scale.value<br>                    scaleY = scale.value<br>                    rotationX = tiltX.value<br>                    rotationY = tiltY.value<br>                }<br>                .pointerInput(isDisabled) {                             // 별도 modifier 노드<br>                    detectTapGestures(<br>                        onPress = { offset -&gt;<br>                            coroutineScope.launch {<br>                                scale.animateTo(0.97f, tween(100))<br>                            }<br>                            // ... 나머지 애니메이션<br>                            tryAwaitRelease()<br>                            coroutineScope.launch {<br>                                scale.animateTo(1f, tween(400))<br>                            }<br>                            // ...<br>                        },<br>                        onTap = { action() }<br>                    )<br>                },<br>        ) { content() }<br>    }<br>}</pre><p>버튼 탭에 대한 트랜지션은 크게 3가지 요인으로 구성됩니다.<br>- 투명도를 다루는 <strong>Alpha</strong><br>- 버튼의 크기가 변경되는 <strong>Scale</strong><br>- 버튼을 감싸는 영역의 배경색상인 <strong>Background</strong></p><p>여기서 주목해야 할 것은 State를 어디서 읽느냐에 따라 렌더링 파이프라인의 진입 지점이 달라진다는 점입니다.</p><p>Scale/Alpha 애니메이션은 .graphicsLayer {} 람다 내부에서 State를 읽습니다. .graphicsLayer {} 람다는 내부적으로 RenderNode의 속성을 설정하는 Draw 단계 스코프에서 실행되기 때문에, Recomposition이 트리거되지 않습니다.</p><p>하지만 <strong>.background(Gray.copy(alpha = backgroundAlpha.value))</strong> 호출은 Composable 함수 본문, 즉 Composition 스코프에서 State를 읽기 때문에 매 애니메이션 프레임마다 Recomposition이 트리거됩니다.</p><p><strong>오버헤드 요약</strong></p><blockquote><strong><em>remember * 4 + rememberCoroutineScope + mutableStateOf </em><br></strong>Slot Table에 6개 슬롯 할당, Recomposition마다 커서 순회 비용 발생</blockquote><blockquote><strong><em>.background(alpha = backgroundAlpha.value)</em></strong><br>Composition 스코프에서 State 읽기 → 매 프레임 Recomposition 트리거.</blockquote><blockquote><strong><em>.onSizeChanged, .graphicsLayer, .pointerInput</em> </strong><br>각각 별도의 modifier 노드 → 체인 길이 증가</blockquote><p>위 분석을 바탕으로, 4개의 modifier 노드를 단일 Modifier.Node로 통합했습니다.</p><pre>object CustomButton {<br>    @Composable<br>    operator fun invoke(<br>        modifier: Modifier = Modifier,<br>        action: () -&gt; Unit,<br>        transitionType: TransitionType = TransitionType.Shrink,<br>        isDisabled: Boolean = false,<br>        content: @Composable () -&gt; Unit<br>    ) {<br>        Row(<br>            // Modifier 체인에 Node Element 하나만 추가<br>            modifier = modifier<br>                .then(CustomButtonElement(transitionType, isDisabled, action))<br>                .then(<br>                    if (transitionType == TransitionType.ShrinkWithGrayBackground)<br>                        Modifier.padding(4.dp)<br>                    else Modifier<br>                ),<br>            // ...<br>        ) { content() }<br>    }<br>}</pre><p>기존 SlotTable 순회 오버헤드를 트리거하던 remember과 mutableStateOf의 경우, 아래 Node 클래스 내부에서 일반 클래스 필드로 관리됩니다.</p><pre>private class CustomButtonNode(<br>    var transitionType: TransitionType,<br>    var isDisabled: Boolean,<br>    var action: () -&gt; Unit,<br>) : DelegatingNode(),<br>    LayoutModifierNode,    // placeWithLayer() - RenderNode 기반 GPU 변환<br>    DrawModifierNode,      // draw() - ShrinkWithGrayBackground 배경 전용<br>    LayoutAwareModifierNode // onRemeasured() - 크기 추적<br>{<br>    // 애니메이션 상태: Node 필드에 직접 저장 (Slot Table 미사용)<br>    private val scale = Animatable(1f)<br>    private val backgroundAlpha = Animatable(0f)<br>    private val tiltX = Animatable(0f)<br>    private val tiltY = Animatable(0f)<br>    private var componentSize = IntSize.Zero<br><br>    // 터치 처리: SuspendingPointerInputModifierNode로 위임<br>    private val pointerInputNode = delegate(<br>        SuspendingPointerInputModifierNode {<br>            detectTapGestures(<br>                onPress = { offset -&gt;<br>                    animatePress(offset)<br>                    tryAwaitRelease()<br>                    animateRelease()<br>                },<br>                onTap = { action() }<br>            )<br>        }<br>    )<br><br>    // LayoutAwareModifierNode: 크기 추적<br>    override fun onRemeasured(size: IntSize) {<br>        if (transitionType == TransitionType.ShrinkWithTilt) {<br>            componentSize = size<br>        }<br>    }<br><br>    // LayoutModifierNode: RenderNode 기반 GPU 가속 변환<br>    // placeWithLayer 람다 내 값 변경 → Layer invalidation만 발생<br>    override fun MeasureScope.measure(<br>        measurable: Measurable,<br>        constraints: Constraints,<br>    ): MeasureResult {<br>        val placeable = measurable.measure(constraints)<br>        return layout(placeable.width, placeable.height) {<br>            placeable.placeWithLayer(0, 0) {<br>                scaleX = this@CustomButtonNode.scale.value<br>                scaleY = this@CustomButtonNode.scale.value<br>                alpha = 1f - this@CustomButtonNode.backgroundAlpha.value<br>                transformOrigin = TransformOrigin.Center<br><br>                if (this@CustomButtonNode.transitionType == TransitionType.ShrinkWithTilt) {<br>                    rotationX = this@CustomButtonNode.tiltX.value<br>                    rotationY = this@CustomButtonNode.tiltY.value<br>                }<br>            }<br>        }<br>    }<br><br>    // DrawModifierNode: ShrinkWithGrayBackground 배경 전용<br>    override fun ContentDrawScope.draw() {<br>        if (transitionType == TransitionType.ShrinkWithGrayBackground) {<br>            val bgAlpha = backgroundAlpha.value<br>            if (bgAlpha &gt; 0f) {<br>                drawRoundRect(<br>                    color = Gray.copy(alpha = bgAlpha),<br>                    cornerRadius = CornerRadius(8.dp.toPx()),<br>                )<br>            }<br>        }<br>        drawContent()<br>    }<br>}</pre><p>Modifier.Node를 사용하면서 진행한 변경점은 아래와 같습니다.</p><ol><li><strong>Slot Table 슬롯 수<br></strong>전: 6개<br>후: 0개</li><li><strong>애니메이션 중 Recompositon<br></strong>전: background() 수정자로 인한 매 프레임 발생 <br>후: 발생하지 않으며, Draw invalidation만 수행</li><li><strong>변환 메커니즘<br></strong>전: .graphicsLayer {} (RenderNode)<br>후: placeWithLayer {} (RenderNode)</li><li><strong>modifier 체인 노드 수 (버튼당)<br></strong>전: 4~5개<br>후: 1개 (DelegatingNode)</li></ol><h3>4. 성능 검증 -UIAutomator를 활용한 프레임 성능 측정</h3><p>Android의 UIAutomator 프레임워크와 dumpsys gfxinfo 시스템 명령을 사용하여 Modifier.Node로의 전환이 성능상 이점이 있는지 검증해보고자 했습니다.</p><p><strong>1. UIAutomator<br></strong>Android에서 제공하는 UI 자동화 테스트 프레임워크입니다. Espresso와 달리 앱 내부의 Compose/View 계층에 대한 직접적인 접근이 아닌, 시스템 수준에서 사용자 상호작용을 시뮬레이션합니다. 이는 성능 테스트에 있어 중요한 차이점인데, 테스트 코드 자체가 앱의 렌더링 파이프라인에 간섭하지 않기 때문에 보다 정확한 프레임 성능을 측정할 수 있습니다.</p><p><strong>2. dumpsys gfxinfo<br></strong>Android 시스템이 프로세스별로 수집하는 그래픽 렌더링 통계를 조회하는 명령입니다.</p><h4>4.1 테스트 환경 설계: Compose 캐시 격리</h4><p>정확한 비교를 위해 가장 신경 쓴 부분은 Compose 런타임 캐시의 격리입니다. 만약 CustomButtonLegacy와 CustomButton을 탭으로 전환하는 구조를 사용하면, 첫 번째 탭에서 생성된 Composition 캐시(Slot Table, 메모리 풀 등)가 두 번째 탭의 측정에 영향을 줄 수 있습니다.</p><p>이를 방지하기 위해 독립된 하위 화면 구조를 채택했습니다.</p><blockquote><em>앱 실행 → “CustomButtonLegacy” 하위 화면 진입 → 10회 스크롤 측정<br> → pressBack() → 메뉴 복귀 (Compose 트리 완전 해제) → “CustomButton” 하위 화면 진입 → 10회 스크롤 측정 → 결과 비교 출력</em></blockquote><h4>4.2 테스트 케이스 1:</h4><p><strong>스크롤 성능 — 다수 버튼이 동시에 존재하는 환경</strong></p><pre>@Test<br>fun scrollPerformance_customButton_vs_customButtonLegacy() = uiAutomator {<br>    // Legacy 화면 진입 → 10회 반복 (gfxinfo reset → 아래 스크롤 → 위 스크롤 → gfxinfo 수집)<br>    // → pressBack() → Node 화면 진입 → 동일 10회 반복<br>}</pre><p>CustomButtonLegacy는 버튼 1개당 6개의 Slot을 차지하므로, 60개 버튼이 동시에 렌더링되면 360개의 슬롯이 Slot Table에 할당됩니다. 또한 버튼당 4~5개의 별도 modifier 노드가 존재하여, 60개 버튼 기준 240~300개의 modifier 노드가 체인에 존재합니다. 컴포넌트 수가 많아질수록 이러한 오버헤드가 증폭되는지를 확인하고자 했습니다.</p><h4>4.3 테스트 케이스 2:</h4><p><strong>LongClick Transition — 단일 버튼의 애니메이션 프레임 분석</strong></p><pre>@Test<br>fun longClickTransition_customButton_vs_customButtonLegacy() = uiAutomator {<br>    // Single Legacy 화면 진입 → 20회 반복 (gfxinfo reset → longClick → release 대기 → gfxinfo 수집)<br>    // → pressBack() → Single Node 화면 진입 → 동일 20회 반복<br>}</pre><p>스크롤 테스트가 다수 버튼의 존재 비용을 측정한다면, 이 테스트는 <strong>단일 버튼의 애니메이션 실행 비용</strong>을 격리하여 측정합니다. 화면에 버튼 1개만 배치하여 다른 컴포넌트의 렌더링 비용을 완전히 제거한 후 테스트하고자 했습니다.</p><p><em>longClick은 </em><em>device.swipe(cx, cy, cx, cy, steps)로 구현했습니다. 동일 좌표로의 swipe는 UIAutomator에서 press → hold → release 사이클과<br> 동일하게 동작하며, steps 값으로 유지 시간을 제어합니다(600ms).</em></p><blockquote><em>Press 시작 → scale 축소 애니메이션 (100ms) → 유지 (약 500ms) → Release → scale 복원 애니메이션 (400ms) → Release 애니메이션 완료 대기 (500ms) → gfxinfo 수집</em></blockquote><h3><strong>5. 성능 테스트 결과</strong></h3><pre>03-01 18:10:21.329 [LongClickPerfComparison] ════════════════════════════════════════<br>03-01 18:10:21.329 [LongClickPerfComparison]  HMButton (Modifier.Node) — LongClick Transition<br>03-01 18:10:21.329 [LongClickPerfComparison] ════════════════════════════════════════<br>03-01 18:10:21.329 [LongClickPerfComparison]  #1  Total: 60, Janky: 2, P99: 22ms<br>03-01 18:10:21.329 [LongClickPerfComparison]  #2  Total: 60, Janky: 2, P99: 11ms<br>03-01 18:10:21.329 [LongClickPerfComparison]  #3  Total: 60, Janky: 1, P99: 15ms<br>03-01 18:10:21.329 [LongClickPerfComparison]  #4  Total: 60, Janky: 2, P99: 10ms<br>03-01 18:10:21.329 [LongClickPerfComparison]  #5  Total: 61, Janky: 1, P99: 14ms<br>03-01 18:10:21.330 [LongClickPerfComparison]  #6  Total: 60, Janky: 2, P99: 11ms<br>03-01 18:10:21.330 [LongClickPerfComparison]  #7  Total: 60, Janky: 2, P99: 12ms<br>03-01 18:10:21.330 [LongClickPerfComparison]  #8  Total: 59, Janky: 1, P99: 15ms<br>03-01 18:10:21.330 [LongClickPerfComparison]  #9  Total: 61, Janky: 2, P99: 17ms<br>03-01 18:10:21.330 [LongClickPerfComparison]  #10  Total: 60, Janky: 2, P99: 15ms<br>03-01 18:10:21.330 [LongClickPerfComparison]  #11  Total: 61, Janky: 1, P99: 12ms<br>03-01 18:10:21.330 [LongClickPerfComparison]  #12  Total: 60, Janky: 2, P99: 10ms<br>03-01 18:10:21.330 [LongClickPerfComparison]  #13  Total: 60, Janky: 2, P99: 10ms<br>03-01 18:10:21.330 [LongClickPerfComparison]  #14  Total: 60, Janky: 1, P99: 9ms<br>03-01 18:10:21.330 [LongClickPerfComparison]  #15  Total: 60, Janky: 2, P99: 8ms<br>03-01 18:10:21.330 [LongClickPerfComparison]  #16  Total: 60, Janky: 1, P99: 11ms<br>03-01 18:10:21.330 [LongClickPerfComparison]  #17  Total: 60, Janky: 2, P99: 18ms<br>03-01 18:10:21.330 [LongClickPerfComparison]  #18  Total: 60, Janky: 2, P99: 12ms<br>03-01 18:10:21.330 [LongClickPerfComparison]  #19  Total: 60, Janky: 2, P99: 18ms<br>03-01 18:10:21.330 [LongClickPerfComparison]  #20  Total: 61, Janky: 2, P99: 14ms<br>03-01 18:10:21.331 [LongClickPerfComparison]  AVG Total: 60.15, AVG Janky: 1.70, AVG P99: 13.20ms<br>03-01 18:10:21.331 [LongClickPerfComparison] ════════════════════════════════════════<br>03-01 18:10:21.331 [LongClickPerfComparison]  HMButtonLegacy (Standard Compose) — LongClick Transition<br>03-01 18:10:21.331 [LongClickPerfComparison] ════════════════════════════════════════<br>03-01 18:10:21.331 [LongClickPerfComparison]  #1  Total: 60, Janky: 2, P99: 26ms<br>03-01 18:10:21.331 [LongClickPerfComparison]  #2  Total: 59, Janky: 2, P99: 17ms<br>03-01 18:10:21.331 [LongClickPerfComparison]  #3  Total: 60, Janky: 2, P99: 16ms<br>03-01 18:10:21.331 [LongClickPerfComparison]  #4  Total: 60, Janky: 2, P99: 17ms<br>03-01 18:10:21.331 [LongClickPerfComparison]  #5  Total: 60, Janky: 2, P99: 16ms<br>03-01 18:10:21.332 [LongClickPerfComparison]  #6  Total: 60, Janky: 2, P99: 12ms<br>03-01 18:10:21.332 [LongClickPerfComparison]  #7  Total: 60, Janky: 2, P99: 11ms<br>03-01 18:10:21.332 [LongClickPerfComparison]  #8  Total: 60, Janky: 2, P99: 12ms<br>03-01 18:10:21.332 [LongClickPerfComparison]  #9  Total: 59, Janky: 2, P99: 15ms<br>03-01 18:10:21.332 [LongClickPerfComparison]  #10  Total: 60, Janky: 2, P99: 12ms<br>03-01 18:10:21.332 [LongClickPerfComparison]  #11  Total: 60, Janky: 2, P99: 16ms<br>03-01 18:10:21.332 [LongClickPerfComparison]  #12  Total: 60, Janky: 2, P99: 14ms<br>03-01 18:10:21.332 [LongClickPerfComparison]  #13  Total: 61, Janky: 2, P99: 14ms<br>03-01 18:10:21.332 [LongClickPerfComparison]  #14  Total: 60, Janky: 2, P99: 14ms<br>03-01 18:10:21.332 [LongClickPerfComparison]  #15  Total: 60, Janky: 2, P99: 18ms<br>03-01 18:10:21.332 [LongClickPerfComparison]  #16  Total: 61, Janky: 2, P99: 12ms<br>03-01 18:10:21.332 [LongClickPerfComparison]  #17  Total: 60, Janky: 2, P99: 14ms<br>03-01 18:10:21.332 [LongClickPerfComparison]  #18  Total: 61, Janky: 2, P99: 16ms<br>03-01 18:10:21.332 [LongClickPerfComparison]  #19  Total: 61, Janky: 2, P99: 17ms<br>03-01 18:10:21.332 [LongClickPerfComparison]  #20  Total: 60, Janky: 2, P99: 15ms<br>03-01 18:10:21.333 [LongClickPerfComparison]  AVG Total: 60.10, AVG Janky: 2.00, AVG P99: 15.20ms<br>03-01 18:10:21.333 [LongClickPerfComparison] ════════════════════════════════════════<br>03-01 18:10:21.333 [LongClickPerfComparison]  COMPARISON<br>03-01 18:10:21.333 [LongClickPerfComparison] ════════════════════════════════════════<br>03-01 18:10:21.333 [LongClickPerfComparison]  Total frames — Node: 60.15, Legacy: 60.10<br>03-01 18:10:21.333 [LongClickPerfComparison]  Janky frames improvement: 15.0%<br>03-01 18:10:21.333 [LongClickPerfComparison]  P99 frame time improvement: 13.2%<br>03-01 18:10:21.333 [LongClickPerfComparison] ════════════════════════════════════════<br><br>03-01 19:41:08.200 [ButtonPerfComparison] ════════════════════════════════════════<br>03-01 19:41:08.200 [ButtonPerfComparison]  HMButton (Modifier.Node) Results<br>03-01 19:41:08.200 [ButtonPerfComparison] ════════════════════════════════════════<br>03-01 19:41:08.200 [ButtonPerfComparison]  #1  Total: 86, Janky: 7, P99: 16ms<br>03-01 19:41:08.200 [ButtonPerfComparison]  #2  Total: 101, Janky: 4, P99: 8ms<br>03-01 19:41:08.200 [ButtonPerfComparison]  #3  Total: 102, Janky: 5, P99: 19ms<br>03-01 19:41:08.200 [ButtonPerfComparison]  #4  Total: 100, Janky: 5, P99: 16ms<br>03-01 19:41:08.201 [ButtonPerfComparison]  #5  Total: 101, Janky: 3, P99: 9ms<br>03-01 19:41:08.201 [ButtonPerfComparison]  #6  Total: 101, Janky: 6, P99: 9ms<br>03-01 19:41:08.201 [ButtonPerfComparison]  #7  Total: 102, Janky: 4, P99: 10ms<br>03-01 19:41:08.201 [ButtonPerfComparison]  #8  Total: 103, Janky: 2, P99: 9ms<br>03-01 19:41:08.201 [ButtonPerfComparison]  #9  Total: 101, Janky: 3, P99: 10ms<br>03-01 19:41:08.201 [ButtonPerfComparison]  #10  Total: 102, Janky: 2, P99: 8ms<br>03-01 19:41:08.203 [ButtonPerfComparison]  AVG Total: 99.90, AVG Janky: 4.10, AVG P99: 11.40ms<br>03-01 19:41:08.203 [ButtonPerfComparison] ════════════════════════════════════════<br>03-01 19:41:08.203 [ButtonPerfComparison]  HMButtonLegacy (Standard Compose) Results<br>03-01 19:41:08.203 [ButtonPerfComparison] ════════════════════════════════════════<br>03-01 19:41:08.203 [ButtonPerfComparison]  #1  Total: 84, Janky: 10, P99: 24ms<br>03-01 19:41:08.203 [ButtonPerfComparison]  #2  Total: 105, Janky: 9, P99: 12ms<br>03-01 19:41:08.203 [ButtonPerfComparison]  #3  Total: 101, Janky: 8, P99: 14ms<br>03-01 19:41:08.203 [ButtonPerfComparison]  #4  Total: 103, Janky: 11, P99: 12ms<br>03-01 19:41:08.203 [ButtonPerfComparison]  #5  Total: 104, Janky: 9, P99: 12ms<br>03-01 19:41:08.203 [ButtonPerfComparison]  #6  Total: 102, Janky: 9, P99: 18ms<br>03-01 19:41:08.204 [ButtonPerfComparison]  #7  Total: 104, Janky: 8, P99: 11ms<br>03-01 19:41:08.204 [ButtonPerfComparison]  #8  Total: 102, Janky: 7, P99: 9ms<br>03-01 19:41:08.204 [ButtonPerfComparison]  #9  Total: 103, Janky: 7, P99: 10ms<br>03-01 19:41:08.204 [ButtonPerfComparison]  #10  Total: 103, Janky: 5, P99: 11ms<br>03-01 19:41:08.204 [ButtonPerfComparison]  AVG Total: 101.10, AVG Janky: 8.30, AVG P99: 13.30ms<br>03-01 19:41:08.204 [ButtonPerfComparison] ════════════════════════════════════════<br>03-01 19:41:08.204 [ButtonPerfComparison]  COMPARISON<br>03-01 19:41:08.205 [ButtonPerfComparison] ════════════════════════════════════════<br>03-01 19:41:08.205 [ButtonPerfComparison]  Janky frames improvement: 50.6%<br>03-01 19:41:08.205 [ButtonPerfComparison]  P99 frame time improvement: 14.3%<br>03-01 19:41:08.205 [ButtonPerfComparison] ════════════════════════════════════════</pre><ol><li><strong>단일 버튼 테스트<br></strong>Janky 개선율: <strong>15.0%<br></strong>P99 개선율: <strong>13.2%</strong></li><li><strong>60개 버튼 테스트<br></strong>Janky 개선율: <strong>50.6%<br></strong>P99 개선율: <strong>14.3%</strong></li></ol><p>화면에 존재하는 컴포넌트 수가 많아질수록 Modifier.Node의 이점이 극대화되는 것을 확인할 수 있습니다.</p><p>단일 버튼에서도 15%의 Janky 개선이 발생한 이유는 .background(alpha = backgroundAlpha.value)가 매 애니메이션 프레임마다 Recomposition을 트리거하여, 6개의 Slot Table 슬롯에 대한 커서 순회와 Group 마커 비교가 매번 발생했기 때문입니다.</p><p>60개 버튼 스크롤에서 Janky 개선율이 50.6%로 크게 증가한 이유는 주로 초기 Composition 비용과 GC 압박에 있습니다. CustomButtonLegacy 60개가 동시에 Compose되면 360개의 Slot Table 슬롯 + 240~300개의 modifier 노드 인스턴스가 생성됩니다. 이 객체들이 힙 메모리를 점유하면서 GC 발생 확률이 높아지고, GC pause는 스크롤 중 예측 불가능한 Jank 프레임을 유발합니다.</p><h3>6. 마무리</h3><p>Modifier.Node API는 Composed Modifier가 암묵적으로 수행하던 Slot Table 할당, 커서 순회, Gap Buffer 관리를 완전히 우회합니다. 그 대가로 ModifierNodeElement, DelegatingNode, DrawModifierNode 등 여러 클래스를 직접 구현해야 하는 코드 복잡도가 발생합니다.</p><p>따라서 모든 Modifier를 Node로 마이그레이션하는 것은 권장하지 않습니다. 최적화 대상을 선정할 때는 다음 기준을 고려하는 것이 바람직합니다.</p><p>- 프레임 단위로 상태가 변경되는 컴포넌트 (애니메이션이 포함된 버튼, 드래그 핸들 등)<br>- 리스트나 그리드처럼 다수가 동시에 존재하는 컴포넌트<br>- 여러 Composed Modifier가 체이닝된 컴포넌트 (단일 Node로 통합할 수 있는 경우)</p><p>이 세 조건 중 두 가지 이상에 해당한다면, Modifier.Node 마이그레이션을 통해 유의미한 성능 개선을 기대할 수 있습니다. 본 글에서 다룬 CustomButton은 세 조건을 모두 충족하는 케이스였으며, 실제 측정 결과도 이를 뒷받침했습니다.</p><h3>7. …인줄 알았으나,,, 놓친 포인트</h3><p><strong>saveLayer vs RenderNode</strong></p><p>위 테스트는 <strong>.background()</strong> 수정자가 사용되는 <strong>ShrinkWithGrayBackground</strong>로 진행되었습니다. 이 TransitionType에서는 Legacy 버전이 .background()수정자로 인해 매 프레임 Recomposition이 발생하므로, Node 버전의 성능 이점이 명확했습니다.</p><p>그런데 <strong>.background()</strong> 수정자를 사용하지 않는 <strong>Shrink</strong>로 테스트를 진행하면 어떨까요? Shrink 타입은 Scale과 Alpha만 변경하며, 이 값들은 Legacy에서도 .graphicsLayer {} 람다 내에서 읽히므로 Recomposition이 발생하지 않습니다.</p><pre>03-01 19:40:42.560: [LongClickPerfComparison] ════════════════════════════════════════<br>03-01 19:40:42.560: [LongClickPerfComparison]  COMPARISON<br>03-01 19:40:42.560: [LongClickPerfComparison] ════════════════════════════════════════<br>03-01 19:40:42.561: [LongClickPerfComparison]  Total frames — Node: 60.05, Legacy: 60.25<br>03-01 19:40:42.561: [LongClickPerfComparison]  Janky frames improvement: -12.9% ← Node 버전이 오히려 느림<br>03-01 19:40:42.561: [LongClickPerfComparison]  P99 frame time improvement: -5.6%<br>03-01 19:40:42.561: [LongClickPerfComparison] ════════════════════════════════════════</pre><p>Recomposition이 발생하지 않는 조건에서는, Modifier.Node 버전이 오히려 더 느렸습니다,,,!</p><h4>7.1 원인 분석: Canvas saveLayer의 비용</h4><p>초기 CustomButtonNode 구현에서는 scale, alpha, rotation 변환을 DrawModifierNode의 draw() 내에서 Canvas API로 직접 처리했습니다.</p><pre>// 초기 구현 (문제 있는 코드)<br>override fun ContentDrawScope.draw() {<br>  val nativeCanvas = drawContext.canvas.nativeCanvas<br><br>  // alpha 적용을 위한 saveLayer — 매 프레임 오프스크린 버퍼 할당<br>  layerPaint.alpha = ((1f - bgAlpha) * 255).toInt()<br>  nativeCanvas.saveLayer(0f, 0f, size.width, size.height, layerPaint)<br><br>  // scale 적용<br>  nativeCanvas.scale(currentScale, currentScale, size.width / 2, size.height / 2)<br><br>  drawContent()  // 자식 콘텐츠를 매 프레임 다시 그림<br>  nativeCanvas.restore()<br>}</pre><p><strong>saveLayer</strong>는 오프스크린 버퍼(offscreen buffer)를 할당하고, 그 위에 콘텐츠를 처음부터 다시 그린 뒤, 결과를 메인 캔버스에 합성합니다. 이 과정이 매 애니메이션 프레임마다 반복됩니다.</p><p>반면, Legacy의 <strong>.graphicsLayer {}</strong> 수정자는 내부적으로 RenderNode를 사용합니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/498/1*NmlJQzUyhCzlE0XqwUfuQA.png" /></figure><p><strong>ShrinkWithGrayBackground</strong> 테스트에서 Node 버전이 빨랐던 이유는 Legacy의 <strong>.background()</strong>가 매 프레임 Recomposition을 유발하는 오버헤드가 saveLayer의 비용보다 컸기 때문이었습니다. 하지만 Shrink 타입에서는 Recomposition이 발생하지 않으므로, Legacy의 RenderNode 대 Node의 saveLayer 순수 비교가 되었고, RenderNode가 더 효율적이었던 것입니다</p><h4><strong>7.2 수정: LayoutModifierNode.placeWithLayer()</strong></h4><p>때문에, 변환 로직을 DrawModifierNode의 Canvas API에서 LayoutModifierNode의 placeWithLayer()로 이전했습니다.</p><pre>// 수정 후: RenderNode 기반 GPU 가속 변환<br>override fun MeasureScope.measure(<br>    measurable: Measurable,<br>    constraints: Constraints,<br>): MeasureResult {<br>    val placeable = measurable.measure(constraints)<br>    return layout(placeable.width, placeable.height) {<br>        placeable.placeWithLayer(0, 0) {<br>            scaleX = this@CustomButtonNode.scale.value<br>            scaleY = this@CustomButtonNode.scale.value<br>            alpha = 1f - this@CustomButtonNode.backgroundAlpha.value<br>            transformOrigin = TransformOrigin.Center<br><br>            if (this@CustomButtonNode.transitionType == TransitionType.ShrinkWithTilt) {<br>                rotationX = this@CustomButtonNode.tiltX.value<br>                rotationY = this@CustomButtonNode.tiltY.value<br>            }<br>        }<br>    }<br>}<br><br>// DrawModifierNode는 ShrinkWithGrayBackground 배경 드로잉 전용으로 축소<br>override fun ContentDrawScope.draw() {<br>    if (transitionType == TransitionType.ShrinkWithGrayBackground) {<br>        val bgAlpha = backgroundAlpha.value<br>        if (bgAlpha &gt; 0f) {<br>            drawRoundRect(<br>                color = Gray.copy(alpha = bgAlpha),<br>                cornerRadius = CornerRadius(8.dp.toPx()),<br>            )<br>        }<br>    }<br>    drawContent()<br>}</pre><p>placeWithLayer()는 Legacy의 .graphicsLayer {}와 동일한 RenderNode 기반 경로를 사용합니다. 콘텐츠를 Display List로 캐싱하고, 애니메이션 중에는 GPU에서 변환 속성만 갱신합니다. DrawModifierNode는 ShrinkWithGrayBackground 타입에서 배경 라운드 렉트를 그리는 용도로만 사용하도록 역할을 분리했습니다.</p><h4><strong>7.3 Shrink Transition 재테스트 결과</strong></h4><p>RenderNode 기반으로 수정 후, .background() 수정자가 사용되지 않는 TransitionType.Shrink로 다시 테스트를 진행했습니다.</p><pre>03-01 20:32:34.086 [LongClickPerfComparison] ════════════════════════════════════════<br>03-01 20:32:34.086 [LongClickPerfComparison]  HMButton (Modifier.Node) — LongClick Transition<br>03-01 20:32:34.087 [LongClickPerfComparison] ════════════════════════════════════════<br>03-01 20:32:34.087 [LongClickPerfComparison]  #1  Total: 60, Janky: 1, P99: 8ms<br>03-01 20:32:34.087 [LongClickPerfComparison]  #2  Total: 60, Janky: 2, P99: 18ms<br>03-01 20:32:34.087 [LongClickPerfComparison]  #3  Total: 60, Janky: 2, P99: 12ms<br>03-01 20:32:34.087 [LongClickPerfComparison]  #4  Total: 60, Janky: 1, P99: 12ms<br>03-01 20:32:34.087 [LongClickPerfComparison]  #5  Total: 60, Janky: 2, P99: 12ms<br>03-01 20:32:34.087 [LongClickPerfComparison]  #6  Total: 61, Janky: 1, P99: 8ms<br>03-01 20:32:34.087 [LongClickPerfComparison]  #7  Total: 59, Janky: 2, P99: 14ms<br>03-01 20:32:34.088 [LongClickPerfComparison]  #8  Total: 60, Janky: 1, P99: 10ms<br>03-01 20:32:34.088 [LongClickPerfComparison]  #9  Total: 60, Janky: 2, P99: 17ms<br>03-01 20:32:34.088 [LongClickPerfComparison]  #10  Total: 60, Janky: 1, P99: 8ms<br>03-01 20:32:34.088 [LongClickPerfComparison]  #11  Total: 60, Janky: 2, P99: 17ms<br>03-01 20:32:34.088 [LongClickPerfComparison]  #12  Total: 60, Janky: 2, P99: 18ms<br>03-01 20:32:34.088 [LongClickPerfComparison]  #13  Total: 60, Janky: 2, P99: 16ms<br>03-01 20:32:34.088 [LongClickPerfComparison]  #14  Total: 60, Janky: 2, P99: 11ms<br>03-01 20:32:34.088 [LongClickPerfComparison]  #15  Total: 59, Janky: 1, P99: 13ms<br>03-01 20:32:34.088 [LongClickPerfComparison]  #16  Total: 60, Janky: 2, P99: 11ms<br>03-01 20:32:34.089 [LongClickPerfComparison]  #17  Total: 60, Janky: 1, P99: 8ms<br>03-01 20:32:34.089 [LongClickPerfComparison]  #18  Total: 60, Janky: 1, P99: 12ms<br>03-01 20:32:34.089 [LongClickPerfComparison]  #19  Total: 60, Janky: 2, P99: 12ms<br>03-01 20:32:34.089 [LongClickPerfComparison]  #20  Total: 61, Janky: 2, P99: 17ms<br>03-01 20:32:34.090 [LongClickPerfComparison]  AVG Total: 60.00, AVG Janky: 1.60, AVG P99: 12.70ms<br>03-01 20:32:34.090 [LongClickPerfComparison] ════════════════════════════════════════<br>03-01 20:32:34.090 [LongClickPerfComparison]  HMButtonLegacy (Standard Compose) — LongClick Transition<br>03-01 20:32:34.090 [LongClickPerfComparison] ════════════════════════════════════════<br>03-01 20:32:34.090 [LongClickPerfComparison]  #1  Total: 61, Janky: 2, P99: 17ms<br>03-01 20:32:34.090 [LongClickPerfComparison]  #2  Total: 60, Janky: 2, P99: 16ms<br>03-01 20:32:34.091 [LongClickPerfComparison]  #3  Total: 61, Janky: 1, P99: 15ms<br>03-01 20:32:34.091 [LongClickPerfComparison]  #4  Total: 59, Janky: 2, P99: 10ms<br>03-01 20:32:34.091 [LongClickPerfComparison]  #5  Total: 59, Janky: 2, P99: 14ms<br>03-01 20:32:34.091 [LongClickPerfComparison]  #6  Total: 61, Janky: 1, P99: 10ms<br>03-01 20:32:34.091 [LongClickPerfComparison]  #7  Total: 61, Janky: 1, P99: 12ms<br>03-01 20:32:34.091 [LongClickPerfComparison]  #8  Total: 60, Janky: 2, P99: 15ms<br>03-01 20:32:34.091 [LongClickPerfComparison]  #9  Total: 60, Janky: 2, P99: 8ms<br>03-01 20:32:34.091 [LongClickPerfComparison]  #10  Total: 59, Janky: 2, P99: 14ms<br>03-01 20:32:34.091 [LongClickPerfComparison]  #11  Total: 60, Janky: 2, P99: 18ms<br>03-01 20:32:34.091 [LongClickPerfComparison]  #12  Total: 60, Janky: 2, P99: 9ms<br>03-01 20:32:34.091 [LongClickPerfComparison]  #13  Total: 60, Janky: 1, P99: 11ms<br>03-01 20:32:34.092 [LongClickPerfComparison]  #14  Total: 60, Janky: 2, P99: 13ms<br>03-01 20:32:34.092 [LongClickPerfComparison]  #15  Total: 61, Janky: 1, P99: 12ms<br>03-01 20:32:34.092 [LongClickPerfComparison]  #16  Total: 60, Janky: 2, P99: 17ms<br>03-01 20:32:34.092 [LongClickPerfComparison]  #17  Total: 61, Janky: 2, P99: 13ms<br>03-01 20:32:34.092 [LongClickPerfComparison]  #18  Total: 60, Janky: 1, P99: 16ms<br>03-01 20:32:34.092 [LongClickPerfComparison]  #19  Total: 60, Janky: 2, P99: 17ms<br>03-01 20:32:34.092 [LongClickPerfComparison]  #20  Total: 61, Janky: 2, P99: 10ms<br>03-01 20:32:34.093 [LongClickPerfComparison]  AVG Total: 60.20, AVG Janky: 1.70, AVG P99: 13.35ms<br>03-01 20:32:34.093 [LongClickPerfComparison] ════════════════════════════════════════<br>03-01 20:32:34.093 [LongClickPerfComparison]  COMPARISON<br>03-01 20:32:34.093 [LongClickPerfComparison] ════════════════════════════════════════<br>03-01 20:32:34.093 [LongClickPerfComparison]  Total frames — Node: 60.00, Legacy: 60.20<br>03-01 20:32:34.093 [LongClickPerfComparison]  Janky frames improvement: 5.9%<br>03-01 20:32:34.093 [LongClickPerfComparison]  P99 frame time improvement: 4.9%<br>03-01 20:32:34.093 [LongClickPerfComparison] ════════════════════════════════════════<br>03-01 20:32:59.754 [ButtonPerfComparison] ════════════════════════════════════════<br>03-01 20:32:59.754 [ButtonPerfComparison]  HMButton (Modifier.Node) Results<br>03-01 20:32:59.754 [ButtonPerfComparison] ════════════════════════════════════════<br>03-01 20:32:59.754 [ButtonPerfComparison]  #1  Total: 87, Janky: 6, P99: 17ms<br>03-01 20:32:59.754 [ButtonPerfComparison]  #2  Total: 101, Janky: 5, P99: 10ms<br>03-01 20:32:59.754 [ButtonPerfComparison]  #3  Total: 103, Janky: 3, P99: 10ms<br>03-01 20:32:59.754 [ButtonPerfComparison]  #4  Total: 101, Janky: 4, P99: 11ms<br>03-01 20:32:59.754 [ButtonPerfComparison]  #5  Total: 102, Janky: 3, P99: 8ms<br>03-01 20:32:59.754 [ButtonPerfComparison]  #6  Total: 101, Janky: 5, P99: 9ms<br>03-01 20:32:59.754 [ButtonPerfComparison]  #7  Total: 101, Janky: 4, P99: 9ms<br>03-01 20:32:59.755 [ButtonPerfComparison]  #8  Total: 101, Janky: 2, P99: 11ms<br>03-01 20:32:59.755 [ButtonPerfComparison]  #9  Total: 101, Janky: 3, P99: 13ms<br>03-01 20:32:59.755 [ButtonPerfComparison]  #10  Total: 101, Janky: 5, P99: 10ms<br>03-01 20:32:59.756 [ButtonPerfComparison]  AVG Total: 99.90, AVG Janky: 4.00, AVG P99: 10.80ms<br>03-01 20:32:59.756 [ButtonPerfComparison] ════════════════════════════════════════<br>03-01 20:32:59.757 [ButtonPerfComparison]  HMButtonLegacy (Standard Compose) Results<br>03-01 20:32:59.757 [ButtonPerfComparison] ════════════════════════════════════════<br>03-01 20:32:59.758 [ButtonPerfComparison]  #1  Total: 81, Janky: 9, P99: 27ms<br>03-01 20:32:59.758 [ButtonPerfComparison]  #2  Total: 100, Janky: 9, P99: 25ms<br>03-01 20:32:59.758 [ButtonPerfComparison]  #3  Total: 102, Janky: 9, P99: 13ms<br>03-01 20:32:59.759 [ButtonPerfComparison]  #4  Total: 101, Janky: 9, P99: 12ms<br>03-01 20:32:59.759 [ButtonPerfComparison]  #5  Total: 101, Janky: 9, P99: 12ms<br>03-01 20:32:59.759 [ButtonPerfComparison]  #6  Total: 100, Janky: 9, P99: 31ms<br>03-01 20:32:59.759 [ButtonPerfComparison]  #7  Total: 103, Janky: 9, P99: 11ms<br>03-01 20:32:59.760 [ButtonPerfComparison]  #8  Total: 99, Janky: 7, P99: 23ms<br>03-01 20:32:59.760 [ButtonPerfComparison]  #9  Total: 103, Janky: 8, P99: 12ms<br>03-01 20:32:59.760 [ButtonPerfComparison]  #10  Total: 103, Janky: 6, P99: 10ms<br>03-01 20:32:59.761 [ButtonPerfComparison]  AVG Total: 99.30, AVG Janky: 8.40, AVG P99: 17.60ms<br>03-01 20:32:59.761 [ButtonPerfComparison] ════════════════════════════════════════<br>03-01 20:32:59.761 [ButtonPerfComparison]  COMPARISON<br>03-01 20:32:59.761 [ButtonPerfComparison] ════════════════════════════════════════<br>03-01 20:32:59.762 [ButtonPerfComparison]  Janky frames improvement: 52.4%<br>03-01 20:32:59.762 [ButtonPerfComparison]  P99 frame time improvement: 38.6%<br>03-01 20:32:59.762 [ButtonPerfComparison] ════════════════════════════════════════</pre><ol><li><strong>단일 버튼 테스트<br></strong>Janky 개선율: -12.9 -&gt; <strong>5.0%<br></strong>P99 개선율: -5.6 -&gt; <strong>4.9%</strong></li><li><strong>60개 버튼 테스트<br></strong>Janky 개선율: <strong>52.4%<br></strong>P99 개선율: <strong>38.6%</strong></li></ol><p>saveLayer에서 RenderNode로 전환함으로써 단일 버튼에서의 성능 역전 현상이 해소되었고, 60개 버튼 스크롤에서는 오히려 P99 개선율이 ShrinkWithGrayBackground(14.3%)보다 크게 향상된 38.6%를 기록했습니다. 이는 Background 드로잉 오버헤드가 완전히 제거된 상태에서 RenderNode 기반 변환만 수행하므로, 순수하게 modifier 체인 통합과 Slot Table 제거의 이점이 반영된 결과입니다.</p><h3>8. 찐 결론</h3><p><strong>Modifier.Node</strong>로 마이그레이션할 때, Compose가 내부적으로 어떤 렌더링 경로를 사용하는지 이해하는 것이 중요하다는 것을 깨달았습니다. 또한 Unit Test의 커버리지가 실제 성능 비교에 얼마나 중요한지 깊이 깨달았습니다.</p><p>감사합니다.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8927068e969b" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[[Jetpack Compose] 우아한 버튼 탭 애니메이션 구현하기]]></title>
            <link>https://medium.com/@neoself1105/jetpack-compose-%EC%9A%B0%EC%95%84%ED%95%9C-%EB%B2%84%ED%8A%BC-%ED%83%AD-%EC%95%A0%EB%8B%88%EB%A9%94%EC%9D%B4%EC%85%98-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-1dd3c19fc7ab?source=rss-18d63a54716d------2</link>
            <guid isPermaLink="false">https://medium.com/p/1dd3c19fc7ab</guid>
            <dc:creator><![CDATA[NEON]]></dc:creator>
            <pubDate>Thu, 12 Feb 2026 02:43:52 GMT</pubDate>
            <atom:updated>2026-02-12T04:04:53.914Z</atom:updated>
            <content:encoded><![CDATA[<p>안녕하세요. 이번에는 Jetpack Compose에서 버튼 탭 애니메이션을 구현하면서 마주쳤던 여러 UX 및 성능 이슈들과 이에 대한 해결과정을 공유하고자 합니다.</p><h4><strong>1. 왜 리팩토링이 필요했을까?</strong></h4><p>기존 코드베이스에서는 각 화면마다 Modifier.clickable을 직접 사용하여 탭 상호작용을 처리했습니다. 이 방식은 몇 가지 문제점을 가지고 있었습니다.</p><p>- <strong>일관성 없는 사용자 경험</strong>: 화면마다 다른 탭 피드백이 제공되거나, 아예 시각적 피드백이 없는 경우가 발생<br>- <strong>중복 코드</strong>: 동일한 애니메이션 로직이 여러 곳에 반복적으로 작성됨<br>- <strong>유지보수의 어려움</strong>: 탭 애니메이션을 변경하려면 수십 개의 파일을 수정해야 함</p><p>이를 해결하기 위해 모든 clickable 수정자를 <strong>범용 버튼 컴포넌트</strong>로 통합하기로 결정했습니다.</p><h4><strong>2. 사용자 경험 측면의 고려사항</strong></h4><p><strong>2.1. Shrink</strong></p><p>리팩토링의 가장 큰 과제는 “<strong>기존 UI를 해치지 않으면서도 일관된 사용자 경험을 제공하는 것</strong>”이었습니다.</p><p>기존 clickable 수정자는 단순히 클릭 영역만 제공했기 때문에, 새로운 버튼 컴포넌트가 시각적 변화를 주게 되면 기존 레이아웃이 깨질 수 있었습니다. 특히 다음과 같은 이슈를 고려해야 했습니다.</p><p>- 배경이 추가되면 패딩만큼 영역이 확장됨<br>- 축소 애니메이션이 주변 요소의 배치에 영향을 줄 수 있음</p><p>이를 해결하기 위해 대부분의 clickable에는 탭 시, 사이즈가 일시적으로 줄어들어 실제 버튼을 누르는 듯한 시각적 효과인 Shrink를 기본 적용하기로 했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*Czs9zz3DVXVsHM3dWzm1NA.gif" /></figure><p>해당 타입은 추가적인 패딩이나 배경 없이 오직 <strong>scale 애니메이션만 제공</strong>하여, 기존 레이아웃을 완전히 보존합니다.</p><p><strong>2.2. ShrinkWithGrayBackground</strong></p><p>배경색이나 border가 존재하여 클릭 여부를 명확히 판단할 수 있는 위 버튼과 달리 영역이 명확하게 보이지 않는 버튼(예: 리스트 아이템 Row)의 경우, 탭했을 때 어떤 영역이 반응하는지 사용자가 알기 어렵습니다.</p><p>이를 위해 ShrinkWithGrayBackground 타입을 추가 구현했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/712/1*aUafNH4wLUlwY8lB4KUj2g.gif" /></figure><pre>.then(<br>    if (transitionType == TransitionType.ShrinkWithGrayBackground) {<br>        Modifier<br>            .background(<br>                Color.Gray.copy(alpha = backgroundAlpha.value),<br>                RoundedCornerShape(8.dp)<br>            )<br>            .padding(4.dp) // 최소한의 패딩<br>    } else {<br>        Modifier<br>    }<br>)</pre><p>이는 탭 시, scale 효과 뿐만 아니라, 회색계열의 배경색을 띄워 영역을 전달합니다.</p><p><strong>2.3. ShrinkWithTilt</strong></p><p>마지막으로 일반적인 버튼과 <strong>중요한 기능을 수행하는 버튼을 차별화</strong>하기 위해 ShrinkWithTilt 타입을 추가했습니다.</p><pre>if (transitionType == TransitionType.ShrinkWithTilt &amp;&amp; componentSize != IntSize.Zero) {<br>    // 탭 위치를 중심점 기준 상대 좌표로 변환 (-1.0 ~ 1.0)<br>    val centerX = componentSize.width / 2f<br>    val centerY = componentSize.height / 2f<br>    val relativeX = (offset.x - centerX) / (componentSize.width / 2f)<br>    val relativeY = (offset.y - centerY) / (componentSize.height / 2f)<br><br>    // rotationX: Y축 위치에 따라 상하 기울기 (위를 누르면 앞으로, 아래를 누르면 뒤로)<br>    val targetRotationX = -relativeY * maxTiltAngle<br>    // rotationY: X축 위치에 따라 좌우 기울기 (왼쪽을 누르면 왼쪽으로, 오른쪽을 누르면 오른쪽으로)<br>    val targetRotationY = relativeX * maxTiltAngle<br><br>    coroutineScope.launch {<br>        tiltX.animateTo(targetValue = targetRotationX, animationSpec = tween(100))<br>    }<br>    coroutineScope.launch {<br>        tiltY.animateTo(targetValue = targetRotationY, animationSpec = tween(100))<br>    }<br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*5kXdLgtR-U_JwEGvr79Q4Q.gif" /></figure><p>앞의 2개 Transition과 달리 탭 위치에 따라 버튼이 <strong>물리적으로 눌리는 듯한 효과</strong>를 구현하여, 사용자에게 더 생동감 있는 경험을 제공하고자 했습니다.</p><p>허나, 효과 자체의 연산량 또한 앞의 2개 Transition보다 더 큰 만큼 버튼의 크기가 크거나 핵심 기능과 직결되는 버튼에 대해서만 이를 사용하고자 했습니다.</p><h4><strong>3. 성능 최적화: GraphicsLayer의 활용</strong></h4><p>Compose에서 애니메이션을 구현하는 방법은 크게 두 가지입니다.</p><pre>// 비효율적<br>Modifier<br>    .scale(animatedScale)<br>    .alpha(animatedAlpha)<br>    .rotate(animatedRotation)</pre><p>이 방식은 애니메이션 프레임마다 <strong>레이아웃 단계와 Draw 단계를 모두 거치기 때문</strong>에 성능 저하를 일으킵니다.</p><pre>// 효율적<br>.graphicsLayer {<br>    alpha = 1f - backgroundAlpha.value<br>    scaleX = scale.value<br>    scaleY = scale.value<br>    if (transitionType == TransitionType.ShrinkWithTilt) {<br>        rotationX = tiltX.value<br>        rotationY = tiltY.value<br>    }<br>}</pre><p>그에 반해 graphicsLayer는 <strong>GPU 가속 레이어에서 변환을 처리</strong>하여 레이아웃 재계산 없이 시각적 변화를 적용합니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/538/1*-rZYU4Nvw-zKLlX1g3oeJA.png" /><figcaption><a href="https://developer.android.com/develop/ui/compose/graphics/draw/modifiers?hl=ko">https://developer.android.com/develop/ui/compose/graphics/draw/modifiers?hl=ko</a></figcaption></figure><h4><strong>3. 코드 설명</strong></h4><p><strong>3.1. tryAwaitRelease</strong></p><pre>detectTapGestures(<br>    onPress = { offset -&gt;<br>        if (!isDisabled) {<br>            // Press 시작 시 축소 및 배경 표시<br>            // Press가 끝날 때까지 대기 <br>            tryAwaitRelease()<br><br>            // Release 시 원래 크기로 복원<br>        }<br>    },<br>    onTap = {<br>        if (!isDisabled) {<br>            action()<br>        }<br>    }<br>)</pre><p>사용자가 버튼을 탭할 경우, onPress 콜백이 호출됩니다. 이 때, tryAwaitRelease 메서드를 호출하면 사용자가 손가락을 화면에서 떼기까지 <strong>suspend </strong>상태로 호출자를 넘기지 않고 대기합니다.</p><p>해당 메서드는 탭 동작이 정상적으로 입력되었는지 여부를 Boolean값으로 반환하기 때문에, 사용자가 최종적으로 손가락을 영역 밖으로 이동 후 놓아 탭 동작을 취소했는지 등을 파악할 수도 있습니다.</p><p><strong>3.2. Animatable vs remember { mutableStateOf }</strong></p><pre>val scale = remember { Animatable(1f) }<br>val backgroundAlpha = remember { Animatable(0f) }<br>val tiltX = remember { Animatable(0f) }<br>val tiltY = remember { Animatable(0f) }</pre><p><strong>Animatable</strong></p><p>1. <strong>애니메이션 취소 및 중단 지원</strong>: 사용자가 빠르게 여러 번 탭할 때, 이전 애니메이션을 자동으로 취소하고 새로운 목표값으로 보간을 전환할 수있습니다.<br>2. <strong>Thread-safe</strong>: 코루틴 기반으로 안전하게 상태 변경</p><p>만약 mutableStateOf를 사용했다면, 빠른 연속 탭 시 애니메이션이 겹쳐서 어색한 동작이 발생할 수 있기에 Animatable을 최종 선택했습니다.</p><p><strong>3.3. onSizeChanged</strong></p><p>Tilt 효과를 구현하기 위해서는 <strong>컴포넌트의 실제 크기를 알아야</strong> 탭 위치를 상대 좌표로 변환할 수 있습니다.</p><pre>var componentSize by remember { mutableStateOf(IntSize.Zero) }<br><br>Row(<br>    modifier = modifier<br>        .onSizeChanged { size -&gt;<br>            if (transitionType == TransitionType.ShrinkWithTilt) {<br>                componentSize = size<br>            }<br>        }</pre><p>버튼의 크기는 컴파일 타임에 알 수 없습니다. content로 전달되는 컴포저블에 따라 크기가 동적으로 결정되기 때문입니다.</p><p>onSizeChanged는 <strong>레이아웃 측정 후 실제 크기가 확정되면</strong> 콜백을 호출하여, 이후 탭 위치 계산에 활용할 수 있게 합니다.</p><pre>val centerX = componentSize.width / 2f<br>val centerY = componentSize.height / 2f<br>val relativeX = (offset.x - centerX) / (componentSize.width / 2f)  // -1.0 ~ 1.0<br>val relativeY = (offset.y - centerY) / (componentSize.height / 2f) // -1.0 ~ 1.0</pre><p>최종코드</p><pre>/**<br> * @param modifier 바깥 여백을 padding으로 조절해주세요.<br> *        버튼영역에 기본 background값이 있을 경우, BaseButton이 아닌 내부 요소에 직접 적용 후, paddingDp를 0으로 설정해주세요.<br> *        modifier에 배경을 적용할 경우, 해당 색상이 차지하는 영역은 graphic layer로 적용되는 scale 애니메이션이 적용되지 않습니다.<br> * Press 시 scale과 배경 애니메이션이 적용됩니다.<br> * */<br><br>object BaseButton {<br>    enum class TransitionType {<br>        // 크기가 줄어드는 효과만 제공합니다. 영역이 명확하게 정의된 대부분의 버튼에 적용합니다.<br>        Shrink,<br>        // 크기가 줄어드는 효과에 tilt효과를 추가합니다. 큰 feature를 수행하는 버튼에 적용합니다.<br>        ShrinkWithTilt,<br>        // 영역이 명확하지 않은 버튼에 제공합니다.<br>        /** @warning 4 padding이 기본 적용됩니다. */<br>        ShrinkWithGrayBackground,<br>    }<br><br>    @Composable<br>    operator fun invoke(<br>        modifier: Modifier = Modifier,<br>        action: () -&gt; Unit,<br>        transitionType: TransitionType = TransitionType.Shrink,<br>        isDisabled: Boolean = false,<br>        content: @Composable () -&gt; Unit<br>    ) {<br>        val backgroundAlphaTarget = 0.3f<br>        val scaleTarget = 0.97f<br>        val maxTiltAngle = 5f<br><br>        // Press 애니메이션<br>        val scale = remember { Animatable(1f) }<br>        val backgroundAlpha = remember { Animatable(0f) }<br>        val tiltX = remember { Animatable(0f) }<br>        val tiltY = remember { Animatable(0f) }<br>        val coroutineScope = rememberCoroutineScope()<br><br>        // 컴포넌트 크기 추적 (ShrinkWithTilt용)<br>        var componentSize by remember { mutableStateOf(IntSize.Zero) }<br><br>        Row(<br>            modifier = modifier<br>                .then(<br>                    if (transitionType == TransitionType.ShrinkWithGrayBackground) {<br>                        Modifier<br>                            .background(<br>                                Color(0xFFE8E8E8).copy(alpha = backgroundAlpha.value),<br>                                RoundedCornerShape(8.dp)<br>                            )<br>                            .padding(4.dp)<br>                    } else {<br>                        Modifier<br>                    }<br>                )<br>                .onSizeChanged { size -&gt;<br>                    if (transitionType == TransitionType.ShrinkWithTilt) {<br>                        componentSize = size<br>                    }<br>                }<br>                .graphicsLayer {<br>                    alpha = 1f - backgroundAlpha.value<br>                    scaleX = scale.value<br>                    scaleY = scale.value<br>                    if (transitionType == TransitionType.ShrinkWithTilt) {<br>                        rotationX = tiltX.value<br>                        rotationY = tiltY.value<br>                    }<br>                }<br>                .pointerInput(isDisabled) {<br>                    detectTapGestures(<br>                        onPress = { offset -&gt;<br>                            if (!isDisabled) {<br>                                // Press 시작 시 축소 및 배경 표시<br>                                coroutineScope.launch {<br>                                    backgroundAlpha.animateTo(<br>                                        targetValue = backgroundAlphaTarget,<br>                                        animationSpec = tween(100)<br>                                    )<br>                                }<br>                                coroutineScope.launch {<br>                                    scale.animateTo(<br>                                        targetValue = scaleTarget,<br>                                        animationSpec = tween(100)<br>                                    )<br>                                }<br><br>                                if (transitionType == TransitionType.ShrinkWithTilt &amp;&amp; componentSize != IntSize.Zero) {<br>                                    // 탭 위치를 중심점 기준 상대 좌표로 변환 (-1.0 ~ 1.0)<br>                                    val centerX = componentSize.width / 2f<br>                                    val centerY = componentSize.height / 2f<br>                                    val relativeX = (offset.x - centerX) / (componentSize.width / 2f)<br>                                    val relativeY = (offset.y - centerY) / (componentSize.height / 2f)<br><br>                                    // rotationX: Y축 위치에 따라 상하 기울기 (위를 누르면 앞으로, 아래를 누르면 뒤로)<br>                                    val targetRotationX = -relativeY * maxTiltAngle<br>                                    // rotationY: X축 위치에 따라 좌우 기울기 (왼쪽을 누르면 왼쪽으로, 오른쪽을 누르면 오른쪽으로)<br>                                    val targetRotationY = relativeX * maxTiltAngle<br><br>                                    coroutineScope.launch {<br>                                        tiltX.animateTo(<br>                                            targetValue = targetRotationX,<br>                                            animationSpec = tween(100)<br>                                        )<br>                                    }<br>                                    coroutineScope.launch {<br>                                        tiltY.animateTo(<br>                                            targetValue = targetRotationY,<br>                                            animationSpec = tween(100)<br>                                        )<br>                                    }<br>                                }<br><br>                                // Press가 끝날 때까지 대기<br>                                tryAwaitRelease()<br><br>                                // Release 시 원래 크기로 복원 및 배경 숨김<br>                                coroutineScope.launch {<br>                                    scale.animateTo(<br>                                        targetValue = 1f,<br>                                        animationSpec = tween(400)<br>                                    )<br>                                }<br>                                coroutineScope.launch {<br>                                    backgroundAlpha.animateTo(<br>                                        targetValue = 0f,<br>                                        animationSpec = tween(400)<br>                                    )<br>                                }<br>                                if (transitionType == TransitionType.ShrinkWithTilt) {<br>                                    coroutineScope.launch {<br>                                        tiltX.animateTo(<br>                                            targetValue = 0f,<br>                                            animationSpec = tween(400)<br>                                        )<br>                                    }<br>                                    coroutineScope.launch {<br>                                        tiltY.animateTo(<br>                                            targetValue = 0f,<br>                                            animationSpec = tween(400)<br>                                        )<br>                                    }<br>                                }<br>                            }<br>                        },<br>                        onTap = {<br>                            if (!isDisabled) {<br>                                action()<br>                            }<br>                        }<br>                    )<br>                },<br>            verticalAlignment = Alignment.CenterVertically,<br>            horizontalArrangement = Arrangement.Center<br>        ) {<br>            content()<br>        }<br>    }<br>}</pre><h4><strong>마무리하며</strong></h4><p>이번 리팩토링을 통해 다음을 달성할 수 있었습니다:</p><p>1. <strong>일관된 사용자 경험</strong>: 앱 전체에서 통일된 탭 피드백 제공<br>2. <strong>성능 최적화</strong>: GraphicsLayer를 통한 GPU 가속으로 부드러운 애니메이션 구현<br>3. <strong>유지보수성 향상</strong>: 단일 컴포넌트로 모든 탭 상호작용 관리<br>4. <strong>UX 차별화</strong>: Tilt 효과를 통한 프리미엄 경험 제공</p><p>작은 상호작용 하나하나가 쌓여 전체 앱의 품질을 만든다는 것을 다시 한번 느낄 수 있었습니다. 성능과 UX 사이의 균형을 맞추는 과정이 쉽지는 않았지만, GraphicsLayer와 같은 강력한 API를 활용하면 두 마리 토끼를 모두 잡을 수 있다는 것을 배웠습니다.</p><p>감사합니다.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1dd3c19fc7ab" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[[Mobile CI/CD] Claude Code Action과 Notion MCP로 iOS/Android 통합 CI/CD 파이프라인 구축하기]]></title>
            <link>https://medium.com/@neoself1105/mobile-ci-cd-claude-code-action%EA%B3%BC-notion-mcp%EB%A1%9C-ios-android-%ED%86%B5%ED%95%A9-ci-cd-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-27898668e828?source=rss-18d63a54716d------2</link>
            <guid isPermaLink="false">https://medium.com/p/27898668e828</guid>
            <dc:creator><![CDATA[NEON]]></dc:creator>
            <pubDate>Tue, 10 Feb 2026 09:14:29 GMT</pubDate>
            <atom:updated>2026-02-11T00:14:00.010Z</atom:updated>
            <content:encoded><![CDATA[<p>안녕하세요. 이번에는 iOS와 Android 플랫폼을 모두 관리하는 프로젝트에서 Claude Code Action과 Notion MCP를 활용한 CI/CD 파이프라인을 구축하면서 마주쳤던 여러 시행착오와 해결 과정을 공유하고자 합니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AUmAybhnyu2jgi-IhTASzg.png" /><figcaption>스케줄링된 github action 워크플로우를 통해 노션 Task를 생성할 수 있습니다.</figcaption></figure><h3><strong>0. 배경: 왜 AI 기반 CI/CD를 도입했는가</strong></h3><p>소켓통신, 드래그앤드롭과 같은 고도 기능들을 추가함과 동시에 양 플랫폼에 최대한 동일한 사용자 경험을 제공하기 위해 저는 동일한 프로젝트를 각각 iOS와 Android 네이티브 언어로 구현하는 결정을 내리게 되었습니다.</p><p>하지만, 점차 프로젝트의 규모가 커지게 되면서 구현 및 유지보수 간, 동기화에 발생하는 비용이 점점 증대되는 것을 체감할 수 있었습니다.</p><p>특히, 다음과 같은 문제점이 있었습니다:</p><blockquote><strong><em>- 수동 코드 리뷰의 한계</em></strong><em>: 리뷰어가 iOS/Android 양쪽 코드를 모두 파악하기 어려움<br>- </em><strong><em>플랫폼 동기화 누락</em></strong><em>: PR에서 한쪽 플랫폼만 변경해도 알아채기 어려움<br>- </em><strong><em>네이밍 컨벤션 불일치</em></strong><em>: 시간이 지날수록 플랫폼 간 네이밍이 점점 달라지며, 플랫폼 간 유지보수 비용이 크게 증대됨</em></blockquote><p>이 문제들을 해결하기 위해 Claude Code Action과 Notion MCP를 조합한 자동화 파이프라인을 설계했습니다.</p><h3><strong>1. 파이프라인 아키텍처 개요</strong></h3><p>최종 구현된 파이프라인은 크게 3개의 워크플로우로 구성됩니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wCjDJPCOorF5s32GtwuobQ.png" /></figure><h4><strong>1. Continuous Integration (CI)</strong></h4><p><strong>Trigger</strong>: dev 브랜치에 PR 생성 시</p><p><strong>Job 1–2</strong>: android-build와 ios-build-test를 병렬 실행</p><p><strong>Job 3</strong>: claude-code-review<br>- 플랫폼 간 동기화 비교 및 미반영 확인<br>- 로직단 미반영 확인<br>- Code Convention 및 Naming Convention 검토</p><h4><strong>2. Continuous Deployment (CD)</strong></h4><p><strong>Trigger</strong>: release-{{yymmdd}} 브랜치에서 커밋 및 푸시 수행 시</p><p><strong>android-deploy</strong>: Firebase App Distribution에 테스트용 APK 자동 배포</p><p><strong>ios-deploy</strong>: TestFlight에 자동 배포</p><h4><strong>3. Comprehensive Code Review</strong></h4><p><strong>Trigger</strong>: 매주 토요일 12시 (KST) 자동 실행</p><p><strong>comprehensive-code-review</strong>:<br>- Code Convention 및 여태 검증 이슈 축적 검증<br>- 검증된 이슈를 노션에 KanBan 생성</p><h3>2. Mobile CI 구현과정</h3><p><strong>2.1 시행착오와 해결 과정</strong></p><p><strong>시행착오 1: 빌드 시간 폭증</strong></p><p>초기에는 iOS와 Android 빌드를 순차적으로 실행했습니다.</p><blockquote>iOS 빌드: ~15분<br>Android 빌드: ~10분<br>-<strong>총 소요 시간: 25분+</strong></blockquote><p>PR 하나당 25분씩 대기하는 것은 개발 속도를 크게 저하시켰습니다.</p><p>이를 해결하고자 병렬 실행으로 전환했습니다.</p><pre>jobs:<br>  # 1단계: 빌드 및 테스트 (iOS/Android 병렬 실행)<br>  build-test-iOS:<br>    runs-on: self-hosted<br>    steps:<br>      - name: Run iOS build and test<br>        run: |<br>          xcodebuild clean test \<br>            -project Humania-iOS/Humania.xcodeproj \<br>            -scheme Humania-dev \<br>            -destination &#39;platform=iOS Simulator,name=iPhone 17,OS=latest&#39;<br><br>  build-test-android:<br>    runs-on: ubuntu-latest<br>    steps:<br>      - name: Run Android build and test<br>        run: |<br>          cd Humania-android<br>          bundle exec fastlane android build</pre><p>병렬 실행으로 전환한 결과, 총 소요 시간이 15분으로 단축되어 <strong>40% 시간을 절약</strong>할 수 있었습니다.</p><p><strong>시행착오 2: Android Gradle 캐시 미스</strong></p><p>Android 빌드에서 가장 시간이 오래 걸리는 부분은 Gradle 의존성 다운로드였습니다. 매번 빌드할 때마다 의존성을 새로 다운로드하니 시간이 오래 걸렸습니다.</p><p>이를 해결하고자 Gradle 캐싱을 추가해 빌드시간을 <strong>10분 -&gt; 7분</strong>으로 단축했습니다.</p><pre>- name: Setup Gradle<br>  uses: gradle/actions/setup-gradle@v3<br>  with:<br>    gradle-version: wrapper<br>    cache-read-only: false  # 캐시 쓰기 허용</pre><p><strong>2.3 Claude Code Review</strong></p><p><strong>시행착오 3: 리뷰 품질 문제</strong></p><p>초기에는 Claude-code-action의 예시로 제시된 프롬프트를 주입하였으나 결과는 너무 일반적이고 표면적인 피드백들이었습니다.</p><p>특히, 가장 시간을 많이 잡아먹던 Android와 iOS 간의 플랫폼 동기화 여부 파악에는 실질적인 도움이 되지 않았습니다.</p><p>때문에 아래와 같이 제 상황에 최적화된 구체적이고 구조화된 프롬프트를 주입하였습니다. 특히 플랫폼 동기화에 대한 Task를 별도로 명시하였습니다.</p><pre>- uses: anthropics/claude-code-action@v1<br>  with:<br>    prompt: |<br>      ## Review Instructions<br>      이 PR에 대해 다음 3가지 관점에서 종합적인 코드 리뷰를 수행해주세요.<br><br>      ### 1. 플랫폼 동기화 검토 (Cross-Platform Sync Review)<br>      **검토 항목**:<br>      - 한 플랫폼에만 변경사항이 있는 경우, 반대 플랫폼에도 동일한 변경이 필요한지 분석<br>      - 새로운 API 연동, DTO 추가, UseCase 변경 등이 양 플랫폼에 반영되었는지 확인<br>      - 도메인 모델 변경 시 양 플랫폼의 모델이 일치하는지 확인<br><br>      ### 2. 코드 품질 검토 (Code Quality Review)<br>      **검토 관점**:<br>      - 코드 퀄리티 및 Best Practices<br>      - 버그 및 이슈 발생 가능성<br>      - 보안 이슈<br>      - 성능 최적화<br><br>      ### 3. 네이밍 컨벤션 검토 (Naming Convention Review)<br>      위에 제공된 &quot;Naming Convention&quot; 내용을 기준으로 검토해주세요.</pre><p><strong>시행착오 4: 네이밍 컨벤션 관리의 어려움</strong></p><p>처음에는 네이밍 컨벤션을 워크플로우 파일에 .md 파일로 하드코딩했습니다. 하지만 컨벤션이 업데이트될 때마다 여러 워크플로우 파일을 수정해야 했고, 버전 관리가 어려웠습니다.</p><p>때문에 Notion을 Single Source of Truth로 활용하고, 워크플로우에서는 Notion API를 직접 호출해 최신 정보를 매번 가져오도록 설계했습니다.</p><pre>#!/bin/bash<br># .github/scripts/fetch-naming-convention.sh<br><br>PAGE_ID=&quot;2c691349069a806c8694c7af8279d6df&quot;<br>NOTION_VERSION=&quot;2022-06-28&quot;<br><br># Notion API로 블록 가져오기<br>fetch_blocks() {<br>  local block_id=$1<br>  curl -s &quot;https://api.notion.com/v1/blocks/${block_id}/children?page_size=100&quot; \<br>    -H &quot;Authorization: Bearer ${NOTION_TOKEN}&quot; \<br>    -H &quot;Notion-Version: ${NOTION_VERSION}&quot;<br>}<br><br># 블록을 마크다운으로 변환<br>convert_to_markdown() {<br>  local blocks_json=$1<br>  local indent=$2<br><br>  echo &quot;$blocks_json&quot; | jq -c &#39;.results[]?&#39; | while read -r block; do<br>    local block_type=$(echo &quot;$block&quot; | jq -r &#39;.type&#39;)<br>    # ... 각 블록 타입별 변환 로직 ...<br>  done<br>}<br><br>main() {<br>  local blocks=$(fetch_blocks &quot;$PAGE_ID&quot;)<br>  echo &quot;# Naming Convention&quot;<br>  convert_to_markdown &quot;$blocks&quot; &quot;&quot;<br>}<br><br>main</pre><p>이 스크립트는 Notion API를 통해 네이밍 컨벤션 페이지의 내용을 가져와 마크다운으로 변환합니다.</p><h3>3. Mobile CD 구현과정</h3><p><strong>3.1 초기 설계: Fastlane 기반 배포</strong></p><p>CD 파이프라인은 Fastlane을 중심으로 설계했습니다. Fastlane은 iOS와 Android 모두를 지원하는 배포 도구입니다.</p><p><strong>iOS 배포 파이프라인</strong></p><pre># iOS/fastlane/Fastfile<br>lane :beta do<br>  setup_ci  # CI 환경 설정<br><br>  # Match로 인증서 관리<br>  match(<br>    type: &quot;appstore&quot;,<br>    readonly: true<br>  )<br><br>  # 빌드 번호 자동 증가<br>  increment_build_number<br><br>  # 빌드 &amp; 업로드<br>  build_app(<br>    scheme: ENV[&quot;SCHEME&quot;],<br>    export_method: &quot;app-store&quot;<br>  )<br><br>  upload_to_testflight(<br>    skip_waiting_for_build_processing: true<br>  )<br>end</pre><p><strong>Android 배포 파이프라인</strong></p><pre># android/fastlane/Fastfile<br>lane :beta do<br>  # Firebase App Distribution에 업로드<br>  firebase_app_distribution(<br>    app: ENV[&quot;APP_ID&quot;],<br>    groups: &quot;internal-testers&quot;,<br>    release_notes: &quot;Automated build from CI&quot;<br>  )<br>end</pre><p><strong>3.2 인증서 관리</strong></p><p><strong>시행착오 5: iOS 인증서</strong></p><p>iOS 배포에서 가장 어려웠던 부분은 코드 서명이었습니다. 처음에는 인증서를 수동으로 관리했는데, 다음과 같은 문제가 발생했습니다:</p><p>- 팀원마다 다른 인증서 사용<br>- 인증서 만료 시 모든 기기에서 재설정 필요<br>- CI 환경에서 인증서 접근 불가</p><p><strong>해결책:</strong>Fastlane Match를 도입했습니다.</p><pre>- name: Install SSH key<br>  uses: shimataro/ssh-key-action@v2<br>  with:<br>    key: ${{ secrets.SSH_KEY }}<br>    known_hosts: ${{ secrets.KNOWN_HOSTS }}<br>    if_key_exists: replace<br><br>- name: Run Fastlane<br>  run: |<br>    cd Humania-iOS<br>    fastlane beta<br>  env:<br>    MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}</pre><p>Match는 인증서를 Git 저장소에 암호화하여 저장하고, 팀원 모두가 동일한 인증서를 사용할 수 있게 해줍니다.</p><p><strong>시행착오 6: Android Firebase 인증</strong></p><p>Android는 Firebase App Distribution을 사용하는데, 서비스 계정 인증이 필요했습니다. 처음에는 JSON 파일을 직접 저장소에 커밋했다가 보안 경고를 받았습니다.</p><p><strong>해결책</strong>: Base64 인코딩하여 GitHub Secrets에 저장했습니다.</p><pre>- name: Create Firebase Service Credentials file<br>  run: |<br>    cd Humania-android<br>    echo &quot;$FIREBASE_CREDENTIALS&quot; &gt; firebase_credentials.json.b64<br>    base64 -d -i firebase_credentials.json.b64 &gt; firebase_credentials.json<br>    rm firebase_credentials.json.b64<br>  env:<br>    FIREBASE_CREDENTIALS: ${{ secrets.FIREBASE_CREDENTIALS }}</pre><h3><strong>4. Comprehensive Code Review: 정기 검진</strong></h3><p><strong>4.1 왜 정기 검진의 필요성</strong></p><p>PR 단위 리뷰만으로는 충분하지 않았습니다. 시간이 지나면서 아래 문제들이 발생했습니다.</p><p>- 네이밍 컨벤션이 서서히 무너짐<br>- 플랫폼 간 기능 격차 발생<br>- 기술 부채 누적</p><p>이를 해결하기 위해 <strong>매주 토요일 자동으로 전체 코드베이스를 점검</strong>하는 워크플로우를 만들었습니다.</p><p><strong>4.2 Notion MCP: Claude의 새로운 능력</strong></p><p>여기서 저는 Claude-code-action에 Notion MCP를 연결하는 선택을 내리게 되었습니다.</p><blockquote>MCP(Model Context Protocol)는 Claude가 외부 시스템과 상호작용할 수 있게 해주는 프로토콜입니다. 이를 통해 Claude가 직접 Notion 데이터베이스에 접근 및 Task를 생성할 수 있게 되었습니다.</blockquote><pre>- name: Create MCP Config<br>  run: |<br>    cat &gt; /tmp/mcp-config.json &lt;&lt;EOF<br>    {<br>      &quot;mcpServers&quot;: {<br>        &quot;notion&quot;: {<br>          &quot;command&quot;: &quot;npx&quot;,<br>          &quot;args&quot;: [&quot;-y&quot;, &quot;@notionhq/notion-mcp-server@1.9.1&quot;],<br>          &quot;env&quot;: {<br>            &quot;NOTION_TOKEN&quot;: &quot;${{ secrets.NOTION_API_KEY }}&quot;<br>          }<br>        }<br>      }<br>    }<br>    EOF<br><br>- name: Run comprehensive review with Claude<br>  uses: anthropics/claude-code-action@v1<br>  with:<br>    claude_args: |<br>      --model claude-sonnet-4-5-20250929<br>      --mcp-config /tmp/mcp-config.json<br>      --allowedTools &quot;mcp__notion__API-create-pages,mcp__notion__API-post-page,...&quot;</pre><p>1. <strong>mcpServers</strong>: 사용할 MCP 서버 정의 (여기서는 Notion)<br>2. <strong>command</strong>: MCP 서버를 실행할 명령어 (npx로 Notion MCP 서버 실행)<br>3. <strong>args</strong>: 버전정보를 포함한 Notion MCP 서버 패키지 지정<br>4. <strong>env</strong>: Notion API 토큰 전달<br>5. <strong>— allowedTools</strong>: Claude가 사용할 수 있는 Notion API 명시</p><p><strong>각 권한의 역할</strong></p><p><strong><em>API-retrieve-a-database</em></strong><em>: 데이터베이스 구조 확인<br></em><strong><em>API-post-database-query</em></strong><em>: Task 중복 검사<br></em><strong><em>API-create-pages, </em></strong><strong><em>API-post-page</em></strong><em>: Task 생성<br></em><strong><em>API-retrieve-a-page</em></strong><em>, </em><strong><em>API-get-block-children</em></strong><em>: 기존 Task 확인<br></em><strong><em>API-patch-block-children</em></strong><em>, </em><strong><em>API-update-a-page</em></strong><em>, </em><strong><em>API-patch-page</em></strong><em>: Task 수정</em></p><p><strong>MCP의 역할과 동작 흐름</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/274/1*7_qNZJat8bVw1T22OKpf0Q.png" /></figure><p>특히 Claude가 MCP를 통해 생성하는 Task 내부에는 아래와 같은 정보를 포함하도록 프롬프팅하여, 추후 내부 정보를 그대로 복사하여 붙여넣기하는 것만으로 AI에게 Task 컨텍스트 전달이 가능하도록 했습니다.</p><p>- <strong>작업명</strong>: 무엇을 수정해야 하는지 명확히 표시<br>- <strong>상태</strong>: “AI로 생성됨”으로 설정하여 자동 생성된 Task임을 구분<br>- <strong>작업팀</strong>: iOS/Android 구분하여 책임 소재 명확화<br>- <strong>우선순위</strong>: 위반의 심각도에 따라 자동 설정<br>- <strong>상세내용</strong>: 파일 경로, 현재 코드, 권장 수정안을 포함한 구체적인 가이드</p><p>이렇게 생성된 Task는 Notion의 Kanban 보드에서 “AI로 생성됨” 상태로 나타나며, 팀원들이 검토 후 작업을 진행할 수 있습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AUmAybhnyu2jgi-IhTASzg.png" /></figure><h3><strong>5. 각 코드의 역할 정리</strong></h3><p>보안 규정으로 인해 전체 워크플로우 파일은 공유가 힘들다는 점 양해 부탁드립니다.</p><p><strong>Mobile CI 워크플로우 구조</strong></p><pre># 트리거: PR이 dev 브랜치에 열릴 때<br>on:<br>  pull_request:<br>    branches: [dev]<br>    types: [opened, reopened]<br><br>jobs:<br>  # Job 1: 빌드 및 테스트 (iOS)<br>  build-test-iOS:<br>    runs-on: self-hosted  # Mac Mini 러너 사용<br>    steps:<br>      - name: Run iOS build and test<br>        # Xcode의 xcodebuild 명령으로 빌드 및 테스트<br>        # - clean: 이전 빌드 산출물 삭제<br>        # - test: 유닛 테스트 실행<br>        # - -destination: 시뮬레이터 지정<br><br>  # Job 2: 빌드 및 테스트 (Android)<br>  build-test-android:<br>    runs-on: ubuntu-latest  # GitHub 호스팅 러너<br>    steps:<br>      - name: Setup Gradle<br>        # Gradle 캐싱으로 빌드 시간 단축<br>        # cache-read-only: false → 캐시 쓰기 허용<br><br>      - name: Run Android build and test<br>        # Fastlane을 통한 빌드<br>        # bundle exec: Ruby 번들러를 통해 의존성 격리<br>        # fastlane android build: Fastfile의 build 레인 실행<br><br>  # Job 3: Claude 코드 리뷰<br>  claude-code-review:<br>    needs: [build-test-iOS, build-test-android]  # 빌드 성공 후에만 실행<br>    steps:<br>      - name: Get Changed Files<br>        # git diff로 변경된 파일 목록 추출<br>        # base branch와 head branch 비교<br><br>      - name: Fetch Naming Convention from Notion<br>        # Notion API를 통해 최신 네이밍 컨벤션 가져오기<br>        # bash 스크립트로 마크다운 변환<br><br>      - uses: anthropics/claude-code-action@v1<br>        # Claude에게 코드 리뷰 요청<br>        # 1. 플랫폼 동기화 검토<br>        # 2. 코드 품질 검토<br>        # 3. 네이밍 컨벤션 검토<br>        # 결과를 PR 코멘트로 작성</pre><p><strong>Mobile CD 워크플로우 구조</strong></p><pre># 트리거: release-** 브랜치에 푸시될 때<br>on:<br>  push:<br>    branches:<br>      - release-**<br><br># 동시 실행 방지<br>concurrency:<br>  group: mobile-cd-${{ github.ref }}<br>  cancel-in-progress: false<br><br>jobs:<br>  # Job 1: Android 배포<br>  android-deploy:<br>    steps:<br>      - name: Create Firebase Service Credentials<br>        # GitHub Secrets에서 Firebase 인증 정보 복호화<br>        # Base64 디코딩하여 JSON 파일 생성<br><br>      - name: Run Fastlane<br>        # Firebase App Distribution에 APK 업로드<br>        # 내부 테스터 그룹에 자동 배포<br><br>  # Job 2: iOS 배포<br>  ios-deploy:<br>    runs-on: self-hosted  # Mac Mini 러너 사용<br>    steps:<br>      - name: Install SSH key<br>        # Match를 위한 SSH 키 설정<br>        # 암호화된 인증서 저장소 접근<br><br>      - name: Run Fastlane<br>        # 1. Match로 인증서 다운로드<br>        # 2. 빌드 번호 자동 증가<br>        # 3. IPA 파일 생성<br>        # 4. TestFlight에 업로드</pre><p><strong>Comprehensive Code Review 워크플로우 구조</strong></p><pre># 트리거: 매주 토요일 03:00 (UTC) / 12:00 (KST)<br>on:<br>  schedule:<br>    - cron: &#39;0 3 * * 6&#39;<br><br>jobs:<br>  # Job 1: 파일 수집<br>  collect-codebase-files:<br>    steps:<br>      - name: Collect all codebase files by layer<br>        # find 명령으로 레이어별 파일 목록 수집<br>        # Domain, Data, Presentation, Infrastructure<br>        # iOS는 .swift, Android는 .kt 파일<br><br>  # Job 2: 종합 리뷰 및 Notion Task 생성<br>  comprehensive-review-and-notion-task:<br>    needs: collect-codebase-files<br>    steps:<br>      - name: Fetch Naming Convention from Notion<br>        # 최신 네이밍 컨벤션 가져오기<br><br>      - name: Create MCP Config<br>        # Notion MCP 서버 설정<br>        # Claude가 Notion API를 사용할 수 있도록 구성<br><br>      - uses: anthropics/claude-code-action@v1<br>        # Claude에게 전체 코드베이스 리뷰 요청<br>        # 1. 네이밍 컨벤션 위반 사항 수집<br>        # 2. 플랫폼 동기화 미비점 수집<br>        # 3. 발견된 이슈를 Notion Task로 생성<br>        # MCP를 통해 직접 Notion에 작성</pre><h3><strong>6. 성과 배포 주기 안정화</strong></h3><p>기존의 경우, 기능 개발과 플랫폼간 코드 동기화 작업을 별도 Task로 분리하여 개발을 진행하였습니다. 이러한 순차적 진행방식은 자연스레 앱 배포 주기를 느리게 만들었으며, 하나의 기능을 구현하여 배포하기까지 총 3주의 시간이 걸렸습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PsuHk_1nIQEkDX12Rqhkgw.png" /></figure><p>하지만, CI를 통해 변경사항에 대한 플랫폼 동기화 여부를 자동 추적할 수 있게 됨에 따라, 기능 구현과 플랫폼 동기화를 병행하여 진행할 수 있게 되었으며, 배포 및 빌드 테스트 자동화 워크플로우로 인해 기능 구현에 대한 주기 또한 앞당길수 있게 되었습니다.</p><p><strong>이로 인해 업데이트 배포 주기를 3주에서 1주로 단축시키는 쾌거를 달성했습니다.</strong></p><h3><strong>7. 마무리하며</strong></h3><p>iOS와 Android를 동시에 관리하는 프로젝트에서는, <strong>플랫폼 간 동기화</strong>가 가장 큰 과제였습니다. Claude Code Action과 Notion MCP를 활용한 자동화를 통해 아래 성과를 거둘 수 있었습니다.</p><p>- 플랫폼 간 불일치를 자동으로 감지<br>- 네이밍 컨벤션 위반을 사전에 차단<br>- 반복적인 리뷰 작업을 자동화</p><h4><strong>얻은 교훈</strong></h4><p><strong>1. AI는 도구일 뿐</strong></p><p>Claude가 아무리 뛰어나도, 구체적인 지시사항 없이는 실제 문제를 해결해주지 않았습니다. 때문에, 정확한 프롬프팅을 통해 해결하고자 하는 문제에 대한 컨텍스트를 제공해야만 AI를 효과적으로 활용할 수 있었습니다.</p><p><strong>2. Single Source of Truth의 중요성</strong></p><p>Notion을 네이밍 컨벤션의 중앙 저장소로 사용하면서, 문서 관리가 획기적으로 개선되었습니다. 때문에, 협업 툴 수정만으로 모든 워크플로우가 자동 반영되어 유지보수 비용을 획기적으로 줄일 수 있었습니다.</p><p>감사합니다.</p><h3><strong>References</strong></h3><p>- [GitHub Actions Documentation](https://docs.github.com/en/actions)</p><p>- [Fastlane Documentation](https://docs.fastlane.tools/)</p><p>- [Claude Code Documentation](https://docs.anthropic.com/claude-code)</p><p>- [Notion API Documentation](https://developers.notion.com/)</p><p>- [Model Context Protocol (MCP)](https://modelcontextprotocol.io/)</p><p>- [Anthropic Claude Code Action](https://github.com/anthropics/claude-code-action)</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=27898668e828" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[[Jetpack Compose] Tap 동작을 소화하는 DnD 리스트 구현]]></title>
            <link>https://medium.com/@neoself1105/jetpack-compose-tap-%EB%8F%99%EC%9E%91%EC%9D%84-%EC%86%8C%ED%99%94%ED%95%98%EB%8A%94-dnd-%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EA%B5%AC%ED%98%84-6009c0890565?source=rss-18d63a54716d------2</link>
            <guid isPermaLink="false">https://medium.com/p/6009c0890565</guid>
            <dc:creator><![CDATA[NEON]]></dc:creator>
            <pubDate>Wed, 28 Jan 2026 04:57:42 GMT</pubDate>
            <atom:updated>2026-03-17T15:18:16.829Z</atom:updated>
            <content:encoded><![CDATA[<p>안녕하세요. 이번에는 Jetpack Compose에서 드래그 앤 드롭(DnD) 리스트를 구현하면서 마주쳤던 여러 가지 기술적 난관과 이에 대한 해결과정을 공유하고자 합니다.</p><h3>1. pointerInput vs dragAndDropTarget</h3><p>초기에는pointerInput의 detectDragGestures를 사용하여 DnD 콜백을 주입시키고자 했습니다. 하지만, <strong>수정자(Modifier)가 드래그를 가로채는 순간, 해당 Row의 Tap(클릭) 동작이 먹히지 않는 이슈</strong>를 마주하게 되었습니다.</p><p>저희는 아이템을 클릭해서 상세 바텀시트롤 표시하는 기능과, 길게 눌러 순서를 바꾸는 기능이 공존해야 했습니다. 이를 위해 Compose 1.7에서 도입된 정식 DnD API인 dragAndDropSource와 dragAndDropTarget을 활용하기로 했습니다.</p><h3>2. onGloballyPositioned 활용</h3><p>DragAndDropEvent에서 반환하는 y 값을 기준으로 Drop되는 index값을 계산하는 로직을 구현한 결과, 아래와 같이 계산된 index가 조금씩 어긋나는 현상이 발생했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*ueL5SDX8gnUsv_ifewZNKg.gif" /><figcaption>커서가 있는 위치보다 1칸 더 뒤로 drop이 되고 있습니다.</figcaption></figure><p>원인은 DragAndDropEvent에 있었습니다.</p><p><a href="https://developer.android.com/reference/kotlin/android/view/DragEvent#getX()">DragEvent | API reference | Android Developers</a></p><p>Android 개발자 문서를 확인해 보면, DragEvent.getX() / getY()가 반환하는 값은 <strong>전체 화면(Window) 기준</strong>입니다. 하지만 우리가 리스트의 인덱스를 계산하기 위해 필요한 값은 <strong>Box(컨테이너) 내부의 로컬 좌표</strong>입니다.</p><p>이를 해결하기 위해 onGloballyPositioned 수정자를 통해 컨테이너의 절대 위치를 지속적으로 추적하여, index 추적에 필요한 y값을 후가공하기로 했습니다.</p><pre>// containerTopOffset: onGloballyPositioned에서 업데이트되는 원본 상태<br>var containerTopOffset by remember { mutableFloatStateOf(0f) }<br>// currentContainerTopOffset: remember 블록 내에서 최신 값을 참조하기 위한 래퍼<br>val currentContainerTopOffset by rememberUpdatedState(containerTopOffset)<br><br>Box(<br>    modifier = Modifier<br>        .onGloballyPositioned { coordinates -&gt;<br>            // Window 기준 Y 좌표 수집<br>            containerTopOffset = coordinates.positionInRoot().y<br>        }<br>        ...<br>        .dragAndDropTarget(<br>            shouldStartDragAndDrop = { event -&gt;<br>              ...<br>            },<br>            target = remember {<br>                object : DragAndDropTarget {<br>                    override fun onEntered(event: DragAndDropEvent) {<br>                        val y = event.toAndroidDragEvent().y - currentContainerTopOffset</pre><p><a href="https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/LayoutCoordinates">LayoutCoordinates | API reference | Android Developers</a></p><h4>3. 왜 내 Handler는 과거의 데이터만 기억할까…?</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*cDZ6xYUtD4pL2pSyD5zSrw.gif" /></figure><p>구현 중 가장 난해했던 이슈는 <strong>리스트가 재정렬되었음에도 불구하고, 드래그를 시작한 아이템이 엉뚱한 ID를 참조하는 현상</strong>이었습니다.</p><p>드래그되는 컴포저블의 식별자가 제때 갱신되지 않는 것이 원인이라고 가정하고, 아래와 같이 longPress 시 호출되는 콜백이 위치한 dragAndDropSource 내부에 Log를 삽입하여 item.id가 리스트 재정렬 시점에 맞춰 갱신되는지 확인해보고자 했습니다.</p><pre>Column(Modifier<br>    .fillMaxSize()<br>    .verticalScroll(scrollState)) {<br>    items.forEachIndexed { index, item -&gt;<br>        DemoItemRow(<br>            modifier = Modifier<br>                .height(rowHeight)<br>                .dragAndDropSource {<br>                    Log.d(&quot;디버깅&quot;, &quot;Handler invoked: item.id ${item.id}&quot;)<br>                    detectTapGestures(<br>                        onTap = { Log.d(&quot;디버깅&quot;,&quot;item: item.id ${item.id}&quot;) },<br>                        onLongPress = {<br>                            startTransfer(<br>                                transferData = DragAndDropTransferData(<br>                                    clipData = ClipData.newPlainText(<br>                                        &quot;demo/item-id&quot;,<br>                                        item.id.toString()<br>                                    )<br>                                )<br>                            )<br>                        }<br>                    )<br>                },<br>            item = item,<br>            isEditMode = true<br>        )<br>    }<br>}<br><br>fun DemoItemRow(<br>    modifier: Modifier = Modifier,<br>    item: DemoItem,<br>    isEditMode: Boolean = true<br>) {<br>    SideEffect {<br>        Log.d(&quot;디버깅&quot;, &quot;Row recomposed: id=${item.id}&quot;)<br>    }<br><br>    Row(<br>        modifier = modifier<br>            .fillMaxWidth()<br>            .background(MaterialTheme.colorScheme.surface)<br>            .padding(horizontal = 16.dp, vertical = 12.dp),<br>        verticalAlignment = Alignment.CenterVertically<br>    ) {</pre><pre>디버깅 D  Handler invoked: item.id 1 # 각 Row에 대해 첫 상호작용 시에 핸들러 호출<br>디버깅 D  item: item.id 1 # 1번째 Row Tap<br>디버깅 D  Row recomposed: id=2 # 1번째를 2번째 Row 뒤로 Drag and Drop<br>디버깅 D  Row recomposed: id=1<br># 첫번째 Row Tap, 본래 드래그앤드롭이 원활히 실행되려면 이때 Handler가 다시 등록되어야함.<br>디버깅 D  item: item.id 1 # 이전 Handler가 다시 호출되며 다시 1번째 Row Tap 시 1이 출력됨</pre><p>Drag and Drop 시, DemoItemRow 자체는 재구성이 발생하는 반면에, 위치 변경 후, 1번째로 위치가 이동된 2번째 Row를 탭하여도, 처음 초기화 당시 해당 위치에 있었던 1번째 Row의 id가 출력되고 있었습니다.</p><p>소스 코드를 분석해 본 결과, dragAndDropSource 내부의 DragAndDropSourceNode 구현에서 원인을 찾을 수 있었습니다.</p><pre>fun Modifier.dragAndDropSource(<br>    block: suspend DragAndDropSourceScope.() -&gt; Unit<br>): Modifier = this then DragAndDropSourceWithDefaultShadowElement( // 주목<br>    dragAndDropSourceHandler = block,<br>)<br><br>// 1. DragAndDropSourceWithDefaultShadowElement<br>private class DragAndDropSourceWithDefaultShadowElement(<br>    val dragAndDropSourceHandler: suspend DragAndDropSourceScope.() -&gt; Unit // 주목<br>) : ModifierNodeElement&lt;DragSourceNodeWithDefaultPainter&gt;() {<br>    // dragAndDropSourceHandler를 DragSourceNodeWithDefaultPainter에 담아 상호작용 콜백 관리<br>    override fun create() = DragSourceNodeWithDefaultPainter(<br>        dragAndDropSourceHandler = dragAndDropSourceHandler,<br>    )<br><br>    override fun update(node: DragSourceNodeWithDefaultPainter) = with(node) {<br>        dragAndDropSourceHandler =<br>            this@DragAndDropSourceWithDefaultShadowElement.dragAndDropSourceHandler<br>    }<br>    ...<br>}<br><br>// 2. DragSourceNodeWithDefaultPainter<br>private class DragSourceNodeWithDefaultPainter(<br>    var dragAndDropSourceHandler: suspend DragAndDropSourceScope.() -&gt; Unit<br>) : DelegatingNode() {<br><br>    init {<br>        val cacheDrawScopeDragShadowCallback = CacheDrawScopeDragShadowCallback().also {<br>            delegate(CacheDrawModifierNode(it::cachePicture))<br>        }<br>        delegate(<br>            DragAndDropSourceNode( // 주목<br>                drawDragDecoration = {<br>                    cacheDrawScopeDragShadowCallback.drawDragShadow(this)<br>                },<br>                dragAndDropSourceHandler = {<br>                    dragAndDropSourceHandler.invoke(this)<br>                }<br>            )<br>        )<br>    }<br>}<br><br>internal class DragAndDropSourceNode(<br>    var drawDragDecoration: DrawScope.() -&gt; Unit,<br>    var dragAndDropSourceHandler: suspend DragAndDropSourceScope.() -&gt; Unit<br>) : DelegatingNode(),<br>    LayoutAwareModifierNode {<br>    private var size: IntSize = IntSize.Zero<br><br>    // init 블록은 한번만 실행되게 보장<br>    init {<br>        val dragAndDropModifierNode = delegate(<br>            DragAndDropModifierNode()<br>        )<br><br>        delegate(<br>            SuspendingPointerInputModifierNode {<br>                // node가 attach될 때, 한번 실행되고 이후 suspend 상태 유지<br>                dragAndDropSourceHandler(...)  // 상호작용 콜백<br>            }<br>        )<br>    }<br><br>    override fun onRemeasured(size: IntSize) {<br>        this.size = size<br>    }<br>}</pre><p>dragAndDropSource는 내부적으로 SuspendingPointerInputModifierNode를 사용합니다.</p><p>문제는 <strong>init 블록 내에서 실행된 핸들러가 최초 실행 시점의 상태(Stale State)를 캡처</strong>한다는 점입니다. 리스트의 순서가 바뀌어 노드가 업데이트되어도, 내부의 코루틴이 유지되고 있다면 과거의 item.id를 계속 쥐고 있게 됩니다. 때문에, dragAndDropSource 수정자 block 내부에서 캡처한 값들은 node가 업데이트 되어도 block은 최초 실행 시점의 값을 계속 유지하게 됩니다.</p><p><strong>해결책: </strong><strong>key를 통한 노드 재생성 강제</strong></p><p>이 문제를 해결하기 위해, Compose의 key를 사용하여 identity를 명확히 부여하여 노드를 <strong>업데이트하는 것이 아니라 재생성</strong>하기로 했습니다. 이때 key를 어디에 선언하느냐에 따라 성능이 좌우되는 것을 확인할 수 있었습니다.</p><p><strong>Case A: </strong>전체 Column에 key(items) 적용 (권장하지 않음)</p><p>리스트가 바뀔 때마다 Column 전체를 다시 그립니다. 모든 상태(스크롤 위치 등)가 파괴됩니다.</p><pre>key(items) {<br>    Column {<br>        items.forEachIndexed { index, item -&gt;<br>            DemoItemRow(...)  // 모든 remember 상태 초기화<br>        }<br>    }<br>}<br><br>@Composable<br>private fun DemoItemRow(item: DemoItem) {<br>    SideEffect {<br>        Log.d(&quot;디버깅&quot;, &quot;Row recomposed: id=${item.id}&quot;)<br>    }<br>    Row(</pre><pre>D  Row recomposed: id=1<br>D  Row recomposed: id=2<br>D  Row recomposed: id=3<br>D  Row recomposed: id=4<br>D  Row recomposed: id=5<br>D  Row recomposed: id=6<br>D  Row recomposed: id=7<br>D  Row recomposed: id=8<br>D  Row recomposed: id=9<br>D  Row recomposed: id=10<br>D  Row recomposed: id=11<br>D  Row recomposed: id=12<br>D  Row recomposed: id=13<br>D  Row recomposed: id=14<br>D  Row recomposed: id=15<br>D  Row recomposed: id=16<br>D  Row recomposed: id=17<br>D  Row recomposed: id=18<br>D  Row recomposed: id=19<br>D  Row recomposed: id=20 # 초기 구성 작업 완료<br># 드래그앤 드롭 수행<br>D  Row recomposed: id=2 <br>D  Row recomposed: id=1<br>D  Row recomposed: id=3<br>D  Row recomposed: id=4<br>D  Row recomposed: id=5<br>D  Row recomposed: id=6<br>D  Row recomposed: id=7<br>D  Row recomposed: id=8<br>D  Row recomposed: id=9<br>D  Row recomposed: id=10<br>D  Row recomposed: id=11<br>D  Row recomposed: id=12<br>D  Row recomposed: id=13<br>D  Row recomposed: id=14<br>D  Row recomposed: id=15<br>D  Row recomposed: id=16<br>D  Row recomposed: id=17<br>D  Row recomposed: id=18<br>D  Row recomposed: id=19<br>D  Row recomposed: id=20 # 드래그 앤드롭 시에도, 모든 Row가 재구성되고 있음!</pre><p>items 리스트 변경 시, key가 변경됨에 따라, Column 전체가 새 Composable로 인식되며 모든 remember 상태 파괴 및 재생성됩니다. 이 과정에서 스크롤 위치, 애니메이션 상태가 모두 초기화됩니다.</p><p><strong>Case B: </strong>각 Row에 key(item.id) 적용 (최적)</p><p>리스트 내에서 아이템의 위치가 바뀌어도 각 Row는 고유한 identity를 유지합니다. <strong>상태가 이동(Move)되면서 Modifier Node가 새로 등록</strong>되어 Stale 데이터 문제가 해결됩니다.</p><pre>Column(Modifier.verticalScroll(scrollState)) {<br>    items.forEachIndexed { index, item -&gt;<br>        key(item.id) { // Row별 identity 부여로 노드 재생성 유도<br>            DemoItemRow(...) <br>        }<br>    }<br>}</pre><pre>D  Row recomposed: id=1<br>D  Row recomposed: id=2<br>D  Row recomposed: id=3<br>D  Row recomposed: id=4<br>D  Row recomposed: id=5<br>D  Row recomposed: id=6<br>D  Row recomposed: id=7<br>D  Row recomposed: id=8<br>D  Row recomposed: id=9<br>D  Row recomposed: id=10<br>D  Row recomposed: id=11<br>D  Row recomposed: id=12<br>D  Row recomposed: id=13<br>D  Row recomposed: id=14<br>D  Row recomposed: id=15<br>D  Row recomposed: id=16<br>D  Row recomposed: id=17<br>D  Row recomposed: id=18<br>D  Row recomposed: id=19<br>D  Row recomposed: id=20 # 초기 구성 작업 완료<br># 드래그앤드롭 실행<br># 더이상 재구성이 발생하지 않음</pre><h4>3. LazyColumn을 포기한 이유: 강제 스크롤 Glitch</h4><p>효율적인 리스트 렌더링을 위해 처음에는 LazyColumn을 선택했습니다. 허나 , 이와 관련하여 치명적인 UX 이슈를 발견했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*fxWbJcfQsR-G_IvUQttihQ.gif" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*TwVDZBFo2Ln2ubkMCrBNqg.gif" /></figure><ol><li><strong>상단 이동 시 튐 현상</strong>: 첫 번째 요소를 아래로 드래그하면, 이동된 위치에 맞춰 화면의 scrollOffset이 강제로 변경되었습니다.</li><li><strong>아이템 가려짐</strong>: 첫 번째 요소 위로 다른 아이템을 드래그할 경우, 아이템이 화면 밖으로 가려지는 현상이 발생했습니다.</li></ol><p>원인은 LazyListState 내부의 updateScrollPositionIfTheFirstItemWasMoved 메서드 때문이었습니다. 아이템의 ID(key)가 유지된 채 인덱스만 바뀌면, Compose는 사용자가 보고 있는 첫 번째 아이템을 유지하려 스크롤을 강제로 조정합니다.</p><p><a href="https://stackoverflow.com/questions/74640293/how-to-prevent-lazycolumn-from-autoscrolling-if-the-first-item-was-moved/74640653#74640653">How to prevent LazyColumn from autoscrolling if the first item was moved</a></p><p><strong>1차 해결 시도: scrollToItem 보정 (실패)</strong></p><pre>override fun onDrop(event: DragAndDropEvent): Boolean {<br>    // ... 아이템 데이터 추출 로직 ...<br>    <br>    val y = event.toAndroidDragEvent().y<br>    val at = computeTargetIndex(y)<br>    val currentIndex = items.indexOfFirst { it.id == draggedId }<br>    // 이동 후 예상되는 인덱스 계산<br>    val predictedInsertIndex = if (currentIndex != -1) {<br>        if (currentIndex &lt; at) (at - 1).coerceIn(0, items.size - 1)<br>        else at.coerceIn(0, items.size - 1)<br>    } else at<br>    // 실제 아이템 이동 콜백 실행<br>    moveItem(droppedItem, at)<br>    // 드롭 완료 후 해당 위치로 강제 스크롤 보정<br>    scrollScope.launch {<br>        if (items.isNotEmpty()) {<br>            listState.animateScrollToItem(<br>                index = predictedInsertIndex,<br>                scrollOffset = 0<br>            )<br>        }<br>    }<br>    return true<br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*Txw8JE56MDR_GeEXoGimcw.gif" /></figure><p>드롭 직후 scrollToItem을 호출하여 오프셋을 재조정하려 했으나, 드롭 직후 화면이 glitch되는 현상이 발생해, 리스트가 재정렬되었는지 사용자가 혼란을 느끼는 결과를 초래했습니다.</p><p><strong>최종 해결: Column 전환과 커스텀 애니메이션</strong></p><p>결국 성능과 UX 사이의 트레이드오프 끝에, <strong>LazyColumn을 포기하고 Column + verticalScroll 조합</strong>으로 선회했습니다. 제가 타겟팅하는 화면에서는 아이템 수의 상한선이 정해져있었기에 가능한 결정이었습니다.</p><h3>4. 최종구현</h3><p>기본적인 DnD기능을 구현한 후에는 재사용이 가능한 CustomDraggableList로 구현하였습니다. SwiftUI와 달리 Compose는 양방향 데이터 흐름이 기본이 아니기에, 상태 변경을 외부로 위임하는 콜백 구조를 설계했습니다.</p><ul><li>Draggable 인터페이스를 상속받은 어떤 데이터 모델이든 수용 가능하도록 <strong>제네릭 타입 T </strong>을 사용토록<strong> </strong>설계했습니다.</li><li>아이템과 목적지 인덱스를 전달하여 원본 리스트를 직접 수정할 수 있도록 <strong>onReorder 콜백</strong>을 추가했습니다.</li><li>리스트 상/하단에 유연하게 header와 footer를 삽입할 수 있도록 범용성을 증대했습니다.</li></ul><pre>/**<br> * @param items 리스트에 표시할 아이템 목록<br> * @param rowHeight 각 행의 고정 높이<br> * @param isDragEnabled 드래그 기능 활성화 여부<br> * @param onReorder 아이템 재정렬 완료 시 호출되는 콜백 (item, insertIndex)<br> */<br>@Composable<br>fun &lt;T : Draggable&gt; CustomDraggableList(<br>    items: List&lt;T&gt;,<br>    rowHeight: Dp,<br>    isDragEnabled: Boolean,<br>    onReorder: (T, Int) -&gt; Unit,<br>    onTapRow: (T) -&gt; Unit,<br>    itemContent: @Composable (T, Boolean) -&gt; Unit,<br>    header: @Composable (() -&gt; Unit)? = null,<br>    footer: @Composable (() -&gt; Unit)? = null,<br>    listState: LazyListState = rememberLazyListState(),<br>    modifier: Modifier = Modifier<br>) { ... }</pre><h4><strong>애니메이션 구현</strong></h4><ol><li><strong>Row 재정렬 애니메이션</strong></li></ol><p>Column은 LazyColumn의 Modifier.animateItem()을 지원하지 않습니다. 이를 해결하기 위해 Animatable과 graphicsLayer를 조합하여 직접 인덱스 변경에 따른 이동 애니메이션을 구현했습니다.</p><pre>items.forEachIndexed { index, item -&gt;<br>    key(item.id) {<br>        var previousIndex by remember { mutableIntStateOf(index) }<br>        val offsetY = remember { Animatable(0f) }<br>        LaunchedEffect(index) {<br>            if (previousIndex != index) {<br>                // 이전 인덱스와 현재 인덱스 차이를 계산하여 델타값 도출<br>                val delta = (previousIndex - index) * (rowHeightPx + gapPx)<br>                offsetY.snapTo(delta)<br>                // 0(새로운 위치)으로 부드럽게 복귀<br>                offsetY.animateTo(0f, spring(dampingRatio = 0.8f, stiffness = 300f))<br>                previousIndex = index<br>            }<br>        }<br>        Column(Modifier.graphicsLayer { translationY = offsetY.value }) {<br>            itemContent(item, isDragging)<br>        }<br>    }<br>}</pre><p><strong>2. 드롭 인디케이터 표시/미표시 애니메이션</strong></p><p>애니메이션을 구현하면서 가장 고민했던 부분은 <strong>“어떻게 하면 아이템이 밀려나고 들어가는 과정이 iOS처럼 자연스러울까?”</strong> 하는 점이었습니다. 이 과정에서 SwiftUI와 Jetpack Compose의 애니메이션 구동 로직 차이를 명확히 체감할 수 있었습니다.</p><p><strong>SwiftUI: 선언적 ‘결과’에 대한 자동 보간</strong></p><p>SwiftUI에서는 .animation(.spring(), value: items) 코드 한 줄이면 충분합니다. 시스템이 뷰의 이전 상태와 다음 상태를 비교하여, <strong>뷰가 차지하는 영역(Layout Area)의 변화를 포함한 모든 요소</strong>에 대해 알아서 보간법을 적용해 줍니다.</p><p><strong>Jetpack Compose: 명시적 ‘전환’의 설계</strong></p><p>반면 Jetpack Compose는 애니메이션 내용을 개발자가 <strong>명시적으로 선언</strong>해야 합니다. 단순히 “값이 바뀌었으니 알아서 그려줘”가 아니라, “이 영역이 나타날 때 어떤 물리적 특성을 가질 것인가”를 지정해야 합니다.</p><ul><li><strong>fadeIn / </strong><strong>fadeOut의 한계</strong>: 단순히 투명도만 조절할 경우, 드롭 인디케이터가 생성될 때 주변 아이템들이 즉각적으로 위치를 이동해 버려 화면이 툭툭 끊기는 느낌을 줍니다.</li><li><strong>expandVertically / </strong><strong>shrinkVertically의 활용</strong>: 드롭 인디케이터에 이 보간법을 적용하면, 실제 뷰의 높이값이 0에서부터 스프링 물리 법칙에 따라 점진적으로 증가합니다. 이때 Compose의 레이아웃 엔진은 변화하는 높이를 실시간으로 측정하여 하위 아이템들을 부드럽게 밀어냅니다.</li></ul><pre>val transitionDuration = 120<br>AnimatedVisibility(<br>    visible = targetedDropIndex == index,<br>    // fadeIn보다 expandVertically를 사용하여 영역 할당 자체를 애니메이션화<br>    enter = expandVertically(tween(transitionDuration)), <br>    exit = shrinkVertically(tween(transitionDuration)),<br>) {<br>    dropIndicator()<br>}</pre><p>결국 Jetpack Compose에서의 DnD는 단순한 위치 이동을 넘어, <strong>레이아웃의 변화를 어떻게 프레임 단위로 쪼개어 보여줄 것인가</strong>에 대한 설계가 핵심이었습니다. 명시적으로 선언해야 한다는 점은 번거로울 수 있지만, 반대로 말하면 물리적인 디테일을 개발자가 완벽하게 통제할 수 있다는 강력한 장점이 됩니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*QBey7SzV1rXRt24AyjlVRA.gif" /></figure><p><strong>3. Tap 피드백 애니메이션</strong></p><p>DnD 기능 구현 과정에서 또 하나 중요한 UX 개선 포인트를 발견했습니다. 바로 <strong>일반 Tap과 Long Press 사이의 피드백 문제</strong>였습니다.</p><p>dragAndDropSource 내부의 detectTapGestures는 사용자가 LongPress를 수행할 때까지 아무런 시각적 피드백을 제공하지 않습니다. 때문에, 사용자의 탭 동작이 드래그로 이어지고 있는지를 0.3초가 지나서야 알수 있다는 한계가 존재했습니다.</p><p><strong>해결책: 탭 시작 즉시 피드백 제공</strong></p><p>이를 해결하기 위해, 이전에 구현한 탭 애니메이션 ShrinkWithGrayBackground 스타일을 리스트 아이템에 동일하게 적용하였습니다.</p><p><a href="https://medium.com/@neoself1105/jetpack-compose-%EC%9A%B0%EC%95%84%ED%95%9C-%EB%B2%84%ED%8A%BC-%ED%83%AD-%EC%95%A0%EB%8B%88%EB%A9%94%EC%9D%B4%EC%85%98-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-1dd3c19fc7ab">[Jetpack Compose] 우아한 버튼 탭 애니메이션 구현하기</a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*uE1VYcxCIruGcUgcaA-wag.gif" /></figure><p><strong>최종 코드</strong></p><pre>/**<br> * 드래그 앤 드롭으로 아이템 순서를 변경할 수 있는 리스트 컴포넌트.<br> *<br> * 주요 기능:<br> * - Android의 Drag and Drop API를 사용하여 네이티브 드래그 동작을 지원합니다.<br> * - 드래그 시 해당 행이 [Primary10] 배경으로 하이라이트되고, 햅틱 피드백이 제공됩니다.<br> * - 드래그 중 리스트 상하단에 도달하면 자동 스크롤이 활성화됩니다.<br> * - 아이템 순서 변경 시 spring 애니메이션으로 부드러운 전환 효과가 적용됩니다.<br> * - [isDragEnabled]가 false이면 드래그가 비활성화되고 탭만 동작합니다.<br> *<br> * 아이템은 [Draggable] 인터페이스를 구현해야 하며, [id] 프로퍼티로 각 아이템을 식별합니다.<br> *<br> * @param T [Draggable]을 구현하는 아이템 타입.<br> * @param modifier 리스트에 적용할 Modifier.<br> * @param items 리스트에 표시할 아이템 목록.<br> * @param rowHeight 각 행의 고정 높이. 드래그 위치 계산에 사용됩니다.<br> * @param isDragEnabled 드래그 기능 활성화 여부<br> * @param onReorder 아이템 재정렬 완료 시 호출되는 콜백 (item, targetIndex)<br> * @param onTapRow 행 탭 시 호출되는 콜백<br> * @param itemContent 각 아이템의 컨텐츠를 생성하는 composable (item, isDragging)<br> * @param header 리스트 상단에 표시할 헤더 컴포저블<br> * @param footer 리스트 하단에 표시할 푸터 컴포저블<br> * @param scrollState ScrollState (외부에서 주입 가능)<br> * @param modifier Modifier<br> */<br>interface Draggable {<br>    val id: Int<br>}<br><br>@OptIn(ExperimentalFoundationApi::class)<br>@Composable<br>fun &lt;T : Draggable&gt; NeoDraggableList(<br>    modifier: Modifier = Modifier,<br>    items: List&lt;T&gt;,<br>    rowHeight: Dp,<br>    isDragEnabled: Boolean,<br>    onReorder: (T, Int) -&gt; Unit,<br>    onTapRow: (T) -&gt; Unit,<br>    itemContent: @Composable (T, Boolean) -&gt; Unit,<br>    header: @Composable (() -&gt; Unit)? = null,<br>    footer: @Composable (() -&gt; Unit)? = null<br>) {<br>    val scrollState = rememberScrollState()<br>    val density = LocalDensity.current<br>    val hapticFeedback = LocalHapticFeedback.current<br>    val overallHeightPx = with(density) { (rowHeight + DROP_INDICATOR_HEIGHT + DROP_INDICATOR_ROW_GAP).toPx() }<br><br>    val currentItems by rememberUpdatedState(items)<br>    val currentOnReorder by rememberUpdatedState(onReorder)<br><br>    // 드롭 타겟 인덱스 (items 리스트 기준, 0..items.size)<br>    var targetedDropIndex by remember { mutableStateOf&lt;Int?&gt;(null) }<br>    var prevTargetedDropIndex by remember { mutableStateOf&lt;Int?&gt;(null) }<br><br>    // 자동 스크롤 속도<br>    var autoScrollVelocity by remember { mutableFloatStateOf(0f) }<br><br>    // 마지막 드래그 Y 좌표 (Window 기준)<br>    var lastDragY by remember { mutableStateOf&lt;Float?&gt;(null) }<br><br>    // 드래그 중인 아이템 ID 추적 (시각적 피드백용)<br>    var draggingItemId by remember { mutableStateOf&lt;Int?&gt;(null) }<br><br>    // 컨테이너의 Window 기준 Y 오프셋 및 높이<br>    var containerTopOffset by remember { mutableFloatStateOf(0f) }<br>    var containerHeight by remember { mutableFloatStateOf(0f) }<br><br>    // 헤더 높이 (computeTargetIndex에서 사용)<br>    var headerHeightPx by remember { mutableFloatStateOf(0f) }<br><br>    // ========================================<br>    // 자동 스크롤 속도 계산<br>    // ========================================<br>    fun updateAutoScrollVelocity() {<br>        val y = lastDragY<br>        if (y == null) {<br>            autoScrollVelocity = 0f<br>            return<br>        }<br>        val toTop = y - containerTopOffset<br>        val toBottom = (containerTopOffset + containerHeight) - y<br><br>        val scrollThresholdPx = with(density) { SCROLL_THRESHOLD_DP.toPx() }<br>        val headerThresholdPx = with(density) { HEADER_THRESHOLD_DP.toPx() }<br><br>        autoScrollVelocity = when {<br>            toTop &lt; (scrollThresholdPx + headerThresholdPx) -&gt; -SCROLL_VELOCITY<br>            toBottom &lt; scrollThresholdPx -&gt; SCROLL_VELOCITY<br>            else -&gt; 0f<br>        }<br>    }<br><br>    // ========================================<br>    // 프레임 동기화 자동 스크롤 루프<br>    // ========================================<br>    LaunchedEffect(scrollState) {<br>        var lastTime = 0L<br>        while (isActive) {<br>            val dtNanos = withFrameNanos { now -&gt;<br>                val dt = if (lastTime == 0L) 0L else now - lastTime<br>                lastTime = now<br>                dt<br>            }<br>            val v = autoScrollVelocity<br>            if (v != 0f &amp;&amp; dtNanos &gt; 0L) {<br>                val dtSec = dtNanos / 1_000_000_000f<br>                val delta = v * dtSec<br>                val consumed = scrollState.scrollBy(delta)<br>                if (abs(consumed) &lt; 0.5f) {<br>                    autoScrollVelocity = 0f<br>                }<br>            }<br>        }<br>    }<br><br>    LaunchedEffect(targetedDropIndex) {<br>        if (prevTargetedDropIndex != targetedDropIndex &amp;&amp; targetedDropIndex != null) {<br>            hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)<br>        }<br>        prevTargetedDropIndex = targetedDropIndex<br>    }<br><br>    // ========================================<br>    // 드롭 타겟 인덱스 계산 (items 리스트 기준 인덱스 반환)<br>    //<br>    // Window Y → 컨테이너 로컬 Y → 스크롤 보정한 절대 Y → 헤더 제외 → rowHeight로 인덱스 계산<br>    // ========================================<br>    fun computeTargetIndex(y: Float): Int {<br>        return DraggableListLogic.computeTargetSlot(<br>            windowY = y,<br>            containerTopOffset = containerTopOffset,<br>            scrollOffset = scrollState.value,<br>            headerHeightPx = headerHeightPx,<br>            rowSlotHeightPx = overallHeightPx,<br>            itemCount = currentItems.size<br>        )<br>    }<br><br>    fun moveItem(item: T, targetItemsIndex: Int) {<br>        val currentIndex = currentItems.indexOfFirst { it.id == item.id }<br>        if (currentIndex == -1) return<br>        val insertIndex = DraggableListLogic.computeInsertIndex(<br>            currentIndex = currentIndex,<br>            targetSlot = targetItemsIndex,<br>            itemCount = currentItems.size<br>        )<br>        currentOnReorder(item, insertIndex)<br>    }<br><br>    // region SubComponent<br>    @Composable fun dropIndicator() {<br>        Box(<br>            modifier = Modifier<br>                .fillMaxWidth()<br>                .height(DROP_INDICATOR_HEIGHT)<br>                .background(Primary10, RoundedCornerShape(4.dp))<br>        )<br>    }<br>    Box(<br>        modifier = modifier<br>            .fillMaxSize()<br>            .onGloballyPositioned { coordinates -&gt;<br>                containerTopOffset = coordinates.positionInRoot().y<br>                containerHeight = coordinates.size.height.toFloat()<br>            }<br>            .dragAndDropTarget(<br>                shouldStartDragAndDrop = { event -&gt;<br>                    event.mimeTypes().contains(ClipDescription.MIMETYPE_TEXT_PLAIN)<br>                },<br>                target = remember {<br>                    object : DragAndDropTarget {<br>                        override fun onEntered(event: DragAndDropEvent) {<br>                            val y = event.toAndroidDragEvent().y<br>                            lastDragY = y<br>                            targetedDropIndex = computeTargetIndex(y)<br>                        }<br><br>                        override fun onMoved(event: DragAndDropEvent) {<br>                            val y = event.toAndroidDragEvent().y<br>                            lastDragY = y<br>                            updateAutoScrollVelocity()<br>                            targetedDropIndex = computeTargetIndex(y)<br>                        }<br><br>                        override fun onExited(event: DragAndDropEvent) {<br>                            autoScrollVelocity = 0f<br>                            lastDragY = null<br>                            targetedDropIndex = null<br>                            draggingItemId = null<br>                        }<br><br>                        override fun onEnded(event: DragAndDropEvent) {<br>                            autoScrollVelocity = 0f<br>                            lastDragY = null<br>                            targetedDropIndex = null<br>                            draggingItemId = null<br>                        }<br><br>                        override fun onDrop(event: DragAndDropEvent): Boolean {<br>                            val text = event.toAndroidDragEvent().clipData<br>                                ?.getItemAt(0)?.text?.toString()<br>                            val draggedId = text?.toIntOrNull() ?: return false<br>                            val droppedItem = currentItems.firstOrNull { it.id == draggedId } ?: return false<br><br>                            val dropSlot = targetedDropIndex ?: computeTargetIndex(event.toAndroidDragEvent().y)<br>                            moveItem(droppedItem, dropSlot)<br>                            targetedDropIndex = null<br>                            draggingItemId = null<br>                            return true<br>                        }<br>                    }<br>                }<br>            )<br>    ) {<br>        Column(<br>            modifier = Modifier<br>                .fillMaxSize()<br>                .verticalScroll(scrollState),<br>            verticalArrangement = Arrangement.spacedBy(0.dp)<br>        ) {<br>            header?.let {<br>                Box(Modifier.onGloballyPositioned { coordinates -&gt;<br>                    headerHeightPx = coordinates.size.height.toFloat()<br>                }<br>                ) { it() }<br>            }<br><br>            items.forEachIndexed { index, item -&gt;<br>                key(item.id) {<br>                    // 리오더 애니메이션: 인덱스 변경 시 이전 위치에서 새 위치로 슬라이드<br>                    var previousIndex by remember { mutableIntStateOf(index) }<br>                    val offsetY = remember { Animatable(0f) }<br><br>                    LaunchedEffect(index) {<br>                        if (previousIndex != index) {<br>                            // index * Row 높이로 하여 애니메이션이 진행될 offsetPx을 계산<br>                            val delta = (previousIndex - index) * overallHeightPx<br>                            // 기존 변경된 위치에서,<br>                            offsetY.snapTo(delta)<br>                            // 새로 변경된 위치로 애니메이션 진행<br>                            offsetY.animateTo(<br>                                targetValue = 0f,<br>                                animationSpec = spring(<br>                                    dampingRatio = 0.8f,<br>                                    stiffness = 300f<br>                                )<br>                            )<br>                            previousIndex = index<br>                        }<br>                    }<br><br>                    val isLast = index==(items.size-1)<br><br>                    // 눌림 효과를 위한 scale 애니메이션<br>                    val scale = remember { Animatable(1f) }<br>                    // Press 시 배경 alpha 애니메이션<br>                    val backgroundAlpha = remember { Animatable(0f) }<br>                    val coroutineScope = rememberCoroutineScope()<br><br>                    Column(<br>                        Modifier<br>                            .height(rowHeight+ DROP_INDICATOR_HEIGHT + DROP_INDICATOR_ROW_GAP +<br>                                    (if (isLast) (DROP_INDICATOR_HEIGHT + DROP_INDICATOR_ROW_GAP) else 0.dp)<br>                            ) // 순수 Row Height + DropIndicator<br>                            .fillMaxWidth()<br>                            .padding(horizontal = 16.dp)<br>                            .background(<br>                                Gray25.copy(alpha = backgroundAlpha.value),<br>                                RoundedCornerShape(8.dp)<br>                            )<br>                            .padding(4.dp)<br>                            .graphicsLayer {<br>                                alpha = 1f - backgroundAlpha.value<br>                                translationY = offsetY.value<br>                                scaleX = scale.value<br>                                scaleY = scale.value<br>                            }<br>                            .then(<br>                                if (isDragEnabled) {<br>                                    Modifier<br>                                        .dragAndDropSource {<br>                                            detectTapGestures(<br>                                                onPress = {<br>                                                    // Press 시작 시 축소 및 배경 표시<br>                                                    coroutineScope.launch {<br>                                                        scale.animateTo(<br>                                                            targetValue = 0.97f,<br>                                                            animationSpec = tween(100)<br>                                                        )<br>                                                    }<br>                                                    coroutineScope.launch {<br>                                                        backgroundAlpha.animateTo(<br>                                                            targetValue = 0.3f,<br>                                                            animationSpec = tween(100)<br>                                                        )<br>                                                    }<br>                                                    // Press가 끝날 때까지 대기<br>                                                    tryAwaitRelease()<br>                                                    // Release 시 원래 크기로 복원 및 배경 숨김<br>                                                    coroutineScope.launch {<br>                                                        scale.animateTo(<br>                                                            targetValue = 1f,<br>                                                            animationSpec = tween(400)<br>                                                        )<br>                                                    }<br>                                                    coroutineScope.launch {<br>                                                        backgroundAlpha.animateTo(<br>                                                            targetValue = 0f,<br>                                                            animationSpec = tween(400)<br>                                                        )<br>                                                    }<br>                                                },<br>                                                onTap = { onTapRow(item) },<br>                                                onLongPress = {<br>                                                    hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)<br>                                                    draggingItemId = item.id<br>                                                    startTransfer(<br>                                                        transferData = DragAndDropTransferData(<br>                                                            clipData = ClipData.newPlainText(<br>                                                                &quot;draggable-item-id&quot;,<br>                                                                item.id.toString()<br>                                                            )<br>                                                        )<br>                                                    )<br>                                                }<br>                                            )<br>                                        }<br>                                } else {<br>                                    Modifier<br>                                }<br>                            ),<br>                        verticalArrangement = Arrangement.spacedBy(DROP_INDICATOR_ROW_GAP)<br>                    ) {<br>                        val transitionDuration = 120<br>                        AnimatedVisibility(<br>                            visible = targetedDropIndex == index,<br>                            enter = expandVertically(tween(transitionDuration)),<br>                            exit = shrinkVertically(tween(transitionDuration)),<br>                        ) {<br>                            dropIndicator()<br>                        }<br>                        Column(Modifier.height(rowHeight)) {<br>                            itemContent(item, draggingItemId == item.id)<br>                        }<br>                        AnimatedVisibility(<br>                            visible = isLast &amp;&amp; targetedDropIndex == index + 1,<br>                            enter = expandVertically(tween(transitionDuration)),<br>                            exit = shrinkVertically(tween(transitionDuration)),<br>                        ) {<br>                            dropIndicator()<br>                        }<br>                    }<br>                }<br>            }<br>            footer?.let { it() }<br>        }<br>    }<br>}<br><br>private val SCROLL_THRESHOLD_DP = 96.dp<br>private val HEADER_THRESHOLD_DP = 64.dp<br>private val DROP_INDICATOR_HEIGHT = 12.dp<br>private val DROP_INDICATOR_ROW_GAP = 4.dp<br>private const val SCROLL_VELOCITY = 1200f<br><br>/**<br> * DnD 재정렬 로직의 순수 계산 함수.<br> * Composable 내부 로컬 함수와 동일한 로직을 외부에서 테스트할 수 있도록 분리.<br> */<br>internal object DraggableListLogic {<br><br>    /**<br>     * Window Y 좌표 → 드롭 슬롯 인덱스 (0..itemCount) 변환.<br>     * Composable 내부 computeTargetIndex와 동일한 로직.<br>     */<br>    fun computeTargetSlot(<br>        windowY: Float,<br>        containerTopOffset: Float,<br>        scrollOffset: Int,<br>        headerHeightPx: Float,<br>        rowSlotHeightPx: Float,<br>        itemCount: Int<br>    ): Int {<br>        val localY = windowY - containerTopOffset<br>        val absoluteY = localY + scrollOffset<br>        val relativeY = absoluteY - headerHeightPx<br>        if (relativeY &lt;= 0f) return 0<br><br>        val index = (relativeY / rowSlotHeightPx).toInt()<br>        val midOffset = relativeY - (index * rowSlotHeightPx)<br>        val adjustedIndex = if (midOffset &gt; rowSlotHeightPx / 2f) index + 1 else index<br>        return adjustedIndex.coerceIn(0, itemCount)<br>    }<br><br>    /**<br>     * 드롭 슬롯 → 실제 삽입 인덱스 변환.<br>     * Composable 내부 moveItem과 동일한 로직.<br>     */<br>    fun computeInsertIndex(<br>        currentIndex: Int,<br>        targetSlot: Int,<br>        itemCount: Int<br>    ): Int {<br>        val coercedTarget = targetSlot.coerceIn(0, itemCount)<br>        return if (coercedTarget &gt; currentIndex) coercedTarget - 1 else coercedTarget<br>    }<br>}</pre><h4>마무리하며</h4><p>이번 DnD 구현 과정은 Jetpack Compose의 <strong>좌표 시스템과 Modifier Node의 생명주기</strong>를 깊게 이해할 수 있는 좋은 계기였습니다. 화려한 UI 뒤에는 노드의 재생성 메커니즘과 같은 로우 레벨에 대한 고민이 필수적이라는 것을 느꼈습니다.</p><p>감사합니다.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6009c0890565" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[[iOS 16, SwiftUI] Drag and Drop 리스트 구현]]></title>
            <link>https://medium.com/@neoself1105/swiftui-16-drag-and-drop-%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EA%B5%AC%ED%98%84-7923ebb8ff9f?source=rss-18d63a54716d------2</link>
            <guid isPermaLink="false">https://medium.com/p/7923ebb8ff9f</guid>
            <dc:creator><![CDATA[NEON]]></dc:creator>
            <pubDate>Fri, 23 Jan 2026 07:11:29 GMT</pubDate>
            <atom:updated>2026-01-30T01:43:34.628Z</atom:updated>
            <content:encoded><![CDATA[<h3>[iOS 16, SwiftUI] Drag and Drop 사용자 경험 향상시키기</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/295/1*j7912lkOu7WTC0gB_Cf9XA.gif" /></figure><h4><strong>0. 구현 배경</strong></h4><p>초기에는 iOS 16에서 제공하는 dropDestination을 통해 순서 정렬 로직을 구성하였습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/660/1*GGJj8yLXmJbSaPEwVuxUtA.png" /></figure><p>본래 구현 목표는 사용자가 드래그 도중 ItemRow 위를 이동할때, ItemRow의 상단 영역과 하단영역에 따라 각기 다른 Indicator가 show/hide되도록 구현하고자 했습니다.</p><p>이를 위해 체이닝된 뷰에 드래그 동작으로 진입하였는지 여부를 추적하는dropDestination의 isTargeted 콜백을 사용하고자 하였으나 해당 콜백은 진입여부에 대한 isTargeted 값만 접근이 가능했기에, 진입 당시 사용자가 ItemRow의 상단에 있는지 하단에 있는지 확인할 수 없었습니다.</p><pre>  // dropDestination의 한계 - 위치 정보 없이 진입 여부만 제공<br>  .dropDestination(for: Item.self) { droppedItems, _ in<br>      // 드롭 처리<br>      return true<br>  } isTargeted: { isTargeted in<br>      // Bool 값만 제공 - 상단/하단 구분 불가<br>      targetedDropIndex = isTargeted ? index : nil<br>  }</pre><p>이를 위해 상단과 하단으로 총 2개의 dropDestination을 ItemRow내부에 overlay와 background 뷰 수정자를 통해 배치시켜 드래그 도중 위치를 추적해보려 했지만, ItemRow 자체에서도 Tap 동작을 listen하고 있어야 했기에 두 상호작용 추적 로직이 충돌하는 이슈가 있었습니다.</p><p>때문에, 최종적으로 ItemRow 간 얇은 Spacer를 dropDestination으로 설정하는 방향으로 최종 구현을 결정지었습니다.</p><pre>  // LegacyDraggableList - ItemRow 사이에 별도의 dropZone 배치<br>  LazyVStack(spacing: 0) {<br>      dropZone(at: 0)  // 첫 번째 드롭존<br>      ForEach(Array(items.enumerated()), id: \.element.id) { index, item in<br>          VStack(spacing: 0) {<br>              itemContent(item, false)<br>                  .padding(.bottom, 12)<br>                  .background(<br>                      dropZone(at: index + 1),  // 각 아이템 하단에 드롭존<br>                      alignment: .bottom<br>                  )<br>          }<br>      }<br>      ...</pre><p>하지만 이를 사용하면서 사용자 경험 측면에서 2가지 문제가 존재했습니다.</p><ol><li>0.3초 이상 longPress해야 drag 동작이 호출되는데, 처음에 탭 상호작용이draggable 뷰 수정자까지 정상적으로 소비되지 않을 경우 onStartDrag 동작으로 이어지지 않았고, 0.3초가 지날때까지 이를 사용자가 파악할 수 있는 시각적 피드백이 부재했습니다.</li></ol><p>2. ItemRow 내부에 Drop을 할 경우에는, 순서 재정렬이 되지 않아. 사이 공간에 드래그 위치를 맞춰야하는 번거로움이 존재했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*gE8qLLNI_vTqzrEGQka-nQ.gif" /><figcaption>ItemRow 내부에서 드래그를 drop하면 순서 변경이 발생하지 않습니다</figcaption></figure><p>때문에, 이러한 사용자 경험 관련 문제를 해결하고자, 아래 기준을 충족하는 커스텀 DraggableList를 생성하게 되었습니다.</p><p>구현 조건:<br>- ItemRow에 탭 상호작용이 도달<br>- 드래그 도중 어느 곳에서 드래그를 멈추던 순서 재정렬 수행<br>- 드래그로 연결이 되고 있는지의 여부 전달을 위한 첫 터치에 대한 시각적 피드백 제공</p><h4>1. dropDestination 대신 onDrop + DropDelegate 사용</h4><p><strong>1.1 왜 DropDelegate인가요?</strong></p><p>DropDelegate는 <strong>드롭을 허용하도록 수정된 뷰에서 드롭 작업과 상호 작용하기 위해 구현된 인터페이스</strong>입니다.</p><p>dropDestination은 iOS 16에서 도입된 간편한 API이지만, 드래그 중인 아이템의 정확한 좌표 정보에 접근할 수 없다는 한계가 있습니다. 반면<br> DropDelegate 프로토콜은 DropInfo 객체를 통해 드래그 위치를 실시간으로 추적이 가능했습니다.</p><pre>// DropDelegate 프로토콜의 주요 메서드<br> protocol DropDelegate {<br>   func dropEntered(info: DropInfo) // 드롭 영역 진입<br>   func dropUpdated(info: DropInfo) -&gt; DropProposal? // 드래그 중 위치 업데이트<br>   func dropExited(info: DropInfo) // 드롭 영역 이탈<br>   func performDrop(info: DropInfo) -&gt; Bool // 드롭 수행<br> }</pre><p>핵심은 DropInfo.location 프로퍼티입니다. 이를 통해 드래그 중인 아이템이 현재 뷰의 어느 위치에 있는지 CGPoint 좌표로 확인할 수 있습니다.</p><p><strong>1.2 ItemDropDelegate 구현</strong></p><pre>struct ItemDropDelegate&lt;Item: Draggable&gt;: DropDelegate {<br>      let index: Int<br>      let rowHeight: CGFloat<br>      let onUpdateTarget: (Int?) -&gt; Void<br>      let onDrop: (Item, Int) -&gt; Void<br><br>      // 드래그가 영역에 진입했을 때<br>      func dropEntered(info: DropInfo) {<br>          updateTargetIndex(for: info.location)<br>      }<br><br>      // 드래그 중 위치가 업데이트될 때 (핵심!)<br>      func dropUpdated(info: DropInfo) -&gt; DropProposal? {<br>          updateTargetIndex(for: info.location)<br>          return DropProposal(operation: .move)<br>      }<br><br>      // 드래그가 영역을 벗어났을 때<br>      func dropExited(info: DropInfo) {<br>          onUpdateTarget(nil)<br>      }<br><br>      // 드롭이 수행되었을 때<br>      func performDrop(info: DropInfo) -&gt; Bool {<br>          let isTopHalf = info.location.y &lt; rowHeight / 2<br>          let targetIndex = isTopHalf ? index : index + 1<br><br>          guard let itemProvider = info.itemProviders(for: [.data]).first else {<br>              onUpdateTarget(nil)<br>              return false<br>          }<br><br>          _ = itemProvider.loadTransferable(type: Item.self) { result in<br>              DispatchQueue.main.async {<br>                  switch result {<br>                  case .success(let item):<br>                      onDrop(item, targetIndex)<br>                  case .failure(let error):<br>                      print(&quot;❌ Drop failed: \(error)&quot;)<br>                  }<br>                  onUpdateTarget(nil)<br>              }<br>          }<br>          return true<br>      }<br><br>      // 위치 기반 타겟 인덱스 계산<br>      private func updateTargetIndex(for location: CGPoint) {<br>          let isTopHalf = location.y &lt; rowHeight / 2<br>          let targetIndex = isTopHalf ? index : index + 1<br>          onUpdateTarget(targetIndex)<br>      }<br>  }</pre><p>위와 같이 info.location.y 값을 통해 드래그 위치가 ItemRow의 상단 절반인지 하단 절반인지 판별하여 targetIndex를 계산합니다.</p><p>이 방식을 통해 사용자가 ItemRow의 어느 위치에 드롭하더라도 의도에 맞는 위치에 아이템을 삽입할 수 있게 되었습니다.</p><h4>1.3. onDrop 적용</h4><p>이렇게 구현한 ItemDropDelegate를 드롭 동작을 listen해야하는 뷰에 onDrop 뷰수정자와 함께 체이닝시켜줍니다.</p><pre>.onDrop(<br>      of: [.data],<br>      delegate: ItemDropDelegate&lt;Item&gt;(<br>          index: index,<br>          rowHeight: rowHeight,<br>          onUpdateTarget: { newIndex in<br>              targetedDropIndex = newIndex<br>          },<br>          onDrop: { droppedItem, targetIndex in<br>              moveItem(droppedItem, to: targetIndex)<br>          }<br>      )<br>  )</pre><h4>2. UX 고도화</h4><p><strong>2.1. 터치 피드백 추가</strong></p><p>dropDestination을 사용할 때는 draggable 수정자가 적용된 뷰에 터치 시 기본 피드백이 제공되지 않았습니다. 이를 해결하기 위해 ItemRow를<br> Button으로 감싸 시스템의 기본 터치 피드백을 활용했습니다.</p><pre>// Button으로 감싸서 터치 피드백 제공<br>  Button(action: {}) {<br>      itemContent(item, false)<br>  }<br>  .draggable(item) {<br>      // 드래그 프리뷰<br>      itemContent(item, true)<br>          .frame(height: rowHeight)<br>  }</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/354/1*DMhMDG5KrXEueEGv7g6bUA.gif" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/354/1*jK3jgWOc_mR-n3El8nhKzA.gif" /></figure><p>Button의 기본 동작으로 터치 시 약간의 opacity 변화가 발생하여, 사용자가 터치가 인식되고 있음을 즉시 인지할 수 있게 되었습니다.</p><p><strong>2.2. 순서 변경 시 햅틱 피드백</strong></p><p>드래그 중 삽입 위치가 변경될 때마다 촉각 피드백을 제공하여, 사용자가 현재 어느 위치에 아이템이 삽입될지 직관적으로 파악할 수 있도록 했습니다.</p><pre> .onChange(of: targetedDropIndex) { oldValue, newValue in<br>      // targetIndex가 실제로 변경되었을 때만 햅틱 피드백 발생<br>      if oldValue != newValue, newValue != nil {<br>          let generator = UIImpactFeedbackGenerator(style: .light)<br>          generator.impactOccurred()<br>      }<br>  }</pre><p><strong>2.3 삽입 위치 Indicator 애니메이션</strong></p><p>드래그 중 삽입될 위치를 시각적으로 표시하기 위해 Indicator를 추가하고, 위치 변경 시 부드러운 애니메이션을 적용했습니다.</p><pre>VStack(spacing: 0) {<br>      // 상단 Indicator - 현재 아이템 위에 삽입될 때 표시<br>      if targetedDropIndex == index {<br>          RoundedRectangle(cornerRadius: 2)<br>              .fill(Color.blue.opacity(0.3))<br>              .frame(maxWidth: .infinity, maxHeight: .infinity)<br>              .padding(.horizontal, 16)<br>      }<br><br>      Button(action: {}) {<br>          itemContent(item, false)<br>      }<br><br>      // 하단 Indicator - 현재 아이템 아래에 삽입될 때 표시<br>      if targetedDropIndex == index + 1 {<br>          RoundedRectangle(cornerRadius: 2)<br>              .fill(Color.blue.opacity(0.3))<br>              .frame(maxWidth: .infinity, maxHeight: .infinity)<br>              .padding(.horizontal, 16)<br>      }<br>  }</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/354/1*Tffb3XngigYsEQycwKhbIQ.gif" /></figure><h4>3. 재사용성 향상</h4><p><strong>3.1 Generic Protocol 정의</strong></p><p>다양한 타입의 아이템에서 DraggableList를 사용할 수 있도록 Draggable 프로토콜을 정의했습니다.</p><pre>protocol Draggable: Identifiable, Codable, Equatable, Transferable {<br>      var id: UUID { get }<br>  }<br>//  - Identifiable: ForEach에서 아이템 식별<br>//  - Codable: Transferable 구현을 위한 직렬화<br>//  - Equatable: 아이템 비교 및 변경 감지<br>//  - Transferable: 시스템 드래그 앤 드롭 지원</pre><p><strong>3.2 ViewBuilder를 통한 Row 동적 주입</strong></p><pre>struct DraggableList&lt;Item: Draggable, ItemRow: View&gt;: View {<br>    // ...<br>    let itemContent: (Item, Bool) -&gt; ItemRow<br><br>    init(<br>        // ...<br>        @ViewBuilder itemContent: @escaping (Item, Bool) -&gt; ItemRow<br>    ) {<br>        self.itemContent = itemContent<br>    }<br>}</pre><p>@ViewBuilder를 사용하여 호출부에서 자유롭게 Row 뷰를 정의할 수 있도록 설계했습니다. 여기서 두 번째 파라미터 Bool은 드래그 중인지 여부를 나타내어, 드래그 프리뷰와 일반 상태를 구분할 수 있도록 했습니다.\</p><pre> // 사용 예시<br>  DraggableList(<br>      items: $items,<br>      rowHeight: 72<br>  ) { item, isDragging in<br>      HStack {<br>          Text(item.title)<br>          Spacer()<br>          Image(systemName: &quot;line.3.horizontal&quot;)<br>      }<br>      .opacity(isDragging ? 0.8 : 1.0)<br>  }</pre><p><strong>3.3 콜백을 통한 확장성</strong></p><p>마지막으로 외부에서 처리할 수 있도록 재정렬 이벤트를 주입받도록 했습니다.</p><pre>var onReorder: ((_ from: Int, _ to: Int) -&gt; Void)?</pre><p>이를 통해 서버 동기화, 로깅, 추가 비즈니스 로직 등을 DraggableList 외부에서 처리할 수 있습니다.</p><p><strong>전체 코드</strong></p><pre>import SwiftUI<br>import CoreTransferable<br>internal import UniformTypeIdentifiers<br><br>/// DraggableList에서 사용할 아이템 프로토콜<br>/// Transferable을 준수해야 시스템 드래그 앤 드롭 사용 가능<br>protocol Draggable: Identifiable, Codable, Equatable, Transferable {<br>    var id: Int { get }<br>}<br><br>// MARK: - ItemDropDelegate<br>/// 드래그 위치를 실시간으로 트래킹하기 위한 DropDelegate<br>struct ItemDropDelegate&lt;Item: Draggable&gt;: DropDelegate {<br>    let index: Int<br>    let rowHeight: CGFloat<br>    let onUpdateTarget: (Int?) -&gt; Void<br>    let onDrop: (Item, Int) -&gt; Void<br><br>    func dropEntered(info: DropInfo) {<br>        updateTargetIndex(for: info.location)<br>    }<br><br>    func dropUpdated(info: DropInfo) -&gt; DropProposal? {<br>        updateTargetIndex(for: info.location)<br>        return DropProposal(operation: .move)<br>    }<br><br>    func dropExited(info: DropInfo) {<br>        onUpdateTarget(nil)<br>    }<br><br>    func performDrop(info: DropInfo) -&gt; Bool {<br>        let isTopHalf = info.location.y &lt; rowHeight / 2<br>        let targetIndex = isTopHalf ? index : index + 1<br><br>        guard let itemProvider = info.itemProviders(for: [.data]).first else {<br>            onUpdateTarget(nil)<br>            return false<br>        }<br><br>        _ = itemProvider.loadTransferable(type: Item.self) { result in<br>            DispatchQueue.main.async {<br>                switch result {<br>                case .success(let item):<br>                    onDrop(item, targetIndex)<br>                case .failure(let error):<br>                    print(&quot;❌ Drop failed: \(error)&quot;)<br>                }<br>                onUpdateTarget(nil)<br>            }<br>        }<br><br>        return true<br>    }<br><br>    private func updateTargetIndex(for location: CGPoint) {<br>        let isTopHalf = location.y &lt; rowHeight / 2<br>        let targetIndex = isTopHalf ? index : index + 1<br>        onUpdateTarget(targetIndex)<br>    }<br>}<br><br>// MARK: - DraggableList<br><br>struct DraggableScrollView&lt;Item: Draggable, ItemRow: View, Header: View, Footer: View&gt;: View {<br>    // MARK: - Properties<br>    @Binding var items: [Item]<br><br>    /// 각 행의 고정 높이<br>    let rowHeight: CGFloat<br>    let isDragEnabled: Bool<br><br>    /// 아이템 재정렬 콜백<br>    var onReorder: (() -&gt; Void)?<br>    <br>    /// 각 행의 뷰를 생성하는 클로저<br>    let itemContent: (Item, Bool) -&gt; ItemRow<br>    let onTapRow: (Item) -&gt; Void<br><br>    /// Header/Footer 뷰<br>    let header: Header<br>    let footer: Footer<br><br>    // MARK: - State<br>    /// 현재 드롭 타겟 인덱스<br>    @State private var targetedDropIndex: Int?<br>    /// iOS 16 호환: onChange에서 oldValue 비교를 위한 이전 값 저장<br>    @State private var prevTargetedDropIndex: Int?<br><br>    // MARK: - Initializer<br>    init(<br>        items: Binding&lt;[Item]&gt;,<br>        rowHeight: CGFloat = 60,<br>        isDragEnabled: Bool,<br>        onReorder: (() -&gt; Void)? = nil,<br>        onTapRow: @escaping (Item) -&gt; Void,<br>        @ViewBuilder itemContent: @escaping (Item, Bool) -&gt; ItemRow,<br>        @ViewBuilder header: () -&gt; Header,<br>        @ViewBuilder footer: () -&gt; Footer<br>    ) {<br>        self._items = items<br>        self.rowHeight = rowHeight<br>        self.isDragEnabled = isDragEnabled<br>        self.onReorder = onReorder<br>        self.onTapRow = onTapRow<br>        self.header = header()<br>        self.footer = footer()<br>        self.itemContent = itemContent<br>    }<br>    <br>    var body: some View {<br>        ScrollView {<br>            header<br>            LazyVStack(spacing: 0) {<br>                ForEach(Array(items.enumerated()), id: \.element.id) { index, item in<br>                    itemRowView(index: index, item: item)<br>                }<br>            }<br>            footer<br>        }<br>        .animation(.spring(response: 0.3, dampingFraction: 0.7), value: targetedDropIndex)<br>        .animation(.spring(response: 0.3, dampingFraction: 0.7), value: items)<br>        .onChange(of: targetedDropIndex) { newValue in<br>            if prevTargetedDropIndex != newValue, newValue != nil {<br>                let generator = UIImpactFeedbackGenerator(style: .light)<br>                generator.impactOccurred()<br>            }<br>            prevTargetedDropIndex = newValue<br>        }<br>    }<br><br>    private func itemRowView(index: Int, item: Item) -&gt; some View {<br>        VStack(spacing: 0) {<br>            if targetedDropIndex == index {<br>                UnevenRoundedRectangle(<br>                    topLeadingRadius: index == 0 ? 4 : 0,<br>                    bottomLeadingRadius: 4,<br>                    bottomTrailingRadius: 4,<br>                    topTrailingRadius: index == 0 ? 4 : 0<br>                )<br>                .fill(.blue)<br>                .frame(maxWidth: .infinity, maxHeight: .infinity)<br>                .padding(.horizontal, 16)<br>            }<br>            <br>            Button(action: { onTapRow(item) }) {<br>                itemContent(item, false)<br>            }<br>            <br>            if targetedDropIndex == index + 1 {<br>                UnevenRoundedRectangle(<br>                    topLeadingRadius: 4,<br>                    bottomLeadingRadius: index == items.count - 1 ? 4 : 0,<br>                    bottomTrailingRadius: index == items.count - 1 ? 4 : 0,<br>                    topTrailingRadius: 4<br>                )<br>                .fill(.blue)<br>                .frame(maxWidth: .infinity, maxHeight: .infinity)<br>                .padding(.horizontal, 16)<br>            }<br>        }<br>        .frame(height: rowHeight + 16)<br>        .disabled(!isDragEnabled)<br>        .draggable(item) {<br>            itemContent(item, true)<br>                .frame(height: rowHeight)<br>        }<br>        .id(item.id)<br>        .onDrop(<br>            of: [.data],<br>            delegate: ItemDropDelegate&lt;Item&gt;(<br>                index: index,<br>                rowHeight: rowHeight,<br>                onUpdateTarget: { newIndex in<br>                    targetedDropIndex = newIndex<br>                },<br>                onDrop: { droppedItem, targetIndex in<br>                    moveItem(droppedItem, to: targetIndex)<br>                }<br>            )<br>        )<br>    }<br>    <br>    // MARK: - Move Logic<br>    private func moveItem(_ item: Item, to targetIndex: Int) {<br>        guard let currentIndex = items.firstIndex(where: { $0.id == item.id }) else {<br>            print(&quot;❌ Item not found in array&quot;)<br>            return<br>        }<br>        guard currentIndex != targetIndex else {<br>            print(&quot;❌ Same position, no move needed&quot;)<br>            return<br>        }<br>        /// targetIndex `뒤로` item이 이동되어야 합니다.<br>        let movedItem = items.remove(at: currentIndex)<br>        let insertIndex = targetIndex &gt; currentIndex ? targetIndex - 1 : targetIndex<br>        items.insert(movedItem, at: insertIndex)<br>        onReorder?()<br>    }<br>}<br><br>// MARK: - Convenience Initializers<br><br>extension DraggableScrollView where Header == EmptyView, Footer == EmptyView {<br>    /// Header, Footer 없이 사용하는 경우<br>    init(<br>        items: Binding&lt;[Item]&gt;,<br>        rowHeight: CGFloat = 60,<br>        isDragEnabled: Bool,<br>        onReorder: (() -&gt; Void)? = nil,<br>        onTapRow: @escaping (Item) -&gt; Void,<br>        @ViewBuilder itemContent: @escaping (Item, Bool) -&gt; ItemRow<br>    ) {<br>        self.init(<br>            items: items,<br>            rowHeight: rowHeight,<br>            isDragEnabled: isDragEnabled,<br>            onReorder: onReorder,<br>            onTapRow: onTapRow,<br>            itemContent: itemContent,<br>            header: { EmptyView() },<br>            footer: { EmptyView() }<br>        )<br>    }<br>}<br><br>extension DraggableScrollView where Footer == EmptyView {<br>    /// Header만 사용하는 경우<br>    init(<br>        items: Binding&lt;[Item]&gt;,<br>        rowHeight: CGFloat = 60,<br>        isDragEnabled: Bool,<br>        onReorder: (() -&gt; Void)? = nil,<br>        onTapRow: @escaping (Item) -&gt; Void,<br>        @ViewBuilder itemContent: @escaping (Item, Bool) -&gt; ItemRow,<br>        @ViewBuilder header: () -&gt; Header<br>    ) {<br>        self.init(<br>            items: items,<br>            rowHeight: rowHeight,<br>            isDragEnabled: isDragEnabled,<br>            onReorder: onReorder,<br>            onTapRow: onTapRow,<br>            itemContent: itemContent,<br>            header: header,<br>            footer: { EmptyView() }<br>        )<br>    }<br>}<br><br>extension DraggableScrollView where Header == EmptyView {<br>    /// Footer만 사용하는 경우<br>    init(<br>        items: Binding&lt;[Item]&gt;,<br>        rowHeight: CGFloat = 60,<br>        isDragEnabled: Bool,<br>        onReorder: (() -&gt; Void)? = nil,<br>        onTapRow: @escaping (Item) -&gt; Void,<br>        @ViewBuilder itemContent: @escaping (Item, Bool) -&gt; ItemRow,<br>        @ViewBuilder footer: () -&gt; Footer<br>    ) {<br>        self.init(<br>            items: items,<br>            rowHeight: rowHeight,<br>            isDragEnabled: isDragEnabled,<br>            onReorder: onReorder,<br>            onTapRow: onTapRow,<br>            itemContent: itemContent,<br>            header: { EmptyView() },<br>            footer: footer<br>        )<br>    }<br>}</pre><h4>4. 결론</h4><p>dropDestination은 간단한 드래그 앤 드롭 구현에 적합하지만, 세밀한 위치 추적이 필요한 경우에는 DropDelegate를 사용하는 것이 적합합니다. DropDelegate를 통해 드래그 위치를 실시간으로 추적하고, 이를 기반으로 더 직관적인 사용자 경험을 제공할 수 있었습니다.</p><p>특히 햅틱 피드백과 시각적 Indicator의 조합은 사용자가 드래그 중 현재 상태를 명확하게 인지할 수 있게 해주어, 전체적인 인터랙션 품질을 크게 향상시켰습니다.</p><p>감사합니다.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7923ebb8ff9f" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[[iOS 16, SwiftUI] 채팅 UI를 위한 ScrollView 구현]]></title>
            <link>https://medium.com/@neoself1105/swiftui-16-%EC%83%81%ED%95%98%EB%8B%A8-offset-%EC%B6%94%EC%A0%81%EC%9D%B4-%EA%B0%80%EB%8A%A5%ED%95%9C-scrollview-%EA%B5%AC%ED%98%84-bd23cf47d31e?source=rss-18d63a54716d------2</link>
            <guid isPermaLink="false">https://medium.com/p/bd23cf47d31e</guid>
            <dc:creator><![CDATA[NEON]]></dc:creator>
            <pubDate>Wed, 21 Jan 2026 08:44:55 GMT</pubDate>
            <atom:updated>2026-01-30T01:43:59.804Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/308/1*Xd0_zD7D_dyPc0s7vEQnoQ.gif" /><figcaption>최종 구현물</figcaption></figure><h4>0. 구현 배경</h4><p>초기에는 밑으로 이동 버튼 표시 여부 판단을 위해 Scroll 위치를 파악하고자, 리스트 아이템 내부에 onAppear 콜백이 체이닝된 Spacer()를 삽입한 후, 이를 통해 버튼 표시 여부를 트리거하고자 했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/677/1*ZOtVuz-nS0lCxZjxQIK2Ew.png" /></figure><p>하지만, 2가지 한계점이 존재했습니다.</p><p>1번째로 표시 및 미표시 2개 이벤트에 대한 시점을 모두 제어하기에는 불충분했습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/677/1*GBPwnhTfwmtVfprJ0rhz2w.png" /></figure><p>또한, 2번째로 메시지 간 개행이 삽입되어 아이템 간 높이값에 큰 폭의 차이가 존재할경우, 일관되지 않은 사용자 경험을 제공해 사용자에게 혼란을 줄 수 있었습니다.</p><p>이를 해결하기 위해 가장 먼저 구현이 필요한 기능들을 아래와 같이 정리했습니다.</p><h4>1. 구현 조건 정의</h4><p><strong>최하단으로 이동 플로팅 버튼</strong></p><ul><li>ScrollView를 고정된 거리만큼 위로 스크롤하면, 버튼을 우측 하단에 표시</li><li>반대로 ScrollView의 최하단으로부터 거리가 가까워질 경우, 버튼을 hide</li></ul><p><strong>상단 페이지네이션</strong></p><ul><li>최상단으로 스크롤 시, 자연스럽게 위 방향으로 새로운 Item들이 fetch</li></ul><h4><strong>2. 상단 페이지네이션</strong></h4><p>상단 페이지네이션이 자연스럽게 수행되기 위해선, 아래 2 조건이 충족되어야 합니다.</p><ul><li>리스트 내부 아이템이 추가될때, 현재 리스트의 offset이 변경되지 않고, 자연스럽게 상단에 추가</li><li>스크롤 위치만으로 아이템 추가를 위한 fetch 작업 트리거</li></ul><p>리스트의 offset 변경 없이, Item이 추가되는 방법은 아래 블로그에서 굉장히 세부적으로 다루고 있어, 많이 도움이 되었습니다.</p><p><a href="https://dokit.tistory.com/65#google_vignette">https://dokit.tistory.com/65#google_vignette</a></p><p>이를 참고하여 scaleEffect(y: -1)를 ScrollView에 1번, 내부 Item에 선언하여 ScrollView 자체를 상하단으로 뒤집어 현재 사용자가 보고있는 Item들을 화면에 유지하며 자연스럽게 상단에 새로운 Item이 추가되게끔 설계할 수 있었습니다.</p><pre>import SwiftUI<br><br>struct ContentView: View {<br>    struct Message: Hashable {<br>        let id: UUID = .init()<br>        let text: String<br>    }<br>    <br>    @State var messages: [Message] = (0..&lt;50).map { Message(text: String($0)) }<br><br>    var body: some View {<br>        CustomScrollView { proxy in<br>            LazyVStack(spacing: 0) {<br>                ForEach(messages, id: \.self) { message in<br>                    Text(&quot;\(message.text)&quot;)<br>                        .frame(maxWidth: .infinity)<br>                        .frame(height: 48, alignment: .center)<br>                        .id(message.id)<br>                        // 2. 상하단으로 뒤집어보이지 않게 내부적으로 한번 더 호출<br>                        .scaleEffect(y: -1)<br>                }<br>            }<br>            .background(.gray)<br>        }<br>    }<br>}<br><br>struct CustomScrollView&lt;Content: View, Overlay: View&gt;: View {<br>    @ViewBuilder var content: (ScrollViewProxy) -&gt; Content<br>    var body: some View {<br>        ScrollViewReader { scrollProxy in<br>            ScrollView(.vertical, showsIndicators: true) {<br>                content(scrollProxy)<br>            }<br>            // 1. ScrollView 자체를 상하단으로 뒤집기<br>            .scaleEffect(y: -1)<br>        }<br>    }<br>}</pre><p>여기서 각 Item마다 명시적 정체성을 부여하는 이유는 scaleEffect 처리를 하게 될 경우, SwiftUI에서 상태 변화를 트래킹하는 데에 있어 기존 뷰가 뒤집힌 것인지 vs 데이터 자체에 변동이 있었는지 혼동할 때가 있기 때문입니다.</p><p>때문에 데이터 업데이트 시, 이전의 데이터와 같음을 즉각적으로 전달시켜 불필요한 레이아웃 재계산을 줄이고 화면이 깜빡이는 glitch 현상을 억제하고자 했습니다.</p><h4>3. 스크롤 offset값 실시간 추적</h4><p>플로팅버튼의 정확한 표시 시점을 추적하기 위해선, 스크롤 컨텐츠 내부에서 사용자가 현재 스크롤하고 있는 절대적인 offset 값을 접근해야 했습니다.</p><p>이를 구현하기 위해 GeometryReader와 자식 뷰에서 부모뷰로 데이터를 전달할 수 있는 PreferenceKey를 사용하였습니다.</p><p>GeometryReader 는 상위 View가 제안한 위치, 크기에 대한 정보에 접근할 수 있도록 돕는 도구입니다.</p><p>최상단 y 좌표값과 전체 높이값을 파악하기 위해 제안받은 공간을 CGRect 타입으로 반환하는 .frame() 메서드를 사용했습니다.</p><p>frame() 메서드 사용 시에는 CoordinateSpace 인자를 요구하는데, 이는 아래와 같습니다.</p><ul><li>.global: 스크린 전체에 대한 좌표 공간을 반환</li><li>.local: GeometryReader의 좌표 공간을 반환</li><li>.named : .coordinateSpace(name: ${식별자 String})을 호출한 뷰의 좌표 공간을 반환</li></ul><p>스크롤 가능한 전체 콘텐츠에 대한 offset이 필요했기 때문에, 이와 동일한 공간을 차지할 수 있게끔 .background() 수정자와 함께 GeometryReader로 래핑한 투명 영역을 추가하였으며, ScrollView 자체에 대해 .coordinateSpace 수정자를 체이닝하여 해당 영역에 대한 공간을 접근하고자 했습니다.</p><pre>import SwiftUI<br><br>struct CustomScrollView&lt;Content: View, Overlay: View&gt;: View {<br>    @ViewBuilder var content: (ScrollViewProxy) -&gt; Content<br>    <br>    @Namespace private var coordinateSpaceName: Namespace.ID<br>    <br>    var body: some View {<br>        ScrollViewReader { scrollProxy in<br>            ScrollView(.vertical, showsIndicators: true) {<br>                content(scrollProxy)<br>                    .background {<br>                        GeometryReader { scrollContentProxy in<br>                            Color.clear<br>                        }<br>                    }<br>            }<br>            .scaleEffect(y: -1)<br>            // .frame() 메서드로 ScrollView에 대한 좌표공간 조회 위해 추가<br>            .coordinateSpace(name: coordinateSpaceName)<br>        }<br>    }<br>}</pre><p>이후 각 CoordinateSpace의 차이를 상세히 파악하고자,</p><ol><li>최상단으로 스크롤하였을 때,</li><li>ScrollView 내부 컨텐츠가 스크린 상단에 걸쳐있을때,</li><li>최하단으로 스크롤하였을때</li></ol><p>3가지 경우에 대해 3가지 coordinateSpace에 대한 CGRect를 모두 로그로 출력해보았습니다.</p><p><strong>1. 최상단으로 스크롤하였을 때</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/321/1*mtKE4pEy6gGyvj9uRdrGeA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/284/1*8T_4v9HgGRhMrvI9nht72g.png" /><figcaption>(좌) 실제 스크린샷, (우) CGRect 로그 결과 및 도식</figcaption></figure><p>ScrollView에 대한 좌표 공간의 경우, minY 즉, 좌표의 최상단 좌측 y좌표가 0을 가르키고 있는 반면, global 좌표 공간은 62를 가리키고 있는데요. 이는 전체 스크린 영역에 SafeAreaPadding을 같이 포함시키고 있기 때문입니다.</p><p>그 외에, minX, width, height 값은 모두 동일합니다.</p><p><strong>2. ScrollView 내부 컨텐츠가 스크린 상단에 걸쳐있을때</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/313/1*xw9AghOSbAodS5aESWVz9w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/354/1*0L3LFT9RNNi19niZeLKrPg.png" /></figure><p>스크롤 뷰를 SafeAreaPadding을 넘어 스크린 최상단까지 스크롤할 경우, ScrollView 좌표공간 기준으로는 원래 위치보다 더 SafeAreaPadding만큼 위로 올라갔기 때문에 -62를 가리키는 반면, global 좌표공간은 0을 가르키고 있는 것을 확인할 수 있습니다.</p><p><strong>3. 최하단으로 스크롤하였을 때</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/324/1*jTFw3HDd7YKiW2Xc8kylGQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/501/1*pY8EN2jASjwPahRq8Y_jMA.png" /></figure><p>스크롤을 끝까지 아래로 내렸음에도 불구하고 scrollView와 global 기준 모두 minY가 height와 동일해지지 않았는데요. 그 이유는 minY의 기준점 자체가 컨테이너의 상단점이기 때문에, height만큼 스크롤하려면 가장 하단에 있는 Item 즉 “0” Item이 화면 상단까지 이동되어야 하기 때문입니다. 이는 일반적인 ScrollView에서는 사용자로부터 기대되는 구현방향이 아닙니다. 때문에, 여기에 ScrollView를 품고 있는 부모뷰 자체의 고정 높이값을 충당하여 +-0 이되게끔 연산이 필요합니다.</p><pre>import SwiftUI<br><br>struct CustomScrollView&lt;Content: View, Overlay: View&gt;: View {<br>    @ViewBuilder var content: (ScrollViewProxy) -&gt; Content<br>    <br>    @Namespace private var coordinateSpaceName: Namespace.ID<br>    <br>    var body: some View {<br>        GeometryReader { containerProxy in // 최상단에 GeometryReader 추가<br>            ScrollViewReader { scrollProxy in<br>                ScrollView(.vertical, showsIndicators: true) {<br>                    content(scrollProxy)<br>                        .background {<br>                            GeometryReader { scrollContentProxy in<br>                                Color.clear<br>                            }<br>                        }<br>                }<br>                .scaleEffect(y: -1)<br>                .coordinateSpace(name: coordinateSpaceName)<br>            }<br>        }<br>    }<br>}</pre><p>Scroll offset을 실시간으로 추적하면서 얻을 수 있는 값은 아래와 같습니다.</p><blockquote><em>최하단까지의거리: content(scrollProxy) 높이값 - (스크롤 offset + 컨테이너의 고정높이)</em></blockquote><blockquote><em>최상단까지의거리: 스크롤 offset</em></blockquote><p>이를 PreferenceKey를 통해 content()에서 수집한후, 변화를 추적하도록 하였습니다. PreferenceKey에 대한 설명은 상단에 첨부한 블로그에서도 상세히 다루고 있습니다 :)</p><pre>import SwiftUI<br><br>struct VerticalDistanceKey: PreferenceKey {<br>    static var defaultValue: [CGFloat] = []<br>    static func reduce(value: inout [CGFloat], nextValue: () -&gt; [CGFloat]) {}<br>}<br><br>struct CustomScrollView&lt;Content: View, Overlay: View&gt;: View {<br>    @Binding var distance: (toTop: CGFloat, toBottom: CGFloat)<br>    @ViewBuilder var content: (ScrollViewProxy) -&gt; Content<br>    @Namespace private var coordinateSpaceName: Namespace.ID<br>    <br>    var body: some View {<br>        GeometryReader { containerProxy in // 최상단에 GeometryReader 추가<br>            ScrollViewReader { scrollProxy in<br>                ScrollView(.vertical, showsIndicators: true) {<br>                    content(scrollProxy)<br>                        .background {<br>                            GeometryReader { scrollContentProxy in<br>                                Color.clear<br>                                    .preference(<br>                                        key: VerticalDistanceKey.self,<br>                                        value: [<br>                                            -scrollContentProxy.frame(in: .named(coordinateSpaceName)).minY,<br>                                             scrollContentProxy.frame(in: .named(coordinateSpaceName)).height -<br>                                             (<br>                                                -scrollContentProxy.frame(in: .named(coordinateSpaceName)).minY +<br>                                                 containerProxy.size.height<br>                                             )<br>                                        ]<br>                                    )<br>                            }<br>                        }<br>                }<br>                .scaleEffect(y: -1)<br>                .coordinateSpace(name: coordinateSpaceName)<br>                .onPreferenceChange(VerticalDistanceKey.self) { verticalDistance in<br>                     distance = (toTop : verticalDistance[0], toBottom: verticalDistance[1])<br>                 }<br>            }<br>        }<br>    }<br>}</pre><p>이로써, 이제 CustomScrollView의 부모뷰는 전달받은 verticalDistance 튜플을 통해 offset 변화에 따른 UI 변화, 예를 들어 플로팅버튼 표시나 상단 페이지네이션 트리거가 가능해집니다.</p><pre>struct ContentView: View {<br>    struct Message: Hashable {<br>        let id: UUID = .init()<br>        let text: String<br>    }<br>    <br>    @State var verticalDistance: (toTop: CGFloat, toBottom: CGFloat) = (.zero, .zero)<br>    @State var messages: [Message] = (0..&lt;50).map { Message(text: String($0)) }<br>    <br>    func addMore() async {<br>        DispatchQueue.main.async {<br>            if let lastMessage = messages.last?.text {<br>                let newMessages = (Int(lastMessage)!...Int(lastMessage)!+30).map { i in<br>                    Message(text: String(i))<br>                }<br>                messages.append(contentsOf: newMessages)<br>            }<br>        }<br>    }<br>    <br>    var body: some View {<br>        ZStack {<br>            CustomScrollView(distance: $verticalDistance) { proxy in<br>                LazyVStack(spacing: 0) {<br>                    ForEach(messages, id: \.self) { message in<br>                        Text(&quot;\(message.text)&quot;)<br>                            .frame(maxWidth: .infinity)<br>                            .frame(height: 48, alignment: .center)<br>                            .id(message.id)<br>                            .scaleEffect(y: -1)<br>                    }<br>                }<br>                .background(.gray)<br>            }<br>            // 상단으로부터의 거리가 100보다 줄어들 경우, 상단 페이지네이션 수행<br>            .onChange(of: verticalDistance.toTop) { dist in<br>                if dist &lt; 100 {<br>                    Task {<br>                        await addMore()<br>                    }<br>                }<br>            }<br>            // 하단으로부터의 거리가 100보다 줄어들 경우, 밑으로 이동 버튼 표시<br>            if verticalDistance.toBottom &lt; 100 {<br>                Button(action: {<br>                    withAnimation {<br>                        // first로 한 이유는 scaleEffect(y:-1)로 인해 메시지 순서가 보이는 것과 반대이기 때문입니다.<br>                        if let lastId = messages.first?.id {<br>                            proxy.scrollTo(lastId, anchor: .bottom)<br>                        }<br>                    }<br>                }) {<br>                    Image(systemName: &quot;chevron.down&quot;)<br>                        .renderingMode(.template)<br>                        .foregroundStyle(.gray)<br>                        .frame(width: 48, height: 48, alignment: .center)<br>                        .background(.white, in: Circle())<br>                        .shadow(color: .black.opacity(0.08), radius: 4, x: 0, y: 2)<br>                }<br>                .padding(16)<br>                .transition(.move(edge: .bottom).combined(with: .opacity))<br>                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)<br>            }<br>        }<br>        .animation(.spring, value: verticalDistance.toBottom &gt; 100)<br>    }<br>}</pre><p>하지만, 하단에 보이는 플로팅 버튼은 클릭 시 스크롤 제어를 할 수 있어야 하기에 ScrollProxy에 대한 접근이 필요합니다.</p><pre>import SwiftUI<br><br>struct VerticalDistanceKey: PreferenceKey {<br>    static var defaultValue: [CGFloat] = []<br>    static func reduce(value: inout [CGFloat], nextValue: () -&gt; [CGFloat]) {}<br>}<br><br>struct CustomScrollView&lt;Content: View, Overlay: View&gt;: View {<br>    @Binding var distance: (toTop: CGFloat, toBottom: CGFloat)<br>    var content: (ScrollViewProxy) -&gt; Content<br>    var overlayContent: ((ScrollViewProxy) -&gt; Overlay)?<br>    <br>    init(<br>        distance: Binding&lt;(toTop: CGFloat, toBottom: CGFloat)&gt;,<br>        @ViewBuilder content: @escaping (ScrollViewProxy) -&gt; Content,<br>        @ViewBuilder overlayContent: @escaping (ScrollViewProxy) -&gt; Overlay = { _ in EmptyView() }<br>    ) {<br>        self._distance = distance<br>        self.content = content<br>        self.overlayContent = overlayContent<br>    }<br>    <br>    @Namespace private var coordinateSpaceName: Namespace.ID<br>    <br>    var body: some View {<br>        GeometryReader { containerProxy in<br>            ScrollViewReader { scrollProxy in<br>                ZStack {<br>                    ScrollView(.vertical, showsIndicators: true) {<br>                        content(scrollProxy)<br>                            .background {<br>                                GeometryReader { scrollContentProxy in<br>                                    Color.clear<br>                                        .preference(<br>                                            key: VerticalDistanceKey.self,<br>                                            value: [<br>                                                -scrollContentProxy.frame(in: .named(coordinateSpaceName)).minY,<br>                                                 scrollContentProxy.frame(in: .named(coordinateSpaceName)).height -<br>                                                 (<br>                                                    -scrollContentProxy.frame(in: .named(coordinateSpaceName)).minY +<br>                                                     containerProxy.size.height<br>                                                 )<br>                                            ]<br>                                        )<br>                                }<br>                            }<br>                    }<br>                    .scaleEffect(y: -1)<br>                    .coordinateSpace(name: coordinateSpaceName)<br>                    .onPreferenceChange(VerticalDistanceKey.self) { verticalDistance in<br>                        distance = (toTop : verticalDistance[0], toBottom: verticalDistance[1])<br>                    }<br>                    overlayContent?(scrollProxy)<br>                }<br>            }<br>        }<br>    }<br>}</pre><p>때문에, CustomScrollView 내부에 proxy에 대한 접근이 가능한 overlayContent를 인자로 받을 수 있게끔 설계해, 범용성을 높였습니다.</p><pre>struct ContentView: View {<br>    struct Message: Hashable {<br>        let id: UUID = .init()<br>        let text: String<br>    }<br>    <br>    @State var verticalDistance: (toTop: CGFloat, toBottom: CGFloat) = (.zero, .zero)<br>    @State var messages: [Message] = (0..&lt;50).map { Message(text: String($0)) }<br>    <br>    func addMore() async {<br>        DispatchQueue.main.async {<br>            if let lastMessage = messages.last?.text {<br>                let newMessages = (Int(lastMessage)!...Int(lastMessage)!+30).map { i in<br>                    Message(text: String(i))<br>                }<br>                messages.append(contentsOf: newMessages)<br>            }<br>        }<br>    }<br>    <br>    var body: some View {<br>        CustomScrollView(distance: $verticalDistance) { proxy in<br>            LazyVStack(spacing: 0) {<br>                ForEach(messages, id: \.self) { message in<br>                    Text(&quot;\(message.text)&quot;)<br>                        .frame(maxWidth: .infinity)<br>                        .frame(height: 48, alignment: .center)<br>                        .id(message.id)<br>                        .scaleEffect(y: -1)<br>                }<br>            }<br>            .background(.gray)<br>        } overlayContent: { proxy in<br>            if verticalDistance.toBottom &gt; 100 {<br>                Button(action: {<br>                    withAnimation {<br>                        if let lastId = messages.first?.id {<br>                            proxy.scrollTo(lastId, anchor: .bottom)<br>                        }<br>                    }<br>                }) {<br>                    Image(systemName: &quot;chevron.down&quot;)<br>                        .renderingMode(.template)<br>                        .foregroundStyle(.gray)<br>                        .frame(width: 48, height: 48, alignment: .center)<br>                        .background(.white, in: Circle())<br>                        .shadow(color: .black.opacity(0.08), radius: 4, x: 0, y: 2)<br>                }<br>                .padding(16)<br>                .transition(.move(edge: .bottom).combined(with: .opacity))<br>                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)<br>            }<br>        }<br>        .animation(.spring, value: verticalDistance.toBottom &gt; 100)<br>        .onChange(of: verticalDistance.toTop) { dist in<br>            if dist &lt; 100 {<br>                Task {<br>                    await addMore()<br>                }<br>            }<br>        }<br>    }<br>}</pre><h4><strong>4. 주의점</strong></h4><p>상단 페이지네이션에 호출되는 fetch작업은 일반적으로 비동기적으로 수행됩니다.</p><p>때문에 로딩도중에도 fetch를 반복적으로 연속 트리거할 수 있습니다. 때문에 isLoading 플래그를 통해 불필요한 api 호출을 막아 이를 예방해야합니다.</p><p>감사합니다.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bd23cf47d31e" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[[Jetpack Compose] 커스텀 바텀시트 구현]]></title>
            <link>https://medium.com/@neoself1105/jetpack-compose%EB%A1%9C-%EC%BB%A4%EC%8A%A4%ED%85%80-%EB%B0%94%ED%85%80%EC%8B%9C%ED%8A%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-30bcf095e370?source=rss-18d63a54716d------2</link>
            <guid isPermaLink="false">https://medium.com/p/30bcf095e370</guid>
            <dc:creator><![CDATA[NEON]]></dc:creator>
            <pubDate>Sun, 18 Jan 2026 13:44:13 GMT</pubDate>
            <atom:updated>2026-01-21T08:46:59.216Z</atom:updated>
            <content:encoded><![CDATA[<h4>BackHandler 등록 매커니즘 탐구</h4><h4>0. 구현 배경</h4><p>초기에는 Material3가 제공하는 ModalBottomSheet를 사용하여 바텀시트를 구현하였습니다. 하지만, 내부에 자체 구현한 피커를 사용할 때 피커 조작이 dragDismiss 동작으로 넘어가는 이슈가 있었습니다.</p><p>자체 구현 피커 관련 블로그 링크</p><p><a href="https://medium.com/@neoself1105/jetpack-compose%EB%A1%9C-wheelpicker-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-92b95600eeb4">https://medium.com/@neoself1105/jetpack-compose%EB%A1%9C-wheelpicker-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-92b95600eeb4</a></p><p><strong>0.1. confirmValueChange를 통한 1차 해결 시도</strong></p><p>아래 스택오버플로우 답변을 참고하여 material3에서 제공하는 bottomSheetState 내부 confirmValueChange를 통해 드래그 동작을 통해 바텀시트가 내려가는 이슈를 해결하려 하였으나, 바텀시트가 내려가지는 않지만 드래그 동작이 바텀시트 드래그로 넘어가 피커가 동작되지 않는 이슈가 여전히 존재했습니다.</p><p><a href="https://stackoverflow.com/questions/76472355/how-to-disable-drag-behaviour-on-modalbottomsheet-android-compose">How to disable Drag behaviour on ModalBottomSheet , android compose</a></p><p><strong>0.2. Compose 버전 업데이트를 통한 2차 해결</strong></p><p>material3의 1.4.0 버전부터는 ModalBottomSheet에 SheetGestureEnabled 인자가 추가되었으며, 해당 인자값을 통해 드래그 동작 자체가 수행되지 않도록 제어가 가능해졌습니다.</p><p>하지만, 바텀시트가 표시된 상태에서 네비게이션 바의 뒤로가기 버튼으로 바텀시트를 닫은 후, 다시 동일한 바텀시트를 표시하려 하면 바텀시트가 올라오지 않는 현상이 발생했습니다.</p><figure><img alt="바텀시트를 한번 띄운 후, 뒤로가기 버튼 탭을 통해 바텀시트를 내릴 경우, 동일한 바텀시트가 다시 표시되지 않습니다." src="https://cdn-images-1.medium.com/max/800/1*stbIKpE2JpoqcDpKlkom9g.gif" /></figure><p>이 문제의 원인은 바텀시트 제어 로직의 데이터 일관성 문제에 있었습니다. 선언형 UI 패턴에서 바텀시트를 제어하기 위해 isPresent 불린 상태값을 사용했는데, 내비게이션 바 뒤로가기 시 onDismissRequest 콜백이 호출되지 않는 버그로 인해 isPresent가 false로 변경되지 않았습니다.</p><pre>// 기존 문제가 있던 구조<br>@Composable<br>fun CustomBottomSheet(<br>  isPresent: Boolean,  // 외부에서 주입받는 상태<br>  onDismiss: () -&gt; Unit,<br>  ...<br>) {<br>  if (internalVisible) {<br>    ModalBottomSheet(<br>      onDismissRequest = { /* 시스템 뒤로가기 버튼 클릭 시 호출되지 않음! */ },<br>      ...<br>    )<br>  }<br>}</pre><p>결과적으로 isPresent가 여전히 true인 상태에서 동일한 바텀시트 표시를 시도하면, 상태 변화가 없으므로 아무 동작도수행되지 않았습니다. 이러한 이유로 ModalBottomSheet를 대체할 커스텀 바텀시트를 직접 구현하게 되었습니다.</p><h4>1. 구현 목표</h4><p>위 이유로 인해 ModalBottomSheet를 대체할 수 있는 커스텀 바텀시트를 직접 구현하는 판단을 내리게 되었습니다.</p><p>구현하기에 앞서iOS SwiftUI의 기본 BottomSheet와 동일한 사용자 경험을 제공하기 위해 다음 요구사항을 정의했습니다:</p><ol><li>바텀시트 상단영역만 드래그가 동작</li><li>바텀시트의 열림, 닫힘, 드래그 후 Settling 동작에 <strong>Spring 애니메이션을</strong> 적용</li><li>바텀시트를 최대 높이보다 <strong>위로 더 드래그할 경우</strong>, 드래그 거리에 0.3의 저항 계수를 적용하여 손가락 이동 거리의 30%만 바텀시트가 이동</li><li>Fling 감지: 아래쪽으로 빠르게 튕기듯이 드래그하면 즉시 hide로 변경</li><li><strong>위치 기반:</strong> 드래그가 느릴 경우, 바텀시트 높이의 <strong>50% 이상</strong> 아래로 내렸을 때만 닫히며, 그보다 덜 내리면 다시 원래 위치로 복구</li><li>onOutTapDismissEnabled = true일경우, 바텀시트 외부 영역(Scrim)을 터치하면 바텀시트가 hide로 변경</li><li>어두운 영역(Scrim)은 바텀시트가 노출된 정도(0.0~1.0)에 따라 투명도가 <strong>0% ~ 40%</strong> 사이에서 실시간으로 변경</li></ol><h4>2. 아키텍처 설계: 3계층 구조</h4><p>그 다음, 커스텀 바텀시트를 구현하기 위해 3계층 구조를 설계했습니다:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/304/1*3LaskCelZTrppRLpBRTp2g.png" /></figure><p>이 구조의 핵심은 CustomBottomSheetController가 Single Source of Truth(SSoT) 역할을 수행한다는 점입니다.</p><h4>3. CustomBottomSheetController: 통합된 상태 관리자</h4><pre>// 전체 코드<br>@Stable<br>class CustomBottomSheetController(val scope: CoroutineScope) {<br>    // ==================== 애니메이션 상태 ====================<br>    var status by mutableStateOf(SheetStatus.HIDDEN)<br>        private set<br>    // 애니메이션 진행 도중 연속 트리거 시 발생되는 상태 불일치 문제를 해결을 위해 애니메이션 도중 세우는 플래그입니다.<br>    var isAnimating by mutableStateOf(false)<br>        private set<br>    // 바텀시트가 완전히 펼쳐진 상태(EXPANDED)를 기준으로 얼마나 아래로 내려가 있는지(이동했는지)를 나타내는 값<br>    val offsetY = Animatable(0f)<br>    var sheetHeightPx by mutableFloatStateOf(0f)<br>        private set<br>    // 바텀시트 높이값 수정을 담당하는 이벤트입니다.<br>    var onSlide: ((Float) -&gt; Unit)? = null<br>    // 실제 바텀시트 state를 0.0(hidden)에서 1.0(expanded) 사이의 수치로 치환한 값입니다.<br>    val progress: Float<br>        get() {<br>            if (sheetHeightPx == 0f) return 0f<br>            return 1f - (offsetY.value / sheetHeightPx).coerceIn(0f, 1f)<br>        }<br>    // ==================== 바텀시트 속성 (기존 CustomBottomSheetNavigator) ====================<br>    var content by mutableStateOf&lt;@Composable () -&gt; Unit&gt;({})<br>        private set<br>    var height by mutableStateOf(280.dp)<br>        private set<br>    private var onDismissCallback: (() -&gt; Unit)? = null<br>    private var onTapBackNavBtnCallback: (() -&gt; Unit)? = null<br>    var onTapScrimDismissEnabled by mutableStateOf(true)<br>        private set<br>    var onDragDismissEnabled by mutableStateOf(true)<br>        private set<br>    // ==================== 애니메이션 메서드 ====================<br>    // Initialize state - 바텀시트 높이 업데이트<br>    // 주의: 애니메이션 진행 중에는 snapTo()를 호출하지 않습니다. snapTo()가 animateTo()를 취소시키기 때문입니다.<br>    internal fun updateSheetHeight(heightPx: Float) {<br>        if (this.sheetHeightPx != heightPx) {<br>            this.sheetHeightPx = heightPx<br>            // 애니메이션 중이면 높이만 업데이트하고 offset 조정은 스킵<br>            if (isAnimating) return<br>            // 만일 HIDDEN 상태이면, offset을 강제로 조정합니다.<br>            if (status == SheetStatus.HIDDEN) {<br>                scope.launch {<br>                    offsetY.snapTo(heightPx)<br>                }<br>            } else if (status == SheetStatus.EXPANDED) {<br>                scope.launch {<br>                    offsetY.snapTo(0f)<br>                }<br>            }<br>        } else {<br>            // 애니메이션 중이면 스킵<br>            if (isAnimating) return<br>            // 만일 status가 HIDDEN인데 offset가 height와 불일치할 경우는 offset을 강제로 heightPx에 맞춥니다.<br>            if (status == SheetStatus.HIDDEN &amp;&amp; offsetY.value != heightPx) {<br>                scope.launch { offsetY.snapTo(heightPx) }<br>            }<br>        }<br>    }<br>    internal suspend fun animateTo(target: SheetStatus) {<br>        // 애니메이션 진행 중이면 무시<br>        if (isAnimating) return<br>        val targetOffset = when (target) {<br>            SheetStatus.EXPANDED -&gt; 0f<br>            SheetStatus.HIDDEN -&gt; sheetHeightPx<br>        }<br>        isAnimating = true<br>        try {<br>            if (target == SheetStatus.EXPANDED) {<br>                status = target<br>            }<br>            offsetY.animateTo(<br>                targetValue = targetOffset,<br>                animationSpec = spring(<br>                    dampingRatio = Spring.DampingRatioLowBouncy,<br>                    stiffness = Spring.StiffnessMedium<br>                )<br>            )<br>            if (target == SheetStatus.HIDDEN) {<br>                status = target<br>            }<br>            // Ensure final value sync<br>            if (target == SheetStatus.EXPANDED &amp;&amp; offsetY.value != 0f) offsetY.snapTo(0f)<br>            if (target == SheetStatus.HIDDEN &amp;&amp; offsetY.value != sheetHeightPx) offsetY.snapTo(sheetHeightPx)<br>        } finally {<br>            isAnimating = false<br>        }<br>    }<br>    // 실제 바텀시트 높이값을 특정 수치로 이동시킵니다.<br>    internal fun dispatchSlide() {<br>        onSlide?.invoke(progress)<br>    }<br>    // ==================== 바텀시트 제어 메서드 ====================<br>    /**<br>     * 바텀시트를 표시합니다.<br>     * 데이터 경쟁 조건 방지를 위해 상태 변경과 애니메이션을 순차적으로 처리합니다.<br>     */<br>    fun show(<br>        height: Dp = 280.dp,<br>        onDismiss: () -&gt; Unit,<br>        onTapBackNavBtn: () -&gt; Unit,<br>        isOutTapDismissEnabled: Boolean = true,<br>        isDragDismissEnabled: Boolean = true,<br>        content: @Composable () -&gt; Unit<br>    ) {<br>        // 애니메이션 진행 중이면 무시<br>        if (isAnimating) return<br>        // 속성 설정<br>        this.height = height<br>        this.onDismissCallback = onDismiss<br>        this.onTapBackNavBtnCallback = onTapBackNavBtn<br>        this.onTapScrimDismissEnabled = isOutTapDismissEnabled<br>        this.onDragDismissEnabled = isDragDismissEnabled<br>        this.content = content<br>        // 애니메이션 시작<br>        scope.launch {<br>            animateTo(SheetStatus.EXPANDED)<br>        }<br>    }<br>    /** @hide 자식뷰의 ViewModel 비즈니스 로직을 통해 CustomBottomSheet에 주입되는 isPresent가 false로 변경되면,<br>     * CustomBottomSheetProvider가 이를 감지하여 hide()를 호출합니다.<br>     *  */<br>    fun hide() {<br>        // 애니메이션 진행 중이면 무시<br>        if (isAnimating) return<br>        scope.launch {<br>            animateTo(SheetStatus.HIDDEN)<br>        }<br>    }<br>    internal fun onSheetDismiss() {<br>        // 애니메이션 진행 중이면 무시<br>        if (isAnimating) return<br><br>        scope.launch {<br>            animateTo(SheetStatus.HIDDEN)<br>            onDismissCallback?.invoke()<br>        }<br>    }<br>    internal fun onTapSheetBackNavBtn() {<br>        // 애니메이션 진행 중이면 무시<br>        if (isAnimating) return<br>        onTapBackNavBtnCallback?.invoke()<br>    }<br>    // 드래그 완료 후 상태 결정 및 애니메이션 처리<br>    internal fun handleDragEnd(velocityY: Float) {<br>        val currentY = offsetY.value<br>        val thresholdVelocity = 500f // arbitrary threshold for &quot;Fast&quot;<br>        val isFlingDown = velocityY &gt; thresholdVelocity<br>        val isFlingUp = velocityY &lt; -thresholdVelocity<br>        val target = if (isFlingDown) {<br>            SheetStatus.HIDDEN<br>        } else if (isFlingUp) {<br>            SheetStatus.EXPANDED<br>        } else {<br>            if (currentY &gt; sheetHeightPx / 2) SheetStatus.HIDDEN else SheetStatus.EXPANDED // Distance based<br>        }<br>        scope.launch {<br>            animateTo(target)<br>            if (target == SheetStatus.HIDDEN) {<br>                onDismissCallback?.invoke()<br>            }<br>        }<br>    }<br>    // 드래그 취소 시 현재 상태로 복귀<br>    internal fun handleDragCancel() {<br>        scope.launch { animateTo(status) }<br>    }<br>    // 드래그 중 offset 업데이트<br>    internal fun handleDrag(dragAmount: Float) {<br>        scope.launch {<br>            val resistanceFactor = if (offsetY.value &lt; 0) 0.3f else 1f<br>            val newOffset = offsetY.value + dragAmount * resistanceFactor<br>            offsetY.snapTo(newOffset)<br>        }<br>    }<br>}</pre><p><strong>3.1. 기존 구조의 문제점</strong></p><p>초기 구현에서는 CustomBottomSheetState(애니메이션 상태)와 CustomBottomSheetNavigator(바텀시트 속성)를 분리했습니다. 이로 인해 여러 LaunchedEffect가 각각의 상태를 관찰하고 동기화해야 했습니다:</p><pre>// 기존 구조 - 다수의 LaunchedEffect로 인한 문제<br>@Composable<br>fun BottomSheet(...) {  // LaunchedEffect 1: isPresent 변화 감지<br>  LaunchedEffect(isPresent) {<br>  if (isPresent) navigator.show(...) else navigator.hide()<br>  }<br>  // LaunchedEffect 2: sheetState 연결<br>  LaunchedEffect(sheetState) {<br>      navigator.sheetState = sheetState<br>  }<br>  // LaunchedEffect 3: 높이 업데이트<br>  LaunchedEffect(sheetHeightPx) {<br>      state.updateSheetHeight(sheetHeightPx)<br>  }<br>  // LaunchedEffect 4: 상태 변화 콜백<br>  LaunchedEffect(state.status) {<br>      if (state.status == SheetStatus.HIDDEN &amp;&amp; isPresent) {<br>          onDismiss?.invoke()<br>      }<br>  }<br>  ...<br>}</pre><p><strong>3.2. Compose 프레임 사이클과 데이터 경쟁 조건</strong></p><p>Compose는 다음과 같은 3단계 프레임 사이클을 거칩니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/284/1*YkXn4vFpNv8g82wuB4gOWA.png" /></figure><p>핵심 문제: LaunchedEffect들은 Composition이 완료된 후 같은 시점에 동시에 스케줄됩니다. 실행 순서는 코루틴 디스패처에 의존하며 보장되지 않습니다. 이로 인해 다음과 같은 데이터 경쟁 조건이 발생했습니다.</p><p>경쟁 조건 시나리오</p><blockquote><em>LaunchedEffect A: isPresent=true 감지 → show() 호출</em></blockquote><blockquote><em>LaunchedEffect B: sheetHeightPx 변경 감지 → offset 조정</em></blockquote><blockquote><em>LaunchedEffect C: status 변경 감지 → 콜백 호출</em></blockquote><blockquote><strong>실행 순서가 보장되지 않아 다음과 같은 문제 발생:</strong></blockquote><blockquote><em>1. show()가 아직 완료되지 않은 상태에서 offset이 조정됨</em></blockquote><blockquote><em>2. 애니메이션 중간에 상태가 변경되어 화면 깜빡임 발생</em></blockquote><blockquote><em>3. 콜백이 예상치 못한 시점에 호출됨</em></blockquote><p><strong>3.3. 해결책: 단일 Controller로 통합</strong></p><p>BottomSheetState와 CustomBottomSheetNavigator를 CustomBottomSheetController 하나로 통합했습니다.</p><pre>@Stable<br>class CustomBottomSheetController(val scope: CoroutineScope) {<br>  // ==================== 애니메이션 상태 (기존 BottomSheetState) ====================<br>  var status by mutableStateOf(SheetStatus.HIDDEN)<br>      private set<br>  // 애니메이션 진행 중 플래그 - 경쟁 조건 방지의 핵심<br>  var isAnimating by mutableStateOf(false)<br>      private set<br>  val offsetY = Animatable(0f)<br>  var sheetHeightPx by mutableFloatStateOf(0f)<br>      private set<br>  // ==================== 바텀시트 속성 (기존 Navigator) ====================<br>  var content by mutableStateOf&lt;@Composable () -&gt; Unit&gt;({})<br>      private set<br>  var height by mutableStateOf(280.dp)<br>      private set<br>  private var onDismissCallback: (() -&gt; Unit)? = null<br>  // ...<br>}</pre><blockquote>@Stable 어노테이션은 Compose 컴파일러에게 이 클래스의 인스턴스가 안정적임을 알립니다. 모든 가변 프로퍼티가 MutableState로 래핑되어 있으므로 변경 시 Composition에 자동으로 알림이 전달됩니다. 이로써 Controller를 파라미터로 받는 Composable에서 불필요한 Recomposition이 방지됩니다.</blockquote><p><strong>3.4. isAnimating 플래그를 통한 경쟁 조건 방지</strong></p><p>2 인스턴스를 단일 객체로 통합한 후에는 애니메이션이 진행 중일 때 추가적인 상태 변경을 차단하는 isAnimating 플래그를 도입하여 레이스 컨디션을 해결했습니다.</p><pre>internal suspend fun animateTo(target: SheetStatus) {<br>// 애니메이션 진행 중이면 무시 - 중복 호출 방지<br>if (isAnimating) return<br>  isAnimating = true<br>  try {<br>      // EXPANDED로 전환 시: 먼저 status 변경 후 애니메이션<br>      // → UI가 즉시 표시되고 애니메이션이 부드럽게 진행<br>      if (target == SheetStatus.EXPANDED) {<br>          status = target<br>      }<br>      offsetY.animateTo(<br>          targetValue = targetOffset,<br>          animationSpec = spring(<br>              dampingRatio = Spring.DampingRatioLowBouncy,<br>              stiffness = Spring.StiffnessMedium<br>          )<br>      )<br>      // HIDDEN으로 전환 시: 애니메이션 완료 후 status 변경<br>      // → 애니메이션이 끝까지 재생된 후 UI 제거<br>      if (target == SheetStatus.HIDDEN) {<br>          status = target<br>      }<br>  } finally {<br>      isAnimating = false<br>  }<br>  ```<br>}</pre><p><strong>3.5. 높이 업데이트 시 애니메이션 보호</strong></p><p>updateSheetHeight 메서드에서도 애니메이션 상태를 확인합니다.</p><pre>internal fun updateSheetHeight(heightPx: Float) {<br>  if (this.sheetHeightPx != heightPx) {<br>    this.sheetHeightPx = heightPx<br>    // 애니메이션 중이면 높이만 업데이트하고 offset 조정은 스킵<br>    // → snapTo()가 animateTo()를 취소시키는 문제 방지<br>    if (isAnimating) return<br>    if (status == SheetStatus.HIDDEN) {<br>        scope.launch { offsetY.snapTo(heightPx) }<br>    } else if (status == SheetStatus.EXPANDED) {<br>        scope.launch { offsetY.snapTo(0f) }<br>    }<br>  }<br>  ```<br>}</pre><p>Animatable.snapTo()는 진행 중인 animateTo()를 즉시 취소시킵니다. 애니메이션 중에 snapTo()가 호출되면 애니메이션이 중단되고 값이 점프하여 사용자 경험이 저하됩니다. isAnimating 플래그로 이를 방지합니다.</p><h4>4. InternalCustomBottomSheet: UI 컴포넌트</h4><pre>// 전체 코드<br>@Composable<br>fun InternalCustomBottomSheet(<br>    controller: CustomBottomSheetController,<br>    content: @Composable () -&gt; Unit<br>) {<br>    val density = LocalDensity.current<br>    val navBottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()<br>    // 하단 네비게이션 바 높이를 포함한 전체 높이값입니다.<br>    val fullSheetHeight = controller.height + navBottomPadding<br>    val sheetHeightPx = with(density) { fullSheetHeight.toPx() }<br>    // CustomBottomSheet가 포함된 자식뷰가 구성될 때, 자식뷰에서 정의한 height는 controller에 의해 이곳으로 주입되며 이에 맞춰 Px로 변환됩니다.<br>    // Composition 시점에 동기적으로 높이 업데이트 (LaunchedEffect 제거로 경쟁 조건 방지)<br>    controller.updateSheetHeight(sheetHeightPx)<br>    // offsetY 변경 시 슬라이드 콜백 호출<br>    controller.dispatchSlide()<br>    if (controller.status != SheetStatus.HIDDEN) {<br>        BackHandler(enabled = controller.status != SheetStatus.HIDDEN) {<br>            controller.onTapSheetBackNavBtn()<br>        }<br>        Box(<br>            modifier = Modifier.fillMaxSize(),<br>            contentAlignment = Alignment.BottomCenter<br>        ) {<br>            if (controller.status != SheetStatus.HIDDEN || controller.offsetY.value &lt; controller.sheetHeightPx) {<br>                Box(<br>                    modifier = Modifier<br>                        .fillMaxSize()<br>                        .graphicsLayer {<br>                            alpha = (controller.progress * 0.4f).coerceIn(0f, 0.4f)<br>                        }<br>                        .background(Color.Black)<br>                        .pointerInput(Unit) {<br>                            detectTapGestures(<br>                                onTap = {<br>                                    if (controller.onTapScrimDismissEnabled) {<br>                                        controller.onSheetDismiss()<br>                                    }<br>                                }<br>                            )<br>                        }<br>                )<br>            }<br>            // Sheet Content<br>            Column(<br>                modifier = Modifier<br>                    .widthIn(max = 420.dp)<br>                    .offset { IntOffset(0, controller.offsetY.value.roundToInt()) }<br>                    .height(fullSheetHeight)<br>                    .padding(bottom = navBottomPadding)<br>                    .background(<br>                        color = Gray10,<br>                        shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)<br>                    )<br>                    .drawBehind {<br>                        drawRect(<br>                            color = Gray10,<br>                            topLeft = Offset(0f, size.height - 1f), // Overlap 1px to avoid gap<br>                            size = Size(size.width, density.density * 1000f) // Extend 1000dp down<br>                        )<br>                    }<br>                    // Apply Drag Gesture to the whole sheet container<br>                    .pointerInput(controller.onDragDismissEnabled) {<br>                        if (!controller.onDragDismissEnabled) return@pointerInput<br>                        val velocityTracker = VelocityTracker()<br>                        detectVerticalDragGestures(<br>                            onDragStart = {<br>                                velocityTracker.resetTracking()<br>                            },<br>                            onDragEnd = {<br>                                // Req 2.2: Decision<br>                                val velocityY = velocityTracker.calculateVelocity().y<br>                                controller.handleDragEnd(velocityY)<br>                            },<br>                            onDragCancel = {<br>                                controller.handleDragCancel()<br>                            }<br>                        ) { change, dragAmount -&gt;<br>                            change.consume()<br>                            controller.handleDrag(dragAmount)<br>                        }<br>                    }<br>            ) {<br>                content()<br>            }<br>        }<br>    }<br>}</pre><p><strong>4.1. 저항 계수(Resistance Factor) 구현</strong></p><p>Jetpack Compose의 ModalBottomSheet와 iOS SwiftUI의 기본 바텀시트의 경우, 한계점 위로 더 드래그 동작을 수행할 경우, 고무줄처럼 팽팽하게 늘어나는 효과를 줍니다. 이와 동일한 시각적 효과를 부여하기 위해 드래그 업데이트 로직에 <strong>저항 계수</strong>를 도입했습니다.</p><pre>internal fun handleDrag(dragAmount: Float) {<br>  scope.launch {<br>    // offsetY &lt; 0: 바텀시트가 최대 높이 위로 올라간 상태<br>    val resistanceFactor = if (offsetY.value &lt; 0) 0.3f else 1f<br>    val newOffset = offsetY.value + dragAmount * resistanceFactor<br>    offsetY.snapTo(newOffset)<br>  }<br>}</pre><p>offsetY가 0보다 작아지는 순간(이미 완전히 펼쳐진 상태에서 위로 더 당기는 경우), 드래그 거리의 30%만 반영됩니다. 이는 iOS의 rubber banding 효과와 동일한 사용자 경험을 제공합니다.</p><p><strong>4.2. 속도(Fling)와 거리(Distance) 적용</strong></p><p>사용자가 바텀시트를 놓았을 때, 바텀시트를 닫을지 다시 펼칠지를 결정하는 로직을 구현하기 위해 저는 두 가지 기준을 결합하여 판정 로직을 구축했습니다.</p><ul><li><strong>관성(Fling) 기반:</strong> 사용자가 아래로 빠르게 튕기면, 현재 위치와 상관없이 ‘닫으려는 의도’로 간주하고 즉시 HIDDEN으로 보냅니다.</li><li><strong>거리(Distance) 기반:</strong> 천천히 움직일 때는 바텀시트 전체 높이의 <strong>50%를 기준점</strong>으로 삼습니다. 절반 이하로 내렸다면 다시 돌아오고, 절반 이상 내렸다면 닫힙니다.</li></ul><pre>internal fun handleDragEnd(velocityY: Float) {<br>    val currentY = offsetY.value<br>    val thresholdVelocity = 500f<br>    val isFlingDown = velocityY &gt; thresholdVelocity<br>    val isFlingUp = velocityY &lt; -thresholdVelocity<br>  <br>    val target = when {<br>        isFlingDown -&gt; SheetStatus.HIDDEN      // 빠르게 아래로 튕김<br>        isFlingUp -&gt; SheetStatus.EXPANDED      // 빠르게 위로 튕김<br>        currentY &gt; sheetHeightPx / 2 -&gt; SheetStatus.HIDDEN   // 50% 이상 내림<br>        else -&gt; SheetStatus.EXPANDED                          // 50% 미만 내림<br>    }<br>  <br>    scope.launch {<br>        animateTo(target)<br>        if (target == SheetStatus.HIDDEN) {<br>            onDismissCallback?.invoke()<br>        }<br>    }<br>  <br>  ```<br>}</pre><p><strong>4.3. Scrim 애니메이션과 graphicsLayer 최적화</strong></p><pre>Box(<br>  modifier = Modifier<br>    .fillMaxSize()<br>    .graphicsLayer {<br>        alpha = (controller.progress * 0.4f).coerceIn(0f, 0.4f)<br>    }<br>    .background(Color.Black)<br>)</pre><p>graphicsLayer의 람다 버전을 사용한 이유:</p><ol><li>그리기 단계 격리: 람다 버전은 Drawing 단계에서만 실행되어, alpha 값 변경이 Recomposition을 트리거하지 않습니다.</li><li>하드웨어 가속: graphicsLayer는 별도의 렌더링 레이어를 생성하여 GPU 가속을 활용합니다.</li><li>성능 최적화: 60fps 애니메이션에서 매 프레임 Recomposition 없이 투명도만 변경됩니다.</li></ol><p>이를 통해 시트가 위로 올라올수록 배경은 자연스럽게 어두워집니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*07Lci3qsVKqSTtqblBeCOA.gif" /></figure><p><strong>4.4. drawBehind를 통한 하단 영역 확장</strong></p><p>현재 바텀시트가 펼쳐진 상태에서 상단으로 드래그를 할 경우, 저항계수가 적용된 채로 바텀시트가 더 올라가도록 설계가 되어있지만, 바텀시트 UI 영역 자체의 높이값은 초기 SheetState에서 계산된 height만 감싸기 때문에, 바텀시트가 끊어져 보이는 현상이 발생합니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*MxhzW8iU2Cjp2Xq-wDZG3g.gif" /></figure><p>이를 해결하기 위해 drawBehind 수정자를 활용하여 하단에 하얀 배경의 여백을 추가했습니다.</p><pre>.drawBehind {<br>    drawRect(<br>        color = Gray10,<br>        topLeft = Offset(0f, size.height - 1f),<br>        size = Size(size.width, density.density * 1000f) // 하단으로 1000dp만큼 연장<br>    )<br>}</pre><p>drawBehind는 Drawing 단계에서 실행되므로 Composition이나 Layout에 영향을 주지 않습니다. 1000dp 높이의 사각형을 그려어떤 오버스크롤 상황에서도 배경색이 유지됩니다.</p><h4>5. CustomBottomSheetProvider: 전역 주입</h4><p>만일 InternalBottomSheet를 바텀시트를 표시해야하는 Screen에서 바로 호출하게 될 경우, 앱 내부의 바텀 네비게이션 탭에 의해 바텀시트 일부가 가려지는 이슈가 존재 했습니다.</p><p>때문에, 바텀 네비게이션 탭보다 위에 표시되게 하기 위해, BottomSheet가 표시되는 위치를 Root로 이동시켜야 합니다.</p><pre>val LocalCustomBottomSheetController = compositionLocalOf&lt;CustomBottomSheetController&gt; {<br>    error(&quot;No CustomBottomSheetController provided&quot;)<br>}<br><br>@Composable<br>fun CustomBottomSheetProvider(content: @Composable () -&gt; Unit) {<br>  val scope = rememberCoroutineScope()<br>  val controller = remember(scope) { CustomBottomSheetController(scope) }<br>  <br>    CompositionLocalProvider(LocalCustomBottomSheetController provides controller) {<br>        Box(modifier = Modifier.fillMaxSize()) {<br>            content()  // 앱의 실제 화면들<br>  <br>            // Z-index 최상위에 바텀시트 배치<br>            InternalCustomBottomSheet(controller = controller) {<br>                controller.content()<br>            }<br>        }<br>    }<br>}<br><br>// 앱 루트<br>@AndroidEntryPoint<br>class MainActivity : ComponentActivity() {<br>    override fun onStart() {<br>        super.onStart()<br>        ...<br>    }<br>    override fun onCreate(savedInstanceState: Bundle?) {<br>        ...<br>        setContent {<br>            CustomBottomSheetProvider { // 바텀시트 호출 위치<br>                // 앱 화면<br>            }<br>        }<br>    }<br>    override fun onDestroy() {<br>        super.onDestroy()<br>    }<br>}</pre><p>이때, CompositionLocalProvider를 통해 controller를 하위 모든 Composable에 암시적으로 전달합니다. 이는 iOS의 @EnvironmentObject와 유사한 역할을 수행합니다.</p><h4>6. CustomBottomSheet: 선언형 API</h4><p>최종 사용 API는 iOS SwiftUI의 .sheet(isPresented:)와 유사합니다:</p><pre>@Composable<br>fun CustomBottomSheet(<br>  isPresent: Boolean,<br>  height: Dp = 280.dp,<br>  onDismiss: () -&gt; Unit,<br>  onTapBackNavBtn: (() -&gt; Unit)? = null,<br>  onTapScrimDismissEnabled: Boolean = true,<br>  onDragDismissEnabled: Boolean = true,<br>  content: @Composable () -&gt; Unit<br>) {<br>  val controller = LocalCustomBottomSheetController.current<br>  val currentOnDismiss by rememberUpdatedState(onDismiss)<br>  val currentContent by rememberUpdatedState(content)<br><br>  LaunchedEffect(isPresent, height) {<br>      if (isPresent) {<br>          controller.show(<br>              height = height,<br>              onDismiss = { currentOnDismiss() },<br>              onTapBackNavBtn = currentOnTapBackNavBtn ?: onDismiss,<br>              isOutTapDismissEnabled = onTapScrimDismissEnabled,<br>              isDragDismissEnabled = onDragDismissEnabled,<br>              content = { currentContent() }<br>          )<br>      } else {<br>          controller.hide()<br>      }<br>  }<br><br>  DisposableEffect(Unit) {<br>      onDispose {<br>          if (isPresent) {<br>              controller.hide()<br>              currentOnDismiss()<br>          }<br>      }<br>  }<br>}</pre><p><strong>6.1. rememberUpdatedState의 역할</strong></p><p>LaunchedEffect는 키 값이 변경될 때만 재시작됩니다. 하지만 onDismiss 콜백 자체는 Recomposition마다 새로운 람다 인스턴스가 될 수 있습니다. rememberUpdatedState는 항상 최신 콜백 참조를 유지하면서 LaunchedEffect 재시작을 방지합니다.</p><p><strong>6.2. DisposableEffect를 통한 정리</strong></p><p>화면 이동 등으로 Composable이 Composition에서 제거될 때, 열려있는 바텀시트를 정리합니다.</p><pre>DisposableEffect(Unit) {<br>  onDispose {<br>    if (isPresent) {<br>      controller.hide()<br>      currentOnDismiss()<br>    }<br>}</pre><p><strong>6.3.실제 사용 예시</strong></p><pre>@Composable<br>fun MyScreen(viewModel: MyViewModel) {<br>val uiState by viewModel.uiState.collectAsState()<br>```<br>  CustomBottomSheet(<br>      isPresent = uiState.isBottomSheetVisible,<br>      height = 320.dp,<br>      onDismiss = { // 바텀시트가 내려갈때, 수행되어야 할 로직}<br>  ) {<br>      // 바텀시트 내용<br>  }<br>  // 나머지 화면 구성...<br>```<br>}</pre><h4>7. 결론</h4><p>이번 커스텀 바텀시트 구현을 통해 아래 핵심 인사이트들을 얻을 수 있었습니다.</p><p><strong>7.1. 데이터 경쟁 조건 해결</strong></p><ul><li>문제: 다수의 LaunchedEffect가 동시에 스케줄되어 실행 순서가 보장되지 않음</li><li>해결: 상태와 애니메이션을 단일 Controller로 통합하고, isAnimating 플래그로 중복 호출 차단</li><li>교훈: Compose에서 복잡한 상태 동기화가 필요할 때, 여러 LaunchedEffect보다 단일 상태 홀더가 더 안전하다는 것을 체감했습니다.</li></ul><p><strong>7.2. Compose 프레임 사이클 이해</strong></p><ul><li>Composition → Layout → Drawing 순서 이해가 필수</li><li>LaunchedEffect는 Drawing 이후 실행되므로 동기적 초기화가 필요한 경우 주의가 필요하다는 것을 알게 되었습니다.</li><li>graphicsLayer 람다 버전은 Drawing 단계에서만 실행되어 성능 최적화에 유리합니다.</li></ul><p>때로는 라이브러리를 가져다 쓰는 것이 가장 빠르고 효율적인 정답일 수 있습니다. 하지만 서비스의 핵심 경험이 되는 컴포넌트라면, 원리를 파헤치고 직접 구현해 보는 과정이 필수적이라고 생각합니다.</p><p>긴 글 읽어주셔서 감사합니다. 궁금하신 점이나 더 나은 구현 방식에 대한 의견은 댓글로 언제든 환영입니다 :)</p><h4>8. 문제해결과정- 바텀시트 표시 간, 시스템 Back 이벤트 미도달 이슈</h4><p>iOS에서의 바텀시트 표시로직을 제어할때와 달리, android는 소프트웨어 네비게이션 버튼을 통해 바텀시트가 표시되어있을 동안, 뒤로가기 이벤트 호출이 가능합니다. 만일 바텀시트 내부에 2개 이상의 뎁스가 존재할 경우, 뒤로가기 이벤트가 바텀시트 hide 뿐만 아니라 자식뷰에서 전달하는 비즈니스 로직 콜백도 호출할 수 있어야 했기에, CustomBottomSheet api를 통해 onTapBackNavBtn 인자를 전달한 후, InternalBottomSheet 컴포저블에서 BackHandler 훅을 사용하여 Back 이벤트 핸들링을 처리하고자 했습니다.</p><pre>@Composable<br>fun InternalHMBottomSheet(<br>    controller: HMBottomSheetController,<br>    content: @Composable () -&gt; Unit<br>) {<br>    ...<br>    // Controller에서 관리 및 저장하는 SystemBack 이벤트 콜백을 BackHandler 내부에서 호출<br>    BackHandler(enabled = controller.status != HMSheetStatus.HIDDEN) {<br>      controller.onTapSheetBackNavBtn()<br>    }<br><br>    if (controller.status != HMSheetStatus.HIDDEN) {<br>      // 바텀시트<br>    }<br>}</pre><p>하지만, 바텀시트가 표시된 상태에서 System Back을 호출하여도 바텀시트는 그대로 유지되고, 이를 호출한 자식뷰에서의 뒤로가기 동작이 대신 수행되는 문제가 있었습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/534/1*_HSjfRAaXMk_JTBSMgln2w.png" /></figure><p>이를 해결하기 위해, BackHandler의 공식문서와 내부 구현 코드를 봤습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/533/1*86a5529veDQIDP7NMcdL9A.png" /></figure><p>이를 확인하고자, BottomSheet 내부 BackHandler가 호출되게끔 BottomSheet api를 호출한 자식뷰를 구성하기까지의 tree 구조 상에서 Backhandler를 모두 제거하였으나, backStack에 화면이 남아있을때는 pop() 동작이 호출되나, 더이상 뒤로갈 화면이 없을 때에만 바텀시트가 내려가는 상황이 재현되었습니다.</p><p>이러한 현상의 원인은 NavHost의 내부 구조때문이였습니다.</p><pre>@Composable<br>public fun NavHost(<br>    ...<br>) {<br>    ...<br>    val currentBackStack by composeNavigator.backStack.collectAsState()<br>    // backStack이 남아있을 때에 BackHandler 내부 콜백이 호출됨!<br>    BackHandler(currentBackStack.size &gt; 1) {<br>        navController.popBackStack()<br>    }<br>}</pre><pre>CustomBottomSheetProvider {  <br>      Box {<br>          NavHost {<br>              ...<br>          }<br>          // 동일한 위계 상에 위치한 BottomSheet 내부 Backhandler는 <br>          // NavHost의 BackHandler가 호출되지 않을때 호출됨.<br>          InternalCustomBottomSheet(...)  <br>      }<br>  }</pre><p>여기서 2차로 의문점이 든 것은 NavHost보다 BottomSheet 컴포저블 함수가 더 밑에 선언되어있기에 Top-down 방향으로 호출되는 Jetpack Compose의 구성 사이클에 따라 BottomSheet의 BackHandler가 무조건 우선순위를 점하는게 정상이지 않을까? 였습니다.</p><pre>CustomBottomSheetProvider {<br>    Box {<br>        NavHost {<br>            ... // 1. 여기서 내장된 Backhandler 등록하고 내부 컴포저블 함수 실행<br>        }<br>        <br>        InternalCustomBottomSheet(...)  // 2. 내부 Backhandler 등록<br>    }<br>}</pre><p>하지만, 이는 BackHandler의 내부구현 코드를 통해 해소될 수 있었습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/620/1*mBYKZm1wtKnkJz9n3hHQ3g.png" /></figure><pre>@Composable<br>public fun BackHandler(enabled: Boolean = true, onBack: () -&gt; Unit) {<br>    // Back 콜백을 계속 추적하며 가장 최근에 주입된 값으로 바꿔끼고 있음<br>    val currentOnBack by rememberUpdatedState(onBack)<br>    val backCallback = remember {<br>        object : OnBackPressedCallback(enabled) {<br>            override fun handleOnBackPressed() {<br>                currentOnBack()<br>            }<br>        }<br>    }<br>    ...<br>}</pre><p>BackHandler가 가장 nested Screen에서 등록된 것이 최종 실행된다고 공식문서에서 이유는, BackHandler는 내부적으로 <strong>가장 최종</strong>으로 등록된 onBack만을 저장하고 호출하기 때문입니다.</p><p>NavHost는 내부에 선언된 Screen들의 상태변수에 따라 재구성될 수 있으며, 이때마다 BackHandler를 재등록할 수 있습니다. 때문에, 같은 위계 상 더 상단에 위치하였음에도 BackHandler 콜백을 가로챌 수 있던 것입니다.</p><p>특히, 선언형 구조로 바텀시트를 제어하기 위해 바텀시트 표시 간 isPresent의 불린 타입 상태변수를 ViewModel에서 관리 및 제어하고 있었기에, 바텀시트를 열 경우, isPresent를 관리하는 자식 뷰의 상태가 변경됨에 따라 재구성되었고, 이를 포함하고 있던 NavHost 또한 재구성이 되며 BackHandler가 재등록되고 있었습니다.</p><pre>@Composable<br>fun InternalHMBottomSheet(<br>    controller: HMBottomSheetController,<br>    content: @Composable () -&gt; Unit<br>) {<br>    ...<br>    // NavHost의 BackHandler 재등록 타이밍보다 이르게 도달됨<br><br>    if (controller.status != HMSheetStatus.HIDDEN) {<br>      // 바텀시트 표시 애니메이션 완료 후, State가 변경됨에 따라 가장 최종적으로 도달됨<br>      // BackHandler를 안정적으로 등록할 수 있음<br>      BackHandler(enabled = controller.status != HMSheetStatus.HIDDEN) {<br>        controller.onTapSheetBackNavBtn()<br>      }<br>      // 바텀시트<br>    }<br>}</pre><p>때문에, BackHandler를 조건부 composition 블록 내부에 배치함으로써 등록 시점을 의도적으로 늦추어 바텀시트에 대한 Back 이벤트를 안정적으로 호출할 수 있도록 하여 해결할 수 있었습니다.</p><p>감사합니다.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=30bcf095e370" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[[Jetpack Compose] iOS 스타일의 WheelPicker 구현]]></title>
            <link>https://medium.com/@neoself1105/jetpack-compose%EB%A1%9C-wheelpicker-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-92b95600eeb4?source=rss-18d63a54716d------2</link>
            <guid isPermaLink="false">https://medium.com/p/92b95600eeb4</guid>
            <category><![CDATA[mobile-app-development]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[user-experience]]></category>
            <category><![CDATA[jetpack-compose]]></category>
            <dc:creator><![CDATA[NEON]]></dc:creator>
            <pubDate>Wed, 08 Oct 2025 17:37:39 GMT</pubDate>
            <atom:updated>2026-01-28T07:39:51.482Z</atom:updated>
            <content:encoded><![CDATA[<p>최근 회원가입 및 세부 설정 화면에서 iOS와 동일한 휠 피커 사용경험을 만들기 위해 Wheel Picker를 직접 구현하게 되었습니다.</p><p>Wheel Picker를 직접 구현하는 결정을 내린 이유는, 우선 <br>1. Jetpack Compose는 SwiftUI와 달리 Wheel Picker를 기본제공하지 않았습니다.<br>2. 이를 위해 가장 많이 사용되는 서드파티 라이브러리의 경우(하단 참조), SwiftUI에서의 기본 api와 달리 인접 아이템을 탭했을 때, 포커스가 바로 이동하지 않아 사용자 경험이 떨어졌습니다.<br>3. 그 외에도, 포커스 하이라이트, 페이드 등 UX를 결정짓는 UI 요소들이 큰 차이를 보였습니다.</p><p><a href="https://github.com/zj565061763/compose-wheel-picker">GitHub - zj565061763/compose-wheel-picker: Android Compose wheel picker library based on LazyColumn in vertical and LazyRow in horizontal.</a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/295/1*eSVvONewPuPVgJbCctw60g.gif" /><figcaption>역시 깔끔한 건 iOS다. 근데 왜 안드로이드 하고 있지</figcaption></figure><p>무엇보다 앱 핵심 기능 수행을 위한 상호작용 시에는 안드로이드와 iOS 간 룩앤필은 큰 차이가 없어야 한다는 개인 원칙 때문에 공수를 더 들여서라도 커스텀 작업을 진행하게 되었습니다.</p><h3><strong>1. Wheel Picker 초기 구현</strong></h3><p>초기 피커 UI 구현에 가장 많이 참고한 레퍼런스는 Stack Overflow에서 제시된 InfiniteCircularList 였습니다.</p><p><a href="https://stackoverflow.com/questions/69734451/is-there-a-way-to-create-scroll-wheel-in-jetpack-compose">Is there a way to create Scroll Wheel in Jetpack Compose?</a></p><p>해당 모듈에서는 나머지 연산자를 중심으로 LazyColumn에서 다루는 item element가 단방향으로 스와이프하여도 계속 순환하여 보이게끔 설계되어있었습니다. 유한한 범위 내에서의 선택이 목적이였던 만큼 해당 코드를 무한순환 로직을 아래와 같이 제거하였습니다.</p><pre>@Composable<br>fun MyPicker(<br>    itemHeight: Dp,<br>    numberOfDisplayedItems: Int = 3,<br>    unit: String,<br>    items: List&lt;String&gt;,<br>    initialItem: String,<br>    textStyle: TextStyle,<br>    textColor: Color,<br>    onItemSelected: (index: Int, item: String) -&gt; Unit = { _, _ -&gt; }<br>) {<br>    val itemHalfHeight = LocalDensity.current.run { itemHeight.toPx() / 2f }<br>    val scrollState = rememberLazyListState(0)<br>    var lastSelectedIndex by remember {<br>        mutableStateOf(0)<br>    }<br><br>    var itemsState by remember {<br>        mutableStateOf(items)<br>    }<br><br>    LaunchedEffect(Unit) {<br>        val initialItemIndex = items.indexOf(initialItem)<br><br>        itemsState = List(1) { &quot;&quot; } + items + List(1) { &quot;&quot; }<br>        lastSelectedIndex = initialItemIndex<br>        scrollState.scrollToItem(initialItemIndex)<br>    }<br><br>    Box(<br>        modifier = Modifier.fillMaxSize(),<br>        contentAlignment = Alignment.Center<br>    ) {<br>        LazyColumn(<br>            modifier = Modifier<br>                .fillMaxWidth()<br>                .height(itemHeight * numberOfDisplayedItems),<br>            state = scrollState,<br>            flingBehavior = rememberSnapFlingBehavior(<br>                lazyListState = scrollState<br>            )<br>        ) {<br>            items(<br>                count = itemsState.size,<br>                itemContent = { i -&gt;<br>                    val item = itemsState[i]<br>                    val isSelected = lastSelectedIndex == i<br><br>                    Row(<br>                        modifier = Modifier<br>                            .height(itemHeight)<br>                            .fillMaxWidth()<br>                            .onGloballyPositioned { coordinates -&gt;<br>                                val y = coordinates.positionInParent().y + itemHalfHeight<br>                                val parentHalfHeight = (itemHalfHeight * numberOfDisplayedItems)<br>                                val isCurrentlySelected = (parentHalfHeight - itemHalfHeight &lt; y &amp;&amp; y &lt; parentHalfHeight + itemHalfHeight)<br>                                if (isCurrentlySelected &amp;&amp; lastSelectedIndex != i &amp;&amp; item.isNotEmpty()) {<br>                                    onItemSelected(i - 1, item) // i-1 because first item is empty padding<br>                                    lastSelectedIndex = i<br>                                }<br>                            },<br>                        horizontalArrangement = Arrangement.Center<br>                    ) {<br>                        if (item.toString().isNotEmpty()) {<br>                            Text(<br>                                text = item.toString(),<br>                                style = textStyle,<br>                                modifier = Modifier.padding(end = 4.dp),<br>                                color = if (isSelected) Gray80 else textColor.copy(alpha = 0.6f),<br>                                fontSize = if (isSelected) textStyle.fontSize * 1.2f else textStyle.fontSize<br>                            )<br><br>                            Text(<br>                                text = unit,<br>                                style = textStyle,<br>                                color = if (isSelected) Gray80 else textColor.copy(alpha = 0.6f),<br>                                fontSize = if (isSelected) textStyle.fontSize * 1.2f else textStyle.fontSize<br>                            )<br>                        }<br>                    }<br>                }<br>            )<br>        }<br><br>        Box(<br>            modifier = Modifier<br>                .fillMaxWidth()<br>                .height(itemHeight)<br>                .drawWithContent {<br>                    drawContent()<br>                    drawRoundRect(<br>                        color = Gray20,<br>                        cornerRadius = CornerRadius(8.dp.toPx(), 8.dp.toPx()),<br>                        blendMode = BlendMode.Multiply<br>                    )<br>                }<br>        )<br>    }<br>}</pre><ol><li>itemHeight, numberOfDisplayedItems, unit 등을 외부에서 주입받아 재사용할 수 있도록 설계</li><li>LaunchedEffect(Unit)에서 주입받은 초기 아이템이 위치한 offset으로 스크롤되도록 고도화</li></ol><p>3. onGloballyPositioned로 항목 중심 좌표를 비교하며 포커스 판별</p><blockquote>onGloballyPositioned가 호출되는 시점<br>1. 초기 레이아웃 배치되는 경우<br>2. 사용자에 의한 스크롤이나 scrollToItem으로 위치나 크기가 변경되는 경우<br>3. 재평가 후 실제 레이아웃이 변경되는 경우</blockquote><p>하지만 무한 순환 로직을 제거하게 되면서, 피커 UI의 중앙에 첫번째 아이템과 마지막 아이템을 선택하지 못하는 치명적인 이슈가 있었습니다.</p><p>Wheel Picker UI 특성상, 드래그 동작을 통해 선택하고자 하는 item을 중앙으로 이동시켜야 포커싱이 가능한데, 아이템을 중앙으로 옮겨줄 수 있는 인접 Padding이 부재했기 때문입니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/970/1*weswe7WJ2N1cCAD0dqYCFw.png" /></figure><p>해당 문제를 해결하고자, List(1) { “” } + items + List(1) { “” } 와 같이, 더미 항목을 아이템 배열 앞뒤에 붙임으로써 해결하였습니다.</p><p>하지만 numberOfDisplayedItems와 itemHeight에 맞춰 LazyColumn의 높이 강제가 필요했고, numberOfDisplayedItems에 짝수를 전달할 시 아래와 같이 정중앙이 비어 포커스가 틀어지는 문제가 있었습니다.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/563/1*rq5y3emqcMdNOa_j_buuvQ.png" /><figcaption>numberOfDisplayedItems에 4를 전달할 경우 보이는 UI</figcaption></figure><p>차지하는 영역에 따라 UI가 동적으로 배치되는 것이 아닌, 주어진 인자에 따라 정적인 UI를 구성하는 설계 방향은 폴더블과 같은 다양한 폼팩터에 대응해야하는 안드로이드에는 치명적인 제약으로 다가왔습니다.</p><p>때문에 SwiftUI의 Picker처럼 “영역에 맞춰 자동으로 높이를 계산하는 구조”로의 변경이 불가피했습니다.</p><h3><strong>2. SubcomposeLayout을 통한 높이 계산 로직 추가</strong></h3><p>지정된 영역 내에서의 아이템 정렬을 구현하기 위해, Jetpack Compose가 제공하는 <strong>SubcomposeLayout</strong>을 사용하였습니다. SubcomposeLayout은 커스텀 레이아웃 도구로, 측정(measure) 단계에서 필요한 슬롯만 별도 서브 컴포지션 (subcomposition) 해 높이·너비를 직접 얻을 수 있습니다.</p><pre>@Composable<br>fun TestPicker(<br>    modifier: Modifier = Modifier,<br>    items: List&lt;String&gt;,<br>    initialItem: String,<br>    onItemSelected: (Int, String) -&gt; Unit = { _, _ -&gt; },<br>    content: @Composable ((String, Boolean) -&gt; Unit)<br>) {<br>    val density = LocalDensity.current<br>    val scrollState = rememberLazyListState(0)<br>    var lastSelectedIndex by remember { mutableStateOf(0) }<br>    var itemsState by remember { mutableStateOf(items) }<br>    var itemHeight by remember { mutableStateOf(0.dp) }<br>    var itemHeightPx by remember { mutableStateOf(0f) }<br>    var pickerHeight by remember { mutableStateOf(0.dp) }<br>    var pickerHeightPx by remember { mutableStateOf(0f) }<br><br>    SubcomposeLayout { constraints -&gt;<br>        println(&quot;SubcomposeLayout 실행:${System.currentTimeMillis()}&quot;)<br>        if (itemHeight == 0.dp) {<br>            val sampleItem = items.firstOrNull() ?: &quot;&quot;<br>            val measurables = subcompose(&quot;sample&quot;) {<br>                content(sampleItem, false)<br>            }<br>            val placeables = measurables.map { it.measure(constraints) }<br>            val heightPx = placeables.firstOrNull()?.height?.toFloat() ?: 0f<br>            itemHeight = with(density) { heightPx.toDp() }<br>            itemHeightPx = heightPx<br>        }<br><br>        layout(0, 0) {}<br>    }<br><br>    LaunchedEffect(itemHeightPx, pickerHeightPx) {<br>        println(&quot;LaunchedEffect 실행:${System.currentTimeMillis()}&quot;)<br>        if (itemHeightPx &gt; 0f &amp;&amp; pickerHeightPx &gt; 0f) {<br>            val paddingItemsCount = kotlin.math.floor((pickerHeightPx / 2f) / itemHeightPx).toInt()<br>            itemsState = List(paddingItemsCount) { &quot;&quot; } + items + List(paddingItemsCount) { &quot;&quot; }<br>            val targetIndex = items.indexOf(initialItem)<br>            lastSelectedIndex = targetIndex<br>            scrollState.scrollToItem(targetIndex)<br>        }<br>    }<br><br>    Box(<br>        modifier = modifier<br>            .fillMaxWidth()<br>            .onGloballyPositioned { coordinates -&gt;<br>                val actualHeightPx = coordinates.size.height.toFloat()<br>                if (actualHeightPx &gt; 0f &amp;&amp; pickerHeightPx != actualHeightPx) {<br>                    pickerHeightPx = actualHeightPx<br>                    pickerHeight = with(density) { actualHeightPx.toDp() }<br>                }<br>            },<br>        contentAlignment = Alignment.Center<br>    ) {<br>        LazyColumn(<br>            modifier = Modifier.fillMaxSize(),<br>            state = scrollState,<br>            flingBehavior = rememberSnapFlingBehavior(<br>                lazyListState = scrollState<br>            )<br>        ) {<br>            items(<br>                count = itemsState.size,<br>                itemContent = { i -&gt;<br>                    val item = itemsState[i]<br>                    val isSelected = lastSelectedIndex == i<br><br>                    Row(<br>                        modifier = Modifier<br>                            .fillMaxWidth()<br>                            .height(itemHeight)<br>                            .onGloballyPositioned { coordinates -&gt;<br>                                if (pickerHeightPx &gt; 0f &amp;&amp; itemHeightPx &gt; 0f) {<br>                                    val y = (coordinates.positionInParent().y) + (itemHeightPx / 2f)<br>                                    val parentHalfHeight = (pickerHeightPx / 2f)<br>                                    val isCurrentlySelected = abs(parentHalfHeight - y) &lt;= (itemHeightPx / 2f)<br><br>                                    if (isCurrentlySelected &amp;&amp; lastSelectedIndex != i &amp;&amp; item.isNotEmpty()) {<br>                                        val paddingItemsCount = floor((pickerHeightPx / 2f) / itemHeightPx).toInt()<br>                                        val actualIndex = i - paddingItemsCount<br>                                        if (actualIndex &gt;= 0 &amp;&amp; actualIndex &lt; items.size) {<br>                                            onItemSelected(actualIndex, item)<br>                                            lastSelectedIndex = i<br>                                        }<br>                                    }<br>                                }<br>                            },<br>                        horizontalArrangement = Arrangement.Center<br>                    ) {<br>                        content(item.toString(), isSelected)<br>                    }<br>                }<br>            )<br>        }<br><br>        Box(<br>            modifier = Modifier<br>                .fillMaxWidth()<br>                .height(itemHeight)<br>                .drawWithContent {<br>                    drawRoundRect(<br>                        color = Gray20,<br>                        cornerRadius = CornerRadius(8.dp.toPx()),<br>                        blendMode = BlendMode.Multiply<br>                    )<br>                }<br>        )<br>    }<br>}</pre><p>Compose는 매 프레임마다 (1) 재구성(recomposition) → (2) 레이아웃(측정→배치) → (3) 드로잉 순으로 진행하는데, SubcomposeLayout 블록은 2단계의 “측정” 시점에 호출됩니다. 따라서 최종 구현된 패턴은 아래 순서와 같았습니다.</p><ol><li>SubcomposeLayout { constraints -&gt; … } 내부에서 content(sampleItem, false)를 subcompose하여, 아이템 높이(itemHeightPx) 측정</li><li>같은 측정 단계에서 부모가 확보한 영역을 onGloballyPositioned로 기록해 pickerHeightPx에 저장</li><li>레이아웃 단계 이후, 첫 번째 LaunchedEffect(itemHeightPx, pickerHeightPx)가 실행되면서 상태 변경</li><li>동일 LaunchedEffect안에서 paddingItemsCount를 계산하고, 더미 항목을 양끝에 붙인 itemsState를 만든 뒤 scrollState.scrollToItem(targetIndex)로 초기 스크롤</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/558/1*4yz345ytsIqJNMeEIxtUfw.png" /></figure><p>하지만, 부모 컴포저블로부터 제공받은 영역을 100% 활용함에도 불구하고, 양 끝 Padding은 item 단위로 추가되다 보니, 스냅되는 item offset이 중앙을 살짝 이탈하는 이슈가 있었습니다.</p><p><strong>SubcomposeLayout의 제약(재진입성)</strong></p><p>또한, measurement 단계에서 Composition을 수행하는 SubcomposeLayout의 재진입 동작은 상태 읽기/쓰기 타이밍을 복잡하게 만들고, 예기치 않은 재컴포지션을 유발할 수 있었습니다.</p><p>특히, LaunchedEffect는 해당 컴포저블이 Composition 단계에 들어올때 coroutine을 시작하고 나가면 취소되는데, SubcomposeLayout은 측정 중에 subcompose를 여러 번 호출하거나 슬롯을 새로 만들 수 있어 <strong>해당 컴포저블이 여러 번 enter/leave</strong> 하거나 여러 인스턴스가 생성될 가능성이 크며, 이로 인해 LaunchedEffect가 여러 번 실행될 수 있었습니다.</p><pre>SubcomposeLayout 실행:1759940310247<br>SubcomposeLayout 실행:1759940310349<br>LaunchedEffect 실행:1759940310434<br>// 초기 Compose 단계에서 불필요한 재호출 발생<br>Skipped 40 frames!  The application may be doing too much work on its main thread.<br>SubcomposeLayout 실행:1759940310877<br>LaunchedEffect 실행:1759940310905</pre><p>실제로, SubcomposeLayout과 LaunchedEffect에 로그를 심어둔 결과, 위와 같이 불필요한 재호출이 일어나고 있음을 확인할 수 있었습니다.</p><p>이는 당장 해결이 필요한 문제는 아니였지만, 추후 재컴포지션으로 의도치 않은 동작을 야기할 수 있었기에 변수를 아예 제거하는 방향으로 고도화 결정을 내리게 되었습니다.</p><h3><strong>3. 렌더링 로직 재설계</strong></h3><p>상기 문제들을 해결하기 위해 우선 <br>1. SubcomposeLayout를 활용하였던 초기 측정로직 복잡도를 개선하였으며, <br>2. 패딩 아이템 대신 영역 높이 기반 정확한 수치를 사이에 삽입하였습니다.</p><pre>@Composable<br>fun Picker(<br>    modifier: Modifier = Modifier,<br>    items: List&lt;String&gt;,<br>    initialItem: String,<br>    onItemSelected: (Int, String) -&gt; Unit = { _, _ -&gt; },<br>    content: @Composable ((String, Boolean) -&gt; Unit)<br>) {<br>    val density = LocalDensity.current<br>    val scrollState = rememberLazyListState(0)<br>    var lastSelectedIndex by remember { mutableStateOf(0) }<br><br>    val itemHeight = 44.dp<br>    val itemHeightPx = with(density) { itemHeight.toPx() }<br><br>    BoxWithConstraints(<br>        modifier = modifier.fillMaxWidth(),<br>        contentAlignment = Alignment.Center<br>    ) {<br>        val availableHeight = this.constraints.maxHeight.toFloat()<br>        /** 부모 뷰로부터 할당받은 영역을 최대한 활용토록 하되, 220.dp를 상한선으로 설정합니다. */<br>        val currentPickerHeightPx = if (availableHeight == Constraints.Infinity.toFloat()) {<br>            with(density) { 220.dp.toPx() }<br>        } else {<br>            availableHeight<br>        }<br><br>        /** 초기 스크롤 위치를 계산하여 수행합니다. */<br>        LaunchedEffect(currentPickerHeightPx) {<br>            val targetIndex = items.indexOf(initialItem)<br>            /** initialItem이 items 내부에 존재하지 않아 targetIndex가 정수로 반환되지 않을 경우, 안전하게 0을 반환토록 설계합니다. */<br>            val safeTargetIndex = if (targetIndex &gt;= 0) targetIndex else 0<br>            <br>            lastSelectedIndex = safeTargetIndex<br>            scrollState.scrollToItem(safeTargetIndex)<br>        }<br><br>        LazyColumn(<br>            modifier = Modifier.fillMaxSize(),<br>            state = scrollState,<br>            flingBehavior = rememberSnapFlingBehavior(scrollState),<br>            /** 양끝 값 또한 스크롤하여 선택할 수 있도록 상 하단에 (전체높이 - 단일 아이템 높이)/2 만큼 높이 패딩을 줍니다. */<br>            contentPadding = PaddingValues(vertical = with(density) {<br>                ((currentPickerHeightPx - itemHeightPx) / 2f).toDp()<br>            })<br>        ) {<br>            items(<br>                count = items.size,<br>                itemContent = { i -&gt;<br>                    val item = items[i]<br><br>                    Row(<br>                        modifier = Modifier<br>                            .fillMaxWidth()<br>                            .height(itemHeight)<br>                            .onGloballyPositioned { coordinates -&gt;<br>                                val y = (coordinates.positionInParent().y) + (itemHeightPx / 2f)<br>                                val parentHalfHeight = (currentPickerHeightPx / 2f)<br>                                val isCurrentlySelected = kotlin.math.abs(parentHalfHeight - y) &lt;= (itemHeightPx / 2f)<br><br>                                if (isCurrentlySelected &amp;&amp; lastSelectedIndex != i &amp;&amp; item.isNotEmpty()) {<br>                                    onItemSelected(i, item)<br>                                    lastSelectedIndex = i<br>                                }<br>                            },<br>                        horizontalArrangement = Arrangement.Center,<br>                        verticalAlignment = Alignment.CenterVertically<br>                    ) {<br>                        content(item, lastSelectedIndex == i)<br>                    }<br>                }<br>            )<br>        }<br><br>        Box(<br>            modifier = Modifier<br>                .fillMaxWidth()<br>                .height(itemHeight)<br>                .drawWithContent {<br>                    drawRoundRect(<br>                        color = Gray20,<br>                        cornerRadius = CornerRadius(8.dp.toPx()),<br>                        blendMode = BlendMode.Multiply<br>                    )<br>                }<br>        )<br>    }<br>}</pre><p><strong>3.1. 고정 아이템 높이와 BoxWithConstraints</strong></p><p>우선, 아이템 높이를 iOS 기준과 동일한 36dp 상수로 지정하였습니다. 아이템 고정높이에 대한 힌트를 얻을 수 있던 것은, 의외로 SwiftUI의 Picker였습니다.</p><p>초기 SwiftUI Picker를 사용하면서 item 내부 글자가 일정 크기를 넘어갈 경우, crop되는 이슈가 있어 답답함을 많이 느꼈었는데, SwiftUI의 Picker처럼 각 item의 높이값을 아예 고정하면, 높이 계산 로직으로 발생할 수 있는 변수를 최소화할 수 있습니다.</p><p>이후 SubcomposeLayout 대신, BoxWithConstraints의 maxHeight로 부모가 제공하는 영역을 측정했습니다. 이는 부모의 제약을 composition 시점에 재진입 없이 읽을 수 있게 해주기 때문에, 변수를 최소화합니다. 만약 상위에서 높이를 주지 않았다면 Constraints.Infinity가 들어오는데, 이때는 220.dp(36dp × 5개)를 상한으로 삼아 currentPickerHeightPx를 결정하도록 했습니다.</p><p><strong>3.2. 더미 항목 → contentPadding 치환</strong></p><p>기존 LaunchedEffect의 경우, scrollState.scrollToItem(safeTargetIndex)만 수행하도록 역할을 축소시켰으며, 문제의 원인이였던 더미 item 대신 세부 높이값을 직접 LazyColumn의 contentPadding으로 주입하여 양 끝 아이템을 선택할 수 있도록 했습니다.</p><p><strong>3.3.포커스 판별의 단순화</strong></p><p>더미 item을 제거하게 됨에 따라, onGloballyPositioned 내부에서의 포커스 판별 로직에서의 추가 계산 또한 제거하였습니다. onGloballyPositioned는 Drawing까지 진행이 되고 난 후에 호출되기에 실제 배치 결과를 기준으로 판별하기 때문입니다.</p><p>이렇게 렌더링 로직을 재설계하면서, 높이에 따른 제약을 최소화하고 다양한 폼팩터에서도 안정적인 포커싱을 보장할 수 있게 되었습니다.</p><h3><strong>4. 디테일 마감 작업</strong></h3><p>렌더링 구조가 안정화된 뒤에는 iOS 플랫폼 사용경험과 큰 차이를 느끼지 못하게 하는 것을 목표로 디테일을 다듬었습니다.</p><p><strong>4.1. 리스트 상·하단에 페이드 스크림 추가</strong></p><p>우선 중앙 하이라이트 박스는 그대로 유지하되, 리스트 상·하단에 두 개의 Box를 LazyColumn 위에 겹치고 각각 Brush.verticalGradient를 적용해 위쪽은 연한 하얀색→ 투명, 아래쪽은 투명 → 하얀색으로 그라데이션을 만들었습니다.</p><p>앞서 전달드렸듯 Compose는 재구성(recomposition) → 레이아웃(측정→배치) → 드로잉 순으로 진행됩니다. 때문에, 배치 이후 drawWithContent 단계에서 그라데이션 및 중앙 박스를 추가해 스크롤되는 리스트 위에 고정된 효과(하이라이트, 페이드)가 얹어질 수 있도록 했습니다.</p><p><strong>4.2. 인접 아이템 탭 상호작용 시, 포커스 변경되도록 고도화</strong></p><p>마지막으로, 인접 항목을 탭하면 즉시 포커스가 이동하도록 UX를 마감했습니다. 각 항목 Row에 pointerInput(i) 를 추가하고 detectTapGestures의 onTap 콜백에서 scrollState.animateScrollToItem(i)를 호출하도록 했습니다.</p><blockquote>이때 rememberCoroutineScope()을 사용한 이유는 “이 컴포저블이 화면에 있는 동안만 유효한 코루틴 스코프”를 얻기 위해서입니다. 만일, ClobalScope 등을 통해 컴포저블의 생명주기와 불일치하게 코루틴을 호출할 경우, CancelledCoroutine 예외나 메모리 누수로 이어질 수 있습니다.</blockquote><p>아이템 탭을 통해 위치가 변경되어도, 포커스 여부 판별에 핵심적인onGloballyPositioned를 트리거시키지 않는다면, 아무런 의미가 없을 것입니다. 다행히 animateScrollToItem 호출 시, Compose는 내부적으로 한 프레임(화면이 한번 그려지는 주기 = 통상 16ms)씩 위치를 갱신하면서 각 아이템의 실제 위치 또한 새로 계산됩니다. onGloballyPositioned는 배치가 끝난 직후마다 호출되는 콜백이기에 포커스 판별 로직에서도 엣지케이스를 고려할 필요가 없어집니다.</p><h3><strong>5. 최종 코드 및 결과물</strong></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/400/1*ju7_exCisbeJ8LgGXNkk1g.gif" /></figure><pre>// Wheel Picker<br>@Composable<br>fun WheelPicker(<br>    modifier: Modifier = Modifier,<br>    items: List&lt;String&gt;,<br>    initialItem: String,<br>    onItemSelected: (Int, String) -&gt; Unit = { _, _ -&gt; },<br>    content: @Composable ((String, Boolean) -&gt; Unit)<br>) {<br>    val density = LocalDensity.current<br>    val scrollState = rememberLazyListState(0)<br>    var lastSelectedIndex by remember { mutableStateOf(0) }<br>    val coroutineScope = rememberCoroutineScope()<br><br>    val itemHeight = 36.dp<br>    val itemHeightPx = with(density) { itemHeight.toPx() }<br><br>    Column(modifier) {<br>        BoxWithConstraints(<br>            modifier = Modifier<br>                .fillMaxWidth()<br>                .drawWithContent {<br>                    drawContent()<br>                    val centerY = size.height / 2f<br>                    val rectTop = centerY - (itemHeightPx / 2f)<br>                    val rectHeight = itemHeightPx<br>                    drawRoundRect(<br>                        color = Gray20,<br>                        cornerRadius = CornerRadius(8.dp.toPx()),<br>                        blendMode = BlendMode.Multiply,<br>                        topLeft = Offset(0f, rectTop),<br>                        size = Size(size.width, rectHeight)<br>                    )<br>                },<br>            contentAlignment = Alignment.Center<br>        ) {<br>            val availableHeight = this.constraints.maxHeight.toFloat()<br>            val currentPickerHeightPx = if (availableHeight == Constraints.Infinity.toFloat()) {<br>                with(density) { 220.dp.toPx() }<br>            } else {<br>                availableHeight<br>            }<br><br>            LaunchedEffect(currentPickerHeightPx) {<br>                val targetIndex = items.indexOf(initialItem)<br>                val safeTargetIndex = if (targetIndex &gt;= 0) targetIndex else 0<br><br>                lastSelectedIndex = safeTargetIndex<br>                scrollState.scrollToItem(safeTargetIndex)<br>            }<br><br>            val pickerHeightDp = with(density) { currentPickerHeightPx.toDp() }<br>            val fadeHeightDp =<br>                with(density) { ((currentPickerHeightPx - itemHeightPx) / 2f).toDp() }<br><br>            LazyColumn(<br>                modifier = Modifier<br>                    .fillMaxWidth()<br>                    .height(pickerHeightDp),<br>                state = scrollState,<br>                flingBehavior = rememberSnapFlingBehavior(scrollState),<br>                contentPadding = PaddingValues(vertical = fadeHeightDp)<br>            ) {<br>                items(<br>                    count = items.size,<br>                    itemContent = { i -&gt;<br>                        val item = items[i]<br><br>                        Row(<br>                            modifier = Modifier<br>                                .fillMaxWidth()<br>                                .height(itemHeight)<br>                                .pointerInput(i) {<br>                                    detectTapGestures(<br>                                        onTap = {<br>                                            coroutineScope.launch {<br>                                                scrollState.animateScrollToItem(i)<br>                                            }<br>                                        }<br>                                    )<br>                                }<br>                                .onGloballyPositioned { coordinates -&gt;<br>                                    val y = (coordinates.positionInParent().y) + (itemHeightPx / 2f)<br>                                    val parentHalfHeight = (currentPickerHeightPx / 2f)<br>                                    val isCurrentlySelected = abs(parentHalfHeight - y) &lt;= (itemHeightPx / 2f)<br><br>                                    if (isCurrentlySelected &amp;&amp; lastSelectedIndex != i &amp;&amp; item.isNotEmpty()) {<br>                                        onItemSelected(i, item)<br>                                        lastSelectedIndex = i<br>                                    }<br>                                },<br>                            horizontalArrangement = Arrangement.Center,<br>                            verticalAlignment = Alignment.CenterVertically<br>                        ) {<br>                            content(item, lastSelectedIndex == i)<br>                        }<br>                    }<br>                )<br>            }<br><br>            Box(<br>                modifier = Modifier<br>                    .fillMaxWidth()<br>                    .height(fadeHeightDp)<br>                    .align(Alignment.TopCenter)<br>                    .drawWithContent {<br>                        drawRect(<br>                            brush = Brush.verticalGradient(<br>                                colors = listOf(White, White.copy(alpha = 0f))<br>                            )<br>                        )<br>                    }<br>            )<br><br>            Box(<br>                modifier = Modifier<br>                    .fillMaxWidth()<br>                    .height(fadeHeightDp)<br>                    .align(Alignment.BottomCenter)<br>                    .drawWithContent {<br>                        drawRect(<br>                            brush = Brush.verticalGradient(<br>                                colors = listOf(White.copy(alpha = 0f), White)<br>                            )<br>                        )<br>                    }<br>            )<br>        }<br>    }<br>}</pre><pre>// Wheel Picker 사용 화면<br>@Composable fun TestScreen() {<br>    val isLoading by viewModel.isLoading.collectAsState()<br><br>    var value by remember { mutableStateOf(&quot;5&quot;) }<br>    var isBottomSheetPresent by remember { mutableStateOf(false) }<br><br>    Column(<br>        modifier = Modifier<br>            .imePadding()<br>            .navigationBarsPadding(),<br>    ) {<br>        Spacer(modifier = Modifier.weight(1f))<br><br>        Box(<br>            modifier = Modifier<br>                .fillMaxWidth()<br>                .height(48.dp)<br>                .padding(horizontal = 32.dp)<br>                .border(width = 1.dp, color = Black, shape = RoundedCornerShape(size = 8.dp))<br>                .background(White),<br>            contentAlignment = Alignment.Center<br>        ) {<br>            Text(<br>                text = value,<br>                style = HMFont.subhead3,<br>                color = Black,<br>                modifier = Modifier.padding(horizontal = 16.dp)<br>            )<br>        }<br><br>        Spacer(modifier = Modifier.weight(1f))<br><br>        HMPicker(<br>            modifier = Modifier<br>                .height(420.dp)<br>                .padding(horizontal = 16.dp),<br>            items = (40 downTo 1).map { it.toString() },<br>            initialItem = value,<br>            onItemSelected = { index, selectedValue -&gt;<br>                value = selectedValue<br>            }<br>        )<br>        { item, isSelected -&gt;<br>            if (item.toString().isNotEmpty()) {<br>                Text(<br>                    text = item.toString(),<br>                    modifier = Modifier.padding(end = 4.dp),<br>                    color = if (isSelected) Black else Grey,<br>                )<br><br>                Text(<br>                    text = &quot;단위&quot;,<br>                    color = if (isSelected) Black else Grey,<br>                )<br>            }<br>        }<br>    }<br>}</pre><h3><strong>6. 결론</strong></h3><ul><li>Compose 파이프라인을 다시 정리할 수 있었습니다. SubcomposeLayout이 측정 단계에 끼어드는 순간 상태 타이밍이 얼마나 복잡해지는지 경험했고, BoxWithConstraints + 고정 높이 조합으로 구조를 단순화하면 재배치·재구성 오버헤드를 크게 줄일 수 있다는 사실을 체감할 수 있었습니다.</li><li>onGloballyPositioned처럼 배치 이후 호출되는 콜백을 중심에 두면, 스크롤/애니메이션 방식이 달라져도 동일한 포커스 판별 로직을 재사용할 수 있다는 점을 확인했습니다.</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=92b95600eeb4" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>