<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>xxx592.log</title>
        <link>https://velog.io/</link>
        <description>이력서 https://resume.eunsu.pro</description>
        <lastBuildDate>Sun, 20 Sep 2026 04:14:43 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>xxx592.log</title>
            <url>https://velog.velcdn.com/images/diso592/profile/f35fb5f5-bf03-44e5-a1c7-3596dba3e05c/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. xxx592.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/diso592" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[React Native의 Modal (1. 개념)]]></title>
            <link>https://velog.io/@diso592/React-Native%EC%9D%98-Modal-1.-%EA%B0%9C%EB%85%90</link>
            <guid>https://velog.io/@diso592/React-Native%EC%9D%98-Modal-1.-%EA%B0%9C%EB%85%90</guid>
            <pubDate>Sun, 20 Sep 2026 04:14:43 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p><code>Modal</code> 은 React Native 가 기본으로 제공하는 코어 컴포넌트다. 현재 화면 위에 콘텐츠를 띄워 사용자의 흐름을 잠시 끊고 확인이나 입력을 받을 때 쓴다. 알림, 확인 다이얼로그, 이미지 뷰어, 짧은 폼 정도가 흔한 용도다.</p>
<p>이름만 보면 <code>zIndex</code> 를 크게 준 <code>View</code> 와 비슷해 보이지만 실제 동작은 다르다. Modal 은 네이티브 계층에서 별도의 표시 영역으로 올라간다. iOS 는 <code>UIViewController</code> 의 모달 프리젠테이션을 쓰고, Android 는 별도의 <code>Dialog</code> 윈도우로 렌더링된다. 여기서 말하는 윈도우는 운영체제가 화면에 무언가를 그리려고 할당하는 독립적인 표시 영역이다. 보통 &quot;앱 화면 하나&quot;가 윈도우 하나에 해당한다.</p>
<p>그래서 Modal 은 React Navigation 스택이든 앱 안의 어떤 <code>zIndex</code> 든 상관없이 항상 위에 떠 있다. 편리한 특성이지만 동시에 이 글 뒷부분에 나오는 제약들의 원인이기도 하다.</p>
<p><br><br></p>
<h2 id="기본-사용법">기본 사용법</h2>
<pre><code class="language-tsx">import React, {useState} from &#39;react&#39;;
import {Modal, View, Text, Pressable, StyleSheet} from &#39;react-native&#39;;

export default function ModalExample() {
  const [visible, setVisible] = useState(false);

  return (
    &lt;View style={styles.container}&gt;
      &lt;Pressable onPress={() =&gt; setVisible(true)}&gt;
        &lt;Text&gt;모달 열기&lt;/Text&gt;
      &lt;/Pressable&gt;

      &lt;Modal
        animationType=&quot;slide&quot;
        transparent
        visible={visible}
        onRequestClose={() =&gt; setVisible(false)}&gt;
        &lt;View style={styles.backdrop}&gt;
          &lt;View style={styles.sheet}&gt;
            &lt;Text&gt;모달 내용&lt;/Text&gt;
            &lt;Pressable onPress={() =&gt; setVisible(false)}&gt;
              &lt;Text&gt;닫기&lt;/Text&gt;
            &lt;/Pressable&gt;
          &lt;/View&gt;
        &lt;/View&gt;
      &lt;/Modal&gt;
    &lt;/View&gt;
  );
}

const styles = StyleSheet.create({
  container: {flex: 1, justifyContent: &#39;center&#39;, alignItems: &#39;center&#39;},
  backdrop: {
    flex: 1,
    backgroundColor: &#39;rgba(0,0,0,0.5)&#39;,
    justifyContent: &#39;center&#39;,
    alignItems: &#39;center&#39;,
  },
  sheet: {
    backgroundColor: &#39;white&#39;,
    padding: 24,
    borderRadius: 12,
  },
});</code></pre>
<p>패턴 자체는 단순하다. <code>visible</code> 을 부모 컴포넌트의 state 로 들고 있고, 닫히는 모든 경로에서 그 state 를 <code>false</code> 로 내려주면 된다.</p>
<p>주의할 점은 <code>visible</code> 이 <code>false</code> 인 동안 Modal 의 children 은 렌더되지 않는다는 것이다. 상태가 유지된 채 숨어 있는 게 아니라, 열 때마다 자식 트리 전체가 새로 마운트된다. 모달 안에 무거운 리스트나 차트가 있으면 여는 순간 그 마운트 비용을 한꺼번에 치른다.</p>
<p>커스텀 배경을 만들 거라면 <code>transparent</code> 는 사실상 필수다. 이걸 켜지 않으면 모달 컨테이너 자체가 <code>backdropColor</code>(기본 흰색)로 채워져서 뒤 화면이 보이지 않는다.</p>
<p><br><br></p>
<h2 id="주요-props">주요 Props</h2>
<p>Modal 은 <a href="https://reactnative.dev/docs/view#props">View Props</a> 를 상속한다. 그 위에 Modal 고유의 props 가 얹힌다.</p>
<h3 id="공통">공통</h3>
<table>
<thead>
<tr>
<th>Prop</th>
<th>타입</th>
<th>기본값</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>visible</code></td>
<td><code>bool</code></td>
<td><code>true</code></td>
<td>모달 표시 여부</td>
</tr>
<tr>
<td><code>animationType</code></td>
<td><code>&#39;none&#39; | &#39;slide&#39; | &#39;fade&#39;</code></td>
<td><code>&#39;none&#39;</code></td>
<td>등장 애니메이션. <code>slide</code> 는 아래에서 위로, <code>fade</code> 는 페이드 인</td>
</tr>
<tr>
<td><code>transparent</code></td>
<td><code>bool</code></td>
<td><code>false</code></td>
<td>모달 컨테이너 배경을 투명하게 렌더링. 커스텀 backdrop 을 만들 때 필요</td>
</tr>
<tr>
<td><code>backdropColor</code></td>
<td><code>color</code></td>
<td><code>white</code></td>
<td>모달 컨테이너의 배경색. <code>transparent</code> 가 <code>true</code> 면 무시된다</td>
</tr>
<tr>
<td><code>onRequestClose</code></td>
<td><code>function</code></td>
<td>-</td>
<td>닫기 요청 콜백. Android 와 TV 에서는 필수</td>
</tr>
<tr>
<td><code>onShow</code></td>
<td><code>function</code></td>
<td>-</td>
<td>모달이 화면에 표시된 직후 호출</td>
</tr>
</tbody></table>
<h3 id="ios-전용">iOS 전용</h3>
<table>
<thead>
<tr>
<th>Prop</th>
<th>타입</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>presentationStyle</code></td>
<td><code>&#39;fullScreen&#39; | &#39;pageSheet&#39; | &#39;formSheet&#39; | &#39;overFullScreen&#39;</code></td>
<td>표시 스타일. 기본값은 <code>transparent</code> 가 <code>false</code> 면 <code>fullScreen</code>, <code>true</code> 면 <code>overFullScreen</code></td>
</tr>
<tr>
<td><code>allowSwipeDismissal</code></td>
<td><code>bool</code></td>
<td>아래로 스와이프해서 닫기를 허용한다. 켜면 <code>onRequestClose</code> 를 반드시 구현해야 한다</td>
</tr>
<tr>
<td><code>onDismiss</code></td>
<td><code>function</code></td>
<td>모달이 완전히 사라진 직후 호출</td>
</tr>
<tr>
<td><code>onOrientationChange</code></td>
<td><code>function</code></td>
<td>모달 표시 중 방향이 바뀌면 <code>&#39;portrait&#39;</code> 또는 <code>&#39;landscape&#39;</code> 를 전달</td>
</tr>
<tr>
<td><code>supportedOrientations</code></td>
<td><code>array</code></td>
<td>회전 허용 방향 목록. <code>pageSheet</code>, <code>formSheet</code> 에서는 무시된다</td>
</tr>
</tbody></table>
<p><code>onDismiss</code> 는 실무에서 생각보다 자주 쓰인다. 모달을 닫고 곧바로 다른 모달을 띄우거나 화면을 전환해야 할 때, 이 콜백을 기다리지 않으면 애니메이션이 꼬인다. Android 에는 대응되는 prop 이 없어서 플랫폼 분기가 필요하다.</p>
<h3 id="android-전용">Android 전용</h3>
<table>
<thead>
<tr>
<th>Prop</th>
<th>타입</th>
<th>기본값</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>hardwareAccelerated</code></td>
<td><code>bool</code></td>
<td><code>false</code></td>
<td>모달 윈도우의 하드웨어 가속을 강제한다</td>
</tr>
<tr>
<td><code>statusBarTranslucent</code></td>
<td><code>bool</code></td>
<td><code>false</code></td>
<td>모달을 상태바 아래까지 확장한다</td>
</tr>
<tr>
<td><code>navigationBarTranslucent</code></td>
<td><code>bool</code></td>
<td><code>false</code></td>
<td>모달을 네비게이션 바 아래까지 확장한다. <code>statusBarTranslucent</code> 도 함께 <code>true</code> 여야 한다</td>
</tr>
</tbody></table>
<p>Android 15 부터 edge-to-edge 가 강제되면서 앱 화면은 기본적으로 시스템 바 아래까지 그려진다. 그런데 Modal 은 별도의 Dialog 윈도우라서 앱 화면의 설정을 그대로 물려받지 않는 경우가 많다. 전체 화면을 덮는 모달을 만들었는데 상태바 영역만 색이 다르게 남는다면 이 두 prop 을 먼저 확인하면 된다.</p>
<p><br><br></p>
<h2 id="자주-부딪히는-제약">자주 부딪히는 제약</h2>
<h3 id="한-번에-하나">한 번에 하나</h3>
<p>Modal 은 동시에 하나만 안정적으로 표시된다. 모달을 닫으면서 곧바로 다른 모달을 띄우면 두 번째가 열리지 않거나 애니메이션이 어긋난다. 닫힘이 끝난 뒤에 다음 것을 여는 식으로 순서를 만들어야 한다.</p>
<pre><code class="language-tsx">// iOS: onDismiss 로 닫힘 완료를 기다린다
&lt;Modal visible={firstVisible} onDismiss={() =&gt; setSecondVisible(true)} ... /&gt;</code></pre>
<p>Android 에는 <code>onDismiss</code> 가 없으므로 애니메이션 길이만큼 <code>setTimeout</code> 을 두거나, 애초에 모달 하나 안에서 내용만 바꾸는 방식으로 설계하는 편이 낫다. 모달을 겹쳐 띄우는 UX 가 반복해서 필요하다면 빌트인 Modal 로는 한계가 분명하다.</p>
<h3 id="onrequestclose-와-백-버튼">onRequestClose 와 백 버튼</h3>
<p>Android 에서는 <code>onRequestClose</code> 가 필수다. 빼먹으면 경고가 뜨고, 하드웨어 백 버튼으로 모달을 닫을 수 없게 된다.</p>
<p>더 중요한 건 모달이 열려 있는 동안 <code>BackHandler</code> 이벤트가 아예 발생하지 않는다는 점이다. 백 버튼은 오직 모달의 <code>onRequestClose</code> 로만 간다. 화면 단에서 <code>BackHandler</code> 로 뒤로가기를 가로채는 로직을 짜뒀다면, 모달이 열린 동안에는 그 로직이 통째로 무시된다고 보면 된다. 반대로 이 동작이 필요해서 Modal 을 쓰는 경우도 있다.</p>
<h3 id="키보드">키보드</h3>
<p>모달 안에서 <code>TextInput</code> 을 쓰면 키보드가 입력창을 가리는 문제가 iOS 에서 특히 자주 나온다. 모달 내부에도 <code>KeyboardAvoidingView</code> 를 따로 감싸야 한다.</p>
<p>Android 는 Dialog 윈도우가 액티비티의 <code>windowSoftInputMode</code> 를 그대로 따르지 않기 때문에, 앱 화면에서는 잘 동작하던 키보드 회피가 모달 안에서만 어색해지는 일이 생긴다. 이 경우 원인을 코드에서 찾기 어려워서 시간을 많이 쓰게 된다.</p>
<h3 id="세이프-에어리어">세이프 에어리어</h3>
<p>모달 컨테이너는 앱 화면의 세이프 에어리어 컨텍스트 밖에 있다. 노치나 홈 인디케이터를 피하려면 모달 안에서 별도로 처리해야 한다. <code>react-native-safe-area-context</code> 를 쓴다면 모달 내부를 <code>SafeAreaProvider</code> 로 다시 감싸거나, <code>initialWindowMetrics</code> 를 넘겨줘야 값이 제대로 잡힌다.</p>
<h3 id="presentationstyle-과-transparent-조합">presentationStyle 과 transparent 조합</h3>
<p><code>pageSheet</code> 나 <code>formSheet</code> 는 iOS 가 그리는 시트 형태의 프리젠테이션이다. 여기에 <code>transparent={true}</code> 를 같이 주면 의도한 대로 동작하지 않는다. 시트 스타일은 투명 배경을 전제로 만들어진 게 아니기 때문이다. 커스텀 backdrop 이 필요하면 <code>overFullScreen</code> 을 쓰고 배경을 직접 그리는 쪽이 맞다.</p>
<p><br><br></p>
<h2 id="대안-라이브러리">대안 라이브러리</h2>
<p>빌트인 Modal 로 부족하다고 느낄 때 흔히 쓰는 선택지들이다.</p>
<table>
<thead>
<tr>
<th>라이브러리</th>
<th>어떤 경우에</th>
</tr>
</thead>
<tbody><tr>
<td><code>react-native-modal</code></td>
<td>빌트인 Modal 을 감싸서 애니메이션, 스와이프 닫기, backdrop 제어를 추가한다. 별도 윈도우라는 구조는 그대로다</td>
</tr>
<tr>
<td><code>@gorhom/bottom-sheet</code></td>
<td>제스처로 조작하는 본격적인 바텀시트가 필요할 때</td>
</tr>
<tr>
<td><code>@gorhom/portal</code> 같은 Portal 라이브러리</td>
<td>같은 윈도우 안에서 오버레이를 띄우고 싶을 때. 성능 문제를 우회하는 용도로도 쓴다</td>
</tr>
<tr>
<td>React Navigation 의 <code>modal</code> presentation</td>
<td>모달을 화면 전환의 일부로 다루고 뒤로가기 스택에 태우고 싶을 때</td>
</tr>
</tbody></table>
<p><code>react-native-modal</code> 은 빌트인 Modal 을 래핑한 것이라, 뒤에서 다룰 Android 의 별도 윈도우 문제는 그대로 안고 간다. 성능 때문에 대안을 찾는 상황이라면 이쪽이 아니라 Portal 계열을 봐야 한다.</p>
<p><br><br></p>
<h2 id="정리">정리</h2>
<p>Modal 은 화면 위에 무언가를 잠깐 띄우는 가장 간단한 방법이다. 확인 다이얼로그나 단순한 알림 정도라면 빌트인으로 충분하고, 굳이 라이브러리를 붙일 이유가 없다.</p>
<p>다만 Modal 이 일반 <code>View</code> 가 아니라 네이티브의 별도 표시 영역이라는 사실은 기억해둘 필요가 있다. 한 번에 하나만 열린다는 점, 백 버튼이 가로채진다는 점, 세이프 에어리어와 키보드 처리가 앱 화면과 따로 논다는 점은 전부 이 구조에서 나온다. 제약을 만났을 때 코드가 아니라 구조를 의심해야 답이 빨리 나온다.</p>
<p>모달 내부에 상태 변경이 잦거나 무거운 자식 트리가 있다면 Android 에서 체감 성능이 눈에 띄게 나빠지는데, 이건 사용법으로 해결되는 문제가 아니다. 그 원인과 대안은 이어지는 두 글에서 다룬다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native 에서 Fastlane으로 배포 파이프라인 설계하기 (7.  환경별 아이콘·표시 이름 분리와 고도화)]]></title>
            <link>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C-Fastlane%EC%9C%BC%EB%A1%9C-%EB%B0%B0%ED%8F%AC-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0-7.-%ED%99%98%EA%B2%BD%EB%B3%84-%EC%95%84%EC%9D%B4%EC%BD%98%ED%91%9C%EC%8B%9C-%EC%9D%B4%EB%A6%84-%EB%B6%84%EB%A6%AC%EC%99%80-%EA%B3%A0%EB%8F%84%ED%99%94</link>
            <guid>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C-Fastlane%EC%9C%BC%EB%A1%9C-%EB%B0%B0%ED%8F%AC-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0-7.-%ED%99%98%EA%B2%BD%EB%B3%84-%EC%95%84%EC%9D%B4%EC%BD%98%ED%91%9C%EC%8B%9C-%EC%9D%B4%EB%A6%84-%EB%B6%84%EB%A6%AC%EC%99%80-%EA%B3%A0%EB%8F%84%ED%99%94</guid>
            <pubDate>Sat, 12 Sep 2026 08:26:07 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>앞 편들에서 dev/beta/pro 세 환경을 TestFlight·Play Store까지 올리는 뼈대를 완성했다. 이번 편은 그 위에 얹는 <strong>마감 작업 세 가지</strong>를 다룬다.</p>
<ol>
<li><strong>환경별 아이콘·표시 이름 분리</strong> — 한 폰에 dev/beta/pro를 깔았을 때 눈으로 구분되게</li>
<li><strong>버저닝 전략 고도화</strong> — 대화형 입력·하드코딩에서 벗어나 버전 충돌을 구조적으로 막기</li>
<li><strong>CI 연동</strong> — 내 맥이 아니라 서버가 대신 빌드·배포하게 만들기</li>
</ol>
<p>셋 다 &quot;없어도 배포는 되지만, 팀이 커지면 반드시 아쉬워지는&quot; 것들이다.</p>
<br>

<hr>
<br>

<h2 id="1-환경별-아이콘·표시-이름-분리">1. 환경별 아이콘·표시 이름 분리</h2>
<h3 id="왜-필요한가">왜 필요한가</h3>
<p>앞 편에서 번들 ID/패키지명을 환경별로 나눴으니, 한 폰에 dev/beta/pro 세 앱이 <strong>동시에 깔린다.</strong> 그런데 아이콘과 이름이 다 똑같으면 홈 화면에 회색 아이콘 세 개가 나란히 떠서 어느 게 어느 빌드인지 알 수가 없다. QA가 &quot;버그 재현됐어요&quot; 했는데 알고 보니 dev 앱이었던 상황이 여기서 나온다.</p>
<p>목표는 간단하다. <strong>이름에 접미사</strong>(MyApp / MyApp Beta / MyApp Dev)를 붙이고, <strong>아이콘에 뱃지</strong>(DEV/BETA 리본)를 얹어 한눈에 구분되게 하는 것이다.</p>
<h3 id="표시-이름--이미-절반은-해둔-상태">표시 이름 — 이미 절반은 해둔 상태</h3>
<p>사실 이름 분리는 앞 편에서 이미 하고 있었다.</p>
<ul>
<li><strong>iOS</strong>: <code>update_info_plist(display_name: &quot;MyApp-dev&quot;)</code>로 lane마다 다른 이름을 넣었다.</li>
<li><strong>Android</strong>: 셸 스크립트(<code>change_package_name.sh</code> 계열)가 환경 전환 시 <code>app_name</code> 문자열을 바꾸거나, flavor를 쓴다면 <code>resValue &quot;string&quot;, &quot;app_name&quot;, &quot;MyApp Dev&quot;</code>로 지정한다.</li>
</ul>
<p>즉 표시 이름은 이미 환경별로 다르게 나온다. 남은 건 <strong>아이콘</strong>이다.</p>
<h3 id="아이콘-분리--ios">아이콘 분리 — iOS</h3>
<p>iOS 아이콘은 <code>Assets.xcassets</code> 안의 <strong>App Icon 세트</strong>로 관리된다. 환경별로 다른 아이콘을 쓰는 방법은 크게 둘이다.</p>
<p><strong>방법 A — 아이콘 세트를 여러 개 두고 빌드 설정으로 고르기</strong>
<code>Assets.xcassets</code>에 <code>AppIcon-Dev</code>, <code>AppIcon-Beta</code>, <code>AppIcon</code> 세트를 각각 만들고, 어떤 세트를 쓸지 <code>ASSETCATALOG_COMPILER_APPICON_NAME</code> 빌드 설정으로 정한다. Build Configuration별 <code>.xcconfig</code>(빌드 설정을 파일로 빼둔 것)에 이렇게 적는다.</p>
<pre><code>// Dev.xcconfig
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-Dev</code></pre><p><strong>방법 B — 셸 스크립트로 아이콘 파일을 갈아끼우기 (이 시리즈 방식)</strong>
우리 프로젝트는 환경을 셸 스크립트로 전환하므로, 아이콘도 같은 결로 처리하는 게 자연스럽다. <code>copy_config.sh</code>가 Firebase 설정을 복사하듯, 환경별 아이콘 이미지를 <code>AppIcon.appiconset</code>에 덮어쓰게 한 줄 추가하면 된다.</p>
<pre><code class="language-bash"># copy_config.sh 안 (개념 예시)
cp &quot;icons/$1/&quot;*.png &quot;MyApp/Images.xcassets/AppIcon.appiconset/&quot;
# $1 = dev / beta / pro</code></pre>
<p>방법 A가 더 &quot;정석&quot;이지만, 셸 스크립트 방식과 섞으면 관리 포인트가 둘로 갈린다. <strong>이미 스크립트로 환경을 가르고 있다면 방법 B로 통일</strong>하는 게 덜 헷갈린다.</p>
<h3 id="아이콘-분리--android">아이콘 분리 — Android</h3>
<p>Android 아이콘은 <code>android/app/src/main/res/mipmap-*/</code> 폴더의 <code>ic_launcher</code> 이미지들이다.</p>
<p><strong>flavor를 쓰는 경우</strong>: <code>src/dev/res/mipmap-*/</code>, <code>src/beta/res/mipmap-*/</code>에 각 환경 아이콘을 넣으면 gradle이 알아서 <code>main</code>의 것을 덮어쓴다. 별도 코드가 필요 없어 가장 깔끔하다.</p>
<p><strong>셸 스크립트를 쓰는 경우(이 시리즈)</strong>: <code>change_package_name.sh</code> 계열이 환경 전환 시 해당 환경의 mipmap을 <code>main/res</code>로 복사하게 한다. iOS 방법 B와 같은 발상이다.</p>
<h3 id="뱃지리본-자동-생성--팁">뱃지(리본) 자동 생성 — 팁</h3>
<p>아이콘을 환경마다 손으로 그리기 귀찮다면, fastlane의 <code>badge</code> 플러그인으로 <strong>운영 아이콘 위에 DEV/BETA 리본을 자동 합성</strong>할 수 있다.</p>
<pre><code class="language-bash">bundle exec fastlane add_plugin badge</code></pre>
<pre><code class="language-ruby"># lane 안에서 (dev/beta일 때만)
add_badge(shield: &quot;DEV-blue-orange&quot;, dark: true)   # 아이콘 모서리에 리본</code></pre>
<p>원본 아이콘 하나만 관리하고, 나머지는 빌드 때 리본을 얹어 만들면 유지보수가 편하다. 단, CI에서 아이콘을 매번 변형하므로 원본이 커밋 대상에서 바뀌지 않게 주의한다.</p>
<br>

<hr>
<br>

<h2 id="2-버저닝-전략-고도화">2. 버저닝 전략 고도화</h2>
<h3 id="지금-방식의-한계">지금 방식의 한계</h3>
<p>앞 편의 실제 Fastfile을 떠올리면, 버전 관리가 이랬다.</p>
<ul>
<li>dev/beta: 실행할 때 <strong>사람이 확인/입력</strong> (<code>UI.confirm</code> / <code>UI.input</code>)</li>
<li>pro: 코드에 <strong>버전을 하드코딩</strong></li>
<li>빌드 번호/versionCode: 로컬 파일 값 <strong>+1</strong></li>
</ul>
<p>이건 혼자 로컬에서 돌릴 땐 잘 굴러가지만, 아래 상황에서 깨진다.</p>
<ul>
<li><strong>여러 명이 배포</strong>하면, 각자 로컬의 빌드 번호가 달라 충돌한다. (A가 42로 올렸는데 B의 로컬은 아직 40이라 41로 올리려다 거부당함)</li>
<li><strong>CI에서 자동 배포</strong>하려면 대화형 프롬프트(<code>UI.input</code>)가 사람 입력을 기다리다 멈춘다.</li>
<li><strong>iOS와 Android 버전이 따로 논다.</strong> 마케팅 버전을 양쪽에서 각자 관리하다 어긋난다.</li>
</ul>
<p>핵심 원칙은 하나다. <strong>버전의 &quot;정답&quot;을 한 곳에만 두고, 나머지는 거기서 계산되게</strong> 한다.</p>
<h3 id="전략-1--빌드-번호는-스토어-기준으로-자동-계산">전략 1 — 빌드 번호는 &quot;스토어 기준&quot;으로 자동 계산</h3>
<p>로컬 값 +1 대신, <strong>스토어에 이미 올라간 최댓값 +1</strong>을 쓰면 누가 어디서 돌리든 충돌이 안 난다.</p>
<pre><code class="language-ruby"># iOS
increment_build_number(
  build_number: latest_testflight_build_number(app_identifier: ENV[&quot;APP_IDENTIFIER&quot;]) + 1
)</code></pre>
<pre><code class="language-ruby"># Android
latest = google_play_track_version_codes(
  package_name: ENV[&quot;PACKAGE_NAME&quot;], track: &quot;internal&quot;, json_key: ENV[&quot;PLAY_STORE_JSON_PATH&quot;]
).max || 0
new_version_code = latest + 1</code></pre>
<p>이러면 빌드 번호는 사람이 신경 쓸 필요가 없어진다. 환경별 번들 ID/패키지명이 다르므로 <code>app_identifier</code>·<code>package_name</code>을 꼭 같이 넘겨, 환경별로 독립적인 번호가 유지되게 한다.</p>
<h3 id="전략-2--마케팅-버전은-단일-소스에서-읽기">전략 2 — 마케팅 버전은 단일 소스에서 읽기</h3>
<p>마케팅 버전(<code>1.2.11</code> 같은 사용자용 버전)은 <strong>한 파일에서 읽어</strong> iOS·Android가 같은 값을 쓰게 한다. RN 프로젝트라면 <code>package.json</code>의 <code>version</code>을 소스로 삼는 게 자연스럽다. (RN 개발자에게 가장 익숙한 위치다.)</p>
<pre><code class="language-ruby"># Fastfile 공통 (ios/android 양쪽에서)
require &#39;json&#39;
pkg = JSON.parse(File.read(&quot;../../package.json&quot;))   # 경로는 프로젝트에 맞게
marketing_version = pkg[&quot;version&quot;]                   # 예: &quot;1.2.11&quot;

# iOS
increment_version_number(version_number: marketing_version)
# Android
android_set_version_name(version_name: marketing_version)</code></pre>
<p>이제 배포 전에 <code>package.json</code>의 버전만 올리면(예: <code>npm version patch</code>), iOS·Android가 자동으로 같은 마케팅 버전을 쓴다. 버전이 양쪽에서 어긋나는 문제가 사라진다.</p>
<blockquote>
<p><code>npm version patch</code>는 <code>package.json</code>의 버전을 <code>1.2.11 → 1.2.12</code>로 올리고 git 태그까지 만들어주는 npm 기본 명령이다. 이걸 &quot;버전 올리는 공식 절차&quot;로 정하면 팀 규칙이 단순해진다.</p>
</blockquote>
<h3 id="전략-3--ci에서는-대화형을-끄기">전략 3 — CI에서는 대화형을 끄기</h3>
<p><code>UI.confirm</code>/<code>UI.input</code>은 사람이 있을 때만 쓸 수 있다. CI에서는 이 부분을 건너뛰고 값을 자동으로 정하게 분기한다. fastlane은 지금이 대화형인지 아닌지를 <code>UI.interactive?</code>로 알려준다.</p>
<pre><code class="language-ruby">if UI.interactive?
  # 로컬: 기존처럼 확인/입력 받기
  new_version = UI.confirm(&quot;...&quot;) ? suggested : UI.input(&quot;...&quot;)
else
  # CI: 물어보지 않고 자동값 사용
  new_version = suggested_version
end</code></pre>
<p>이렇게 두면 <strong>로컬에서는 지금처럼 확인 절차가 살아있고, CI에서는 멈추지 않고</strong> 자동으로 굴러간다. 하나의 lane으로 두 환경을 다 커버할 수 있다.</p>
<h3 id="정리하면">정리하면</h3>
<table>
<thead>
<tr>
<th>값</th>
<th>이전</th>
<th>고도화 후</th>
</tr>
</thead>
<tbody><tr>
<td>마케팅 버전</td>
<td>각 플랫폼 따로 입력/하드코딩</td>
<td><code>package.json</code> 단일 소스에서 읽기</td>
</tr>
<tr>
<td>빌드 번호 / versionCode</td>
<td>로컬 파일 +1</td>
<td>스토어 최댓값 +1</td>
</tr>
<tr>
<td>확인 절차</td>
<td>항상 대화형</td>
<td>로컬만 대화형, CI는 자동</td>
</tr>
</tbody></table>
<br>

<hr>
<br>

<h2 id="3-ci-연동">3. CI 연동</h2>
<h3 id="왜-필요한가-1">왜 필요한가</h3>
<p>지금은 배포하려면 <strong>내 맥</strong>에서 명령을 쳐야 한다. 이 방식의 문제는 이렇다.</p>
<ul>
<li>배포할 수 있는 사람이 특정 맥을 가진 사람으로 한정된다.</li>
<li>그 사람 로컬 환경(Xcode 버전, 인증서 상태 등)에 따라 결과가 달라진다.</li>
<li>빌드 도는 20~40분 동안 그 맥을 못 쓴다.</li>
</ul>
<p>CI(Continuous Integration)는 이걸 <strong>서버가 대신</strong> 하게 만든다. &quot;main에 태그가 붙으면 자동으로 beta 배포&quot; 같은 규칙을 걸어두면, 사람은 버튼만 누르거나 태그만 밀면 된다. 여기서는 가장 널리 쓰는 <strong>GitHub Actions</strong> 기준으로 설명한다. (Bitrise·Codemagic 같은 SaaS도 있지만, 원리는 같다.)</p>
<h3 id="ci에서-달라지는-것들">CI에서 달라지는 것들</h3>
<p>로컬과 CI의 결정적 차이는 <strong>CI에는 아무것도 미리 깔려 있지 않다</strong>는 점이다. 인증서도, keystore도, Node도, Ruby도 매번 새로 준비해야 한다. 그래서 두 가지를 챙겨야 한다.</p>
<p><strong>1) 비밀값은 CI Secrets로 주입.</strong> 인증서 비번, API 키, keystore, service account JSON 등을 코드에 두면 안 되니, GitHub의 <strong>Secrets</strong>(암호화 저장소)에 넣고 워크플로에서 꺼내 쓴다. 파일(예: <code>.p8</code>, <code>.keystore</code>, <code>.json</code>)은 base64로 인코딩해 Secret에 넣고, CI에서 디코딩해 파일로 복원하는 게 표준이다.</p>
<pre><code class="language-bash"># 로컬에서 파일을 base64 문자열로 변환 → 이 값을 Secret에 붙여넣음
base64 -i AuthKey_XXX.p8 | pbcopy</code></pre>
<p><strong>2) 인증서·keychain 준비.</strong> iOS는 CI용 임시 키체인이 필요하다. fastlane의 <code>setup_ci</code>가 이걸 만들어 잠금을 풀어준다. 그리고 <code>match</code>는 <strong>CI에서 새 인증서를 만들지 않도록</strong> <code>readonly: true</code>로 부른다. (안 그러면 CI가 실수로 인증서를 발급해 개발자 계정 한도에 걸린다.)</p>
<pre><code class="language-ruby">before_all do
  setup_ci if ENV[&quot;CI&quot;]                 # CI 환경이면 임시 키체인 준비
end
# match 호출 시
match(type: &quot;appstore&quot;, readonly: ENV[&quot;CI&quot;] ? true : false)</code></pre>
<h3 id="플랫폼별-러너-선택">플랫폼별 러너 선택</h3>
<ul>
<li><strong>iOS</strong>: 반드시 <strong>macOS 러너</strong>여야 한다. 그리고 이 시리즈 앞부분에서 다뤘듯, 2026년 현재 App Store 제출은 <strong>Xcode 26 이상</strong>을 요구하므로 러너의 Xcode 버전을 26으로 맞춰야 한다.</li>
<li><strong>Android</strong>: <strong>Linux(ubuntu) 러너</strong>면 충분하다. macOS보다 빠르고 저렴하다.</li>
</ul>
<h3 id="github-actions-예시--android-ubuntu">GitHub Actions 예시 — Android (ubuntu)</h3>
<pre><code class="language-yaml"># .github/workflows/android-deploy.yml
name: Android Deploy

on:
  workflow_dispatch:          # 수동 실행 버튼
    inputs:
      lane:
        description: &quot;dev / beta / pro&quot;
        required: true
        default: &quot;beta&quot;

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: yarn
      - run: yarn install --frozen-lockfile

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17

      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true      # Gemfile 기반 캐시 자동

      # Secret에 base64로 넣어둔 파일들을 복원
      - name: Restore secrets
        run: |
          echo &quot;${{ secrets.PLAY_STORE_JSON_BASE64 }}&quot; | base64 -d &gt; android/play-service-account.json
          echo &quot;${{ secrets.ANDROID_KEYSTORE_BASE64 }}&quot; | base64 -d &gt; android/app/upload.keystore

      - name: Fastlane
        working-directory: android
        env:
          PLAY_STORE_JSON_PATH: ${{ github.workspace }}/android/play-service-account.json
          KEYSTORE_PATH: ${{ github.workspace }}/android/app/upload.keystore
          SIGNING_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
          SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
          SIGNING_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
        run: bundle exec fastlane ${{ github.event.inputs.lane }} --env ${{ github.event.inputs.lane }}</code></pre>
<h3 id="github-actions-예시--ios-macos">GitHub Actions 예시 — iOS (macOS)</h3>
<pre><code class="language-yaml"># .github/workflows/ios-deploy.yml
name: iOS Deploy

on:
  workflow_dispatch:
    inputs:
      lane:
        description: &quot;dev / beta / pro&quot;
        required: true
        default: &quot;beta&quot;

jobs:
  deploy:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4

      # Xcode 26 고정 (App Store 제출 요구사항)
      - uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: &quot;26&quot;

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: yarn
      - run: yarn install --frozen-lockfile

      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true

      - name: Restore App Store Connect API key
        run: |
          mkdir -p ios/fastlane
          echo &quot;${{ secrets.ASC_API_KEY_BASE64 }}&quot; | base64 -d &gt; ios/fastlane/AuthKey.p8

      - name: Fastlane
        working-directory: ios
        env:
          CI: true
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}   # 인증서 repo 접근용
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_PATH: ${{ github.workspace }}/ios/fastlane/AuthKey.p8
        run: |
          bundle exec pod install --project-directory=. || true
          bundle exec fastlane ${{ github.event.inputs.lane }} --env ${{ github.event.inputs.lane }}</code></pre>
<blockquote>
<p>위 두 예시의 <code>env</code> 블록에 넣은 변수 이름은 앞 편에서 lane이 <code>ENV[...]</code>로 읽던 그 이름들과 <strong>정확히 일치</strong>해야 한다. CI는 결국 &quot;로컬에서 <code>.env</code>가 하던 역할을 Secrets가 대신하는 것&quot;뿐이다.</p>
</blockquote>
<h3 id="대화형-lane을-ci에서-돌리기">대화형 lane을 CI에서 돌리기</h3>
<p>앞 편 실제 lane에는 <code>UI.confirm</code>/<code>UI.input</code>이 있었다. 버저닝 전략 3에서 만든 <code>UI.interactive?</code> 분기가 여기서 빛을 발한다. CI에서는 <code>UI.interactive?</code>가 false라 프롬프트를 건너뛰고 자동 버전으로 진행하므로, <strong>lane을 고치지 않고도</strong> 로컬·CI 양쪽에서 같은 lane을 쓸 수 있다. (이 분기가 없다면 CI가 입력을 기다리다 타임아웃으로 죽는다.)</p>
<h3 id="언제-자동으로-돌릴까--트리거">언제 자동으로 돌릴까 — 트리거</h3>
<p>위 예시는 버튼으로 돌리는 <code>workflow_dispatch</code>만 걸었다. 익숙해지면 트리거를 넓힐 수 있다.</p>
<ul>
<li><strong>git 태그 푸시 → 자동 배포</strong>: <code>v1.2.12</code> 태그를 밀면 pro 배포. (<code>on: push: tags: [&#39;v*&#39;]</code>)</li>
<li><strong>특정 브랜치 머지 → beta 배포</strong>: <code>develop</code>에 머지되면 beta.</li>
</ul>
<p>단, <strong>pro(운영) 자동화는 신중히</strong> 한다. 앞 편에서 Android가 <code>internal</code>/<code>draft</code>로만 올리고 사람이 승급하게 한 것과 같은 맥락으로, 운영 배포는 사람의 명시적 확인을 한 단계 두는 게 안전하다.</p>
<br>

<hr>
<br>

<h2 id="마무리--시리즈-전체-정리">마무리 — 시리즈 전체 정리</h2>
<p>이번 시리즈를 통해 다음을 만들었다.</p>
<ul>
<li>✅ Fastlane 환경 세팅 (Ruby/Bundler/Gemfile)</li>
<li>✅ iOS: match로 인증서 공유, dev/beta/pro 환경 분리, TestFlight 자동 업로드</li>
<li>✅ Android: keystore·Play App Signing·Service Account, 환경 분리, Play Store 자동 업로드</li>
<li>✅ 환경별 아이콘·표시 이름 분리로 한 폰에서 세 빌드 구분</li>
<li>✅ 단일 소스 기반 버저닝 + 스토어 기준 빌드 번호 + CI 대응 분기</li>
<li>✅ GitHub Actions로 빌드·배포를 서버에 위임</li>
</ul>
<p>핵심은 처음부터 끝까지 하나였다. <strong>손으로 하던 배포의 각 단계를 코드로 옮기고, 그 코드가 로컬이든 CI든 똑같이 돌게 만드는 것.</strong> Fastlane은 그 단계들을 대체한 게 아니라 &quot;호출&quot;했을 뿐이고, 우리는 그 호출을 환경별로·플랫폼별로·자동으로 엮었다.</p>
<p>여기서 더 나아가고 싶다면 다음을 살펴보면 좋다: Slack 배포 알림(<code>slack</code> 액션), 크래시 심볼 업로드(dSYM → Sentry/Firebase), 스토어 메타데이터·스크린샷 자동화(<code>deliver</code>/<code>supply</code>의 metadata 기능), 그리고 자동 changelog 생성.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native 네이티브 관련 라이브러리 업데이트시 체크리스트]]></title>
            <link>https://velog.io/@diso592/React-Native-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EA%B4%80%EB%A0%A8-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8%EC%8B%9C-%EC%B2%B4%ED%81%AC%EB%A6%AC%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@diso592/React-Native-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EA%B4%80%EB%A0%A8-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8%EC%8B%9C-%EC%B2%B4%ED%81%AC%EB%A6%AC%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Sat, 12 Sep 2026 00:42:35 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p><code>reanimated</code>, <code>gesture-handler</code>, <code>mmkv</code>, <code>vision-camera</code> 같이 네이티브 코드를 포함한 라이브러리를 다룰 때, 실무에서 반복적으로 부딪히는 것들만 추렸다. 한 줄 요약하면 <strong>&quot;JS 패키지만 올리면 끝이 아니다. 플랫폼별 한 단계가 더 필요하다&quot;</strong> 가 전부다.</p>
