<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Grace Notes</title>
    <link>https://meercat.tistory.com/</link>
    <description>사용자에게 보이는 기능부터 빌드&amp;middot;배포 환경까지.
제품을 만들며 경험한 기술적 의사결정과 문제 해결 과정을 기록합니다.</description>
    <language>ko</language>
    <pubDate>Thu, 18 Jun 2026 06:55:32 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Grace Noh</managingEditor>
    <image>
      <title>Grace Notes</title>
      <url>https://tistory1.daumcdn.net/tistory/5429921/attach/f791799df0f04e63b1bd77dde1b82bc1</url>
      <link>https://meercat.tistory.com</link>
    </image>
    <item>
      <title>React Navigation 심화 &amp;mdash; 컴포넌트 트리 밖에서 안전하게 화면 전환하기</title>
      <link>https://meercat.tistory.com/513</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Navigation을 쓰다 보면 useNavigation()이 닿지 않는 곳에서 화면을 옮겨야 할 때가 온다. 딥링크 핸들러, 푸시 알림 콜백, 외부 SDK 콜백, 전역 인터셉터 같은 &quot;컴포넌트 트리 바깥&quot;이다. navigationRef를 쓰면 되지만, 막상 해보면 빈 화면&amp;middot;흰 화면 같은 타이밍 버그가 줄줄이 튀어나온다. 이 글은 트리 밖 화면 전환을 &lt;b&gt;실제로 안정적으로&lt;/b&gt; 만들기까지 밟은 과정과, 그 사이에 만난 버그들의 정리다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-heading=&quot;문제 &amp;mdash; &amp;#96;useNavigation&amp;#96;이 닿지 않는 곳&quot; data-ke-size=&quot;size26&quot;&gt;문제 &amp;mdash; useNavigation이 닿지 않는 곳&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화면 전환은 보통 컴포넌트 안에서 useNavigation() 훅으로 한다. 그런데 React 컴포넌트가 아닌 곳에서 전환을 트리거해야 하는 경우가 의외로 많다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;딥링크 핸들러&lt;/b&gt; &amp;mdash; linking.getInitialURL / subscribe는 모듈 스코프 함수다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;푸시 알림 탭&lt;/b&gt; &amp;mdash; 백그라운드 핸들러는 트리 밖에서 실행된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;외부 SDK / 전역 콜백&lt;/b&gt; &amp;mdash; 결제, 분석, 소켓 이벤트 등.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;axios 인터셉터&lt;/b&gt; &amp;mdash; 401 응답에서 로그인 화면으로 보내기.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 곳에선 훅을 쓸 수 없다. 공식 해법은 NavigationContainer에 &lt;b&gt;ref를 달아 전역에서 접근&lt;/b&gt;하는 것이다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// App.tsx
import { createNavigationContainerRef } from '@react-navigation/native';
export const navigationRef = createNavigationContainerRef();

&amp;lt;NavigationContainer ref={navigationRef}&amp;gt;
  {/* ... */}
&amp;lt;/NavigationContainer&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;// 트리 밖 어디서든
import { navigationRef } from './App';

