Skip to content
Kim Bong-seop
Go back

모바일 앱 진입 경로 통합 — 딥링크·shortcut·푸시·QR을 한 파이프라인으로

Edit page

다섯 진입 경로가 openEntry 함수로 합쳐지는 표지 이미지

Expo Router 기반 안전관리 앱에는 앱 밖에서 특정 화면을 여는 진입점이 다섯 개 있었다. 웹 링크, 카카오톡 shortcut 링크, 푸시 알림 탭, 현장 QR 스캔, 홈의 알림 목록이었다. 진입점마다 처리 코드가 따로 있었고, 코드를 대조하고 백엔드 코드까지 확인한 결과 고칠 문제가 10건 나왔다.

이 글은 문제의 원인을 어떻게 짚었고, 어떤 외부 사례를 참고해 “받기 → 해석 → 판정 → 이동” 파이프라인으로 정리했는지에 대한 기록이다.

문제 10건과 원인

#문제
1로그인 전에 보관한 링크가 사업장 선택 직후 사라짐
2포그라운드에서 알림을 받기만 해도 화면 이동
3getLastNotificationResponseAsync를 JS 시작 때마다 다시 처리
4푸시 탭 이동이 동작하지 않음 (서버는 data.url만 보내는데 앱은 data.code를 찾음)
5비로그인 상태의 shortcut 링크가 로그인 후 원래 목적지로 돌아오지 않음
6QR 서버 조회 중 로딩 표시가 없음
7로그아웃해도 보관한 링크를 비우지 않음
8다른 사업장 링크는 “전환 후 다시 여세요” 안내만 하고 목적지를 버림
9목록에는 보이는데 진입하면 안내 없이 실패 (목록·진입 검사 기준 불일치)
10스킴만 있는 duegosafer://로 앱을 열면 “지원하지 않는 링크” 화면이 뜸

8번의 다른 사업장 안내는 딥링크, shortcut, QR 세 곳에서 각각 alert를 띄우고 끝났다. 카카오톡 링크나 현장 QR은 사용자가 다시 찾기 어려운 진입점이라 목적지를 버리는 비용이 컸다.

1번부터 8번까지는 원인이 두 가지로 모였다.

  1. 진입점마다 해석·이동·보관을 따로 했다. 해석기 resolveMobilePath가 있었지만 푸시와 QR은 이걸 쓰지 않았다. 이동도 진입점마다 router.push를 직접 불렀다.
  2. 보관한 목적지를 관리하는 주체가 없었다. 딥링크 처리기는 pendingUrlRef에 링크를 보관했지만 shortcut은 보관하지 않았다. 언제 꺼내고 언제 비우는지도 정해져 있지 않았다.

1번이 대표적이었다. 사업장을 고르면 상태가 selected로 바뀌고, 딥링크 처리기는 이를 감지해 보관한 링크로 push했다. 같은 시점에 사업장 선택 화면도 서약 여부를 조회한 뒤 자기 규칙대로 replace나 back을 했다. 두 곳이 서로 모르고 이동하니 순서가 보장되지 않았고, 늦게 실행된 쪽의 이동이 남았다.

참고한 외부 사례

고치기 전에 다른 회사들의 사례를 찾아봤다. 해법은 몇 가지 패턴으로 반복됐다.

진입점과 라우트 테이블을 하나로

Airbnb의 DeepLinkDispatch는 모든 링크를 하나의 입구에서 받아 라우트 테이블에서 찾는다. 성공과 실패를 이벤트로 남겨 깨진 링크를 측정한다. Yelp는 외부 링크마다 검증·파싱 계층을 먼저 거치게 하고, 헤이딜러도 “SchemeActivity 하나로 끝내기”라는 같은 방향의 글을 남겼다.

우리 앱에는 이미 resolveMobilePath가 있었으므로, 모든 진입점이 이 함수를 거치게 하면 됐다.

해석과 이동의 분리

29CM은 6년 동안 RootController 하나가 모든 화면 이동을 맡았고, god object가 되어 리팩토링으로 걷어냈다. AutoScout24는 수신·파싱·라우팅·상태를 계층으로 나누고 파싱과 라우팅을 순수 함수로 테스트한다. 여기서 얻은 결론은 해석기는 목적지 데이터만 돌려주고 이동은 한 곳에서만 해야 한다는 것이다.

조건부 링크의 재투입

Yelp는 로그인이 끝나면 원래 링크를 파이프라인에 다시 넣는다. Uber는 “로그인 대기 → 다음 단계 → 목적지” 같은 단계 체인을 정의한다. Slack은 이동 직전에 isReadyForPresentingURL()을 호출하고, 결과는 셋 중 하나다. 로딩 중이면 대기, 현재 팀이 맞으면 즉시 이동, 다른 팀이면 팀 전환 후 이동이다.

우리 앱의 조건은 “로그인 → 사업장 선택 → 서약” 3단계였다. Yelp보다 복잡하지만 Uber 수준의 프레임워크는 필요 없어서 Slack의 3분기 구조를 택했다.