<br>
<br>

<h2 id="공통-설치업그레이드-직후-워크플로우">공통: 설치/업그레이드 직후 워크플로우</h2>
<p>라이브러리를 추가하거나 버전을 올린 직후 매번 돌리는 시퀀스이다.</p>
<pre><code class="language-bash"># 1) JS 의존성 동기화
pnpm install   # 또는 yarn / npm
# 2) iOS 네이티브 의존성 동기화
cd ios &amp;&amp; bundle exec pod install &amp;&amp; cd ..
# 3) Android 빌드 캐시 정리
cd android &amp;&amp; ./gradlew clean &amp;&amp; cd ..</code></pre>
<p>이 세 단계를 빼먹으면 거의 모든 빌드 문제가 여기서 비롯된다. <strong>JS 버전과 네이티브 측 버전이 동기화되어야</strong> 헤더/심볼 mismatch가 나지 않는다.</p>
<br>
<br>

<h2 id="ios-핵심">iOS 핵심</h2>
<h3 id="1-pod-install은-무조건-실행이다">1. <code>pod install</code>은 무조건 실행이다</h3>
<p>RN 0.60+의 autolinking은 <strong>JS 의존성을 감지하는 것까지만</strong> 해준다. 실제로 네이티브 의존성을 받아오고 <code>Podfile.lock</code>을 갱신하는 건 <code>pod install</code>의 몫이다. JS 패키지를 업그레이드한 뒤 <code>pod install</code>을 안 돌리면 JS와 네이티브 버전이 어긋나 빌드가 멈춘다.
확인 방법: <code>Podfile.lock</code>의 해당 pod 버전이 <code>package.json</code> 버전과 일치하는지 본다.</p>
<h3 id="2-빌드가-이상하면-캐시-두-곳을-비운다">2. 빌드가 이상하면 캐시 두 곳을 비운다</h3>
<p>iOS 빌드 캐시는 두 군데에 있다.</p>
<pre><code class="language-bash"># Pod 산출물
cd ios
rm -rf Pods Podfile.lock build
bundle exec pod install
cd ..
# DerivedData
rm -rf ~/Library/Developer/Xcode/DerivedData/&lt;프로젝트명&gt;-*</code></pre>
<p>업그레이드 후 빌드가 깨지면 이 두 군데부터 의심한다.</p>
<h3 id="3-pnpm을-쓴다면-node-linkerhoisted">3. pnpm을 쓴다면 <code>node-linker=hoisted</code></h3>
<p>pnpm 기본 isolated 모드(<code>node_modules/.pnpm/...</code>)는 CocoaPods 스크립트와 충돌이 잦다. 프로젝트 루트 <code>.npmrc</code>에 다음을 박아두면 마찰이 사라진다.</p>
<pre><code>node-linker=hoisted</code></pre><h3 id="4-new-architecture-모드를-명시적으로-통제한다">4. New Architecture 모드를 명시적으로 통제한다</h3>
<p>RN 0.76+부터 New Architecture가 기본 활성화이다. <code>reanimated</code>, <code>gesture-handler</code> 같은 라이브러리는 모드별로 동작 코드가 달라진다. 의도와 다르게 빌드되는 걸 막으려면 환경 변수로 명시한다.</p>
<pre><code class="language-bash">RCT_NEW_ARCH_ENABLED=1 bundle exec pod install   # 켜기
RCT_NEW_ARCH_ENABLED=0 bundle exec pod install   # 끄기</code></pre>
<h3 id="5-xcode-15-에서-빌드-스크립트가-멈추면-샌드박싱-의심">5. Xcode 15+ 에서 빌드 스크립트가 멈추면 샌드박싱 의심</h3>
<p>Xcode 15부터 <code>ENABLE_USER_SCRIPT_SANDBOXING</code>이 기본 <code>YES</code>로 바뀌었다. RN의 일부 Pod 스크립트 페이즈와 충돌한다. Podfile의 <code>post_install</code>에서 끈다.</p>
<pre><code class="language-ruby">post_install do |installer|
  react_native_post_install(installer, config[:reactNativePath])
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings[&#39;ENABLE_USER_SCRIPT_SANDBOXING&#39;] = &#39;NO&#39;
    end
  end
end</code></pre>
<br>
<br>

<h2 id="android-핵심">Android 핵심</h2>
<h3 id="1-gradlew-clean이-ios의-pod-install에-해당한다">1. <code>./gradlew clean</code>이 iOS의 <code>pod install</code>에 해당한다</h3>
<p>Android는 별도의 &quot;install&quot; 명령이 없는 대신, Gradle이 빌드 시점에 <code>node_modules</code>를 스캔해서 네이티브 의존성을 결정한다. 라이브러리 변경 직후에는 이전 빌드 산출물이 캐시되어 있을 수 있으므로 clean을 돌려야 한다.</p>
<pre><code class="language-bash">cd android &amp;&amp; ./gradlew clean &amp;&amp; cd ..</code></pre>
<h3 id="2-kotlin-버전-불일치가-가장-흔한-빌드-실패-원인이다">2. Kotlin 버전 불일치가 가장 흔한 빌드 실패 원인이다</h3>
<p>라이브러리들이 요구하는 Kotlin 버전이 제각각이라, 새 라이브러리를 추가하면 충돌이 자주 난다. 증상은 <code>compileKotlin</code> 단계에서 실패하거나 <code>Unresolved reference</code> 에러이다. 해결책은 프로젝트의 <code>kotlinVersion</code>을 <strong>요구 버전 중 가장 높은 것에 맞춰 올리는</strong> 것이다.</p>
<pre><code class="language-gradle">// android/build.gradle
buildscript {
    ext {
        kotlinVersion = &quot;2.0.21&quot;  // 라이브러리 요구치 이상으로
    }
}</code></pre>
<h3 id="3-jdk-버전이-agp와-맞아야-한다">3. JDK 버전이 AGP와 맞아야 한다</h3>
<p>RN 0.73+는 JDK 17이 필요하다. JDK 버전이 안 맞으면 <code>Unsupported class file major version</code> 에러가 난다.</p>
<pre><code class="language-bash">java -version                          # 현재 버전 확인
export JAVA_HOME=$(/usr/libexec/java_home -v 17)   # macOS에서 JDK 17 강제</code></pre>
<h3 id="4-릴리스-빌드만-깨지면-proguard-의심">4. 릴리스 빌드만 깨지면 ProGuard 의심</h3>
<p>디버그는 통과하는데 릴리스에서만 크래시하거나 빌드가 깨지면, 새 라이브러리가 리플렉션을 쓰는데 ProGuard 규칙이 없을 가능성이 높다. 라이브러리 README의 ProGuard 섹션을 확인하고 <code>android/app/proguard-rules.pro</code>에 규칙을 추가한다.</p>
<h3 id="5-큰-업그레이드-후엔-에뮬레이터디바이스의-앱을-한-번-지운다">5. 큰 업그레이드 후엔 에뮬레이터/디바이스의 앱을 한 번 지운다</h3>
<p>네이티브 모듈의 ABI가 바뀌면 부분 업데이트로 인해 런타임 크래시가 난다. 안전하게는 앱을 삭제한 뒤 다시 설치한다.</p>
<br>
<br>

<h2 id="업그레이드-후-검증-순서">업그레이드 후 검증 순서</h2>
<pre><code>1. package.json + lockfile 갱신 확인
2. iOS:  bundle exec pod install
3. Android: ./gradlew clean
4. 빌드 → 시뮬레이터/에뮬레이터에서 동작 확인
5. 깨지면 → 캐시(DerivedData, Pods, android/build) 정리 후 재시도
6. 그래도 깨지면 → 라이브러리 README의 &quot;Installation&quot; 다시 읽기
   (peer dependency, post_install, ProGuard 규칙 등 누락 항목 체크)</code></pre><br>
<br>

<h2 id="정리">정리</h2>
<p>세 줄로 요약하면 이게 다이다.</p>
<ol>
<li><strong>JS 패키지 갱신 직후 항상 <code>pod install</code> + <code>./gradlew clean</code>을 같이 돌린다</strong>.</li>
<li><strong>버전이 어긋나면 캐시부터 의심한다</strong> — iOS는 DerivedData와 Pods, Android는 <code>android/build</code>.</li>
<li><strong>빌드 도구 호환성을 확인한다</strong> — iOS는 Xcode 버전, Android는 Kotlin/JDK 버전.
라이브러리 README의 Installation 섹션을 꼼꼼히 읽고, 위 워크플로우만 빠뜨리지 않으면 대부분의 문제는 비껴간다.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native 에서 현재 앱이 포그라운드 or 백그라운드 상태인지 확인하는 법]]></title>
            <link>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C-%ED%98%84%EC%9E%AC-%EC%95%B1%EC%9D%B4-%ED%8F%AC%EA%B7%B8%EB%9D%BC%EC%9A%B4%EB%93%9C-or-%EB%B0%B1%EA%B7%B8%EB%9D%BC%EC%9A%B4%EB%93%9C-%EC%83%81%ED%83%9C%EC%9D%B8%EC%A7%80-%ED%99%95%EC%9D%B8%ED%95%98%EB%8A%94-%EB%B2%95</link>
            <guid>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C-%ED%98%84%EC%9E%AC-%EC%95%B1%EC%9D%B4-%ED%8F%AC%EA%B7%B8%EB%9D%BC%EC%9A%B4%EB%93%9C-or-%EB%B0%B1%EA%B7%B8%EB%9D%BC%EC%9A%B4%EB%93%9C-%EC%83%81%ED%83%9C%EC%9D%B8%EC%A7%80-%ED%99%95%EC%9D%B8%ED%95%98%EB%8A%94-%EB%B2%95</guid>
            <pubDate>Wed, 09 Sep 2026 08:54:22 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p><code>AppState</code>는 React Native가 제공하는 모듈로, 앱이 포그라운드에 있는지 백그라운드에 있는지를 알려준다. <code>AppState.currentState</code>는 그중에서 <strong>현재 앱 상태를 동기적으로 읽을 수 있는 정적 프로퍼티</strong>이다.</p>
<pre><code class="language-js">import { AppState } from &#39;react-native&#39;;

console.log(AppState.currentState); // &#39;active&#39;</code></pre>
<p>이벤트를 기다리지 않고 즉시 현재 값을 얻는 용도이며, 상태 변화를 추적하려면 별도의 리스너가 필요하다.</p>
<br>
<br>

<h2 id="가능한-값">가능한 값</h2>
<table>
<thead>
<tr>
<th>값</th>
<th>플랫폼</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>&#39;active&#39;</code></td>
<td>iOS / Android</td>
<td>앱이 포그라운드에서 실행 중이고 사용자 입력을 받는 상태이다.</td>
</tr>
<tr>
<td><code>&#39;background&#39;</code></td>
<td>iOS / Android</td>
<td>앱이 백그라운드로 내려간 상태이다. 다른 앱으로 전환했거나 홈으로 나간 경우이다.</td>
</tr>
<tr>
<td><code>&#39;inactive&#39;</code></td>
<td>iOS 전용</td>
<td>포그라운드지만 입력을 받지 못하는 과도기 상태이다. 전화 수신, 멀티태스킹 화면, 컨트롤 센터 노출, Face ID 인증 팝업 등이 해당한다.</td>
</tr>
<tr>
<td><code>&#39;unknown&#39;</code></td>
<td>iOS</td>
<td>초기 실행 시점에 상태가 확정되지 않은 경우이다.</td>
</tr>
<tr>
<td><code>&#39;extension&#39;</code></td>
<td>iOS</td>
<td>앱 익스텐션 컨텍스트에서 실행 중인 경우이다.</td>
</tr>
</tbody></table>
<blockquote>
<p>Android에는 <code>inactive</code>가 없다. 포그라운드 → 백그라운드가 곧바로 전환된다. 따라서 <code>inactive</code>에 의존하는 로직을 짜면 Android에서 동작하지 않는다.</p>
</blockquote>
<br>
<br>

<h2 id="주의점-currentstate는-스냅샷이다">주의점: currentState는 스냅샷이다</h2>
<p><code>currentState</code>는 <strong>읽는 시점의 값</strong>일 뿐 반응형(reactive) 값이 아니다. 렌더 함수 안에서 직접 참조하면 상태가 바뀌어도 리렌더가 발생하지 않아 stale한 값을 보게 된다.</p>
<pre><code class="language-js">// 나쁜 예: 상태가 바뀌어도 리렌더되지 않는다
function Screen() {
  return &lt;Text&gt;{AppState.currentState}&lt;/Text&gt;;
}</code></pre>
<p>또한 앱 초기 마운트 시점에 <code>null</code>이 반환될 수 있다. 네이티브 초기화가 끝나기 전에 접근하는 경우이며, 초기값 처리를 항상 방어적으로 해야 한다.</p>
<br>
<br>

<h2 id="올바른-사용-패턴">올바른 사용 패턴</h2>
<p><code>currentState</code>는 <strong>초기값</strong>으로만 쓰고, 이후 변화는 <code>addEventListener</code>로 추적하는 것이 정석이다.</p>
<pre><code class="language-tsx">import { useEffect, useRef, useState } from &#39;react&#39;;
import { AppState, AppStateStatus } from &#39;react-native&#39;;

export function useAppState() {
  const appState = useRef&lt;AppStateStatus&gt;(AppState.currentState);
  const [state, setState] = useState&lt;AppStateStatus&gt;(AppState.currentState);

  useEffect(() =&gt; {
    const subscription = AppState.addEventListener(&#39;change&#39;, (next) =&gt; {
      if (appState.current.match(/inactive|background/) &amp;&amp; next === &#39;active&#39;) {
        // 백그라운드 → 포그라운드 복귀 시점
        // 토큰 갱신, 데이터 리페치 등을 여기서 처리한다
      }
      appState.current = next;
      setState(next);
    });

    return () =&gt; subscription.remove();
  }, []);

  return state;
}</code></pre>
<h3 id="구독-해제-방식">구독 해제 방식</h3>
<p>RN 0.65부터 <code>addEventListener</code>는 subscription 객체를 반환한다. 구버전의 <code>AppState.removeEventListener(&#39;change&#39;, handler)</code>는 제거되었으므로, 반드시 <code>subscription.remove()</code>를 사용해야 한다.</p>
<br>
<br>


<h2 id="실전-활용-예">실전 활용 예</h2>
<ul>
<li><strong>세션 만료 처리</strong>: 백그라운드에 머문 시간을 재서 일정 시간이 지나면 재인증을 요구한다.</li>
<li><strong>데이터 리페치</strong>: 복귀 시 서버 데이터를 다시 가져온다. React Query의 <code>focusManager</code>도 내부적으로 AppState를 쓴다.</li>
<li><strong>타이머 보정</strong>: 백그라운드에서 <code>setInterval</code>은 신뢰할 수 없으므로, 복귀 시 실제 경과 시간을 다시 계산한다.</li>
<li><strong>민감 정보 가리기</strong>: iOS에서 <code>inactive</code> 진입 시 앱 스위처 스냅샷을 블러 처리한다. Android는 <code>FLAG_SECURE</code>가 더 적합하다.</li>
</ul>
<br>
<br>

<h2 id="정리">정리</h2>
<ul>
<li><code>AppState.currentState</code>는 <strong>현재 값을 즉시 읽는 용도</strong>이지 상태 관리 수단이 아니다.</li>
<li>초기값으로만 쓰고 변화 추적은 <code>addEventListener</code> + <code>subscription.remove()</code>로 한다.</li>
<li><code>inactive</code>는 iOS 전용이므로 플랫폼 분기를 고려해야 한다.</li>
<li>초기 마운트 시 <code>null</code>이 나올 수 있으니 방어 코드를 넣는다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native 프로덕션 런칭 전 체크 리스트]]></title>
            <link>https://velog.io/@diso592/React-Native-%ED%94%84%EB%A1%9C%EB%8D%95%EC%85%98-%EB%9F%B0%EC%B9%AD-%EC%A0%84-%EC%B2%B4%ED%81%AC-%EB%A6%AC%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@diso592/React-Native-%ED%94%84%EB%A1%9C%EB%8D%95%EC%85%98-%EB%9F%B0%EC%B9%AD-%EC%A0%84-%EC%B2%B4%ED%81%AC-%EB%A6%AC%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Sat, 05 Sep 2026 07:21:14 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>출시 준비를 &quot;스토어 심사를 통과하는가&quot;로만 보면 절반만 보는 것이다. 실제로 문제가 되는 건 올린 뒤다. 디버그에서 멀쩡하던 화면이 릴리스 빌드에서 흰 화면이 되고, 심사에서 막히는 지점은 대부분 코드가 아니라 정책 신고 항목이며, 출시 후에 가장 아쉬운 건 크래시가 났는데 그 사실조차 몰랐다는 상황이다.</p>
<p>이 글은 그 세 가지를 단계별로 정리한 체크 리스트다. 릴리스 빌드에서만 드러나는 문제, 스토어 정책 때문에 리젝당하는 지점, 그리고 운영을 시작하고 나서 후회하는 부분을 순서대로 다룬다. 날짜가 걸린 정책 항목은 2026년 9월 기준이다.</p>
<br>

<hr>
<br>

<h2 id="검증-전제">검증 전제</h2>
<p>체크 리스트를 돌리기 전에 검증 환경부터 맞춰야 한다. 아래 세 가지가 어긋나면 그 뒤의 모든 확인이 의미가 없다.</p>
<p><strong>릴리스 빌드로만 검증한다.</strong> 디버그 빌드는 Metro 서버에 의존하고 JS를 런타임에 평가한다. 프로덕션은 번들이 앱 안에 포함되고 최적화와 난독화가 켜진 상태다. 실행 조건 자체가 다르기 때문에 &quot;디버그에서는 됐다&quot;는 아무 근거가 되지 못한다. iOS는 <code>--mode Release</code>, Android는 <code>assembleRelease</code> 또는 <code>bundleRelease</code> 산출물로 테스트한다.</p>
<p><strong>진짜 콜드 스타트로 검증한다.</strong> 개발 중에 쓰는 reload는 네이티브 프로세스를 살려둔 채 JS만 다시 초기화한다. 영속 저장 누락이나 초기화 순서 버그는 이 방식으로는 절대 재현되지 않는다. 앱을 스와이프로 완전히 종료한 뒤 다시 실행해서 확인한다.</p>
<p><strong>실기기에서 검증한다.</strong> 시뮬레이터와 에뮬레이터는 메모리, CPU, 네트워크 조건이 실기기와 다르다. 특히 저사양 안드로이드 기기는 반드시 한 대 포함한다. 리스트 스크롤이 버벅이는지, 콜드 스타트가 몇 초 걸리는지는 그 기기에서만 드러난다.</p>
<p><br><br></p>
<h2 id="빌드와-릴리스-설정">빌드와 릴리스 설정</h2>
<h3 id="공통">공통</h3>
<ul>
<li><code>__DEV__</code> 분기 코드가 프로덕션으로 새어 들어가지 않는지 확인한다. 디버그 전용 로깅, 목업 데이터, 테스트 플래그가 대표적인 후보다.</li>
<li><code>console.log</code>는 릴리스 빌드에서 제거한다. Hermes에서도 과도한 로깅은 비용이고, 민감 정보가 그대로 찍히는 사고도 흔하다. <code>babel-plugin-transform-remove-console</code>을 릴리스 환경에만 적용한다.</li>
<li>소스맵을 생성해 크래시 리포팅 도구에 업로드한다. 난독화와 minify를 거친 스택트레이스는 소스맵 없이는 읽을 수 없다.</li>
<li>버전은 두 축으로 관리한다. 사용자에게 보이는 <code>versionName</code> / <code>CFBundleShortVersionString</code>과 빌드 식별자인 <code>versionCode</code> / <code>CFBundleVersion</code>을 분리하고, 빌드 넘버 자동 증가는 CI에 넣는다.</li>
</ul>
<h3 id="android">Android</h3>
<ul>
<li><strong>AAB로 출시한다.</strong> Play Store는 APK 직접 업로드를 받지 않는다. <code>./gradlew bundleRelease</code>로 만든다.</li>
<li><strong>R8을 켠다.</strong> <code>minifyEnabled true</code>, <code>shrinkResources true</code>. 다만 켜는 순간 리플렉션에 의존하는 라이브러리가 깨질 수 있다. <code>proguard-rules.pro</code>에 keep 규칙을 추가하고, <strong>R8을 켠 릴리스 빌드로 전 기능 회귀 테스트를 한 번 돌린다.</strong> 난독화 관련 버그는 거의 예외 없이 릴리스에서만 나타나기 때문에, 이 테스트를 건너뛰면 사용자가 먼저 발견한다.</li>
<li>ABI 분할 또는 AAB의 자동 분할로 불필요한 아키텍처를 제외해 다운로드 용량을 줄인다.</li>
<li><code>android:debuggable</code>이 릴리스에서 false인지, <code>android:usesCleartextTraffic</code>이 의도치 않게 열려 있지 않은지 확인한다.</li>
</ul>
<h3 id="ios">iOS</h3>
<ul>
<li>Release scheme으로 아카이브한다. 로컬 IP에 대한 ATS 예외처럼 디버그 전용으로 넣어둔 설정이 Release에 섞이지 않게 한다.</li>
<li>Bitcode는 Apple이 폐기했으므로 신경 쓰지 않아도 된다.</li>
<li>dSYM을 보존하고 크래시 리포팅 도구에 업로드한다.</li>
</ul>
<p><br><br></p>
<h2 id="서명과-키-관리">서명과 키 관리</h2>
<p>Android는 업로드 키와 앱 서명 키를 구분해야 한다. Play App Signing을 쓰면 구글이 앱 서명 키를 관리하고 개발자는 업로드 키만 보관한다. <strong>키스토어와 비밀번호를 잃어버리면 같은 앱으로 업데이트하는 경로가 영구적으로 막힌다.</strong> 새 패키지명으로 다시 올리는 것 말고는 방법이 없고, 그동안 쌓은 설치 수와 리뷰는 전부 사라진다. 키스토어와 비밀번호는 비밀 관리 시스템에 백업하고 저장소에는 절대 커밋하지 않는다.</p>
<p>iOS는 배포 인증서와 프로비저닝 프로파일을 관리한다. <code>fastlane match</code>로 팀의 서명 자산을 한곳에 모아두면 만료나 재발급 때문에 릴리스가 멈추는 일을 줄일 수 있다. 인증서 만료일은 캘린더에 등록해 둔다.</p>
<p>두 플랫폼 모두 서명에 쓰는 비밀값은 CI 시크릿으로만 주입한다. 키스토어 비밀번호, App Store Connect API 키가 코드나 저장소에 들어가면 그 순간 전부 폐기하고 재발급해야 한다.</p>
<p><br><br></p>
<h2 id="스토어-정책-요구사항">스토어 정책 요구사항</h2>
<p>심사에서 가장 자주 막히는 지점들이다. 코드 문제가 아니라 신고 항목과 빌드 설정 문제라서, 출시 직전에 발견하면 일정이 통째로 밀린다.</p>
<h3 id="google-play">Google Play</h3>
<p><strong>타깃 API 레벨.</strong> 2026년 8월 31일부터 신규 앱과 업데이트 모두 Android 16, 즉 API 36 이상을 타깃해야 제출할 수 있다. 아직 준비가 안 됐다면 2026년 11월 1일까지 연장을 신청할 수 있지만, 연장은 유예일 뿐 면제가 아니다. 구글은 &quot;최신 메이저 릴리스로부터 1년 이내&quot;라는 규칙을 매년 적용하므로, <code>targetSdkVersion</code> 상향은 연례 작업으로 잡아두는 편이 낫다.</p>
<p><strong>16KB 페이지 크기.</strong> 2025년 11월 1일부터 Android 15 이상을 타깃하는 앱은 네이티브 라이브러리가 16KB 페이지 정렬을 지원해야 한다. 일회성 연장 마감도 이미 지났다. React Native 코어는 진작에 대응됐지만 문제는 서드파티다. <strong>카메라, 결제, 영상, 암호화, AR 계열 네이티브 모듈 중 하나라도 비호환이면 업로드 자체가 막힌다.</strong> AAB를 Play Console에 올려 메모리 페이지 크기 항목을 확인하거나, 아래처럼 직접 검사한다.</p>
<pre><code class="language-bash">readelf -h lib*.so | grep &#39;Page size&#39;</code></pre>
<p>값이 16384가 아니면 비호환이다. NDK 28 이상으로 재빌드된 최신 버전으로 올리고, 업데이트가 끊긴 라이브러리라면 대체재를 찾아야 한다. 이 작업은 라이브러리 메이저 업그레이드로 이어지는 경우가 많아서 출시 2주 전에 시작하면 늦다.</p>
<p><strong>데이터 안전 폼.</strong> 수집하고 공유하는 데이터 유형을 정확히 신고한다. 실제 동작과 다르면 리젝은 물론 앱 정지 사유가 된다. 직접 수집하지 않더라도 광고나 분석 SDK가 수집하는 항목까지 포함해야 한다.</p>
<p><strong>권한.</strong> 선언한 권한이 실제 기능과 맞는지 확인한다. 백그라운드 위치나 접근성 같은 민감 권한은 정당한 사유를 별도로 소명해야 한다.</p>
<h3 id="app-store">App Store</h3>
<p><strong>프라이버시 매니페스트.</strong> 2024년 5월 1일부터 <code>PrivacyInfo.xcprivacy</code>가 필수다. 파일 타임스탬프, 디스크 여유공간, 시스템 부팅 시간, <code>UserDefaults</code>처럼 사유 선언이 필요한 API를 쓰면 매니페스트에 사유를 적어야 하고, 없으면 App Store Connect가 업로드 단계에서 ITMS-91061 같은 오류로 거부한다. React Native는 코어와 상당수 서드파티 SDK가 이 API를 건드리므로, 앱 매니페스트뿐 아니라 사용하는 SDK들의 매니페스트까지 갖춰져 있어야 한다.</p>
<p><strong>App Tracking Transparency.</strong> IDFA 등으로 추적을 한다면 ATT 권한 요청과 사유 문구가 반드시 있어야 한다.</p>
<p><strong>프라이버시 영양성분 표시.</strong> 실제 수집 항목과 일치하게 작성한다. Play의 데이터 안전 폼과 내용이 어긋나면 그것도 문제가 된다.</p>
<p><strong>권한 사용 설명 문자열.</strong> <code>NSCameraUsageDescription</code>처럼 사용하는 모든 권한에 대한 설명이 채워져 있어야 한다. 비어 있으면 리젝된다. 라이브러리가 요구하는데 정작 앱에서는 안 쓰는 권한이 남아 있는 경우도 흔하니, 최종 빌드의 Info.plist를 직접 열어 확인한다.</p>
<p><br><br></p>
<h2 id="시크릿과-보안">시크릿과 보안</h2>
<p><strong>API 키와 시크릿을 JS 번들에 넣지 않는다.</strong> RN 번들은 추출하기 쉽다. <code>react-native-config</code>에 넣은 값도 빌드 산출물에서 꺼낼 수 있다고 전제해야 한다. 진짜 비밀은 서버에 두고 클라이언트는 토큰 교환만 담당하게 만든다.</p>
<p>토큰과 자격증명은 iOS Keychain, Android Keystore를 사용하는 보안 저장소에 저장한다. <code>react-native-keychain</code>이 대표적이다. <code>AsyncStorage</code>와 <code>MMKV</code>는 암호화되지 않은 일반 저장소이므로 민감 정보를 평문으로 두면 안 된다. MMKV를 쓴다면 암호화 옵션을 켠다.</p>
<p>통신은 HTTPS만 허용한다. 금융이나 의료처럼 보안 요구 수준이 높은 앱이라면 SSL 피닝을 검토한다. 루팅과 탈옥 탐지, 디버거 탐지, 난독화 강도는 앱의 위협 모델에 맞춰 결정할 문제다. 모든 앱이 여기까지 갈 필요는 없다.</p>
<p><br><br></p>
<h2 id="성능">성능</h2>
<p><strong>New Architecture와 Hermes V1이 켜져 있는지 확인한다.</strong> React Native 0.84부터 Hermes V1이 기본값이 되었고 레거시 아키텍처는 제거됐다. 최신 버전에서 새로 만든 프로젝트라면 신경 쓸 일이 없지만, 오래된 템플릿에서 마이그레이션한 프로젝트는 설정이 남아 있는 경우가 있으니 한 번 확인한다.</p>
<p><strong>화면을 넘어가는 길이의 리스트는 FlashList를 쓴다.</strong> <code>FlatList</code>도 동작은 하지만, 항목이 많고 높이가 제각각인 리스트에서는 FlashList 쪽이 스크롤 성능 차이가 확실하다.</p>
<p><strong>애니메이션은 Reanimated worklet으로 UI 스레드에서 돌린다.</strong> JS 스레드에서 프레임을 계산하면 다른 작업 하나에 애니메이션이 통째로 끊긴다.</p>
<p><strong>출시 전에 프로파일링을 한 번은 한다.</strong> Hermes Sampling Profiler나 React DevTools Profiler를 쓴다. &quot;어딘가 느리다&quot;의 원인은 대개 특정 컴포넌트 하나의 불필요한 리렌더인데, 이건 코드를 아무리 읽어도 잘 안 보인다. 측정하면 5분이면 찾는다.</p>
<p>그 외에 이미지 캐싱과 리사이즈가 적절한지, 큰 이미지의 네이티브 디코딩 부담이 없는지 점검한다. 콜드 스타트 지표는 P75 화면 로드 시간처럼 기준선을 정해 측정해 둬야 다음 릴리스에서 나빠졌는지 알 수 있다. 메모리 누수는 <code>useEffect</code> cleanup에서 리스너, 타이머, 구독을 해제했는지 확인하는 것이 기본이다.</p>
<p><br><br></p>
<h2 id="안정성과-모니터링">안정성과 모니터링</h2>
<p><strong>크래시 리포팅은 출시 전에 붙인다.</strong> Sentry나 Firebase Crashlytics 중 하나면 된다. 소스맵과 dSYM 업로드는 CI에 자동화한다. 이걸 안 붙이고 출시하면 사용자 크래시를 눈감고 맞이하는 셈이고, 리뷰에 &quot;자꾸 꺼져요&quot;만 남는다.</p>
<p>처리되지 않은 Promise rejection과 전역 에러를 잡아 리포팅하는 핸들러도 함께 붙인다. New Architecture에서도 처리되지 않은 JS 예외는 앱 종료로 이어질 수 있다.</p>
<p>에러 바운더리로 화면 단위 크래시를 격리한다. 한 화면의 렌더 오류가 앱 전체를 흰 화면으로 만들지 않게 하는 최소한의 방어선이다.</p>
<p>가입이나 결제 같은 핵심 퍼널에는 이벤트 로깅을 심어둔다. 출시 후 어디서 이탈하는지 보려면 데이터가 처음부터 쌓여 있어야 한다.</p>
<p><br><br></p>
<h2 id="네트워크와-백엔드-연동">네트워크와 백엔드 연동</h2>
<p><strong>환경 분리.</strong> dev, staging, prod API 엔드포인트가 빌드 타입별로 정확히 갈리는지 확인한다. 프로덕션 빌드가 staging 서버를 바라보는 사고는 생각보다 자주 일어난다.</p>
<p><strong>버전 호환성.</strong> 앱은 서버보다 오래 살아남는다. 사용자가 업데이트를 안 하면 1년 전 버전이 그대로 돌아간다. 구버전 앱이 신버전 서버와 통신해도 깨지지 않도록 후방 호환을 설계하고, 그럼에도 막아야 할 상황을 위해 강제 업데이트 게이트를 미리 넣어둔다. 이건 나중에 추가할 수 없다. 게이트가 없는 버전은 영원히 게이트가 없다.</p>
<p><strong>실패 처리.</strong> 네트워크 없음, 타임아웃, 5xx 각각에 대한 화면을 정의한다. 재시도는 멱등성 키로 중복 요청을 막는다. 결제 API를 재시도하다 이중 결제가 되는 사고가 여기서 나온다.</p>
<p><strong>타임존과 로케일.</strong> 서버 시간 기준으로 처리하는지, 다국어와 RTL 대응이 필요한지 점검한다.</p>
<p><br><br></p>
<h2 id="권한과-프라이버시-ux">권한과 프라이버시 UX</h2>
<p>런타임 권한은 <strong>거부 시나리오를 반드시 처리한다.</strong> 사용자가 카메라나 위치를 거부해도 앱이 진행 가능한 대체 경로가 있어야 한다. 거부하면 아무것도 못 하고 멈추는 화면은 리젝 사유가 되기도 한다.</p>
<p>권한 요청은 맥락이 생긴 시점에 한다. 앱 첫 실행에 필요한 권한을 전부 몰아서 요청하면 승인율이 눈에 띄게 떨어진다.</p>
<p>개인정보처리방침 URL은 스토어 등록 정보와 앱 내부 양쪽에 둔다. 스토어 필수 항목이다. 계정 생성이 가능한 앱이라면 앱 안에서 계정과 데이터를 삭제할 수 있는 경로를 제공해야 한다. 양쪽 스토어 모두 정책으로 요구한다.</p>
<p><br><br></p>
<h2 id="출시-운영">출시 운영</h2>
<p><strong>단계적 출시.</strong> 한 번에 100%로 풀지 않는다. Play와 App Store 모두 단계적 출시를 지원하니 1~10%부터 시작해 크래시율을 보면서 확대한다. 문제가 생겼을 때 영향받는 사용자 수가 달라진다.</p>
<p><strong>OTA 업데이트 경로.</strong> JS 핫픽스 경로를 미리 마련해 두면 급한 버그를 스토어 심사 없이 고칠 수 있다. Microsoft App Center의 CodePush는 2025년에 서비스가 종료됐으므로, 지금은 Expo Updates나 자체 호스팅 서버, 서드파티 서비스 중에서 고르게 된다. 어느 쪽이든 <strong>네이티브 변경은 OTA로 내보낼 수 없다</strong>는 점을 팀 전체가 알고 있어야 한다. 그리고 OTA 페이로드도 스토어 정책 안에서 운영해야 한다. 앱의 핵심 목적을 바꾸는 변경은 금지다.</p>
<p><strong>롤백 절차.</strong> 직전 정상 버전으로 되돌리는 방법을 문서로 남긴다. 장애 상황에서 문서 없이 기억에 의존하면 복구가 늦어진다.</p>
<p><strong>베타 트랙.</strong> TestFlight나 Play의 내부·비공개 테스트 트랙을 거쳐 실사용자 손에 한 번은 쥐여보고 나간다. 내부 테스터가 못 찾는 버그를 외부 테스터가 하루 만에 찾는다.</p>
<p><strong>모니터링.</strong> 출시일에는 대시보드를 띄워두고 크래시율, ANR, 핵심 퍼널을 실시간으로 본다. 롤아웃 확대 여부를 판단할 근거가 여기서 나온다.</p>
<p><br><br></p>
<h2 id="최종-체크리스트">최종 체크리스트</h2>
<p><strong>빌드와 검증</strong></p>
<ul>
<li><input disabled="" type="checkbox"> R8과 minify를 켠 릴리스 빌드로 전 기능 회귀 테스트 완료</li>
<li><input disabled="" type="checkbox"> 실기기(저사양 안드로이드 포함)에서 콜드 스타트 검증</li>
<li><input disabled="" type="checkbox"> <code>__DEV__</code> 분기, <code>console.log</code>, 디버그 코드 제거 확인</li>
<li><input disabled="" type="checkbox"> 소스맵과 dSYM 생성 및 크래시 리포팅 업로드</li>
</ul>
<p><strong>서명</strong></p>
<ul>
<li><input disabled="" type="checkbox"> Android 키스토어와 비밀번호 안전 백업, 저장소 미커밋</li>
<li><input disabled="" type="checkbox"> iOS 인증서와 프로파일 유효, 만료일 등록</li>
<li><input disabled="" type="checkbox"> 모든 서명 비밀은 CI 시크릿으로만 주입</li>
</ul>
<p><strong>스토어 정책</strong></p>
<ul>
<li><input disabled="" type="checkbox"> Android 타깃 API 36 이상 (또는 연장 승인 상태)</li>
<li><input disabled="" type="checkbox"> 모든 네이티브 <code>.so</code>의 16KB 페이지 크기 호환 확인</li>
<li><input disabled="" type="checkbox"> AAB로 출시</li>
<li><input disabled="" type="checkbox"> iOS <code>PrivacyInfo.xcprivacy</code>와 사용 SDK 매니페스트 구비</li>
<li><input disabled="" type="checkbox"> 데이터 안전 폼과 프라이버시 영양성분 표시가 실제 동작과 일치</li>
<li><input disabled="" type="checkbox"> 모든 권한 사용 설명 문자열과 정당 사유 작성</li>
<li><input disabled="" type="checkbox"> 개인정보처리방침 URL, 해당 시 계정 삭제 경로 제공</li>
</ul>
<p><strong>보안</strong></p>
<ul>
<li><input disabled="" type="checkbox"> API 키와 시크릿 번들 미포함, 토큰은 Keychain / Keystore</li>
<li><input disabled="" type="checkbox"> HTTPS 전용, 민감 정보 평문 저장 없음</li>
</ul>
<p><strong>성능과 안정성</strong></p>
<ul>
<li><input disabled="" type="checkbox"> New Architecture와 Hermes V1 활성 확인</li>
<li><input disabled="" type="checkbox"> 출시 전 프로파일링 1회, 핵심 화면 60fps 확인</li>
<li><input disabled="" type="checkbox"> 크래시 리포팅, 전역 에러 핸들러, 에러 바운더리 동작 확인</li>
<li><input disabled="" type="checkbox"> 리스너, 타이머, 구독 cleanup 점검</li>
</ul>
<p><strong>네트워크와 운영</strong></p>
<ul>
<li><input disabled="" type="checkbox"> prod 엔드포인트 분기 확인, 강제 업데이트 게이트 준비</li>
<li><input disabled="" type="checkbox"> 오프라인과 실패 UX, 멱등 재시도</li>
<li><input disabled="" type="checkbox"> 단계적 출시와 롤백 절차 문서화</li>
<li><input disabled="" type="checkbox"> OTA 핫픽스 경로 마련, 모니터링 대시보드 준비</li>
</ul>
<br>

