딥링크라는 게 개념은 단순하다. 링크를 누르면 앱의 특정 화면이 열리는 것. 그런데 막상 구현하려고 보면 “이 URL이 어디서 만들어져서, 어떻게 앱까지 도달하고, 앱 안에서 누가 받아서 처리하는 거지?”라는 의문이 꼬리를 문다.
이 글에서는 AppsFlyer의 OneLink를 사용해서 언리얼 엔진 모바일 앱에 딥링크를 연동하는 전체 파이프라인을 처음부터 끝까지 정리한다.
큰 그림부터 보자
딥링크의 여정을 한 줄로 요약하면 이렇다:
웹에서 공유 URL 생성 → 사용자 클릭 → 앱 실행 → URL 수신 → 파싱 → 화면 이동
좀 더 구체적으로 펼치면:
[OneLink URL]
https://myapp.onelink.me/AbCd/share?deep_link_value=myapp://episode/abc-123
↓
[AppsFlyer SDK] ← 앱에 설치된 SDK가 URL을 받아서
↓
[플랫폼 핸들러] ← iOS / Android 각각의 네이티브 코드가 중계
↓
[C++ DeepLinkManager] ← URL을 파싱하고 타입/ID를 분류
↓
[Delegate 브로드캐스트] ← "이런 딥링크 왔어요" 이벤트 발행
↓
[Blueprint] ← 실제 화면 전환, API 호출 등 처리
각 단계를 하나씩 뜯어보자.
1단계: OneLink URL — 딥링크의 출발점
웹이나 다른 앱에서 공유 버튼을 누르면 이런 URL이 만들어진다:
https://myapp.onelink.me/SpvA/release?deep_link_value=myapp%3A%2F%2Fepisode%2Fabc-123
URL 디코딩하면 핵심이 보인다:
| 부분 | 의미 |
|---|---|
myapp.onelink.me |
AppsFlyer OneLink 도메인 |
/SpvA/release |
OneLink 템플릿 + 캠페인 |
deep_link_value |
실제 딥링크 값 |
결국 앱이 최종적으로 받는 건 deep_link_value 안에 담긴 myapp://episode/abc-123이다. 나머지는 AppsFlyer가 어트리뷰션 추적용으로 쓰는 포장지 같은 것.
2단계: 앱 진입 — Direct vs Deferred
사용자가 링크를 클릭했을 때, 앱이 이미 설치되어 있느냐 아니냐에 따라 경로가 갈린다.
Direct Deep Link (앱 설치됨)
링크 클릭 → 앱 바로 실행 → AppsFlyer가 OnAppOpenAttribution 콜백 호출
앱이 있으니까 바로 열린다. 심플.
Deferred Deep Link (앱 미설치)
링크 클릭 → 스토어로 이동 → 설치 → 첫 실행 → AppsFlyer가 OnConversionDataReceived 콜백 호출
앱이 없어서 스토어를 거친다. AppsFlyer가 “이 사람이 원래 어떤 링크를 눌렀었는지” 기억해뒀다가, 설치 후 첫 실행 때 전달해준다. 꽤 마법 같은 기능이다.
중요한 차이: Direct는 즉시 실행하고, Deferred는 저장만 해뒀다가 로그인 후에 실행한다. 처음 설치한 사용자는 로그인부터 해야 하니까.
3단계: AppsFlyer SDK 초기화
앱이 시작될 때 AppsFlyer SDK를 초기화하고 콜백을 바인딩한다.
void UDeepLinkManager::Initialize(UGameInstance* InGameInstance)
{
// AppsFlyer 설정
UAppsFlyerSDKBlueprint::configure();
// 콜백 바인딩 후 시작 (약간의 딜레이를 줘서 안정적으로)
FTimerHandle Timer;
World->GetTimerManager().SetTimer(Timer, [this]()
{
BindAppsFlyerCallbacks();
UAppsFlyerSDKBlueprint::start();
}, 0.1f, false);
}
콜백 바인딩에서는 AppsFlyer가 제공하는 4가지 이벤트를 연결한다:
void UDeepLinkManager::BindAppsFlyerCallbacks()
{
// AppsFlyerSDKCallbacks 오브젝트를 찾아서
for (TObjectIterator<UAppsFlyerSDKCallbacks> Itr; Itr; ++Itr)
{
UAppsFlyerSDKCallbacks* Callbacks = *Itr;
// 기존 바인딩 정리 후 C++로 재바인딩
Callbacks->OnAppOpenAttribution.Clear();
Callbacks->OnConversionDataReceived.Clear();
Callbacks->OnAppOpenAttribution.AddDynamic(this, &UDeepLinkManager::OnDirectDeepLinkReceived);
Callbacks->OnConversionDataReceived.AddDynamic(this, &UDeepLinkManager::OnDeferredDeepLinkReceived);
}
}
여기서 .Clear()를 호출하는 이유가 있다. Blueprint 쪽에서도 같은 콜백을 바인딩할 수 있는데, C++에서 일괄 관리하기 위해 기존 바인딩을 날리고 C++로 통일하는 것이다.
4단계: 플랫폼별 네이티브 핸들러
AppsFlyer SDK 콜백만으로는 모든 케이스를 커버하지 못한다. Custom URL Scheme이나 Universal Link / App Link로 직접 들어오는 경우도 처리해야 한다.
iOS — Method Swizzling
iOS에서는 Objective-C의 Method Swizzling으로 openURL과 continueUserActivity를 후킹한다.
Custom Scheme: myapp://episode/abc-123 → openURL 후킹
Universal Link: https://myapp.world/episode/... → continueUserActivity 후킹
Cold Start 대응이 핵심이다. 앱이 완전히 종료된 상태에서 딥링크로 실행되면, 언리얼 엔진 초기화가 끝나기 전에 URL이 도착할 수 있다. 그래서 URL을 NSString으로 임시 저장해뒀다가, C++ 콜백이 설정되면 그때 전달한다.
Android — JNI
Android에서는 Java의 Intent에서 딥링크 URL을 추출하고, JNI를 통해 C++로 전달한다.
Java: onNewIntent() → nativeOnDeepLinkReceived(url)
→ JNI → C++ GameThread에서 콜백 실행
iOS와 마찬가지로 Cold Start 시 URL을 임시 저장하는 로직이 필요하다.
5단계: URL 파싱 — 의미 있는 데이터로 변환
여러 경로로 들어온 URL이 결국 하나의 함수에 모인다:
void UDeepLinkManager::OnDeepLinkReceived(const FString Deeplink)
{
// "myapp://episode/abc-123" → "episode/abc-123"
FString Path = ExtractDeepLinkPath(Deeplink);
// "episode/abc-123" → Type: Episode, ID: "abc-123"
ParseDeepLinkPath(Path, DeepLinkData.Type, DeepLinkData.ID);
if (bLoggedIn)
ExecuteDeepLink(); // 즉시 실행
else
Delegate.Broadcast(); // 로그인 후 실행되도록 저장
}
파싱 함수는 단순하다. URL 경로의 첫 세그먼트로 타입을 구분하고, 나머지를 ID로 쓴다:
bool ParseDeepLinkPath(const FString& Path, EDeepLinkType& OutType, FString& OutID)
{
if (Path.StartsWith(TEXT("character/")))
{
OutType = EDeepLinkType::Character;
OutID = Path.Replace(TEXT("character/"), TEXT(""));
return true;
}
else if (Path.StartsWith(TEXT("episode/")))
{
OutType = EDeepLinkType::Episode;
OutID = Path.Replace(TEXT("episode/"), TEXT(""));
return true;
}
return false;
}
새로운 딥링크 타입을 추가할 때는 여기에 else if 분기 하나만 추가하면 된다.
6단계: 실행 — Blueprint로 위임
ExecuteDeepLink()에서 타입별로 분기한다. 여기서 중요한 설계 판단이 있다.
Character나 Group처럼 C++에서 직접 위젯을 생성하고 API를 호출하는 방식도 가능하지만, 이미 Blueprint에 화면 로직이 구현되어 있다면 delegate로 넘기는 게 훨씬 깔끔하다:
void UDeepLinkManager::ExecuteDeepLink()
{
if (DeepLinkData.Type == EDeepLinkType::Episode)
{
// BP로 위임 — delegate 브로드캐스트만 하고 끝
OnDeepLinkReceivedDelegate.Broadcast(DeepLinkData);
ResetDeepLink();
bDeepLinkExecuting = false;
}
}
Blueprint 쪽에서는 GameInstance의 delegate를 바인딩해서 처리한다:
GameInstance.OnDeepLinkReceivedDelegate
→ DeepLinkData.Type == Episode?
→ Yes → RequestGet_EpisodeDetail(DeepLinkData.ID)
→ 에피소드 상세 화면 열기
GameInstance에서 delegate를 중계하는 구조 덕분에, 레벨이 바뀌어도 딥링크 이벤트를 놓치지 않는다.
7단계: Deferred의 마지막 퍼즐 — 로그인 대기
Deferred 딥링크는 앱 첫 설치 후 들어오기 때문에, 사용자가 아직 로그인하지 않은 상태다. 이때는 데이터만 저장해두고, 로그인 완료 이벤트를 기다린다:
void UDeepLinkManager::OnLoginComplete(...)
{
if (DeepLinkData.Type == EDeepLinkType::None) return;
// 레벨 전환이 완료될 때까지 잠시 대기
FTimerHandle Timer;
World->GetTimerManager().SetTimer(Timer, [this]()
{
ExecuteDeepLink();
}, 1.5f, false);
}
1.5초 딜레이를 주는 이유는, 로그인 직후 메인 레벨로 전환되는 시간이 필요하기 때문이다. 전환이 끝나기 전에 delegate를 쏘면 받을 BP가 아직 로드되지 않았을 수 있다.
전체 흐름 한눈에 보기
[OneLink URL 클릭]
│
┌───────────┴───────────┐
│ │
[앱 설치됨] [앱 미설치]
│ │
앱 바로 실행 스토어 → 설치 → 실행
│ │
OnAppOpenAttribution OnConversionDataReceived
(Direct) (Deferred)
│ │
│ 데이터 저장만
│ │
└───────┬───────────────┘
│
OnDeepLinkReceived()
│
URL 파싱 (Type + ID)
│
┌───────┴───────┐
│ │
[로그인 O] [로그인 X]
│ │
ExecuteDeepLink() 로그인 대기
│ │
Delegate 발행 로그인 완료 후
│ ExecuteDeepLink()
│ │
└───────┬───────┘
│
Blueprint에서 처리
(API 호출 → 화면 이동)
새 딥링크 타입을 추가할 때 체크리스트
나중에 character/, group/, episode/ 외에 새 타입을 추가하고 싶다면:
- Enum 추가:
EDeepLinkType에 새 타입 추가 - 파싱 추가:
ParseDeepLinkPath()에else if분기 추가 - 실행 분기:
ExecuteDeepLink()에서 해당 타입 처리 - BP 처리: delegate에서 새 타입 분기 → API 호출 → 화면 이동
C++ 쪽은 1~3번까지 각 5줄 내외의 수정이고, 실제 화면 로직은 Blueprint에서 처리하면 된다.
마무리
딥링크 파이프라인은 겉보기엔 복잡해 보이지만, 결국 URL을 만들고 → 받고 → 파싱하고 → 실행하는 네 단계다. AppsFlyer OneLink가 “만들고 받는” 부분을 처리해주고, 우리는 “파싱하고 실행하는” 부분에 집중하면 된다.
핵심 포인트를 정리하면:
- OneLink URL은
deep_link_value파라미터에 실제 딥링크를 담는 포장지다 - Direct vs Deferred의 차이는 “즉시 실행” vs “저장 후 로그인 대기”
- 플랫폼 핸들러(iOS Swizzling, Android JNI)는 Cold Start를 대비해 URL을 임시 저장하는 로직이 필수
- C++에서 파싱, Blueprint에서 실행하는 구조가 유지보수하기 편하다
- 새 타입 추가는 enum + 파싱 + 실행 분기 3곳만 건드리면 된다