React Navigation 문서도 로그인 상태가 바뀔 때 화면에서 직접 navigate하지 말라고 경고한다. 로그인 화면과 링크 처리기가 각자 이동하면 경합이 생기는데, 1번 문제가 정확히 이 경우였다.

다른 워크스페이스 링크

우리 앱의 “다른 사업장”은 협업 도구의 “다른 워크스페이스”와 같은 문제였다.

확인 없이 현재 계정으로 열기, 확인 없이 자동 전환, 전환 시 캐시 미삭제가 각각 사고로 이어졌다.

국내 기술 블로그의 딥링크 글은 대부분 스킴과 Universal Link 비교, 앱 설치 여부 판별 같은 마케팅·웹 주제였다. 로그인 후 복귀나 중앙 라우터를 다룬 글은 찾지 못했다.

설계: 받기 → 해석 → 판정 → 이동

다섯 진입점의 요청을 모두 하나의 파이프라인으로 처리하기로 했다.

 [받기]                  [해석]                    [판정]                          [이동]
 외부 링크 ─┐                                      ┌─ 준비 안 됨(로그인 전) ──▶ 보관 ──▶ 로그인 화면
 shortcut  ─┤  URL로     resolveMobilePath         │
 푸시 탭   ─┼─ 정규화 ─▶ (순수 함수, 이미 있음) ─▶ ├─ 같은 사업장 ──────────────────────▶ router.push
 QR        ─┤                                      │
 알림 목록 ─┘                                      ├─ 내가 속한 다른 사업장 ─▶ 확인 ─▶ 보관 ─▶ 사업장 진입
                                                   ├─ 속하지 않은 사업장 ─▶ 안내하고 멈춤
                                                   └─ 앱 미지원 / 외부 웹 ─▶ 기존 처리
단계담당역할
받기각 진입점입력을 URL 하나로 정규화
해석resolveMobilePath (기존)URL을 목적지 데이터로 변환 (순수 함수)
판정openEntry(url, source)지금 이동할 수 있는지 결정
이동openEntry(url, source)실제 화면 이동

진입점에서 router.push를 직접 호출하는 코드는 모두 없앴다.

대기 목적지: 한 칸, 소비 지점 하나

로그인 전이거나 다른 사업장이라 바로 이동할 수 없는 목적지는 대기 목적지로 보관했다.

type PendingEntry = {
  url: string; // 해석이 끝난 최종 URL
  targetCompanySeq: string | null;
  userId: string | null; // 넣을 때의 로그인 사용자 (로그인 전이면 null)
  expiresAt: number; // 넣은 시각 + 10분
};

규칙은 다음과 같이 정했다.

대기 목적지를 꺼내는 곳은 사업장 진입이 끝나는 지점 한 곳으로 정했다. 사업장 선택 화면은 원래 “선택 → 서약 확인 → 이동” 순서로 동작했고, 여기서 목적지를 정할 때 대기 목적지를 확인하도록 했다. 로그인 전에 들어온 링크, 다른 사업장 링크, 사용자의 수동 사업장 전환이 모두 이 지점을 지나므로 경합이 생길 자리가 없어졌다.

Expo 블로그나 Software Mansion 글 등 많은 자료는 앱 루트에서 로그인 상태 변화를 감지해 보관한 링크를 꺼낸다. 하지만 우리 앱은 사업장 선택 뒤에 서약 단계가 있어서, 이 방식을 쓰면 서약을 건너뛰거나 서약 화면이 목적지를 덮어쓰게 됐다. 서약 화면까지 목적지를 넘기는 returnTo가 이미 있었으므로 그 시작점에서 꺼내는 쪽이 변경이 가장 적었다. Slack이 로그인과 팀 선택이 끝나는 한 지점에서 링크를 소비하는 것과 같은 구조였다.

예외가 하나 있었다. 로그인한 상태로 앱을 종료한 뒤 링크로 다시 실행하면 세션이 복원되면서 사업장 선택 화면을 거치지 않는다. 이때 대기 목적지에 넣으면 꺼내는 곳이 없었다. 그래서 인증 복원 중에 들어온 요청은 잠시 들고 있다가 상태가 확정되면 파이프라인에 다시 넣게 했다(Yelp의 재투입 방식). 이동이 아니라 재판정이므로 소비 지점은 여전히 하나로 유지됐다.

다른 사업장 링크는 확인 후 전환하게 했다

다른 사업장 링크가 오면 판정 시점에 사업장 목록을 서버에서 새로 받아 소속 여부를 확인하게 했다.

목록 기준과 진입 기준이 달라서 사전 확인을 통과해도 진입이 거부될 수 있었다. 이 경우는 뒤에서 설명할 903 처리가 안내하도록 했다.

업계에서는 대체로 푸시 탭은 자동 전환, 링크는 확인 후 전환으로 나뉜다. 첫 버전에서는 둘 다 확인을 받기로 했다. 이유는 네 가지였다.

사업장 전환 시 데이터 격리