<hr>
<br>

<h2 id="정리">정리</h2>
<p>이 체크 리스트를 한 문장으로 줄이면 이렇다. <strong>디버그에서 되는 것과 프로덕션에서 안 터지는 것은 다른 문제다.</strong></p>
<p>검증은 릴리스 빌드로, 실기기에서, 완전 종료 후 재실행으로 한다. 출시 후를 볼 눈, 즉 크래시 리포팅과 모니터링을 먼저 붙이고 나서 단계적으로 내보낸다. 서명 키 백업과 강제 업데이트 게이트처럼 나중에 추가할 수 없는 항목은 첫 출시 전에 끝내둔다.</p>
<p>스토어 정책의 구체적인 날짜와 타깃 버전은 매년 갱신된다. 출시 직전에 Play Console과 App Store Connect의 현행 요구사항, 그리고 React Native 릴리스 노트를 한 번 더 확인하는 습관을 들이는 게 좋다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native 에서의 화면 전환은 네이티브가 아닌 js레벨에서 일어나는 이유]]></title>
            <link>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C%EC%9D%98-%ED%99%94%EB%A9%B4-%EC%A0%84%ED%99%98%EC%9D%80-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C%EA%B0%80-%EC%95%84%EB%8B%8C-js%EB%A0%88%EB%B2%A8%EC%97%90%EC%84%9C-%EC%9D%BC%EC%96%B4%EB%82%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C%EC%9D%98-%ED%99%94%EB%A9%B4-%EC%A0%84%ED%99%98%EC%9D%80-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C%EA%B0%80-%EC%95%84%EB%8B%8C-js%EB%A0%88%EB%B2%A8%EC%97%90%EC%84%9C-%EC%9D%BC%EC%96%B4%EB%82%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Thu, 03 Sep 2026 01:59:19 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>React Native로 앱을 만들다 보면 한 번쯤 이런 의문이 생긴다. iOS에는 <code>UINavigationController</code>가 있고 Android에는 Fragment와 백스택이 있는데, 왜 React Navigation은 이걸 그대로 쓰지 않고 JS에서 화면 스택을 관리할까.</p>
<p>화면 전환이 JS 레벨에서 일어난다는 건 단순한 구현 선택이 아니라 React Native의 구조에서 따라 나오는 결과다. 그리고 이 구조를 이해하고 있으면 &quot;버튼을 눌렀는데 화면이 늦게 뜬다&quot; 같은 문제를 훨씬 정확하게 진단할 수 있다.</p>
<p><br><br></p>
<h2 id="1-앱의-화면-구조는-곧-react-트리">1. 앱의 화면 구조는 곧 React 트리</h2>
<p>React Native 앱에서 화면은 결국 컴포넌트다. <code>HomeScreen</code>, <code>ProductDetailScreen</code> 모두 다른 컴포넌트와 다를 게 없는 함수 컴포넌트이고, 이들이 모여 하나의 React 트리를 구성한다. 그 트리를 소유하고 있는 건 JS 런타임이다.</p>
<p>네이티브 쪽은 이 트리가 그려낸 결과를 화면에 올리는 렌더링 대상에 가깝다. RN 앱은 보통 하나의 Activity 또는 하나의 UIViewController 위에 단일 루트 뷰를 올리고, 그 안에서 모든 화면이 렌더링된다.</p>
<p>이 상태에서 네이티브가 화면 스택을 소유한다고 가정해 보면 문제가 분명해진다. 네이티브가 &quot;지금은 상세 화면&quot;이라고 알고 있고, JS도 &quot;지금은 상세 화면&quot;이라고 알고 있어야 한다. 두 개의 상태를 항상 동기화해야 하고, 어긋나는 순간 뒤로가기가 이상하게 동작하거나 화면이 두 번 쌓인다.</p>
<p>그래서 내비게이션 상태를 앱 상태의 일부로 보고 JS에 두는 쪽이 자연스럽다. 화면 전환은 결국 <code>navigate()</code> 호출로 상태를 바꾸고, 그 결과로 React가 리렌더링하는 흐름이다.</p>
<p><br><br></p>
<h2 id="2-플랫폼마다-다른-내비게이션-모델">2. 플랫폼마다 다른 내비게이션 모델</h2>
<p>두 번째 이유는 iOS와 Android의 내비게이션 모델이 생각보다 많이 다르다는 점이다.</p>
<p>Android에는 시스템 백 버튼과 태스크 백스택이 있지만 iOS에는 없다. iOS에는 화면 왼쪽 끝에서 시작하는 스와이프 백 제스처가 표준이지만 Android는 제조사와 OS 버전에 따라 동작이 다르다. 화면 전환 애니메이션의 기본값, 헤더 처리 방식, 모달의 표현도 제각각이다.</p>
<p>이 차이를 그대로 노출하면 개발자는 플랫폼별 분기 코드를 잔뜩 써야 한다. 내비게이션 상태를 JS가 소유하면 그 위에 공통 API를 하나 얹고, 플랫폼별 표현만 아래에서 처리할 수 있다. <code>navigation.navigate(&#39;Detail&#39;)</code>이라는 한 줄이 양쪽에서 동일하게 동작하는 이유다.</p>
<p><br><br></p>
<h2 id="3-상태로-표현되기-때문에-얻는-것들">3. 상태로 표현되기 때문에 얻는 것들</h2>
<p>내비게이션이 상태라는 점은 실무에서 꽤 큰 편의로 돌아온다.</p>
<p>로그인 여부에 따라 화면 구성을 통째로 바꾸는 경우를 보면 명확하다.</p>
<pre><code class="language-tsx">function RootNavigator() {
  const { user, isLoading } = useAuth();

  if (isLoading) return &lt;SplashScreen /&gt;;

  return (
    &lt;Stack.Navigator&gt;
      {user ? (
        &lt;&gt;
          &lt;Stack.Screen name=&quot;Home&quot; component={HomeScreen} /&gt;
          &lt;Stack.Screen name=&quot;Profile&quot; component={ProfileScreen} /&gt;
        &lt;/&gt;
      ) : (
        &lt;Stack.Screen name=&quot;Login&quot; component={LoginScreen} /&gt;
      )}
    &lt;/Stack.Navigator&gt;
  );
}</code></pre>
<p>로그아웃 시 스택을 비우고 로그인 화면으로 보내는 별도의 명령형 코드가 필요 없다. <code>user</code>가 <code>null</code>이 되면 트리가 바뀌고 화면도 따라 바뀐다.</p>
<p>딥링크 처리도 마찬가지다. URL을 파싱해서 내비게이션 상태 객체를 만들면 되고, 앱을 종료했다 다시 켰을 때 화면 위치를 복원하는 것도 상태를 직렬화했다 복원하는 문제로 환원된다.</p>
<p><br><br></p>
<h2 id="4-react-native-screens가-네이티브로-옮긴-것">4. react-native-screens가 네이티브로 옮긴 것</h2>
<p>여기서 흔한 오해가 하나 있다. native-stack을 쓰면 화면 전환이 네이티브에서 일어나니까 JS와 무관하다는 오해다.</p>
<p>react-native-screens가 하는 일은 내비게이션 상태를 네이티브로 옮기는 게 아니라, 화면을 담는 컨테이너를 네이티브 컴포넌트로 바꾸는 것이다. iOS에서는 <code>UINavigationController</code>, Android에서는 Fragment 기반 컨테이너를 실제로 사용한다.</p>
<p>덕분에 전환 애니메이션과 스와이프 백 제스처가 네이티브 구현을 그대로 따르고, 화면에 보이지 않는 스크린은 네이티브 뷰 계층에서 분리되어 메모리와 렌더링 비용을 줄인다. 하지만 &quot;다음에 어떤 화면을 쌓을지&quot; 결정하는 주체는 여전히 JS다.</p>
<p>방향도 양쪽으로 흐른다. 사용자가 스와이프 백 제스처를 하면 네이티브가 그 제스처를 처리해 애니메이션을 그리면서, 동시에 그 사실을 JS로 전달해 내비게이션 상태를 갱신한다. 그래서 JS 스레드가 완전히 멈추면 화면 전환도 결국 멈춘다.</p>
<p>참고로 react-native-navigation처럼 각 화면을 네이티브 뷰 컨트롤러로 등록하고 네이티브가 스택을 소유하는 방식의 라이브러리도 있다. 전환 품질은 좋지만 화면마다 별도의 React 루트가 생기면서 상태 공유가 까다로워지고 네이티브 설정이 늘어난다. 생태계가 JS 소유 방식으로 수렴한 데는 이런 트레이드오프가 있었다.</p>
<p><br><br></p>
<h2 id="5-new-architecture에서의-차이">5. New Architecture에서의 차이</h2>
<p>New Architecture로 넘어와도 내비게이션 상태가 JS에 있다는 사실은 변하지 않는다. React 트리는 여전히 JS에 있고, Fabric은 그 결과를 C++ 섀도우 트리로 만들어 네이티브에 커밋한다.</p>
<p>달라진 부분은 커밋 경로다. 브릿지를 통한 비동기 직렬화 대신 JSI 기반으로 동작하면서 레이아웃 정보 접근과 커밋 순서가 훨씬 예측 가능해졌다. 예전 아키텍처에서 native-stack이 헤더나 화면 크기 계산에서 간헐적으로 어긋나던 문제들이 상당 부분 정리된 배경이다.</p>
<p>React 18의 동시성 기능을 쓸 수 있게 된 점도 실무에서는 의미가 있다. 무거운 화면의 첫 렌더를 <code>startTransition</code>으로 감싸 우선순위를 낮추는 식의 대응이 가능해졌다.</p>
<p><br><br></p>
<h2 id="6-전환이-느리게-느껴질-때">6. 전환이 느리게 느껴질 때</h2>
<p>이 구조를 알고 나면 &quot;화면 전환이 버벅인다&quot;는 증상을 두 가지로 나눠 볼 수 있다.</p>
<p><strong>애니메이션 자체가 끊기는 경우</strong>는 native-stack을 쓰고 있다면 잘 발생하지 않는다. 전환 애니메이션은 네이티브에서 그려지기 때문이다. 만약 JS 기반 stack을 쓰고 있다면 native-stack으로 바꾸는 것만으로 개선되는 경우가 많다.</p>
<p><strong>애니메이션은 부드러운데 새 화면이 빈 상태로 나타났다 뒤늦게 채워지는 경우</strong>가 훨씬 흔하다. 이건 애니메이션 문제가 아니라 새 화면의 첫 렌더가 JS에서 일어나기 때문이다. 화면 컴포넌트가 무겁거나, 마운트 직후 큰 데이터를 동기적으로 가공하거나, 그 시점에 다른 작업이 JS 스레드를 점유하고 있으면 그대로 지연으로 이어진다.</p>
<p>이 경우 데이터를 미리 당겨오는 게 효과적이다.</p>
<pre><code class="language-tsx">const queryClient = useQueryClient();

const openDetail = (id: string) =&gt; {
  queryClient.prefetchQuery({
    queryKey: [&#39;product&#39;, id],
    queryFn: () =&gt; fetchProduct(id),
  });

  navigation.navigate(&#39;ProductDetail&#39;, { id });
};</code></pre>
<p>리스트 아이템을 누르는 시점, 또는 화면에 보이는 시점에 미리 요청을 보내두면 상세 화면이 마운트될 때 캐시에서 바로 읽는다.</p>
<p>화면 컴포넌트 자체가 무거운 경우라면 첫 렌더에서 그려야 할 것을 줄이는 쪽이 우선이다. 상단 영역만 먼저 그리고 아래쪽 무거운 섹션은 이후에 붙이는 식으로 나누는 편이 낫다.</p>
<p>뒤로 간 화면이 계속 리렌더링되는 문제도 짚어둘 만하다. 화면이 스택에 남아 있으면 React 트리에서 언마운트되지 않기 때문에, 전역 상태가 바뀔 때마다 보이지도 않는 화면들이 함께 리렌더링될 수 있다. 최근 버전의 react-native-screens는 화면이 포커스를 잃으면 렌더링을 동결하는 동작이 기본으로 적용되어 있고, 화면 단위로 <code>freezeOnBlur</code> 옵션을 조정할 수 있다.</p>
<p><br><br></p>
<h2 id="정리">정리</h2>
<p>React Native에서 화면 전환이 JS 레벨에서 일어나는 이유는 앱의 화면 구조 자체가 React 트리이고, 그 트리를 소유한 쪽이 JS이기 때문이다. 내비게이션 상태까지 네이티브에서 별도로 관리하면 JS와 네이티브의 상태를 동기화해야 한다. 이렇게 관리해야 할 상태가 늘어나면 구조가 복잡해지고, 단순히 네이티브에서 상태를 관리하는 것보다 얻는 이점이 크지 않을 수 있다.</p>
<p>native-stack과 react-native-screens는 이 구조를 바꾸는 게 아니라, 화면을 담는 컨테이너와 전환 애니메이션을 네이티브로 내려 사용자가 체감하는 품질을 끌어올린다. 상태는 JS, 표현은 네이티브라는 역할 분담이다.</p>
<p>그래서 전환이 느리다고 느껴질 때 봐야 할 곳은 애니메이션이 아니라 새 화면의 첫 렌더 비용인 경우가 대부분이다. 데이터를 미리 가져오고, 첫 렌더에서 그리는 양을 줄이고, 보이지 않는 화면이 불필요하게 일하지 않게 하는 것이 실질적인 개선으로 이어진다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native Android에서 가 공유 시트 닫힐 때 덜컹거리던 문제 — 원인과 해결]]></title>
            <link>https://velog.io/@diso592/React-Native-Android%EC%97%90%EC%84%9C-%EA%B0%80-%EA%B3%B5%EC%9C%A0-%EC%8B%9C%ED%8A%B8-%EB%8B%AB%ED%9E%90-%EB%95%8C-%EB%8D%9C%EC%BB%B9%EA%B1%B0%EB%A6%AC%EB%8D%98-%EB%AC%B8%EC%A0%9C-%EC%9B%90%EC%9D%B8%EA%B3%BC-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@diso592/React-Native-Android%EC%97%90%EC%84%9C-%EA%B0%80-%EA%B3%B5%EC%9C%A0-%EC%8B%9C%ED%8A%B8-%EB%8B%AB%ED%9E%90-%EB%95%8C-%EB%8D%9C%EC%BB%B9%EA%B1%B0%EB%A6%AC%EB%8D%98-%EB%AC%B8%EC%A0%9C-%EC%9B%90%EC%9D%B8%EA%B3%BC-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Tue, 01 Sep 2026 02:10:04 GMT</pubDate>
            <description><![CDATA[<h2 id="1-문제-현상">1. 문제 현상</h2>
<p>Android에서 상세 화면 상단의 헤더(뒤로가기 · 공유 등 액션 버튼이 있는 바)에서 <strong>공유하기</strong>를 누르면 OS 기본 공유 시트(시스템 바텀시트)가 올라온다. 그 시트를 닫는 순간, 헤더가 한 번 <strong>아래로 내려갔다가 다시 제자리로 튀어 올라오는</strong> 스프링 같은 진동(덜컹거림)이 발생했다.</p>
<p>여기서 두 가지를 구분해야 한다.</p>
<ul>
<li><strong>불가피한 기본 동작(baseline)</strong>: Android는 공유 시트가 올라올 때 상단 status bar가 잠깐 사라진다. 그러면 화면 상단 여백이 줄면서 콘텐츠 전체가 살짝 위로 붙었다가, 시트가 닫히면 status bar가 돌아오며 원위치로 내려온다. 이건 다른 상용 앱들에서도 동일하게 나타나는 OS 레벨 현상이라 없앨 수 없다.</li>
<li><strong>우리 앱에만 있던 추가 버그</strong>: baseline은 &quot;위로 올라갔다 → 내려옴&quot; 한 방향 움직임이다. 그런데 우리 헤더 UI는 그 위에 <strong>반대 방향으로 한 번 더(아래로 내려갔다 → 위로) 오버슈트하며 진동</strong>했다. 이 추가 진동이 제거 대상이었다.</li>
</ul>
<p>iOS에서는 이 현상이 없었다. <strong>Android 전용 문제</strong>다.</p>
<br>
<br>

<h2 id="2-배경-지식">2. 배경 지식</h2>
<h3 id="헤더의-세로-위치는-무엇이-결정하나">헤더의 세로 위치는 무엇이 결정하나</h3>
<p>이 헤더는 화면 최상단에 <code>position: absolute</code>, <code>top: 0</code>으로 고정되는 오버레이다. 스크롤 위에 떠 있으면서 위로 밀리지 않는다. 그래서 헤더 안 콘텐츠(버튼 줄)의 세로 위치는 사실상 <strong>위쪽 안전영역 패딩(top inset) 하나</strong>로만 결정된다.</p>
<pre><code class="language-tsx">&lt;View position=&#39;absolute&#39; top={0} paddingTop={safePaddingTop}&gt;
  {/* 뒤로가기 / 공유 등 액션 버튼 줄 */}
&lt;/View&gt;</code></pre>
<p>이 <code>safePaddingTop</code>은 status bar 높이만큼 버튼들을 아래로 밀어내, 버튼이 status bar에 가려지지 않게 하는 값이다.</p>
<h3 id="safepaddingtop은-라이브-값이었다">safePaddingTop은 &quot;라이브&quot; 값이었다</h3>
<p><code>safePaddingTop</code>은 안전영역 inset을 읽는 훅에서 나온다.</p>
<pre><code class="language-ts">function useSafePaddingTop(): number {
  const { top } = useSafeAreaInsets();
  return top;
}</code></pre>
<p>핵심은 <code>useSafeAreaInsets()</code>가 <strong>라이브 React 상태</strong>라는 점이다. 시스템이 전달하는 inset 값이 바뀌면 이 훅을 쓰는 컴포넌트가 리렌더되고, <code>paddingTop</code>이 새 값으로 다시 적용되어 헤더가 즉시 재배치된다.</p>
<br>
<br>

<h2 id="3-원인-분석">3. 원인 분석</h2>
<h3 id="1-헤더-안의-reanimated는-범인이-아니다">1) 헤더 안의 Reanimated는 범인이 아니다</h3>
<p>처음엔 헤더 내부의 Reanimated 애니메이션을 의심했다. 하지만 헤더가 쓰는 애니메이션 값은 <strong>불투명도(opacity)와 아이콘 크기(scale)</strong>만 구동한다. 스크롤에 따라 배경/타이틀을 페이드인하고 아이콘을 살짝 키우는 용도다. <strong>세로 위치(translateY)를 움직이는 애니메이션은 하나도 없다.</strong></p>
<p>따라서 &quot;헤더가 아래로 내려갔다 올라오는&quot; 세로 진동은 헤더의 애니메이션 코드에서 나올 수 없다. Reanimated는 이 증상에 대해선 무죄였다.</p>
<br>

<h3 id="2-진짜-원인--라이브-inset에-묶인-위치">2) 진짜 원인 — 라이브 inset에 묶인 위치</h3>
<p>헤더의 세로 위치를 움직일 수 있는 유일한 변수는 <code>paddingTop = safePaddingTop</code>이고, 이 값은 라이브 inset이다. 즉 <strong>inset 값이 흔들리면 헤더도 그대로 흔들린다.</strong></p>
<p>남은 질문은 하나다. &quot;왜 공유 시트를 닫을 때 inset 값이 한 방향이 아니라 위아래로 진동(오버슈트)하는가?&quot;</p>
<br>

<h3 id="3-android-공유-시트는-별도의-시스템-윈도우다">3) Android 공유 시트는 별도의 시스템 윈도우다</h3>
<p>iOS의 공유 시트(<code>UIActivityViewController</code>)는 <strong>같은 앱 윈도우 안에 뜨는 모달</strong>이다. 그래서 안전영역 inset이 바뀌지 않는다. → iOS에선 헤더가 흔들릴 이유가 없다.</p>
<p>반면 Android의 공유 시트는 <code>Intent.createChooser</code>로 뜨는 <strong>별도의 시스템 윈도우</strong>다. 이 윈도우가 떴다가 닫히는 과정에서 우리 앱 윈도우는 포커스를 잃었다가 되찾고, 그때 시스템이 <code>WindowInsets</code>(status bar 등 여백 정보)를 다시 내려보낸다(re-dispatch). 이 재전달 과정에서 top inset 값이 출렁인다.</p>
<br>

<h3 id="4-keyboardprovider가-inset-변화를-스프링으로-보간한다">4) KeyboardProvider가 inset 변화를 스프링으로 보간한다</h3>
<p>앱 루트에는 <code>react-native-keyboard-controller</code>의 <code>KeyboardProvider</code>가 감싸여 있다. 이 라이브러리는 Android에서 시스템 윈도우 inset 전환을 부드럽게 처리하려고 <strong>inset 변화를 스프링(spring) 보간 프레임으로 흘려보낸다.</strong></p>
<p>스프링 보간은 본질적으로 목표값을 향해 가면서 <strong>오버슈트(목표를 살짝 지나쳤다가 되돌아오는)</strong>가 생긴다. 그래서 공유 시트가 닫힐 때 top inset이 &quot;원래값 → 살짝 초과 → 되돌아옴&quot; 식으로 <strong>위아래로 여러 프레임에 걸쳐 진동</strong>하며 도착한다.</p>
<p>결과적으로:</p>
<pre><code>공유 시트 닫힘
  → 시스템이 WindowInsets 재전달
  → KeyboardProvider가 inset 변화를 spring으로 보간 (오버슈트 진동)
  → useSafeAreaInsets().top 값이 위아래로 진동
  → 라이브 inset에 묶인 헤더 paddingTop이 진동
  → 헤더가 덜컹거림 (baseline 위에 얹힌 추가 진동)</code></pre><p>이것이 &quot;내려갔다 다시 올라오는&quot; 추가 진동의 정체다. baseline(status bar가 사라졌다 돌아오는 한 방향 움직임)은 어쩔 수 없지만, 그 위에 얹힌 <strong>스프링 오버슈트 진동은 우리가 라이브 inset을 그대로 따라가도록 둔 탓</strong>이었다.</p>
<br>
<br>

<h2 id="4-해결-과정">4. 해결 과정</h2>
<p>해결의 방향은 명확했다. <strong>헤더의 위치를 &quot;진동하는 라이브 inset&quot;에서 떼어내, 안정된 값 하나에 고정하는 것.</strong></p>
<h3 id="1-1차-시도--앱-시작-시점의-고정-inset-사용-부작용-발생">1) 1차 시도 — 앱 시작 시점의 고정 inset 사용 (부작용 발생)</h3>
<p>처음엔 안전영역 라이브러리가 제공하는 &quot;앱 시작 시점에 측정된 고정 inset&quot;을 쓰려고 했다.</p>
<pre><code class="language-ts">// 1차 시도: 앱 시작값으로 고정
const initialTop = initialWindowMetrics?.insets.top;
const baseTop = stable &amp;&amp; initialTop != null ? initialTop : liveTop;</code></pre>
<p>이 값은 앱 부팅 때 한 번 측정되고 이후 변하지 않으므로, 공유 시트로 인한 진동에 영향을 받지 않는다. <strong>진동(덜컹거림)은 사라졌다.</strong></p>
<p>그러나 <strong>새로운 부작용</strong>이 생겼다. 헤더의 초기 위치가 평소보다 한 칸 정도 아래로 내려간 것이다.</p>
<p>원인은 이렇다. &quot;앱 시작 시점 측정값(<code>initialWindowMetrics</code>)&quot;과 &quot;화면에 안정적으로 자리잡은 라이브값(<code>useSafeAreaInsets</code>)&quot;은 <strong>항상 같지 않다.</strong> 이 기기/edge-to-edge 설정 조합에서는 시작 시점 측정값이 settled 라이브값보다 더 컸다. 그래서 그 값으로 고정하니 패딩이 커져 헤더가 아래로 밀린 것이다.</p>
<p>즉, 진동은 잡았지만 기준점이 어긋났다.</p>
<br>

<h3 id="2-최종-해결--마운트-시점의-settled-라이브값을-1회-래치">2) 최종 해결 — 마운트 시점의 settled 라이브값을 1회 래치</h3>
<p>올바른 기준값은 &quot;앱 시작 순간의 값&quot;이 아니라 <strong>&quot;이 헤더가 화면에 떴을 때 이미 안정적으로 자리잡은 라이브 inset&quot;</strong>이다. 그래서 시작 시점 메트릭을 버리고, <strong>헤더가 마운트될 때 라이브값을 딱 한 번 캡처(래치)해서 고정</strong>하는 방식으로 바꿨다.</p>
<pre><code class="language-ts">function useStableSafePaddingTop(): number {
  const liveTop = useSafePaddingTop();

  // 첫 유효(&gt;0) 값을 한 번만 래치한 뒤 그 값을 계속 반환
  const stableRef = useRef&lt;number | null&gt;(null);
  if (stableRef.current === null &amp;&amp; liveTop &gt; 0) {
    stableRef.current = liveTop;
  }

  return stableRef.current ?? liveTop;
}</code></pre>
<p>동작 원리:</p>
<ul>
<li><p><strong>초기 위치</strong>
마운트 때의 라이브값을 그대로 캡처하므로, 기존(원래) 위치와 <strong>동일</strong>하다. 1차 시도의 &quot;한 칸 내려감&quot; 부작용이 사라졌다.</p>
</li>
<li><p><strong>진동 제거</strong> 
한 번 래치한 뒤로는 라이브 inset이 어떻게 진동하든 무시하고 고정값만 반환한다. 공유 시트 오버슈트가 헤더에 전달되지 않는다.</p>
</li>
<li><p><strong>오염 걱정 없음</strong>
이 헤더는 앱이 부팅된 뒤 사용자가 상세 화면으로 진입할 때 마운트된다. 그 시점엔 안전영역 inset이 이미 안정적으로 자리잡은 상태라, 첫 캡처값이 곧 정상값이다. 공유 시트는 마운트보다 한참 뒤에 열리므로 캡처 순간엔 진동이 존재하지 않는다.</p>
</li>
<li><p><strong>폴백</strong>
혹시 첫 값이 아직 유효하지 않으면(0) 래치하지 않고 라이브값을 그대로 흘려보내, inset이 해결되는 리렌더에서 자연스럽게 보정된다.</p>
</li>
</ul>
<br>

<h3 id="3-전용-훅으로-분리">3) 전용 훅으로 분리</h3>
<p><code>useSafePaddingTop</code>은 앱 전반의 여러 헤더에서 쓰는 공용 훅이다. 이 안정화 동작이 필요한 곳은 현재 이 상세 화면 헤더 하나뿐이라, 공용 훅에 옵션을 끼워 넣어 모두에게 영향을 주기보다 <strong>별도 훅으로 분리</strong>하는 편이 깔끔했다.</p>
<p>그래서 안정화 로직은 <code>useStableSafePaddingTop</code>이라는 전용 훅에 담고, <code>useSafePaddingTop</code>은 원래 형태 그대로 두었다.</p>
<pre><code class="language-tsx">// 헤더에서의 사용 — 일반 useSafePaddingTop 대신 안정화 버전을 호출
const safePaddingTop = useStableSafePaddingTop();</code></pre>
<br>
<br>

<h2 id="5-적용-범위와-트레이드오프">5. 적용 범위와 트레이드오프</h2>
<ul>
<li><p><strong>적용 대상</strong>
화면 상단에 <code>absolute</code>로 고정되며, 앱 부팅 이후 진입하는 화면의 헤더이다. 일반 flow 컴포넌트는 보통 루트 SafeAreaView 한 곳에서 top inset을 1회 먹는다. absolute 오버레이는 그 흐름 밖이라 자동으로 안 밀린다 → 컴포넌트가 직접 paddingTop = inset 손배선. 그래서 inset 진동을 직격으로 맞는다.</p>
</li>
<li><p><strong>일반 화면</strong> 
안정화가 필요 없다. 기존 <code>useSafePaddingTop</code>을 그대로 쓴다.</p>
</li>
<li><p><strong>트레이드오프</strong> 
한 번 래치한 뒤로는 inset 변화를 따라가지 않으므로, 화면 회전이나 status bar 높이의 실제 변경에는 반응하지 않는다. 이 상세 화면은 세로 고정이라 실질적 영향이 없지만, 회전을 지원하는 화면에서 이 훅을 재사용한다면 이 한계를 고려해야 한다.</p>
</li>
<li><p><strong>확장</strong> 
다른 absolute 헤더(예: 다른 탭·화면들의 헤더)에서도 같은 덜컹거림이 보인다면, 그 헤더의 <code>useSafePaddingTop</code> 호출을 <code>useStableSafePaddingTop</code>으로 바꾸면 동일하게 해결된다.</p>
</li>
</ul>
<br>
<br>

<h2 id="6-핵심-교훈">6. 핵심 교훈</h2>
<ul>
<li><strong>증상이 보이는 곳과 원인이 있는 곳은 다를 수 있다.</strong> 헤더가 흔들렸지만 헤더의 애니메이션 코드엔 위치 애니메이션이 없었다. 진짜 원인은 &quot;헤더가 라이브 inset에 묶여 있다&quot;는 데이터 바인딩 쪽이었다.</li>
<li><strong>&quot;진동하는 신호를 매번 구독&quot;하는 대신 &quot;안정된 값을 한 번 캡처해서 붙박이로&quot;</strong> — 외부에서 출렁이는 값에 UI를 직접 묶지 말고, 의미 있는 한 시점의 값을 고정하는 패턴이 떨림을 없앤다.</li>
<li><strong>플랫폼 차이를 활용해 원인을 좁혀라.</strong> iOS는 멀쩡하고 Android만 깨졌다는 사실이, &quot;같은 윈도우 내 모달 vs 별도 시스템 윈도우 + inset 재전달&quot;이라는 핵심 차이로 곧장 안내했다.</li>
<li><strong>고정값의 출처를 정확히 골라라.</strong> &quot;앱 시작 시점값&quot;과 &quot;마운트 시점 settled값&quot;은 다르다. 진동을 잡겠다고 아무 고정값이나 쓰면 기준점이 어긋난다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native android 앱에서 페이지(Page)란? ]]></title>
            <link>https://velog.io/@diso592/React-Native-android-%EC%95%B1%EC%97%90%EC%84%9C-%ED%8E%98%EC%9D%B4%EC%A7%80Page%EB%9E%80</link>
            <guid>https://velog.io/@diso592/React-Native-android-%EC%95%B1%EC%97%90%EC%84%9C-%ED%8E%98%EC%9D%B4%EC%A7%80Page%EB%9E%80</guid>
            <pubDate>Mon, 31 Aug 2026 04:24:21 GMT</pubDate>
            <description><![CDATA[<h2 id="1-개요">1. 개요</h2>
<p>운영체제는 메모리(RAM)를 한 바이트씩 다루지 않는다. 너무 비효율적이기 때문이다. 대신 메모리를 일정한 크기의 <strong>고정된 덩어리</strong>로 잘라서 관리하는데, 이 덩어리 하나를 <strong>페이지(page)</strong>라고 부른다. 그리고 그 덩어리의 크기가 <strong>페이지 크기(page size)</strong>다.</p>
<p>비유하자면 메모리는 한 권의 책이고, OS는 책을 한 글자씩 읽는 게 아니라 <strong>페이지 단위로 넘기며</strong> 읽는다. OS 입장에서 메모리 관리의 최소 단위가 곧 페이지인 셈이다.</p>
<p>페이지라는 단위가 존재하는 이유는 크게 두 가지다.</p>
<ul>
<li><strong>가상 메모리(Virtual Memory) 관리</strong>: OS는 앱마다 &quot;가상 주소&quot;를 주고, 이걸 실제 물리 메모리 주소로 매핑한다. 이 매핑을 페이지 단위로 관리한다.</li>
<li>바이트 단위로 일일이 추적하면 관리 비용이 폭발한다. 페이지라는 큰 단위로 묶으면 추적해야 할 항목 수가 크게 줄어든다.</li>
</ul>
<br>
<br>

<h2 id="2-android-의-페이지-정책-변화">2. android 의 페이지 정책 변화</h2>
<p>지금까지 대부분의 안드로이드(ARM64) 기기는 페이지 크기를 <strong>4KB</strong>로 써왔다. 오랫동안 이게 표준이었다.</p>
<p>그런데 기기에 탑재되는 RAM이 점점 커지면서, 더 큰 페이지 크기인 <strong>16KB</strong>를 쓰는 방향으로 바뀌고 있다. Android 15(API 35)부터 AOSP가 16KB 페이지를 정식 지원한다.</p>
<p>페이지 크기를 키우면 얻는 이점은 이렇다.</p>
<ul>
<li><strong>앱 실행 속도 향상</strong> : 메모리 압박 상황에서 평균 약 3%, 일부 앱은 최대 30%까지 빨라진다.</li>
<li><strong>전력 소모 감소</strong> : 앱 실행 시 평균 약 4.5% 절감된다.</li>
<li><strong>시스템 부팅 시간 단축</strong> : 평균 약 8%(약 950ms) 빨라진다.</li>
</ul>
<p>원리는 단순하다. 페이지가 커지면 CPU가 자잘한 메모리 조각을 일일이 관리하는 데 드는 오버헤드가 줄고, 그만큼 실제 작업에 자원을 더 쓸 수 있다.</p>
<p>단점도 있다. 페이지가 커지면 작은 메모리를 요청해도 최소 16KB 페이지 하나를 통째로 차지하므로, 평균적으로 메모리를 약간 더 쓴다.</p>
<br>
<br>

<h2 id="3-rn-앱에서-왜-이게-문제가-되는가">3. RN 앱에서 왜 이게 문제가 되는가</h2>
<p>여기가 핵심이다. <strong>순수 JavaScript만으로 이루어진 앱은 페이지 크기와 무관하다.</strong> 페이지 정렬은 어디까지나 네이티브 바이너리(<code>.so</code> 파일) 수준의 이야기이기 때문이다.</p>
<p>문제는 React Native 앱이 <strong>결코 순수 JS가 아니라는 점</strong>이다. RN 앱은 다음과 같은 네이티브 라이브러리(<code>.so</code>)를 잔뜩 포함한다.</p>
<ul>
<li>Hermes 엔진 (<code>libhermes.so</code>)</li>
<li>JSI / New Architecture 관련 네이티브 코드 (<code>libreactnative.so</code> 등)</li>
<li>서드파티 네이티브 모듈: Reanimated, react-native-svg, gesture-handler, MMKV, 카메라, 지도, 결제 SDK 등 상당수가 C/C++ 네이티브 코드를 포함한다.</li>
</ul>
<p>이 <code>.so</code> 파일들이 <strong>4KB 페이지를 가정하고 정렬(alignment)된 채로 빌드</strong>되어 있으면, 16KB 페이지를 쓰는 기기에서 제대로 로드되지 못하거나 크래시(segmentation fault)가 날 수 있다.</p>
<p>즉, RN 앱의 16KB 대응이란 결국 <strong>&quot;앱 안에 들어있는 모든 <code>.so</code> 파일이 16KB 정렬로 빌드되어 있는가&quot;</strong> 의 문제다.</p>
<br>
<br>

<h2 id="4-정렬alignment이라는-개념">4. &#39;정렬(Alignment)&#39;이라는 개념</h2>
<p><code>.so</code> 파일을 메모리에 올릴 때, OS는 파일의 각 세그먼트(LOAD section)를 <strong>페이지 경계에 맞춰</strong> 배치한다. 이때 세그먼트의 시작 주소가 페이지 크기의 배수여야 깔끔하게 들어맞는다. 이것을 &quot;정렬되어 있다&quot;고 표현한다.</p>
<ul>
<li>4KB 정렬 : 세그먼트가 4KB(0x1000)의 배수 주소에서 시작</li>
<li>16KB 정렬 : 세그먼트가 16KB(0x4000)의 배수 주소에서 시작</li>
</ul>
<p>4KB의 배수가 항상 16KB의 배수인 것은 아니다. 그래서 4KB로 정렬된 <code>.so</code>는 16KB 기기에서 경계가 어긋난다. APK Analyzer나 빌드 도구에서 다음과 같은 경고가 뜨는 이유가 바로 이것이다.</p>
<pre><code>4 KB LOAD section alignment, but 16 KB is required</code></pre><p>이 경고는 <strong>앱 다운로드 용량과는 전혀 무관</strong>하다. 오로지 네이티브 라이브러리의 메모리 정렬에 관한 이야기다.</p>
<br>
<br>

<h2 id="5-google-play의-요구사항">5. Google Play의 요구사항</h2>
<p>구글은 이 변화에 맞춰 정책을 도입했다.</p>
<ul>
<li><strong>2025년 11월 1일</strong>: Android 15(API 35) 이상을 타겟팅하는 <strong>신규 앱 및 업데이트</strong> 제출 시 16KB 페이지 지원 필수</li>
<li><strong>연장 기한</strong>: Play Console을 통해 신청 시 2026년 5월 31일까지 연장 가능</li>
</ul>
<p>대응하지 않으면 해당 마감 이후 앱 업데이트를 게시할 수 없게 된다. 버그 수정도, 신규 기능도, 보안 패치도 올릴 수 없다는 뜻이다.</p>
<br>
<br>

<h2 id="6-react-native에서의-대응">6. React Native에서의 대응</h2>
<p>다행히 RN은 16KB 페이지를 이미 공식 지원한다. 대응의 본질은 결국 <strong>버전 업그레이드와 재빌드</strong>다.</p>
<h3 id="핵심-조치">핵심 조치</h3>
<ol>
<li><p><strong>React Native 버전 업그레이드</strong>
RN은 0.75.3부터 16KB를 지원하기 시작했으며, 0.76 이상(New Architecture 기본)으로 올리는 것이 가장 안전하다.</p>
</li>
<li><p><strong>빌드 툴체인 업데이트</strong></p>
<ul>
<li>NDK r28 이상</li>
<li>Android Gradle Plugin(AGP) 8.5.1 이상</li>
</ul>
<p>이 버전들은 빌드 시 16KB 정렬을 자동으로 처리해준다.</p>
</li>
<li><p><strong>서드파티 네이티브 모듈 최신화</strong>
네이티브 코드를 포함한 라이브러리(Reanimated, MMKV 등)를 16KB 호환 버전으로 올린다. 보통 라이브러리 메이저/마이너 버전 업으로 해결된다.</p>
</li>
</ol>
<br>

<h3 id="테스트-방법">테스트 방법</h3>
<p>실제 16KB 환경에서 동작을 검증하는 것이 가장 확실하다.</p>
<ul>
<li><strong>에뮬레이터</strong>: Android Studio SDK Manager에서 &quot;16 KB Page Size&quot; 시스템 이미지를 받아 AVD를 생성한다.</li>
<li><strong>실기기</strong>: Pixel(Android 15 QPR1 이상)의 개발자 옵션에서 16KB 페이지를 직접 켤 수 있다.</li>
<li>기기에서 실제 페이지 크기는 다음 명령으로 확인한다.</li>
</ul>
<pre><code class="language-bash">adb shell getconf PAGE_SIZE
# 4096 또는 16384 출력</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native에서의 BottomSheetModal 성능 저하 원인 및 최적화 방안]]></title>
            <link>https://velog.io/@diso592/React-Native%EC%97%90%EC%84%9C%EC%9D%98-BottomSheetModal-%EC%84%B1%EB%8A%A5-%EC%A0%80%ED%95%98-%EC%9B%90%EC%9D%B8-%EB%B0%8F-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%B0%A9%EC%95%88</link>
            <guid>https://velog.io/@diso592/React-Native%EC%97%90%EC%84%9C%EC%9D%98-BottomSheetModal-%EC%84%B1%EB%8A%A5-%EC%A0%80%ED%95%98-%EC%9B%90%EC%9D%B8-%EB%B0%8F-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%B0%A9%EC%95%88</guid>
            <pubDate>Fri, 28 Aug 2026 05:50:18 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p><code>@gorhom/bottom-sheet</code>로 바텀시트를 구현하다 보면, 동일한 UI/UX 요구사항임에도 <code>BottomSheetModal</code>과 <code>BottomSheet</code> 사이에 드래그 응답성 차이가 발생하는 경우가 있다. 두 컴포넌트는 내부적으로 동일한 코어를 공유하지만, 마운트 위치가 달라지면서 React 렌더링 파이프라인과 UI 스레드 자원 점유 양상이 달라진다. 이 글에서는 그 기술적 원인을 라이브러리 내부 구조 관점에서 분석하고, 실무에서 적용 가능한 최적화 방안을 정리한다.</p>
<blockquote>
<p>본 글은 <code>@gorhom/bottom-sheet</code> v5(Reanimated v3 + Gesture Handler v2) 기준으로 작성되었다.</p>
</blockquote>
<br>
<br>

<h3 id="문제-현상">문제 현상</h3>
<ul>
<li><strong><code>BottomSheetModal</code> 사용 시:</strong> 드래그(상하 제스처) 중 프레임 드롭과 응답 지연이 관측된다. 특히 하위 화면이 지도, 비디오, 캔버스 등 네이티브 GPU 자원을 점유하는 뷰일 때 두드러진다.</li>
<li><strong><code>BottomSheet</code> 사용 시:</strong> 동일한 시각 구조에서도 60fps에 가까운 부드러운 드래그가 유지된다.</li>
</ul>
<p>체감상으로는 &quot;모달 쪽이 더 무겁다&quot;는 인상이지만, 실제로 두 컴포넌트의 코어 로직은 동일하다. 차이는 거의 전적으로 <strong>마운트 위치</strong>에서 비롯된다.</p>
<br>

<h3 id="원인-분석">원인 분석</h3>
<p>문제의 본질은 컴포넌트 단일의 연산 속도가 아니라, <strong>React 트리에서의 마운트 위치 차이</strong>와 그로부터 파생되는 <strong>reconciliation 범위, 제스처/애니메이션 worklet의 컨텍스트 부담, 네이티브 뷰 레이아웃 비용</strong>의 차이에 있다.</p>
<h4 id="1-마운트-위치의-차이--portal-host">1) 마운트 위치의 차이 : Portal Host</h4>
<p><code>BottomSheet</code>는 자신을 사용하는 컴포넌트의 자식으로 그 자리에 마운트된다. 반면 <code>BottomSheetModal</code>은 내부적으로 <code>@gorhom/portal</code>을 사용해 <code>BottomSheetModalProvider</code>가 제공하는 Portal Host로 children을 옮겨 마운트한다. <code>BottomSheetModal</code>은 사실상 <em>Portal로 감싸진 <code>BottomSheet</code></em>이다.</p>
<p>실제로 체감 성능 차이를 만드는 지점은 다음 세 가지다.</p>
<h4 id="2-react-트리-구조와-context-의존성">2) React 트리 구조와 Context 의존성</h4>
<p><code>BottomSheetModalProvider</code>는 보통 앱 루트(<code>App.tsx</code>) 근처에 위치한다 즉, <code>BottomSheetModal</code>의 children은 화면 컴포넌트가 아닌 <strong>앱 루트 근처의 Context 영향권</strong>에 마운트된다. 이로 인해 다음과 같은 부수 효과가 발생할 수 있다.</p>
<ul>
<li>상위 Provider(예: <code>NavigationContainer</code>, 테마, 인증, i18n)의 값이 변경되면 Portal 내부 children까지 리렌더링 대상에 포함된다.</li>
<li>화면 단위에서만 의미 있는 Context를 모달 내부에서 참조하면, 해당 Context를 위로 끌어올리거나 별도 동기화 로직이 필요해진다. 이 과정에서 불필요한 리렌더가 누적될 수 있다.</li>
</ul>
<p>이는 GPU의 합성 비용이 아니라 <strong>JS 스레드의 reconciliation 비용</strong>으로 누적되며, 드래그처럼 매 프레임 worklet과 JS 간 통신이 빈번한 상황에서 체감 성능에 영향을 준다. 때문에 해당 BottomSheetModal의 바로 상위부모에 위치하도록 바텀시트모달Provider를 구현하면 좋다.</p>
<h4 id="3-reanimated-worklet과-gesture-handler의-컨텍스트">3) Reanimated worklet과 Gesture Handler의 컨텍스트</h4>
<p><code>@gorhom/bottom-sheet</code>의 드래그 동작은 <code>react-native-gesture-handler</code>와 <code>react-native-reanimated</code>의 worklet 위에서 실행된다. 라이브러리는 <code>BottomSheetInternalContext</code>를 통해 <code>animatedPosition</code> 등의 SharedValue를 공유하며, 이 값들은 UI 스레드에 상주하므로 이상적인 환경에서는 JS 스레드의 부하와 무관하게 60/120fps를 유지할 수 있다.</p>
<p>문제는 다음과 같은 상황에서 발생한다.</p>
<ul>
<li>모달 내부에서 JS 스레드에 의존하는 값(예: 외부 상태, Context, 네트워크 결과)을 <code>useAnimatedStyle</code>이나 <code>useDerivedValue</code>에서 참조하면, worklet 실행마다 JSI 호출이 끼어들어 프레임 일관성이 깨진다.</li>
<li>Portal 구조상 모달이 앱 루트에 가깝게 마운트되므로, 화면 단위 상태를 worklet에서 참조하려면 shared value로 끌어올리는 별도 설계가 필요하다. 이를 누락하면 JS 의존성이 늘어나 worklet의 이점이 약화된다.</li>
</ul>
<p>즉, 핵심은 &quot;GPU 합성 비용 증가&quot;보다는 <strong>UI 스레드 자원 경합</strong>과 <strong>네이티브 뷰의 자체 갱신 부담</strong>이다.</p>
<h3 id="25-컴포넌트-생명주기와-상시-마운트">2.5 컴포넌트 생명주기와 상시 마운트</h3>
<p><code>BottomSheetModal</code>을 화면 진입과 동시에 트리에 두면, 닫혀 있어도 다음과 같은 비용이 유지된다.</p>
<ul>
<li>children의 초기 마운트 비용이 화면 진입 시점에 발생한다.</li>
<li>Provider 하위에 상주하므로 부모 리렌더의 영향권에 계속 포함된다.</li>
<li>내부 SharedValue와 worklet 핸들러가 메모리에 유지된다.</li>
</ul>
<p>이는 단일 화면에서는 미미해 보일 수 있으나, 화면 스택이 깊어지면 누적 부담이 된다.</p>
<br>
<br>




