React Navigation을 쓰다 보면 useNavigation()이 닿지 않는 곳에서 화면을 옮겨야 할 때가 온다. 딥링크 핸들러, 푸시 알림 콜백, 외부 SDK 콜백, 전역 인터셉터 같은 "컴포넌트 트리 바깥"이다. navigationRef를 쓰면 되지만, 막상 해보면 빈 화면·흰 화면 같은 타이밍 버그가 줄줄이 튀어나온다. 이 글은 트리 밖 화면 전환을 실제로 안정적으로 만들기까지 밟은 과정과, 그 사이에 만난 버그들의 정리다.

문제 — useNavigation이 닿지 않는 곳

화면 전환은 보통 컴포넌트 안에서 useNavigation() 훅으로 한다. 그런데 React 컴포넌트가 아닌 곳에서 전환을 트리거해야 하는 경우가 의외로 많다.

  • 딥링크 핸들러 — linking.getInitialURL / subscribe는 모듈 스코프 함수다.
  • 푸시 알림 탭 — 백그라운드 핸들러는 트리 밖에서 실행된다.
  • 외부 SDK / 전역 콜백 — 결제, 분석, 소켓 이벤트 등.
  • axios 인터셉터 — 401 응답에서 로그인 화면으로 보내기.

이런 곳에선 훅을 쓸 수 없다. 공식 해법은 NavigationContainer에 ref를 달아 전역에서 접근하는 것이다.

// App.tsx
import { createNavigationContainerRef } from '@react-navigation/native';
export const navigationRef = createNavigationContainerRef();

<NavigationContainer ref={navigationRef}>
  {/* ... */}
</NavigationContainer>
// 트리 밖 어디서든
import { navigationRef } from './App';

if (navigationRef.isReady()) {
  navigationRef.navigate('SomeScreen');
}

여기까지는 문서에도 나온다. 문제는 "단순 navigate가 아니라 네비게이터 그룹을 통째로 바꿔야 할 때", 그리고 "트리 밖 ref와 React 생명주기가 어긋날 때" 시작된다.

1. 즉시 navigate가 불가능한 경우 — 플래그 + watcher 패턴

우리 앱은 user.role에 따라 루트 네비게이터 그룹이 통째로 갈린다. 일반 사용자 그룹과 별도 그룹(예: 관리자 그룹)이 조건부로 렌더된다.

function RootNavigator() {
  const { data: user } = useGetUser();
  return (
    <Stack.Navigator>
      {user?.role === 'ADMIN' ? (
        <Stack.Screen name="AdminNav" component={AdminNavigator} />
      ) : (
        <Stack.Screen name="Home" component={Home} />
      )}
    </Stack.Navigator>
  );
}

문제는 트리 밖에서 "지금 관리자 그룹으로 보내!"를 호출하는 순간, AdminNav는 아직 트리에 존재하지 않는다. 그룹 전환은 user.role이 바뀌고 → 리렌더가 일어나야 비로소 마운트되기 때문이다. 이 시점에 바로 navigate('AdminNav')를 하면 "해당 라우트가 없다"며 실패한다.

그래서 직접 이동 대신 의도(플래그)만 남기고, 트리 안의 watcher가 적절한 타이밍에 소비하게 했다.

// pendingEntry.ts — 모듈 스코프 플래그
let pending = false;
export const setPending = (v: boolean) => { pending = v; };
export const consumePending = () => {
  const v = pending;
  pending = false;   // 1회성 소비
  return v;
};
// 트리 밖: 상태를 바꾸고 의도만 남긴다
function handleExternalTrigger() {
  // ...role 을 바꾸는 작업(세션 갱신 등)...
  setPending(true);   // 직접 navigate 하지 않는다
}
// 트리 안 watcher: 그룹이 마운트된 "다음" 프레임에 소비
useEffect(() => {
  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]);

