개발 노트/React Native

React Native 통합 앱 프로파일별 화면 접근 제어 (Route Guard 구현하기)

pposooj 2026. 5. 20. 13:21

React Native로 여러 앱(프로파일)을 하나의 코드베이스로 묶어서 운영하다 보면, 앱마다 보여줘야 하는 화면과 막아야 하는 화면이 달라지는 경우가 자주 생깁니다. 예를 들어 어떤 앱은 지도 기능이 필요하고, 어떤 앱은 공지만 보여주면 되는 식입니다.

처음에는 단순히 메뉴에서 버튼만 숨기면 끝날 줄 알았습니다. 하지만 실제로 서비스를 운영해보니 사용자가 화면에 진입하는 경로는 생각보다 훨씬 다양하더군요.

  • 메뉴 및 탭 버튼 클릭
  • 딥링크(Deep Link)를 통한 외부 진입
  • 푸시 알림(Push Notification) 클릭
  • 내부 코드 내 navigation.navigate 직접 호출
  • 앱 재실행 시 기존 화면 상태 복원

메뉴만 대충 숨겨두면, 메뉴에는 없는 화면인데 딥링크나 푸시 알림을 타고 비활성화된 화면으로 쓱 들어오는 문제가 생깁니다. 최악의 경우 연동된 데이터가 없어서 앱이 죽어버리기도(Crash) 합니다.

화면마다 if (profileId === '...') 같은 조건문을 일일이 넣다 보면 나중에 화면이 늘어났을 때 관리가 불가능해집니다. 자꾸 까먹기도 해서, 한 곳에서 깔끔하게 라우팅을 검증하는 Route Guard 구조를 정립하고 코드를 정리해 둡니다.

1. 기본 구조 정의: enabledRoutes 관리하기

화면 마다 조건문을 흩뿌리는 대신, 프로파일 레지스트리에 각 앱이 접근할 수 있는 Route 목록을 명확히 데이터로 정의해 주는 것이 좋습니다.

TypeScript
 
type RouteName = 'Home' | 'Map' | 'MapDetail' | 'Notice' | 'Settings' | 'WebView';

type AppProfile = {
  profileId: string;
  displayName: string;
  initialRoute: RouteName;
  enabledRoutes: RouteName[];
  fallbackRoute: RouteName;
};

const profileRegistry: Record<string, AppProfile> = {
  sampleTour: {
    profileId: 'sampleTour',
    displayName: '샘플 관광 앱',
    initialRoute: 'Home',
    enabledRoutes: ['Home', 'Map', 'MapDetail', 'Notice', 'Settings'],
    fallbackRoute: 'Home',
  },
  sampleInfo: {
    profileId: 'sampleInfo',
    displayName: '샘플 정보 앱',
    initialRoute: 'Notice',
    enabledRoutes: ['Home', 'Notice', 'Settings', 'WebView'],
    fallbackRoute: 'Notice',
  },
};

function getProfile(profileId: string): AppProfile {
  const profile = profileRegistry[profileId];
  if (!profile) {
    throw new Error(`Unknown profileId: ${profileId}`);
  }
  return profile;
}

function canAccessRoute(profile: AppProfile, routeName: RouteName): boolean {
  return profile.enabledRoutes.includes(routeName);
}

이렇게 정형화해 두면 어떤 프로파일이 어떤 화면을 쓸 수 있는지 한눈에 파악할 수 있어서 추후 유지보수할 때 훨씬 덜 헷갈립니다.

2. 안전한 Navigation Helper 만들기

각 컴포넌트에서 navigation.navigate()를 직접 호출하게 두면 가드 로직을 빼먹는 실수가 무조건 나옵니다. 공통 헬퍼 함수를 만들어 거쳐 가도록 구조를 잡아줍니다.

TypeScript
 
type NavigateParams = {
  routeName: RouteName;
  params?: Record<string, unknown>;
};

type NavigationLike = {
  navigate: (routeName: string, params?: Record<string, unknown>) => void;
  replace?: (routeName: string, params?: Record<string, unknown>) => void;
};

export function navigateWithGuard(
  navigation: NavigationLike,
  profile: AppProfile,
  target: NavigateParams,
) {
  // 권한이 있다면 정상 이동
  if (canAccessRoute(profile, target.routeName)) {
    navigation.navigate(target.routeName, target.params);
    return;
  }

  // 권한이 없다면 fallbackRoute 혹은 initialRoute로 튕겨내기
  const fallbackRoute = profile.fallbackRoute || profile.initialRoute;

  if (navigation.replace) {
    navigation.replace(fallbackRoute);
    return;
  }

  navigation.navigate(fallbackRoute);
}