<h2 id="3-측정과-가설-검증">3. 측정과 가설 검증</h2>
<p>최적화에 앞서, 체감으로만 판단하지 말고 다음 도구로 병목 위치를 좁혀야 한다.</p>
<ul>
<li><strong>Perf Monitor (<code>DevSettings</code>):</strong> JS FPS와 UI FPS를 분리해서 확인한다. UI FPS는 멀쩡한데 JS FPS만 떨어진다면 reconciliation/JS 의존성 문제이고, UI FPS가 같이 떨어진다면 네이티브 뷰 갱신이나 worklet 자체 비용 문제다.</li>
<li><strong>Reanimated <code>useAnimatedReaction</code> 로깅:</strong> worklet 내부에서 SharedValue 변화 빈도를 확인한다. JS bridge로 새는 호출이 없는지 점검할 수 있다.</li>
<li><strong>Flipper / Hermes Profiler:</strong> 드래그 중 어떤 컴포넌트가 리렌더되는지 추적한다. React DevTools의 &quot;Highlight updates&quot;도 동일한 목적에 유효하다.</li>
<li><strong>Systrace / Android Studio Profiler:</strong> 네이티브 뷰의 draw 비용이 의심될 때 사용한다.</li>
</ul>
<p>원인을 분리해두면, 아래 최적화 중 어떤 것이 실제로 효과가 있을지 빠르게 판단할 수 있다.</p>
<br>
<br>



<h2 id="4-최적화-전략">4. 최적화 전략</h2>
<h3 id="1-렌더링-위치-선택--모달이-꼭-필요한가">1) 렌더링 위치 선택 : 모달이 꼭 필요한가?</h3>
<p>전역 어디서든 띄울 필요가 없고, 특정 화면 내에서만 동작하는 바텀시트라면 <code>BottomSheetModal</code> 대신 <code>BottomSheet</code>를 사용한다. Portal Host를 거치지 않으므로 화면 Context를 그대로 사용할 수 있고, reconciliation 범위가 좁아진다.</p>
<p><code>BottomSheetModal</code>이 적합한 경우는 다음과 같다.</p>
<ul>
<li>여러 화면에서 동일한 모달을 띄워야 하는 경우</li>
<li>네비게이션 헤더/탭바 위에 시각적으로 겹쳐야 하는 경우</li>
<li>모달을 제어하는 주체와 표시되는 위치가 서로 다른 컴포넌트인 경우(예: 전역 액션 시트)</li>
</ul>
<br>

<h3 id="2-snap-point-단순화">2) Snap Point 단순화</h3>
<p><code>snapPoints={[&#39;25%&#39;, &#39;50%&#39;, &#39;90%&#39;]}</code>처럼 다단계 스냅을 두면 드래그 범위와 worklet 계산 빈도가 늘어난다. 다음을 검토한다.</p>
<ul>
<li>사용자가 실제로 사용하는 스냅이 1~2개라면 단일 스냅으로 줄이고 <code>enablePanDownToClose</code>로 닫기만 허용한다.</li>
<li>동적 콘텐츠 높이가 필요하면 <code>enableDynamicSizing</code>(v5에서 기본값 <code>true</code>)을 활용해 불필요한 좌표 추적을 줄인다. 단, 콘텐츠 크기 변화가 잦은 경우 오히려 layout 비용이 증가할 수 있으므로 측정 후 결정한다.</li>
</ul>
<br>

<h3 id="3-지연-마운트-패턴">3) 지연 마운트 패턴</h3>
<pre><code class="language-tsx">const [visible, setVisible] = useState(false);
const bottomSheetRef = useRef&lt;BottomSheetModal&gt;(null);

return (
  &lt;&gt;
    &lt;Button onPress={() =&gt; setVisible(true)} title=&quot;Open&quot; /&gt;
    {visible &amp;&amp; (
      &lt;MySheet
        ref={bottomSheetRef}
        onDismiss={() =&gt; setVisible(false)}
      /&gt;
    )}
  &lt;/&gt;
);</code></pre>
<p><code>BottomSheetModal</code>을 쓰더라도 children 자체를 조건부로 마운트하면, 닫혀 있는 동안의 reconciliation 부담을 제거할 수 있다. <code>dismiss()</code>만으로는 children 트리가 유지되는 경우가 있어, 무거운 children일수록 조건부 마운트가 유효하다.</p>
<br>

<h3 id="4-worklet-친화적-설계">4) worklet 친화적 설계</h3>
<p>드래그 중 변하는 값은 <code>SharedValue</code>로 관리하고, JS 상태는 <code>scheduleOnRN</code>로 필요한 시점에만 동기화한다.</p>
<pre><code class="language-tsx">// ❌ worklet 안에서 JS Context를 직접 참조 — 매 프레임 JSI 호출
const animatedStyle = useAnimatedStyle(() =&gt; {
  return { opacity: theme.isDark ? 0.8 : 1 }; // theme은 JS 객체
});

// ✅ 필요한 값만 SharedValue로 끌어올리고 worklet에서는 그것만 참조
const opacity = useSharedValue(theme.isDark ? 0.8 : 1);
useEffect(() =&gt; {
  opacity.value = theme.isDark ? 0.8 : 1;
}, [theme.isDark]);

const animatedStyle = useAnimatedStyle(() =&gt; {
  return { opacity: opacity.value };
});</code></pre>
<p>추가로:</p>
<ul>
<li>모달 내부에서 외부 Context를 worklet에서 직접 참조하지 않는다. 필요한 값만 SharedValue로 끌어올린다.</li>
<li><code>BottomSheetScrollView</code> / <code>BottomSheetFlatList</code> 등 라이브러리가 제공하는 통합 스크롤 컴포넌트를 사용한다. 일반 <code>ScrollView</code>를 쓰면 제스처 충돌과 추가 JS 핸들링이 발생한다.</li>
<li><code>onChange</code> 같은 콜백 안에서 무거운 setState를 호출하지 않는다. 필요하면 <code>useDerivedValue</code>로 UI 스레드에서 처리하고, JS 동기화는 최소 빈도로 한다.</li>
</ul>
<br>

<h3 id="5-하위-네이티브-뷰-부담-줄이기">5) 하위 네이티브 뷰 부담 줄이기</h3>
<p>지도/비디오/캔버스 위에 모달이 떠야 한다면 다음을 고려한다.</p>
<ul>
<li>모달이 열려 있는 동안 하위 뷰의 갱신 빈도를 낮춘다. 지도 카메라 애니메이션 일시 중지, 비디오 일시정지, Skia 렌더 루프 중단(<code>useFrameCallback</code>의 <code>setActive(false)</code>) 등.</li>
<li>모달이 충분히 큰 영역을 가린다면 <code>pointerEvents=&quot;none&quot;</code>만으로는 부족하므로, 상태 기반으로 하위 뷰의 활성화 여부를 분기한다.</li>
<li>가능하면 <code>react-native-maps</code>의 <code>liteMode</code>(Android) 등 경량 모드를 검토한다. iOS에는 직접적인 대응이 없으므로, 카메라 이동/마커 갱신을 멈추는 방식으로 우회한다.</li>
</ul>
<br>
<br>

<h2 id="5-의사결정-체크리스트">5. 의사결정 체크리스트</h2>
<p>실무에서 빠르게 판단하기 위한 요약이다.</p>
<ol>
<li><strong>이 바텀시트, 정말 모달이어야 하는가?</strong><ul>
<li>상시 열려 있고 닫을 수 없는 시트 → <code>BottomSheet</code></li>
<li>열고 닫는 동작이 있는 시트 → <code>BottomSheetModal</code></li>
</ul>
</li>
<li><strong>닫혀 있을 때도 마운트되어 있는가?</strong><ul>
<li>그렇다 → <code>{visible &amp;&amp; &lt;Sheet /&gt;}</code> 패턴으로 지연 마운트</li>
</ul>
</li>
<li><strong>스냅 포인트가 3개 이상인가?</strong><ul>
<li>사용자가 실제로 다 쓰는지 확인 → 1~2개로 축소</li>
</ul>
</li>
<li><strong>worklet에서 JS 객체나 Context를 직접 참조하는가?</strong><ul>
<li>그렇다 → SharedValue로 끌어올림</li>
</ul>
</li>
<li><strong>하위에 지도/비디오/WebView/Skia가 있는가?</strong><ul>
<li>그렇다 → 모달 오픈 동안 갱신 빈도 낮춤</li>
</ul>
</li>
<li><strong>Fabric을 켰는가?</strong><ul>
<li>안 켰다면, 신규 프로젝트는 켜고 재측정</li>
</ul>
</li>
</ol>
<br>
<br>



<h2 id="6-요약">6. 요약</h2>
<ul>
<li><code>BottomSheetModal</code>의 성능 저하는 GPU 합성 자체의 문제가 아니라, <strong>Portal로 인한 마운트 위치 변화</strong>, <strong>reconciliation 범위 확대</strong>, <strong>worklet의 JS 의존성</strong>, <strong>하위 네이티브 뷰와의 UI 스레드 자원 경합</strong>이 결합된 결과이다.</li>
<li>최적화 전에 Perf Monitor와 프로파일러로 병목이 JS 스레드인지 UI 스레드인지부터 분리해야 한다.</li>
<li>모달이 반드시 필요하지 않다면 <code>BottomSheet</code>로 통합한다.</li>
<li>스냅 포인트를 단순화하고, children을 조건부로 마운트하며, worklet에서 JS 의존성을 최소화한다.</li>
<li>하위에 무거운 네이티브 뷰가 있다면, 모달이 열린 동안 그 뷰의 갱신 비용을 능동적으로 낮춘다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native 에서 이미지 최적화  (3.  이미지 Preload시 주의사항)]]></title>
            <link>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94-3.-%EC%9D%B4%EB%AF%B8%EC%A7%80-Preload%EC%8B%9C-%EC%A3%BC%EC%9D%98%EC%82%AC%ED%95%AD</link>
            <guid>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94-3.-%EC%9D%B4%EB%AF%B8%EC%A7%80-Preload%EC%8B%9C-%EC%A3%BC%EC%9D%98%EC%82%AC%ED%95%AD</guid>
            <pubDate>Wed, 26 Aug 2026 07:25:18 GMT</pubDate>
            <description><![CDATA[<h2 id="1-preload의-핵심-원칙">1. Preload의 핵심 원칙</h2>
<p><strong>프리로드하는 URL은 컴포넌트가 실제로 렌더링할 URL과 글자 단위까지 동일해야 한다.</strong>
FastImage는 <code>URL(+헤더)</code>를 캐시 키로 쓰기 때문에, URL이 조금이라도 다르면 다른 캐시로 취급되어 프리로드가 무효가 된다.</p>
<pre><code class="language-typescript">// 컴포넌트가 이걸 렌더링한다면
&lt;FastImage source={{ uri: &#39;https://cdn.example.com/img.jpg?w=300&#39; }} /&gt;

// 프리로드도 정확히 같은 URL로 해야 한다
FastImage.preload([{ uri: &#39;https://cdn.example.com/img.jpg?w=300&#39; }]);</code></pre>
<br>
<br>

<h2 id="2-리사이징된-이미지는-리사이징된-url로-프리로드한다">2. 리사이징된 이미지는 리사이징된 URL로 프리로드한다</h2>
<p>서버/CDN에서 리사이징한 이미지를 표시한다면, <strong>리사이징된 URL</strong>로 프리로드해야 한다.</p>
<pre><code class="language-typescript">// ❌ 잘못된 예 — 원본을 프리로드, 화면엔 리사이즈본을 표시
FastImage.preload([{ uri: &#39;https://cdn.example.com/img.jpg&#39; }]);
//... 마운트 시
&lt;FastImage source={{ uri: &#39;https://cdn.example.com/img.jpg?w=300&amp;h=300&#39; }} /&gt;
// → 캐시 키가 다름 → 마운트 시 재다운로드 → 프리로드 의미 없음

// ✅ 올바른 예 — 표시할 URL 그대로 프리로드
const displayUrl = &#39;https://cdn.example.com/img.jpg?w=300&amp;h=300&#39;;
FastImage.preload([{ uri: displayUrl }]);
//... 마운트 시
&lt;FastImage source={{ uri: displayUrl }} /&gt;</code></pre>
<p>표시용 URL을 만드는 함수를 하나 만들어 두고, 프리로드와 렌더링 양쪽에서 같은 함수를 호출하면 불일치를 원천 차단할 수 있다.</p>
<pre><code class="language-typescript">const buildImageUrl = (id: string, w: number, h: number) =&gt;
  `https://cdn.example.com/${id}.jpg?w=${w}&amp;h=${h}`;

const url = buildImageUrl(&#39;img&#39;, 300, 300);
FastImage.preload([{ uri: url }]); // 프리로드
&lt;FastImage source={{ uri: url }} /&gt;; // 렌더링</code></pre>
<br>

<h3 id="3-resizemode와-실제-리사이징을-혼동하지-않는다">3) <code>resizeMode</code>와 &quot;실제 리사이징&quot;을 혼동하지 않는다</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>의미</th>
<th>캐시 키에 영향</th>
</tr>
</thead>
<tbody><tr>
<td><strong>서버/CDN 리사이징</strong></td>
<td>URL이 다름, 받는 바이트가 다름 (<code>?w=300</code> 등)</td>
<td><strong>영향 있음</strong> → 표시 URL로 프리로드 필요</td>
</tr>
<tr>
<td><strong><code>resizeMode</code> (cover/contain/...)</strong></td>
<td>받아온 이미지를 화면에 맞추는 표시 방식</td>
<td><strong>영향 없음</strong> → 같은 URL이면 같은 캐시</td>
</tr>
</tbody></table>
<p>즉 <code>resizeMode</code>만 다르고 URL이 같으면 프리로드는 그대로 유효하다. 신경 쓸 건 &quot;URL이 달라지는 리사이징&quot;뿐이다.</p>
<br>

<h3 id="4-url이-미세하게-달라지는-케이스-주의-캐시-미스-유발">4) URL이 미세하게 달라지는 케이스 주의 (캐시 미스 유발)</h3>
<p>같은 이미지인데 캐시 미스가 나는 흔한 원인들:</p>
<ul>
<li>쿼리 파라미터 <strong>순서</strong>가 다름 (<code>?w=300&amp;h=300</code> vs <code>?h=300&amp;w=300</code>)</li>
<li>불필요한 파라미터가 붙거나 빠짐 (<code>&amp;t=...</code> 타임스탬프, 트래킹 파라미터 등)</li>
<li>프로토콜/도메인 차이 (<code>http</code> vs <code>https</code>, <code>cdn.</code> 유무)</li>
<li>대소문자/인코딩 차이 (<code>%2F</code> vs <code>/</code>)</li>
</ul>
<p>→ URL을 <strong>정규화(normalize)</strong> 해서 프리로드와 렌더링에 동일하게 쓰는 것이 안전하다.</p>
<br>

<h3 id="5-인증-헤더--서명presigned-url-주의">5) 인증 헤더 / 서명(presigned) URL 주의</h3>
<ul>
<li><p><strong>헤더가 필요한 이미지</strong>는 프리로드 시에도 같은 <code>headers</code>를 넘겨야 한다.</p>
<pre><code class="language-typescript">FastImage.preload([
  { uri: url, headers: { Authorization: `Bearer ${token}` } },
]);</code></pre>
</li>
<li><p><strong>서명/만료되는 URL(presigned)</strong> 은 특히 주의한다.</p>
<ul>
<li>프리로드할 때 생성한 URL과 렌더링할 때 생성한 URL의 <strong>서명 쿼리(<code>X-Amz-Signature</code>, 토큰 등)가 다르면</strong> 서로 다른 캐시 키가 된다 → 캐시 미스.</li>
<li>프리로드 후 마운트까지 시간이 지나 <strong>토큰이 만료되면</strong> 다운로드 자체가 실패할 수 있다.</li>
<li>대응: 같은 URL 문자열을 재사용하거나, 만료 시간을 충분히 길게 잡거나, (포크가 지원한다면) 안정적인 <code>cacheKey</code>를 사용한다.</li>
</ul>
</li>
</ul>
<br>

<h3 id="6-프리로드-양과-타이밍">6) 프리로드 양과 타이밍</h3>
<ul>
<li><strong>곧 화면에 보일 것만</strong> 선별적으로 프리로드한다. 리스트 전체를 한 번에 프리로드하면 대역폭·디스크·메모리 낭비다.</li>
<li>다음 화면 진입 직전, 또는 리스트에서 곧 보일 다음 페이지 정도만 미리 받는 패턴이 적절하다.</li>
<li>프리로드는 <strong>fire-and-forget</strong> 으로 두되, 결과를 기다려 UI를 막지 않는다.</li>
</ul>
<br>

<h3 id="7-프리로드가-보장하는-범위">7) 프리로드가 보장하는 범위</h3>
<ul>
<li>프리로드는 보통 <strong>네트워크 다운로드 + 디스크 캐시 저장</strong>까지를 보장한다.</li>
<li><strong>디코딩(decode)·디스플레이 준비</strong>까지 미리 끝내주는 것은 아니다. 따라서 마운트 시점에 디코딩 비용은 남을 수 있다. &quot;프리로드 = 화면에 즉시 픽셀이 뜬다&quot;가 아니라 &quot;네트워크 왕복을 없애준다&quot;로 이해한다.</li>
</ul>
<br>


<h2 id="정리">정리</h2>
<p>이미지 URL에 리사이징·최적화 파라미터를 붙이는 것은 단순한 편의 기능이 아니라, <strong>네트워크·메모리·렌더링 성능을 한 번에 개선하는 핵심 최적화 기법</strong>이다. 요점은 다음과 같다.</p>
<ul>
<li>표시 크기에 맞춰 <code>w</code>를 지정해 불필요하게 큰 이미지를 받지 않는다.</li>
<li>React Native에서는 <code>PixelRatio</code>를 반영하되, 과도한 요청을 막기 위해 DPR 상한을 두는 것을 고려한다.</li>
<li><code>q=75~85</code>로 화질과 용량의 균형을 맞춘다.</li>
<li><code>f=webp</code>(또는 상황에 따라 <code>avif</code>)로 포맷을 최적화한다.</li>
<li>파라미터 조합을 일정하게 유지해 CDN 캐시 적중률을 높인다.</li>
</ul>
<p>특히 메모리에 민감한 모바일 환경에서는, 비트맵 디코딩 메모리가 파일 용량이 아니라 <strong>픽셀 수에 비례한다</strong>는 점 때문에 리사이징의 효과가 웹보다 훨씬 크게 나타난다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native 에서 이미지 최적화  (2. URL 리사이징)]]></title>
            <link>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94-2.-URL-%EB%A6%AC%EC%82%AC%EC%9D%B4%EC%A7%95</link>
            <guid>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94-2.-URL-%EB%A6%AC%EC%82%AC%EC%9D%B4%EC%A7%95</guid>
            <pubDate>Tue, 25 Aug 2026 12:32:46 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>모던 웹·앱에서 이미지를 다룰 때, 원본 이미지를 그대로 내려받는 경우는 거의 없다. 대신 이미지 CDN 또는 이미지 변환 서버(image transformation server)에 <strong>쿼리 파라미터</strong>를 붙여서 &quot;내가 필요한 크기·품질·포맷으로 변환해서 내려달라&quot;고 요청한다. 이 방식을 <strong>on-the-fly image resizing</strong> 또는 <strong>dynamic image optimization</strong>이라 부른다.</p>
<p>대표적인 서비스로 Cloudinary, imgix, Thumbor, Cloudflare Images, Next.js Image Optimization, ImageKit 등이 있다. 파라미터 이름은 서비스마다 조금씩 다르지만 개념은 동일하다.</p>
<pre><code>https://cdn.example.com/photo.jpg?w=300&amp;q=85&amp;f=webp
                                  └──────┬──────┘
                                   변환 요청 파라미터</code></pre><p>서버는 이 요청을 받으면 원본을 변환한 결과를 생성하고, 그 결과를 캐시에 저장한 뒤 응답한다. 같은 파라미터로 다시 요청이 오면 캐시된 결과를 즉시 반환하므로 변환 비용은 최초 1회만 발생한다.</p>
<br>
<br>

<h2 id="1주요-파라미터">1.주요 파라미터</h2>
<h3 id="1-w--h--너비·높이-width--height">1) <code>w</code> / <code>h</code> — 너비·높이 (width / height)</h3>
<p>출력 이미지의 크기를 픽셀 단위로 지정한다. 가장 핵심적인 파라미터다.</p>
<pre><code>?w=300        // 너비 300px로 리사이즈 (높이는 비율 유지)
?w=300&amp;h=200  // 300×200으로 리사이즈</code></pre><p>원본이 4000×3000인 이미지를 화면에서는 300px로만 보여준다면, 원본을 그대로 받는 것은 거대한 낭비다. <code>w=300</code>으로 요청하면 서버가 300px짜리로 줄여서 내려주므로 전송량이 수십 배 줄어든다.</p>
<br>

<h3 id="2-q--품질-quality">2) <code>q</code> — 품질 (quality)</h3>
<p>JPEG·WebP 같은 손실 압축 포맷의 압축 강도를 지정한다. 보통 1~100 범위이며, 값이 낮을수록 용량은 작아지지만 화질이 떨어진다.</p>
<pre><code>?q=85   // 품질 85 (용량/화질 균형점으로 널리 쓰임)</code></pre><p>대체로 <strong>75~85 구간</strong>이 사람 눈에 화질 저하가 거의 느껴지지 않으면서 용량을 크게 줄이는 sweet spot이다. 100은 거의 무손실에 가깝지만 용량 대비 이득이 작아 실무에서는 잘 쓰지 않는다.</p>
<br>

<h3 id="3-f--fm--format--출력-포맷-format">3) <code>f</code> / <code>fm</code> / <code>format</code> — 출력 포맷 (format)</h3>
<p>이미지를 어떤 포맷으로 변환할지 지정한다.</p>
<pre><code>?f=webp   // WebP로 변환
?f=avif   // AVIF로 변환
?f=auto   // 클라이언트가 지원하는 최적 포맷 자동 선택</code></pre><p>포맷별 특징은 아래와 같다.</p>
<table>
<thead>
<tr>
<th>포맷</th>
<th>압축률</th>
<th>투명도</th>
<th>호환성</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>JPEG</td>
<td>보통</td>
<td>✗</td>
<td>최고</td>
<td>사진에 적합, 오래된 표준</td>
</tr>
<tr>
<td>PNG</td>
<td>낮음(무손실)</td>
<td>✓</td>
<td>최고</td>
<td>로고·아이콘·투명 이미지</td>
</tr>
<tr>
<td>WebP</td>
<td>높음</td>
<td>✓</td>
<td>높음</td>
<td>JPEG 대비 25~35% 용량 절감</td>
</tr>
<tr>
<td>AVIF</td>
<td>매우 높음</td>
<td>✓</td>
<td>중간</td>
<td>WebP보다 더 작지만 디코딩 비용 큼</td>
</tr>
</tbody></table>
<p>동일 화질 기준으로 <strong>WebP는 JPEG보다 보통 25~35% 작고</strong>, AVIF는 그보다 더 작다. 다만 AVIF는 디코딩에 CPU를 더 쓰므로 저사양 기기에서는 렌더링이 느려질 수 있어 트레이드오프를 고려해야 한다.</p>
<br>

<h3 id="4-dpr--디바이스-픽셀-비율-device-pixel-ratio">4) <code>dpr</code> — 디바이스 픽셀 비율 (device pixel ratio)</h3>
<p>고해상도 디스플레이를 위한 배율이다. 일부 CDN은 <code>w</code>와 별개로 <code>dpr</code>를 받아 내부적으로 곱해준다.</p>
<pre><code>?w=100&amp;dpr=3   // 실제로는 300px짜리를 생성</code></pre><p>이 파라미터를 지원하지 않는 서버라면, 클라이언트에서 직접 <code>w</code>에 픽셀 비율을 곱해 보내야 한다(아래 React Native 섹션 참고).</p>
<br>

<h3 id="5-fit--c--크롭·맞춤-모드-fit--crop">5) <code>fit</code> / <code>c</code> — 크롭·맞춤 모드 (fit / crop)</h3>
<p>지정한 박스에 이미지를 어떻게 맞출지 결정한다.</p>
<pre><code>?w=300&amp;h=300&amp;fit=cover     // 박스를 꽉 채우고 넘치는 부분은 잘라냄
?w=300&amp;h=300&amp;fit=contain   // 비율 유지하며 박스 안에 모두 담음(여백 생길 수 있음)</code></pre><p><code>cover</code>는 썸네일·프로필 이미지처럼 정사각형 박스를 빈틈없이 채워야 할 때, <code>contain</code>은 이미지 전체가 잘리지 않고 보여야 할 때 쓴다.</p>
<br>

<hr>
<br>


<h2 id="react-native에서의-활용">React Native에서의 활용</h2>
<p>React Native에서는 <code>PixelRatio</code>를 통해 기기의 픽셀 밀도를 얻어 <code>w</code> 파라미터에 반영하는 패턴이 핵심이다. 레이아웃 단위(dp)와 실제 물리 픽셀(px)이 다르기 때문이다.</p>
<pre><code class="language-typescript">import { PixelRatio } from &#39;react-native&#39;;

export const resizeImageUrl = (url: string, width: string): string =&gt; {
  if (!url) return &#39;&#39;;

  const newWidth = width * PixelRatio.get();
  const quality = 85;
  const separator = url.includes(&#39;?&#39;) ? &#39;&amp;&#39; : &#39;?&#39;;

  return url + `${separator}w=${Math.floor(newWidth)}&amp;q=${quality}&amp;f=webp`;
};</code></pre>
<p>핵심은 <code>width * PixelRatio.get()</code>이다. <code>PixelRatio.get()</code>은 기기의 DPR을 반환한다(@2x 기기면 2, @3x 기기면 3). 화면에 100dp로 그릴 이미지라도 @3x 기기에서는 300px짜리 이미지를 받아와야 선명하게 보인다. 이 보정을 하지 않으면 고해상도 화면에서 이미지가 뿌옇게 보이고, 반대로 항상 최대 배율로 받으면 저해상도 기기에서 불필요하게 큰 이미지를 받게 된다.</p>
<p><code>separator</code> 처리는 URL에 이미 쿼리스트링이 있는지에 따라 <code>?</code>와 <code>&amp;</code>를 구분해 붙여 URL이 깨지지 않게 하는 안전장치다.</p>
<h3 id="보완-포인트">보완 포인트</h3>
<p><code>PixelRatio.get()</code>을 그대로 곱하면 @3x 기기에서 넓은 이미지를 요청할 때 요청 너비가 과하게 커질 수 있다. 다음과 같은 방식으로 다듬을 수 있다.</p>
<pre><code class="language-typescript">// 방법 1: RN이 제공하는 헬퍼 사용 (반올림 처리까지 해줌)
const newWidth = PixelRatio.getPixelSizeForLayoutSize(width);

// 방법 2: DPR에 상한선을 둠 (예: 최대 2배까지만)
const dpr = Math.min(PixelRatio.get(), 2);
const newWidth = width * dpr;</code></pre>
<p>DPR에 상한을 두는 이유는, @3x와 @2x의 실질적 화질 차이가 사람 눈에 잘 구분되지 않는 경우가 많은 반면 용량 차이(3²:2² = 2.25배)는 크기 때문이다. 화질과 용량의 균형을 위해 실무에서 자주 쓰는 기법이다.</p>
<br>
<br>

<h2 id="효과">효과</h2>
<h3 id="1-네트워크-트래픽-절감">1) 네트워크 트래픽 절감</h3>
<p>가장 직접적인 효과다. 4000×3000(약 5MB) 원본을 300px WebP(수십 KB)로 변환하면 전송량이 <strong>수십 배에서 100배 이상</strong> 줄어든다. 모바일 데이터 환경에서 체감 로딩 속도가 크게 개선되고, CDN 전송 비용도 절감된다.</p>
<h3 id="2-메모리-사용량-감소">2) 메모리 사용량 감소</h3>
<p>이 효과는 특히 React Native에서 중요하다. 이미지는 디코딩되면 <strong>압축이 풀린 비트맵 상태로 메모리에 올라간다</strong>. 비트맵 메모리는 대략 <code>너비 × 높이 × 4바이트(RGBA)</code>로 계산된다.</p>
<ul>
<li>4000×3000 원본 : 약 <strong>48MB</strong> (파일 용량과 무관하게 디코딩 시 메모리)</li>
<li>300×225로 리사이즈 : 약 <strong>270KB</strong></li>
</ul>
<p>긴 리스트에서 원본 이미지를 그대로 쓰면 메모리가 폭증해 OOM(Out Of Memory) 크래시로 이어지기 쉽다. 표시 크기에 맞춰 리사이즈된 이미지를 받으면 이 문제를 근본적으로 줄일 수 있다.</p>
<h3 id="3-렌더링·스크롤-성능-향상">3) 렌더링·스크롤 성능 향상</h3>
<p>디코딩해야 할 픽셀 수가 줄어들면 디코딩 시간이 짧아지고, GPU에 올리는 텍스처 크기도 작아진다. 그 결과 이미지가 화면에 표시되기까지의 지연이 줄고, 리스트 스크롤 시 프레임 드랍이 완화된다.</p>
<h3 id="4-포맷-최적화에-따른-추가-절감">4) 포맷 최적화에 따른 추가 절감</h3>
<p><code>f=webp</code> 또는 <code>f=avif</code>로 변환하면 같은 화질에서 용량을 추가로 줄인다. WebP는 JPEG 대비 평균 25~35% 작으므로, 리사이즈 효과와 곱해져 전체 절감 폭이 더 커진다.</p>
<h3 id="5-cdn-캐싱과의-시너지">5) CDN 캐싱과의 시너지</h3>
<p>변환 결과는 파라미터 조합을 키로 캐싱된다. 즉 <code>?w=300&amp;q=85&amp;f=webp</code>로 들어온 요청은 최초 1회만 변환되고 이후에는 엣지 캐시에서 즉시 응답된다. 사용하는 파라미터 조합의 가짓수를 일정하게 유지(예: DPR 상한, 정해진 너비 단계 사용)하면 <strong>캐시 적중률이 높아져</strong> 변환 부하와 응답 지연이 함께 줄어든다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native 에서 이미지 최적화  (1. 캐싱이 작동할 좋은 타이밍) ]]></title>
            <link>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94-1.-%EC%BA%90%EC%8B%B1%EC%9D%B4-%EC%9E%91%EB%8F%99%ED%95%A0-%EC%A2%8B%EC%9D%80-%ED%83%80%EC%9D%B4%EB%B0%8D</link>
            <guid>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94-1.-%EC%BA%90%EC%8B%B1%EC%9D%B4-%EC%9E%91%EB%8F%99%ED%95%A0-%EC%A2%8B%EC%9D%80-%ED%83%80%EC%9D%B4%EB%B0%8D</guid>
            <pubDate>Sat, 22 Aug 2026 14:16:28 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p><code>&lt;FastImage /&gt;</code>만 잘 써도 대부분의 화면은 동작한다. 하지만 리스트 → 상세 화면 전환에서 이미지가 한 박자 늦게 뜨는 깜빡임, 무거운 갤러리 화면 진입 후 앱이 버벅이거나 백그라운드에서 강제 종료(OOM kill)되는 현상, 사용자 저장 공간을 잡아먹는 누적 디스크 캐시 등은 결국 <strong>언제 캐시에 올리고 언제 비울 것인가</strong>의 문제다.</p>
