개발 노트/React Native

[React Native] 통합 앱에서 딥링크와 푸시 클릭 라우팅을 프로파일별로 검증하는 방법

pposooj 2026. 5. 20. 14:27

통합 앱에서 딥링크와 푸시 알림 클릭은 단순한 화면 이동 기능처럼 보일 수 있습니다. 하지만 여러 프로파일을 하나의 코드베이스에서 관리하다 보면 생각보다 조심해야 할 부분이 많습니다.

어떤 앱에서는 지도 상세 화면이 열려야 하지만, 다른 앱에서는 지도 기능 자체가 없을 수 있습니다. 또 어떤 프로파일은 알림은 사용하지만 특정 상세 화면 접근은 막아야 할 수도 있습니다.

이 때 딥링크 URL이나 푸시 payload를 그대로 navigation.navigate()에 넘기면 위험할 수 있습니다. payload에 들어온 route 이름이 현재 프로파일에서 허용되는지, 필요한 feature flag가 켜져 있는지, params 형식이 맞는지 반드시 확인해야 합니다. 

처음에는 URL을 파싱해서 route로 바꾸는 정도면 충분하다고 생각했지만 통합 앱에서는 이 route가 현재 프로파일에서 열려도 되는지까지 확인해야 했습니다. 이 글에서는 React Native 통합 앱에서 딥링크와 푸시 클릭 라우팅을 프로파일별로 검증하는 기준을 정리해보겠습니다.

이 글에서 다룰 문제

딥링크와 푸시 라우팅 검증이 부족하면 다음 문제가 생길 수 있습니다.

  • 지도 기능이 없는 앱에서 딥링크로 지도 상세 화면이 열린다.
  • 푸시 payload의 route 이름을 그대로 믿고 잘못된 화면으로 이동한다.
  • params가 없거나 타입이 맞지 않아 상세 화면에서 오류가 발생한다.
  • selectedProfiles에는 없는 프로파일의 payload가 현재 앱에 전달된다.
  • feature flag가 꺼진 기능 화면으로 알림 클릭 이동이 발생한다.
  • fallback route가 없어 잘못된 링크 클릭 시 빈 화면이 표시된다.

처음에는 딥링크와 푸시 알림 클릭을 단순히 화면 이동으로만 보기 쉽습니다. 하지만 외부에서 들어오는 값은 앱 내부에서 만든 안전한 값이 아닐 수 있습니다. 그래서 route 이름, params, 기능 사용 여부를 한 번 더 확인하는 구조가 필요합니다.

딥링크와 푸시 라우팅을 나눠서 봐야 하는 이유

딥링크와 푸시 알림 클릭은 모두 화면 이동으로 끝나지만 시작점이 다릅니다. 입력 값의 성격이 다르기 때문에 검증 기준도 분리해서 봐야 합니다.

구분 입력 값 주요 위험
딥링크 URL, path, query string 잘못된 route, 외부 조작 가능성
푸시 클릭 notification/data payload route 이름과 params 신뢰 문제
내부 navigation 앱 코드에서 호출 route guard 누락
상태 복원 이전 화면 stack 비활성 route 복원

딥링크는 외부에서 들어오는 URL이고, 푸시 payload는 서버 또는 알림 시스템에서 전달되는 데이터입니다. 둘 다 앱 내부에서 만든 값이라고 가정하면 안 됩니다. route 이름, params, feature 조건을 모두 검증하는 편이 안전합니다.

기본 구조: route 요청 모델 만들기

먼저 딥링크와 푸시 payload를 바로 navigation에 넘기지 않고, 공통 route 요청 모델로 변환하는 것이 좋습니다. 입력 형식은 달라도 검증 단계부터는 같은 모델로 처리할 수 있기 때문입니다.

type RouteName = 'Home' | 'NoticeDetail' | 'MapDetail' | 'Settings';

type RouteRequest = {
  routeName: RouteName;
  params?: Record<string, string>;
  source: 'deeplink' | 'push';
};

type AppProfile = {
  profileId: string;
  enabledRoutes: RouteName[];
  features: {
    notice: boolean;
    map: boolean;
    settings: boolean;
  };
  fallbackRoute: RouteName;
};

이 구조를 두면 딥링크와 푸시가 서로 다른 입력 형식을 갖더라도, 이후 단계에서는 RouteRequest 하나를 기준으로 처리할 수 있습니다. 처음에는 모델을 하나 더 만드는 것이 번거롭게 느껴질 수 있지만, 검증 흐름을 정리하기에는 훨씬 편한 것 같습니다.

딥링크 URL 파싱 예시

아래 코드는 실제 앱 스킴이나 서버 URL을 제거한 예시입니다. 