쿼리 캐시는 이미 invalidate가 아니라 removeQueries로 제거하고 있어서 이전 데이터가 잠깐 보일 일은 없었다. 여기에 두 가지를 추가했다.

푸시와 QR

푸시는 수신 시 알림 목록만 갱신하고, 화면 이동은 탭에서만 하게 했다(2번). Expo 공식 예제, OneSignal, FCM(Firebase Cloud Messaging) 모두 이 방식을 쓴다. 중복 처리는 두 단계로 막았다(3번).

useLastNotificationResponse 훅은 콜드 스타트 이중 발화 버그(expo#30649)가 있어서 쓰지 않았다.

QR은 스캔 직후 모달을 닫던 동작을 바꿨다(6번). 카메라를 연 채로 로딩 표시를 띄우고, 조회가 끝나면 모달을 닫고 openEntry에 넘기게 했다. 실패하면 안내 후 다시 스캔할 수 있게 했고, 조회 중 사용자가 닫으면 응답이 와도 이동하지 않게 했다.

백엔드 코드에서 확인한 것

설계 중 서버팀에 확인할 질문이 다섯 개 생겼고, 백엔드 코드를 직접 읽어 답을 얻었다. 그 과정에서 4번과 9번 문제가 드러났다.

푸시 탭 이동이 동작하지 않았다 (4번)

앱은 푸시 payload의 data.code를 보고 이동하도록 되어 있었다. 그런데 메시지 서버가 보내는 payload는 다음이 전부였다.

{ to, title, body: "", data: { url } }

code 필드가 없으므로 앱의 if (typeof data?.code === 'string') 조건은 항상 거짓이었다. 푸시 탭 이동 전체가 동작하지 않는 상태였다.

url 값은 /open/shortcutRedirect?code=...로 카카오톡 shortcut 링크와 형식이 같았다. code를 꺼내 shortcut 해석 경로로 넘기도록 바꿨고, 서버 변경은 필요 없었다. 그 결과 shortcut, QR, 푸시가 같은 경로를 타게 됐다. 기존 푸시 전용 라우터와 라우트 테이블은 삭제했다.

서버가 알림 수신자를 계정의 푸시 토큰으로 찾는다는 것도 알게 됐다. 앱에서 어느 사업장을 보고 있든 다른 사업장 알림은 이미 오고 있었으니, 다른 사업장 확인 흐름이 푸시에서도 필요했다.

목록과 진입 검사의 기준이 달랐다 (9번)

대상목록 API 노출 조건진입 검사 통과 조건
근로자status != 'D'status = 'A' 그리고 부서 status = 'A'
파트너status != 'D'status = 'A'

두 조건 사이에 걸린 사업장을 누르면 서버가 권한 없음(903)을 돌려줬다.

903 응답은 세션 무효 처리로 이어지지 않았다. 이 에러는 HTTP 200에 본문 status: 903으로 왔다. 성공 인터셉터가 이를 reject하는데, axios는 인터셉터 쌍을 then(fulfilled, rejected)로 연결하므로 같은 단계의 에러 인터셉터에 닿지 않는다. 그래서 증상은 로그아웃이 아니라, 에러 처리가 없어 카드 로딩만 멈추고 안내가 뜨지 않는 것이었다.

사업장 선택의 onError에서 “이 사업장에 들어갈 수 없어요”를 안내하도록 했다. 이 수정은 다른 작업보다 먼저 했다. 사업장을 직접 고를 때도 이미 발생할 수 있었고, 다른 사업장 자동 진입(8번)이 생기면 이 경로를 더 자주 타게 되기 때문이었다.

두 문제 모두 앱에서 해결했고, 이번 작업에 서버 변경은 없었다.

실기기 검증에서 추가로 고친 것

Android 실기기 검증에서 코드 대조로는 보이지 않던 문제가 더 나왔다.

택하지 않은 방식

방식택하지 않은 이유
Stack.Protected로 가드 전환SDK 57에는 원래 목적지를 보존하는 redirectTo가 없어 이번 문제를 풀지 못했다
딥링크마다 스택 재구성Shopify가 React Native 전환 때 포기한 방식. 기존 “returnTo → canGoBack → 기본 화면” back 규칙으로 충분했다
Uber식 단계 체인조건이 3단계뿐이라 과했다. 소비 지점을 하나로 모으는 것으로 같은 효과를 얻었다

정리: 이동 지점 하나, 소비 지점 하나

진입 경로 문제는 대부분 “여러 곳이 각자 이동한다”와 “보관한 목적지를 관리하는 주체가 없다”에서 나왔다. 그래서 이동은 openEntry 한 곳에서만 하고, 대기 목적지는 사업장 진입이 끝나는 한 지점에서만 꺼내도록 했다.


참고 자료


Edit page
Share this post on:

Previous Post
토큰 갱신과 사업장 전환 경합 정리 — single-flight와 epoch로 세운 세션 규칙
Next Post
Part 5. Oh My Bridge — 플러그인 구성과 작동 방식