<p>RN CLI 환경에서 이미지 캐싱과 관련해 자주 부딪치는 세 가지 작업—<strong>프리로드, 메모리 캐시 정리, 디스크 캐시 정리</strong>—을 각각 어떤 타이밍에 호출하는 것이 적절한지 정리해보자.</p>
<blockquote>
<p>본 글은 <code>@d11/react-native-fast-image</code>를 기준으로 작성했다. 원본 <code>react-native-fast-image</code>(DylanVann)는 유지보수가 멈췄고, <code>@d11</code> 포크가 New Architecture(Fabric)를 지원하므로 New Arch 프로젝트에서는 이쪽을 쓴다. iOS는 SDWebImage, Android는 Glide를 내부 백엔드로 사용한다.</p>
<ul>
<li>프리로드: <code>FastImage.preload(sources)</code></li>
<li>메모리 캐시 정리: <code>FastImage.clearMemoryCache()</code></li>
<li>디스크 캐시 정리: <code>FastImage.clearDiskCache()</code></li>
</ul>
</blockquote>
<br>
<br>

<h2 id="1-메모리-캐시-vs-디스크-캐시">1. 메모리 캐시 vs 디스크 캐시</h2>
<p>타이밍 얘기 전에 먼저 두 캐시의 차이를 분명히 해두는 게 좋다. 헷갈리면 잘못된 시점에 잘못된 캐시를 비우게 된다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>메모리(RAM) 캐시</th>
<th>디스크(파일 시스템) 캐시</th>
</tr>
</thead>
<tbody><tr>
<td>저장 위치</td>
<td>프로세스 메모리</td>
<td>앱 샌드박스 내 파일</td>
</tr>
<tr>
<td>접근 속도</td>
<td>즉시 (디코딩된 비트맵)</td>
<td>디스크 I/O + 디코딩 필요</td>
</tr>
<tr>
<td>앱 종료 시</td>
<td><strong>사라짐</strong></td>
<td>유지됨</td>
</tr>
<tr>
<td>비우는 이유</td>
<td>OOM 방지, 메모리 압박 완화</td>
<td>저장 공간 확보, 무결성</td>
</tr>
<tr>
<td>비용</td>
<td>정리해도 디스크에서 다시 빠르게 복원 가능</td>
<td>정리하면 네트워크 재다운로드 필요</td>
</tr>
</tbody></table>
<p>핵심은 <strong>메모리 캐시 정리는 비교적 안전한 동작이고, 디스크 캐시 정리는 비싸고 되돌릴 수 없는 동작</strong>이다. 이 비대칭을 기억하면 호출 타이밍 판단이 훨씬 쉬워진다.</p>
<br>
<br>

<h2 id="2-프리로드-preload">2. 프리로드 (preload)</h2>
<p><strong>목적:</strong> 사용자가 화면을 보기 직전에 미리 이미지를 다운받아, 진입하자마자 깜빡임 없이 노출되도록 하는 것이 목표이다.</p>
<h3 id="1-언제-호출하는가">1) 언제 호출하는가</h3>
<p><strong>① 목록 → 상세 화면 진입 직전</strong></p>
<p>리스트에서 카드를 누르는 순간(<code>onPress</code> 핸들러 내부)에 다음 화면의 메인 이미지를 프리로드한다. 네비게이션 전환 애니메이션(보통 250~400ms) 동안 이미지가 백그라운드에서 로드되어, 상세 화면이 뜨는 시점에는 이미 메모리에 올라와 있다.</p>
<pre><code class="language-tsx">const onPressItem = (item: Item) =&gt; {
  FastImage.preload([{ uri: item.heroImageUrl }]);
  navigation.navigate(&#39;Detail&#39;, { itemId: item.id });
};</code></pre>
<p><code>onPress</code>가 아니라 <code>onPressIn</code>에 걸면 더 일찍 시작할 수도 있다. 다만 사용자가 실수로 닿기만 해도 호출되므로 트래픽 낭비가 생긴다. <strong><code>onPress</code>가 균형점</strong>이다.</p>
<br>

<p><strong>② 무한 스크롤 / 페이지네이션 데이터 도착 직후</strong></p>
<p>다음 페이지의 API 응답을 받은 직후, 렌더링 전에 상위 N개 아이템의 이미지를 프리로드한다.</p>
<pre><code class="language-tsx">const { data } = await fetchNextPage();
const urlsToPreload = data
  .slice(0, 12)               // 화면에 먼저 보일 만큼만
  .map(item =&gt; item.thumbnail);
FastImage.preload(urlsToPreload.map(uri =&gt; ({ uri })));</code></pre>
<br>

<p><strong>③ 앱 진입 직후 (필수 데이터에 한정)</strong></p>
<p>스플래시 화면이나 초기 부트스트랩 단계에서, 홈 최상단에 반드시 노출되는 배너/히어로 이미지 1~2개. 그 이상은 권장하지 않는다.</p>
<br>

<p><strong>④ 다음 캐러셀 슬라이드</strong></p>
<p>스와이프 캐러셀에서 현재 슬라이드의 다음 1~2장. 사용자가 스와이프하는 순간 이미지가 이미 메모리에 있다.</p>
<br>

<h3 id="2-실무-팁">2) 실무 팁</h3>
<p><strong>선택과 집중 — 전부 프리로드하지 마라</strong></p>
<p>데이터 배열의 모든 이미지를 프리로드하면 다음이 발생한다.</p>
<ul>
<li><p><strong>네트워크 병목</strong>
수십~수백 개 요청이 동시에 발사되어 정작 화면에 보이는 이미지 다운로드가 느려진다.</p>
</li>
<li><p><strong>유저 데이터 요금 낭비</strong>
셀룰러 사용자에겐 보이지도 않을 이미지에 대한 트래픽 청구.</p>
</li>
<li><p><strong>메모리 캐시 오염</strong>
보이지 않을 이미지가 메모리를 차지해, 정작 필요한 이미지가 밀리게 된다.</p>
</li>
</ul>
<p>배치 상한선을 두는 것이 표준이다. 예를 들어 한 번에 최대 40~50개로 제한하는 식이다.</p>
<pre><code class="language-tsx">const PRELOAD_BATCH_LIMIT = 48;

function safePreload(urls: string[]) {
  const sources = urls
    .slice(0, PRELOAD_BATCH_LIMIT)
    .map(uri =&gt; ({ uri }));
  FastImage.preload(sources);
}</code></pre>
<br>

<p><strong>우선순위 — &#39;먼저 보일 것&#39;을 먼저</strong></p>
<p>스크롤 아래 안 보이는 이미지를 프리로드하느니, 상단 viewport에 곧 들어올 이미지에 집중한다. <code>FastImage</code>는 <code>priority: &#39;high&#39; | &#39;normal&#39; | &#39;low&#39;</code>를 지원하므로 활용한다.</p>
<pre><code class="language-tsx">FastImage.preload([
  { uri: heroUrl, priority: FastImage.priority.high },
  ...thumbnailUrls.map(uri =&gt; ({ uri, priority: FastImage.priority.normal })),
]);</code></pre>
<br>

<p><strong>캐시 정책 — <code>source.cache</code>로 갱신 동작을 제어</strong></p>
<p><code>FastImage</code>는 소스 단위로 캐시 제어 옵션을 제공한다. 프리로드/렌더 시 동일하게 지정한다.</p>
<ul>
<li><code>FastImage.cacheControl.immutable</code>(기본값): URL이 같으면 갱신하지 않는다. 콘텐츠가 바뀌지 않는 정적 리소스에 적합하다.</li>
<li><code>FastImage.cacheControl.web</code>: HTTP 캐시 헤더(만료/ETag)를 존중해 필요 시 재검증한다.</li>
<li><code>FastImage.cacheControl.cacheOnly</code>: 캐시에서만 로드하고 네트워크 요청은 하지 않는다.</li>
</ul>
<pre><code class="language-tsx">FastImage.preload([
  { uri: heroUrl, cache: FastImage.cacheControl.web }, // 자주 바뀌는 이미지
]);</code></pre>
<br>

<p><strong>네트워크 상태 고려</strong></p>
<p>셀룰러나 저속 네트워크에서는 프리로드를 줄이거나 끄는 것이 좋다. <code>@react-native-community/netinfo</code>로 연결 종류를 확인할 수 있다.</p>
<pre><code class="language-tsx">const { type } = await NetInfo.fetch();
if (type === &#39;wifi&#39;) {
  FastImage.preload(sources);
}</code></pre>
<br>

<p><strong>프리로드는 &quot;결과를 기다리지 않는&quot; 패턴</strong></p>
<p>대부분의 경우 프리로드는 fire-and-forget이다. 완료를 기다리지 않고 즉시 다음 단계로 진행하며, 운 좋게 미리 받아져 있으면 좋고 아니면 평소처럼 로드된다. <code>await FastImage.preload(...)</code>로 묶는 건 의미가 없을 뿐 아니라(promise도 아님) 화면 진입을 지연시키는 안티패턴이다.</p>
<br>
<br>

<h2 id="3-메모리-캐시-정리-clearmemorycache">3. 메모리 캐시 정리 (clearMemoryCache)</h2>
<p><strong>목적:</strong> RAM 점유를 낮춰 OOM 종료를 방지하고, 무거운 화면 진입 후의 메모리 압박을 완화한다.</p>
<h3 id="1-언제-호출하는가-1">1) 언제 호출하는가</h3>
<p><strong>① 이미지 많은 화면을 떠날 때 (unmount)</strong></p>
<p>수십 장의 이미지가 노출되는 갤러리, 탐색, 피드 상세 화면 등이 unmount 될 때 <code>useEffect</code>의 cleanup에서 호출한다.</p>
<pre><code class="language-tsx">useEffect(() =&gt; {
  return () =&gt; {
    FastImage.clearMemoryCache();
  };
}, []);</code></pre>
<p>여기서 디스크 캐시는 그대로 남아 있으므로, 사용자가 다시 그 화면으로 돌아왔을 때 네트워크 재다운로드는 발생하지 않는다(디스크에서 복원). 그래서 비교적 안전한 동작이다.</p>
<br>

<p><strong>② 앱이 백그라운드로 전환될 때</strong></p>
<p><code>AppState</code>를 감지해 <code>&#39;background&#39;</code> 진입 시점에 메모리 캐시를 비우면, OS가 메모리 부족 상태에서 우리 앱을 강제 종료할 확률이 줄어든다. 특히 이미지가 많은 앱에서는 효과가 크다.</p>
<pre><code class="language-tsx">useEffect(() =&gt; {
  const sub = AppState.addEventListener(&#39;change&#39;, state =&gt; {
    if (state === &#39;background&#39;) {
      FastImage.clearMemoryCache();
    }
  });
  return () =&gt; sub.remove();
}, []);</code></pre>
<br>

<p><strong>③ 메모리 경고 시 (iOS) / Trim Memory 콜백 (Android)</strong></p>
<p>iOS는 <code>UIApplicationDidReceiveMemoryWarningNotification</code>을, Android는 <code>onTrimMemory</code> 콜백을 발생시킨다. 이 신호를 받는 시점이야말로 메모리 캐시를 비우기 가장 좋은 타이밍이다. 다만 RN JS 측에서 직접 듣기는 까다로워서, 보통은 라이브러리가 내부적으로 처리하거나 별도 네이티브 모듈/브릿지(New Architecture라면 TurboModule)가 필요하다. <code>FastImage</code>는 내부적으로 SDWebImage(iOS) / Glide(Android)를 사용하므로 메모리 경고에 대한 기본 대응이 어느 정도 들어 있다.</p>
<br>

<h3 id="2-주의사항">2) 주의사항</h3>
<p><strong>1. 너무 자주 비우면 깜빡임이 생긴다</strong></p>
<p>화면 전환마다 메모리 캐시를 비우면, 디스크에서 다시 RAM으로 끌어와 디코딩하는 비용이 매번 발생해 미세한 깜빡임이나 지연이 보일 수 있다. <strong>확실한 분기점</strong>(무거운 화면 unmount, 백그라운드 전환)에서만 호출한다.</p>
<br>

<p><strong>2. 리스트 컴포넌트 unmount에서 무분별하게 호출 금지</strong></p>
<p>탭 전환마다 unmount되는 화면에서 매번 메모리 캐시를 비우면, 탭을 오갈 때마다 모든 이미지가 다시 디코딩된다. 충분히 무거운 화면(50장 이상의 이미지, 큰 해상도)에 한정해서 적용한다.</p>
<br>

<p><strong>3. <code>clearMemoryCache()</code>는 Promise를 반환한다</strong></p>
<p><code>FastImage.clearMemoryCache()</code>는 Promise를 반환한다. 완료를 기다려야 하는 시나리오는 거의 없지만, 로그아웃 등 후속 동작이 있는 경우에는 <code>await</code>로 묶는다.</p>
<br>
<br>

<h2 id="4-디스크-캐시-정리-cleardiskcache">4. 디스크 캐시 정리 (clearDiskCache)</h2>
<p>사용자의 물리적 저장 공간을 확보하고, 캐시 무결성을 보장하는 것을 목적으로 한다. (예: 동일 URL이지만 내용이 바뀐 이미지가 있는 경우)</p>
<h3 id="1-언제-호출하는가-2">1) 언제 호출하는가</h3>
<p><strong>① 사용자의 명시적 요청</strong></p>
<p>설정 화면의 &quot;캐시 데이터 지우기&quot; 버튼처럼, 사용자가 의도적으로 누른 경우.</p>
<pre><code class="language-tsx">const onPressClearCache = async () =&gt; {
  await FastImage.clearDiskCache();
  await FastImage.clearMemoryCache(); // 함께 정리하는 게 일반적
  showToast(&#39;캐시를 정리했습니다&#39;);
};</code></pre>
<br>

<p><strong>② 로그아웃 / 회원 탈퇴 시</strong></p>
<p>이전 사용자의 프로필 사진, 비공개 이미지 등이 디스크에 남아 있을 수 있다. 다음 사용자에게 노출되지 않도록 정리한다. auth 도메인의 로그아웃 핸들러 내부에서 호출한다.</p>
<pre><code class="language-tsx">async function logout() {
  await apiLogout();
  await FastImage.clearDiskCache();
  await FastImage.clearMemoryCache();
  resetAuthState();
}</code></pre>
<br>

<p><strong>③ 앱 버전 업데이트 후 (선택적)</strong></p>
<p>이미지 URL 정책이 바뀌었거나, 캐시 키 구조가 바뀌었거나, CDN을 교체한 경우 한 번에 한해 정리. 사용자 모르게 자동으로 실행되도록 한다.</p>
<br>

<p><strong>④ 캐시 무결성 이슈 발생 시</strong></p>
<p>서버에서 동일 URL의 이미지 내용을 바꿔버린 경우(잘못된 운영 관행이지만 현실에선 자주 발생), 기존 디스크 캐시는 잘못된 이미지를 계속 반환한다. 이때 임시 대응으로 사용. 근본 해결은 URL에 해시/버전을 붙이는 것이다.</p>
<br>

<h3 id="2-실무-팁-1">2) 실무 팁</h3>
<ul>
<li><strong>렌더링 사이클이나 스크롤 중에는 절대 호출하지 않는다</strong></li>
</ul>
<p>디스크 I/O는 비용이 큰 작업이다. 스크롤 도중 호출하면 프레임 드랍이 발생한다. UI가 정적인 시점(설정 화면, 로그아웃 직후 등)에서만 호출한다.</p>
<ul>
<li><strong>비가역적이라는 점을 사용자에게 인지시킨다</strong></li>
</ul>
<p>디스크 캐시를 비우면 사용자가 자주 보던 이미지도 다시 네트워크에서 받아와야 한다. 셀룰러 환경에선 추가 데이터 요금이 나간다. 명시적 정리 버튼에는 확인 다이얼로그를 띄우는 것이 일반적이다.</p>
<ul>
<li><strong>자동 정리는 신중하게</strong></li>
</ul>
<p>&quot;일정 주기마다 자동으로 디스크 캐시 비우기&quot; 같은 정책은 거의 항상 잘못된 결정이다. <code>FastImage</code>는 내부 백엔드(SDWebImage / Glide)의 LRU 기반 캐시 evict 정책을 그대로 사용하므로, 수동 개입 없이도 무한정 늘어나지 않는다. 굳이 자동 정리를 한다면 &quot;디스크 사용량이 X MB를 넘으면&quot; 같은 임계치 기반이 더 적절하다.</p>
<br>
<br>

<h2 id="5-자주-만나는-함정">5. 자주 만나는 함정</h2>
<h3 id="1-프리로드를-useeffect로-매-렌더마다-호출">1) 프리로드를 <code>useEffect</code>로 매 렌더마다 호출</h3>
<pre><code class="language-tsx">// ❌ 의존성 배열 잘못 잡으면 매 렌더 호출
useEffect(() =&gt; {
  FastImage.preload(items.map(i =&gt; ({ uri: i.url })));
});</code></pre>
<p><code>items</code>가 새 배열로 만들어질 때마다 동일 URL을 재요청한다. 라이브러리가 중복 제거를 해주긴 하지만, 검사 비용이 발생한다. 의존성을 정확히 지정하거나, 데이터가 처음 도착했을 때 한 번만 호출하는 패턴을 쓴다.</p>
<br>

<h3 id="2-로그아웃-시-메모리-캐시만-비우고-디스크는-안-비움">2) 로그아웃 시 메모리 캐시만 비우고 디스크는 안 비움</h3>
<p>토큰이 만료된 상태로 디스크 캐시에 남은 인증 이미지 URL이 있다면, 다음 로그인 시점에 잘못된 권한으로 이미지가 표시되거나(또는 안 표시) 다른 사용자의 캐시가 노출될 수 있다. 로그아웃 시에는 <strong>메모리 + 디스크 모두</strong> 비우는 것이 안전하다.</p>
<br>

<h3 id="3-모든-화면에서-clearmemorycache">3) 모든 화면에서 <code>clearMemoryCache</code></h3>
<p>&quot;메모리 절약된다&quot;는 이유로 모든 화면 unmount마다 호출하면, 디코딩 비용이 매번 발생하고 오히려 체감 성능이 나빠진다. <strong>이미지가 많고 무거운 화면에 한정</strong>한다.</p>
<br>

<h3 id="4-imageprefetchreact-native-내장와-fastimagepreload-혼동">4) <code>Image.prefetch</code>(React Native 내장)와 <code>FastImage.preload</code> 혼동</h3>
<p>React Native 내장 <code>Image.prefetch()</code>는 OS 레벨 HTTP 캐시에만 영향을 주고, <code>FastImage</code>의 내부 캐시(iOS의 SDWebImage, Android의 Glide)와는 별개다. <code>FastImage</code>를 쓴다면 반드시 <code>FastImage.preload()</code>를 써야 캐시가 실제로 채워진다. 둘을 섞어 쓰면 프리로드했다고 생각한 이미지가 정작 <code>FastImage</code> 렌더 시 캐시 미스로 다시 받아진다.</p>
<br>

<hr>
<br>

<h2 id="6-정리">6. 정리</h2>
<table>
<thead>
<tr>
<th>작업</th>
<th>타이밍</th>
<th>비용</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>프리로드</td>
<td>리스트 아이템 <code>onPress</code> 시점</td>
<td>낮음</td>
<td>다음 화면 메인 이미지만</td>
</tr>
<tr>
<td>프리로드</td>
<td>다음 페이지 API 응답 직후</td>
<td>낮음</td>
<td>상한선 두기 (40~50개)</td>
</tr>
<tr>
<td>프리로드</td>
<td>앱 부트스트랩</td>
<td>매우 낮음</td>
<td>필수 1~2장만</td>
</tr>
<tr>
<td>메모리 캐시 정리</td>
<td>무거운 화면 unmount</td>
<td>낮음</td>
<td>가끔 깜빡임</td>
</tr>
<tr>
<td>메모리 캐시 정리</td>
<td>백그라운드 전환</td>
<td>낮음</td>
<td>OOM kill 방지 효과</td>
</tr>
<tr>
<td>디스크 캐시 정리</td>
<td>사용자 명시적 요청</td>
<td><strong>높음 (I/O)</strong></td>
<td>확인 다이얼로그 권장</td>
</tr>
<tr>
<td>디스크 캐시 정리</td>
<td>로그아웃 / 탈퇴</td>
<td><strong>높음 (I/O)</strong></td>
<td>메모리도 함께</td>
</tr>
<tr>
<td>디스크 캐시 정리</td>
<td>앱 버전 마이그레이션</td>
<td><strong>높음 (I/O)</strong></td>
<td>한 번만</td>
</tr>
</tbody></table>
<p>원칙은 단순하다.</p>
<ul>
<li><strong>프리로드는 &quot;곧 보일 것&quot;에만, 상한선을 두고.</strong></li>
<li><strong>메모리 캐시 정리는 &quot;확실한 분기점&quot;에서만.</strong></li>
<li><strong>디스크 캐시 정리는 &quot;사용자가 동의한 명시적 시점&quot;에서만.</strong></li>
</ul>
<p>이 세 가지만 지켜도 이미지 깜빡임, OOM kill, 저장 공간 잠식이라는 흔한 세 가지 문제를 대부분 막을 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native에서의 Lottie란? (JSON 형태의 애니메이션)]]></title>
            <link>https://velog.io/@diso592/React-Native%EC%97%90%EC%84%9C%EC%9D%98-Lottie%EB%9E%80-JSON-%ED%98%95%ED%83%9C%EC%9D%98-%EC%95%A0%EB%8B%88%EB%A9%94%EC%9D%B4%EC%85%98</link>
            <guid>https://velog.io/@diso592/React-Native%EC%97%90%EC%84%9C%EC%9D%98-Lottie%EB%9E%80-JSON-%ED%98%95%ED%83%9C%EC%9D%98-%EC%95%A0%EB%8B%88%EB%A9%94%EC%9D%B4%EC%85%98</guid>
            <pubDate>Wed, 19 Aug 2026 07:30:05 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>디자이너가 만든 애니메이션을 앱에 그대로 옮기는 일은 생각보다 까다롭다. 로딩 스피너 하나만 해도 회전 속도, 이징, 페이드 타이밍을 코드로 다시 맞춰야 하고, 결과물은 대체로 원본과 미묘하게 다르다.</p>
<p>Lottie는 이 과정을 없애는 방식으로 접근한다. After Effects에서 만든 애니메이션을 Bodymovin 플러그인으로 JSON으로 내보내면, 네이티브 런타임이 그 JSON을 읽어 벡터로 직접 그린다. 개발자가 다시 만들 필요가 없고, 디자이너가 수정하면 JSON 파일만 교체하면 된다.</p>
<p>React Native에서는 <code>lottie-react-native</code>가 iOS의 lottie-ios와 Android의 lottie-android를 감싸서 제공한다. 현재 최신 버전은 7.x대이며 New Architecture를 지원한다.</p>
<p><br><br></p>
<h2 id="lottie를-선택하는-기준">Lottie를 선택하는 기준</h2>
<p>애니메이션을 넣는 방법은 크게 세 가지다. Reanimated로 직접 구현하거나, GIF나 동영상을 재생하거나, Lottie를 쓰는 것이다.</p>
<p><strong>Reanimated</strong>는 사용자 입력에 반응해야 할 때 유리하다. 드래그 위치에 따라 카드가 따라오거나, 스크롤 값에 헤더가 연동되는 동작은 Lottie로 표현하기 어렵다. 반대로 복잡한 일러스트가 여러 레이어로 움직이는 애니메이션을 Reanimated로 만드는 건 비효율적이다.</p>
<p><strong>GIF나 동영상</strong>은 래스터 이미지라 고해상도 기기에서 뭉개지고, 배경 투명 처리가 번거롭고, 파일이 무겁다. 같은 애니메이션이 GIF로 2MB일 때 Lottie JSON은 수십 KB에 그치는 경우가 흔하다.</p>
<p><strong>Lottie</strong>는 정해진 타임라인을 그대로 재생하는 데 최적화되어 있다. 스플래시, 온보딩 일러스트, 로딩 인디케이터, 결제 성공 화면, 빈 목록 화면처럼 &quot;예쁘게 보여주고 끝나는&quot; 구간이 주 무대다. 여기에 재생 구간을 나눠 상태에 따라 다른 부분을 재생하는 정도의 인터랙션까지는 충분히 소화한다.</p>
<p><br><br></p>
<h2 id="설치와-new-architecture">설치와 New Architecture</h2>
<p>RN CLI 프로젝트 기준으로 설치는 단순하다.</p>
<pre><code class="language-bash">npm install lottie-react-native
cd ios &amp;&amp; pod install</code></pre>
<p>New Architecture가 켜져 있어도 별도 설정은 필요 없다. 7.x는 Fabric 컴포넌트로 구현되어 있어 <code>newArchEnabled=true</code> 상태에서 그대로 동작한다. 다만 6.x 이하 버전은 Fabric 지원이 불완전해 iOS에서 첫 렌더가 비는 문제가 보고된 적이 있으므로, New Architecture를 쓴다면 7.x 이상으로 올리는 편이 안전하다.</p>
<p>애니메이션 파일은 <code>src/assets/lottie/</code> 같은 디렉터리에 두고 <code>require</code>로 참조한다. Metro가 기본적으로 <code>.json</code>을 처리하므로 추가 설정은 없다.</p>
<p><br><br></p>
<h2 id="기본-사용법">기본 사용법</h2>
<pre><code class="language-tsx">import LottieView from &#39;lottie-react-native&#39;;