핵심: 조건부 렌더링과 즉시 navigate는 타이밍이 어긋난다. "상태를 바꿔 그룹을 마운트시킨 뒤, 마운트된 다음 프레임에 이동"이라는 2단계로 끊으면 레이스가 사라진다. resetRoot를 쓴 이유는 단순 이동이 아니라 스택을 갈아끼워 뒤로가기로 돌아오지 못하게 하기 위함이다.

2. user가 바뀌지 않는 진입 경로 — listener 보강

위 watcher는 [user] 의존성으로 돈다. 즉 user 값이 바뀔 때만 재실행된다. 그런데 진입 경로에 따라선 user가 아예 바뀌지 않는 경우가 있다.

예를 들어 이미 비로그인 상태(user가 이미 그 값)인 화면에 머무는 중에 외부 트리거가 들어오면, user는 그대로라 effect가 다시 돌지 않는다. 플래그만 세워지고 아무도 소비하지 않아 화면이 고착된다.

해결은 플래그 set 자체를 구독 가능한 이벤트로 만드는 것이다.

// pendingEntry.ts
let listener: (() => void) | null = null;

export const setPending = (v: boolean) => {
  pending = v;
  if (v) listener?.();   // set(true) 시 즉시 통지 → user 변화에 의존하지 않음
};
export const subscribePending = (cb: () => void) => {
  listener = cb;
  return () => { if (listener === cb) listener = null; };
};
// 트리 안: user 변화 외에 "플래그 set" 도 직접 구독
useEffect(() => subscribePending(processPending), []);

진입 경로가 여러 개면 신호도 여러 개 필요하다. "상태 변화에 묻어가는 신호"([user] effect)와 "상태와 무관한 직접 신호"(listener)를 함께 둬야 모든 경로가 빠짐없이 처리된다. watcher 하나로 다 되겠지 하면 꼭 한 경로가 샌다.

3. 가장 지독한 버그 — Activity 재생성과 죽은 ref

여기서부터가 진짜 함정이다. HOC나 트리 밖 코드에서 resetRoot를 쓰려고, onReady에서 컨테이너의 live ref를 모듈 스코프 브릿지에 주입해 뒀다고 하자.

<NavigationContainer
  ref={navigationRef}
  onReady={() => setNavigationRef(navigationRef.current)}  // 브릿지에 주입
>

이게 Android의 "활동을 유지하지 않음" 설정이나 메모리 회수로 Activity가 재생성될 때 터진다. JS 프로세스(컨텍스트)는 살아 있어서 모듈 스코프의 navigationRef가 이미 파괴된 컨테이너를 가리킨 채 남는다. 리마운트된 화면이 그 ref를 "준비됨(non-null)"으로 오인하고 죽은 컨테이너에 resetRoot를 흘려보내면 — 아무 일도 안 일어나고 빈 화면이 된다.

해결은 의외로 단순하다. 컨테이너가 언마운트될 때 브릿지를 비운다.

useEffect(() => {
  return () => setNavigationRef(null);   // 언마운트 시 죽은 ref 정리
}, []);

이렇게 하면 리마운트된 코드가 ref를 다시 null에서 출발해(콜드 스타트와 동일) onReady를 정상적으로 재구독한다.

교훈: 모듈 스코프(React 밖)에 둔 ref는 컴포넌트 생명주기와 자동으로 동기화되지 않는다. 마운트 시 주입 / 언마운트 시 정리를 한 쌍으로 직접 관리해야 한다. 안 그러면 "살아있어 보이는 시체 ref"에 당한다.

4. Fast Refresh가 삼킨 per-screen reset

개발 중에만 재현되던 흰 화면도 있었다. 어떤 화면을 감싼 가드 HOC에서, 특정 조건이면 다른 그룹으로 보내려고 per-screen navigation.dispatch(reset)을 썼다.

// ❌ Fast Refresh 상황에서 이 dispatch 가 무시되는 경우가 있었다
navigation.dispatch(CommonActions.reset({
  index: 0, routes: [{ name: 'OtherNav' }],
}));

