React Native 통합 앱에서 프로파일별 딥링크와 푸시 라우팅 안전하게 검증하기
하나의 코드베이스로 여러 브랜디드 앱이나 프로파일을 빌드하는 통합 앱을 운영하다 보면, 딥링크와 푸시 알림 처리가 생각보다 까다로워집니다. 어떤 앱(프로파일)에서는 지도 상세 화면이 잘 열려야 하지만, 다른 앱에서는 지도 기능 자체가 아예 빠져 있을 수 있기 때문입니다.
저의 경우도 처음에는 단순히 외부 URL을 파싱해서 화면 이동만 시켜주면 끝날 줄 알았는데, 이래저래 운영을 하다 보니 특정 프로파일에서 열리면 안 되는 화면이 열리거나 params 타입이 안 맞아 앱이 뻗는 문제가 발생하곤 했습니다.
외부에서 들어오는 값은 언제든 바뀔 수 있고 오류가 섞이기 쉽기 때문에, 무작정 navigation.navigate()에 던지면 위험합니다. 나중에 또 까먹을까 봐 안전하게 라우팅을 검증하는 흐름을 정리해 둡니다.
검증이 부족할 때 발생하는 문제점들
- 기능이 없는 앱에서 화면 진입: 지도 기능이 없는 프로파일인데 딥링크를 타고 지도 상세 화면이 열림
- 잘못된 라우팅: 푸시 payload의 route 이름을 그대로 믿었다가 엉뚱한 화면으로 이동
- 크래시 발생: 필수 params가 누락되거나 타입이 맞지 않아 상세 화면 렌더링 중 에러 발생
- 권한 및 기능 플래그 무시: feature flag가 꺼진 기능인데 알림 클릭으로 강제 진입
- 화면 먹통 현상: 잘못된 링크가 들어왔을 때 처리할 fallback route가 없어 빈 화면만 표시
딥링크와 푸시 라우팅을 나눠서 봐야 하는 이유
둘 다 결국 화면 이동으로 끝나지만, 들어오는 입력 값의 성격이 완전히 다릅니다. 입력 방식에 따라 검증 기준도 분리해서 처리해야 안전합니다.
| 구분 | 입력 값 | 주요 위험 요소 |
| 딥링크 | URL, path, query string | 잘못된 route 구조, 외부 조작 가능성 |
| 푸시 클릭 | notification / data payload | 서버 설정 변경 또는 구버전 앱 간의 payload 신뢰 문제 |
| 내부 navigation | 앱 코드 내부 호출 | route guard 누락 가능성 |
1. 기본 구조: 공통 route 요청 모델 만들기
딥링크든 푸시든 입력 형식은 다르더라도, 검증 단계부터는 동일한 구조로 처리할 수 있도록 공통 모델(RouteRequest)을 먼저 정의해 주는 것이 관리하기 편합니다.
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;
};
생각 공유: 처음에는 모델을 하나 더 만드는 게 번거롭게 느껴질 수 있지만, 이 구조를 잡아두면 파싱 이후의 검증 로직이 하나로 통일되어 유지보수가 아주 깔끔해집니다.
2. 딥링크 URL 파싱하기
실제 앱 스킴이나 주소를 제외한 파싱 예시 코드입니다. 파싱에 실패했을 때는 억지로 맞추지 말고 null을 반환해서 fallback으로 넘기는 것이 안전합니다.
function parseDeepLink(url: string): RouteRequest | null {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null; // 잘못된 URL 형식은 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;
}
3. 푸시 페이로드(Payload) 검증하기
"서버에서 보내주는 값이니까 무조건 안전하겠지"라고 생각하면 안 됩니다. 앱 버전 업데이트 속도와 서버 데이터 형식이 일치하지 않을 수 있으므로 반드시 허용된 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;
}
// 상세 화면인데 id가 string이 아니면 진입 차단
if ((routeName === 'NoticeDetail' || routeName === 'MapDetail') && typeof id !== 'string') {
return null;
}
return {
routeName,
params: typeof id === 'string' ? { id } : undefined,
source: 'push',
};
}
4. 프로파일별 Route Guard 적용하기
이제 공통 모델로 변환된 요청을 현재 앱의 프로파일 정보(enabledRoutes, feature 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 {
// 1. 현재 프로파일에서 허용된 라우트인지 확인
if (!profile.enabledRoutes.includes(request.routeName)) {
return false;
}
// 2. 관련 기능 플래그(Feature Flag)가 켜져 있는지 확인
const feature = requiredFeatureForRoute(request.routeName);
if (!feature) {
return true;
}
return profile.features[feature] === true;
}
function resolveRouteRequest(profile: AppProfile, request: RouteRequest | null): RouteRequest {
// 검증을 통과하지 못하면 프로파일별 지정된 기본 fallbackRoute로 대체
if (!request || !canOpenRoute(profile, request)) {
return {
routeName: profile.fallbackRoute,
source: request?.source || 'deeplink',
};
}
return request;
}
이렇게 처리하면 지도 기능이 꺼진 프로파일로 MapDetail 딥링크가 찔고 들어와도, 안전하게 fallbackRoute(예: 홈 화면)로 우회시킬 수 있어서 크래시를 예방할 수 있습니다.
5. 네비게이션 최종 연결
모든 파싱과 프로파일 기반 검증이 끝난 안전한 RouteRequest 데이터만 최종적으로 네비게이션에 전달하여 이동시킵니다.
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 (이동)] 입니다. 단계를 이렇게 확실히 쪼개두면 도중에 들어오는 예외 데이터들을 제어하기가 훨씬 수월해집니다.
실패 시 Fallback 처리 기준 정의하기
잘못된 링크나 페이로드를 받았을 때 사용자에게 에러 코드나 빈 화면을 보여주면 안 됩니다. 상황별로 명확한 대안 화면을 지정하는 작업이 필요합니다.
- URL 파싱 실패: 메인(Home) 또는 안전한 안내 팝업창 노출
- 존재하지 않는 Route: 현재 프로파일의 fallbackRoute로 리다이렉트
- 필수 Params 누락: 상세 화면 대신 목록 화면으로 보내거나 안내 처리
- Feature Flag 비활성: 기능 미지원 안내 후 메인 화면으로 이동
- 서버/앱 버전 불일치: 업데이트 유도 안내 팝업 또는 기본 화면 이동
주의하셔야 할 점:
빌드 대상 프로파일을 관리하는 selectedProfiles 설정과 런타임에서 접근을 제어하는 route guard를 혼동하면 안 됩니다. 빌드 단계에서 특정 프로파일 파일을 제외했다고 해서, 외부에서 들어오는 딥링크 호출까지 자동으로 안전해지는 것은 아닙니다. 앱 구동 중에 체크하는 런타임 검증은 필수입니다.
단위 테스트로 무결성 유지하기
이 로직들은 UI 테스트 환경을 구축하지 않아도, 단순 Helper 함수 단위로 검증하기 아주 좋습니다. 버그 수정을 하다가 기존 로직이 깨지는 회귀 오류를 막기 위해 아래 케이스들은 테스트 코드를 작성해 두는 편이 이롭습니다.
- 잘못된 URL 포맷이 들어왔을 때 fallback route로 잘 빠지는지 확인
- 페이로드에 정의되지 않은 알 수 없는 routeName이 들어오면 차단되는지 확인
- 필수 params가 누락되었을 때 상세 화면 진입을 막는지 확인
- feature flag가 false일 때 차단 로직이 올바르게 작동하는지 확인
- 에러 로그를 남길 때 푸시 토큰이나 유저 식별자 같은 민감 정보가 포함되지 않는지 확인
마무리하며
단순히 UI 메뉴에서 권한이 없다고 버튼을 숨기는 것만으로는 라우팅 관리가 완벽하지 않습니다. 사용자는 딥링크나 예전에 받아둔 푸시 알림 클릭을 통해 언제든 비활성화된 화면으로 다이렉트 진입을 시도할 수 있기 때문입니다.
조금 번거롭더라도 입력을 바로 넘기지 말고 파싱과 검증 단계를 명확히 분리하고, 프로파일별 권한 체크(Route Guard)를 거치도록 구조를 잡아두면 다중 프로파일 통합 앱을 훨씬 더 안정적으로 서비스할 수 있습니다. 비슷한 환경에서 고민 중이신 분들께 작은 도움이 되었으면 합니다.