export function LoadingIndicator() {
  return (
    &lt;LottieView
      source={require(&#39;@/assets/lottie/loading.json&#39;)}
      style={{ width: 120, height: 120 }}
      autoPlay
      loop
    /&gt;
  );
}</code></pre>
<p><code>style</code>에 크기를 반드시 지정해야 한다. LottieView는 기본 크기를 갖지 않아서 크기가 없으면 아무것도 보이지 않는다. Lottie가 안 나온다는 문의의 절반 이상이 여기서 나온다.</p>
<p><code>resizeMode</code>는 <code>contain</code>, <code>cover</code>, <code>center</code>를 지원하며 기본값은 <code>contain</code>이다. 디자인 시안의 비율과 컨테이너 비율이 다르면 여백이 생기므로, 배경에 꽉 채워야 하는 경우에만 <code>cover</code>를 쓴다.</p>
<p><br><br></p>
<h2 id="재생-제어">재생 제어</h2>
<p><code>autoPlay</code>만으로 부족한 경우 ref로 명령형 제어를 한다.</p>
<pre><code class="language-tsx">const lottieRef = useRef&lt;LottieView&gt;(null);

lottieRef.current?.play();        // 처음부터 재생
lottieRef.current?.play(30, 90);  // 30~90 프레임 구간만 재생
lottieRef.current?.pause();
lottieRef.current?.resume();
lottieRef.current?.reset();</code></pre>
<p>구간 재생이 실무에서 가장 쓸모 있다. 하나의 JSON에 여러 상태의 애니메이션을 담아두고 프레임 범위로 나눠 쓰는 방식인데, 파일을 여러 개로 쪼개는 것보다 관리가 편하고 상태 전환도 매끄럽다.</p>
<p>좋아요 버튼이 전형적인 예다.</p>
<pre><code class="language-tsx">export function LikeButton({ liked, onToggle }: Props) {
  const lottieRef = useRef&lt;LottieView&gt;(null);

  const handlePress = () =&gt; {
    if (!liked) {
      lottieRef.current?.play(0, 60); // 채워지는 구간
    } else {
      lottieRef.current?.reset();
    }
    onToggle();
  };

  return (
    &lt;Pressable onPress={handlePress} hitSlop={8}&gt;
      &lt;LottieView
        ref={lottieRef}
        source={require(&#39;@/assets/lottie/heart.json&#39;)}
        style={{ width: 40, height: 40 }}
        loop={false}
        progress={liked ? 1 : 0}
      /&gt;
    &lt;/Pressable&gt;
  );
}</code></pre>
<p><code>progress</code>를 함께 넘긴 이유는 서버 상태가 늦게 도착하거나 화면을 다시 진입했을 때의 초기 표시 때문이다. 애니메이션 없이 이미 좋아요가 눌린 상태를 그려야 하는 순간이 반드시 생긴다.</p>
<p>재생이 끝나는 시점을 잡아야 한다면 <code>onAnimationFinish</code>를 쓴다.</p>
<pre><code class="language-tsx">&lt;LottieView
  source={require(&#39;@/assets/lottie/payment-success.json&#39;)}
  style={styles.animation}
  autoPlay
  loop={false}
  onAnimationFinish={() =&gt; navigation.replace(&#39;OrderDetail&#39;, { orderId })}
/&gt;</code></pre>
<p><code>loop</code>가 켜져 있으면 이 콜백은 호출되지 않는다. 그리고 일부 7.x 초기 버전에서 마운트 직후 콜백이 한 번 잘못 발화되는 이슈가 있었으므로, 화면 전환처럼 되돌릴 수 없는 동작을 붙일 때는 실제 기기에서 확인하고 필요하면 플래그로 한 번만 실행되게 막는다.</p>
<p><br><br></p>
<h2 id="색상-커스터마이징">색상 커스터마이징</h2>
<p>다크 모드나 테마 대응 때문에 애니메이션 색상만 바꿔야 할 때가 있다. 매번 디자이너에게 색상별 파일을 요청하는 대신 <code>colorFilters</code>로 특정 레이어의 색을 덮어쓸 수 있다.</p>
<pre><code class="language-tsx">&lt;LottieView
  source={require(&#39;@/assets/lottie/spinner.json&#39;)}
  style={{ width: 48, height: 48 }}
  colorFilters={[{ keypath: &#39;Shape Layer 1&#39;, color: theme.colors.primary }]}
  autoPlay
  loop
/&gt;</code></pre>
<p><code>keypath</code>는 After Effects의 레이어 이름과 정확히 일치해야 한다. 디자이너에게 레이어 이름을 의미 있게 지어달라고 미리 요청해두면 나중에 훨씬 편하다. 다만 이 기능은 iOS와 Android의 렌더러 구현 차이 때문에 결과가 완전히 같지 않은 경우가 있어, 양쪽 모두에서 확인이 필요하다.</p>
<p><br><br></p>
<h2 id="주의사항">주의사항</h2>
<p><strong>After Effects 기능이 전부 지원되지는 않는다.</strong> 표현식, 일부 마스크, 블렌드 모드, 특정 이펙트는 무시되거나 다르게 그려진다. 시안이 확정되기 전에 디자이너와 지원 범위를 맞춰두는 게 낫다. 작업이 끝난 뒤 &quot;이 부분이 안 나온다&quot;를 발견하면 재작업 비용이 크다.</p>
<p><strong>JSON 파일 크기를 확인한다.</strong> 벡터로만 구성된 애니메이션은 대체로 가볍지만, 비트맵 이미지가 포함되면 base64로 인코딩되어 파일이 수 MB까지 부풀 수 있다. 번들 크기와 파싱 시간에 직접 영향을 주므로, 이미지가 포함된 파일을 받았다면 벡터로 다시 작업할 수 있는지 물어보는 편이 좋다.</p>
<p><strong>리스트 안에서 여러 개를 동시에 재생하지 않는다.</strong> FlatList의 각 아이템마다 Lottie가 루프를 돌면 프레임 드랍이 눈에 띄게 발생한다. 화면에 보이는 항목만 재생하도록 <code>viewabilityConfig</code>와 연동하거나, 리스트에서는 정적 이미지를 쓰고 상세 화면에서만 애니메이션을 재생하는 방식을 고려한다.</p>
<p><strong>백그라운드 전환 후 복귀를 확인한다.</strong> 앱이 백그라운드로 갔다 돌아오면 애니메이션이 멈춘 채로 남는 경우가 있다. 로딩 오버레이처럼 계속 돌아야 하는 요소라면 <code>AppState</code>를 구독해 다시 <code>resume()</code>을 호출한다.</p>
<p><strong>모션 감소 설정을 존중한다.</strong> OS에서 모션 줄이기를 켠 사용자에게는 애니메이션이 불편할 수 있다. <code>AccessibilityInfo.isReduceMotionEnabled()</code>로 확인해 정적인 대체 화면을 보여주는 처리를 넣어두면 좋다.</p>
<p><br><br></p>
<h2 id="트러블슈팅">트러블슈팅</h2>
<p><strong>아무것도 보이지 않을 때</strong>는 <code>style</code>의 크기부터 확인한다. 크기가 있는데도 안 보인다면 JSON 파일 자체가 올바른지, 애니메이션의 색상이 배경과 같지는 않은지 본다.</p>
<p><strong><code>.lottie</code> 파일이 로드되지 않을 때</strong>는 <code>.json</code>으로 바꿔본다. dotLottie 형식은 내부적으로 zip이라 별도 처리가 필요한데, Android의 New Architecture 환경에서 zip을 JSON으로 파싱하려다 실패하는 버그가 보고된 적이 있다. 압축률이 절실하지 않다면 <code>.json</code>이 무난하다.</p>
<p><strong>네이티브 로그를 먼저 본다.</strong> JS 콘솔에는 아무 메시지가 없어도 Android의 Logcat이나 Xcode 콘솔에는 파싱 실패 이유가 남는 경우가 많다. 소수점이 들어가면 안 되는 값에 소수가 들어간 것처럼 파일 자체가 규격에 어긋난 상황도 여기서 확인된다.</p>
<p><strong>원격 URL로 불러올 때</strong>는 캐싱을 직접 처리해야 한다. <code>source</code>에 URL 문자열을 넘기면 동작하지만 매번 다시 받아오므로, 자주 쓰는 애니메이션은 번들에 포함하고 자주 바뀌는 것만 원격으로 두는 식으로 나눈다.</p>
<p><br><br></p>
<h2 id="정리">정리</h2>
<p>Lottie는 디자이너의 결과물을 그대로 앱에 옮기기 위한 도구다. 정해진 타임라인을 재생하는 영역에서는 직접 구현보다 압도적으로 효율적이고, 사용자 입력에 실시간으로 반응해야 하는 영역에서는 Reanimated가 여전히 맞다. 이 경계를 구분하는 것이 도입의 출발점이다.</p>
<p>실무에서 자주 마주치는 지점은 세 가지로 압축된다. <code>style</code>에 크기를 지정하는 것, 하나의 파일을 프레임 구간으로 나눠 상태별로 재생하는 것, 그리고 파일 크기와 동시 재생 개수를 성능 관점에서 관리하는 것이다.</p>
<p>New Architecture 환경이라면 7.x 이상을 쓰고, dotLottie 대신 JSON을 기본으로 두면 대부분의 문제를 미리 피할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native에서 모달 등장 시 미세한 jank 원인을 추적한 기록]]></title>
            <link>https://velog.io/@diso592/React-Native%EC%97%90%EC%84%9C-%EB%AA%A8%EB%8B%AC-%EB%93%B1%EC%9E%A5-%EC%8B%9C-%EB%AF%B8%EC%84%B8%ED%95%9C-jank-%EC%9B%90%EC%9D%B8%EC%9D%84-%EC%B6%94%EC%A0%81%ED%95%9C-%EA%B8%B0%EB%A1%9D</link>
            <guid>https://velog.io/@diso592/React-Native%EC%97%90%EC%84%9C-%EB%AA%A8%EB%8B%AC-%EB%93%B1%EC%9E%A5-%EC%8B%9C-%EB%AF%B8%EC%84%B8%ED%95%9C-jank-%EC%9B%90%EC%9D%B8%EC%9D%84-%EC%B6%94%EC%A0%81%ED%95%9C-%EA%B8%B0%EB%A1%9D</guid>
            <pubDate>Sun, 09 Aug 2026 02:51:48 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>React Native + Tamagui Dialog로 만든 알림 모달이 등장할 때마다 미세한 jank가 발생했다. 처음엔 모달 컴포넌트 자체가 무겁다고 의심했지만, 진짜 범인은 모달이 뜨는 그 순간 같이 발사되는 5개의 네트워크 요청이었다. 이 글은 그 원인을 좁혀가며 시도했던 우회들이 왜 실패했는지, 그리고 실무에서 제일 실용적인 해결법은 무엇이었는지 정리한 기록이다.</p>
<br>
<br>

<h2 id="1-증상">1. 증상</h2>
<p>폼에서 &quot;등록&quot; 버튼을 누르면 BottomSheet가 닫히고 곧바로 &quot;게시글이 등록되었습니다&quot; 같은 알림 모달이 뜨는 흐름이 있었다.</p>
<ul>
<li>모달이 등장할 때 약 1~2프레임 정도 끊김이 있었다.</li>
<li>iOS·Android 모두 발생.</li>
<li><strong>항상 끊기는 것은 아니다.</strong> 같은 동작을 반복하면 부드러울 때도, 끊길 때도 있었다.</li>
<li>모달이 사라질 때도 가끔 끊김. 다만 등장보다는 빈도가 훨씬 낮았다.</li>
</ul>
<p>이 &quot;있을 때도 있고 없을 때도 있다&quot;가 결국 원인 추적의 결정적인 단서가 됐다.</p>
<br>
<br>

<h2 id="2-첫-번째-가설--tamagui-dialog가-무거운가">2. 첫 번째 가설 — Tamagui Dialog가 무거운가?</h2>
<p>알림 모달은 Tamagui의 <code>Dialog</code> + <code>Dialog.Portal</code> + <code>Dialog.Overlay</code> + <code>Dialog.Content</code> 조합으로 구현돼 있었다. Tamagui의 Dialog.Portal은 내부적으로 React Native의 <code>Modal</code> 컴포넌트를 mount한다. 즉 등장·소멸 시점마다 <strong>native modal window</strong>의 생성·해제 비용이 발생한다.</p>
<p>같은 코드베이스에 이미 다른 패턴으로 만든 특정 동작의 모달이 있었다. 그 모달은 Tamagui의 <code>Portal</code>(RN Modal이 아닌, 자체 PortalProvider 위치에 mount되는 쪽)과 Reanimated의 <code>FadeIn</code>/<code>FadeOut</code>을 직접 조합해서 native modal을 우회하고 있었다.</p>
<pre><code class="language-tsx">// 패턴 A — 자체 Portal + Reanimated 애니메이션
&lt;Portal&gt;
  &lt;AnimatedView entering={BACKDROP_ENTERING} exiting={BACKDROP_EXITING} ...&gt;
    &lt;AnimatedView entering={CONTENT_ENTERING} exiting={CONTENT_EXITING} ...&gt;
      {/* content */}
    &lt;/AnimatedView&gt;
  &lt;/AnimatedView&gt;
&lt;/Portal&gt;</code></pre>
<p>가설을 하나 세워봤다. &quot;알림 모달도 패턴 A로 바꾸면 native modal 비용이 사라지니까 jank가 줄어들지 않을까?&quot;</p>
<p>하지만 이 단계에서 멈춰서 <strong>진짜 원인이 무엇인지</strong> 한 번 더 확인하기로 했다. 우회로를 먼저 깔면 원인은 모른 채로 코드만 복잡해진다.</p>
<br>
<br>

<h2 id="3두-번째-가설--모달-등장-시점에-동시에-일어나는-일">3.두 번째 가설 — 모달 등장 시점에 동시에 일어나는 일</h2>
<p>모달이 뜨는 트리거를 따라가봤다. 게시글 생성 mutation의 <code>onSuccess</code> 콜백은 대략 이런 흐름이었다.</p>
<pre><code class="language-ts">const { mutate: createReview } = useMutation({
  mutationFn: createPost,
  onSuccess: () =&gt; {
    dismissBottomSheetAndNext(() =&gt; {
      resetForm();
      setCompletedModalType(&#39;CREATED&#39;); // ← 모달 등장 트리거

      queryClient.invalidateQueries({ queryKey: ... });  
       queryClient.invalidateQueries({ queryKey: ... });
    });
  },
});</code></pre>
<p>같은 tick에서 모달 등장 + 폼 state 리셋 3~4개 + invalidateQueries 2개가 한꺼번에 실행되고 있었다.</p>
<p>특히 <code>[POST_KEYS.ROOT]</code> 같은 <strong>prefix key</strong>로 invalidate하면 그 prefix를 공유하는 모든 쿼리가 동시에 refetch된다. 네트워크 탭을 열어보니 실제로 모달이 뜨는 순간 5개의 API가 동시에 호출되고 있었다.</p>
<table>
<thead>
<tr>
<th>쿼리 키</th>
<th>호출되는 엔드포인트</th>
</tr>
</thead>
<tbody><tr>
<td><code>[RPOST_KEYS.ROOT, POST_KEYS.KEYWORD_CATEGORIES]</code></td>
<td><code>api/post/keyword-categories</code></td>
</tr>
<tr>
<td><code>[POST_KEYS.ROOT, POST_KEYS.SUMMARY, entityId]</code></td>
<td><code>api/post/entity/:id</code></td>
</tr>
<tr>
<td><code>[POST_KEYS.ROOT, POST_KEYS.MINE, entityId]</code></td>
<td><code>api/post/entity/:id/me</code></td>
</tr>
<tr>
<td><code>[POST_KEYS.ROOT, POST_KEYS.LIST, entityId, params]</code></td>
<td><code>api/post/entity/:id/list?...</code></td>
</tr>
<tr>
<td><code>[ENTITY_KEYS.WITH_BOOKMARK, ENTITY_KEYS.GET_DETAIL, entityId]</code></td>
<td><code>api/entity/:id</code></td>
</tr>
</tbody></table>
<p>이게 jank의 핵심이었다.</p>
<ol>
<li><code>setCompletedModalType</code>이 모달 mount를 트리거.</li>
<li>같은 tick에서 invalidate가 5개 쿼리에 refetch를 큐잉.</li>
<li>JS 스레드는 무효화/refetch 트리거 처리에 점유됨.</li>
<li>응답들이 짧은 간격으로 도착하면서 각 구독자 컴포넌트가 연쇄적으로 리렌더.</li>
<li>모달의 등장 애니메이션 프레임이 그 사이에 끼면서 1~2프레임을 드롭.</li>
</ol>
<p>&quot;항상 끊기지는 않는다&quot;는 점이 여기서 설명된다. 네트워크 응답 타이밍에 따라 리렌더가 등장 애니메이션 윈도우에 겹치는 경우와 겹치지 않는 경우가 갈리는 것이다.</p>
<br>
<br>

<h2 id="4-가설-검증">4. 가설 검증</h2>
<p>원인을 확정하기 위해 가장 간단한 검증을 했다. <strong>invalidateQueries 호출만 일시적으로 주석 처리하고 빌드.</strong></p>
<p>결과: 모달이 완전히 부드럽게 등장. 주석을 풀면 jank가 다시 돌아왔다. 재현 가능했다.</p>
<p>이 시점에서 패턴 A(자체 Portal로 모달 재작성)는 <strong>본질적인 해결책이 아니라는 것</strong>이 분명해졌다. native modal 비용도 영향을 줄 수는 있지만, 진짜 큰 비용은 동시에 발사되는 5개의 refetch였다.</p>
<br>
<br>


<h2 id="5-실패한-우회-1--interactionmanagerrunafterinteractions">5. 실패한 우회 1 — <code>InteractionManager.runAfterInteractions</code></h2>
<p>해결 방향은 명확했다. <strong>invalidate 호출을 모달 등장 애니메이션이 끝난 다음으로 미루기.</strong></p>
<p>가장 먼저 떠오른 API는 <code>InteractionManager.runAfterInteractions</code>였다. &quot;현재 진행 중인 인터랙션/애니메이션이 끝난 다음 콜백을 실행해준다&quot;는 시맨틱이 정확히 우리가 원하는 것이었다.</p>
<pre><code class="language-ts">InteractionManager.runAfterInteractions(() =&gt; {
  queryClient.invalidateQueries({ queryKey: [REVIEW_KEYS.ROOT] });
});</code></pre>
<p>그런데 코드를 쓰자마자 의문이 들었다. React Native 최신 버전에서 deprecated 아니던가?</p>
<p>확인을 위해 <code>node_modules/react-native/Libraries/Interaction/InteractionManager.d.ts</code>를 열어봤다.</p>
<pre><code class="language-ts">/**
 * @deprecated
 */
export interface InteractionManagerStatic {
  ...
  /**
   * Schedule a function to run after all interactions have completed.
   * @deprecated
   */
  runAfterInteractions(...): { ... };
  ...
}</code></pre>
<p><code>InteractionManager.js</code> 본체에도 <code>@deprecated</code> JSDoc이 있었고, 심지어 <code>ReactNativeFeatureFlags.disableInteractionManager()</code> 플래그가 켜지면 통째로 stub으로 대체되는 분기까지 들어가 있었다. 미래에 사라질 수 있는 API에 의존하는 건 분명한 안티패턴이었다.</p>
<p><strong>실패 이유</strong>: API가 deprecated이며 stub으로 교체 가능. 시맨틱은 깔끔하지만 의존하면 안 됨.</p>
<br>
<br>


<h2 id="6-실패한-우회-2--setimmediate">6. 실패한 우회 2 — <code>setImmediate</code></h2>
<p>다음 후보는 <code>setImmediate</code>였다. &quot;현재 task가 끝난 직후 다음 task에서 실행&quot;이라는 시맨틱이고 deprecated도 아니었다.</p>
<pre><code class="language-ts">setImmediate(() =&gt; {
  queryClient.invalidateQueries({ queryKey: [REVIEW_KEYS.ROOT] });
});</code></pre>
<p>빌드하고 테스트했다. <strong>여전히 끊겼다.</strong></p>
<p>이유는 시간 스케일 미스매치였다.</p>
<ul>
<li><code>setImmediate</code>는 보통 0~16ms 안에 실행된다.</li>
<li>모달의 fade-in 애니메이션은 약 200ms 안팎에 걸쳐 진행된다.</li>
</ul>
<p>즉 <code>setImmediate</code>로 미뤄도 모달이 아직 fade-in 중인 시점에 invalidate가 시작된다. JS 스레드는 곧바로 다시 점유되고, 5개 응답이 도착할 때마다 리렌더가 등장 애니메이션 위에 떨어진다. &quot;다음 tick&quot; 정도의 지연은 등장 애니메이션 윈도우를 벗어나기엔 한참 짧았다.</p>
<p><strong>실패 이유</strong>: 지연 시간이 모달 등장 애니메이션 duration에 비해 한 자릿수 짧음.</p>
<br>
<br>

<h2 id="7-작동한-해결책--settimeout-300ms">7. 작동한 해결책 — <code>setTimeout</code> 300ms</h2>
<p>결국 가장 단순한 답으로 돌아왔다.</p>
<pre><code class="language-ts">setCompletedModalType(&#39;CREATED&#39;);

setTimeout(() =&gt; {
  queryClient.invalidateQueries({ queryKey: [REVIEW_KEYS.ROOT] });
  queryClient.invalidateQueries({
    queryKey: [ENTITY_KEYS.WITH_BOOKMARK, ENTITY_KEYS.GET_DETAIL, entityId],
  });
}, 300);</code></pre>
<p>300ms는 모달 fade-in duration(~210ms)에 약간의 여유를 더한 값이다. 빌드하고 테스트하니 jank가 완전히 사라졌다. 반복해도 끊김이 재현되지 않았다.</p>
<br>


<h3 id="이게-적절한-해결책일까">이게 적절한 해결책일까?</h3>
<p>&quot;setTimeout으로 시간을 때려박는 게 정답이라고?&quot; 라는 직감이 드는 건 자연스럽다. 객관적으로 평가해보자. 살짝 꼼수처럼 쓰여지는 느낌이 없지않아 있다.</p>
<ul>
<li><strong>매직 넘버</strong>: 왜 300인지 코드만 보면 알 수 없다. 모달 애니메이션 duration이 바뀌면 깨질 수 있다.</li>
<li><strong>시간 기반 동기화</strong>: &quot;애니메이션이 끝났을 것이다&quot;라는 시간 가정에 의존. 진짜 끝났는지 시그널을 받는 게 아니다.</li>
<li><strong>인과관계가 드러나지 않음</strong>: 주석 없으면 다음 사람은 이 setTimeout이 왜 필요한지 모른다.</li>
</ul>
<br>

<h3 id="그럼에도-받아들여지는-패턴인-이유">그럼에도 받아들여지는 패턴인 이유</h3>
<ol>
<li><strong>TanStack Query 메인테이너도 인정한 패턴</strong>. &quot;애니메이션 후 invalidate&quot;는 커뮤니티에서 자주 인용된다.</li>
<li><strong>deprecated API 의존이 없음</strong>. <code>setTimeout</code>은 사라질 일이 없는 표준 API.</li>
<li><strong>수정 비용 대비 효과 압도적</strong>. 1~2줄 추가로 jank 해결.</li>
<li><strong>시간 가정이 깨질 위험이 낮음</strong>. 모달 애니메이션 duration은 fixed value고, 디바이스 성능에 거의 영향받지 않는다(JS 스레드 점유로 늘어날 수는 있지만, 그 경우 더 잘 작동하는 방향).</li>
</ol>
<p>산업 관행상 받아들여지는 &quot;정당한 트레이드오프&quot;의 범주에 들어간다. 꼼수의 색이 약간 있는 건 사실이지만, 안티패턴은 아니다.</p>
<br>

<hr>
<br>

<h2 id="더-본질적인-대안들-참고">더 본질적인 대안들 (참고)</h2>
<p>setTimeout이 마음에 들지 않는다면 더 본질적인 방향도 있다. 다만 각각 다른 비용을 진다.</p>
<h3 id="1-낙관적-업데이트로-fan-out-자체-제거">1. 낙관적 업데이트로 fan-out 자체 제거</h3>
<p><code>invalidateQueries</code>를 호출하지 않고, mutation 응답에 들어온 새 객체로 캐시를 <strong>직접</strong> 갱신한다.</p>
<pre><code class="language-ts">onSuccess: (newReview) =&gt; {
  queryClient.setQueryData([REVIEW_KEYS.ROOT, REVIEW_KEYS.MINE, entityId], newReview);
  queryClient.setQueryData(
    [REVIEW_KEYS.ROOT, REVIEW_KEYS.LIST, entityId],
    (prev) =&gt; prependToInfiniteList(prev, newReview),
  );
  // ...
};</code></pre>
<ul>
<li><strong>장점</strong>: refetch fan-out 자체가 없다. 시간 의존 0. 데이터 반영도 즉각적.</li>
<li><strong>단점</strong>: 각 쿼리의 캐시 형태에 맞춰 분기 코드를 써야 한다. InfiniteData인지 단순 객체인지, 집계 데이터를 어떻게 업데이트할지 등. 코드량과 유지보수 부담이 늘어난다.</li>
</ul>
<p>가장 본질적이지만 가장 비싸다. 캐시 모양이 자주 바뀌는 화면이면 비용이 더 커진다.</p>
<br>

<h3 id="2-모달-닫힐-때-invalidate">2. 모달 닫힐 때 invalidate</h3>
<p>등장 시점이 아니라 사용자가 모달의 &quot;확인&quot; 버튼을 눌러 닫는 시점에 invalidate를 호출한다.</p>
<pre><code class="language-tsx">&lt;Button onPress={() =&gt; {
  setCompletedModalType(null);
  queryClient.invalidateQueries({ queryKey: [REVIEW_KEYS.ROOT] });
}}&gt;
  확인
&lt;/Button&gt;</code></pre>
<ul>
<li><strong>장점</strong>: 등장은 100% 부드럽다. 매직 넘버 없음.</li>
<li><strong>단점</strong>: 닫힘 시점에 jank가 생길 수 있다. 또한 사용자가 모달을 보는 동안 뒤 화면 데이터는 stale 상태가 된다. 사용자가 모달을 닫지 않고 백 제스처로 뒤로 가면 새 데이터가 반영되지 않는다. dismiss 경로가 여러 개라면 모든 경로에서 invalidate가 호출되도록 신경써야 한다.</li>
</ul>
<br>

<h3 id="3-애니메이션-완료-콜백으로-정확히-동기화">3. 애니메이션 완료 콜백으로 정확히 동기화</h3>
<p>모달을 Tamagui Dialog가 아닌 Reanimated 기반으로 재작성하면 <code>withTiming</code>의 완료 콜백을 통해 정확한 시점에 invalidate를 호출할 수 있다.</p>
<pre><code class="language-ts">opacity.value = withTiming(1, { duration: 210 }, (finished) =&gt; {
  if (finished) {
    runOnJS(handleInvalidate)();
  }
});</code></pre>
<ul>
<li><strong>장점</strong>: 매직 넘버 제거. 실제 애니메이션 종료에 정확히 동기화.</li>
<li><strong>단점</strong>: 모달 컴포넌트 자체를 재작성해야 한다. 단순 알림 모달엔 과한 투자.</li>
</ul>
<p>현재 모달처럼 복잡한 UI라면 정당화되지만, 알림 한 줄짜리 모달에는 ROI가 떨어진다.</p>
<br>
<br>

<h2 id="정리">정리</h2>
<p>이 패턴은 결국 <strong>&quot;무거운 데이터 동기화와 UI 애니메이션을 같은 tick에 발사하지 않는다&quot;</strong>는 원칙으로 요약된다. 그 원칙을 구현하는 방법의 비용 대비 효과는 대략 이렇다.</p>
<table>
<thead>
<tr>
<th>방법</th>
<th>변경 비용</th>
<th>효과</th>
<th>위험</th>
</tr>
</thead>
<tbody><tr>
<td><code>setTimeout</code> + 의도 주석</td>
<td>매우 낮음</td>
<td>충분히 부드러움</td>
<td>매직 넘버. 애니메이션 duration 변경 시 깨질 수 있음</td>
</tr>
<tr>
<td>낙관적 업데이트</td>
<td>높음</td>
<td>가장 부드러움</td>
<td>캐시 분기 유지보수</td>
</tr>
<tr>
<td>닫힘 시점 invalidate</td>
<td>낮음</td>
<td>등장은 완벽</td>
<td>닫힘 jank·stale 데이터</td>
</tr>
<tr>
<td>Reanimated 콜백</td>
<td>매우 높음</td>
<td>가장 정확</td>
<td>모달 재작성</td>
</tr>
</tbody></table>
<p>대부분의 경우 <strong><code>setTimeout</code> + 상수 추출 + 의도 주석</strong>이 가장 합리적이다. 다음 사람이 코드를 안전하게 손댈 수 있을 만큼 의도를 드러내고, deprecated 의존 없이 동작하며, 변경 비용도 최소화된다. 캐시 구조가 단순하고 응답 페이로드에 갱신할 데이터가 모두 들어 있다면 그때 낙관적 업데이트로 한 단계 올리면 된다.</p>
<br>


<p>이 트러블슈팅에서 얻은 가장 큰 교훈은 <strong>&quot;눈에 보이는 컴포넌트를 의심하기 전에, 그 컴포넌트가 등장하는 시점에 같이 일어나는 일을 의심하라&quot;</strong> 였다. 모달이 끊기니까 모달 구현을 바꾸려고 했지만, 실제 원인은 모달과 무관한 위치의 invalidateQueries였다.</p>
<p>prefix 기반 <code>invalidateQueries</code>는 코드를 짧게 쓰게 해주지만, 그 prefix가 얼마나 많은 쿼리에 fan-out되는지 무심코 놓치기 쉽다. mutation 직후 같은 화면을 가리는 모달을 띄우는 패턴이라면, 항상 한 번 더 의심해볼 가치가 있다.</p>
<p>그리고 deprecated API를 추천하기 전에 <code>node_modules</code>의 타입 정의를 직접 열어보는 것도 의미가 있다. 메모리상 &quot;있다고 알고 있는&quot; API의 상태는 종종 현재의 상태와 다르다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native 가상화 리스트 컴포넌트의 성능 관련 props 모음]]></title>
            <link>https://velog.io/@diso592/React-Native-%EA%B0%80%EC%83%81%ED%99%94-%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%9D%98-%EC%84%B1%EB%8A%A5-%EA%B4%80%EB%A0%A8-props-%EB%AA%A8%EC%9D%8C</link>
            <guid>https://velog.io/@diso592/React-Native-%EA%B0%80%EC%83%81%ED%99%94-%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%9D%98-%EC%84%B1%EB%8A%A5-%EA%B4%80%EB%A0%A8-props-%EB%AA%A8%EC%9D%8C</guid>
            <pubDate>Sat, 08 Aug 2026 09:22:30 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>긴 리스트의 성능 문제는 결국 <strong>&quot;화면에 보이지 않는 아이템까지 다 그리느냐&quot;</strong>의 싸움이다. 세 라이브러리 모두 가상화(virtualization), 즉 보이는 영역 주변만 렌더링하는 방식으로 이 문제를 푼다. 다만 그 방식과 튜닝 노브가 서로 다르다.</p>
<p>이 글은 실무에서 실제로 손대게 되는 성능 관련 props 위주로, 세 가상화 리스트를 구별해 정리한 것이다.</p>
<br>
<br>

<h2 id="1-세-리스트에-공통으로-존재하는-성능-관여-props">1. 세 리스트에 공통으로 존재하는 성능 관여 props</h2>
<p>FlashList와 LegendList는 둘 다 <strong>FlatList의 drop-in 대체제</strong>를 표방한다. 그래서 API 표면 대부분이 FlatList와 호환되고, 성능과 직결되는 다음 props는 세 라이브러리에 공통으로 존재한다.</p>
<p><strong><code>keyExtractor</code></strong> — 셋 다 가장 중요한 성능 prop이다. 각 아이템에 안정적인 고유 키를 부여해, <code>data</code>가 바뀌어도 변경된 아이템만 갱신하고 나머지 레이아웃 정보는 재사용하게 한다. 인덱스를 키로 쓰면(재정렬·prepend 시) 리스트가 크게 깨지므로 반드시 데이터 고유값을 반환해야 한다. LegendList는 중복 키를 감지하면 경고를 띄울 정도로 이 부분을 중요하게 본다.</p>
<p><strong><code>onEndReached</code> / <code>onEndReachedThreshold</code></strong> — 페이지네이션(무한 스크롤)의 기본이다. 데이터를 한 번에 다 들고 있지 않고 끝에 가까워질 때 나눠 불러오는 것 자체가 성능 전략이다. <code>onEndReachedThreshold</code>는 화면 크기 배수로 계산되므로, <code>0.5</code>면 &quot;끝에서 화면 절반 거리&quot;에서 콜백이 발동한다.</p>
<p><strong><code>numColumns</code> / <code>horizontal</code></strong> — 레이아웃 모드를 결정하는 공통 props이며, 컬럼 구성과 측정 방식에 영향을 준다.</p>
<p>다만 <strong>여기서부터가 핵심인데, &quot;깊은 성능 튜닝 노브&quot;는 세 라이브러리가 완전히 갈라진다.</strong> FlatList는 렌더링 윈도우를 개발자가 수동으로 조율하는 방식이고, FlashList와 LegendList는 그 수치 조율을 대부분 내부로 흡수하고 아이템 재활용(recycling)으로 접근한다. 아래에서 하나씩 구별한다.</p>
<br>
<br>

<h2 id="1-flatlist-react-native-코어">1. FlatList (React Native 코어)</h2>
<p>FlatList는 아이템을 <strong>재활용하지 않는다.</strong> 화면에 들어오면 마운트하고 나가면 언마운트하는 방식이라, 성능 튜닝이 &quot;렌더링 윈도우를 얼마나 크게/촘촘하게 둘 것인가&quot;의 수동 조율로 귀결된다. 그래서 props가 가장 많고 손이 많이 간다.</p>
<p>실무에서 만지는 핵심 props는 다음과 같다.</p>
<ul>
<li><strong><code>windowSize</code></strong> (기본값 <code>21</code>) — 렌더링해 둘 영역의 크기다. 단위는 아이템 개수가 아니라 <strong>화면(viewport) 높이의 배수</strong>다. 기본 21은 &quot;보이는 화면 1 + 위로 10 + 아래로 10&quot;을 의미한다. 줄이면 메모리는 절약되지만 빠르게 스크롤할 때 빈 칸(blank area)이 보일 수 있다.</li>
<li><strong><code>maxToRenderPerBatch</code></strong> (기본값 <code>10</code>) — 한 배치(batch)에서 렌더링하는 아이템 수다. 키우면 스크롤 중 콘텐츠 채워지는 속도가 빨라지지만 한 번에 일을 많이 해서 프레임이 끊길 수 있다.</li>
<li><strong><code>initialNumToRender</code></strong> (기본값 <code>10</code>) — 첫 렌더에서 그릴 아이템 수다. 첫 화면을 꽉 채울 최소 개수로 맞추는 게 초기 로딩 체감에 좋다.</li>
<li><strong><code>updateCellsBatchingPeriod</code></strong> (기본값 <code>50</code>ms) — 배치 렌더링 사이의 간격(ms)이다. 늘리면 렌더 빈도가 줄어 부하가 분산되지만 빈 칸 노출 시간이 길어진다.</li>
<li><strong><code>removeClippedSubviews</code></strong> (Android 기본값 <code>true</code>, iOS 기본값 <code>false</code>) — 화면 밖 뷰를 네이티브 뷰 계층에서 떼어내는 옵션이다. 효과는 있지만 콘텐츠 사라짐·포커스 버그 위험이 있어 공식 문서가 &quot;본인 책임하에 사용&quot;을 권고한다. 특히 아이템에 <code>TextInput</code>·비디오·복잡한 중첩 뷰가 있으면 신중해야 한다.</li>
<li><strong><code>getItemLayout</code></strong> — 아이템 높이가 <strong>고정일 때</strong> 가장 강력한 최적화다. 각 아이템 위치를 동적 측정 없이 계산식으로 알려줘서, 측정 비용을 없애고 <code>scrollToIndex</code>도 정확해진다. 가변 높이라면 쓰기 어렵다.</li>
</ul>
<p>정리하면 FlatList 튜닝은 <strong><code>windowSize</code> / <code>maxToRenderPerBatch</code> / <code>initialNumToRender</code> 3종 세트</strong>를 데이터 특성에 맞게 맞추고, 고정 높이면 <code>getItemLayout</code>을 더하는 흐름이다.</p>
<br>
<br>

<h2 id="2-flashlist-v2-shopifyflash-list">2. FlashList v2 (<code>@shopify/flash-list</code>)</h2>
<p>FlashList는 Shopify가 만든 고성능 리스트로, 아이템을 <strong>재활용(recycling)한다.</strong> 화면 밖으로 나간 컴포넌트를 버리지 않고 새 아이템에 재사용하는 방식이라 FlatList보다 빠르다.</p>
<p>여기서 중요한 건 <strong>v2가 완전 재작성판</strong>이라는 점이다. 실무 관점에서 v1과의 차이를 반드시 알아야 한다.</p>
<ul>
<li><strong>New Architecture(신아키텍처)가 필수다.</strong> v2는 RN 신아키텍처 위에서만 동작한다. 구아키텍처 프로젝트라면 v2를 쓸 수 없다. (RN CLI 프로젝트라면 자신의 아키텍처 설정을 먼저 확인해야 한다.)</li>
<li><strong><code>estimatedItemSize</code>가 사라졌다.</strong> v1의 가장 번거로운 점이 아이템 크기를 추정해 넘겨주는 것이었는데, v2는 신아키텍처의 동기 레이아웃 측정을 이용해 추정값 없이 크기를 알아서 계산한다. 즉 v1에서 쓰던 <code>estimatedItemSize</code>, <code>estimatedListSize</code>, <code>estimatedFirstItemOffset</code>, <code>onBlankArea</code>, <code>disableAutoLayout</code> 등은 제거되거나 더 이상 필요 없다.</li>
<li><strong><code>maintainVisibleContentPosition</code>이 기본 활성화다.</strong> 위쪽에서 아이템이 추가·변경돼도 현재 보고 있는 콘텐츠가 튀지 않는다.</li>
<li><strong><code>masonry</code>가 prop이 됐다.</strong> 별도 <code>MasonryFlashList</code> 컴포넌트는 deprecated 되고, <code>&lt;FlashList masonry numColumns={3} /&gt;</code>처럼 prop으로 핀터레스트식 레이아웃을 쓴다. <code>optimizeItemArrangement</code>로 컬럼 높이 차이를 줄이는 옵션도 생겼다.</li>
<li><strong><code>inverted</code>가 deprecated 됐다.</strong> 대신 <code>maintainVisibleContentPosition</code>로 채팅형 UI를 처리하는 방향이다.</li>
<li><strong><code>overrideItemLayout</code>은 이제 <code>span</code>(컬럼 차지 수)만 지원한다.</strong> v1에서 가능했던 size 추정 지정은 빠졌다.</li>
<li><strong><code>drawDistance</code></strong> — 뷰포트 위아래로 미리 렌더링해 둘 버퍼(px)다. FlatList의 <code>windowSize</code>에 대응하는, FlashList의 미리 그리기 조절 노브다.</li>
<li>ref 타입이 <code>FlashList</code>에서 <strong><code>FlashListRef</code></strong>로 바뀌었다.</li>
</ul>
<p>실무 포인트는 명확하다. <strong>신아키텍처만 쓴다면 FlashList v2는 추정값 입력이 사라져서 가장 손이 안 가는 선택지</strong>가 됐다. 반대로 아직 구아키텍처라면 v2는 후보에서 빠진다.</p>
<br>
<br>

<h2 id="3-legendlist-v2-legendapplist">3. LegendList v2 (<code>@legendapp/list</code>)</h2>
<p>LegendList는 Legend App(Jay Meistrich)이 만든 신생 리스트로, 가장 큰 특징은 <strong>100% 자바스크립트 구현이라 네이티브 코드 의존성이 없다</strong>는 점이다. 그래서 RN Web, macOS, Windows, TV 등 어떤 RN 플랫폼에서도 동작하고, 설치 후 네이티브 빌드 이슈가 적다. FlatList와 FlashList 양쪽 모두의 drop-in 대체제를 표방한다.</p>
<p>성능 관점에서 알아둘 props는 다음과 같다.</p>
<ul>
<li><strong><code>recycleItems</code></strong> (기본값 <code>false</code>) — <strong>이게 FlashList와의 결정적 차이다.</strong> LegendList는 재활용을 <strong>끄고 시작한다.</strong> 즉 기본 상태에서는 아이템마다 새 컴포넌트를 만들어서(덜 빠르지만) 내부 상태가 있는 아이템도 안전하다. 최고 성능을 원하면 명시적으로 <code>recycleItems</code>를 켜야 하는데, 그 경우 아이템에 로컬 state가 있으면 재사용 과정에서 엉뚱하게 섞일 수 있어 주의해야 한다. FlashList 동작과 똑같이 맞추려면 이 prop을 켜면 된다.</li>
<li><strong><code>drawDistance</code></strong> (기본값 <code>250</code>) — 뷰포트 위아래로 미리 그려둘 버퍼(px)다. 키우면 빠른 스크롤 시 빈 칸이 줄지만 메모리를 더 쓴다.</li>
<li><strong><code>estimatedItemSize</code> / <code>getEstimatedItemSize</code></strong> (선택) — 첫 프레임 레이아웃을 추정하는 힌트다. 안 줘도 동작하며(이후엔 실제 측정한 평균 크기를 사용한다), 안 주면 최적값을 로그로 제안해준다. FlashList v2처럼 &quot;추정 없이도 됨&quot; 방향으로 정리됐다.</li>
<li><strong><code>getFixedItemSize</code></strong> (v2) — 아이템 크기가 고정임을 알려주면 측정·갱신 오버헤드를 없애 최적 성능을 낸다. FlatList의 <code>getItemLayout</code>과 같은 발상이다.</li>
<li><strong><code>getItemType</code></strong> (v2) — 아이템 유형을 분류하면 같은 타입끼리 더 효율적으로 재활용된다. (FlashList에도 있는 개념이다.)</li>
<li><strong><code>maintainVisibleContentPosition</code></strong> (기본값 <code>true</code>) — 위쪽 아이템이 추가·변경·리사이즈돼도 보이는 콘텐츠가 안 튄다. 내부적으로 ScrollView의 동명 prop을 사용하므로, Android에서 쓰려면 RN 0.72 이상이 필요하다.</li>
<li><strong><code>alignItemsAtEnd</code> / <code>maintainScrollAtEnd</code></strong> — <code>inverted</code> 없이 채팅 UI를 만드는 props다. LegendList는 inverted를 쓰지 않고 콘텐츠를 하단 정렬해 양방향 무한 스크롤을 매끄럽게 처리하는 걸 강점으로 내세운다.</li>
<li><strong><code>initialContainerPoolRatio</code></strong> (기본값 <code>2</code>) — 미리 잡아두는 컨테이너 풀의 비율이다. 고정 크기 아이템이면 <code>1</code>에 가깝게, 크기 변동이 크면 더 키우는 식으로 조절한다. 풀이 부족하면 컨테이너를 추가 할당하며 재렌더가 일어나 프레임이 잠깐 끊길 수 있다.</li>
</ul>
<br>
<br>

<h2 id="4-한눈에-비교">4. 한눈에 비교</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>FlatList</th>
<th>FlashList v2</th>
<th>LegendList v2</th>
</tr>
</thead>
<tbody><tr>
<td>제공처</td>
<td>RN 코어</td>
<td>Shopify</td>
<td>Legend App</td>
</tr>
<tr>
<td>아이템 재활용</td>
<td>❌ (마운트/언마운트)</td>
<td>✅ 기본 활성</td>
<td>⚠️ 선택 (<code>recycleItems</code>, 기본 off)</td>
</tr>
<tr>
<td>네이티브 코드</td>
<td>있음</td>
<td>있음</td>
<td>❌ 없음 (순수 JS)</td>
</tr>
<tr>
<td>크기 추정 입력</td>
<td>불필요(윈도우 수동 조율)</td>
<td>불필요 (v2에서 제거)</td>
<td>선택 (없어도 동작)</td>
</tr>
<tr>
<td>신아키텍처</td>
<td>둘 다 지원</td>
<td><strong>필수</strong></td>
<td>불필요(어디서나)</td>
</tr>
<tr>
<td>미리 그리기 조절</td>
<td><code>windowSize</code> 등 3종 세트</td>
<td><code>drawDistance</code></td>
<td><code>drawDistance</code></td>
</tr>
<tr>
<td>고정 높이 최적화</td>
<td><code>getItemLayout</code></td>
<td>자동 측정</td>
<td><code>getFixedItemSize</code></td>
</tr>
<tr>
<td>콘텐츠 위치 유지</td>
<td>ScrollView prop 수동</td>
<td>기본 활성</td>
<td>기본 활성</td>
</tr>
<tr>
<td>채팅 UI</td>
<td><code>inverted</code></td>
<td><code>maintainVisibleContentPosition</code></td>
<td><code>alignItemsAtEnd</code> 등 (inverted 불필요)</td>
</tr>
</tbody></table>
<br>
<br>

<h2 id="5-실무-선택-가이드">5. 실무 선택 가이드</h2>
<ul>
<li><strong>FlatList</strong> — 추가 의존성 없이 가야 하거나, 리스트가 짧고 단순하면 충분하다. 대신 길고 무거운 리스트에서는 <code>windowSize</code>·<code>maxToRenderPerBatch</code>·<code>initialNumToRender</code>를 직접 튜닝해야 하고, 고정 높이면 <code>getItemLayout</code>을 더한다. 안드에서 리스트가 잘리거나 안 보이면 <code>removeClippedSubviews</code>(안드 기본 <code>true</code>)부터 의심한다.</li>
<li><strong>FlashList v2</strong> — <strong>신아키텍처를 쓰고 있다면</strong> 가장 추천할 만하다. 추정값 입력이 사라져 손이 거의 안 가면서 재활용으로 빠르다. 단, 구아키텍처면 쓸 수 없다는 점이 결정적 제약이다.</li>
<li><strong>LegendList v2</strong> — 네이티브 의존성을 피하고 싶거나(웹/멀티플랫폼), 가변 높이 아이템·채팅 UI가 핵심이거나, 구아키텍처라서 FlashList v2를 못 쓰는 상황에서 강하다. 단 최고 성능을 내려면 <code>recycleItems</code>를 직접 켜야 하고, 그 경우 아이템 내부 state 처리에 주의해야 한다.</li>
</ul>
<p>공통 원칙은 하나다. <strong>어떤 리스트를 쓰든 <code>keyExtractor</code>를 제대로 주고, 아이템 컴포넌트를 <code>React.memo</code> 등으로 가볍게 유지하는 것</strong>이 모든 튜닝 노브보다 먼저다. 가상화 라이브러리는 &quot;안 보이는 걸 안 그리게&quot; 도와줄 뿐, 보이는 아이템 하나하나가 무거우면 어떤 리스트도 느려진다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native에서의 콜드 스타트 최적화 & UX 전략]]></title>
            <link>https://velog.io/@diso592/React-Native-%EC%BD%9C%EB%93%9C-%EC%8A%A4%ED%83%80%ED%8A%B8-%EC%B5%9C%EC%A0%81%ED%99%94-UX-%EC%A0%84%EB%9E%B5</link>
            <guid>https://velog.io/@diso592/React-Native-%EC%BD%9C%EB%93%9C-%EC%8A%A4%ED%83%80%ED%8A%B8-%EC%B5%9C%EC%A0%81%ED%99%94-UX-%EC%A0%84%EB%9E%B5</guid>
            <pubDate>Sat, 08 Aug 2026 08:57:15 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>최적화는 어디서 시간이 쓰이는지 분해하는 데서 시작한다. 콜드 스타트는 크게 다음 구간으로 나뉜다.</p>
<ol>
<li><strong>프로세스 생성 + 네이티브 초기화</strong> — OS가 앱 프로세스를 띄우고 네이티브 런타임이 올라오는 구간이다. 앱이 직접 줄이기 가장 어렵지만, 링킹 방식이나 시작 시점 SDK 초기화로 늘어날 수는 있다.</li>
<li><strong>JS 엔진 초기화 + 번들 로드/평가</strong> — Hermes가 바이트코드를 로드하고 JS 번들을 평가하는 구간이다. 번들 크기와 top-level 실행 코드가 직접 영향을 준다.</li>
<li><strong>첫 렌더(TTI까지)</strong> — 루트 컴포넌트가 마운트되고 첫 화면이 인터랙션 가능해지는 구간이다.</li>
<li><strong>데이터 페치 + 화면 완성</strong> — API 응답을 받아 실제 콘텐츠가 채워지는 구간이다.</li>
</ol>
<p>전략은 &quot;이 구간을 실제로 줄인다(actual)&quot;와 &quot;이 구간을 느리게 느끼지 않게 한다(perceived)&quot; 두 축으로 나눠서 본다.</p>
<blockquote>
<p><strong>기준 버전</strong>: 이 글은 React Native 0.84 이상을 기준으로 한다. 0.82부터 Legacy Architecture는 더 이상 지원되지 않고, 0.84부터 Hermes V1이 기본 엔진이다.</p>
</blockquote>
<br>
<br>

<h2 id="1-빌드번들-레벨-최적화-actual">1. 빌드/번들 레벨 최적화 (actual)</h2>
<h3 id="1-hermes는-선택이-아니라-전제다">1) Hermes는 선택이 아니라 전제다</h3>
<p>New Architecture에서는 Hermes가 사실상 유일한 선택지다. JSC는 0.80에서 코어 밖으로 분리되어 <code>@react-native-community/javascriptcore</code> 별도 패키지가 됐다. Hermes는 릴리즈 빌드에서 JS를 AOT로 바이트코드까지 컴파일해 두기 때문에, 런타임에 파싱·컴파일하는 비용이 사라지고 바이트코드는 필요한 시점에 온디맨드로 메모리에 올라간다.</p>
<p>0.84부터는 <strong>Hermes V1</strong>이 기본값이다. 이전 버전에서 올라온 프로젝트라면 opt-out 플래그(<code>RCT_HERMES_V1_ENABLED=0</code>, <code>hermesV1Enabled=false</code>)가 남아 있지 않은지 확인한다.</p>
<h3 id="2-번들-크기를-직접-줄인다">2) 번들 크기를 직접 줄인다</h3>
<p>JS 번들 평가는 콜드 스타트의 큰 축이다. 번들이 작을수록 로드·평가가 빠르다.</p>
<ul>
<li><strong>소스맵 기반 번들 분석</strong>을 돌려 무엇이 번들을 차지하는지 본다. <code>react-native-bundle-visualizer</code>, 또는 <code>--sourcemap-output</code>으로 뽑은 소스맵을 <code>source-map-explorer</code>에 물려 거대한 의존성을 찾아낸다.</li>
<li>moment.js → day.js, lodash 전체 import → <code>lodash/get</code> 식 개별 import로 무거운 라이브러리를 교체하거나 좁힌다. Metro는 기본적으로 트리셰이킹을 하지 않으므로, &quot;번들러가 알아서 털어주겠지&quot;를 전제하면 안 된다.</li>
<li>아이콘 폰트, JSON 사전 데이터, base64 인라인 이미지처럼 번들에 박혀 있는 큰 정적 리소스를 에셋으로 분리한다.</li>
</ul>
<h3 id="3-inline-requires--이미-켜져-있는지부터-확인한다">3) Inline Requires — 이미 켜져 있는지부터 확인한다</h3>
<p>Metro의 inline requires는 모듈을 top-level에서 한 번에 평가하지 않고 <strong>실제로 호출되는 시점</strong>에 평가한다. 콜드 스타트 시점에 평가할 코드량이 줄어 TTI가 빨라진다.</p>
<p>다만 <strong>React Native CLI 프로젝트에서는 이미 기본 활성화</strong>되어 있다. <code>require()</code> 호출은 내 코드와 <code>node_modules</code> 양쪽에서 자동으로 인라인된다. 그러니 &quot;켜자&quot;가 아니라 &quot;꺼져 있지 않은지 확인하자&quot;가 맞다. (Expo는 기본 비활성이라 별도 설정이 필요하다.)</p>
<p>주의할 점은 <strong>ESM <code>import</code> 문은 자동 인라인 대상이 아니라는 것</strong>이다. <code>import</code>까지 인라인하려면 <code>experimentalImportSupport</code>를 함께 켜야 하고, 이건 모듈 평가 순서를 바꿀 수 있어 부수효과에 의존하는 코드에서 문제가 될 수 있다. 켜면 반드시 실기기에서 전체 회귀 테스트를 돌린다.</p>
<pre><code class="language-js">// metro.config.js
transformer: {
  getTransformOptions: async () =&gt; ({
    transform: {
      inlineRequires: true,
      experimentalImportSupport: true, // 도입 시 회귀 테스트 필수
    },
  }),
},</code></pre>
<blockquote>
<p>RAM 번들(<code>indexedRamBundle</code>)은 <strong>Hermes와 함께 쓸 수 없다.</strong> Hermes 바이트코드가 RAM 번들 포맷과 호환되지 않고, 애초에 Hermes가 같거나 더 나은 온디맨드 로딩을 제공한다. 오래된 최적화 글에서 자주 보이는 항목이니 그대로 따라 하지 않는다.</p>
</blockquote>
<h3 id="4-android-r8-앱-크기-baseline-profile">4) Android: R8, 앱 크기, Baseline Profile</h3>
<ul>
<li>릴리즈 빌드에서 R8(코드 축소·난독화)을 켜서 DEX 크기를 줄인다. 다만 이건 <strong>주로 앱 크기 최적화</strong>이고 콜드 스타트 개선폭은 제한적이다. 기대치를 정확히 잡는다.</li>
<li>App Bundle(AAB)로 배포하면 Play가 ABI/밀도별로 알아서 분리해 준다. AAB를 쓰면서 <code>enableSeparateBuildPerCPUArchitecture</code>를 같이 켤 필요는 없다. APK 직접 배포일 때만 의미가 있다.</li>
<li><strong>Baseline Profile</strong>을 넣으면 첫 실행부터 핫패스가 AOT 컴파일되어 있어 초기 실행 구간에서 실질적인 이득이 있다. 앱 크기 최적화보다 콜드 스타트에는 이쪽이 효과가 크다.</li>
</ul>
<h3 id="5-ios-링킹-방식과-프레임워크-수">5) iOS: 링킹 방식과 프레임워크 수</h3>
<ul>
<li><code>use_frameworks! :linkage =&gt; :dynamic</code>은 dyld가 로드해야 할 동적 프레임워크 수를 늘려 앱 시작 시간을 직접적으로 늘린다. 가능하면 <strong>static linkage</strong>를 쓴다.</li>
<li>0.84부터 React Native 코어는 프리컴파일된 <code>.xcframework</code>로 내려받는다. 이건 <strong>빌드 시간</strong> 개선이지 런타임 시작 시간 개선이 아니다. 혼동하지 않는다.</li>
</ul>
<h3 id="6-불필요한-네이티브-모듈sdk-제거">6) 불필요한 네이티브 모듈/SDK 제거</h3>
<p>서드파티 SDK(분석, 광고, 푸시 등)는 앱 시작 시 자체 초기화 코드를 돌리는 경우가 많다. New Architecture의 TurboModule은 <strong>lazy init</strong>(처음 호출될 때 로드)이 기본 이점이지만, 라이브러리가 <code>AppDelegate</code>/<code>Application.onCreate</code>에서 강제 초기화하면 그 이점이 사라진다. 시작 시점에 꼭 필요하지 않은 SDK 초기화는 첫 화면 렌더 이후로 미룬다.</p>
<br>

<hr>
<br>

<h2 id="2-js-실행-레벨-최적화">2. JS 실행 레벨 최적화</h2>
<h3 id="1-진입-코드를-가볍게-한다">1) 진입 코드를 가볍게 한다</h3>
<p><code>index.js</code>와 루트 컴포넌트의 top-level에서 무거운 연산, 동기 스토리지 접근, 거대한 객체 생성을 하지 않는다. 여기서 하는 일은 전부 첫 렌더를 늦춘다. 특히 모듈 top-level의 부수효과(전역 인스턴스 생성, 리스너 등록)는 inline requires로도 피할 수 없는 경우가 많으니 함수 안으로 옮긴다.</p>
<h3 id="2-화면-단위-지연-로딩--무엇이-줄어드는지-정확히-알기">2) 화면 단위 지연 로딩 — 무엇이 줄어드는지 정확히 알기</h3>
<p>초기 진입에 필요 없는 화면·기능은 <code>React.lazy</code> + 동적 <code>import()</code>로 분리한다. 설정 화면, 결제 플로우, 무거운 차트 라이브러리가 대표적이다.</p>
<p>여기서 오해가 잦다. <strong>Metro는 기본적으로 코드 스플리팅을 하지 않는다.</strong> 동적 <code>import()</code>도 결국 같은 번들 안에 들어가므로, 줄어드는 건 <strong>다운로드/번들 크기가 아니라 콜드 스타트 시점의 평가 비용</strong>이다. 그것만으로도 TTI에는 충분히 의미가 있지만, &quot;번들이 작아진다&quot;고 기대하면 측정 결과와 어긋난다.</p>
<h3 id="3-무거운-작업은-첫-렌더-이후로">3) 무거운 작업은 첫 렌더 이후로</h3>
<p>비핵심 초기화(로그 전송, 캐시 워밍, 백그라운드 동기화)를 첫 렌더 뒤로 미뤄 메인/JS 스레드를 비워 준다.</p>
<ul>
<li>React 19 기준으로는 <code>startTransition</code>, <code>useDeferredValue</code>가 우선 선택지다. Fabric의 우선순위 기반 렌더와 자연스럽게 맞물린다.</li>
<li><code>InteractionManager.runAfterInteractions()</code>는 여전히 동작하지만 애니메이션 핸들 기반이라 동시성 렌더와 항상 잘 맞지는 않는다. 화면 진입 애니메이션 이후로 미루는 용도로 한정해서 쓴다.</li>
</ul>
<h3 id="4-폰트리소스-비동기화">4) 폰트/리소스 비동기화</h3>
<p>커스텀 폰트, 대형 에셋 로딩이 첫 렌더를 블록하지 않게 한다. 폰트가 로드되기 전에는 시스템 폰트로 렌더하고 로드 후 교체하는 식으로 진입을 막지 않는다. 단, 폰트 교체로 레이아웃이 크게 흔들린다면 메트릭이 비슷한 폴백 폰트를 지정해 시프트를 줄인다.</p>
<br>