if (navigationRef.isReady()) {
  navigationRef.navigate('SomeScreen');
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지는 문서에도 나온다. 문제는 &lt;b&gt;&quot;단순 navigate가 아니라 네비게이터 그룹을 통째로 바꿔야 할 때&quot;&lt;/b&gt;, 그리고 &lt;b&gt;&quot;트리 밖 ref와 React 생명주기가 어긋날 때&lt;/b&gt;&quot; 시작된다.&lt;/p&gt;
&lt;h2 data-heading=&quot;1. 즉시 navigate가 불가능한 경우 &amp;mdash; 플래그 + watcher 패턴&quot; data-ke-size=&quot;size26&quot;&gt;1. 즉시 navigate가 불가능한 경우 &amp;mdash; 플래그 + watcher 패턴&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 앱은 user.role에 따라 루트 네비게이터 그룹이 통째로 갈린다. 일반 사용자 그룹과 별도 그룹(예: 관리자 그룹)이 조건부로 렌더된다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;function RootNavigator() {
  const { data: user } = useGetUser();
  return (
    &amp;lt;Stack.Navigator&amp;gt;
      {user?.role === 'ADMIN' ? (
        &amp;lt;Stack.Screen name=&quot;AdminNav&quot; component={AdminNavigator} /&amp;gt;
      ) : (
        &amp;lt;Stack.Screen name=&quot;Home&quot; component={Home} /&amp;gt;
      )}
    &amp;lt;/Stack.Navigator&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 트리 밖에서 &quot;지금 관리자 그룹으로 보내!&quot;를 호출하는 순간, &lt;b&gt;AdminNav는 아직 트리에 존재하지 않는다.&lt;/b&gt; 그룹 전환은 user.role이 바뀌고 &amp;rarr; 리렌더가 일어나야 비로소 마운트되기 때문이다. 이 시점에 바로 navigate('AdminNav')를 하면 &quot;해당 라우트가 없다&quot;며 실패한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;b&gt;직접 이동 대신 의도(플래그)만 남기고, 트리 안의 watcher가 적절한 타이밍에 소비&lt;/b&gt;하게 했다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// pendingEntry.ts &amp;mdash; 모듈 스코프 플래그
let pending = false;
export const setPending = (v: boolean) =&amp;gt; { pending = v; };
export const consumePending = () =&amp;gt; {
  const v = pending;
  pending = false;   // 1회성 소비
  return v;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;// 트리 밖: 상태를 바꾸고 의도만 남긴다
function handleExternalTrigger() {
  // ...role 을 바꾸는 작업(세션 갱신 등)...
  setPending(true);   // 직접 navigate 하지 않는다
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;// 트리 안 watcher: 그룹이 마운트된 &quot;다음&quot; 프레임에 소비
useEffect(() =&amp;gt; {
  if (user === undefined) return;          // 역할 미확정이면 대기
  if (user.role !== 'ADMIN') return;       // 아직 그룹 미마운트면 대기
  if (!consumePending()) return;

  if (navigationRef.isReady()) {
    navigationRef.resetRoot({ index: 0, routes: [{ name: 'AdminNav' }] });
  } else {
    pendingResetRef.current = true;         // 준비 전이면 onReady 로 미룬다
  }
}, [user]);
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심:&lt;/b&gt; 조건부 렌더링과 즉시 navigate는 타이밍이 어긋난다. &quot;상태를 바꿔 그룹을 마운트시킨 뒤, 마운트된 다음 프레임에 이동&quot;이라는 2단계로 끊으면 레이스가 사라진다. resetRoot를 쓴 이유는 단순 이동이 아니라 &lt;b&gt;스택을 갈아끼워 뒤로가기로 돌아오지 못하게&lt;/b&gt; 하기 위함이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-heading=&quot;2. user가 바뀌지 않는 진입 경로 &amp;mdash; listener 보강&quot; data-ke-size=&quot;size26&quot;&gt;2. user가 바뀌지 않는 진입 경로 &amp;mdash; listener 보강&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 watcher는 [user] 의존성으로 돈다. 즉 &lt;b&gt;user 값이 바뀔 때만&lt;/b&gt; 재실행된다. 그런데 진입 경로에 따라선 user가 아예 바뀌지 않는 경우가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 이미 비로그인 상태(user가 이미 그 값)인 화면에 머무는 중에 외부 트리거가 들어오면, user는 그대로라 effect가 다시 돌지 않는다. 플래그만 세워지고 아무도 소비하지 않아 &lt;b&gt;화면이 고착&lt;/b&gt;된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결은 플래그 set 자체를 &lt;b&gt;구독 가능한 이벤트로&lt;/b&gt; 만드는 것이다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// pendingEntry.ts
let listener: (() =&amp;gt; void) | null = null;

export const setPending = (v: boolean) =&amp;gt; {
  pending = v;
  if (v) listener?.();   // set(true) 시 즉시 통지 &amp;rarr; user 변화에 의존하지 않음
};
export const subscribePending = (cb: () =&amp;gt; void) =&amp;gt; {
  listener = cb;
  return () =&amp;gt; { if (listener === cb) listener = null; };
};
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;// 트리 안: user 변화 외에 &quot;플래그 set&quot; 도 직접 구독
useEffect(() =&amp;gt; subscribePending(processPending), []);
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진입 경로가 여러 개면 &lt;b&gt;신호도 여러 개&lt;/b&gt; 필요하다. &quot;상태 변화에 묻어가는 신호&quot;([user] effect)와 &quot;상태와 무관한 직접 신호&quot;(listener)를 함께 둬야 모든 경로가 빠짐없이 처리된다. watcher 하나로 다 되겠지 하면 꼭 한 경로가 샌다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-heading=&quot;3. 가장 지독한 버그 &amp;mdash; Activity 재생성과 죽은 ref&quot; data-ke-size=&quot;size26&quot;&gt;3. 가장 지독한 버그 &amp;mdash; Activity 재생성과 죽은 ref&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서부터가 진짜 함정이다. HOC나 트리 밖 코드에서 resetRoot를 쓰려고, onReady에서 컨테이너의 live ref를 &lt;b&gt;모듈 스코프 브릿지&lt;/b&gt;에 주입해 뒀다고 하자.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;&amp;lt;NavigationContainer
  ref={navigationRef}
  onReady={() =&amp;gt; setNavigationRef(navigationRef.current)}  // 브릿지에 주입
&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 Android의 &lt;b&gt;&quot;활동을 유지하지 않음&quot;&lt;/b&gt; 설정이나 메모리 회수로 &lt;b&gt;Activity가 재생성&lt;/b&gt;될 때 터진다. JS 프로세스(컨텍스트)는 살아 있어서 모듈 스코프의 navigationRef가 &lt;b&gt;이미 파괴된 컨테이너를 가리킨 채&lt;/b&gt; 남는다. 리마운트된 화면이 그 ref를 &quot;준비됨(non-null)&quot;으로 오인하고 죽은 컨테이너에 resetRoot를 흘려보내면 &amp;mdash; &lt;b&gt;아무 일도 안 일어나고 빈 화면&lt;/b&gt;이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결은 의외로 단순하다. &lt;b&gt;컨테이너가 언마운트될 때 브릿지를 비운다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;arcade&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  return () =&amp;gt; setNavigationRef(null);   // 언마운트 시 죽은 ref 정리
}, []);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 리마운트된 코드가 ref를 다시 null에서 출발해(콜드 스타트와 동일) onReady를 정상적으로 재구독한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;교훈:&lt;/b&gt; 모듈 스코프(React 밖)에 둔 ref는 컴포넌트 생명주기와 자동으로 동기화되지 않는다. &lt;b&gt;마운트 시 주입 / 언마운트 시 정리&lt;/b&gt;를 한 쌍으로 직접 관리해야 한다. 안 그러면 &quot;살아있어 보이는 시체 ref&quot;에 당한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-heading=&quot;4. Fast Refresh가 삼킨 per-screen reset&quot; data-ke-size=&quot;size26&quot;&gt;4. Fast Refresh가 삼킨 per-screen reset&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 중에만 재현되던 흰 화면도 있었다. 어떤 화면을 감싼 가드 HOC에서, 특정 조건이면 다른 그룹으로 보내려고 &lt;b&gt;per-screen&lt;/b&gt; navigation.dispatch(reset)을 썼다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// ❌ Fast Refresh 상황에서 이 dispatch 가 무시되는 경우가 있었다
navigation.dispatch(CommonActions.reset({
  index: 0, routes: [{ name: 'OtherNav' }],
}));
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Fast Refresh로 컴포넌트가 갈아끼워지는 타이밍에 이 per-screen dispatch가 &lt;b&gt;삼켜져서&lt;/b&gt;, 리다이렉트 대상이 원래 화면에 그대로 머무는 흰 화면이 났다. 해결은 per-screen navigation 대신 &lt;b&gt;항상 동작하는 컨테이너 live ref의 resetRoot&lt;/b&gt;로 바꾸는 것이었다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// ✅ 컨테이너 ref 의 resetRoot 는 항상 동작
const ref = getNavigationRef();
if (ref) {
  ref.resetRoot({ index: 0, routes: [{ name: 'OtherNav' }] });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로, 가드의 effect는 부모 NavigationContainer의 onReady보다 &lt;b&gt;먼저&lt;/b&gt; 커밋된다(자식 effect가 부모보다 먼저 실행). 그래서 첫 렌더에 ref가 아직 null일 수 있는데, 이걸 그냥 두면 리다이렉트가 안 일어나 고착된다. &lt;b&gt;ref 주입 시점을 구독&lt;/b&gt;해 준비되면 effect를 재실행하게 했다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;const [isNavReady, setIsNavReady] = useState(() =&amp;gt; getNavigationRef() != null);

useEffect(() =&amp;gt; {
  if (isNavReady) return;
  const unsub = subscribeNavigationRef(() =&amp;gt; setIsNavReady(true));
  // 초기 useState 평가와 구독 등록 사이 갭에서 놓친 주입을 보정
  if (getNavigationRef() != null) setIsNavReady(true);
  return unsub;
}, [isNavReady]);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-heading=&quot;5. 보너스 &amp;mdash; 딥링크 경로 판별은 부분 일치로 하지 마라&quot; data-ke-size=&quot;size26&quot;&gt;5. 보너스 &amp;mdash; 딥링크 경로 판별은 부분 일치로 하지 마라&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트리 밖 화면 전환은 딥링크에서 자주 일어나는데, 여기서도 작은 함정을 하나 밟았다. 특정 경로(admin 같은)를 판별하려고 처음엔 endsWith를 썼다.&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;// ❌ superadmin, foo/admin 까지 오탐
if (url.endsWith('admin')) { ... }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;superadmin이나 something/admin 같은 URL이 전부 매칭됐다. prefix를 떼어낸 &lt;b&gt;나머지 경로가 정확히 일치&lt;/b&gt;하는지로 바꿔야 한다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// ✅ prefix 제거 후 완전 일치
const PREFIXES = ['myapp://', 'https://myapp.example.com'];
const getPathAfterPrefix = (url: string) =&amp;gt; {
  const prefix = PREFIXES.find((p) =&amp;gt; url.startsWith(p));
  const rest = prefix ? url.slice(prefix.length) : url;
  return rest.split('#')[0].split('?')[0].replace(/^\/+|\/+$/g, '');
};
const isAdminPath = (url: string) =&amp;gt; getPathAfterPrefix(url) === 'admin';
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;판별에 쓰는 prefix 배열은 linking.prefixes와 &lt;b&gt;같은 상수를 공유&lt;/b&gt;하게 해서 둘이 어긋나지 않도록 했다.&lt;/p&gt;
&lt;h2 data-heading=&quot;정리&quot; data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트리 밖 화면 전환의 어려움은 결국 &lt;b&gt;&quot;React 밖에서 시작해 React 안에서 끝나는 흐름&quot;&lt;/b&gt;의 타이밍을 맞추는 일이다. 정리하면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;즉시 navigate가 안 되면 플래그+watcher로 끊어라.&lt;/b&gt; 조건부 렌더로 마운트될 그룹은, 마운트된 다음 프레임에 이동시킨다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;진입 경로가 여럿이면 신호도 여럿이다.&lt;/b&gt; 상태 변화에 묻어가는 신호([user] effect)와 직접 신호(listener)를 함께 둬라.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;모듈 스코프 ref는 생명주기와 어긋난다.&lt;/b&gt; 마운트 시 주입 / 언마운트 시 정리를 한 쌍으로 관리하라. Activity 재생성의 &quot;좀비 ref&quot;를 조심.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;그룹 전환엔 per-screen reset보다 컨테이너 resetRoot.&lt;/b&gt; Fast Refresh에서 per-screen dispatch가 삼켜질 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ref 준비 시점을 구독하라.&lt;/b&gt; 자식 effect는 부모 onReady보다 먼저 커밋된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;딥링크 경로는 부분 일치로 판별하지 마라.&lt;/b&gt; prefix 제거 후 완전 일치.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴들은 처음부터 설계로 떠오르지 않는다. 빈 화면&amp;middot;흰 화면 버그를 하나씩 잡아가며 &quot;아, 여기서 React 밖과 안이 어긋나는구나&quot;를 깨달은 흔적에 가깝다. 같은 곳에서 헤매는 누군가에게 지름길이 되면 좋겠다.&lt;/p&gt;</description>
      <category>Engineering/Mobile</category>
      <author>Grace Noh</author>
      <guid isPermaLink="true">https://meercat.tistory.com/513</guid>
      <comments>https://meercat.tistory.com/513#entry513comment</comments>
      <pubDate>Mon, 8 Jun 2026 16:46:30 +0900</pubDate>
    </item>
    <item>
      <title>useEvent 패턴 &amp;mdash; 부모 리렌더링이 자식 effect를 폭주시키는 문제 해결하기</title>
      <link>https://meercat.tistory.com/512</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리액트로 외부 라이브러리나 SDK를 붙이다 보면 한 번쯤 마주치는 함정이 있다. 부모에서 콜백을 prop으로 넘겼을 뿐인데, 부모가 리렌더링될 때마다 자식의 useEffect가 통째로 다시 실행되는 현상이다. 이 글에서는 그 원인을 짚고, useEvent라는 한 줄짜리 훅으로 깔끔하게 해결하는 방법을 정리한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-heading=&quot;어떤 문제인가&quot; data-ke-size=&quot;size26&quot;&gt;어떤 문제인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리액트에서 자식 컴포넌트(특히 외부 라이브러리)에 콜백을 prop으로 넘길 때, &lt;b&gt;자식이 그 콜백을 useEffect의 deps 배열에 잡고 있으면&lt;/b&gt; 문제가 생긴다. 부모가 리렌더링될 때마다 새 콜백 reference가 만들어지고, 그 reference 변화가 자식의 effect를 매번 재실행시킨다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 자식 (외부 라이브러리라고 가정)
function ThirdPartyWidget({ onResult }) {
  useEffect(() =&amp;gt; {
    const handle = expensiveSetup(onResult);   // 무거운 초기화
    return () =&amp;gt; handle.cleanup();
  }, [onResult]);   // &amp;larr; 콜백 reference가 바뀔 때마다 setup/cleanup 반복
  // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 부모
function Parent() {
  const { user } = useAuth();

  const onResult = async (result) =&amp;gt; {
    // user 같은 값을 클로저로 캡처해서 써야 한다
    await sendResult({ ...result, userId: user.id });
  };

  return &amp;lt;ThirdPartyWidget onResult={onResult} /&amp;gt;;
  // &amp;uarr; 매 렌더마다 새 함수 &amp;rarr; 자식 effect가 무한히 재트리거
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 딜레마는 이렇다. 콜백은 &lt;b&gt;항상 최신 user를 봐야 하므로&lt;/b&gt; 매 렌더에 다시 만들어지는 게 자연스럽다. 그런데 그러면 reference가 매번 바뀌어서 자식 effect가 폭주한다. &quot;최신 클로저&quot;와 &quot;안정적인 reference&quot;를 동시에 원하는 것이다.&lt;/p&gt;
&lt;h2 data-heading=&quot;기존 해결책과 그 한계&quot; data-ke-size=&quot;size26&quot;&gt;기존 해결책과 그 한계&lt;/h2&gt;
&lt;h3 data-heading=&quot;방법 1 &amp;mdash; &amp;#96;useCallback(fn, [deps])&amp;#96;&quot; data-ke-size=&quot;size23&quot;&gt;방법 1 &amp;mdash; useCallback(fn, [deps])&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;const onResult = useCallback(async (result) =&amp;gt; {
  await sendResult({ ...result, userId: user.id });
}, [user.id]);
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;❌ deps에 들어간 값이 바뀌면 결국 reference가 바뀐다 &amp;rarr; 자식 effect 재실행은 못 막는다.&lt;/li&gt;
&lt;li&gt;❌ deps를 빠뜨리면 stale closure 버그. 옛날 값을 캡처한 함수가 호출된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;deps 누락은 react-hooks/exhaustive-deps ESLint 규칙이 잡아주긴 하지만, 그 규칙을 따를수록 reference가 더 자주 바뀌는 모순에 빠진다.&lt;/p&gt;
&lt;h3 data-heading=&quot;방법 2 &amp;mdash; &amp;#96;useCallback(fn, [])&amp;#96; + 값마다 &amp;#96;useRef&amp;#96;&quot; data-ke-size=&quot;size23&quot;&gt;방법 2 &amp;mdash; useCallback(fn, []) + 값마다 useRef&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;reference를 영구히 고정하고 싶으니 deps를 비운다. 대신 최신값이 필요한 변수들을 ref로 따로 들고 있는다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;const userLatest = useRef(user);
userLatest.current = user;
const dataLatest = useRef(data);
dataLatest.current = data;
// ... 캡처할 값마다 이 두 줄을 반복

const onResult = useCallback(async (result) =&amp;gt; {
  await sendResult({ ...result, userId: userLatest.current.id });
}, []);
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;✅ reference는 영구 안정. 자식 effect를 재트리거하지 않는다.&lt;/li&gt;
&lt;li&gt;✅ stale closure도 없다. ref를 통해 항상 최신값을 읽는다.&lt;/li&gt;
&lt;li&gt;❌ 보일러플레이트가 너무 많다. 캡처할 값을 추가/제거할 때마다 ref를 손봐야 한다.&lt;/li&gt;
&lt;li&gt;❌ 새 변수를 쓰기 시작했는데 ref 등록을 깜빡하면 그 변수만 조용히 stale해진다. 가장 찾기 어려운 종류의 버그다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동작은 완벽하지만 사람이 실수하기 좋은 구조다. 이걸 한 줄로 줄이는 게 useEvent다.&lt;/p&gt;
&lt;h2 data-heading=&quot;해결: useEvent 훅&quot; data-ke-size=&quot;size26&quot;&gt;해결: useEvent 훅&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아이디어는 단순하다. 변수마다 ref를 만드는 대신, &lt;b&gt;콜백 함수 자체&lt;/b&gt;를 하나의 ref에 저장한다. 그리고 그 ref를 호출하기만 하는 안정적인 wrapper를 바깥에 노출한다.&lt;/p&gt;
&lt;pre class=&quot;moonscript&quot;&gt;&lt;code&gt;// hooks/useEvent.ts
import { useCallback, useLayoutEffect, useRef } from 'react';

export function useEvent&amp;lt;TArgs extends unknown[], TReturn&amp;gt;(
  fn: (...args: TArgs) =&amp;gt; TReturn,
): (...args: TArgs) =&amp;gt; TReturn {
  const ref = useRef(fn);

  useLayoutEffect(() =&amp;gt; {
    ref.current = fn;   // 매 렌더 직후 최신 fn으로 갱신
  });

  return useCallback((...args: TArgs) =&amp;gt; ref.current(...args), []);
  //     &amp;uarr; 영구히 동일한 reference. 내부에서 최신 fn을 호출.
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;사용&quot; data-ke-size=&quot;size23&quot;&gt;사용&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;import { useEvent } from './hooks/useEvent';

const onResult = useEvent(async (result: Result) =&amp;gt; {
  // user, data 등을 그냥 평범하게 클로저로 접근 &amp;mdash; 항상 최신값
  await sendResult({ ...result, userId: user.id });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;한 줄.&lt;/b&gt; ref 보일러플레이트가 전부 사라진다. 함수 안에서는 평소처럼 부모 스코프 변수를 쓰면 되고, 바깥으로 나가는 reference는 컴포넌트 수명 내내 고정된다.&lt;/p&gt;
&lt;h2 data-heading=&quot;동작 원리&quot; data-ke-size=&quot;size26&quot;&gt;동작 원리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 가지 사실이 맞물려 돌아간다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;컴포넌트 lifetime 동안 동일 reference&lt;/b&gt;&lt;br /&gt;useCallback(..., [])이 한 번 만든 wrapper 함수를 영원히 재사용한다. 이 wrapper가 자식 effect의 deps에 들어가도 절대 바뀌지 않으니 effect를 재실행시키지 않는다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;호출 시점에는 항상 최신 클로저&lt;/b&gt;&lt;br /&gt;useLayoutEffect가 매 렌더 직후 ref.current를 최신 fn으로 덮어쓴다. wrapper가 호출되면 ref.current(...)를 통해 가장 최신 함수를 부른다. 그 함수 안에서는 평범하게 부모 스코프의 변수에 접근하므로 항상 최신값이 보인다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;useEffect가 아니라 useLayoutEffect인 이유&lt;/b&gt;useEvent에서 굳이 useLayoutEffect를 쓰는 이유는 &lt;b&gt;타이밍 안전성&lt;/b&gt; 때문이다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;useEffect는 commit 이후 &lt;b&gt;비동기&lt;/b&gt;라, ref가 갱신되기 전에 wrapper가 호출되는 짧은 윈도우가 존재한다 &amp;rarr; 그 사이에 호출되면 옛 fn이 실행될 수 있다.&lt;/li&gt;
&lt;li&gt;useLayoutEffect는 commit 직후 &lt;b&gt;동기&lt;/b&gt;라 그 윈도우가 없다 &amp;rarr; wrapper가 호출되는 시점에는 항상 최신 fn이 보장된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;&lt;b&gt;useLayoutEffect란?&lt;/b&gt;&lt;/i&gt;&lt;span&gt;&lt;i&gt;&amp;nbsp;&lt;/i&gt;&lt;br /&gt;&lt;/span&gt;useEffect와 거의 같지만&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;실행 타이밍이 다른&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;훅이다. 리액트가 DOM을 업데이트(commit)한 직후,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;브라우저가 화면을 그리기(paint) 전에 동기적으로&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;실행된다. useEffect는 반대로 브라우저가 그린 뒤에 비동기로 실행된다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;단계&lt;/td&gt;
&lt;td&gt;render&lt;/td&gt;
&lt;td&gt;commit (DOM 업데이트)&lt;/td&gt;
&lt;td&gt;useLayoutEffect&lt;/td&gt;
&lt;td&gt;&lt;span&gt;paint (브라우저 그리기)&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;useEffect&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;타이밍&lt;/td&gt;
&lt;td&gt;동기&lt;/td&gt;
&lt;td&gt;동기&lt;/td&gt;
&lt;td&gt;&lt;b&gt;동기, paint 직전&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;비동기&lt;/td&gt;
&lt;td&gt;&lt;b&gt;paint 이후&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통은 useEffect가 디폴트다 &amp;mdash; paint를 막지 않아 더 빠르다. useLayoutEffect는 DOM 측정/조작이 paint 전에 끝나야 하는 경우(레이아웃 깜빡임 방지)에 쓴다.&lt;/p&gt;
&lt;h2 data-heading=&quot;한눈에 비교&quot; data-ke-size=&quot;size26&quot;&gt;한눈에 비교&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;방식&lt;/td&gt;
&lt;td&gt;코드량&lt;/td&gt;
&lt;td&gt;stale 위험&lt;/td&gt;
&lt;td&gt;자식 재트리거&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;평범한 함수&lt;/td&gt;
&lt;td&gt;적음&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;&lt;b&gt;있음&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;useCallback([deps])&lt;/td&gt;
&lt;td&gt;보통&lt;/td&gt;
&lt;td&gt;deps 누락 시 있음&lt;/td&gt;
&lt;td&gt;deps 변화 시 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;useCallback([]) + 값마다 useRef&lt;/td&gt;
&lt;td&gt;&lt;b&gt;많음&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;ref 빠뜨리면 있음&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;useEvent&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;한 줄&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-heading=&quot;리액트 공식 API: &amp;#96;useEffectEvent&amp;#96; (React 19.2)&quot; data-ke-size=&quot;size26&quot;&gt;리액트 공식 API: useEffectEvent (React 19.2)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 리액트 팀도 똑같은 컨셉을 정식 API로 내놓았다. 이름은 &lt;b&gt;useEffectEvent&lt;/b&gt;, React 19.2부터 안정 API다. (초기 RFC에서는 useEvent라는 이름이었는데, &quot;Effect 안에서 호출하는 게 주된 용도&quot;라는 의미를 분명히 하려고 useEffectEvent로 바뀌었다.)&lt;/p&gt;
&lt;h3 data-heading=&quot;기본 사용&quot; data-ke-size=&quot;size23&quot;&gt;기본 사용&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;import { useEffectEvent } from 'react';

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() =&amp;gt; {
    showNotification('Connected!', theme);   // theme 항상 최신
  });

  useEffect(() =&amp;gt; {
    const conn = createConnection(roomId);
    conn.on('connected', () =&amp;gt; onConnected());
    conn.connect();
    return () =&amp;gt; conn.disconnect();
  }, [roomId]);   // &amp;larr; theme도, onConnected도 deps에 안 넣어도 된다
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;핵심 특성&quot; data-ke-size=&quot;size23&quot;&gt;핵심 특성&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;항상 최신 props/state 캡처&lt;/b&gt; &amp;mdash; userland useEvent와 동일하다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;deps 배열에 넣지 않아야 한다&lt;/b&gt; &amp;mdash; 오히려 넣으면 ESLint가 경고한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Effect 내부 호출이 권장 패턴&lt;/b&gt; &amp;mdash; 자식 컴포넌트에 prop으로 직접 넘기는 건 제약이 있을 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;렌더 중 호출 금지&lt;/b&gt; &amp;mdash; 컴포넌트 render body에서 직접 부르면 안 된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-heading=&quot;그렇다면 userland &amp;#96;useEvent&amp;#96;는 이제 필요 없을까?&quot; data-ke-size=&quot;size23&quot;&gt;그렇다면 userland useEvent는 이제 필요 없을까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useEffectEvent는 본래 &lt;b&gt;effect 내부에서 호출하는 non-reactive 이벤트 핸들러&lt;/b&gt;를 추출하기 위해 설계됐다. 이 글에서 다룬 &quot;외부 컴포넌트에 콜백 prop으로 직접 넘기는&quot; 케이스는 결이 조금 다르다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;사용처&lt;/td&gt;
&lt;td&gt;userland useEvent&lt;/td&gt;
&lt;td&gt;공식 useEffectEvent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;useEffect 내부 호출&lt;/td&gt;
&lt;td&gt;✅ 동작&lt;/td&gt;
&lt;td&gt;✅ 권장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;자식 컴포넌트 prop&lt;/td&gt;
&lt;td&gt;✅ 동작&lt;/td&gt;
&lt;td&gt;⚠️ 동작하지만 ESLint 경고&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이벤트 핸들러 prop (onClick 등)&lt;/td&gt;
&lt;td&gt;✅ 동작&lt;/td&gt;
&lt;td&gt;⚠️ 동작하지만 권장 안 함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;외부 SDK / setTimeout 콜백&lt;/td&gt;
&lt;td&gt;✅ 동작&lt;/td&gt;
&lt;td&gt;⚠️ 동작은 함&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 React 19.2 이상이라면 &lt;b&gt;effect 내부에서 호출하는 케이스는 공식 useEffectEvent로&lt;/b&gt; 옮기는 게 좋다. 반면 &lt;b&gt;외부 라이브러리에 콜백 prop으로 넘기는 케이스는 userland useEvent가 여전히 유용&lt;/b&gt;하다. 의미적으로 &quot;effect 전용&quot;이라는 시그널이 없는 게 오히려 자연스럽고, ESLint 경고도 피할 수 있다. 두 API가 같은 패턴을 공유하므로, 나중에 권장 방식이 바뀌더라도 마이그레이션이 매끄럽다.&lt;/p&gt;
&lt;h2 data-heading=&quot;어디에 쓰면 좋은가&quot; data-ke-size=&quot;size26&quot;&gt;어디에 쓰면 좋은가&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;외부 컴포넌트에 콜백을 넘기는데 그 콜백이 자식의 effect deps에 들어가는 경우&lt;/li&gt;
&lt;li&gt;WebView 메시지 핸들러, 외부 SDK 콜백, 차트/지도 라이브러리의 이벤트 핸들러 등&lt;/li&gt;
&lt;li&gt;결과 전송처럼 &quot;최신 상태를 캡처해야 하지만 reference는 고정돼야 하는&quot; 비동기 콜백&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 &lt;b&gt;일반적인 이벤트 핸들러(버튼 onClick/onPress 등)는 굳이 쓸 필요 없다.&lt;/b&gt; 리액트가 알아서 처리해주는 영역이라 과한 추상화가 된다.&lt;/p&gt;
&lt;h2 data-heading=&quot;실전 적용 예시&quot; data-ke-size=&quot;size26&quot;&gt;실전 적용 예시&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 위젯 컴포넌트가 onResult prop을 effect deps로 잡고 있어서, 부모의 data&amp;middot;locale&amp;middot;deviceInfo 같은 값이 바뀔 때마다 결과 전송이 반복 트리거되는 문제가 있었다고 하자.&lt;/p&gt;
&lt;h3 data-heading=&quot;Before &amp;mdash; 값마다 &amp;#96;useRef&amp;#96;&quot; data-ke-size=&quot;size23&quot;&gt;Before &amp;mdash; 값마다 useRef&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;const sendResultLatest = useRef(sendResult);
sendResultLatest.current = sendResult;
const routineIdLatest = useRef(data?.routineId);
routineIdLatest.current = data?.routineId;
const localeLatest = useRef(locale);
localeLatest.current = locale;

const onResult = useCallback(async (result) =&amp;gt; {
  const body = {
    routineId: routineIdLatest.current ?? 0,
    preferredLang: localeLatest.current,
    deviceId: deviceInfo?.deviceId,   // &amp;larr; ref 등록을 빠뜨림! stale closure
    // ...
  };
  await sendResultLatest.current(body);
}, []);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;deviceInfo만 ref로 감싸는 걸 깜빡했고, 그 결과 이 필드만 옛날 값으로 전송되는 버그가 숨어 있다. 코드만 봐서는 눈에 잘 띄지 않는다.&lt;/p&gt;
&lt;h3 data-heading=&quot;After &amp;mdash; &amp;#96;useEvent&amp;#96;&quot; data-ke-size=&quot;size23&quot;&gt;After &amp;mdash; useEvent&lt;/h3&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;const onResult = useEvent(async (result: Result) =&amp;gt; {
  const body = {
    routineId: data?.routineId ?? 0,
    preferredLang: locale,
    deviceId: deviceInfo?.deviceId,   // &amp;larr; 자동으로 최신값
    // ...
  };
  await sendResult(body);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드량은 절반 이하로 줄고, 어떤 변수를 추가하든 자동으로 최신값을 본다. stale closure가 구조적으로 발생할 수 없다.&lt;/p&gt;
&lt;h2 data-heading=&quot;마치며&quot; data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useEvent는 &quot;최신 클로저&quot;와 &quot;안정적인 reference&quot;라는, 평소엔 양립하기 어려운 두 요구를 한 줄로 충족시킨다. 외부 라이브러리를 자주 붙이는 코드베이스라면 공용 훅으로 하나 만들어두는 것을 추천한다. React 19.2 이상이라면 effect 내부 호출은 공식 useEffectEvent로, 콜백 prop 전달은 useEvent로 &amp;mdash; 두 패턴을 상황에 맞게 함께 쓰면 된다.&lt;/p&gt;
&lt;h3 data-heading=&quot;참고 링크&quot; data-ke-size=&quot;size23&quot;&gt;참고 링크&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;React 공식 문서: &lt;a href=&quot;https://react.dev/reference/react/useEffectEvent&quot; data-tooltip-position=&quot;top&quot;&gt;useEffectEvent&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&quot;Separating Events from Effects&quot; 가이드: &lt;a href=&quot;https://react.dev/learn/separating-events-from-effects&quot;&gt;https://react.dev/learn/separating-events-from-effects&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;원본 RFC: &lt;a href=&quot;https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md&quot;&gt;https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Engineering/FrontEnd</category>
      <author>Grace Noh</author>
      <guid isPermaLink="true">https://meercat.tistory.com/512</guid>
      <comments>https://meercat.tistory.com/512#entry512comment</comments>
      <pubDate>Mon, 8 Jun 2026 12:54:28 +0900</pubDate>
    </item>
    <item>
      <title>iOS 빌드 인프라, 어디서 돌릴까 &amp;mdash; 6가지 옵션 비교와 self-hosted runner 구축기</title>
      <link>https://meercat.tistory.com/511</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Native 모노레포의 iOS 빌드를 어디서 돌릴지 고민하며 6가지 인프라 옵션을 비교했고, 최종적으로 사내 Mac에 GitHub Actions self-hosted runner를 붙였습니다. 옵션별 비교, 우리가 self-hosted를 고른 이유, 그리고 self-hosted 특유의 함정(코드 서명&amp;middot;키체인)을 어떻게 넘었는지 공유합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;들어가며&quot; data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 팀은 여러 개의 React Native 앱을 하나의 Nx 모노레포에서 운영하고 있습니다. 빌드/배포 파이프라인은 GitHub Actions 위에 올라가 있고, Git 태그를 푸시하면 해당 앱이 Stage/Prod로 빌드되는 구조입니다. Android는 Linux runner에서 잘 돌아갑니다. 문제는 항상 &lt;b&gt;iOS&lt;/b&gt;였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;iOS 빌드는 macOS에서만 가능합니다. Xcode가 macOS 전용이기 때문에 Linux runner를 아무리 늘려도 IPA 하나 만들 수 없습니다. 그래서 &quot;iOS 빌드를 &lt;b&gt;어디서&lt;/b&gt; 돌릴 것인가&quot;는 단순한 CI 설정 문제가 아니라, &lt;b&gt;macOS 컴퓨팅 자원을 어떻게 확보할 것인가&lt;/b&gt;라는 인프라 의사결정이 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 ① iOS 빌드 인프라 6가지 옵션을 비교하고, ② 저희가 &lt;b&gt;사내 Mac + self-hosted runner&lt;/b&gt;를 선택한 이유, ③ 그리고 self-hosted를 실제로 굴리면서 마주친 코드 서명&amp;middot;키체인 문제를 어떻게 해결했는지를 다룹니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;iOS 빌드는 왜 인프라 문제가 되는가&quot; data-ke-size=&quot;size26&quot;&gt;iOS 빌드는 왜 인프라 문제가 되는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub Actions에서 Linux runner는 분당 약 $0.006, Windows는 $0.010 수준입니다. 그런데 macOS runner는 &lt;b&gt;분당 $0.06 안팎&lt;/b&gt;으로 한 자릿수 배 비쌉니다. macOS는 Apple 하드웨어에서만 합법적으로 실행할 수 있어서, 클라우드 사업자 입장에서도 &quot;Mac 하드웨어를 사서 빌려주는&quot; 구조라 단가가 높을 수밖에 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 두 가지 제약이 더 붙습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;분당 과금 구조&lt;/b&gt;: iOS 빌드는 Pod 설치, 네이티브 컴파일, 코드 서명, 아카이브까지 한 번에 15~25분이 쉽게 걸립니다. 빌드 횟수가 늘수록 비용이 선형으로 증가합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Apple의 라이선스 제약&lt;/b&gt;: 일부 클라우드 Mac은 macOS 라이선스 약관 때문에 &lt;b&gt;24시간 최소 대여&lt;/b&gt; 같은 제약이 있습니다. &quot;필요할 때 1시간만&quot; 쓰는 게 불가능한 경우가 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, iOS 빌드 인프라는 &quot;싸고, 빠르고, 관리 안 해도 되는&quot; 세 가지를 동시에 만족하기 어렵습니다. 그래서 팀의 &lt;b&gt;빌드 빈도&lt;/b&gt;와 &lt;b&gt;관리 여력&lt;/b&gt;에 따라 정답이 달라집니다. 먼저 선택지를 펼쳐 보겠습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 비용은 저희가 리서치하던 시점 기준의 대략적인 수치입니다. 가격 정책은 자주 바뀌므로 정확한 금액은 각 서비스 공식 페이지를 확인하세요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;옵션 비교&quot; data-ke-size=&quot;size26&quot;&gt;옵션 비교&lt;/h2&gt;
&lt;h3 data-heading=&quot;1. GitHub Actions macOS Runner&quot; data-ke-size=&quot;size23&quot;&gt;1. GitHub Actions macOS Runner&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub이 호스팅하는 macOS runner. 기존 워크플로우에서 runs-on만 바꾸면 되는 가장 손쉬운 선택지입니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;항목&lt;/td&gt;
&lt;td&gt;내용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;가격&lt;/td&gt;
&lt;td&gt;표준(3~4 core) &lt;b&gt;~$0.062/분&lt;/b&gt;, xlarge(M2 Pro) &lt;b&gt;~$0.10/분&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;설정 난이도&lt;/td&gt;
&lt;td&gt;낮음 (runs-on: macos-15 한 줄)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;관리 부담&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;빌드 제한&lt;/td&gt;
&lt;td&gt;분당 과금 (private repo는 무료 tier 미포함)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;장점&lt;/b&gt;: 인프라 관리가 전혀 필요 없고, 기존 GitHub Actions 워크플로우를 그대로 쓸 수 있습니다. 더 빠른 머신이 필요하면 runs-on만 xlarge로 바꾸면 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단점&lt;/b&gt;: 분당 과금이라 빌드가 많아지면 비용이 가파르게 오릅니다. 빌드 캐시 용량 제한(10GB)이 있고, 머신 환경을 커스텀하기 어렵습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;월 예상&lt;/b&gt;: 20분 빌드 &amp;times; 30회 = 600분 &amp;rarr; 표준 &lt;b&gt;~$37&lt;/b&gt;, xlarge &lt;b&gt;~$61&lt;/b&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;2. Codemagic&quot; data-ke-size=&quot;size23&quot;&gt;2. Codemagic&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Native&amp;middot;Flutter를 공식 지원하는 모바일 전용 CI/CD. 코드 서명과 스토어 업로드를 자동으로 관리해 줍니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;항목&lt;/td&gt;
&lt;td&gt;내용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;가격(무료)&lt;/td&gt;
&lt;td&gt;M2 머신 &lt;b&gt;500분/월&lt;/b&gt;, 동시 빌드 1개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;가격(종량제)&lt;/td&gt;
&lt;td&gt;M2 &lt;b&gt;~$0.095/분&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;가격(정액)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;~$333/월&lt;/b&gt;(무제한)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;설정 난이도&lt;/td&gt;
&lt;td&gt;중간 (codemagic.yaml)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;장점&lt;/b&gt;: 무료 500분이면 소규모 팀엔 충분합니다. Fastlane 연동, 코드 서명 자동 관리, TestFlight 자동 업로드 등 모바일에 특화돼 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단점&lt;/b&gt;: GitHub Actions와 &lt;b&gt;별도의 CI를 이중 운영&lt;/b&gt;하게 됩니다. 기존 워크플로우를 마이그레이션해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;3. EAS Build (Expo)&quot; data-ke-size=&quot;size23&quot;&gt;3. EAS Build (Expo)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Expo의 빌드 서비스. bare workflow(순수 RN)도 지원합니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;항목&lt;/td&gt;
&lt;td&gt;내용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;가격(무료)&lt;/td&gt;
&lt;td&gt;iOS &lt;b&gt;15빌드/월&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;가격(유료)&lt;/td&gt;
&lt;td&gt;Starter &lt;b&gt;$19/월&lt;/b&gt;, Production &lt;b&gt;$199/월&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;설정 난이도&lt;/td&gt;
&lt;td&gt;중간 (eas.json + Expo CLI)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;장점&lt;/b&gt;: RN에 특화돼 빌드 설정이 단순하고, EAS Update(OTA)와 통합됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단점&lt;/b&gt;: bare를 지원하지만 결국 &lt;b&gt;Expo 생태계(eas-cli)에 종속&lt;/b&gt;됩니다. 무료 15빌드는 금방 소진되고, 빌드 큐 대기가 발생합니다. 기존 Fastlane 설정과 충돌할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;4. MacStadium&quot; data-ke-size=&quot;size23&quot;&gt;4. MacStadium&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전용 Mac 하드웨어를 월 단위로 대여하는 호스팅 서비스.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;항목&lt;/td&gt;
&lt;td&gt;내용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;가격&lt;/td&gt;
&lt;td&gt;M2 8GB &lt;b&gt;~$109/월&lt;/b&gt; ~ M4 Pro &lt;b&gt;~$299/월&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;설정 난이도&lt;/td&gt;
&lt;td&gt;높음 (서버 세팅&amp;middot;CI 연동 직접 구성)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;빌드 제한&lt;/td&gt;
&lt;td&gt;무제한&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;장점&lt;/b&gt;: 전용 하드웨어라 성능이 안정적이고 빌드가 무제한입니다. self-hosted runner로 붙일 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단점&lt;/b&gt;: 월 고정 비용이 들고, OS&amp;middot;Xcode 업데이트 같은 머신 관리는 직접 해야 합니다. 빌드가 적으면 비효율적입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;5. AWS EC2 Mac&quot; data-ke-size=&quot;size23&quot;&gt;5. AWS EC2 Mac&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS가 제공하는 클라우드 Mac(Dedicated Host).&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;항목&lt;/td&gt;
&lt;td&gt;내용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;가격&lt;/td&gt;
&lt;td&gt;mac2.metal &lt;b&gt;~$6.5/hr&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;최소 대여&lt;/td&gt;
&lt;td&gt;&lt;b&gt;24시간&lt;/b&gt; (Dedicated Host 제약)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;설정 난이도&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;장점&lt;/b&gt;: AWS 인프라(VPC, IAM)와 통합됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단점&lt;/b&gt;: &lt;b&gt;24시간 최소 과금&lt;/b&gt;이 치명적입니다. 1시간만 써도 24시간분(약 $156)이 청구됩니다. 인스턴스를 stop해도 Dedicated Host를 release하기 전까지 과금이 계속됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;결론&lt;/b&gt;: 간헐적인 CI 빌드 용도로는 &lt;b&gt;부적합&lt;/b&gt;합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;6. Self-hosted Mac (사내 Mac)&quot; data-ke-size=&quot;size23&quot;&gt;6. Self-hosted Mac (사내 Mac)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사내에 Mac mini를 두고, GitHub Actions self-hosted runner를 설치해 빌드를 받는 방식.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항목 내용&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;항목&lt;/td&gt;
&lt;td&gt;내용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;가격&lt;/td&gt;
&lt;td&gt;하드웨어 구매 비용만 (Mac mini M4 ~80만 원대)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;설정 난이도&lt;/td&gt;
&lt;td&gt;중간 (runner 설치 + 서비스 등록)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;관리 부담&lt;/td&gt;
&lt;td&gt;높음 (물리적 관리, OS/Xcode 업데이트)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;빌드 제한&lt;/td&gt;
&lt;td&gt;무제한&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;장점&lt;/b&gt;: 한 번 사두면 &lt;b&gt;빌드 횟수&amp;middot;시간 추가 비용이 없습니다&lt;/b&gt;(전기료 제외). 스펙을 자유롭게 고를 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단점&lt;/b&gt;: 전원&amp;middot;네트워크&amp;middot;OS 업데이트 같은 물리적 관리가 필요하고, 장애 시 빌드가 멈춥니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;비교 요약&quot; data-ke-size=&quot;size26&quot;&gt;비교 요약&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;옵션&lt;/td&gt;
&lt;td&gt;월 예상 비용&lt;/td&gt;
&lt;td&gt;설정 난이도&lt;/td&gt;
&lt;td&gt;관리 부담&lt;/td&gt;
&lt;td&gt;빌드 제한&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Actions 표준&lt;/td&gt;
&lt;td&gt;~$37&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;분당 과금&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Actions xlarge&lt;/td&gt;
&lt;td&gt;~$61&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;분당 과금&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codemagic 무료&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;500분/월&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codemagic 종량제&lt;/td&gt;
&lt;td&gt;~$57&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;분당 과금&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EAS Build 무료&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;15빌드/월&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MacStadium&lt;/td&gt;
&lt;td&gt;$109~&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;무제한&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Self-hosted Mac&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;$0 (하드웨어 별도)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;&lt;b&gt;무제한&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS EC2 Mac&lt;/td&gt;
&lt;td&gt;$4,680~&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;무제한&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;우리의 선택: Self-hosted runner&quot; data-ke-size=&quot;size26&quot;&gt;우리의 선택: Self-hosted runner&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 상황을 정리하면 이랬습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앱이 여러 개라 &lt;b&gt;빌드 빈도가 꾸준히 높다&lt;/b&gt;. Stage QA 빌드, Prod 심사 빌드가 앱별로 쌓이면 월 수십 회를 쉽게 넘긴다.&lt;/li&gt;
&lt;li&gt;이미 &lt;b&gt;GitHub Actions를 메인 파이프라인으로 쓰고 있다&lt;/b&gt;. 별도 CI(Codemagic/EAS)를 이중 운영하고 싶지 않았다.&lt;/li&gt;
&lt;li&gt;기존에 &lt;b&gt;Fastlane 기반 코드 서명&amp;middot;TestFlight 업로드&lt;/b&gt;가 구성돼 있어, 이걸 그대로 가져가고 싶었다.&lt;/li&gt;
&lt;li&gt;사무실에 상시 켜둘 수 있는 &lt;b&gt;Mac이 확보돼 있었다&lt;/b&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분당 과금 옵션은 빌드가 늘수록 비용이 우상향합니다. 반면 self-hosted는 &lt;b&gt;빌드를 아무리 많이 돌려도 추가 비용이 0&lt;/b&gt;입니다. 빌드 빈도가 높고 GitHub Actions를 이미 쓰는 팀에게는, 사내 Mac을 self-hosted runner로 붙이는 게 비용&amp;middot;일관성 양쪽에서 가장 합리적이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 &lt;b&gt;CI 파이프라인을 갈아엎지 않는다&lt;/b&gt;는 점입니다. 워크플로우에서 iOS 빌드 잡의 runs-on만 self-hosted로 바꾸면, 나머지 로직(태그 파싱, Doppler, Fastlane)은 그대로 재사용됩니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;jobs:
  build:
    name: iOS (${{ inputs.app_dir }})
    runs-on: self-hosted   # macos runner &amp;rarr; 사내 Mac
    steps:
      - uses: actions/checkout@v4
      # ... 이하 동일
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;하이브리드 구성 &amp;mdash; macOS가 필요한 잡만 self-hosted로&quot; data-ke-size=&quot;size26&quot;&gt;하이브리드 구성 &amp;mdash; macOS가 필요한 잡만 self-hosted로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;self-hosted라고 해서 &lt;b&gt;모든 잡을 Mac에서 돌릴 필요는 없습니다&lt;/b&gt;. 오히려 그러면 안 됩니다. Lint&amp;middot;타입체크&amp;middot;테스트&amp;middot;태그 파싱처럼 macOS가 필요 없는 작업까지 한 대뿐인 Mac에 몰아넣으면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그게 곧 병목이 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 저희는 &lt;b&gt;잡 단위로 runner를 나눴습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-05 오후 1.35.29.png&quot; data-origin-width=&quot;699&quot; data-origin-height=&quot;419&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dw44UE/dJMcaiQ5DgA/8mzE5RkOKUZkLzJkuc9Wk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dw44UE/dJMcaiQ5DgA/8mzE5RkOKUZkLzJkuc9Wk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dw44UE/dJMcaiQ5DgA/8mzE5RkOKUZkLzJkuc9Wk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdw44UE%2FdJMcaiQ5DgA%2F8mzE5RkOKUZkLzJkuc9Wk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;699&quot; height=&quot;419&quot; data-filename=&quot;스크린샷 2026-06-05 오후 1.35.29.png&quot; data-origin-width=&quot;699&quot; data-origin-height=&quot;419&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;잡&lt;/td&gt;
&lt;td&gt;runner&lt;/td&gt;
&lt;td&gt;이유&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PR 체크(Lint/Typecheck/Test)&lt;/td&gt;
&lt;td&gt;ubuntu-latest&lt;/td&gt;
&lt;td&gt;macOS 불필요. GitHub 무료/저가 Linux&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;태그 파싱&lt;/td&gt;
&lt;td&gt;ubuntu-latest&lt;/td&gt;
&lt;td&gt;단순 셸 스크립트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android 빌드&lt;/td&gt;
&lt;td&gt;ubuntu-latest&lt;/td&gt;
&lt;td&gt;JVM 빌드, Linux로 충분&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;iOS 빌드&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;self-hosted&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Xcode 필요 &amp;rarr; 사내 Mac만 가능&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Linux로 충분한 작업은 전부 GitHub의 저렴한 Linux runner에 맡기고, &lt;b&gt;macOS가 반드시 필요한 iOS 빌드 잡 하나만&lt;/b&gt; 사내 Mac으로 보냅니다. self-hosted Mac의 부하를 최소화하면서, macOS 자원이 꼭 필요한 곳에만 쓰는 구성입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;iOS 빌드 잡은 재사용 워크플로우(workflow_call)로 빼두고, Stage/Prod 워크플로우가 파라미터만 바꿔 호출합니다. self-hosted 전환 후에도 호출부는 그대로입니다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;# stage.yml &amp;mdash; 태그를 파싱해 iOS/Android 빌드를 분기 호출
build-ios:
  needs: parse-tag
  if: needs.parse-tag.outputs.build_ios == 'true'
  uses: ./.github/workflows/_build-ios.yml   # 재사용 워크플로우
  with:
    app_dir: ${{ needs.parse-tag.outputs.app_dir }}
    lane: beta
    version: ${{ needs.parse-tag.outputs.version }}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;self-hosted의 진짜 난관 &amp;mdash; &amp;quot;환경이 영속적이다&amp;quot;&quot; data-ke-size=&quot;size26&quot;&gt;self-hosted의 진짜 난관 &amp;mdash; &quot;환경이 영속적이다&quot;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호스팅 runner와 self-hosted의 본질적 차이는 &lt;b&gt;상태의 영속성&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub 호스팅 macOS runner는 매 빌드마다 &lt;b&gt;깨끗한 새 가상머신&lt;/b&gt;에서 시작합니다. Xcode도, 키체인도, Ruby도 매번 초기 상태입니다. 반면 사내 Mac은 &lt;b&gt;항상 같은 머신&lt;/b&gt;입니다. 지난 빌드의 흔적이 남아 있고, 로그인 키체인은 잠겨 있고, Ruby는 시스템 버전이 아니라 우리가 깔아둔 버전을 써야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 차이에서 self-hosted 특유의 문제들이 나옵니다. 하나씩 보겠습니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;1. 코드 서명과 키체인 잠금 &amp;mdash; 가장 큰 함정&quot; data-ke-size=&quot;size23&quot;&gt;1. 코드 서명과 키체인 잠금 &amp;mdash; 가장 큰 함정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;self-hosted iOS 빌드에서 십중팔구 처음 막히는 지점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;iOS 아카이브에는 &lt;b&gt;배포 인증서로 코드 서명&lt;/b&gt;이 필요하고, 그 인증서의 개인 키는 macOS &lt;b&gt;키체인&lt;/b&gt;에 들어 있습니다. 그런데 GitHub Actions runner는 서비스(데몬)로 백그라운드 실행되기 때문에, 로그인 세션의 키체인이 &lt;b&gt;잠긴 상태&lt;/b&gt;입니다. 이대로 codesign을 호출하면 인증서 키에 접근하지 못해 빌드가 실패합니다. 호스팅 runner에서는 매번 임시 키체인을 새로 만들어 쓰기 때문에 겪지 않는 문제입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결은 빌드 직전에 &lt;b&gt;키체인을 잠금 해제&lt;/b&gt;하고, 코드 서명 도구가 &lt;b&gt;프롬프트 없이&lt;/b&gt; 키에 접근하도록 권한을 여는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;dsconfig&quot;&gt;&lt;code&gt;- name: Unlock keychain
  run: |
    KEYCHAIN_PW=$(doppler secrets get KEYCHAIN_PASSWORD --plain ...)
    # 로그인 키체인 잠금 해제
    security unlock-keychain -p &quot;$KEYCHAIN_PW&quot; \
      &quot;$HOME/Library/Keychains/login.keychain-db&quot;
    # codesign이 비대화식으로 키에 접근하도록 partition list 설정
    security set-key-partition-list -S apple-tool:,apple: -s \
      -k &quot;$KEYCHAIN_PW&quot; &quot;$HOME/Library/Keychains/login.keychain-db&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;set-key-partition-list가 핵심입니다. 이게 없으면 빌드 중 &quot;키체인 접근을 허용하시겠습니까?&quot; 같은 &lt;b&gt;GUI 프롬프트가 떠서&lt;/b&gt; CI가 무한 대기에 빠집니다. 사람이 없는 빌드 머신에서는 절대 응답되지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Fastlane 레인 안에서도 한 번 더 명시적으로 잠금을 풉니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;lane :beta do
  api_key = app_store_connect_api_key(
    key_id: ENV[&quot;ASC_KEY_ID&quot;], issuer_id: ENV[&quot;ASC_ISSUER_ID&quot;],
    key_filepath: ENV[&quot;ASC_KEY_FILE&quot;]
  )

  clear_derived_data                       # &amp;larr; 영속 환경 정리(아래 참고)
  get_provisioning_profile(api_key: api_key)
  update_code_signing_settings(            # 자동 서명 끄고 수동 서명 고정
    use_automatic_signing: false,
    code_sign_identity: &quot;iPhone Distribution&quot;,
    profile_name: lane_context[SharedValues::SIGH_NAME]
  )
  unlock_keychain(                         # 빌드 직전 재확인
    path: File.expand_path(&quot;~/Library/Keychains/login.keychain-db&quot;),
    password: ENV[&quot;KEYCHAIN_PASSWORD&quot;]
  )
  build_app(workspace: &quot;...&quot;, scheme: &quot;...&quot;, export_method: &quot;app-store&quot;)
  upload_to_testflight(api_key: api_key, skip_waiting_for_build_processing: true)
end
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증서&amp;middot;프로비저닝은 &lt;b&gt;App Store Connect API 키&lt;/b&gt;로 처리합니다. 키 파일은 GitHub Secrets에 base64로 넣어두고 빌드 시 디코드해 사용하므로, 머신에 자격증명을 영구 보관하지 않습니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;2. 환경 격리 &amp;mdash; 지난 빌드의 잔재 지우기&quot; data-ke-size=&quot;size23&quot;&gt;2. 환경 격리 &amp;mdash; 지난 빌드의 잔재 지우기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 머신을 재사용하니 이전 빌드의 DerivedData, Pods 캐시가 남아 &lt;b&gt;유령 같은 빌드 실패&lt;/b&gt;를 만들 수 있습니다. 그래서 빌드 시작 시 clear_derived_data로 파생 데이터를 비우고, Pods는 &lt;b&gt;명시적 캐시 키로만&lt;/b&gt; 관리합니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;- name: Restore Pods cache
  uses: actions/cache@v4
  with:
    path: apps/${{ inputs.app_dir }}/ios/Pods
    key: pods-${{ runner.os }}-${{ hashFiles('.../Podfile.lock') }}
    restore-keys: pods-${{ runner.os }}-
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Podfile.lock 해시를 키로 쓰기 때문에, 의존성이 바뀌지 않았으면 캐시를 재사용해 pod install을 건너뛰고, 바뀌면 자동으로 새로 받습니다. 영속 머신에서도 캐시가 &lt;b&gt;결정적으로&lt;/b&gt; 동작합니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;3. 런타임 PATH &amp;mdash; 시스템 Ruby를 쓰지 않기&quot; data-ke-size=&quot;size23&quot;&gt;3. 런타임 PATH &amp;mdash; 시스템 Ruby를 쓰지 않기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Fastlane/CocoaPods는 Ruby 위에서 돕니다. macOS 시스템 Ruby를 직접 쓰면 권한&amp;middot;버전 문제가 잦아, 저희는 &lt;b&gt;rbenv로 깐 Ruby&lt;/b&gt;를 씁니다. 그런데 runner를 서비스로 띄우면 로그인 셸의 PATH 설정(.zshrc 등)을 타지 않아, rbenv가 PATH에 없습니다. 그래서 잡 안에서 직접 PATH를 넣어 줍니다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;- name: Setup rbenv PATH
  run: |
    echo &quot;$HOME/.rbenv/shims&quot; &amp;gt;&amp;gt; $GITHUB_PATH
    echo &quot;$HOME/.rbenv/bin&quot; &amp;gt;&amp;gt; $GITHUB_PATH
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호스팅 runner였다면 신경 쓸 일이 없지만, 영속 머신에서는 &quot;내 셸에선 되는데 CI에선 안 되는&quot; 전형적인 PATH 함정입니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;4. 시크릿 관리 &amp;mdash; 머신에 값을 박아두지 않기&quot; data-ke-size=&quot;size23&quot;&gt;4. 시크릿 관리 &amp;mdash; 머신에 값을 박아두지 않기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;self-hosted라고 환경변수를 머신 .env에 박아두면, 그게 곧 관리 사각지대가 됩니다. 저희는 &lt;b&gt;Doppler&lt;/b&gt;로 시크릿을 중앙 관리하고, 빌드 시점에 필요한 값만 내려받아 .env를 생성합니다. Firebase 설정(GoogleService-Info.plist), Sentry 토큰, 카카오 키 등 민감 파일도 전부 Doppler에서 주입합니다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;- name: Pull env from Doppler
  run: |
    doppler secrets download --no-file --format=env \
      --project ${{ inputs.doppler_project }} \
      --config ${{ inputs.doppler_config }} \
      --token &quot;$DOPPLER_TOKEN&quot; &amp;gt; .env
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 머신이 바뀌거나 runner를 새로 깔아도, &lt;b&gt;자격증명은 머신이 아니라 Doppler에 산다&lt;/b&gt;는 원칙이 유지됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;runner 등록과 자동 복구&quot; data-ke-size=&quot;size26&quot;&gt;runner 등록과 자동 복구&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;runner 설치 자체는 어렵지 않습니다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;mkdir actions-runner &amp;amp;&amp;amp; cd actions-runner
curl -o runner.tar.gz -L https://github.com/actions/runner/releases/download/vX.Y.Z/actions-runner-osx-arm64-X.Y.Z.tar.gz
tar xzf runner.tar.gz
./config.sh --url https://github.com/ORG/REPO --token 

# launchd 서비스로 등록 &amp;rarr; 재부팅 시 자동 시작
./svc.sh install
./svc.sh start
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;svc.sh install로 &lt;b&gt;launchd 서비스 등록&lt;/b&gt;을 해두는 게 중요합니다. 사내 Mac은 정전&amp;middot;재부팅을 피할 수 없는데, 서비스로 등록해두면 부팅 후 runner가 자동으로 다시 붙습니다. 추가로 macOS &lt;b&gt;시스템 설정 &amp;rarr; 에너지 &amp;rarr; &quot;정전 후 자동으로 시작&quot;&lt;/b&gt; 을 켜두면, 전원만 복구되면 사람 개입 없이 빌드 머신이 살아납니다. 원격 관리는 SSH와 화면 공유(VNC)로 하고, 고정 IP 또는 VPN을 함께 둡니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;운영하며 느낀 트레이드오프&quot; data-ke-size=&quot;size26&quot;&gt;운영하며 느낀 트레이드오프&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;self-hosted는 만능이 아닙니다. &lt;b&gt;비용을 관리 부담과 맞바꾸는&lt;/b&gt; 선택입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;좋았던 점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;빌드 횟수가 늘어도 비용이 그대로다. 분당 과금의 심리적 압박이 사라졌다.&lt;/li&gt;
&lt;li&gt;기존 GitHub Actions&amp;middot;Fastlane 자산을 100% 재사용했다. runs-on만 바꿨다.&lt;/li&gt;
&lt;li&gt;머신을 우리가 통제하니 Xcode 버전, 캐시, 도구 체인을 원하는 대로 고정할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;감수해야 하는 점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;단일 장애점(SPOF)&lt;/b&gt;: Mac 한 대가 멈추면 iOS 빌드가 전부 멈춘다. 빌드가 잦은 팀이라면 예비 머신이나 호스팅 runner로의 &lt;b&gt;폴백 전략&lt;/b&gt;을 고려해야 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;물리&amp;middot;OS 관리&lt;/b&gt;: Xcode 업데이트, 디스크 정리, 인증서 갱신을 누군가는 챙겨야 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;동시성&lt;/b&gt;: 머신이 한 대면 빌드는 직렬화된다. 여러 앱을 동시에 빌드하려면 머신을 늘리거나 호스팅 runner를 섞어야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 저희는 &lt;b&gt;하이브리드&lt;/b&gt;가 정답이라고 봤습니다. Linux로 되는 일은 GitHub Linux runner에, macOS가 꼭 필요한 iOS 빌드만 사내 Mac에. 필요하면 언제든 runs-on을 macos-15로 되돌려 호스팅 runner로 폴백할 수 있다는 점이, self-hosted를 마음 편히 운영하는 안전판이 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;마치며&quot; data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;iOS 빌드 인프라에는 &quot;무조건 이게 정답&quot;인 옵션이 없습니다. &lt;b&gt;빌드 빈도가 낮으면&lt;/b&gt; Codemagic&amp;middot;EAS 무료 tier나 GitHub 호스팅 runner가 관리 부담 없이 가장 편하고, &lt;b&gt;빌드가 잦고 이미 GitHub Actions를 쓰고 있다면&lt;/b&gt; 사내 Mac + self-hosted runner가 비용&amp;middot;일관성에서 유리합니다. 반대로 AWS EC2 Mac처럼 과금 구조가 CI에 맞지 않는 옵션은 후보에서 빠르게 지웠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;self-hosted를 고른다면, 진짜 난이도는 runner 설치가 아니라 &lt;b&gt;&quot;환경이 영속적&quot;이라는 데서 오는 코드 서명&amp;middot;키체인&amp;middot;PATH 문제&lt;/b&gt;에 있습니다. 이 글이 그 함정을 미리 피하는 데 도움이 되길 바랍니다.&lt;/p&gt;</description>
      <category>Architecture/CI-CD</category>
      <author>Grace Noh</author>
      <guid isPermaLink="true">https://meercat.tistory.com/511</guid>
      <comments>https://meercat.tistory.com/511#entry511comment</comments>
      <pubDate>Fri, 5 Jun 2026 13:41:41 +0900</pubDate>
    </item>
    <item>
      <title>Yarn Berry PnP 마이그레이션 &amp;mdash; node_modules를 없앤 이유와 과정</title>
      <link>https://meercat.tistory.com/510</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Nx 웹 모노레포에서 Yarn v1(Classic)을 Yarn v4(Berry) + PnP로 전환한 과정을 공유합니다. 왜 전환했는지, PnP가 뭔지, 마이그레이션 중 어떤 문제를 만났는지를 다룹니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;들어가며&quot; data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 팀은 Nx 기반 웹 모노레포를 Yarn v1(Classic)으로 운영하고 있었습니다. 큰 문제 없이 잘 돌아가고 있었는데, 마이그레이션 과정에서 axios, lodash, qs, uuid, react-router 같은 패키지가 &lt;b&gt;package.json에 선언되지 않았는데도 사용되고 있었다&lt;/b&gt;는 사실을 발견했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 현상이 왜 발생하는지, 그리고 이를 해결하기 위해 Yarn Berry의 PnP(Plug'n'Play)로 전환한 과정을 정리합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;Yarn v1의 node_modules, 뭐가 문제인가&quot; data-ke-size=&quot;size26&quot;&gt;Yarn v1의 node_modules, 뭐가 문제인가&lt;/h2&gt;
&lt;h3 data-heading=&quot;호이스팅과 유령 의존성&quot; data-ke-size=&quot;size23&quot;&gt;호이스팅과 유령 의존성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;npm v3 이전에는 각 패키지가 자신의 node_modules/ 안에 의존성을 중첩으로 설치했습니다. 같은 패키지가 여러 번 설치되는 비효율이 있었고, 이를 해결하기 위해 npm v3과 Yarn v1은 &lt;b&gt;호이스팅(hoisting)&lt;/b&gt; 을 도입했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호이스팅은 여러 패키지가 동일한 의존성을 사용할 때, 해당 의존성을 프로젝트 루트의 node_modules/에 한 번만 설치하는 방식입니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# 호이스팅 전 &amp;mdash; 중복 설치
node_modules/
├── package-A/
│   └── node_modules/
│       └── lodash@4.17.21/    &amp;larr; 복사본 1
├── package-B/
│   └── node_modules/
│       └── lodash@4.17.21/    &amp;larr; 복사본 2

# 호이스팅 후 &amp;mdash; 루트에 한 번만 설치
node_modules/
├── lodash@4.17.21/            &amp;larr; 한 번만 설치
├── package-A/
├── package-B/
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중복 설치 문제는 해결되었지만, 부작용이 생겼습니다. &lt;b&gt;직접 의존하지 않는 패키지를 import할 수 있게 된 것&lt;/b&gt;입니다. 이를 &lt;b&gt;유령 의존성(Phantom Dependency)&lt;/b&gt; 이라 부릅니다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;// package.json에 lodash가 없는데도 동작함
// &amp;rarr; 다른 패키지의 의존성이 호이스팅되어 루트에 있기 때문
import _ from 'lodash';
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 왜 문제일까요?&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;호이스팅해준 패키지가 lodash 의존을 제거하면 &amp;rarr; &lt;b&gt;우리 코드가 갑자기 깨짐&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;호이스팅해준 패키지가 lodash 버전을 바꾸면 &amp;rarr; &lt;b&gt;의도하지 않은 버전이 사용됨&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;package.json만 보고는 어떤 패키지를 실제로 사용하는지 &lt;b&gt;파악 불가&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 이번 마이그레이션에서 발견된 유령 의존성은 다음과 같습니다:&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;패키지&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;상황&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;axios&lt;/td&gt;
&lt;td&gt;다른 패키지의 의존성으로 호이스팅되어 사용 중이었음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lodash&lt;/td&gt;
&lt;td&gt;동일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;qs&lt;/td&gt;
&lt;td&gt;동일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;uuid&lt;/td&gt;
&lt;td&gt;동일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;react-router&lt;/td&gt;
&lt;td&gt;react-router-dom의 내부 의존성으로 호이스팅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@toast-ui/editor&lt;/td&gt;
&lt;td&gt;@toast-ui/react-editor의 내부 의존성으로 호이스팅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패키지들은 언제든 예고 없이 사라지거나 버전이 바뀔 수 있었습니다. 지금까지 문제가 없었던 건 운이 좋았을 뿐입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;PnP(Plug'n'Play)란&quot; data-ke-size=&quot;size26&quot;&gt;PnP(Plug'n'Play)란&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Yarn Berry(v2+)가 도입한 의존성 해석 방식입니다. 핵심은 &lt;b&gt;node_modules/를 생성하지 않는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;node_modules 방식&quot; data-ke-size=&quot;size23&quot;&gt;node_modules 방식&lt;/h3&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;node_modules/
├── react/           &amp;larr; 실제 파일이 수만 개 복사됨
├── lodash/
├── axios/
└── ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Node.js가 require('react')를 만나면 node_modules/ 디렉토리를 탐색하여 파일을 찾습니다. 파일이 수만 개이므로 설치도 느리고, 디스크도 많이 차지합니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;PnP 방식&quot; data-ke-size=&quot;size23&quot;&gt;PnP 방식&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;.pnp.cjs             &amp;larr; 패키지 위치 매핑 파일
.yarn/cache/
├── react-npm-19.0.0-abc123.zip
├── lodash-npm-4.17.21-def456.zip
└── ...               &amp;larr; 의존성이 zip으로 압축 저장
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PnP에서는 .pnp.cjs 파일이 &quot;이 패키지는 이 zip 파일 안에 있어&quot;라고 Node.js에 직접 알려줍니다. 디렉토리 탐색이 필요 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.pnp.cjs 안의 실제 매핑은 이렇게 생겼습니다:&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;[&quot;react&quot;, [
  [&quot;npm:19.0.0&quot;, {
    &quot;packageLocation&quot;: &quot;./.yarn/cache/react-npm-19.0.0-abc123.zip/node_modules/react/&quot;,
    &quot;packageDependencies&quot;: [
      [&quot;react&quot;, &quot;npm:19.0.0&quot;],
      [&quot;loose-envify&quot;, &quot;npm:1.4.0&quot;],
      [&quot;object-assign&quot;, &quot;npm:4.1.1&quot;]
    ],
    &quot;linkType&quot;: &quot;HARD&quot;
  }]
]],
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;PnP vs node_modules 비교&quot; data-ke-size=&quot;size23&quot;&gt;PnP vs node_modules 비교&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;항목&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;node_modules&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;PnP&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;설치 속도&lt;/td&gt;
&lt;td&gt;느림 (파일 수만 개 복사)&lt;/td&gt;
&lt;td&gt;빠름 (zip 캐시 활용)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;디스크 사용량&lt;/td&gt;
&lt;td&gt;큼 (중복 설치)&lt;/td&gt;
&lt;td&gt;작음 (zip 압축)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;유령 의존성&lt;/td&gt;
&lt;td&gt;가능 (호이스팅)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;차단&lt;/b&gt; (선언된 것만 접근 가능)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;의존성 정확성&lt;/td&gt;
&lt;td&gt;느슨함&lt;/td&gt;
&lt;td&gt;엄격함&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PnP는 각 패키지가 자신의 package.json에 선언한 의존성만 접근할 수 있도록 강제합니다. 선언하지 않은 패키지를 import하면 &lt;b&gt;즉시 에러가 발생&lt;/b&gt;합니다. 유령 의존성을 원천 차단하는 것입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;Zero-Install&quot; data-ke-size=&quot;size26&quot;&gt;Zero-Install&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PnP의 추가 이점으로, .yarn/cache/와 .pnp.cjs를 git에 포함하면 &lt;b&gt;clone 후 install 없이 바로 실행&lt;/b&gt;할 수 있습니다. 이를 Zero-Install이라 부릅니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;오프라인 상태에서도 프로젝트 실행 가능&lt;/li&gt;
&lt;li&gt;브랜치 전환 시 yarn install 불필요&lt;/li&gt;
&lt;li&gt;의존성도 git으로 버전 관리 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;.gitignore 설정&quot; data-ke-size=&quot;size23&quot;&gt;.gitignore 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Zero-Install 사용 시:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;.yarn/*
!.yarn/cache
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Zero-Install 미사용 시:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;마이그레이션 과정&quot; data-ke-size=&quot;size26&quot;&gt;마이그레이션 과정&lt;/h2&gt;
&lt;h3 data-heading=&quot;Before / After&quot; data-ke-size=&quot;size23&quot;&gt;Before / After&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;항목&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Before&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;After&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yarn 버전&lt;/td&gt;
&lt;td&gt;v1.22.21&lt;/td&gt;
&lt;td&gt;v4.13.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;모듈 해석&lt;/td&gt;
&lt;td&gt;node_modules (hoisting)&lt;/td&gt;
&lt;td&gt;PnP (Plug'n'Play)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;의존성 저장&lt;/td&gt;
&lt;td&gt;node_modules/ 폴더&lt;/td&gt;
&lt;td&gt;.yarn/cache/ (zip 파일)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lockfile 형식&lt;/td&gt;
&lt;td&gt;yarn.lock v1&lt;/td&gt;
&lt;td&gt;yarn.lock v8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-heading=&quot;1. .yarnrc.yml 설정&quot; data-ke-size=&quot;size23&quot;&gt;1. .yarnrc.yml 설정&lt;/h3&gt;
&lt;pre class=&quot;http&quot;&gt;&lt;code&gt;nodeLinker: pnp

packageExtensions:
  &quot;@toast-ui/editor@*&quot;:
    dependencies:
      prosemirror-transform: &quot;*&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;nodeLinker: pnp&lt;/b&gt; &amp;mdash; 의존성 해석 방식을 PnP로 설정합니다. 사용 가능한 옵션:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;pnp &amp;mdash; PnP 모드. node_modules 없이 .yarn/cache의 zip 파일을 직접 참조&lt;/li&gt;
&lt;li&gt;pnpm &amp;mdash; pnpm 스타일의 심볼릭 링크 기반 node_modules 생성&lt;/li&gt;
&lt;li&gt;node-modules &amp;mdash; 기존 Yarn v1과 동일한 방식 (호환성 모드)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;packageExtensions&lt;/b&gt; &amp;mdash; 서드파티 패키지의 누락된 의존성을 보충합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PnP는 각 패키지가 선언한 의존성만 접근을 허용하는데, 일부 서드파티 패키지는 내부적으로 사용하는 의존성을 package.json에 선언하지 않은 경우가 있습니다. 기존 node_modules 방식에서는 호이스팅 덕분에 우연히 접근이 되었지만, PnP에서는 에러가 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 설정은 @toast-ui/editor가 prosemirror-transform을 내부적으로 사용하지만 자체 package.json에 선언하지 않은 것을 보충해줍니다. 이런 경우는 대부분 서드파티 패키지의 문제이며, 직접 작성하는 코드에서는 yarn add로 명시적으로 추가하면 됩니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;2. 유령 의존성 해소&quot; data-ke-size=&quot;size23&quot;&gt;2. 유령 의존성 해소&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PnP로 전환하면 그동안 호이스팅으로 사용되던 미선언 패키지가 전부 에러를 뱉습니다. 하나씩 찾아서 명시적으로 추가했습니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;yarn add axios lodash qs uuid react-router @toast-ui/editor
yarn add -D @types/lodash @types/qs @types/uuid
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;3. 미사용 패키지 정리&quot; data-ke-size=&quot;size23&quot;&gt;3. 미사용 패키지 정리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마이그레이션 과정에서 코드에서 사용하지 않는 패키지도 발견되었습니다. 이 기회에 정리했습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 150px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;패키지&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;비고&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;@prisma/client, prisma&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;코드에서 미사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;bcrypt, @types/bcrypt&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;코드에서 미사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;mysql2&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;코드에서 미사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;jsonwebtoken, @types/jsonwebtoken&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;코드에서 미사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;reflect-metadata&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;코드에서 미사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;register-service-worker&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;코드에서 미사용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 패키지들이 남아있었던 것도 node_modules 방식의 느슨함 때문입니다. 선언만 되어있고 실제로 사용하지 않아도 에러가 발생하지 않으니 방치된 것입니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;4. VS Code SDK 설정&quot; data-ke-size=&quot;size23&quot;&gt;4. VS Code SDK 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PnP에서 TypeScript가 정상 동작하려면 VS Code가 .yarn/cache/ 안의 zip 파일에서 타입 정보를 찾을 수 있어야 합니다. 기본적으로 VS Code는 node_modules/에서 TypeScript를 찾기 때문에 별도 설정이 필요합니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;yarn dlx @yarnpkg/sdks vscode
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행하면 .yarn/sdks/ 폴더에 VS Code용 설정 파일이 생성됩니다. 이후 VS Code에서 Ctrl+Shift+P &amp;rarr; &quot;TypeScript: Select TypeScript Version&quot; &amp;rarr; &lt;b&gt;&quot;Use Workspace Version&quot;&lt;/b&gt; 을 선택하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설정은 레포에 포함되므로 &lt;b&gt;한 번만 실행&lt;/b&gt;하면 됩니다. 다만 새로 clone한 개발자는 &quot;Use Workspace Version&quot; 선택을 잊지 않도록 안내가 필요합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;CI/CD 변경사항&quot; data-ke-size=&quot;size26&quot;&gt;CI/CD 변경사항&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;항목&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Before&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;After&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;설치 명령&lt;/td&gt;
&lt;td&gt;yarn install --frozen-lockfile&lt;/td&gt;
&lt;td&gt;yarn install --immutable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;캐시 대상&lt;/td&gt;
&lt;td&gt;node_modules/&lt;/td&gt;
&lt;td&gt;.yarn/cache/&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;캐시 키&lt;/td&gt;
&lt;td&gt;yarn.lock 해시&lt;/td&gt;
&lt;td&gt;yarn.lock 해시 (동일)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 폭이 크지 않습니다. 설치 명령의 플래그 이름이 바뀌고, 캐시 대상 디렉토리만 바뀝니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;개발자 가이드&quot; data-ke-size=&quot;size26&quot;&gt;개발자 가이드&lt;/h2&gt;
&lt;h3 data-heading=&quot;달라진 점&quot; data-ke-size=&quot;size23&quot;&gt;달라진 점&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 88px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;항목&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;Yarn Classic&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;b&gt;Yarn Berry&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;설치 후 생성&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;node_modules/ 폴더&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;.pnp.cjs + .yarn/cache/&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;lockfile 옵션&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;--frozen-lockfile&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;--immutable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;패키지 실행&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;npx 또는 yarn&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;yarn dlx 또는 yarn&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-heading=&quot;패키지 설치는 동일&quot; data-ke-size=&quot;size23&quot;&gt;패키지 설치는 동일&lt;/h3&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;yarn install     # 의존성 설치
yarn add axios   # 패키지 추가
yarn add -D jest # devDependency 추가
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;주의: 새 패키지 import 시&quot; data-ke-size=&quot;size23&quot;&gt;주의: 새 패키지 import 시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PnP는 package.json에 선언된 의존성만 접근을 허용합니다. 다른 패키지의 하위 의존성을 직접 import하면 에러가 발생합니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;// ❌ 'qs'가 package.json에 없으면 에러
import qs from 'qs';

// ✅ 해결: yarn add qs 로 명시적 추가 후 import
import qs from 'qs';
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;node_modules 방식에서는 호이스팅 덕분에 우연히 동작하던 코드가 PnP에서는 에러를 뱉습니다. &lt;b&gt;&quot;다른 사람 코드에서는 되는데 내 코드에서만 안 돼요&quot;&lt;/b&gt; 같은 상황이 사라집니다. 모든 의존성이 명시적이기 때문입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;왜 앱 프로젝트에서는 PnP를 안 쓰나&quot; data-ke-size=&quot;size26&quot;&gt;왜 앱 프로젝트에서는 PnP를 안 쓰나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 팀은 웹 모노레포에서는 Yarn Berry PnP를, React Native 앱 모노레포에서는 pnpm을 사용합니다. PnP를 앱에서 쓰지 않는 이유는 &lt;b&gt;React Native의 네이티브 빌드 도구가 node_modules/를 직접 탐색&lt;/b&gt;하기 때문입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Metro 번들러&lt;/b&gt;: node_modules/를 탐색하여 JS 모듈을 resolve. PnP의 가상 경로를 이해하지 못함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CocoaPods / Gradle&lt;/b&gt;: node_modules/react-native/... 실제 경로로 네이티브 코드를 찾음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;react-native CLI&lt;/b&gt;: autolinking에서 node_modules/ 안의 네이티브 모듈을 스캔&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹 프로젝트는 이런 제약이 없으므로 PnP의 이점을 최대한 활용할 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;마치며&quot; data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Yarn Berry PnP 마이그레이션의 본질은 &lt;b&gt;&quot;선언하지 않은 건 쓸 수 없다&quot;&lt;/b&gt; 라는 원칙을 강제하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;유령 의존성 원천 차단&lt;/b&gt; &amp;mdash; package.json에 선언한 것만 접근 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;설치 속도 향상&lt;/b&gt; &amp;mdash; zip 캐시 활용, 파일 복사 없음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;디스크 절약&lt;/b&gt; &amp;mdash; 의존성이 zip으로 압축 저장&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Zero-Install 가능&lt;/b&gt; &amp;mdash; clone 후 install 불필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마이그레이션 자체는 어렵지 않았습니다. .yarnrc.yml 설정, 유령 의존성 명시적 추가, VS Code SDK 설정이 전부입니다. 오히려 마이그레이션 과정에서 &quot;이 패키지가 왜 선언 없이 쓰이고 있었지?&quot;를 발견하는 것이 가장 큰 수확이었습니다. 지금까지 동작했던 게 호이스팅이라는 우연 덕분이었다는 것을 알게 되니까요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;node_modules/를 없애는 게 목적이 아니라, &lt;b&gt;의존성을 정확하게 관리하는 것&lt;/b&gt;이 목적입니다. PnP는 그 수단입니다.&lt;/p&gt;</description>
      <category>Architecture/Monorepo</category>
      <category>node_modules</category>
      <category>NX</category>
      <category>Phantom Dependency</category>
      <category>Plug'n'Play</category>
      <category>pnp</category>
      <category>yarn berry</category>
      <category>Zero-Install</category>
      <category>모노레포</category>
      <category>유령 의존성</category>
      <category>패키지 매니저</category>
      <author>Grace Noh</author>
      <guid isPermaLink="true">https://meercat.tistory.com/510</guid>
      <comments>https://meercat.tistory.com/510#entry510comment</comments>
      <pubDate>Fri, 17 Apr 2026 12:54:54 +0900</pubDate>
    </item>
    <item>
      <title>웹 모노레포 CI/CD 개편기 &amp;mdash; 라벨 배포에서 태그 배포로</title>
      <link>https://meercat.tistory.com/509</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Nx 웹 모노레포에서 PR 라벨 기반 배포를 걷어내고, Git 태그 + Doppler로 배포 파이프라인을 재설계한 과정을 공유합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;들어가며&quot; data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 팀은 Nx 기반 웹 모노레포에서 여러 앱을 운영하고 있습니다. 관리자 페이지, 백오피스, 로그인 페이지, 웹뷰 등 성격이 다른 앱들이 하나의 레포에 들어 있고, 각각 독립적으로 빌드/배포됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 PR 라벨로 배포를 트리거하고, 환경변수는 GitHub Secrets에 앱별로 등록하는 방식이었습니다. 앱이 2~3개일 때는 문제가 없었지만, 앱과 환경이 늘어나면서 관리가 어려워졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 &lt;b&gt;기존 방식의 문제점&lt;/b&gt;, &lt;b&gt;태그 기반 배포로의 전환&lt;/b&gt;, &lt;b&gt;Doppler를 활용한 환경변수 관리&lt;/b&gt;, 그리고 &lt;b&gt;최종 CI/CD 파이프라인 구조&lt;/b&gt;를 다룹니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;기존 방식의 문제점&quot; data-ke-size=&quot;size26&quot;&gt;기존 방식의 문제점&lt;/h2&gt;
&lt;h3 data-heading=&quot;PR 라벨 기반 배포&quot; data-ke-size=&quot;size23&quot;&gt;PR 라벨 기반 배포&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 PR에 라벨을 붙여서 어떤 앱을 어떤 환경에 배포할지 결정했습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;PR에 &quot;deploy:care-admin:dev&quot; 라벨 &amp;rarr; 머지 시 care-admin dev 배포
PR에 &quot;deploy:care-admin:prod&quot; 라벨 &amp;rarr; 머지 시 care-admin prod 배포
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱이 적을 때는 괜찮았지만, 앱 &amp;times; 환경 조합이 늘어나면서 문제가 생겼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 상황&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;라벨 조합 폭발&lt;/td&gt;
&lt;td&gt;앱 7개 &amp;times; 환경 2~4개 = 라벨 20개 이상. 어떤 라벨을 붙여야 하는지 헷갈림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실수 위험&lt;/td&gt;
&lt;td&gt;라벨을 잘못 붙이면 의도하지 않은 앱/환경에 배포. 라벨을 빼먹으면 배포 누락&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;배포 시점 제어 불가&lt;/td&gt;
&lt;td&gt;PR 머지 = 배포. &quot;코드는 머지하되 배포는 나중에&quot;가 불가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이력 추적 어려움&lt;/td&gt;
&lt;td&gt;&quot;이 앱이 마지막으로 언제 배포됐지?&quot; &amp;rarr; PR 라벨을 하나하나 뒤져야 함&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-heading=&quot;GitHub Secrets 직접 관리&quot; data-ke-size=&quot;size23&quot;&gt;GitHub Secrets 직접 관리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱별 환경변수(S3 버킷, CloudFront Distribution ID 등)를 GitHub Secrets에 개별 등록했습니다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;AWS_S3_BUCKET_CARE_ADMIN_DEV
AWS_S3_BUCKET_CARE_ADMIN_PROD
AWS_CLOUDFRONT_ID_CARE_ADMIN_DEV
AWS_CLOUDFRONT_ID_CARE_ADMIN_PROD
AWS_S3_BUCKET_BACKOFFICE_DEV
AWS_S3_BUCKET_BACKOFFICE_PROD
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 상황&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Secrets 수 폭발&lt;/td&gt;
&lt;td&gt;앱 추가 시마다 S3 버킷, CloudFront ID, 기타 설정을 수동 등록&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이중 관리&lt;/td&gt;
&lt;td&gt;로컬 개발용 .env와 CI Secrets를 따로 관리. 값이 달라져도 모름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;가시성 부족&lt;/td&gt;
&lt;td&gt;GitHub Secrets는 값을 볼 수 없음. &quot;이 값이 맞나?&quot; 확인 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;개편 방향&quot; data-ke-size=&quot;size26&quot;&gt;개편 방향&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 가지를 바꿨습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;배포 트리거&lt;/b&gt;: PR 라벨 &amp;rarr; &lt;b&gt;Git 태그&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;환경변수 관리&lt;/b&gt;: GitHub Secrets 직접 관리 &amp;rarr; &lt;b&gt;Doppler&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;태그 기반 배포&quot; data-ke-size=&quot;size26&quot;&gt;태그 기반 배포&lt;/h2&gt;
&lt;h3 data-heading=&quot;태그 네이밍 컨벤션&quot; data-ke-size=&quot;size23&quot;&gt;태그 네이밍 컨벤션&lt;/h3&gt;
&lt;pre class=&quot;dust&quot;&gt;&lt;code&gt;{앱이름}/{환경}/YYYY.MM.DD[.N]
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;날짜 기반 버전을 사용합니다. SemVer가 필요한 라이브러리가 아니라 배포 단위의 웹 앱이기 때문입니다.&lt;/li&gt;
&lt;li&gt;같은 날 여러 번 배포 시 suffix를 붙입니다: .1, .2, ...&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;태그 설명&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;care-admin/dev/2026.04.17&lt;/td&gt;
&lt;td&gt;care-admin을 dev 환경에 배포&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;care-admin/prod/2026.04.17&lt;/td&gt;
&lt;td&gt;care-admin을 prod 환경에 배포&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;care-admin/prod/2026.04.17.1&lt;/td&gt;
&lt;td&gt;같은 날 prod 재배포&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rehab-manager/dev/2026.04.17&lt;/td&gt;
&lt;td&gt;rehab-manager를 dev 환경에 배포&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-heading=&quot;배포 방법&quot; data-ke-size=&quot;size23&quot;&gt;배포 방법&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# CLI
git tag care-admin/dev/2026.04.17
git push origin care-admin/dev/2026.04.17
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub GUI로도 가능합니다. Releases &amp;rarr; Draft a new release &amp;rarr; 태그 이름 입력 &amp;rarr; Create new tag &amp;rarr; Publish release.&lt;/p&gt;
&lt;h3 data-heading=&quot;라벨 대비 장점&quot; data-ke-size=&quot;size23&quot;&gt;라벨 대비 장점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PR 라벨 Git 태그&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;배포 시점&lt;/td&gt;
&lt;td&gt;PR 머지 시 (제어 불가)&lt;/td&gt;
&lt;td&gt;태그 push 시 (원하는 시점)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이력 추적&lt;/td&gt;
&lt;td&gt;PR 라벨 뒤지기&lt;/td&gt;
&lt;td&gt;git tag -l &quot;care-admin/prod/*&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실수 가능성&lt;/td&gt;
&lt;td&gt;라벨 선택 실수&lt;/td&gt;
&lt;td&gt;태그 이름이 곧 배포 대상이라 명확&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;롤백&lt;/td&gt;
&lt;td&gt;이전 PR 찾아서 재배포&lt;/td&gt;
&lt;td&gt;이전 태그의 커밋에서 새 태그 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;Doppler로 환경변수 관리&quot; data-ke-size=&quot;size26&quot;&gt;Doppler로 환경변수 관리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.doppler.com&quot; data-tooltip-position=&quot;top&quot;&gt;Doppler&lt;/a&gt;는 환경변수를 중앙에서 관리하는 서비스입니다. 앱별, 환경별로 config를 만들어두면 로컬 개발과 CI 양쪽에서 동일한 환경변수를 사용할 수 있습니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;기존 vs 개편&quot; data-ke-size=&quot;size23&quot;&gt;기존 vs 개편&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Before:&lt;/b&gt; 앱별 S3 버킷, CloudFront ID 등을 GitHub Secrets에 개별 등록&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;# 워크플로우에서 Secrets를 하나하나 꺼내서 주입
env:
  S3_BUCKET: ${{ secrets.AWS_S3_BUCKET_CARE_ADMIN_DEV }}
  CLOUDFRONT_ID: ${{ secrets.AWS_CLOUDFRONT_ID_CARE_ADMIN_DEV }}
  VITE_API_URL: ${{ secrets.VITE_API_URL_CARE_ADMIN_DEV }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;After:&lt;/b&gt; Doppler config 이름 하나만 넘기면 환경변수가 자동 주입&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;# 워크플로우에서 doppler run으로 실행하면 끝
- run: doppler run --project ipixel-admin --config dev_care_manager -- bash scripts/deploy.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;GitHub Secrets에 남는 것&quot; data-ke-size=&quot;size23&quot;&gt;GitHub Secrets에 남는 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Doppler 도입 후 GitHub Secrets에는 &lt;b&gt;인프라 인증 정보만&lt;/b&gt; 남깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Secret 용도&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DOPPLER_TOKEN_*&lt;/td&gt;
&lt;td&gt;앱/환경별 Doppler 접근 토큰&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS_ACCESS_KEY_ID&lt;/td&gt;
&lt;td&gt;AWS 인증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS_SECRET_ACCESS_KEY&lt;/td&gt;
&lt;td&gt;AWS 인증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS_REGION&lt;/td&gt;
&lt;td&gt;AWS 리전&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱별 S3 버킷, CloudFront ID, API URL 등은 전부 Doppler에서 관리합니다. 앱이 추가되어도 GitHub Secrets에 등록할 건 Doppler 토큰 하나뿐입니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;로컬 개발에서의 사용&quot; data-ke-size=&quot;size23&quot;&gt;로컬 개발에서의 사용&lt;/h3&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;# 앱별 .env 파일 다운로드
nx run care-admin:doppler   # &amp;rarr; .env 파일 생성
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CI에서는 doppler run으로 환경변수를 직접 주입하므로 .env 파일이 필요 없습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;CI/CD 파이프라인 구조&quot; data-ke-size=&quot;size26&quot;&gt;CI/CD 파이프라인 구조&lt;/h2&gt;
&lt;h3 data-heading=&quot;워크플로우 파일&quot; data-ke-size=&quot;size23&quot;&gt;워크플로우 파일&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일 역할&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;workspace-ci.yml&lt;/td&gt;
&lt;td&gt;PR 시 nx affected --target=build로 영향받은 앱만 빌드 검증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;workspace-cd.yml&lt;/td&gt;
&lt;td&gt;태그 push 트리거. 태그 prefix로 앱/환경을 분기하여 cd.yml 호출&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cd.yml&lt;/td&gt;
&lt;td&gt;재사용 워크플로우. 실제 빌드 + 배포 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 workspace-cd.yml과 cd.yml의 분리입니다. workspace-cd.yml은 태그를 파싱해서 &quot;어떤 앱을, 어떤 환경으로&quot; 배포할지 결정하고, 실제 빌드/배포 로직은 cd.yml에 위임합니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;cd.yml &amp;mdash; 재사용 워크플로우&quot; data-ke-size=&quot;size23&quot;&gt;cd.yml &amp;mdash; 재사용 워크플로우&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# cd.yml (재사용 워크플로우)
inputs:
  script-path     # 실행할 배포 스크립트 경로
  doppler-config  # Doppler config 이름

secrets:
  DOPPLER_TOKEN
  AWS_ACCESS_KEY_ID
  AWS_SECRET_ACCESS_KEY
  AWS_REGION
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 앱의 배포가 동일한 흐름을 따릅니다:&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;코드 체크아웃
&amp;rarr; Doppler CLI 설치
&amp;rarr; node_modules 캐시 복원
&amp;rarr; AWS 인증
&amp;rarr; doppler run으로 환경변수 주입 + 배포 스크립트 실행
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;배포 스크립트 프로세스&quot; data-ke-size=&quot;size23&quot;&gt;배포 스크립트 프로세스&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 앱의 배포 스크립트(scripts/deploy_*.sh)는 동일한 구조입니다:&lt;/p&gt;
&lt;pre class=&quot;dsconfig&quot;&gt;&lt;code&gt;#!/bin/bash
set -e

# 1. 의존성 설치
yarn install --immutable

# 2. 빌드 (Nx 캐시 스킵 &amp;mdash; CI에서는 항상 fresh 빌드)
yarn build:care-dev --skip-nx-cache

# 3. 기존 파일 삭제 + 빌드 결과물 업로드
aws s3 rm s3://$S3_BUCKET --recursive
aws s3 sync dist/ s3://$S3_BUCKET --delete

# 4. index.html에 no-cache 메타데이터 설정
aws s3 cp s3://$S3_BUCKET/index.html s3://$S3_BUCKET/index.html \
  --metadata-directive REPLACE --cache-control &quot;no-cache&quot;

# 5. CloudFront 캐시 무효화
aws cloudfront create-invalidation \
  --distribution-id $CLOUDFRONT_ID --paths &quot;/*&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;S3_BUCKET, CLOUDFRONT_ID 등의 환경변수는 스크립트에 하드코딩하지 않습니다. doppler run이 Doppler config에서 가져와 자동으로 주입해줍니다. 덕분에 &lt;b&gt;하나의 스크립트 구조로 모든 앱/환경을 처리&lt;/b&gt;할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;set -e가 적용되어 있어서 빌드가 실패하면 S3 업로드 없이 즉시 중단됩니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;CI 흐름 (PR)&quot; data-ke-size=&quot;size23&quot;&gt;CI 흐름 (PR)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PR이 main으로 열리면:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;PR &amp;rarr; nx affected --target=build &amp;rarr; 변경된 앱만 빌드 검증
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 앱을 빌드하지 않고 nx affected로 변경된 앱만 검증합니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;CD 흐름 (배포)&quot; data-ke-size=&quot;size23&quot;&gt;CD 흐름 (배포)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;태그가 push되면:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;태그 push
&amp;rarr; workspace-cd.yml: 태그 prefix로 앱/환경 분기
&amp;rarr; cd.yml: Doppler config + 배포 스크립트 전달
&amp;rarr; doppler run으로 환경변수 주입 + 스크립트 실행
&amp;rarr; S3 업로드 + CloudFront 캐시 무효화
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;전체 구성 요약&quot; data-ke-size=&quot;size26&quot;&gt;전체 구성 요약&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-04-17 오후 12.47.12.png&quot; data-origin-width=&quot;663&quot; data-origin-height=&quot;882&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CifdP/dJMcacbIAO7/wnjQ5PnpKej6PmWA0gAja1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CifdP/dJMcacbIAO7/wnjQ5PnpKej6PmWA0gAja1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CifdP/dJMcacbIAO7/wnjQ5PnpKej6PmWA0gAja1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCifdP%2FdJMcacbIAO7%2FwnjQ5PnpKej6PmWA0gAja1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;663&quot; height=&quot;882&quot; data-filename=&quot;스크린샷 2026-04-17 오후 12.47.12.png&quot; data-origin-width=&quot;663&quot; data-origin-height=&quot;882&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항목 Before After&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;배포 트리거&lt;/td&gt;
&lt;td&gt;PR 라벨&lt;/td&gt;
&lt;td&gt;Git 태그 (앱/환경/날짜)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;환경변수 관리&lt;/td&gt;
&lt;td&gt;GitHub Secrets (앱별 개별 등록)&lt;/td&gt;
&lt;td&gt;Doppler (중앙 관리)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI 검증&lt;/td&gt;
&lt;td&gt;전체 빌드&lt;/td&gt;
&lt;td&gt;nx affected (변경된 앱만)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;배포 워크플로우&lt;/td&gt;
&lt;td&gt;앱별 개별 워크플로우&lt;/td&gt;
&lt;td&gt;workspace-cd.yml + cd.yml 재사용 구조&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;롤백&lt;/td&gt;
&lt;td&gt;이전 PR 찾아서 재배포&lt;/td&gt;
&lt;td&gt;이전 태그 커밋에서 새 태그 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;인프라 구성&quot; data-ke-size=&quot;size26&quot;&gt;인프라 구성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항목 기술&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;호스팅&lt;/td&gt;
&lt;td&gt;AWS S3 (정적 파일)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDN&lt;/td&gt;
&lt;td&gt;AWS CloudFront&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;환경변수&lt;/td&gt;
&lt;td&gt;Doppler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;빌드 도구&lt;/td&gt;
&lt;td&gt;Nx + Vite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;패키지 매니저&lt;/td&gt;
&lt;td&gt;Yarn Berry (PnP)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI/CD&lt;/td&gt;
&lt;td&gt;GitHub Actions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;주의사항&quot; data-ke-size=&quot;size26&quot;&gt;주의사항&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;macOS 대소문자&lt;/b&gt;: macOS는 파일명 대소문자를 구분하지 않지만, CI(Linux)에서는 구분합니다. import 경로의 대소문자를 정확히 맞춰야 CI에서 빌드가 깨지지 않습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;빌드 실패 시 안전장치&lt;/b&gt;: 모든 배포 스크립트에 set -e가 적용되어 있어, 빌드가 실패하면 S3 업로드 없이 즉시 중단됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;롤백 방법&lt;/b&gt;: 이전 태그의 커밋에서 새 태그를 생성하여 재배포하면 됩니다. 별도의 롤백 메커니즘은 필요 없습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;마치며&quot; data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 이번 개편에서 바꾼 건 두 가지입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;배포 트리거를 PR 라벨에서 Git 태그로&lt;/b&gt; &amp;mdash; 배포 시점을 명시적으로 제어하고, 태그 자체가 배포 이력이 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;환경변수를 GitHub Secrets에서 Doppler로&lt;/b&gt; &amp;mdash; 로컬/CI 환경변수를 한 곳에서 관리하고, 앱 추가 시 Secrets 수동 등록 작업을 없앴습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특별히 복잡한 기술을 도입한 것은 아닙니다. 태그 네이밍 컨벤션을 정하고, 재사용 워크플로우로 중복을 제거하고, 환경변수 관리를 외부 서비스로 위임한 것뿐입니다. 하지만 이 세 가지만으로도 &quot;이 앱 어디에 배포된 거지?&quot;, &quot;이 환경변수 값이 맞나?&quot; 같은 질문이 사라졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모노레포에서 앱이 늘어날수록 CI/CD 관리 비용은 빠르게 증가합니다. 비슷한 고민을 하고 계시다면, &lt;b&gt;태그 컨벤션 하나 정하는 것&lt;/b&gt;부터 시작해보시길 추천드립니다.&lt;/p&gt;</description>
      <category>Architecture/CI-CD</category>
      <category>AWS S3</category>
      <category>CI/CD</category>
      <category>CloudFront</category>
      <category>Doppler</category>
      <category>Git 태그</category>
      <category>github actions</category>
      <category>NX</category>
      <category>모노레포</category>
      <category>배포 자동화</category>
      <category>환경변수</category>
      <author>Grace Noh</author>
      <guid isPermaLink="true">https://meercat.tistory.com/509</guid>
      <comments>https://meercat.tistory.com/509#entry509comment</comments>
      <pubDate>Fri, 17 Apr 2026 12:48:19 +0900</pubDate>
    </item>
    <item>
      <title>React Native 모노레포에서 앱 빌드 및 배포 전략 설계하기</title>
      <link>https://meercat.tistory.com/508</link>
      <description>&lt;h2 data-heading=&quot;들어가며&quot; data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 팀은 4개의 React Native 앱을 운영하고 있습니다. 처음에는 각각 독립된 레포지토리로 관리했지만, 앱이 늘어나면서 공통 코드 중복, 의존성 파편화, CI/CD 파이프라인 중복 등의 문제가 반복되었습니다. 이를 해결하기 위해 &lt;b&gt;Nx + pnpm 기반 모노레포&lt;/b&gt;로 통합했고, 이 글에서는 통합 이후 &lt;b&gt;빌드 타입 분리, 브랜치 전략, 태그 기반 배포, CodePush 운영 방식&lt;/b&gt;을 어떻게 설계했는지 다룹니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모노레포 자체의 마이그레이션 과정은 이 글의 범위를 벗어나므로, 빌드와 배포 전략에 집중합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;서버 환경 구성&quot; data-ke-size=&quot;size26&quot;&gt;서버 환경 구성&lt;/h2&gt;
&lt;h3 data-heading=&quot;제약 조건&quot; data-ke-size=&quot;size23&quot;&gt;제약 조건&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계에 앞서 몇 가지 제약 조건이 있었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버팀 요구사항: &lt;b&gt;테스트 데이터가 Prod DB에 올라가면 안 됨&lt;/b&gt; &amp;rarr; Stage 서버 사용 필수&lt;/li&gt;
&lt;li&gt;내부 테스트(QA)와 심사 제출이 별도의 빌드로 이루어져야 함&lt;/li&gt;
&lt;li&gt;스토어 심사 제출은 수동으로 진행 (CI에서 빌드까지만)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;환경 정의&quot; data-ke-size=&quot;size23&quot;&gt;환경 정의&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;환경 용도 비고&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Dev&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;로컬 개발&lt;/td&gt;
&lt;td&gt;개발자 로컬에서만 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Stage&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;내부 테스트 (QA, 기능 검증)&lt;/td&gt;
&lt;td&gt;테스트 데이터 격리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Prod&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;스토어 릴리즈 / 심사 제출&lt;/td&gt;
&lt;td&gt;실 운영 데이터&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;환경이 3개인 이유는 단순합니다. 로컬 개발 환경과 QA 환경에서 Prod 데이터를 건드리면 안 되고, QA에서 검증된 코드만 Prod 빌드로 만들어야 하기 때문입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;빌드 타입과 배포 트랙&quot; data-ke-size=&quot;size26&quot;&gt;빌드 타입과 배포 트랙&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-04-17 오후 12.32.32.png&quot; data-origin-width=&quot;821&quot; data-origin-height=&quot;403&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nHSiM/dJMcaaLKtUU/YvBbMoADkk0vbq17dwmT3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nHSiM/dJMcaaLKtUU/YvBbMoADkk0vbq17dwmT3K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nHSiM/dJMcaaLKtUU/YvBbMoADkk0vbq17dwmT3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnHSiM%2FdJMcaaLKtUU%2FYvBbMoADkk0vbq17dwmT3K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;821&quot; height=&quot;403&quot; data-filename=&quot;스크린샷 2026-04-17 오후 12.32.32.png&quot; data-origin-width=&quot;821&quot; data-origin-height=&quot;403&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드 타입 서버 엔드포인트 배포 대상 트리거 시점 배포 방식&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dev&lt;/td&gt;
&lt;td&gt;Dev&lt;/td&gt;
&lt;td&gt;로컬 실행&lt;/td&gt;
&lt;td&gt;개발 중 상시&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage&lt;/td&gt;
&lt;td&gt;Stage&lt;/td&gt;
&lt;td&gt;내부 테스트 업로드&lt;/td&gt;
&lt;td&gt;QA / 기능 검증 시&lt;/td&gt;
&lt;td&gt;CI 자동&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prod&lt;/td&gt;
&lt;td&gt;Prod&lt;/td&gt;
&lt;td&gt;스토어 심사 제출&lt;/td&gt;
&lt;td&gt;릴리즈 확정 시&lt;/td&gt;
&lt;td&gt;CI 빌드 &amp;rarr; &lt;b&gt;수동 심사 제출&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 가지 자주 받는 질문이 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Stage에서 테스트한 빌드를 그대로 스토어에 올리면 안 되나요?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;안 됩니다.&lt;/b&gt; Stage 빌드와 Prod 빌드는 서버 엔드포인트가 다릅니다. Stage 빌드는 Stage 서버를 바라보고, Prod 빌드는 Prod 서버를 바라봅니다. 동일한 바이너리를 재사용할 수 없으므로 &lt;b&gt;빌드가 2번 발생하는 것은 정상적인 플로우&lt;/b&gt;입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;브랜치 전략&quot; data-ke-size=&quot;size26&quot;&gt;브랜치 전략&lt;/h2&gt;
&lt;h3 data-heading=&quot;왜 Trunk-based가 아닌가&quot; data-ke-size=&quot;size23&quot;&gt;왜 Trunk-based가 아닌가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모노레포에서 Trunk-based Development(main 단일 브랜치)를 쓰면 Stage/Prod 빌드 분리가 까다로워집니다. main에 머지하는 순간 Prod 배포가 트리거되면, 아직 QA가 끝나지 않은 코드도 함께 나갈 수 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;b&gt;develop 브랜치를 두고, Stage/Prod 배포 시점을 분리&lt;/b&gt;하는 전략을 선택했습니다.&lt;/p&gt;
&lt;h3 data-heading=&quot;브랜치 종류&quot; data-ke-size=&quot;size23&quot;&gt;브랜치 종류&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브랜치 분기 기준 머지 대상 용도&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;main&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;Prod 릴리즈된 안정 코드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;develop&lt;/td&gt;
&lt;td&gt;main&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;개발 통합 브랜치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;feat/*&lt;/td&gt;
&lt;td&gt;develop&lt;/td&gt;
&lt;td&gt;develop&lt;/td&gt;
&lt;td&gt;새 기능 개발&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fix/*&lt;/td&gt;
&lt;td&gt;develop&lt;/td&gt;
&lt;td&gt;develop&lt;/td&gt;
&lt;td&gt;일반 버그 수정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hotfix/*&lt;/td&gt;
&lt;td&gt;main&lt;/td&gt;
&lt;td&gt;main + develop&lt;/td&gt;
&lt;td&gt;Prod 긴급 수정 (CodePush 포함)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-heading=&quot;브랜치 플로우&quot; data-ke-size=&quot;size23&quot;&gt;브랜치 플로우&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-04-17 오후 12.33.04.png&quot; data-origin-width=&quot;747&quot; data-origin-height=&quot;443&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4dTxN/dJMcaiCZhvU/BRAh8PYRe1RJ9EvUdZH8gk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4dTxN/dJMcaiCZhvU/BRAh8PYRe1RJ9EvUdZH8gk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4dTxN/dJMcaiCZhvU/BRAh8PYRe1RJ9EvUdZH8gk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4dTxN%2FdJMcaiCZhvU%2FBRAh8PYRe1RJ9EvUdZH8gk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;747&quot; height=&quot;443&quot; data-filename=&quot;스크린샷 2026-04-17 오후 12.33.04.png&quot; data-origin-width=&quot;747&quot; data-origin-height=&quot;443&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-heading=&quot;시나리오별 상세&quot; data-ke-size=&quot;size23&quot;&gt;시나리오별 상세&lt;/h3&gt;
&lt;h4 data-heading=&quot;1. 기능 개발 / 버그 수정&quot; data-ke-size=&quot;size20&quot;&gt;1. 기능 개발 / 버그 수정&lt;/h4&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;develop에서 분기 &amp;rarr; 개발 &amp;rarr; develop에 머지 &amp;rarr; Stage 태그
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;develop에서 분기하고 develop으로만 머지합니다. 머지 후 Stage 태그를 찍어 내부 테스트 빌드를 트리거합니다.&lt;/p&gt;
&lt;h4 data-heading=&quot;2. QA 완료 후 릴리즈&quot; data-ke-size=&quot;size20&quot;&gt;2. QA 완료 후 릴리즈&lt;/h4&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;develop &amp;rarr; main 머지 &amp;rarr; Prod 태그 &amp;rarr; CI 빌드 &amp;rarr; 수동 심사 제출
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Stage 테스트 완료 후 develop을 main에 머지합니다. main 머지 시점에 Prod 태그를 찍어 빌드를 트리거하고, CI가 빌드 산출물(AAB/IPA)을 생성하면 수동으로 스토어에 심사 제출합니다.&lt;/p&gt;
&lt;h4 data-heading=&quot;3. Prod 긴급 수정 (hotfix)&quot; data-ke-size=&quot;size20&quot;&gt;3. Prod 긴급 수정 (hotfix)&lt;/h4&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;main에서 분기 &amp;rarr; 수정 &amp;rarr; main에 머지 &amp;rarr; CodePush 태그
                     &amp;rarr; develop에도 머지 (동기화)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;main에서 분기하는 이유는 Prod 코드 기준으로 수정해야 하기 때문입니다. &lt;b&gt;main + develop 양쪽에 머지&lt;/b&gt;해야 합니다. develop에 안 넣으면 다음 릴리즈에서 수정이 빠집니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;태그 기반 배포 전략&quot; data-ke-size=&quot;size26&quot;&gt;태그 기반 배포 전략&lt;/h2&gt;
&lt;h3 data-heading=&quot;왜 브랜치가 아니라 태그인가&quot; data-ke-size=&quot;size23&quot;&gt;왜 브랜치가 아니라 태그인가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모노레포에서 앱별 독립 배포를 할 때, &lt;b&gt;브랜치 기반은 동작하지 않습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브랜치는 레포 전체에 걸리기 때문에 앱별로 독립적인 릴리즈가 불가능합니다. 예를 들어 App A는 아직 Stage 테스트 중인데 App B는 심사를 올려야 하는 상황에서, 하나의 release 브랜치로는 이를 분리할 수 없습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-04-17 오후 12.33.32.png&quot; data-origin-width=&quot;745&quot; data-origin-height=&quot;359&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vZ3rV/dJMcaiQu5WL/LJBizlCFnFSYSBcvKiNOlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vZ3rV/dJMcaiQu5WL/LJBizlCFnFSYSBcvKiNOlK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vZ3rV/dJMcaiQu5WL/LJBizlCFnFSYSBcvKiNOlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvZ3rV%2FdJMcaiQu5WL%2FLJBizlCFnFSYSBcvKiNOlK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;745&quot; height=&quot;359&quot; data-filename=&quot;스크린샷 2026-04-17 오후 12.33.32.png&quot; data-origin-width=&quot;745&quot; data-origin-height=&quot;359&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;태그는 앱 이름을 포함하므로 &lt;b&gt;앱별로 독립적인 빌드 트리거&lt;/b&gt;가 가능합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브랜치 기반 태그 기반&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;앱별 독립 릴리즈&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;❌ 불가&lt;/td&gt;
&lt;td&gt;✅ 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;브랜치 수&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;앱 &amp;times; 환경만큼 증가&lt;/td&gt;
&lt;td&gt;develop, main 정도만 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;빌드 타입 결정&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;브랜치로 결정&lt;/td&gt;
&lt;td&gt;태그 패턴으로 결정&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-heading=&quot;태그 컨벤션&quot; data-ke-size=&quot;size23&quot;&gt;태그 컨벤션&lt;/h3&gt;
&lt;pre class=&quot;dust&quot;&gt;&lt;code&gt;{앱 이름}/{빌드 타입}/{버전}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;app-a/stage/1.0.0         &amp;rarr; App A Stage 빌드 &amp;rarr; 내부 테스트
app-a/prod/1.0.0          &amp;rarr; App A Prod 빌드 &amp;rarr; 심사 제출
app-a/codepush/1.0.0-cp.1 &amp;rarr; App A CodePush &amp;rarr; OTA 배포
app-b/stage/2.1.0         &amp;rarr; App B Stage 빌드 &amp;rarr; 내부 테스트
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴 하나로 &lt;b&gt;어떤 앱을, 어떤 빌드 타입으로, 어떤 버전으로&lt;/b&gt; 배포하는지가 결정됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;CodePush (OTA 업데이트)&quot; data-ke-size=&quot;size26&quot;&gt;CodePush (OTA 업데이트)&lt;/h2&gt;
&lt;h3 data-heading=&quot;네이티브 빌드 vs CodePush&quot; data-ke-size=&quot;size23&quot;&gt;네이티브 빌드 vs CodePush&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네이티브 빌드 CodePush&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;배포 대상&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;바이너리 전체 (네이티브 + JS)&lt;/td&gt;
&lt;td&gt;JS 번들 + 에셋만&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;심사 필요&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;✅ 필요&lt;/td&gt;
&lt;td&gt;❌ 불필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;배포 속도&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;심사 대기 (1~3일)&lt;/td&gt;
&lt;td&gt;즉시 반영&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;적용 범위&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;네이티브 코드 변경 포함&lt;/td&gt;
&lt;td&gt;JS/에셋 변경만 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-heading=&quot;적용 범위: Prod 빌드에서만 사용&quot; data-ke-size=&quot;size23&quot;&gt;적용 범위: Prod 빌드에서만 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CodePush는 &lt;b&gt;스토어에 릴리즈된 Prod 바이너리에 대한 핫픽스/긴급 패치 용도로만 사용&lt;/b&gt;합니다. Stage 환경에서는 CodePush를 사용하지 않고, 내부 테스트는 항상 네이티브 빌드로 진행합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-04-17 오후 12.33.55.png&quot; data-origin-width=&quot;573&quot; data-origin-height=&quot;410&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kCSHf/dJMcabqmSu7/vKT6k2VRRWeRdR5OopZK71/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kCSHf/dJMcabqmSu7/vKT6k2VRRWeRdR5OopZK71/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kCSHf/dJMcabqmSu7/vKT6k2VRRWeRdR5OopZK71/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkCSHf%2FdJMcabqmSu7%2FvKT6k2VRRWeRdR5OopZK71%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;573&quot; height=&quot;410&quot; data-filename=&quot;스크린샷 2026-04-17 오후 12.33.55.png&quot; data-origin-width=&quot;573&quot; data-origin-height=&quot;410&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Stage에서 CodePush를 쓰지 않는 이유:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Stage는 내부 테스트용이라 네이티브 빌드를 다시 올리면 됨 (심사 대기 없음)&lt;/li&gt;
&lt;li&gt;CodePush 환경을 Stage/Prod 이중으로 관리하면 복잡도만 증가&lt;/li&gt;
&lt;li&gt;CodePush의 핵심 가치는 &lt;b&gt;심사 없이 Prod 사용자에게 즉시 배포&lt;/b&gt;하는 것&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;CodePush 판단 기준&quot; data-ke-size=&quot;size23&quot;&gt;CodePush 판단 기준&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;긴급 수정이 발생했을 때, CodePush로 배포할 수 있는지 판단하는 기준입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-04-17 오후 12.34.23.png&quot; data-origin-width=&quot;491&quot; data-origin-height=&quot;938&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b0Zdxt/dJMcacbIzSH/PqD7d7MKZhaJgkR5VQd53k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b0Zdxt/dJMcacbIzSH/PqD7d7MKZhaJgkR5VQd53k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b0Zdxt/dJMcacbIzSH/PqD7d7MKZhaJgkR5VQd53k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb0Zdxt%2FdJMcacbIzSH%2FPqD7d7MKZhaJgkR5VQd53k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;491&quot; height=&quot;938&quot; data-filename=&quot;스크린샷 2026-04-17 오후 12.34.23.png&quot; data-origin-width=&quot;491&quot; data-origin-height=&quot;938&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나라도 &quot;Yes&quot;이면 네이티브 빌드가 필요합니다. 모두 &quot;No&quot;일 때만 CodePush로 즉시 배포할 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;CI/CD 파이프라인&quot; data-ke-size=&quot;size26&quot;&gt;CI/CD 파이프라인&lt;/h2&gt;
&lt;h3 data-heading=&quot;태그 &amp;rarr; 빌드 &amp;rarr; 배포 흐름&quot; data-ke-size=&quot;size23&quot;&gt;태그 &amp;rarr; 빌드 &amp;rarr; 배포 흐름&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-04-17 오후 12.34.40.png&quot; data-origin-width=&quot;869&quot; data-origin-height=&quot;749&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vgQOo/dJMcabjBLCG/lshn74nC3nLpTCZtXECOr1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vgQOo/dJMcabjBLCG/lshn74nC3nLpTCZtXECOr1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vgQOo/dJMcabjBLCG/lshn74nC3nLpTCZtXECOr1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvgQOo%2FdJMcabjBLCG%2Flshn74nC3nLpTCZtXECOr1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;869&quot; height=&quot;749&quot; data-filename=&quot;스크린샷 2026-04-17 오후 12.34.40.png&quot; data-origin-width=&quot;869&quot; data-origin-height=&quot;749&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-heading=&quot;GitHub Actions 설정 예시&quot; data-ke-size=&quot;size23&quot;&gt;GitHub Actions 설정 예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;태그 패턴으로 앱과 빌드 타입을 파싱하는 방식입니다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;on:
  push:
    tags:
      - 'app-a/**'
      - 'app-b/**'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: 태그에서 앱/빌드타입/버전 파싱
        run: |
          TAG=${GITHUB_REF#refs/tags/}
          APP=$(echo $TAG | cut -d'/' -f1)
          BUILD_TYPE=$(echo $TAG | cut -d'/' -f2)   # stage, prod, codepush
          VERSION=$(echo $TAG | cut -d'/' -f3)

      # Stage 빌드
      - name: Stage 빌드 &amp;rarr; 내부 테스트 업로드
        if: contains(github.ref, '/stage/')
        run: # Stage 엔드포인트로 빌드 후 App Distribution / TestFlight 업로드

      # Prod 빌드
      - name: Prod 빌드 &amp;rarr; 산출물 저장
        if: contains(github.ref, '/prod/')
        run: # Prod 엔드포인트로 빌드 후 산출물(AAB/IPA) 저장 (심사 제출은 수동)

      # CodePush (Prod만)
      - name: CodePush &amp;rarr; Prod
        if: contains(github.ref, '/codepush/')
        run: # appcenter codepush release --deployment-name Production
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;전체 배포 플로우 요약&quot; data-ke-size=&quot;size26&quot;&gt;전체 배포 플로우 요약&lt;/h2&gt;
&lt;h3 data-heading=&quot;네이티브 빌드 경로 (일반 개발)&quot; data-ke-size=&quot;size23&quot;&gt;네이티브 빌드 경로 (일반 개발)&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;로컬에서 Dev 서버로 개발&lt;/li&gt;
&lt;li&gt;PR &amp;rarr; 코드 리뷰 &amp;rarr; develop 머지&lt;/li&gt;
&lt;li&gt;Stage 태그 &amp;rarr; 자동 Stage 빌드 &amp;rarr; 내부 테스트 업로드&lt;/li&gt;
&lt;li&gt;QA 완료 &amp;rarr; develop을 main에 머지&lt;/li&gt;
&lt;li&gt;Prod 태그 &amp;rarr; 자동 Prod 빌드 &amp;rarr; 산출물 저장 &amp;rarr; &lt;b&gt;수동으로 심사 제출&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-heading=&quot;CodePush 경로 (Prod 핫픽스)&quot; data-ke-size=&quot;size23&quot;&gt;CodePush 경로 (Prod 핫픽스)&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;스토어 릴리즈 후 Prod에서 긴급 이슈 발견 (JS/에셋 수준)&lt;/li&gt;
&lt;li&gt;main에서 hotfix/* 브랜치 분기 &amp;rarr; 수정&lt;/li&gt;
&lt;li&gt;main + develop 양쪽에 머지&lt;/li&gt;
&lt;li&gt;CodePush 태그 &amp;rarr; Prod 키로 CodePush 릴리즈 &amp;rarr; 점진적 롤아웃&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;마치며&quot; data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 전략의 핵심은 &lt;b&gt;태그 컨벤션 하나로 앱, 빌드 타입, 버전을 결정&lt;/b&gt;한다는 점입니다. 모노레포에서 여러 앱을 운영하면 배포 복잡도가 급격히 올라가는데, 태그 기반 배포는 이를 단순하고 예측 가능한 구조로 만들어줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;서버 환경은 3개&lt;/b&gt; (Dev / Stage / Prod), Stage와 Prod는 별도 빌드&lt;/li&gt;
&lt;li&gt;&lt;b&gt;브랜치는 2개&lt;/b&gt; (develop + main), 배포 시점은 태그로 제어&lt;/li&gt;
&lt;li&gt;&lt;b&gt;태그 패턴 하나&lt;/b&gt;로 앱별 독립 배포 (앱/빌드타입/버전)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CodePush는 Prod 핫픽스 전용&lt;/b&gt;, JS/에셋 변경만 가능할 때 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조로 전환한 뒤 &quot;어떤 앱이 어떤 상태인지&quot; 파악하는 것이 훨씬 명확해졌고, 배포 실수도 줄어들었습니다. 비슷한 고민을 하고 계신 팀에 도움이 되었으면 합니다.&lt;/p&gt;</description>
      <category>Architecture/CI-CD</category>
      <category>CI/CD</category>
      <category>CodePush</category>
      <category>github actions</category>
      <category>NX</category>
      <category>pnpm</category>
      <category>React Native</category>
      <category>모노레포</category>
      <category>브랜치 전략</category>
      <category>앱 배포</category>
      <category>태그 기반 배포</category>
      <author>Grace Noh</author>
      <guid isPermaLink="true">https://meercat.tistory.com/508</guid>
      <comments>https://meercat.tistory.com/508#entry508comment</comments>
      <pubDate>Fri, 17 Apr 2026 12:45:29 +0900</pubDate>
    </item>
    <item>
      <title>실행 컨텍스트(Execution Context) 완벽 이해</title>
      <link>https://meercat.tistory.com/507</link>
      <description>&lt;p data-end=&quot;256&quot; data-start=&quot;182&quot; data-ke-size=&quot;size16&quot;&gt;자바스크립트를 조금 깊게 공부하다 보면&lt;br /&gt;&lt;b&gt;&amp;ldquo;실행 컨텍스트(Execution Context)&amp;rdquo;&lt;/b&gt; 라는 용어가 꼭 등장합니다.&lt;/p&gt;
&lt;p data-end=&quot;342&quot; data-start=&quot;258&quot; data-ke-size=&quot;size16&quot;&gt;이 개념은 스코프(Scope), 클로저(Closure), this 바인딩 같은&lt;br /&gt;자바스크립트의 핵심 원리를 이해하기 위한 &lt;b&gt;기초 중의 기초&lt;/b&gt;예요.&lt;/p&gt;
&lt;hr data-end=&quot;347&quot; data-start=&quot;344&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;364&quot; data-start=&quot;349&quot; data-ke-size=&quot;size26&quot;&gt;  실행 컨텍스트란?&lt;/h2&gt;
&lt;blockquote data-end=&quot;392&quot; data-start=&quot;366&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;392&quot; data-start=&quot;368&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;자바스크립트 코드가 실행되는 환경&amp;rdquo;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;478&quot; data-start=&quot;394&quot; data-ke-size=&quot;size16&quot;&gt;조금 더 구체적으로 말하자면,&lt;br /&gt;자바스크립트 엔진이 코드를 실행할 때&lt;br /&gt;&lt;b&gt;변수, 함수, this, 스코프 정보 등을 관리하기 위한 객체&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-end=&quot;560&quot; data-start=&quot;480&quot; data-ke-size=&quot;size16&quot;&gt;즉, 어떤 코드가 &lt;b&gt;&amp;ldquo;어디서, 어떤 환경에서, 어떤 변수들을 가지고 실행되는가&amp;rdquo;&lt;/b&gt;&lt;br /&gt;를 결정하는 일종의 &lt;b&gt;실행 박스&lt;/b&gt;라고 보면 됩니다.&lt;/p&gt;
&lt;hr data-end=&quot;565&quot; data-start=&quot;562&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;584&quot; data-start=&quot;567&quot; data-ke-size=&quot;size26&quot;&gt;⚙️ 실행 컨텍스트의 종류&lt;/h2&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 88px;&quot; border=&quot;1&quot; data-end=&quot;856&quot; data-start=&quot;586&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 22px;&quot;&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;&lt;b&gt;종류&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 22px;&quot;&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 22px;&quot; data-end=&quot;690&quot; data-start=&quot;614&quot;&gt;
&lt;td style=&quot;height: 22px;&quot; data-col-size=&quot;sm&quot; data-end=&quot;645&quot; data-start=&quot;614&quot;&gt;&lt;b&gt;전역 컨텍스트 (Global Context)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 22px;&quot; data-end=&quot;690&quot; data-start=&quot;645&quot; data-col-size=&quot;md&quot;&gt;코드가 처음 실행될 때 단 한 번 생성. 전역 변수, 함수 선언이 등록됨.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 22px;&quot; data-end=&quot;778&quot; data-start=&quot;691&quot;&gt;
&lt;td style=&quot;height: 22px;&quot; data-col-size=&quot;sm&quot; data-end=&quot;724&quot; data-start=&quot;691&quot;&gt;&lt;b&gt;함수 컨텍스트 (Function Context)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 22px;&quot; data-end=&quot;778&quot; data-start=&quot;724&quot; data-col-size=&quot;md&quot;&gt;함수가 호출될 때마다 새로 생성. 함수 내 지역 변수, 매개변수, 내부 함수 등이 등록됨.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 22px;&quot; data-end=&quot;856&quot; data-start=&quot;779&quot;&gt;
&lt;td style=&quot;height: 22px;&quot; data-col-size=&quot;sm&quot; data-end=&quot;810&quot; data-start=&quot;779&quot;&gt;&lt;b&gt;Eval 컨텍스트 (Eval Context)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 22px;&quot; data-end=&quot;856&quot; data-start=&quot;810&quot; data-col-size=&quot;md&quot;&gt;eval() 실행 시 만들어지지만, 실제 코드에서는 거의 사용하지 않음.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;861&quot; data-start=&quot;858&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;883&quot; data-start=&quot;863&quot; data-ke-size=&quot;size26&quot;&gt;  실행 컨텍스트의 내부 구성&lt;/h2&gt;
&lt;p data-end=&quot;919&quot; data-start=&quot;885&quot; data-ke-size=&quot;size16&quot;&gt;하나의 실행 컨텍스트는 다음 세 가지로 이루어져 있습니다  &lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1399&quot; data-start=&quot;921&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1036&quot; data-start=&quot;921&quot;&gt;&lt;b&gt;Variable Environment (변수 환경)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1036&quot; data-start=&quot;962&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;994&quot; data-start=&quot;962&quot;&gt;var로 선언된 변수와 함수 선언을 저장합니다.&lt;/li&gt;
&lt;li data-end=&quot;1036&quot; data-start=&quot;998&quot;&gt;코드가 실행되기 전에 미리 메모리에 등록됩니다. (  호이스팅)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1199&quot; data-start=&quot;1038&quot;&gt;&lt;b&gt;Lexical Environment (렉시컬 환경)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1199&quot; data-start=&quot;1079&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1121&quot; data-start=&quot;1079&quot;&gt;let, const로 선언된 변수나 함수 표현식이 저장됩니다.&lt;/li&gt;
&lt;li data-end=&quot;1199&quot; data-start=&quot;1125&quot;&gt;외부 환경 참조(Outer Environment Reference)를 포함하고 있어&lt;br /&gt;&lt;b&gt;스코프 체인&lt;/b&gt;을 형성합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1399&quot; data-start=&quot;1201&quot;&gt;&lt;b&gt;This Binding (this 바인딩)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1399&quot; data-start=&quot;1237&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1399&quot; data-start=&quot;1237&quot;&gt;실행 컨텍스트가 &amp;ldquo;어떤 객체를 this로 바라볼지&amp;rdquo; 결정합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1399&quot; data-start=&quot;1282&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1330&quot; data-start=&quot;1282&quot;&gt;전역 실행 시: window(브라우저) or global(Node.js)&lt;/li&gt;
&lt;li data-end=&quot;1366&quot; data-start=&quot;1336&quot;&gt;함수 호출 시: 호출한 주체(객체)에 따라 다름&lt;/li&gt;
&lt;li data-end=&quot;1399&quot; data-start=&quot;1372&quot;&gt;생성자 함수 실행 시: 새로 만들어진 인스턴스&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-end=&quot;1404&quot; data-start=&quot;1401&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1430&quot; data-start=&quot;1406&quot; data-ke-size=&quot;size26&quot;&gt;  실행 컨텍스트의 생성과 동작 과정&lt;/h2&gt;
&lt;p data-end=&quot;1466&quot; data-start=&quot;1432&quot; data-ke-size=&quot;size16&quot;&gt;함수가 호출될 때 실행 컨텍스트는 아래 순서로 동작합니다  &lt;/p&gt;
&lt;h3 data-end=&quot;1498&quot; data-start=&quot;1468&quot; data-ke-size=&quot;size23&quot;&gt;1️⃣ 생성 단계 (Creation Phase)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1564&quot; data-start=&quot;1499&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1518&quot; data-start=&quot;1499&quot;&gt;새 실행 컨텍스트가 만들어짐&lt;/li&gt;
&lt;li data-end=&quot;1548&quot; data-start=&quot;1519&quot;&gt;변수와 함수 선언이 메모리에 등록 (호이스팅)&lt;/li&gt;
&lt;li data-end=&quot;1564&quot; data-start=&quot;1549&quot;&gt;this 바인딩 결정&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-end=&quot;1615&quot; data-start=&quot;1566&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1615&quot; data-start=&quot;1568&quot; data-ke-size=&quot;size16&quot;&gt;이 단계에서는 변수들이 &lt;b&gt;초기화는 되어 있지만 값은 undefined 상태&lt;/b&gt;예요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-end=&quot;1648&quot; data-start=&quot;1617&quot; data-ke-size=&quot;size23&quot;&gt;2️⃣ 실행 단계 (Execution Phase)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1686&quot; data-start=&quot;1649&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1686&quot; data-start=&quot;1649&quot;&gt;코드가 한 줄씩 실행되며 실제 값이 할당되고 로직이 수행됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1691&quot; data-start=&quot;1688&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1722&quot; data-start=&quot;1693&quot; data-ke-size=&quot;size26&quot;&gt;  실행 컨텍스트 스택 (Call Stack)&lt;/h2&gt;
&lt;p data-end=&quot;1803&quot; data-start=&quot;1724&quot; data-ke-size=&quot;size16&quot;&gt;자바스크립트 엔진은 실행 컨텍스트를 &lt;b&gt;스택(Stack)&lt;/b&gt; 구조로 관리합니다.&lt;br /&gt;가장 위에 있는 컨텍스트가 &amp;ldquo;현재 실행 중인 코드&amp;rdquo;입니다.&lt;/p&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760764523773&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function a() {
  console.log('A');
  b();
}
function b() {
  console.log('B');
}
a();&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 data-end=&quot;1919&quot; data-start=&quot;1909&quot; data-ke-size=&quot;size23&quot;&gt;실행 순서:&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;2063&quot; data-start=&quot;1920&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1942&quot; data-start=&quot;1920&quot;&gt;전역 컨텍스트 생성 (Global)&lt;/li&gt;
&lt;li data-end=&quot;1970&quot; data-start=&quot;1943&quot;&gt;a() 호출 &amp;rarr; a 컨텍스트 push&lt;/li&gt;
&lt;li data-end=&quot;1998&quot; data-start=&quot;1971&quot;&gt;b() 호출 &amp;rarr; b 컨텍스트 push&lt;/li&gt;
&lt;li data-end=&quot;2016&quot; data-start=&quot;1999&quot;&gt;b() 종료 &amp;rarr; pop&lt;/li&gt;
&lt;li data-end=&quot;2034&quot; data-start=&quot;2017&quot;&gt;a() 종료 &amp;rarr; pop&lt;/li&gt;
&lt;li data-end=&quot;2063&quot; data-start=&quot;2035&quot;&gt;프로그램 종료 &amp;rarr; Global 컨텍스트 pop&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;2142&quot; data-start=&quot;2065&quot; data-ke-size=&quot;size16&quot;&gt;이런 구조 덕분에&lt;br /&gt;자바스크립트는 함수 실행 순서를 추적하고,&lt;br /&gt;비동기 처리나 에러 스택 트레이스 등을 효율적으로 관리할 수 있습니다.&lt;/p&gt;
&lt;hr data-end=&quot;2147&quot; data-start=&quot;2144&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2163&quot; data-start=&quot;2149&quot; data-ke-size=&quot;size26&quot;&gt;  예시로 이해하기&lt;/h2&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760764574451&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const x = 1;

function foo() {
  let y = 2;

  function bar() {
    console.log(x + y);
  }

  bar();
}

foo(); // 3&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-end=&quot;331&quot; data-start=&quot;314&quot; data-ke-size=&quot;size23&quot;&gt;  실행 흐름 살펴보기&lt;/h3&gt;
&lt;p data-end=&quot;356&quot; data-start=&quot;333&quot; data-ke-size=&quot;size16&quot;&gt;1️⃣ &lt;b&gt;전역 실행 컨텍스트 생성&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;514&quot; data-start=&quot;357&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;388&quot; data-start=&quot;357&quot;&gt;전역 변수 x와 함수 foo가 등록됩니다.&lt;/li&gt;
&lt;li data-end=&quot;474&quot; data-start=&quot;389&quot;&gt;x는 const로 선언되었기 때문에 &lt;b&gt;TDZ(Temporal Dead Zone)&lt;/b&gt; 를 거쳐&lt;br /&gt;실제 코드 실행 시 값이 할당됩니다.&lt;/li&gt;
&lt;li data-end=&quot;514&quot; data-start=&quot;475&quot;&gt;함수 foo는 선언 자체가 호이스팅되어 메모리에 등록됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;556&quot; data-start=&quot;516&quot; data-ke-size=&quot;size16&quot;&gt;2️⃣ &lt;b&gt;foo() 호출 &amp;rarr; 새로운 함수 실행 컨텍스트 생성&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;663&quot; data-start=&quot;557&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;613&quot; data-start=&quot;557&quot;&gt;foo의 렉시컬 환경이 만들어지고, 지역 변수 y와 내부 함수 bar가 등록됩니다.&lt;/li&gt;
&lt;li data-end=&quot;663&quot; data-start=&quot;614&quot;&gt;y는 let으로 선언되었기 때문에 &lt;b&gt;블록 스코프 내에서만 유효&lt;/b&gt;합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;704&quot; data-start=&quot;665&quot; data-ke-size=&quot;size16&quot;&gt;3️⃣ &lt;b&gt;bar() 호출 &amp;rarr; 또 하나의 실행 컨텍스트 생성&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;816&quot; data-start=&quot;705&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;736&quot; data-start=&quot;705&quot;&gt;bar 내부에서는 x와 y를 찾습니다.&lt;/li&gt;
&lt;li data-end=&quot;816&quot; data-start=&quot;737&quot;&gt;x는 전역 스코프(outer environment reference)에서,&lt;br /&gt;y는 foo의 렉시컬 환경에서 발견됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;851&quot; data-start=&quot;818&quot; data-ke-size=&quot;size16&quot;&gt;4️⃣ &lt;b&gt;console.log(x + y) 실행&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;910&quot; data-start=&quot;852&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;889&quot; data-start=&quot;852&quot;&gt;탐색 순서: bar &amp;rarr; foo &amp;rarr; 전역(Global)&lt;/li&gt;
&lt;li data-end=&quot;910&quot; data-start=&quot;890&quot;&gt;최종 결과: 1 + 2 = 3&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;2582&quot; data-start=&quot;2579&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2595&quot; data-start=&quot;2584&quot; data-ke-size=&quot;size26&quot;&gt;✨ 마무리 요약&lt;/h2&gt;
&lt;div&gt;
&lt;div&gt;구분내용
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-end=&quot;2787&quot; data-start=&quot;2597&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-end=&quot;2787&quot; data-start=&quot;2625&quot;&gt;
&lt;tr data-end=&quot;2654&quot; data-start=&quot;2625&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2630&quot; data-start=&quot;2625&quot;&gt;정의&lt;/td&gt;
&lt;td data-end=&quot;2654&quot; data-start=&quot;2630&quot; data-col-size=&quot;sm&quot;&gt;코드 실행에 필요한 정보를 담은 환경&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;2676&quot; data-start=&quot;2655&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2660&quot; data-start=&quot;2655&quot;&gt;종류&lt;/td&gt;
&lt;td data-end=&quot;2676&quot; data-start=&quot;2660&quot; data-col-size=&quot;sm&quot;&gt;전역, 함수, eval&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;2720&quot; data-start=&quot;2677&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2685&quot; data-start=&quot;2677&quot;&gt;구성 요소&lt;/td&gt;
&lt;td data-end=&quot;2720&quot; data-start=&quot;2685&quot; data-col-size=&quot;sm&quot;&gt;Variable Env, Lexical Env, this&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;2753&quot; data-start=&quot;2721&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2729&quot; data-start=&quot;2721&quot;&gt;동작 순서&lt;/td&gt;
&lt;td data-end=&quot;2753&quot; data-start=&quot;2729&quot; data-col-size=&quot;sm&quot;&gt;생성(호이스팅) &amp;rarr; 실행(코드 수행)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;2787&quot; data-start=&quot;2754&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2762&quot; data-start=&quot;2754&quot;&gt;관리 방식&lt;/td&gt;
&lt;td data-end=&quot;2787&quot; data-start=&quot;2762&quot; data-col-size=&quot;sm&quot;&gt;스택(Call Stack) 구조로 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;2792&quot; data-start=&quot;2789&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-end=&quot;2913&quot; data-start=&quot;2794&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;2867&quot; data-start=&quot;2796&quot; data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;결국, 실행 컨텍스트를 이해하면&lt;/b&gt;&lt;br /&gt;자바스크립트의 스코프, 호이스팅, 클로저, this가 한눈에 연결됩니다.&lt;/p&gt;
&lt;p data-end=&quot;2913&quot; data-start=&quot;2874&quot; data-ke-size=&quot;size16&quot;&gt;즉, 실행 컨텍스트는 자바스크립트 엔진의 &amp;ldquo;두뇌&amp;rdquo;라고 할 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Notes/JavaScript</category>
      <author>Grace Noh</author>
      <guid isPermaLink="true">https://meercat.tistory.com/507</guid>
      <comments>https://meercat.tistory.com/507#entry507comment</comments>
      <pubDate>Sat, 18 Oct 2025 14:18:47 +0900</pubDate>
    </item>
    <item>
      <title>브라우저 렌더링 최적화: Reflow와 Repaint 완벽 이해</title>
      <link>https://meercat.tistory.com/506</link>
      <description>&lt;p data-end=&quot;274&quot; data-start=&quot;138&quot; data-ke-size=&quot;size16&quot;&gt;웹 성능을 이야기할 때 꼭 등장하는 단어가 있습니다.&lt;br /&gt;바로 &lt;b&gt;Reflow(리플로우)&lt;/b&gt; 와 &lt;b&gt;Repaint(리페인트)&lt;/b&gt; 입니다.&lt;br /&gt;이 두 개념은 브라우저가 화면을 그리는 과정의 핵심이며,&lt;br /&gt;렌더링 성능 최적화의 출발점이기도 합니다.&lt;/p&gt;
&lt;hr data-end=&quot;279&quot; data-start=&quot;276&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;307&quot; data-start=&quot;281&quot; data-ke-size=&quot;size26&quot;&gt;  Reflow란? (Layout 단계)&lt;/h2&gt;
&lt;p data-end=&quot;408&quot; data-start=&quot;309&quot; data-ke-size=&quot;size16&quot;&gt;Reflow는 브라우저가 &lt;b&gt;요소의 크기나 위치를 다시 계산하는 과정&lt;/b&gt;이에요.&lt;br /&gt;즉, DOM 구조나 CSS 속성 중 &lt;b&gt;레이아웃에 영향을 주는 변화&lt;/b&gt;가 있을 때 발생합니다.&lt;/p&gt;
&lt;p data-end=&quot;442&quot; data-start=&quot;410&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어, 다음과 같은 상황이 Reflow를 유발합니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;564&quot; data-start=&quot;443&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;492&quot; data-start=&quot;443&quot;&gt;요소의 width, height, margin, padding 변경&lt;/li&gt;
&lt;li data-end=&quot;515&quot; data-start=&quot;493&quot;&gt;새로운 DOM 요소 추가 / 제거&lt;/li&gt;
&lt;li data-end=&quot;535&quot; data-start=&quot;516&quot;&gt;display 속성 변경&lt;/li&gt;
&lt;li data-end=&quot;548&quot; data-start=&quot;536&quot;&gt;폰트 크기 변경&lt;/li&gt;
&lt;li data-end=&quot;564&quot; data-start=&quot;549&quot;&gt;창 크기 조절(리사이즈)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;696&quot; data-start=&quot;566&quot; data-ke-size=&quot;size16&quot;&gt;Reflow는 화면의 구조를 완전히 다시 계산해야 하므로&lt;br /&gt;&lt;b&gt;비용이 크고, 성능 저하의 주요 원인&lt;/b&gt;이 됩니다.&lt;br /&gt;특히 부모 요소의 레이아웃 변경은 자식 요소 전체에 영향을 주어&lt;br /&gt;연쇄적으로 Reflow가 발생할 수 있습니다.&lt;/p&gt;
&lt;hr data-end=&quot;701&quot; data-start=&quot;698&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;732&quot; data-start=&quot;703&quot; data-ke-size=&quot;size26&quot;&gt;  Repaint란? (Painting 단계)&lt;/h2&gt;
&lt;p data-end=&quot;822&quot; data-start=&quot;734&quot; data-ke-size=&quot;size16&quot;&gt;Repaint는 &lt;b&gt;요소의 크기나 위치는 그대로지만, 외형이 바뀔 때&lt;/b&gt; 발생합니다.&lt;br /&gt;즉, 시각적인 스타일(appearance)만 다시 그리는 과정이에요.&lt;/p&gt;
&lt;p data-end=&quot;850&quot; data-start=&quot;824&quot; data-ke-size=&quot;size16&quot;&gt;다음과 같은 변경이 Repaint를 일으킵니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;961&quot; data-start=&quot;851&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;901&quot; data-start=&quot;851&quot;&gt;color, background-color, border-color 변경&lt;/li&gt;
&lt;li data-end=&quot;921&quot; data-start=&quot;902&quot;&gt;visibility 토글&lt;/li&gt;
&lt;li data-end=&quot;961&quot; data-start=&quot;922&quot;&gt;box-shadow, outline 등 시각적 속성 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1032&quot; data-start=&quot;963&quot; data-ke-size=&quot;size16&quot;&gt;Repaint는 Reflow보다는 가볍지만,&lt;br /&gt;잦은 스타일 변경이나 애니메이션에서는 여전히 성능에 영향을 줄 수 있습니다.&lt;/p&gt;
&lt;hr data-end=&quot;1037&quot; data-start=&quot;1034&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1069&quot; data-start=&quot;1039&quot; data-ke-size=&quot;size26&quot;&gt;⚖️ Reflow vs Repaint 한눈에 비교&lt;/h2&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-end=&quot;1347&quot; data-start=&quot;1071&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;구분&lt;/td&gt;
&lt;td&gt;Reflow&lt;/td&gt;
&lt;td&gt;Repaint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1208&quot; data-start=&quot;1162&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1170&quot; data-start=&quot;1162&quot;&gt;발생 조건&lt;/td&gt;
&lt;td data-end=&quot;1188&quot; data-start=&quot;1170&quot; data-col-size=&quot;sm&quot;&gt;레이아웃(위치, 크기) 변경&lt;/td&gt;
&lt;td data-end=&quot;1208&quot; data-start=&quot;1188&quot; data-col-size=&quot;sm&quot;&gt;외형(색상, 그림자 등) 변경&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1242&quot; data-start=&quot;1209&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1218&quot; data-start=&quot;1209&quot;&gt;렌더링 단계&lt;/td&gt;
&lt;td data-end=&quot;1230&quot; data-start=&quot;1218&quot; data-col-size=&quot;sm&quot;&gt;Layout 단계&lt;/td&gt;
&lt;td data-end=&quot;1242&quot; data-start=&quot;1230&quot; data-col-size=&quot;sm&quot;&gt;Paint 단계&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1268&quot; data-start=&quot;1243&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1251&quot; data-start=&quot;1243&quot;&gt;성능 비용&lt;/td&gt;
&lt;td data-end=&quot;1259&quot; data-start=&quot;1251&quot; data-col-size=&quot;sm&quot;&gt;높음 ⚠️&lt;/td&gt;
&lt;td data-end=&quot;1268&quot; data-start=&quot;1259&quot; data-col-size=&quot;sm&quot;&gt;중간 정도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1347&quot; data-start=&quot;1269&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1274&quot; data-start=&quot;1269&quot;&gt;예시&lt;/td&gt;
&lt;td data-end=&quot;1308&quot; data-start=&quot;1274&quot; data-col-size=&quot;sm&quot;&gt;width, display, font-size&lt;/td&gt;
&lt;td data-end=&quot;1347&quot; data-start=&quot;1308&quot; data-col-size=&quot;sm&quot;&gt;color, background, visibility&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;1352&quot; data-start=&quot;1349&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1368&quot; data-start=&quot;1354&quot; data-ke-size=&quot;size26&quot;&gt;  성능 최적화 팁&lt;/h2&gt;
&lt;h3 data-end=&quot;1388&quot; data-start=&quot;1370&quot; data-ke-size=&quot;size23&quot;&gt;1️⃣ Reflow 최소화&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1568&quot; data-start=&quot;1389&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1443&quot; data-start=&quot;1389&quot;&gt;DOM 접근/변경은 &lt;b&gt;한 번에 묶어서&lt;/b&gt; 처리 (documentFragment 사용)&lt;/li&gt;
&lt;li data-end=&quot;1507&quot; data-start=&quot;1444&quot;&gt;layout 관련 속성(offsetWidth, clientHeight)을 &lt;b&gt;반복해서 읽지 않기&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1568&quot; data-start=&quot;1508&quot;&gt;CSS 애니메이션은 &lt;b&gt;transform, opacity 중심으로 구성&lt;/b&gt;해 GPU 가속 활용&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;1589&quot; data-start=&quot;1570&quot; data-ke-size=&quot;size23&quot;&gt;2️⃣ Repaint 최소화&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1754&quot; data-start=&quot;1590&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1666&quot; data-start=&quot;1590&quot;&gt;시각적 변화가 잦은 요소는&lt;br /&gt;&lt;b&gt;will-change: opacity, transform&lt;/b&gt; 속성으로 GPU 레이어 분리&lt;/li&gt;
&lt;li data-end=&quot;1754&quot; data-start=&quot;1667&quot;&gt;보이지 않게 할 때 display:none 대신&lt;br /&gt;opacity: 0 + pointer-events: none 사용 (Reflow 방지)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1759&quot; data-start=&quot;1756&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1772&quot; data-start=&quot;1761&quot; data-ke-size=&quot;size26&quot;&gt;✨ 마무리 요약&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1934&quot; data-start=&quot;1774&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1815&quot; data-start=&quot;1774&quot;&gt;&lt;b&gt;Reflow&lt;/b&gt;는 &amp;ldquo;레이아웃 재계산&amp;rdquo;으로, 가장 비싼 연산이다.&lt;/li&gt;
&lt;li data-end=&quot;1857&quot; data-start=&quot;1816&quot;&gt;&lt;b&gt;Repaint&lt;/b&gt;는 &amp;ldquo;시각적 변경&amp;rdquo;으로, 상대적으로 덜 비싸다.&lt;/li&gt;
&lt;li data-end=&quot;1893&quot; data-start=&quot;1858&quot;&gt;둘 다 빈번하게 일어나면 성능 저하의 주요 원인이 된다.&lt;/li&gt;
&lt;li data-end=&quot;1934&quot; data-start=&quot;1894&quot;&gt;가능한 한 DOM 변경을 묶고, GPU 가속 가능한 속성을 사용하자.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1939&quot; data-start=&quot;1936&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-end=&quot;2032&quot; data-start=&quot;1941&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;2032&quot; data-start=&quot;1943&quot; data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;결국 핵심은 &amp;ldquo;언제 브라우저가 다시 그려지는가&amp;rdquo;를 이해하는 것.&lt;/b&gt;&lt;br /&gt;이 원리를 이해하면, 자연스럽게 성능 좋은 UI 코드를 작성할 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>Notes/JavaScript</category>
      <category>Reflow</category>
      <category>Repaint</category>
      <category>리페인트</category>
      <category>리플로우</category>
      <category>브라우저렌더링</category>
      <author>Grace Noh</author>
      <guid isPermaLink="true">https://meercat.tistory.com/506</guid>
      <comments>https://meercat.tistory.com/506#entry506comment</comments>
      <pubDate>Sat, 18 Oct 2025 14:11:56 +0900</pubDate>
    </item>
    <item>
      <title>클로저(Closure) 완벽 정리 &amp;mdash; 자바스크립트 개발자라면 반드시 알아야 할 핵심 개념</title>
      <link>https://meercat.tistory.com/505</link>
      <description>&lt;p data-end=&quot;92&quot; data-start=&quot;0&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;클로저(Closure)&lt;/b&gt;는 자바스크립트의 핵심 개념 중 하나로, &lt;b&gt;함수가 선언될 때의 환경(스코프)을 기억하는 함수&lt;/b&gt;를 말합니다.&lt;/p&gt;
&lt;hr data-end=&quot;97&quot; data-start=&quot;94&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;113&quot; data-start=&quot;99&quot; data-ke-size=&quot;size26&quot;&gt;  1. 기본 개념&lt;/h2&gt;
&lt;p data-end=&quot;316&quot; data-start=&quot;114&quot; data-ke-size=&quot;size16&quot;&gt;자바스크립트에서는 &lt;b&gt;함수가 선언될 때의 스코프(lexical scope)&lt;/b&gt; 를 기억합니다.&lt;br /&gt;이때, &lt;b&gt;내부 함수(inner function)&lt;/b&gt; 가 &lt;b&gt;자신을 둘러싼 외부 함수(outer function)&lt;/b&gt; 의 변수에 접근할 수 있을 때,&lt;br /&gt;그 &lt;b&gt;관계를 유지한 채로 외부 함수가 종료된 이후에도 접근 가능한 구조&lt;/b&gt; &amp;mdash; 이것이 바로 클로저입니다.&lt;/p&gt;
&lt;hr data-end=&quot;321&quot; data-start=&quot;318&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;343&quot; data-start=&quot;323&quot; data-ke-size=&quot;size26&quot;&gt;  2. 예시 코드로 이해하기&lt;/h2&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760233933439&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function makeCounter() {
  let count = 0; // 외부 함수의 지역 변수

  return function() { // 내부 함수 (클로저)
    count++;
    return count;
  };
}

const counter = makeCounter();

console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-end=&quot;632&quot; data-start=&quot;610&quot; data-ke-size=&quot;size23&quot;&gt;  무슨 일이 일어나고 있을까?&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;891&quot; data-start=&quot;633&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;679&quot; data-start=&quot;633&quot;&gt;makeCounter()가 실행되면 지역 변수 count가 생성됩니다.&lt;/li&gt;
&lt;li data-end=&quot;746&quot; data-start=&quot;680&quot;&gt;내부 함수가 return되며, 이 함수는 count에 접근할 수 있는 &lt;b&gt;권한&lt;/b&gt;을 가진 채 밖으로 나갑니다.&lt;/li&gt;
&lt;li data-end=&quot;838&quot; data-start=&quot;747&quot;&gt;makeCounter()의 실행은 끝났지만, 내부 함수가 count를 &lt;b&gt;참조 중이기 때문에 GC(가비지 컬렉터)&lt;/b&gt; 가 그 변수를 제거하지 않습니다.&lt;/li&gt;
&lt;li data-end=&quot;891&quot; data-start=&quot;839&quot;&gt;그 결과, counter()를 호출할 때마다 count 값이 유지되고 증가합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-end=&quot;896&quot; data-start=&quot;893&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;917&quot; data-start=&quot;898&quot; data-ke-size=&quot;size26&quot;&gt;  3. 클로저의 특징 요약&lt;/h2&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-end=&quot;1161&quot; data-start=&quot;919&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1008&quot; data-start=&quot;947&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;956&quot; data-start=&quot;947&quot;&gt;&lt;b&gt;정의&lt;/b&gt;&lt;/td&gt;
&lt;td data-end=&quot;1008&quot; data-start=&quot;956&quot; data-col-size=&quot;md&quot;&gt;함수가 선언될 당시의 스코프를 기억하여, 외부 함수의 변수에 접근할 수 있는 내부 함수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1032&quot; data-start=&quot;1009&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1018&quot; data-start=&quot;1009&quot;&gt;&lt;b&gt;형태&lt;/b&gt;&lt;/td&gt;
&lt;td data-end=&quot;1032&quot; data-start=&quot;1018&quot; data-col-size=&quot;md&quot;&gt;함수 안의 함수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1069&quot; data-start=&quot;1033&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1045&quot; data-start=&quot;1033&quot;&gt;&lt;b&gt;생성 시점&lt;/b&gt;&lt;/td&gt;
&lt;td data-end=&quot;1069&quot; data-start=&quot;1045&quot; data-col-size=&quot;md&quot;&gt;함수가 &lt;b&gt;선언될 때(정의 시점)&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1123&quot; data-start=&quot;1070&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1082&quot; data-start=&quot;1070&quot;&gt;&lt;b&gt;소멸 시점&lt;/b&gt;&lt;/td&gt;
&lt;td data-end=&quot;1123&quot; data-start=&quot;1082&quot; data-col-size=&quot;md&quot;&gt;외부 함수가 끝나도 내부 함수가 참조 중이면 &lt;b&gt;메모리에서 유지&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1161&quot; data-start=&quot;1124&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1136&quot; data-start=&quot;1124&quot;&gt;&lt;b&gt;주요 효과&lt;/b&gt;&lt;/td&gt;
&lt;td data-end=&quot;1161&quot; data-start=&quot;1136&quot; data-col-size=&quot;md&quot;&gt;데이터 은닉, 상태 유지, 모듈화 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;1166&quot; data-start=&quot;1163&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1192&quot; data-start=&quot;1168&quot; data-ke-size=&quot;size26&quot;&gt;  4. 클로저의 대표적인 활용 예시&lt;/h2&gt;
&lt;h3 data-end=&quot;1220&quot; data-start=&quot;1194&quot; data-ke-size=&quot;size23&quot;&gt;✅ (1) &lt;b&gt;데이터 은닉 / 캡슐화&lt;/b&gt;&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760234065205&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function createAccount() {
  let balance = 0;

  return {
    deposit(amount) {
      balance += amount;
    },
    getBalance() {
      return balance;
    }
  };
}

const account = createAccount();
account.deposit(1000);
console.log(account.getBalance()); // 1000&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1569&quot; data-start=&quot;1497&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 외부에서는 balance를 직접 수정할 수 없고, 오직 deposit() / getBalance()로만 접근 가능.&lt;/p&gt;
&lt;hr data-end=&quot;1574&quot; data-start=&quot;1571&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;1605&quot; data-start=&quot;1576&quot; data-ke-size=&quot;size23&quot;&gt;✅ (2) &lt;b&gt;이벤트 핸들러에서 상태 유지&lt;/b&gt;&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760234103903&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function createClickCounter(button) {
  let count = 0;

  button.addEventListener('click', function() {
    count++;
    console.log(`Clicked ${count} times`);
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1815&quot; data-start=&quot;1784&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 각 버튼마다 클릭 횟수를 &amp;ldquo;기억&amp;rdquo;하는 클로저를 가짐.&lt;/p&gt;
&lt;hr data-end=&quot;1820&quot; data-start=&quot;1817&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;1866&quot; data-start=&quot;1822&quot; data-ke-size=&quot;size23&quot;&gt;✅ (3) &lt;b&gt;React에서의 예시 (useState와 비슷한 원리)&lt;/b&gt;&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;pre id=&quot;code_1760234136745&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function useCounter() {
  let count = 0;

  function increment() {
    count++;
    console.log(count);
  }

  return [() =&amp;gt; count, increment];
}

const [getCount, increment] = useCounter();
increment(); // 1
increment(); // 2
console.log(getCount()); // 2&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-end=&quot;2138&quot; data-start=&quot;2135&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2154&quot; data-start=&quot;2140&quot; data-ke-size=&quot;size26&quot;&gt;⚠️ 5. 주의할 점&lt;/h2&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-end=&quot;2354&quot; data-start=&quot;2156&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;문제&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;2243&quot; data-start=&quot;2184&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2197&quot; data-start=&quot;2184&quot;&gt;&lt;b&gt;메모리 누수&lt;/b&gt;&lt;/td&gt;
&lt;td data-end=&quot;2243&quot; data-start=&quot;2197&quot; data-col-size=&quot;md&quot;&gt;클로저가 너무 많은 변수를 오래 참조하면 메모리 점유가 계속 유지될 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;2307&quot; data-start=&quot;2244&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2262&quot; data-start=&quot;2244&quot;&gt;&lt;b&gt;의도치 않은 값 공유&lt;/b&gt;&lt;/td&gt;
&lt;td data-end=&quot;2307&quot; data-start=&quot;2262&quot; data-col-size=&quot;md&quot;&gt;반복문 안에서 var를 사용하면 모든 클로저가 같은 변수를 참조하게 됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;2354&quot; data-start=&quot;2308&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2318&quot; data-start=&quot;2308&quot;&gt;&lt;b&gt;해결법&lt;/b&gt;&lt;/td&gt;
&lt;td data-end=&quot;2354&quot; data-start=&quot;2318&quot; data-col-size=&quot;md&quot;&gt;let 사용 또는 즉시실행함수(IIFE)로 스코프 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760234198930&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;for (var i = 0; i &amp;lt; 3; i++) {
  setTimeout(() =&amp;gt; console.log(i), 1000); // 3 3 3
}

for (let i = 0; i &amp;lt; 3; i++) {
  setTimeout(() =&amp;gt; console.log(i), 1000); // 0 1 2
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-start=&quot;948&quot; data-end=&quot;951&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;864&quot; data-start=&quot;830&quot; data-ke-size=&quot;size26&quot;&gt;⚛️ 6. React / TypeScript 실전 예제&lt;/h2&gt;
&lt;p data-end=&quot;946&quot; data-start=&quot;866&quot; data-ke-size=&quot;size16&quot;&gt;React에서는 클로저가 &lt;b&gt;매우 자주&lt;/b&gt; 사용됩니다.&lt;br /&gt;특히 useEffect, useState, 이벤트 핸들러, 비동기 로직에서요.&lt;/p&gt;
&lt;h3 data-end=&quot;981&quot; data-start=&quot;953&quot; data-ke-size=&quot;size23&quot;&gt;✅ 예제 1: useEffect와 클로저&lt;/h3&gt;
&lt;pre id=&quot;code_1760234430491&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() =&amp;gt; {
    const interval = setInterval(() =&amp;gt; {
      console.log(&quot;count:&quot;, count); // ⚠️ 클로저 발생
    }, 1000);

    return () =&amp;gt; clearInterval(interval);
  }, []);

  return (
    &amp;lt;button onClick={() =&amp;gt; setCount(c =&amp;gt; c + 1)}&amp;gt;
      Count: {count}
    &amp;lt;/button&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-end=&quot;1423&quot; data-start=&quot;1400&quot; data-ke-size=&quot;size20&quot;&gt;  여기서 무슨 일이 일어날까?&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1618&quot; data-start=&quot;1424&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1471&quot; data-start=&quot;1424&quot;&gt;useEffect는 &lt;b&gt;mount 시점에 한 번만 실행&lt;/b&gt;됩니다 ([]).&lt;/li&gt;
&lt;li data-end=&quot;1538&quot; data-start=&quot;1472&quot;&gt;따라서 setInterval 안의 함수는 &lt;b&gt;초기 렌더링 당시의 count&lt;/b&gt;를 기억하는 클로저를 만듭니다.&lt;/li&gt;
&lt;li data-end=&quot;1618&quot; data-start=&quot;1539&quot;&gt;이후 버튼을 눌러도 count가 증가해도,&lt;br /&gt;console.log(&quot;count:&quot;, count)는 &lt;b&gt;0만 계속 찍습니다!&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1651&quot; data-start=&quot;1620&quot; data-ke-size=&quot;size16&quot;&gt;이건 바로 클로저의 &amp;ldquo;환경을 기억하는 성질&amp;rdquo; 때문이에요.&lt;/p&gt;
&lt;h4 data-end=&quot;1665&quot; data-start=&quot;1653&quot; data-ke-size=&quot;size20&quot;&gt;✅ 해결 방법&lt;/h4&gt;
&lt;p data-end=&quot;1719&quot; data-start=&quot;1666&quot; data-ke-size=&quot;size16&quot;&gt;현재의 count 값이 필요하다면 &lt;b&gt;함수형 업데이트&lt;/b&gt;나 &lt;b&gt;ref&lt;/b&gt;를 이용해야 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1760234448028&quot; class=&quot;javascript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  const interval = setInterval(() =&amp;gt; {
    setCount(c =&amp;gt; {
      console.log(&quot;count:&quot;, c);
      return c + 1;
    });
  }, 1000);
  return () =&amp;gt; clearInterval(interval);
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;1996&quot; data-start=&quot;1930&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 하면 setCount의 인자 (c)가 매번 최신 상태를 전달받기 때문에 클로저 문제를 피할 수 있습니다.&lt;/p&gt;
&lt;hr data-end=&quot;2001&quot; data-start=&quot;1998&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;2030&quot; data-start=&quot;2003&quot; data-ke-size=&quot;size23&quot;&gt;✅ 예제 2: 이벤트 핸들러에서 상태 유지&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760234520503&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useState } from &quot;react&quot;;

export default function ToggleButton() {
  const [isOn, setIsOn] = useState(false);

  function handleClick() {
    // handleClick이 선언될 때의 스코프를 기억함 (클로저)
    console.log(&quot;현재 상태:&quot;, isOn);
    setIsOn(!isOn);
  }

  return &amp;lt;button onClick={handleClick}&amp;gt;{isOn ? &quot;ON&quot; : &quot;OFF&quot;}&amp;lt;/button&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-end=&quot;2377&quot; data-start=&quot;2364&quot; data-ke-size=&quot;size20&quot;&gt;  동작 설명&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2556&quot; data-start=&quot;2378&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2418&quot; data-start=&quot;2378&quot;&gt;handleClick 함수는 렌더링될 때마다 새롭게 만들어집니다.&lt;/li&gt;
&lt;li data-end=&quot;2478&quot; data-start=&quot;2419&quot;&gt;하지만 이벤트 핸들러 내부의 isOn은 &lt;b&gt;그 함수가 생성된 당시의 state 값을 기억&lt;/b&gt;합니다.&lt;/li&gt;
&lt;li data-end=&quot;2556&quot; data-start=&quot;2479&quot;&gt;React는 매 렌더마다 새로운 클로저를 만들기 때문에,&lt;br /&gt;이벤트가 트리거될 때 &lt;b&gt;현재 렌더의 상태값&lt;/b&gt;을 참조하게 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-end=&quot;2620&quot; data-start=&quot;2558&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;2620&quot; data-start=&quot;2560&quot; data-ke-size=&quot;size16&quot;&gt;즉, React는 클로저를 매 렌더마다 새로 만들어서 &amp;ldquo;이전 상태 유지&amp;rdquo; 대신 &amp;ldquo;현재 상태 반영&amp;rdquo;을 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-end=&quot;2625&quot; data-start=&quot;2622&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;2656&quot; data-start=&quot;2627&quot; data-ke-size=&quot;size23&quot;&gt;✅ 예제 3: 비동기 요청에서 클로저 주의하기&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760234614535&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  let isMounted = true;

  async function fetchData() {
    const res = await fetch(&quot;/api/data&quot;);
    const data = await res.json();

    if (isMounted) {
      setData(data);
    }
  }

  fetchData();

  return () =&amp;gt; {
    isMounted = false;
  };
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-end=&quot;2962&quot; data-start=&quot;2944&quot; data-ke-size=&quot;size20&quot;&gt;⚠️ 왜 이렇게 하냐면:&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3110&quot; data-start=&quot;2963&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3025&quot; data-start=&quot;2963&quot;&gt;fetchData가 실행되는 동안 컴포넌트가 언마운트되면 setData()를 호출하면 에러가 납니다.&lt;/li&gt;
&lt;li data-end=&quot;3110&quot; data-start=&quot;3026&quot;&gt;isMounted 변수가 &amp;ldquo;현재 컴포넌트가 살아 있는지&amp;rdquo;를 &lt;b&gt;클로저로 기억&lt;/b&gt;하고,&lt;br /&gt;cleanup에서 false로 바꿔주는 패턴이에요.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-start=&quot;948&quot; data-end=&quot;951&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;193&quot; data-start=&quot;142&quot; data-ke-size=&quot;size26&quot;&gt;⚛️ 7. 클로저, 그리고 useCallback / useMemo / 비동기 버그의 진짜 이유&lt;/h2&gt;
&lt;h3 data-end=&quot;224&quot; data-start=&quot;200&quot; data-ke-size=&quot;size23&quot;&gt;  1️⃣ 클로저와 React의 관계&lt;/h3&gt;
&lt;p data-end=&quot;322&quot; data-start=&quot;226&quot; data-ke-size=&quot;size16&quot;&gt;React 컴포넌트는 &lt;b&gt;렌더링될 때마다 함수가 새로 호출&lt;/b&gt;됩니다.&lt;br /&gt;즉, 컴포넌트 내부의 &lt;b&gt;모든 변수, 함수, state 참조&lt;/b&gt;가 매 렌더마다 새롭게 만들어집니다.&lt;/p&gt;
&lt;blockquote data-end=&quot;415&quot; data-start=&quot;324&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;415&quot; data-start=&quot;326&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 따라서 &amp;ldquo;이전 렌더의 변수나 state를 참조하는 클로저&amp;rdquo;가 발생할 수 있어요.&lt;br /&gt;React의 모든 훅은 사실상 &amp;ldquo;클로저 위에서 작동하는 구조&amp;rdquo;입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-end=&quot;420&quot; data-start=&quot;417&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;467&quot; data-start=&quot;422&quot; data-ke-size=&quot;size23&quot;&gt;⚙️ 2️⃣ useCallback / useMemo에서 클로저가 중요한 이유&lt;/h3&gt;
&lt;h4 data-end=&quot;507&quot; data-start=&quot;469&quot; data-ke-size=&quot;size20&quot;&gt;  (1) useCallback은 함수를 &amp;ldquo;메모이제이션&amp;rdquo;한다&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760234912760&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleClick = useCallback(() =&amp;gt; {
  console.log(count);
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;600&quot; data-start=&quot;590&quot; data-ke-size=&quot;size18&quot;&gt;❌ 문제:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;719&quot; data-start=&quot;601&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;658&quot; data-start=&quot;601&quot;&gt;[] 의존성 배열이 비어 있으면 count의 &lt;b&gt;초기값만 기억한 클로저&lt;/b&gt;가 유지됩니다.&lt;/li&gt;
&lt;li data-end=&quot;719&quot; data-start=&quot;659&quot;&gt;즉, 이후 count가 바뀌어도 handleClick은 &lt;b&gt;옛날 count를 콘솔에 찍습니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;731&quot; data-start=&quot;721&quot; data-ke-size=&quot;size18&quot;&gt;✅ 해결:&lt;/p&gt;
&lt;p data-end=&quot;798&quot; data-start=&quot;732&quot; data-ke-size=&quot;size16&quot;&gt;의존성 배열에 count를 넣어야, React가 &lt;b&gt;새로운 count를 클로저에 캡처한 새 함수&lt;/b&gt;를 만들어줍니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760234929313&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const handleClick = useCallback(() =&amp;gt; {
  console.log(count);
}, [count]);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;891&quot; data-start=&quot;887&quot; data-ke-size=&quot;size16&quot;&gt;즉,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;984&quot; data-start=&quot;892&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;930&quot; data-start=&quot;892&quot;&gt;useCallback은 클로저의 &amp;ldquo;스냅샷&amp;rdquo;을 저장하는 훅,&lt;/li&gt;
&lt;li data-end=&quot;984&quot; data-start=&quot;931&quot;&gt;deps는 &amp;ldquo;이 클로저를 언제 새로 만들어야 하는가&amp;rdquo;를 React에게 알려주는 장치예요.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;989&quot; data-start=&quot;986&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-end=&quot;1024&quot; data-start=&quot;991&quot; data-ke-size=&quot;size20&quot;&gt;⚙️ (2) useMemo도 똑같이 클로저를 기억한다&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760234944304&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const doubled = useMemo(() =&amp;gt; {
  console.log('memoized');
  return count * 2;
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1216&quot; data-start=&quot;1125&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1176&quot; data-start=&quot;1125&quot;&gt;[]로 두면 &lt;b&gt;처음 count만 기억&lt;/b&gt;하고, 이후 변경돼도 다시 계산하지 않아요.&lt;/li&gt;
&lt;li data-end=&quot;1216&quot; data-start=&quot;1177&quot;&gt;[count]를 넣으면 &lt;b&gt;새 count로 다시 계산&lt;/b&gt;합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1290&quot; data-start=&quot;1218&quot; data-ke-size=&quot;size16&quot;&gt;  useMemo의 콜백도 결국 클로저입니다.&lt;br /&gt;React는 &amp;ldquo;의존성 배열을 기준으로 클로저를 새로 만들지 말지&amp;rdquo;를 결정하죠.&lt;/p&gt;
&lt;hr data-end=&quot;1295&quot; data-start=&quot;1292&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;1332&quot; data-start=&quot;1297&quot; data-ke-size=&quot;size23&quot;&gt;⚠️ 3️⃣ stale closure(오래된 클로저) 문제&lt;/h3&gt;
&lt;h4 data-end=&quot;1343&quot; data-start=&quot;1334&quot; data-ke-size=&quot;size20&quot;&gt;  개념&lt;/h4&gt;
&lt;blockquote data-end=&quot;1426&quot; data-start=&quot;1344&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1426&quot; data-start=&quot;1346&quot; data-ke-size=&quot;size16&quot;&gt;Stale Closure란, 오래된 변수 값을 기억하는 클로저 때문에&lt;br /&gt;&lt;b&gt;최신 state나 props를 읽지 못하는 버그&lt;/b&gt;를 말합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-end=&quot;1431&quot; data-start=&quot;1428&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-end=&quot;1477&quot; data-start=&quot;1433&quot; data-ke-size=&quot;size20&quot;&gt;  예제: setTimeout 안에서 발생하는 stale closure&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760234963016&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function Timer() {
  const [count, setCount] = useState(0);

  const start = () =&amp;gt; {
    setTimeout(() =&amp;gt; {
      console.log(&quot;Count:&quot;, count); // ❌ 항상 오래된 count!
    }, 2000);
  };

  return (
    &amp;lt;&amp;gt;
      &amp;lt;p&amp;gt;{count}&amp;lt;/p&amp;gt;
      &amp;lt;button onClick={() =&amp;gt; setCount(c =&amp;gt; c + 1)}&amp;gt;+1&amp;lt;/button&amp;gt;
      &amp;lt;button onClick={start}&amp;gt;Start&amp;lt;/button&amp;gt;
    &amp;lt;/&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1842&quot; data-start=&quot;1836&quot; data-ke-size=&quot;size16&quot;&gt;  원인:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2003&quot; data-start=&quot;1843&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1891&quot; data-start=&quot;1843&quot;&gt;start 함수는 &lt;b&gt;렌더링 시점의 count&lt;/b&gt;를 기억하는 클로저를 만듭니다.&lt;/li&gt;
&lt;li data-end=&quot;2003&quot; data-start=&quot;1892&quot;&gt;2초 후 콜백 실행 시점엔 이미 여러 번 렌더링이 일어났을 수 있지만,&lt;br /&gt;setTimeout 내부 클로저는 여전히 **&amp;ldquo;start 버튼을 눌렀던 순간의 count&amp;rdquo;**만 가지고 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;2008&quot; data-start=&quot;2005&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;2042&quot; data-start=&quot;2010&quot; data-ke-size=&quot;size23&quot;&gt;  4️⃣ stale closure 방지 패턴 정리&lt;/h3&gt;
&lt;h4 data-end=&quot;2077&quot; data-start=&quot;2044&quot; data-ke-size=&quot;size20&quot;&gt;✅ (1) 최신 값을 가져오려면 함수형 업데이트 사용&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760234983978&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;setCount(c =&amp;gt; c + 1);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2190&quot; data-start=&quot;2111&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 이 패턴은 &lt;b&gt;React가 내부적으로 최신 state를 전달&lt;/b&gt;해주기 때문에,&lt;br /&gt;클로저에 오래된 state가 남아 있어도 안전합니다.&lt;/p&gt;
&lt;hr data-end=&quot;2195&quot; data-start=&quot;2192&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-end=&quot;2225&quot; data-start=&quot;2197&quot; data-ke-size=&quot;size20&quot;&gt;✅ (2) useRef로 최신 값을 추적하기&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760235006052&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function Timer() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  useEffect(() =&amp;gt; {
    countRef.current = count; // 항상 최신값으로 갱신
  }, [count]);

  const start = () =&amp;gt; {
    setTimeout(() =&amp;gt; {
      console.log(&quot;Count:&quot;, countRef.current); // ✅ 항상 최신값
    }, 2000);
  };

  return (
    &amp;lt;&amp;gt;
      &amp;lt;p&amp;gt;{count}&amp;lt;/p&amp;gt;
      &amp;lt;button onClick={() =&amp;gt; setCount(c =&amp;gt; c + 1)}&amp;gt;+1&amp;lt;/button&amp;gt;
      &amp;lt;button onClick={start}&amp;gt;Start&amp;lt;/button&amp;gt;
    &amp;lt;/&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;blockquote data-end=&quot;2775&quot; data-start=&quot;2703&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;2775&quot; data-start=&quot;2705&quot; data-ke-size=&quot;size16&quot;&gt;  useRef는 렌더링과 무관하게 &lt;b&gt;변수를 지속적으로 유지&lt;/b&gt;하므로, stale closure를 방지할 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-end=&quot;2780&quot; data-start=&quot;2777&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-end=&quot;2833&quot; data-start=&quot;2782&quot; data-ke-size=&quot;size20&quot;&gt;✅ (3) useEvent (React 19+ or useCallbackRef 패턴)&lt;/h4&gt;
&lt;p data-end=&quot;2900&quot; data-start=&quot;2834&quot; data-ke-size=&quot;size16&quot;&gt;React 19부터 공식적으로 useEvent 훅이 추가됩니다.&lt;br /&gt;이는 &amp;ldquo;항상 최신 클로저를 보장&amp;rdquo;하는 훅입니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760235017772&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useEvent } from &quot;react&quot;;

function Timer() {
  const [count, setCount] = useState(0);

  const onTimeout = useEvent(() =&amp;gt; {
    console.log(&quot;Count:&quot;, count); // ✅ 항상 최신 count
  });

  const start = () =&amp;gt; setTimeout(onTimeout, 2000);

  return &amp;lt;button onClick={() =&amp;gt; setCount(c =&amp;gt; c + 1)}&amp;gt;+1&amp;lt;/button&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;blockquote data-end=&quot;3283&quot; data-start=&quot;3226&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;3283&quot; data-start=&quot;3228&quot; data-ke-size=&quot;size16&quot;&gt;⚡️ useEvent는 렌더링 시점을 새로 캡처하지 않고, 내부적으로 최신 클로저를 유지합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-end=&quot;3288&quot; data-start=&quot;3285&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-end=&quot;3323&quot; data-start=&quot;3290&quot; data-ke-size=&quot;size20&quot;&gt;✅ (4) 커스텀 훅으로 안전한 비동기 로직 관리하기&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760235032986&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function useSafeAsync(callback: () =&amp;gt; void, deps: any[]) {
  const callbackRef = useRef(callback);

  useEffect(() =&amp;gt; {
    callbackRef.current = callback;
  }, [callback]);

  return useCallback((...args: any[]) =&amp;gt; {
    callbackRef.current(...args);
  }, []);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3607&quot; data-start=&quot;3601&quot; data-ke-size=&quot;size16&quot;&gt;사용 예시:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760235045039&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const safeLog = useSafeAsync(() =&amp;gt; console.log(count), [count]);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3736&quot; data-start=&quot;3685&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 이렇게 하면, 오래된 클로저가 아닌 &lt;b&gt;항상 최신 상태를 참조하는 함수&lt;/b&gt;를 반환합니다.&lt;/p&gt;
&lt;hr data-end=&quot;2537&quot; data-start=&quot;2534&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2550&quot; data-start=&quot;2539&quot; data-ke-size=&quot;size26&quot;&gt;  요약 정리&lt;/h2&gt;
&lt;p data-end=&quot;155&quot; data-start=&quot;94&quot; data-ke-size=&quot;size16&quot;&gt;클로저는 단순히 &amp;ldquo;함수 안의 함수&amp;rdquo;가 아니라,&lt;br /&gt;&lt;b&gt;함수가 선언될 당시의 스코프를 기억하는 함수&lt;/b&gt;예요.&lt;/p&gt;
&lt;p data-end=&quot;235&quot; data-start=&quot;157&quot; data-ke-size=&quot;size16&quot;&gt;이를 통해 함수 외부에서 접근할 수 없는 변수를 &lt;b&gt;안전하게 유지&lt;/b&gt;하고,&lt;br /&gt;필요할 때마다 그 값을 &lt;b&gt;참조하거나 수정&lt;/b&gt;할 수 있죠.&lt;/p&gt;
&lt;p data-end=&quot;246&quot; data-start=&quot;237&quot; data-ke-size=&quot;size16&quot;&gt;즉, 클로저는&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;357&quot; data-start=&quot;247&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;267&quot; data-start=&quot;247&quot;&gt;&lt;b&gt;상태를 기억하는 함수&lt;/b&gt;,&lt;/li&gt;
&lt;li data-end=&quot;289&quot; data-start=&quot;268&quot;&gt;&lt;b&gt;데이터를 은닉하는 도구&lt;/b&gt;,&lt;/li&gt;
&lt;li data-end=&quot;357&quot; data-start=&quot;290&quot;&gt;그리고 &lt;b&gt;React의 useCallback / useMemo 같은 훅에서 핵심적으로 작동하는 메커니즘&lt;/b&gt;이에요.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;441&quot; data-start=&quot;359&quot; data-ke-size=&quot;size16&quot;&gt;하지만 너무 많은 변수를 참조하면 메모리 누수가 생길 수 있으니,&lt;br /&gt;&lt;b&gt;의도를 명확히 하고 꼭 필요한 경우에만 사용하는 습관&lt;/b&gt;이 중요합니다.&lt;/p&gt;
&lt;p data-end=&quot;448&quot; data-start=&quot;443&quot; data-ke-size=&quot;size16&quot;&gt;결국,&lt;/p&gt;
&lt;blockquote data-end=&quot;500&quot; data-start=&quot;449&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;500&quot; data-start=&quot;451&quot; data-ke-size=&quot;size16&quot;&gt;클로저를 이해한다는 건 자바스크립트의 &amp;ldquo;함수&amp;rdquo;와 &amp;ldquo;스코프&amp;rdquo;를 진짜로 이해한다는 뜻이에요.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>Notes/JavaScript</category>
      <category>closure</category>
      <category>클로저</category>
      <author>Grace Noh</author>
      <guid isPermaLink="true">https://meercat.tistory.com/505</guid>
      <comments>https://meercat.tistory.com/505#entry505comment</comments>
      <pubDate>Sun, 12 Oct 2025 12:04:02 +0900</pubDate>
    </item>
    <item>
      <title>[RN]  에러일지 - iOS17에서 App Crash 생기는 이유</title>
      <link>https://meercat.tistory.com/504</link>
      <description>&lt;p data-pm-slice=&quot;1 3 []&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;paragraph&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-size=&quot;size16&quot;&gt;iOS 17 이상에서 &lt;span data-prosemirror-mark-name=&quot;code&quot; data-prosemirror-content-type=&quot;mark&quot;&gt;UIGraphicsBeginImageContext()&lt;/span&gt;를 시도할 때, 이미지 사이즈가 (0, 0)인 경우 강제종료될 수 있으므로 &lt;span data-prosemirror-mark-name=&quot;code&quot; data-prosemirror-content-type=&quot;mark&quot;&gt;UIGraphicsImageRenderer&lt;/span&gt;를 쓰는 것이 권장된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;bulletList&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot;&gt;문제 패키지: &lt;span data-prosemirror-mark-name=&quot;code&quot; data-prosemirror-content-type=&quot;mark&quot;&gt;react-native-fast-image&lt;/span&gt;, &lt;span data-prosemirror-mark-name=&quot;code&quot; data-prosemirror-content-type=&quot;mark&quot;&gt;react-native-linear-gradient&lt;/span&gt;&lt;/li&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot;&gt;react-native-linear-gradient v2.6.2 &amp;rarr; v.2.8.3 upgrade&lt;/li&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot;&gt;react-native-fast-image 는 현재 문제 메서드가 deprecated 되서는 안되는 환경 문제 이슈로 patch-package 사용해서 수정.&lt;/li&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot;&gt;iOS 내부에서 &lt;span data-prosemirror-mark-name=&quot;code&quot; data-prosemirror-content-type=&quot;mark&quot;&gt;UIGraphicsBeginImageContext() &lt;/span&gt;사용된 함수 수정&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Engineering/Mobile</category>
      <category>ios17 crash</category>
      <category>React Native</category>
      <category>react-native-fast-image</category>
      <category>react-native-linear-gradient</category>
      <category>rn</category>
      <author>Grace Noh</author>
      <guid isPermaLink="true">https://meercat.tistory.com/504</guid>
      <comments>https://meercat.tistory.com/504#entry504comment</comments>
      <pubDate>Wed, 24 Sep 2025 22:19:30 +0900</pubDate>
    </item>
  </channel>
</rss>