Fast Refresh로 컴포넌트가 갈아끼워지는 타이밍에 이 per-screen dispatch가 삼켜져서, 리다이렉트 대상이 원래 화면에 그대로 머무는 흰 화면이 났다. 해결은 per-screen navigation 대신 항상 동작하는 컨테이너 live ref의 resetRoot로 바꾸는 것이었다.

// ✅ 컨테이너 ref 의 resetRoot 는 항상 동작
const ref = getNavigationRef();
if (ref) {
  ref.resetRoot({ index: 0, routes: [{ name: 'OtherNav' }] });
}

추가로, 가드의 effect는 부모 NavigationContainer의 onReady보다 먼저 커밋된다(자식 effect가 부모보다 먼저 실행). 그래서 첫 렌더에 ref가 아직 null일 수 있는데, 이걸 그냥 두면 리다이렉트가 안 일어나 고착된다. ref 주입 시점을 구독해 준비되면 effect를 재실행하게 했다.

const [isNavReady, setIsNavReady] = useState(() => getNavigationRef() != null);

useEffect(() => {
  if (isNavReady) return;
  const unsub = subscribeNavigationRef(() => setIsNavReady(true));
  // 초기 useState 평가와 구독 등록 사이 갭에서 놓친 주입을 보정
  if (getNavigationRef() != null) setIsNavReady(true);
  return unsub;
}, [isNavReady]);

5. 보너스 — 딥링크 경로 판별은 부분 일치로 하지 마라

트리 밖 화면 전환은 딥링크에서 자주 일어나는데, 여기서도 작은 함정을 하나 밟았다. 특정 경로(admin 같은)를 판별하려고 처음엔 endsWith를 썼다.

// ❌ superadmin, foo/admin 까지 오탐
if (url.endsWith('admin')) { ... }

superadmin이나 something/admin 같은 URL이 전부 매칭됐다. prefix를 떼어낸 나머지 경로가 정확히 일치하는지로 바꿔야 한다.

// ✅ prefix 제거 후 완전 일치
const PREFIXES = ['myapp://', 'https://myapp.example.com'];
const getPathAfterPrefix = (url: string) => {
  const prefix = PREFIXES.find((p) => url.startsWith(p));
  const rest = prefix ? url.slice(prefix.length) : url;
  return rest.split('#')[0].split('?')[0].replace(/^\/+|\/+$/g, '');
};
const isAdminPath = (url: string) => getPathAfterPrefix(url) === 'admin';

판별에 쓰는 prefix 배열은 linking.prefixes와 같은 상수를 공유하게 해서 둘이 어긋나지 않도록 했다.

정리

트리 밖 화면 전환의 어려움은 결국 "React 밖에서 시작해 React 안에서 끝나는 흐름"의 타이밍을 맞추는 일이다. 정리하면:

  • 즉시 navigate가 안 되면 플래그+watcher로 끊어라. 조건부 렌더로 마운트될 그룹은, 마운트된 다음 프레임에 이동시킨다.
  • 진입 경로가 여럿이면 신호도 여럿이다. 상태 변화에 묻어가는 신호([user] effect)와 직접 신호(listener)를 함께 둬라.
  • 모듈 스코프 ref는 생명주기와 어긋난다. 마운트 시 주입 / 언마운트 시 정리를 한 쌍으로 관리하라. Activity 재생성의 "좀비 ref"를 조심.
  • 그룹 전환엔 per-screen reset보다 컨테이너 resetRoot. Fast Refresh에서 per-screen dispatch가 삼켜질 수 있다.
  • ref 준비 시점을 구독하라. 자식 effect는 부모 onReady보다 먼저 커밋된다.
  • 딥링크 경로는 부분 일치로 판별하지 마라. prefix 제거 후 완전 일치.

이 패턴들은 처음부터 설계로 떠오르지 않는다. 빈 화면·흰 화면 버그를 하나씩 잡아가며 "아, 여기서 React 밖과 안이 어긋나는구나"를 깨달은 흔적에 가깝다. 같은 곳에서 헤매는 누군가에게 지름길이 되면 좋겠다.