<hr>
<br>

<h2 id="3-스플래시--게이트-전략">3. 스플래시 &amp; 게이트 전략</h2>
<h3 id="1-네이티브-스플래시로-빈-화면을-덮는다">1) 네이티브 스플래시로 빈 화면을 덮는다</h3>
<p>JS 번들 로드·Hermes 초기화·첫 렌더까지의 구간은 사용자에게 흰 화면/검은 화면으로 보일 수 있다. <code>react-native-bootsplash</code> 같은 <strong>네이티브 스플래시</strong>로 이 구간을 자연스럽게 덮는다. 네이티브에서 즉시 뜨므로 JS가 준비되기 전부터 보인다. Android 12 이상에서는 시스템 SplashScreen API를 거치게 되어 있으므로, 자체 스플래시 액티비티를 따로 두면 스플래시가 두 번 보이는 문제가 생긴다.</p>
<h3 id="2-스플래시-종료-기준-주요-api만">2) 스플래시 종료 기준: &quot;주요 API만&quot;</h3>
<p>홈의 <strong>모든 API</strong>를 기다리면 스플래시 종료가 가장 느린 요청·최악의 네트워크에 인질로 잡히고, 부가 API 하나의 실패/지연이 전체 진입을 막는 단일 실패점이 된다.</p>
<p>따라서 기준은 <strong>첫 화면 above-the-fold를 의미 있게 그리는 주요 API</strong>까지로 잡는다. 단, 반드시 아래 세트와 함께 쓴다.</p>
<ul>
<li><strong>타임아웃 가드</strong>: 주요 API에도 상한(예: 3~5초)을 둔다. 초과 시 캐시 데이터/에러·리트라이 화면으로 폴백해 스플래시 무한 대기를 막는다.</li>
<li><strong>스켈레톤 UI</strong>: 부가 영역은 스플래시가 아니라 홈 화면 안에서 스켈레톤으로 로딩한다.</li>
<li><strong>폴백 화면</strong>: 주요 API 실패 시 빈 홈이 아니라 명확한 재시도 UI를 보여준다.</li>
</ul>
<pre><code class="language-ts">const gate = Promise.race([
  fetchCriticalHomeData(),
  new Promise((_, reject) =&gt; setTimeout(() =&gt; reject(new TimeoutError()), 4000)),
]);

try {
  await gate;
} catch {
  // 캐시 렌더 또는 재시도 화면으로 폴백
} finally {
  await BootSplash.hide({ fade: true }); // 어떤 경로로든 반드시 내린다
}</code></pre>
<p>예외는 금융 잔액 화면처럼 부분 렌더가 오정보로 오인될 수 있는 도메인뿐이다. 이때만 &quot;전체 대기&quot;가 정당하다.</p>
<h3 id="3-스플래시-시간을-페치-시간으로-쓴다">3) 스플래시 시간을 페치 시간으로 쓴다</h3>
<p>네이티브 스플래시가 떠 있는 동안, JS가 준비되는 즉시 주요 API를 <strong>prefetch</strong>한다. 스플래시 표시 시간과 네트워크 대기 시간을 겹쳐서, 사용자 입장에서 &quot;기다리는 시간&quot;을 하나로 합친다. 인증 토큰이 이미 로컬에 있다면 네비게이션 트리 마운트를 기다리지 말고 앱 진입 최상단에서 요청을 띄운다.</p>
<br>
<br>

<h2 id="4-데이터네트워크-전략-actual--perceived">4. 데이터/네트워크 전략 (actual + perceived)</h2>
<h3 id="1-캐시-우선--stale-while-revalidate">1) 캐시 우선 + Stale-While-Revalidate</h3>
<p>TanStack Query, SWR, 또는 직접 구현으로 <strong>캐시된 데이터를 먼저 즉시 그린 뒤 백그라운드에서 갱신</strong>한다. 두 번째 이후 진입에서는 네트워크를 기다리지 않고 바로 화면이 채워지므로 체감 속도가 크게 좋아진다.</p>
<h3 id="2-영속-캐시로-두-번째-콜드-스타트를-빠르게">2) 영속 캐시로 &quot;두 번째 콜드 스타트&quot;를 빠르게</h3>
<p>MMKV나 AsyncStorage에 마지막 데이터를 영속화해 둔다. 콜드 스타트여도 직전 세션의 데이터를 즉시 렌더하고 뒤에서 갱신하면, 실제 콜드 스타트인데도 웜 스타트처럼 느껴진다.</p>
<p>MMKV는 JSI 기반 동기 접근이라 진입 경로에서 <code>await</code> 왕복이 없다는 게 핵심 이점이다. 다만 v3부터는 New Architecture(Nitro Modules)를 전제로 하므로 버전과 아키텍처 대응 여부를 함께 확인한다. 반대로 캐시 복원 데이터가 크면 동기 파싱 비용이 그대로 첫 렌더를 막으므로, <strong>화면에 당장 필요한 조각만 복원</strong>한다.</p>
<h3 id="3-요청-합치기bffaggregation">3) 요청 합치기(BFF/aggregation)</h3>
<p>홈 진입에 5개 API가 따로 나간다면, 가능하면 서버에서 하나의 엔드포인트로 합쳐 받는다. 라운드트립 수와 워터폴이 줄어 주요 데이터 도착이 빨라진다. 서버 변경이 어렵다면 클라이언트에서 병렬(<code>Promise.all</code>)로 묶되, 주요/부가를 분리해 주요만 게이트로 둔다.</p>
<h3 id="4-워터폴-제거">4) 워터폴 제거</h3>
<p>&quot;인증 → 프로필 → 홈 데이터&quot;처럼 순차 의존이 길면 그만큼 진입이 늦다. 의존 없는 요청은 병렬화하고, 토큰 검증과 데이터 페치를 가능한 한 겹친다. 토큰 갱신이 필요한 경우에도 만료 전 선제 갱신으로 진입 경로에서 왕복이 추가되지 않게 한다.</p>
<br>
<br>

<h2 id="5-체감-성능perceived-performance-ux">5. 체감 성능(Perceived Performance) UX</h2>
<p>실제 시간을 더 못 줄이는 구간에서 가장 비용 대비 효과가 큰 영역이다.</p>
<h3 id="1-스켈레톤--스피너">1) 스켈레톤 &gt; 스피너</h3>
<p>빈 화면에 스피너만 도는 것보다, 최종 레이아웃과 비슷한 <strong>스켈레톤</strong>을 보여주는 쪽이 더 빠르게 느껴지고 레이아웃 시프트도 줄인다. 사용자는 &quot;곧 무엇이 어디에 올지&quot; 예측하게 된다. 단, 스켈레톤과 실제 콘텐츠의 높이가 다르면 오히려 시프트가 커지므로 치수를 맞춘다.</p>
<h3 id="2-점진적-렌더--골든-패스-우선">2) 점진적 렌더 / 골든 패스 우선</h3>
<p>화면 전체를 한 번에 채우려 하지 말고, <strong>사용자가 가장 먼저 보는 영역</strong>부터 채운다. above-the-fold(헤더, 첫 카드)를 먼저 그리고, 아래쪽·개인화·추천은 뒤따라 채운다. React 19의 Suspense 경계를 영역 단위로 나눠 두면 이 구조를 그대로 표현할 수 있다.</p>
<h3 id="3-첫-화면은-리스트-가상화로">3) 첫 화면은 리스트 가상화로</h3>
<p>홈이 긴 리스트라면 가상화 리스트로 화면에 보이는 만큼만 렌더한다. 초기 렌더 비용이 줄어 TTI가 빨라진다. <code>FlatList</code>를 쓴다면 <code>initialNumToRender</code>를 첫 화면에 실제로 보이는 개수로 맞춘다(기본 10은 대개 과하다). New Architecture 전용으로 다시 쓰인 FlashList v2도 선택지이며, 이 경우 <code>estimatedItemSize</code> 같은 수동 튜닝 없이 동작한다.</p>
<h3 id="4-애니메이션으로-전환을-부드럽게">4) 애니메이션으로 전환을 부드럽게</h3>
<p>스플래시 → 홈 전환을 페이드/스케일로 부드럽게 처리하면, 동일한 시간이라도 끊김 없이 빠른 느낌을 준다. 단, 전환 애니메이션이 길어 실제 진입을 늦추지 않게 짧게(200~300ms) 둔다.</p>
<br>

<hr>
<br>

<h2 id="6-측정-없이는-최적화도-없다">6. 측정 없이는 최적화도 없다</h2>
<p>추측으로 최적화하지 않는다. 진입 경로를 계측한다.</p>
<ul>
<li><strong>TTI(Time To Interactive)</strong> 를 직접 마킹한다. 앱 시작 시점부터 첫 화면이 인터랙션 가능해질 때까지를 측정한다. RN에 들어온 Web Performance API(<code>performance.now()</code>, <code>PerformanceObserver</code>, mark/measure)나 <code>react-native-performance</code>를 쓰면 JS 구간을 표준 방식으로 남길 수 있다.</li>
<li>네이티브 측 콜드 스타트 시간(Android <code>reportFullyDrawn</code> 및 Play Console의 시작 시간 지표, iOS Instruments App Launch / MetricKit)과 JS 측 마커를 함께 본다. 한쪽만 보면 원인 구간을 잘못 짚는다.</li>
<li>Firebase Performance, Sentry, 또는 자체 로깅으로 <strong>실사용자(RUM) p50/p95/p99</strong> 를 본다. p50만 보면 꼬리 지연을 놓친다.</li>
<li>반드시 <strong>릴리즈 빌드</strong>에서 측정한다. 디버그 빌드는 Hermes 바이트코드 AOT 이점이 없고 Metro가 붙어 있어 숫자가 무의미하다.</li>
<li>최적화 전후를 같은 디바이스·같은 네트워크 조건에서 비교한다. 저사양 단말과 느린 네트워크를 반드시 포함한다.</li>
<li>첫 설치 직후 실행과 재실행을 구분해서 본다. Baseline Profile이나 캐시 효과 때문에 둘의 숫자가 크게 다르다.</li>
</ul>
<br>
<br>

<h2 id="7-우선순위-체크리스트">7. 우선순위 체크리스트</h2>
<p>비용 대비 효과 순으로 정리하면 대략 다음과 같다.</p>
<ol>
<li>릴리즈 빌드 기준으로 TTI·RUM 계측을 먼저 붙인다. 기준선 없이 시작하지 않는다.</li>
<li>Hermes(V1)가 켜져 있는지, inline requires가 꺼져 있지 않은지 확인한다.</li>
<li>네이티브 스플래시 + &quot;주요 API만 게이트 + 타임아웃 + 스켈레톤&quot; 조합을 적용한다.</li>
<li>캐시 우선(SWR) + 영속 캐시(MMKV)로 재진입을 웜 스타트처럼 만든다.</li>
<li>진입 경로의 워터폴을 병렬화하고, 가능하면 주요 API를 하나로 합친다.</li>
<li>비핵심 SDK·초기화를 첫 렌더 이후로 미룬다.</li>
<li>화면 단위 lazy import로 콜드 스타트 시점의 평가량을 줄인다.</li>
<li>번들 분석으로 무거운 의존성을 교체·분리한다.</li>
<li>Android Baseline Profile, iOS static linkage 등 플랫폼별 항목을 정리한다.</li>
</ol>
<p>핵심 원칙은 하나다. <strong>줄일 수 있는 시간은 실제로 줄이고(actual), 줄이기 어려운 시간은 느려 보이지 않게 만든다(perceived).</strong> 콜드 스타트 UX는 이 둘의 합이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ios에서 usb 연결하고 빌드 후 Metro 와의 연결이 끊길 때]]></title>
            <link>https://velog.io/@diso592/ios%EC%97%90%EC%84%9C-usb-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B3%A0-%EB%B9%8C%EB%93%9C-%ED%9B%84-Metro-%EC%99%80%EC%9D%98-%EC%97%B0%EA%B2%B0%EC%9D%B4-%EB%81%8A%EA%B8%B8-%EB%95%8C</link>
            <guid>https://velog.io/@diso592/ios%EC%97%90%EC%84%9C-usb-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B3%A0-%EB%B9%8C%EB%93%9C-%ED%9B%84-Metro-%EC%99%80%EC%9D%98-%EC%97%B0%EA%B2%B0%EC%9D%B4-%EB%81%8A%EA%B8%B8-%EB%95%8C</guid>
            <pubDate>Wed, 05 Aug 2026 12:00:14 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>물리적 iOS 기기에서 앱이 실행된 직후 Metro 번들러와의 연결이 끊어지는 현상은 기기가 Mac에서 실행 중인 Metro 서버(기본 포트 8081)와 통신하지 못할 때 발생한다.</p>
<p>시뮬레이터와 달리 물리적 기기는 USB 연결만으로는 디버그 서버와 통신할 수 없으며, 로컬 네트워크(Wi-Fi)를 통한 접근이 필요하다. 원인을 차근차근 파악해보자.</p>
<br>
<br>

<h3 id="1-동일한-wi-fi-네트워크-접속-확인">1. 동일한 Wi-Fi 네트워크 접속 확인</h3>
<p>Mac과 iOS 기기가 정확히 동일한 Wi-Fi 네트워크에 연결되어 있어야한다. 네트워크 분리 기능이 활성화된 환경에서는 기기가 Mac의 로컬 IP를 찾을 수 없어 연결이 즉시 끊어진다.</p>
<br>

<h3 id="2-ios-로컬-네트워크-권한-활성화">2. iOS 로컬 네트워크 권한 활성화</h3>
<p>iOS 14 이상 버전은 앱이 로컬 네트워크에 접근할 때 명시적인 권한을 요구한다. 권한이 거부된 상태라면 앱이 실행되더라도 Metro와의 통신이 차단된다.</p>
<ul>
<li>iOS 기기에서 설정 &gt; 개인정보 보호 및 보안 &gt; 로컬 네트워크로 이동한다.</li>
<li>개발 중인 앱의 항목을 찾아 스위치를 켜서(활성화) 통신을 허용한다.</li>
</ul>
<br>

<h3 id="3-번들러-ip-주소-수동-지정">3. 번들러 IP 주소 수동 지정</h3>
<p>앱이 Mac의 IP 주소를 자동으로 인식하지 못하는 경우가 있다. 이 경우 개발자 메뉴에서 IP를 직접 지정해야 한다.</p>
<ul>
<li>Mac의 터미널에서 네트워크 설정(ifconfig 또는 시스템 설정)을 확인하여 Mac의 로컬 IP 주소(예: 192.168.x.x)를 기록한다.</li>
<li>iOS 기기를 흔들어(Shake) React Native 개발자 메뉴(Developer Menu)를 호출한다.</li>
<li>Configure Bundler를 선택한다.</li>
<li>기록한 Mac의 IP 주소와 포트를 입력한다. (입력 예시: 192.168.0.15:8081)</li>
<li>설정을 저장하고 앱을 다시 로드한다.</li>
</ul>
<br>


<h3 id="4-metro-캐시-초기화-및-재빌드">4. Metro 캐시 초기화 및 재빌드</h3>
<p>기존의 잘못된 연결 정보나 캐시가 남아있어 연결을 방해할 수 있다.</p>
<ul>
<li>실행 중인 Metro 터미널 프로세스를 모두 종료한다.</li>
<li>터미널에서 pnpm start --reset-cache 명령어를 실행하여 캐시를 초기화한 상태로 Metro를 시작한다.</li>
<li>iOS 기기에서 실행 중인 앱을 스와이프하여 완전히 강제 종료한다.</li>
<li>새 터미널 탭을 열고 pnpm ios를 다시 실행하여 빌드 및 기기 연결을 다시 시도한다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native 에서의 메모이제이션 (4.  React Compiler 적용시 지켜야할 필수 규칙들)]]></title>
            <link>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C%EC%9D%98-%EB%A9%94%EB%AA%A8%EC%9D%B4%EC%A0%9C%EC%9D%B4%EC%85%98-4.-React-Compiler-%EC%A0%81%EC%9A%A9%EC%8B%9C-%EC%A7%80%EC%BC%9C%EC%95%BC%ED%95%A0-%ED%95%84%EC%88%98-%EA%B7%9C%EC%B9%99%EB%93%A4</link>
            <guid>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C%EC%9D%98-%EB%A9%94%EB%AA%A8%EC%9D%B4%EC%A0%9C%EC%9D%B4%EC%85%98-4.-React-Compiler-%EC%A0%81%EC%9A%A9%EC%8B%9C-%EC%A7%80%EC%BC%9C%EC%95%BC%ED%95%A0-%ED%95%84%EC%88%98-%EA%B7%9C%EC%B9%99%EB%93%A4</guid>
            <pubDate>Wed, 05 Aug 2026 11:48:34 GMT</pubDate>
            <description><![CDATA[<h2 id="1-eslint-plugin-react-hooks개요">1. eslint-plugin-react-hooks개요</h2>
<p>핵심 전제부터 짚는다. React Compiler는 위반을 만나면 <strong>빌드를 실패시키지 않고 해당 함수를 그대로 둔다.</strong> 즉 &quot;skip&quot;은 에러가 아니라 안전 후퇴(silent bailout)다. RC는 Rules of React 위반을 정적으로 감지하면, 그 컴포넌트/훅만 최적화에서 안전하게 제외하고 나머지는 계속 컴파일한다. 100% 최적화는 목표가 아니다.</p>
<p>문제는 이 후퇴가 <strong>명시적 신호 없이</strong> 일어난다는 점이다. 어떤 컴포넌트가 skip됐는지 모르고 지나갈 수 있다. 그래서 &quot;무엇이 skip을 유발하는가&quot;라는 패턴 카탈로그와, &quot;skip됐는지 어떻게 확인하는가&quot;라는 검증 도구를 함께 다뤄야 한다.</p>
<br>
<br>

<h2 id="2-skip이-일어나는-메커니즘">2. skip이 일어나는 메커니즘</h2>
<h3 id="1-panicthreshold-옵션">1) <code>panicThreshold</code> 옵션</h3>
<p>RC는 Rules of React 위반을 만났을 때의 처리 정책을 <code>panicThreshold</code>로 제어한다. 값은 <code>&#39;none&#39; | &#39;critical_errors&#39; | &#39;all_errors&#39;</code> 세 가지이며, <strong><code>&#39;none&#39;</code>이 기본값이자 공식 권장값</strong>이다.</p>
<pre><code class="language-js">[&#39;babel-plugin-react-compiler&#39;, {
  panicThreshold: &#39;none&#39;, // 기본값. 명시하지 않아도 동일하게 동작한다.
}]</code></pre>
<p><code>&#39;none&#39;</code>은 &quot;위반 함수만 미컴파일 상태로 두고 나머지는 계속 컴파일&quot;을 의미한다. 즉 <strong>&quot;skip&quot;의 정체가 곧 이 옵션의 기본 동작</strong>이다.</p>
<table>
<thead>
<tr>
<th>값</th>
<th>동작</th>
</tr>
</thead>
<tbody><tr>
<td><code>&#39;none&#39;</code> <strong>(기본값·권장)</strong></td>
<td>컴파일 불가 함수는 skip하고 빌드는 계속</td>
</tr>
<tr>
<td><code>&#39;critical_errors&#39;</code></td>
<td>치명적 컴파일러 에러에서만 빌드 실패</td>
</tr>
<tr>
<td><code>&#39;all_errors&#39;</code></td>
<td>어떤 진단이든 만나면 빌드 실패</td>
</tr>
</tbody></table>
<br>

<h3 id="2-compilationmode-옵션과의-관계">2) <code>compilationMode</code> 옵션과의 관계</h3>
<p>대상 함수 자체의 선정은 <code>compilationMode</code>가 결정한다. skip 정책(<code>panicThreshold</code>)은 그 후에 작동한다. <strong>기본값은 <code>&#39;infer&#39;</code></strong>다.</p>
<table>
<thead>
<tr>
<th>모드</th>
<th>동작</th>
</tr>
</thead>
<tbody><tr>
<td><code>&#39;infer&#39;</code> <strong>(기본값)</strong></td>
<td>컴포넌트/훅으로 추정되는 함수를 자동 컴파일 — <code>&quot;use memo&quot;</code>가 붙었거나, 이름이 컴포넌트(PascalCase)·훅(<code>use</code> 접두)이면서 JSX를 만들거나 다른 훅을 호출하는 함수</td>
</tr>
<tr>
<td><code>&#39;all&#39;</code></td>
<td>모든 최상위 함수 컴파일 시도. 비-React 함수까지 건드릴 수 있어 <strong>권장되지 않음</strong></td>
</tr>
<tr>
<td><code>&#39;annotation&#39;</code></td>
<td><code>&quot;use memo&quot;</code> 디렉티브가 붙은 함수만. 점진 도입에 적합</td>
</tr>
<tr>
<td><code>&#39;syntax&#39;</code></td>
<td><strong>Flow의 component/hook 문법</strong>을 사용하는 컴포넌트·훅만. (TypeScript 환경에서는 사실상 해당 없음)</td>
</tr>
</tbody></table>
<blockquote>
<p>주의: <code>&#39;syntax&#39;</code>는 JSX 유무로 거르는 모드가 아니라 <strong>Flow 전용 문법</strong>을 대상으로 하는 모드다. TS/RN CLI 프로젝트라면 보통 기본값 <code>&#39;infer&#39;</code>를 그대로 둔다.</p>
</blockquote>
<p>이 글이 다루는 케이스는 <em>대상이지만 위반으로 skip</em> 되는 상황이다.</p>
<br>
<br>