허용되지 않은 route로 가려고 하면 빈 화면으로 멈춰있는 대신, 설정해 둔 fallbackRoute로 즉시 돌려버리기 때문에 사용자 경험 측면에서 안전합니다.

실무 Tip: 운영 단계에서는 가드에 걸려 차단되는 시점에 로그(Logging)를 남겨두도록 고도화하면, 어떤 프로파일에서 잘못된 접근이 일어나는지 추적할 때 아주 유용합니다.

3. 메뉴 노출과 가드 기준 일치시키기

라우터 가드를 잘 세워뒀어도 UI 메뉴에 계속 보이면 사용자는 의아함을 느낍니다. 메뉴를 그릴 때도 동일하게 enabledRoutes 데이터를 바라보도록 필터링해 줍니다.

TypeScript
 
type MenuItem = {
  label: string;
  routeName: RouteName;
};

const allMenuItems: MenuItem[] = [
  { label: '홈', routeName: 'Home' },
  { label: '지도', routeName: 'Map' },
  { label: '공지', routeName: 'Notice' },
  { label: '설정', routeName: 'Settings' },
];

function getVisibleMenuItems(profile: AppProfile): MenuItem[] {
  return allMenuItems.filter((item) => canAccessRoute(profile, item.routeName));
}

메뉴 표시 기준과 실제 라우팅 진입 차단 기준이 완벽히 삼박자를 이뤄야 버그 없는 통합 앱처럼 보입니다.

4. 외부 진입 경로(딥링크 및 푸시 알림) 검증

외부 페이로드(Payload)를 타고 들어오는 데이터는 보안상 신뢰할 수 없다고 가정하고 접근해야 합니다. 현재 활성화된 프로파일을 기준으로 라우트명 유효성 검사를 필히 거쳐야 합니다.

TypeScript
 
type IncomingRoutePayload = {
  routeName?: string;
  id?: string;
};

function isRouteName(value: unknown): value is RouteName {
  return ['Home', 'Map', 'MapDetail', 'Notice', 'Settings', 'WebView'].includes(String(value));
}

export function resolveIncomingRoute(
  profile: AppProfile,
  payload: IncomingRoutePayload,
): NavigateParams {
  const routeName = payload.routeName;

  // 1. 존재하는 Route 구조인지 검증
  if (!isRouteName(routeName)) {
    return { routeName: profile.fallbackRoute };
  }

  // 2. 현재 앱 프로파일에서 허용된 Route인지 검증
  if (!canAccessRoute(profile, routeName)) {
    return { routeName: profile.fallbackRoute };
  }

  return {
    routeName,
    params: payload.id ? {id: payload.id} : undefined,
  };
}

알림 payload에 화면 이름이 있다고 덥석 이동시키지 말고, 이와 같이 resolveIncomingRoute 단계를 거쳐 안전함이 보장된 경로만 반환받아 이동시켜야 안정적입니다.

⚠️ 주의해야 할 점 (실무 체크포인트)

  1. Route Guard는 서버 권한 검증이 아닙니다 화면 진입을 막아줄 뿐이지 실제 데이터 접근을 막아주는 게 아닙니다. 사용자가 패킷을 가로채거나 API를 직접 찌를 수 있으므로, 중요 데이터 권한 검증은 반드시 서버 API에서 별도로 처리해야 합니다.
  2. 초기 화면(initialRoute) 누락 실수 enabledRoutes 목록에 각 프로파일의 initialRoute를 깜빡하고 빼먹는 경우가 간혹 있습니다. 이 경우 앱이 켜지자마자 권한 없음으로 판단해 무한 루프에 빠지거나 튕길 수 있으니 주의가 필요합니다.
  3. Fallback 경로 지정 필수 잘못된 접근을 막았을 때 보내줄 목적지(fallbackRoute)를 명확히 지정해 주지 않으면 사용자에게 빈 화면이나 알 수 없는 에러가 노출되어 당황스러울 수 있습니다.

요약

통합 앱을 운영할 때 하드코딩으로 분기 처리를 하면 서비스 규모가 커질 때 감당이 안 됩니다. 이렇게 enabledRoutes 레지스트리 형태로 명세화하고 공통 Helper로 라우팅 길목을 제어하면, 나중에 새로운 앱 프로파일이나 화면이 추가되어도 이 설정 파일 하나만 슥 수정하면 끝나기 때문에 관리가 대단히 수월해집니다.