function parseDeepLink(url: string): RouteRequest | null {
  let parsed: URL;

  try {
    parsed = new URL(url);
  } catch {
    return null;
  }

  const path = parsed.pathname.replace(/^\//, '');
  const id = parsed.searchParams.get('id') || undefined;

  if (path === 'notice/detail' && id) {
    return {
      routeName: 'NoticeDetail',
      params: {id},
      source: 'deeplink',
    };
  }

  if (path === 'map/detail' && id) {
    return {
      routeName: 'MapDetail',
      params: {id},
      source: 'deeplink',
    };
  }

  if (path === 'settings') {
    return {
      routeName: 'Settings',
      source: 'deeplink',
    };
  }

  return null;
}

중요한 점은 파싱에 실패했을 때 null을 반환하고, 이후 fallback 처리로 넘기는 것입니다. 잘못된 URL을 억지로 route로 만들면 오류 추적이 어려워질 수 있습니다.

URL 형식만 맞으면 바로 화면으로 보내도 괜찮아 보일 수 있습니다. 하지만 path나 query string이 예상과 다를 수 있기 때문에, 파싱 실패도 정상적인 입력 케이스로 보고 처리하는 편이 좋습니다.

푸시 payload 검증 예시

푸시 payload도 route 이름을 그대로 믿으면 안 됩니다. 허용된 route 이름인지 먼저 확인하고, 상세 화면에 필요한 params가 있는지도 함께 봐야 합니다.

const routeNames: RouteName[] = ['Home', 'NoticeDetail', 'MapDetail', 'Settings'];

function isRouteName(value: unknown): value is RouteName {
  return routeNames.includes(String(value) as RouteName);
}

function parsePushPayload(payload: Record<string, unknown>): RouteRequest | null {
  const routeName = payload.routeName;
  const id = payload.id;

  if (!isRouteName(routeName)) {
    return null;
  }

  if ((routeName === 'NoticeDetail' || routeName === 'MapDetail') && typeof id !== 'string') {
    return null;
  }

  return {
    routeName,
    params: typeof id === 'string' ? {id} : undefined,
    source: 'push',
  };
}

푸시 payload는 서버에서 만들었다고 해도 앱에서는 검증하는 편이 안전합니다. 서버 설정이 바뀌거나 예전 앱 버전과 payload 형식이 어긋날 수 있기 때문입니다.

이 부분은 서버에서 내려준 값이니까 안전하다고 생각할 수 있지만, 앱 버전과 서버 payload가 항상 같은 속도로 맞춰지는 것은 아닙니다.

프로파일별 route guard 적용하기

이제 공통 route 요청 모델을 현재 프로파일 기준으로 검증합니다. 여기서는 enabledRoutesfeature flag를 함께 확인합니다.

function requiredFeatureForRoute(routeName: RouteName): keyof AppProfile['features'] | undefined {
  if (routeName === 'NoticeDetail') return 'notice';
  if (routeName === 'MapDetail') return 'map';
  if (routeName === 'Settings') return 'settings';
  return undefined;
}

function canOpenRoute(profile: AppProfile, request: RouteRequest): boolean {
  if (!profile.enabledRoutes.includes(request.routeName)) {
    return false;
  }

  const feature = requiredFeatureForRoute(request.routeName);
  if (!feature) {
    return true;
  }

  return profile.features[feature] === true;
}

function resolveRouteRequest(profile: AppProfile, request: RouteRequest | null): RouteRequest {
  if (!request || !canOpenRoute(profile, request)) {
    return {
      routeName: profile.fallbackRoute,
      source: request?.source || 'deeplink',
    };
  }

  return request;
}

예를 들어 지도 feature가 꺼진 프로파일에서 MapDetail 딥링크가 들어오면 fallbackRoute로 보내는 식입니다. 이렇게 하면 잘못된 route가 바로 navigation으로 전달되는 일을 줄일 수 있습니다.

navigation에 연결하기

검증이 끝난 요청만 navigation에 넘깁니다. 입력을 바로 navigate()에 넘기지 않고, 파싱과 검증 단계를 거친 뒤 이동하는 흐름입니다.

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

function openResolvedRoute(navigation: NavigationLike, request: RouteRequest) {
  navigation.navigate(request.routeName, request.params);
}

핵심은 parse, validate, resolve, navigate 순서를 지키는 것입니다. 입력을 바로 navigation에 넘기지 않으면 잘못된 route나 params로 인한 오류를 줄일 수 있습니다.

fallback route 기준

잘못된 링크나 payload를 받았을 때는 어떤 화면으로 보낼지 정해야 합니다. fallback 기준이 없으면 빈 화면으로 이동하거나, 사용자가 이해하기 어려운 오류 화면을 보게 될 수 있습니다.

상황 fallback 기준
URL 파싱 실패 Home 또는 안전한 안내 화면
route 이름 오류 프로파일 fallbackRoute
params 누락 목록 화면 또는 안내 화면
feature 비활성 기능 안내 화면 또는 Home
profile 불일치 현재 앱의 기본 화면
예전 앱 버전 payload 업데이트 안내 또는 기본 화면

사용자에게 내부 오류 메시지를 그대로 보여주면 안 됩니다. 지원하지 않는 링크라는 정도의 일반적인 안내를 보여주고, 개발 로그에는 민감하지 않은 범위에서 원인만 남기는 것이 좋습니다.

selectedProfiles와의 관계

selectedProfiles는 빌드 대상 관리에 가깝고, 딥링크와 푸시 route guard는 런타임 접근 검증에 가깝습니다. 둘을 혼동하면 안 됩니다.

항목 역할
selectedProfiles 이번 빌드에 포함할 프로파일 선택
profile registry 현재 프로파일의 기능과 route 기준 제공
enabledRoutes 접근 가능한 route 목록 정의
feature flag route에 필요한 기능 사용 여부 판단
route guard 입력 route를 열어도 되는지 검증

selectedProfiles로 빌드 대상을 줄였다고 해서 모든 딥링크가 자동으로 안전해지는 것은 아닙니다. 런타임에서는 현재 profile 기준으로 다시 확인해야 합니다.

테스트 기준

딥링크와 푸시 라우팅은 단위 테스트를 가져가는 것이 좋습니다. UI 자동화 없이도 대부분 helper 함수 단위로 검증할 수 있기 때문입니다.

  • 잘못된 URL은 fallback route로 이동하는지 확인한다.
  • 알 수 없는 routeName은 차단되는지 확인한다.
  • 필수 params가 없으면 상세 화면으로 이동하지 않는지 확인한다.
  • feature flag가 false인 route는 차단되는지 확인한다.
  • enabledRoutes에 없는 route는 fallback 되는지 확인한다.
  • 푸시 payload를 직접 navigate에 넘기지 않는지 확인한다.
  • fallback 로그에 토큰, 서버 URL, 사용자 식별자가 포함되지 않는지 확인한다.

처음에는 parseDeepLink(), parsePushPayload(), resolveRouteRequest() 세 함수만 테스트해도 효과가 있습니다. 작은 함수처럼 보이지만 외부 입력을 다루는 핵심 흐름이라, 회귀가 생겼을 때 빠르게 잡아낼 수 있습니다.

자주 하는 실수

1. 딥링크 URL을 파싱하자마자 바로 navigation에 넘기는 경우가 있습니다. route 이름과 params를 검증하지 않으면 잘못된 링크 하나로 앱이 이상한 화면에 들어갈 수 있습니다.

2. 푸시 payload를 신뢰하는 경우도 있습니다. 서버에서 보내는 값이어도 앱 버전 차이나 설정 실수로 잘못된 route가 올 수 있습니다. 앱에서는 한 번 더 검증하는 편이 안전합니다.

3. 메뉴 숨김과 route 차단을 같은 것으로 보는 경우가 있습니다. 메뉴에서 버튼을 숨겨도 딥링크나 알림 클릭으로는 접근할 수 있으므로 route guard가 필요합니다.

결론

React Native 통합 앱에서 딥링크와 푸시 클릭 라우팅은 반드시 프로파일 기준으로 검증해야 합니다. 입력을 그대로 route로 바꾸는 것이 아니라, URL이나 payload를 먼저 공통 RouteRequest로 변환하고, 현재 프로파일의 enabledRoutesfeature flag를 기준으로 열어도 되는지 확인해야 합니다.

좋은 흐름은 parse, validate, resolve, navigate 순서입니다. 딥링크와 푸시 payload를 같은 모델로 변환하면 테스트하기 쉽고, fallback 기준도 일관되게 관리할 수 있습니다.

특히 route guard는 보안의 전부는 아니지만, 앱 내부 화면 접근을 안정적으로 제어하는 데 중요합니다. 실제 데이터 접근 권한은 서버에서 별도로 검증하고, 클라이언트에서는 잘못된 route와 params를 안전하게 fallback 처리하는 구조를 준비하는 것이 좋습니다.

참고 자료

  • React Native Linking 공식 문서
  • React Navigation deep linking 관련 문서
  • Firebase Cloud Messaging 또는 사용 중인 푸시 SDK 문서
  • 프로젝트 내부 profile registry, selectedProfiles, route guard, push payload 문서