<h2 id="3-skip을-유발하는-패턴-카탈로그">3. skip을 유발하는 패턴 카탈로그</h2>
<p>공식 <a href="https://react.dev/reference/rules">Rules of React</a>를 기준으로 정리한다. 모두 RC 분석을 막는 사유이고, <code>eslint-plugin-react-hooks</code> recommended preset이 빌드 전에 잡아낼 수 있는 항목이기도 하다.</p>
<h3 id="1-components-and-hooks-must-be-pure">1) Components and Hooks Must Be Pure</h3>
<p>가장 빈번한 위반 카테고리이다. 기본적인 규칙이므로 잘 기억하자.</p>
<p><strong>a. 렌더 중 props 변이</strong></p>
<pre><code class="language-tsx">// ❌ skip 유발 — props는 immutable
const ListItem = ({ item }: { item: Item }) =&gt; {
  item.viewed = true;
  return &lt;Text&gt;{item.title}&lt;/Text&gt;;
};</code></pre>
<p><strong>b. 렌더 중 state 변이</strong></p>
<pre><code class="language-tsx">// ❌ skip 유발 — useState로 받은 객체를 직접 mutate
const Form = () =&gt; {
  const [data] = useState({ name: &#39;A&#39; });
  data.name = &#39;B&#39;; // setState 없이 mutate
  return &lt;Text&gt;{data.name}&lt;/Text&gt;;
};</code></pre>
<p><strong>c. JSX에 넘긴 값을 그 뒤에 변이</strong></p>
<pre><code class="language-tsx">// ❌ skip 유발 — JSX에 들어간 객체는 더 이상 mutate 금지
const View = () =&gt; {
  const data = { name: &#39;A&#39; };
  const node = &lt;Display data={data} /&gt;;
  data.name = &#39;B&#39;;
  return node;
};</code></pre>
<p><strong>d. 렌더 중 ref 읽기/쓰기</strong></p>
<pre><code class="language-tsx">// ❌ skip 유발 — .current는 effect/이벤트 핸들러 안에서만
const View = () =&gt; {
  const ref = useRef(0);
  ref.current += 1;
  return &lt;Text&gt;{ref.current}&lt;/Text&gt;;
};</code></pre>
<p><strong>e. 렌더 중 전역 변이</strong></p>
<pre><code class="language-tsx">// ❌ skip 유발 — 모듈 스코프 변수에 대한 할당/변이
let counter = 0;

const Counter = () =&gt; {
  counter += 1;
  return &lt;Text&gt;{counter}&lt;/Text&gt;;
};</code></pre>
<p><strong>f. 컴포넌트가 idempotent하지 않음</strong></p>
<p>같은 입력에 같은 출력을 보장해야 한다. 렌더 출력이 매번 바뀌는 호출(<code>Date.now()</code>, <code>Math.random()</code> 등)을 렌더 중에 직접 쓰는 패턴은 메모이제이션 가정과 충돌한다. ESLint 규칙 <code>purity</code>가 알려진 비순수 함수 호출을 잡는다.</p>
<br>

<h3 id="2-rules-of-hooks">2) Rules of Hooks</h3>
<p><strong>a. 조건/루프/조기 반환 뒤에서 hook 호출</strong></p>
<pre><code class="language-tsx">// ❌ skip 유발 — early return 뒤 hook
const Card = ({ enabled }: Props) =&gt; {
  if (!enabled) return null;
  const [v] = useState(0);
  return &lt;Text&gt;{v}&lt;/Text&gt;;
};</code></pre>
<pre><code class="language-tsx">// ❌ skip 유발 — 조건 안에서 hook
const Card = ({ enabled }: Props) =&gt; {
  if (enabled) {
    const [v] = useState(0);
  }
  return null;
};</code></pre>
<p><strong>b. 일반 함수에서 hook 호출</strong></p>
<pre><code class="language-tsx">// ❌ skip 유발 — 이벤트 핸들러/일반 함수에서 hook 호출
const handler = () =&gt; {
  const v = useSomething();
};</code></pre>
<p><strong>c. <code>use</code>로 시작하지 않는 함수에서 hook 호출</strong></p>
<p>훅 명명 규칙(<code>use*</code>)을 어기면 ESLint 규칙 <code>rules-of-hooks</code>가 잡고, RC도 hook으로 인식하지 못한다.</p>
<br>

<h3 id="3-react-calls-components-and-hooks">3) React Calls Components and Hooks</h3>
<p><strong>a. 컴포넌트를 일반 함수로 직접 호출</strong></p>
<pre><code class="language-tsx">// ❌ skip 유발 — JSX가 아닌 직접 호출
const Page = () =&gt; {
  return &lt;View&gt;{Header({ title: &#39;x&#39; })}&lt;/View&gt;;
};</code></pre>
<p>컴포넌트는 항상 <code>&lt;Header title=&quot;x&quot; /&gt;</code> 형태로 사용해야 한다.</p>
<p><strong>b. hook을 값으로 전달/저장</strong></p>
<pre><code class="language-tsx">// ❌ skip 유발 — hook을 일반 값처럼 다룸
const run = (hook: () =&gt; number) =&gt; hook();
const Comp = () =&gt; run(useCounter);</code></pre>
<br>

<h3 id="4-rc가-호환하지-못하는-라이브러리-사용">4) RC가 호환하지 못하는 라이브러리 사용</h3>
<p>특정 라이브러리는 내부 패턴이 RC의 메모이제이션 가정과 양립하지 않는다. ESLint 규칙 <code>incompatible-library</code>가 이를 식별한다(구체 목록은 버전마다 달라지므로 lint 결과로 확인). 단, 이 규칙은 기본 심각도가 <code>warn</code>이라 빌드를 막지는 않고 경고만 띄운다(4장 표 참조).</p>
<br>
<br>

<h2 id="4-사전-차단--eslint-규칙">4. 사전 차단 — ESLint 규칙</h2>
<p>RC의 진단은 빌드 타임에 조용히 skip되지만, <strong><code>eslint-plugin-react-hooks</code> recommended preset</strong>은 이 위반들을 개발 중에 표면화시킨다. 아래는 공식 recommended preset에 포함된 규칙 전체와, 그 <strong>기본 심각도</strong>다. 대부분 <code>error</code>지만 세 개(<code>exhaustive-deps</code>, <code>unsupported-syntax</code>, <code>incompatible-library</code>)는 <code>warn</code>이라는 점이 실무적으로 중요하다 — 이 셋은 위반이 있어도 빌드를 막지 않고 경고만 낸다.</p>
<table>
<thead>
<tr>
<th>규칙</th>
<th>심각도</th>
<th>잡는 패턴</th>
</tr>
</thead>
<tbody><tr>
<td><code>rules-of-hooks</code></td>
<td>error</td>
<td>Rules of Hooks 위반</td>
</tr>
<tr>
<td><code>exhaustive-deps</code></td>
<td><strong>warn</strong></td>
<td>hook deps 누락·과잉</td>
</tr>
<tr>
<td><code>purity</code></td>
<td>error</td>
<td>알려진 비순수 함수 호출</td>
</tr>
<tr>
<td><code>immutability</code></td>
<td>error</td>
<td>props·state 등 immutable 값 변이</td>
</tr>
<tr>
<td><code>globals</code></td>
<td>error</td>
<td>렌더 중 전역 할당/변이</td>
</tr>
<tr>
<td><code>refs</code></td>
<td>error</td>
<td>렌더 중 ref 읽기/쓰기</td>
</tr>
<tr>
<td><code>set-state-in-render</code></td>
<td>error</td>
<td>렌더 중 setState</td>
</tr>
<tr>
<td><code>set-state-in-effect</code></td>
<td>error</td>
<td>effect 안 동기 setState</td>
</tr>
<tr>
<td><code>static-components</code></td>
<td>error</td>
<td>매 렌더마다 재생성되는 컴포넌트</td>
</tr>
<tr>
<td><code>component-hook-factories</code></td>
<td>error</td>
<td>함수 내부에서 컴포넌트/훅을 정의</td>
</tr>
<tr>
<td><code>preserve-manual-memoization</code></td>
<td>error</td>
<td>기존 수동 메모이제이션의 의미가 깨지는 경우</td>
</tr>
<tr>
<td><code>incompatible-library</code></td>
<td><strong>warn</strong></td>
<td>RC와 양립하지 않는 라이브러리 사용</td>
</tr>
<tr>
<td><code>unsupported-syntax</code></td>
<td><strong>warn</strong></td>
<td>RC가 지원하지 않는 문법</td>
</tr>
<tr>
<td><code>use-memo</code></td>
<td>error</td>
<td><code>useMemo</code>에서 반환값 누락</td>
</tr>
<tr>
<td><code>error-boundaries</code></td>
<td>error</td>
<td>자식 에러를 try/catch 대신 ErrorBoundary로</td>
</tr>
<tr>
<td><code>config</code></td>
<td>error</td>
<td>RC config 옵션 검증</td>
</tr>
<tr>
<td><code>gating</code></td>
<td>error</td>
<td>gating 모드 설정 검증</td>
</tr>
</tbody></table>
<p>이 규칙들은 <strong>RC를 아직 도입하지 않은 상태에서도 켤 수 있다.</strong> RC 도입 직전 사전 정리 단계에서 가장 가성비가 좋다. 위반이 있는 코드 위에 RC를 켜봐야 skip만 늘어나기 때문이다.</p>
<br>
<br>

<h2 id="5-use-no-memo-디렉티브--디버깅-도구">5. <code>&quot;use no memo&quot;</code> 디렉티브 — 디버깅 도구</h2>
<p>특정 컴포넌트가 RC와 함께 두면 동작이 깨지거나 의심스러울 때, 한시적으로 RC를 끄고 원인을 격리한다.</p>
<pre><code class="language-tsx">const Suspicious = () =&gt; {
  &#39;use no memo&#39;; // 이 함수만 컴파일 제외
  // ...
};</code></pre>
<p>배치 규칙 (공식 문서 기준):</p>
<ul>
<li><strong>함수 본문 첫 줄</strong> — 해당 함수만 컴파일 제외. (디렉티브는 import나 다른 코드보다 앞, 본문 맨 앞에 와야 하며 주석은 허용. 백틱이 아닌 따옴표로 작성.)</li>
<li><strong>파일 최상단</strong> — 그 파일의 모든 함수를 컴파일 제외(모듈 레벨).</li>
<li><strong>함수 레벨이 모듈 레벨을 덮어쓴다.</strong> 그리고 이 디렉티브는 모든 <code>compilationMode</code>보다 우선한다(<code>&#39;all&#39;</code> 모드에서도 해당 함수는 제외).</li>
</ul>
<p>용도:</p>
<ul>
<li>이슈 격리 — <code>&quot;use no memo&quot;</code>를 붙여 문제가 사라지면 → 해당 컴포넌트에 Rules of React 위반이 있다는 신호.</li>
<li><strong>영구 사용 금지.</strong> 근본 위반을 고친 뒤 디렉티브를 제거하고 <code>Memo ✨</code> 배지로 확인하는 것이 정상 흐름이다.</li>
</ul>
<p>반대 디렉티브 <strong><code>&quot;use memo&quot;</code></strong> 는 <code>compilationMode: &#39;annotation&#39;</code>에서 명시적으로 컴파일을 켜거나, <code>&#39;infer&#39;</code> 모드의 추정을 강제로 덮어쓸 때 사용한다(점진 도입 시).</p>
<br>
<br>

<h2 id="6-사후-확인--memo-✨-배지">6. 사후 확인 — <code>Memo ✨</code> 배지</h2>
<p>React Native DevTools의 Component Inspector에서 컴포넌트 옆에 <code>Memo ✨</code> 배지가 보이면 RC가 컴파일한 컴포넌트다. 배지가 <strong>없으면 skip</strong>된 것.</p>
<p>확인 흐름:</p>
<ol>
<li>ESLint 진단을 모두 통과시킨다(특히 <code>error</code> 규칙. <code>warn</code> 항목도 RC 최적화를 막을 수 있으니 함께 점검).</li>
<li>앱을 띄우고 DevTools로 화면 진입.</li>
<li>의도한 컴포넌트에 배지가 붙었는지 확인.</li>
<li>배지가 없는 컴포넌트는 3장 카탈로그 항목과 lint 결과를 다시 본다.</li>
</ol>
<p>핵심 화면 — 리스트 <code>renderItem</code>, BottomSheet, 무거운 진입 화면 — 만큼은 배지 확인을 PR 체크리스트에 넣는 게 안전하다. RC가 자동으로 깔아주는 메모이제이션은 <strong>분석을 통과한 컴포넌트에서만</strong> 작동하기 때문이다.</p>
<br>
<br>

<hr>
<br>

<h3 id="한-줄-요약">한 줄 요약</h3>
<p>RC의 skip은 에러가 아닌 <strong>조용한 안전 후퇴</strong>다(<code>panicThreshold</code>의 기본값이자 권장값인 <code>&#39;none&#39;</code>의 동작). 트리거는 본질적으로 <strong>Rules of React 위반</strong> — 렌더 중 props/state/ref/전역 변이, 조건부 hook 호출, 컴포넌트를 일반 함수처럼 호출, hook을 값으로 전달 등. <strong><code>eslint-plugin-react-hooks</code> recommended preset</strong>으로 빌드 전에 막고(단 <code>exhaustive-deps</code>·<code>unsupported-syntax</code>·<code>incompatible-library</code>는 <code>warn</code>), <strong><code>&quot;use no memo&quot;</code></strong> 로 격리 디버깅하고, <strong>DevTools <code>Memo ✨</code> 배지</strong>로 사후 확인하는 3단 검증이 표준 운영 방식이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native 에서의 메모이제이션 (3.  React Compiler 의 적용시 기존 메모이제이션들의 방향)]]></title>
            <link>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C%EC%9D%98-%EB%A9%94%EB%AA%A8%EC%9D%B4%EC%A0%9C%EC%9D%B4%EC%85%98-3.-React-Compiler-%EC%9D%98-%EC%A0%81%EC%9A%A9%EC%8B%9C-%EA%B8%B0%EC%A1%B4-%EB%A9%94%EB%AA%A8%EC%9D%B4%EC%A0%9C%EC%9D%B4%EC%85%98%EB%93%A4%EC%9D%98-%EB%B0%A9%ED%96%A5</link>
            <guid>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C%EC%9D%98-%EB%A9%94%EB%AA%A8%EC%9D%B4%EC%A0%9C%EC%9D%B4%EC%85%98-3.-React-Compiler-%EC%9D%98-%EC%A0%81%EC%9A%A9%EC%8B%9C-%EA%B8%B0%EC%A1%B4-%EB%A9%94%EB%AA%A8%EC%9D%B4%EC%A0%9C%EC%9D%B4%EC%85%98%EB%93%A4%EC%9D%98-%EB%B0%A9%ED%96%A5</guid>
            <pubDate>Tue, 04 Aug 2026 13:14:13 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>RC를 도입했을 때 <strong>수동 메모이제이션이 불필요해지는 라이브러리·기능들</strong>을 정리해보자. 결론부터 말하자면 본질은 모두 같다. RC가 인라인 객체·함수·배열을 표현식 단위로 자동 캐시하므로, 그 위에 손으로 깔던 <code>useMemo</code> / <code>useCallback</code> / <code>React.memo</code>가 의미를 잃는다. 그렇다면, 기존에 바텀시트 &amp; 가상화 리스트 등 UI에 불필요한 애니메이션 재등록, 리렌더링을 막기 위해, 최적화를 위해 메모이제이션을 적용했는데, 이것들도 그럼 수동으로 지정하는 것이 필요가 없어지는지 살펴보자.</p>
<br>
<br>


<h2 id="1-reanimated-worklet-재등록-문제와-메모이제이션">1. Reanimated worklet 재등록 문제와 메모이제이션</h2>
<p>가장 중요한 논제이다. Reanimated의 worklet은 UI 스레드에서 도는 함수인데, <strong>컴포넌트가 리렌더될 때마다 worklet이 새로 만들어지면, 그 worklet은 매 렌더마다 다시 등록(re-register)된다.</strong> 참조가 불안정하면 불필요한 재등록이 계속 쌓이고, 특히 리스트처럼 같은 컴포넌트가 수십 개 깔리는 화면에서 비용이 증폭된다.</p>
<h3 id="1-왜-재등록이-문제인가">1) 왜 재등록이 문제인가</h3>
<p>worklet이나 제스처 객체는 &quot;한 번 등록해두고 그 등록된 객체를 재사용&quot;하는 것이 이상적이다. 그런데 매 렌더마다 새 함수/새 객체가 생기면, Reanimated/Gesture Handler 입장에서는 &quot;새로운 것&quot;으로 보여 다시 등록 및 재부착한다. 화면이 가벼우면 무시할 수 있지만, <code>FlatList</code> 아이템처럼 다수가 동시에 존재하면 누적 비용이 체감된다.</p>
<br>

<h3 id="2-참조-안정성을-보장해-해결하기">2) 참조 안정성을 보장해 해결하기</h3>
<p>해법은 앞선 메모이제이션 논의와 정확히 같다. <strong>worklet/제스처의 참조를 고정하면, 캡처한 값이 실제로 바뀔 때만 재등록된다.</strong> 공식 Performance 가이드가 명시하는 두 지점은 다음과 같다.</p>
<p><strong>1. <code>useFrameCallback</code> — 콜백을 <code>useCallback</code>으로 감싼다.</strong></p>
<pre><code class="language-tsx">useFrameCallback(
  useCallback(
    () =&gt; {
      &#39;worklet&#39;;
      // ...
    },
    [
      /* deps */
    ],
  ),
);</code></pre>
<p>이렇게 하면 프레임 콜백이 매 렌더마다 재생성·재등록되지 않는다.</p>
<br>

<p><strong>2. Gesture 객체 — <code>useMemo</code>로 감싼다.</strong></p>
<pre><code class="language-tsx">const pan = useMemo(
  () =&gt;
    Gesture.Pan()
      .onStart(() =&gt; {})
      .onEnd(() =&gt; {}),
  [
    /* deps */
  ],
);</code></pre>
<p>제스처가 매 렌더마다 다시 부착되는 것을 막는다. 공식 문서는 이 처리가 <strong><code>FlatList</code> 아이템에서 특히 중요</strong>하다고 못 박는다.</p>
<p>핵심은 <strong>의존성 배열을 정확히 채우는 것</strong>이다. deps가 비어 있으면 값이 바뀌어도 worklet이 갱신되지 않고(스테일), deps가 과하면 불필요한 재등록이 돌아온다. &quot;캡처하는 값이 바뀔 때만 재등록&quot;이 목표다.</p>
<br>

<h3 id="3-react-compiler를-켜면-이게-자동화된다">3) React Compiler를 켜면 이게 자동화된다</h3>
<p>여기가 React Compiler와 Reanimated가 만나는 지점이다. <a href="https://docs.swmansion.com/react-native-reanimated/docs/guides/performance/#-memoize-gesture-objects">공식 Performance 가이드</a>는 두 항목 모두에 대해 다음을 명시한다.</p>
<ul>
<li><strong>&quot;React Compiler를 사용 중이라면, 프레임 콜백은 자동으로 메모이제이션된다.&quot;</strong></li>
<li><strong>&quot;React Compiler를 사용 중이라면, 제스처 객체는 자동으로 메모이제이션된다.&quot;</strong></li>
</ul>
<p>즉 위의 <code>useCallback</code>/<code>useMemo</code>를 손으로 깔던 일을 컴파일러가 대신 해준다. <strong>단, 컴파일러가 해당 컴포넌트를 분석·최적화할 수 있을 때</strong>의 이야기다. 분석 불가로 건너뛴 컴포넌트라면 자동 메모이제이션이 적용되지 않으므로, 그런 경우엔 수동 <code>useCallback</code>/<code>useMemo</code>가 여전히 필요하다. (DevTools의 <code>Memo ✨</code> 배지로 확인할 것.)</p>
<br>

<h3 id="4-shared-value는-value-대신-get--set-을-사용">4) shared value는 <code>.value</code> 대신 <code>get()</code> / <code>set()</code> 을 사용</h3>
<p>React Compiler를 켤 때 Reanimated에서 <strong>반드시 지켜야 하는 규칙</strong>이 있다. 공식 <code>useSharedValue</code> 문서가 직접 명시한다.</p>
<blockquote>
<p>React Compiler와 함께 쓸 때는 <code>value</code> 속성을 직접 읽거나 쓰지 말고, <code>get</code>/<code>set</code> 메서드를 사용하라. 이것이 React Compiler 표준에 부합하는 대체 API다.</p>
</blockquote>
<pre><code class="language-tsx">const sv = useSharedValue(100);

// ✅ .value 대신 get()
const animatedStyle = useAnimatedStyle(() =&gt; ({ width: sv.get() * 100 });

const handlePress = () =&gt; {
  sv.set(v =&gt; v + 1); // ✅ .value = ... 대신 set()
};</code></pre>
<p>이유는 명확하다. shared value의 읽기/쓰기는 외부 가변 저장소에 대한 <strong>부수효과(side effect)</strong>다. <code>.value</code>에 대한 단순 속성 접근을 컴파일러의 정적 분석이 잘못 해석하면 메모이제이션이 어긋날 수 있다. <code>get()</code>/<code>set()</code>은 이를 함수 호출로 명시해 컴파일러가 안전하게 다루도록 한다.</p>
<br>

<h3 id="5-useanimatedstyle--usederivedvalue는-별개">5) <code>useAnimatedStyle</code> / <code>useDerivedValue</code>는 별개</h3>
<p>흔한 오해 하나를 정리한다. <strong><code>useAnimatedStyle</code>이나 <code>useDerivedValue</code>에 넘기는 worklet은 <code>useCallback</code>으로 감쌀 대상이 아니다.</strong> 이 훅들은 Reanimated Babel 플러그인이 worklet으로 자동 변환(workletize)하고, 훅 내부에서 의존성 추적과 매퍼 등록을 관리한다. 따라서 위 2)에서 말한 &quot;수동 재등록 방지(useCallback/useMemo)&quot;가 직접 필요한 대상은 <strong><code>useFrameCallback</code>의 콜백</strong>과 <strong>Gesture 객체</strong>다. 모든 worklet을 일률적으로 <code>useCallback</code>으로 감싸야 한다고 오해하지 말 것.</p>
<br>

<h3 id="6-렌더-중에-shared-value를-읽거나-쓰지-않는다">6) 렌더 중에 shared value를 읽거나 쓰지 않는다</h3>
<p>마지막 규칙. <strong>컴포넌트 렌더 중에 <code>sv.value</code>(또는 <code>get</code>/<code>set</code>)에 접근하지 않는다.</strong> 렌더 중 접근은 부수효과이고 Rules of React 위반이다. 읽기/쓰기는 렌더 중 실행되지 않는 콜백 — <code>useAnimatedStyle</code>, <code>useEffect</code>, 이벤트 핸들러 등 — 안에서만 해야 한다.</p>
<p>또한 JS 스레드에서 <code>sv.value</code>를 읽는 것 자체를 <a href="https://docs.swmansion.com/react-native-reanimated/docs/guides/performance/#-avoid-reading-shared-values-on-the-js-thread">피하라는 가이드</a>도 있다. JS 스레드에서 값을 읽으면 UI 스레드와 동기화될 때까지 JS 스레드가 블록되며, UI 스레드가 바쁘거나 반복 읽기가 있으면 그 대기 시간이 무시 못 할 수준이 될 수 있다. 값 읽기는 worklet 내부(UI 스레드)에서 하는 것이 원칙이다.</p>
<br>
<br>

<h2 id="2-외부-라이브러리-props와-자동-메모이제이션">2. 외부 라이브러리 props와 자동 메모이제이션</h2>
<p>Reanimated 외에도 <strong>외부 라이브러리에 prop으로 넘기는 인라인 객체·함수·배열</strong>은 전통적으로 수동 메모이제이션의 1순위 대상이었다. <code>@gorhom/bottom-sheet</code>의 <code>snapPoints</code>/<code>onChange</code>/<code>onDismiss</code>, <code>react-native-tab-view</code>의 <code>renderScene</code>, 차트 라이브러리의 <code>config</code> 등이 모두 같은 결이다. 라이브러리 내부 <code>React.memo</code>/<code>useEffect</code> deps가 참조 비교에 의존하기 때문에, 부모가 매 렌더마다 새 참조를 만들면 라이브러리 측 최적화가 무력화되거나 부수효과가 반복 실행된다.</p>
<br>

<h3 id="1-rc가-켜져-있으면-자동-메모이제이션">1) RC가 켜져 있으면 자동 메모이제이션</h3>
<p>React Compiler는 정확히 이 케이스를 노린다. 부모 컴포넌트가 분석 통과한 경우, JSX에 박힌 인라인 값이 표현식 단위로 캐시된다.</p>
<pre><code class="language-tsx">// RC ON, 부모가 분석 통과한 경우 — 아래 3개 props 모두 자동 메모이제이션
&lt;BottomSheet
  snapPoints={[&#39;25%&#39;, &#39;50%&#39;]} // 배열 리터럴 → 캐시
  onChange={idx =&gt; setIndex(idx)} // 함수 리터럴 → 캐시
  onDismiss={() =&gt; closeSheet()} // 함수 리터럴 → 캐시
/&gt;</code></pre>
<p><code>useMemo</code> / <code>useCallback</code>을 손으로 까는 것과 의미상 동치이고, 표현식 단위라 더 잘게 쪼개 캐시한다. <a href="./react-compiler.md">react-compiler.md</a> 의 3번 이점(&quot;참조 안정성의 자동 확보&quot;)이 외부 라이브러리 prop 경계에서 발현되는 지점이다.</p>
<br>

<h3 id="2-자동이-안-되는-경우--여전히-수동">2) 자동이 안 되는 경우 — 여전히 수동</h3>
<p>다음 상황에서는 RC가 손대지 못한다.</p>
<ul>
<li><strong>부모 컴포넌트가 RC 분석에서 skip</strong> — Rules of React 위반, 비표준 hook 패턴 등으로 컴파일러가 포기한 경우. DevTools의 <code>Memo ✨</code> 배지가 없는 컴포넌트가 여기 해당.</li>
<li><strong>deps 체인이 끊긴 경우</strong> — 부모가 받은 prop·context 값이 이미 매 렌더 새 참조면, 그 값을 의존성으로 잡는 RC 캐시도 매번 무효화된다. 위쪽 컴포넌트 중 하나가 skip이면 그 아래로 전염된다.</li>
</ul>
<p>이 경우에는 기존처럼 <code>useMemo</code> / <code>useCallback</code>을 명시적으로 깔아야 한다.</p>
<br>

<h3 id="3-모듈-스코프-상수는-여전히-best">3) 모듈 스코프 상수는 여전히 best</h3>
<p>RC 유무와 무관하게 가장 안전한 방법은 <strong>컴포넌트 밖으로 상수를 들어내는 것</strong>이다.</p>
<pre><code class="language-tsx">const SNAP_POINTS = [&#39;25%&#39;, &#39;50%&#39;] as const; // 모듈 최상단

const Sheet = () =&gt; &lt;BottomSheet snapPoints={SNAP_POINTS} {...rest} /&gt;;</code></pre>
<p>처음부터 한 번만 만들어지는 참조이므로 RC가 분석 가능/불가능 어느 쪽이든 항상 안정적이다. 컴포넌트 prop·state에 의존하지 않는 값(고정 snapPoints, 정적 config 객체 등)이라면 이 형태가 최선.</p>
<br>

<h3 id="4-정리">4) 정리</h3>
<table>
<thead>
<tr>
<th>케이스</th>
<th>RC ON 시 처리</th>
</tr>
</thead>
<tbody><tr>
<td>부모 컴포넌트가 분석 통과 + 인라인 객체·함수·배열</td>
<td><strong>자동</strong> 메모이제이션</td>
</tr>
<tr>
<td>부모가 분석 skip (<code>Memo ✨</code> 없음)</td>
<td>수동 <code>useMemo</code>/<code>useCallback</code> 필요</td>
</tr>
<tr>
<td>부모는 통과했지만 상위 deps가 불안정</td>
<td>위쪽부터 끊긴 지점을 수동으로 메우거나, 모듈 스코프로 들어내기</td>
</tr>
<tr>
<td>컴포넌트 prop·state에 의존하지 않는 정적 값</td>
<td>모듈 스코프 상수가 RC 유무 무관 best</td>
</tr>
</tbody></table>
<p>&quot;RC 켰으니 외부 라이브러리 prop은 무조건 자동&quot; 이 아니라, <strong><code>Memo ✨</code> 배지로 적용 여부를 확인하는 절차</strong>가 새로운 검증 의무로 들어온다.</p>
<br>
<br>

<h2 id="3-가상화-리스트-renderitem--아이템-memo-와-rc">3. 가상화 리스트 (<code>renderItem</code> / 아이템 <code>memo()</code>) 와 RC</h2>
<p><code>FlatList</code> / <code>FlashList</code> / <code>LegendList</code> 같은 가상화 리스트는 전통적으로 두 가지 메모이제이션이 필수였다.</p>
<ol>
<li><strong><code>renderItem</code> 을 <code>useCallback</code> 으로 감싸기</strong> — 리스트 내부 effect 재실행과 인라인 함수의 새 참조 문제 방지</li>
<li><strong>리스트 아이템 컴포넌트에 <code>memo()</code> 적용</strong> — 동일 데이터 셀의 재렌더 방지</li>
</ol>
<p>RC를 켜면 둘 다 자동화된다. 다만 결이 약간 달라 별도로 짚는다.</p>
<blockquote>
<p>참고: 라이브러리 버전에 따라 권장 패턴이 다를 수 있다. 특히 FlashList는 v2에서 내부 최적화 정책과 권장 props가 일부 바뀌었으니, 세부 권장은 사용하는 라이브러리 버전의 공식 문서를 함께 확인한다. 아래 설명은 &quot;참조 안정성이 왜 중요한가&quot;라는 일반 원리에 대한 것이다.</p>
</blockquote>
<br>

<h3 id="1-renderitem-의-usecallback--2번과-동일한-결">1) <code>renderItem</code> 의 <code>useCallback</code> — 2번과 동일한 결</h3>
<p>2번에서 본 &quot;외부 라이브러리에 prop으로 넘기는 인라인 함수&quot; 케이스와 정확히 같다. 부모가 분석 통과한 경우 RC가 표현식 단위로 캐시한다.</p>
<pre><code class="language-tsx">// RC ON, 부모가 분석 통과한 경우 — useCallback 불필요
&lt;FlashList
  data={items}
  renderItem={({ item }) =&gt; &lt;ItemCard item={item} onPress={handlePress} /&gt;}
  keyExtractor={keyExtractor}
/&gt;</code></pre>
<p><code>renderItem</code> 인라인 화살표가 자동 캐시되므로 리스트 내부 effect 의존성이 안정된다.</p>
<br>

<h3 id="2-리스트-아이템의-memo--공식-문서가-직접-답을-준다">2) 리스트 아이템의 <code>memo()</code> — 공식 문서가 직접 답을 준다</h3>
<p><code>React.memo</code> 공식 문서의 &quot;Do I still need React.memo if I use React Compiler?&quot; 섹션이 명시한다.</p>
<blockquote>
<p>&quot;When you enable React Compiler, you typically don&#39;t need <code>React.memo</code> anymore. The compiler automatically optimizes component re-rendering for you.&quot;</p>
</blockquote>
<p>그리고 한 발 더 들어가서:</p>
<blockquote>
<p>&quot;The compiler&#39;s optimization is actually more comprehensive than <code>React.memo</code>. It also memoizes intermediate values and expensive computations within your components, similar to combining <code>React.memo</code> with <code>useMemo</code> throughout your component tree.&quot;</p>
</blockquote>
<p>즉 RC의 메모이제이션은 <strong><code>React.memo</code> + <code>useMemo</code>를 컴포넌트 트리 전체에 깐 것보다 포괄적</strong>이라는 것이 공식 입장이다. 가상화 리스트의 셀 재사용 환경에서도:</p>
<ul>
<li>셀이 같은 데이터로 유지되는 경우 → RC 캐시 hit → JSX 재사용</li>
<li>셀이 새 데이터로 재활용되는 경우 → 새 props로 캐시 miss → 정상 렌더 (memo도 동일)</li>
</ul>
<p>결론: <strong>분석 통과한 아이템 컴포넌트라면 <code>memo()</code> 래퍼는 redundant.</strong></p>
<br>

<h3 id="3-그럼-다-빼도-되는가--공식-권장은-기존-코드는-두고-신규-코드만-rc에-맡겨라">3) 그럼 다 빼도 되는가 — 공식 권장은 &quot;기존 코드는 두고, 신규 코드만 RC에 맡겨라&quot;</h3>
<p>이 단서는 React.memo 페이지가 아니라 <strong><a href="https://react.dev/blog/2025/10/07/react-compiler-1">React Compiler 1.0 발표 글(react.dev/blog)</a></strong>에 나온다. 원문은 신규 코드 → 기존 코드 순서로 다음과 같이 권장한다.</p>
<blockquote>
<p>&quot;For new code, we recommend relying on the compiler for memoization and using <code>useMemo</code>/<code>useCallback</code> where needed to achieve precise control. For existing code, we recommend either leaving existing memoization in place (removing it can change compilation output) or carefully testing before removing the memoization.&quot;</p>
</blockquote>
<p>핵심은 <strong>&quot;제거가 컴파일 출력을 바꿀 수 있다&quot;</strong> 는 공식 경고 그대로. 정리 단계에서도 회귀 테스트 없이 일괄 제거하지 않는다.</p>
<br>

<h3 id="4-memo-가-여전히-escape-hatch로-유용한-경우">4) <code>memo()</code> 가 여전히 escape hatch로 유용한 경우</h3>
<p>공식 문서가 escape hatch로서의 가치를 부정하지 않는다. 가상화 리스트 맥락에서 유지·추가가 정당화되는 케이스:</p>
<ul>
<li><strong>부모가 RC 분석 skip</strong> — 부모가 안정 참조를 보장하지 못하면 자식의 RC 캐시도 매번 무효화된다. <code>memo()</code>로 명시적 prop 비교 게이트를 만들어 두면 그 무효화가 그 자식에서 멈춘다.</li>
<li><strong>명시적 제어가 필요한 고비용 셀</strong> — 차트·지도 마커·복잡한 이미지 비교 등. RC의 dep 추적이 보수적이라 불필요한 cache miss가 생기는 경우 <code>memo()</code> + <code>areEqual</code>로 비교 함수를 명시.</li>
<li><strong>외부 라이브러리 셀 컴포넌트</strong> — 라이브러리가 제공하는 컴포넌트(내부 RC 미적용)는 외부에서 한 번 <code>memo()</code>로 감싸 두는 편이 안전.</li>
</ul>
<br>

<h3 id="5-keyextractor--overrideitemtype-은-별개">5) <code>keyExtractor</code> / <code>overrideItemType</code> 은 별개</h3>
<p>이 둘은 RC 도입과 무관하다. 컴포넌트 외부 위치인 <strong>모듈 스코프 상수</strong>로 빼는 게 좋다. RC가 메모이제이션할 대상이 아니라 <em>애초에 안정 참조</em>가 보장되는 형태로 작성하는 항목이다.</p>
<pre><code class="language-tsx">// ✅ RC 유무와 무관하게 항상 이렇게
const keyExtractor = (item: Item) =&gt; item.id;
const getOverrideItemType = (item: FeedItem) =&gt; item.type;</code></pre>
<br>
<br>

<hr>
<br>

<h3 id="한-줄-요약">한 줄 요약</h3>
<p><a href="./react-compiler.md">react-compiler.md</a> 의 RC는 &quot;메모이제이션을 언제 거느냐&quot;를 자동화한다. <strong>Reanimated worklet 재등록·외부 라이브러리 인라인 props·가상화 리스트의 <code>renderItem</code> 과 아이템 <code>memo()</code></strong> 가 모두 본질적으로 <strong>참조 안정성</strong> 문제이고, 컴파일러는 (분석 가능한 컴포넌트에 한해) 이 세 영역 모두를 자동으로 처리한다 — <code>React.memo</code> 공식 문서가 &quot;RC를 켜면 <code>React.memo</code>가 보통 필요 없고, 그 최적화는 <code>memo</code> + <code>useMemo</code> 조합보다 더 포괄적&quot;이라고 명시한다. 다만 Reanimated 쪽은 <strong><code>get()</code>/<code>set()</code> API 사용</strong>과 <strong>렌더 중 접근 금지</strong> 규칙을 지켜야 하고, <strong>기존 코드의 <code>memo</code>/<code>useCallback</code>/<code>useMemo</code> 제거는 회귀 테스트 후 점진적으로</strong>(RC 1.0 발표 글의 권장), <strong><code>Memo ✨</code> 배지로 분석 통과 여부 확인</strong>이 새 검증 의무로 들어온다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native 에서의 메모이제이션 (2.  React Compiler 의 자동 메모이제이션)]]></title>
            <link>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C%EC%9D%98-%EB%A9%94%EB%AA%A8%EC%9D%B4%EC%A0%9C%EC%9D%B4%EC%85%98-2.-React-Compiler-%EC%9D%98-%EC%9E%90%EB%8F%99-%EB%A9%94%EB%AA%A8%EC%9D%B4%EC%A0%9C%EC%9D%B4%EC%85%98</link>
            <guid>https://velog.io/@diso592/React-Native-%EC%97%90%EC%84%9C%EC%9D%98-%EB%A9%94%EB%AA%A8%EC%9D%B4%EC%A0%9C%EC%9D%B4%EC%85%98-2.-React-Compiler-%EC%9D%98-%EC%9E%90%EB%8F%99-%EB%A9%94%EB%AA%A8%EC%9D%B4%EC%A0%9C%EC%9D%B4%EC%85%98</guid>
            <pubDate>Sun, 02 Aug 2026 10:27:39 GMT</pubDate>
            <description><![CDATA[<h2 id="1-react-compiler란">1. React Compiler란?</h2>
<p>React Compiler(이하 RC)는 <strong>빌드 타임에 코드를 분석해 메모이제이션을 자동으로 삽입하는 컴파일러</strong>다. 지금까지 개발자가 손으로 깔던 <code>React.memo</code> / <code>useMemo</code> / <code>useCallback</code>을, 컴파일러가 데이터 흐름과 가변성(mutability)을 분석해 적절한 위치에 자동으로 넣어준다. 런타임 라이브러리가 아니라 <strong>빌드 단계의 변환 도구</strong>이고, Rules of React를 따르는 코드라면 코드를 다시 쓸 필요 없이 적용된다.</p>
<p>2025년 10월 7일 <strong>1.0 정식 버전</strong>이 출시되었고, React와 React Native 양쪽을 지원한다.</p>
<br>

<h3 id="1-rc가-실제로-절약하는-것--js-스레드의-재실행·재조정">1) RC가 실제로 절약하는 것 — JS 스레드의 재실행·재조정</h3>
<p>가장 먼저 바로잡아야 할 오해는 &quot;RC가 네이티브 렌더링이나 화면 갱신을 줄여준다&quot;는 그림이다. 아니다. RC가 메모이제이션으로 절약하는 대상은 <strong>컴포넌트 함수의 재실행과 그 서브트리의 재조정(reconciliation)</strong> 이며, 둘 다 순수하게 <strong>JS 스레드</strong>에서 일어나는 작업이다.</p>
<p>React의 동작을 단계로 나누면 분명해진다. 상태·props 변화로 컴포넌트 함수가 재실행되고(렌더 단계), 새 트리와 이전 트리를 diff해 변경분을 추리고(재조정), 변경분만 호스트에 반영한다(커밋 단계). 출력이 이전과 같으면 재조정 단계에서 &quot;변경 없음&quot;으로 걸러져 커밋(네이티브 갱신)은 어차피 일어나지 않는다 — 메모이제이션 여부와 무관하게.</p>
<p>따라서 RC가 막아주는 것은 &quot;출력이 같은데도 굳이 컴포넌트를 재실행하고 그 자식들을 재조정하는&quot; JS 스레드 낭비다. 이 구분이 RC를 이해하는 핵심이다.</p>
<br>

<h3 id="2-어떻게-동작하는가--표현식-단위-캐시">2) 어떻게 동작하는가 — 표현식 단위 캐시</h3>
<p>RC는 컴포넌트를 컴파일하면서, <strong>렌더 중 생성되는 각 값·함수가 어떤 입력에 의존하는지</strong>를 표현식 단위로 추적한다. 그리고 값마다 <strong>독립된 캐시 슬롯</strong>과 <strong>독립된 의존성 비교</strong>를 배치한다. (산출물에 등장하는 <code>_c</code> 헬퍼는 내부 구현 디테일이며 공식 API가 아님)</p>
<pre><code class="language-tsx">// 원본
function Component({ x, y }) {
  const a = expensiveA(x); // x에만 의존
  const b = expensiveB(y); // y에만 의존
  return &lt;Child a={a} b={b} /&gt;;
}</code></pre>
<pre><code class="language-tsx">// RC가 생성하는 코드 (개념도)
function Component({ x, y }) {
  const $ = _c(5); // 캐시 슬롯 배열

  let a;
  if ($[0] !== x) {
    // a는 x가 바뀔 때만 재계산
    a = expensiveA(x);
    $[0] = x;
    $[1] = a;
  } else {
    a = $[1];
  }

  let b;
  if ($[2] !== y) {
    // b는 y가 바뀔 때만 재계산 — a와 독립
    b = expensiveB(y);
    $[2] = y;
    $[3] = b;
  } else {
    b = $[3];
  }

  // &lt;Child a={a} b={b} /&gt; JSX도 a·b가 그대로면 이전 엘리먼트를 재사용
  // ...
}</code></pre>
<p><code>x</code>만 바뀌면 <code>a</code>만 다시 계산하고 <code>b</code>는 캐시 값을 그대로 쓴다. JSX 엘리먼트 자체도 같은 방식으로 캐시되어, props가 그대로면 이전에 만든 엘리먼트를 재사용한다 — 이것이 <code>React.memo</code>가 하던 &quot;props 안 바뀌면 자식 리렌더 스킵&quot;과 동일한 효과다.</p>
<br>

<h2 id="2-수동-메모이제이션보다-정밀한-이유">2. 수동 메모이제이션보다 정밀한 이유</h2>
<p>수동 <code>useMemo</code>는 <strong>블록 단위</strong>다. 콜백이 반환하는 값을 통째로 캐시하고, deps 중 하나만 바뀌어도 콜백 전체가 다시 돈다. 위 예시를 <code>useMemo</code> 하나로 묶으면 <code>x</code>만 바뀌어도 <code>expensiveB(y)</code>까지 재실행된다.</p>
<p>RC는 이를 <strong>표현식 단위</strong>로 쪼개므로, 같은 정밀도를 손으로 내려면 값마다 <code>useMemo</code>를 따로 걸어야 하며 값이 늘수록 비현실적이다. &quot;표현식 단위로 캐시한다&quot;는 말의 뜻은 각 값이 자기 의존성에만 반응하는 것이며 값마다 독립된 캐시 슬롯과 독립된 의존성 비교를 둔다는 것이다.</p>
<p>게다가 RC는 <strong>조건부(분기 안에서의) 메모이제이션</strong>처럼 수동으로는 표현하기 어려운 최적화도 한다. 공식 문서가 &quot;RC는 <code>useMemo</code>/<code>useCallback</code>/<code>React.memo</code>보다 더 정밀하고 granular하게 메모이제이션한다&quot;고 말하는 근거가 이것이다.</p>
<br>

<h2 id="3-rules-of-react에-의존">3. Rules of React에 의존</h2>
<p>RC가 안전하게 동작하는 전제는 <strong>코드가 Rules of React를 지키는 것</strong>이다(렌더 순수성, 렌더 중 props/state/ref/전역 변이 금지, 훅 규칙 등). 컴파일러는 이 규칙 위반을 정적으로 감지하면 <strong>그 컴포넌트만 최적화에서 skip 하고 나머지는 계속 컴파일</strong>한다.</p>
<p>여기서 중요한 성질이 있는데, 바로 이 skip은 <strong>에러가 아니라 조용한 안전 후퇴</strong>다. 기본 동작은 치명적이지 않은 위반을 빌드를 깨지 않고 skip하는 것이며, <strong>모든 위반을 무중단 skip 처리하려면 <code>panicThreshold: &#39;none&#39;</code>을 명시</strong>(공식 docs의 프로덕션 권장 설정)한다. 어쨌든 어떤 컴포넌트가 최적화에서 빠졌는지 명시적 신호 없이 지나갈 수 있으므로, 두 가지 도구가 책임을 나눠 갖는다.</p>
<ul>
<li><strong><code>eslint-plugin-react-hooks</code> recommended/<code>recommended-latest</code> 프리셋</strong> — RC 1.0부터 별도 <code>eslint-plugin-react-compiler</code>는 deprecated이고, 검증 규칙이 이쪽으로 통합되었다. 역할은 <strong>컴파일러가 못 잡은 Rules of React 위반을 빌드 전에 표면화</strong>하는 것이다(RC를 켜지 않아도 켤 수 있다).</li>
<li><strong><code>✨</code> 배지</strong> — React(Native) DevTools의 Component Inspector에서 실제로 컴파일된 컴포넌트에 ✨ 배지가 붙는다. <strong>적용 여부 확인은 이쪽 책임</strong>이다 — 배지가 없으면 skip된 것.</li>
</ul>
<p>즉 &quot;RC를 켰으니 무조건 다 최적화된다&quot;가 아니라, <strong>린트로는 사전 위반 검출, DevTools 배지로는 적용 여부 확인</strong>이라는 절차가 따라붙는다. RC가 안전하게 적용하기 위해 지켜야 하는 rules는 다다음 글에서 살펴볼 예정이다.</p>
<br>

<h2 id="4-react-19--react-native와의-관계">4. React 19 / React Native와의 관계</h2>
<p>RC는 React 19를 네이티브로 지원하며, <strong>RN은 0.78부터 React 19 런타임을 채택</strong>했다(RN 자체 버전은 여전히 <code>0.x</code>다). React 17/18에서도 동작시킬 수 있는데, 그땐 두 가지가 추가로 필요하다.</p>
<ul>
<li><code>react-compiler-runtime</code> 패키지 설치</li>
<li>compiler config에 <code>target: &#39;17&#39; | &#39;18&#39;</code> 명시 (기본 타깃은 <code>&#39;19&#39;</code>)</li>
</ul>
<p>RN에서는 Metro가 Babel을 쓰므로 <code>babel-plugin-react-compiler</code>로 적용한다. <strong>이 플러그인은 다른 Babel 플러그인들보다 먼저 실행되어야 한다</strong> — <code>babel.config.js</code>의 <code>plugins</code> 배열에서 <strong>반드시 첫 번째 위치</strong>에 둬야 변환이 깨지지 않는다(공식 docs에서 강조하는 제약).</p>
<p>New Architecture로 네이티브 커밋 비용이 낮아졌어도 <strong>컴포넌트 함수 실행과 재조정은 여전히 JS 스레드에 남으므로</strong>, RC의 역할은 아키텍처 전환과 무관하게 유지된다.</p>
<br>

<h2 id="5-rc가-바꾸지-않는-것">5. RC가 바꾸지 않는 것</h2>
<p>마지막으로, RC가 무엇을 <strong>안</strong> 바꾸는지가 그 정체를 가장 잘 드러낸다. RC는 메모이제이션의 <strong>비용·이득 모델 자체를 바꾸지 않는다.</strong> 절약되는 대상은 여전히 JS 스레드의 재실행·재조정이고, 그 가치가 RN의 단일 JS 스레드 구조에서 더 크다는 점도 그대로다. RC가 바꾼 것은 오직 <strong>적용 방식</strong>이다 — 사람이 어디에 <code>memo</code>를 걸지 판단하던 일을, 컴파일러가 더 정밀하게 자동으로 한다.</p>
<br>

<hr>
<br>

<h3 id="정리">정리</h3>
<p>React Compiler는 빌드 타임에 데이터 흐름을 분석해 <strong>표현식 단위로 메모이제이션을 자동 삽입</strong>하는 컴파일러다. 절약하는 대상은 네이티브 렌더링이 아니라 <strong>JS 스레드의 컴포넌트 재실행·재조정</strong>이고, 수동 <code>useMemo</code>보다 잘게·조건부로 최적화한다. </p>
<p>동작 전제는 Rules of React 준수이며, 위반 컴포넌트는 빌드를 깨지 않고 조용히 skip한다. 그래서 <strong><code>eslint-plugin-react-hooks</code>로 사전 위반을 검출</strong>하고 <strong>DevTools의 <code>✨</code> 배지로 실제 적용 여부를 확인</strong>하는 절차가 따라온다. RN에 도입할 땐 <code>babel-plugin-react-compiler</code>를 <strong>Babel <code>plugins</code> 배열의 첫 번째</strong>로 두고, React 17/18에서는 <code>react-compiler-runtime</code> + <code>target</code> 옵션을 명시한다. &quot;이론은 동일하고 적용만 자동화된다&quot;가 핵심이다.</p>
]]></description>
        </item>
    </channel>
</rss>