Flutter 앱에서 딥 링크(Deep Linking)를 어떻게 구현하나요?
질문
Flutter 앱에서 딥 링크(Deep Linking)를 구현하는 방법과 실제 사례에 대해 설명해주세요.
답변
Flutter 앱에서 딥 링크를 구현하는 것은 사용자가 URL을 통해 앱의 특정 화면이나 콘텐츠로 직접 이동할 수 있게 하는 중요한 기능입니다. 이는 마케팅 캠페인, 알림, 이메일 등에서 유용하게 활용됩니다. 딥 링크 구현 방법과 그 응용에 대해 자세히 알아보겠습니다.
1. 딥 링크의 종류
Flutter에서는 두 가지 유형의 딥 링크를 구현할 수 있습니다:
- URI 스킴 링크(URL Scheme Links):
myapp://details/1
과 같은 커스텀 스킴을 사용하는 전통적인 딥 링크 - 유니버설 링크(iOS)/앱 링크(Android):
https://myapp.com/details/1
과 같이 일반 웹 URL과 동일한 형식의 딥 링크로, 앱이 설치되어 있으면 앱에서 열리고, 그렇지 않으면 웹 브라우저에서 열립니다.
2. 기본 설정: uni_links 패키지 사용하기
딥 링크를 구현하는 가장 간단한 방법은 uni_links
패키지를 사용하는 것입니다:
# pubspec.yaml
dependencies:
uni_links: ^0.5.1
3. 플랫폼별 설정
3.1 Android 설정 (AndroidManifest.xml)
<manifest ...>
<application ...>
<activity
android:name=".MainActivity"
android:launchMode="singleTask">
<!-- URL 스킴 딥 링크 -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" />
</intent-filter>
<!-- 앱 링크 (Android) -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="myapp.com"
android:pathPrefix="/details" />
</intent-filter>
</activity>
</application>
</manifest>
3.2 iOS 설정 (Info.plist)
URL 스킴 설정:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.myapp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
유니버설 링크 설정:
apple-app-site-association
파일을 웹 서버에 업로드- Info.plist에 Associated Domains 추가:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:myapp.com</string>
</array>
4. 딥 링크 처리 구현하기
4.1 초기 딥 링크 처리
앱이 시작될 때 딥 링크를 처리하는 코드:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:uni_links/uni_links.dart';
import 'package:flutter/services.dart' show PlatformException;
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
StreamSubscription? _sub;
Uri? _initialUri;
Uri? _latestUri;
Object? _err;
@override
void initState() {
super.initState();
_initUniLinks();
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
Future<void> _initUniLinks() async {
// 앱이 이미 실행 중일 때 들어오는 링크 처리
_sub = linkStream.listen((String? link) {
if (link == null) return;
setState(() {
_latestUri = Uri.parse(link);
_err = null;
});
_handleDeepLink(_latestUri!);
}, onError: (err) {
setState(() {
_latestUri = null;
_err = err;
});
});
// 앱이 종료된 상태에서 딥 링크로 실행된 경우 처리
try {
final initialLink = await getInitialLink();
if (initialLink != null) {
setState(() {
_initialUri = Uri.parse(initialLink);
_err = null;
});
_handleDeepLink(_initialUri!);
}
} on PlatformException catch (err) {
setState(() {
_initialUri = null;
_err = err;
});
}
}
void _handleDeepLink(Uri uri) {
// 여기서 딥 링크 URI에 따라 적절한 화면으로 라우팅
print('딥 링크 처리: $uri');
if (uri.pathSegments.isNotEmpty && uri.pathSegments.first == 'details') {
// 예: myapp://details/123 또는 https://myapp.com/details/123
if (uri.pathSegments.length > 1) {
final id = uri.pathSegments[1];
Navigator.of(context).pushNamed('/details', arguments: id);
}
} else if (uri.path == '/settings') {
// 예: myapp://settings 또는 https://myapp.com/settings
Navigator.of(context).pushNamed('/settings');
}
// 필요에 따라 다른 경로 추가
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '딥 링크 데모',
theme: ThemeData(primarySwatch: Colors.blue),
initialRoute: '/',
routes: {
'/': (context) => HomeScreen(),
'/details': (context) => DetailsScreen(),
'/settings': (context) => SettingsScreen(),
},
);
}
}
4.2 go_router를 사용한 딥 링크 처리
더 복잡한 라우팅 처리를 위해 go_router
패키지를 사용할 수 있습니다:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:uni_links/uni_links.dart';
void main() {
runApp(MyApp());
}
final _router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => HomeScreen(),
),
GoRoute(
path: '/details/:id',
builder: (context, state) {
final id = state.params['id']!;
return DetailsScreen(id: id);
},
),
GoRoute(
path: '/settings',
builder: (context, state) => SettingsScreen(),
),
],
);
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
StreamSubscription? _sub;
@override
void initState() {
super.initState();
_initDeepLinking();
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
Future<void> _initDeepLinking() async {
// 1. 앱이 완전히 종료된 상태에서 링크로 열린 경우 처리
try {
final initialLink = await getInitialLink();
if (initialLink != null) {
_handleDeepLink(initialLink);
}
} catch (e) {
print('초기 딥 링크 처리 오류: $e');
}
// 2. 앱이 백그라운드 또는 실행 중일 때 링크로 열린 경우 처리
_sub = linkStream.listen((String? link) {
if (link != null) {
_handleDeepLink(link);
}
}, onError: (err) {
print('딥 링크 스트림 오류: $err');
});
}
void _handleDeepLink(String link) {
final uri = Uri.parse(link);
// URI를 적절한 앱 경로로 변환
String path;
if (uri.scheme == 'myapp') {
// URI 스킴 처리
path = uri.path;
} else {
// 웹 URL 처리
path = uri.path;
}
// 특정 쿼리 파라미터가 있는 경우 처리
if (uri.queryParameters.containsKey('ref')) {
// 예: 리퍼러 추적
final ref = uri.queryParameters['ref'];
print('리퍼러: $ref');
}
// go_router를 사용하여 해당 경로로 이동
if (path.isNotEmpty) {
_router.go(path);
}
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: '딥 링크 데모',
theme: ThemeData(primarySwatch: Colors.blue),
routerConfig: _router,
);
}
}
5. 동적 링크와 파이어베이스 딥 링크 통합하기
Firebase Dynamic Links를 사용하면 앱 설치 여부에 관계없이 효과적으로 사용자를 올바른 장소로 유도할 수 있습니다:
- Firebase Dynamic Links 설정:
dependencies:
firebase_core: ^2.15.0
firebase_dynamic_links: ^5.3.4
- Dynamic Links 구현:
import 'package:firebase_dynamic_links/firebase_dynamic_links.dart';
class DynamicLinkService {
Future<Uri> createDynamicLink({
required String path,
required String title,
String? description,
String? imageUrl,
}) async {
final dynamicLinkParams = DynamicLinkParameters(
link: Uri.parse('https://myapp.com$path'),
uriPrefix: 'https://myapp.page.link',
androidParameters: const AndroidParameters(
packageName: 'com.myapp.android',
minimumVersion: 1,
),
iosParameters: const IOSParameters(
bundleId: 'com.myapp.ios',
minimumVersion: '1.0.0',
appStoreId: '123456789',
),
socialMetaTagParameters: SocialMetaTagParameters(
title: title,
description: description,
imageUrl: imageUrl != null ? Uri.parse(imageUrl) : null,
),
);
final shortLink = await FirebaseDynamicLinks.instance.buildShortLink(dynamicLinkParams);
return shortLink.shortUrl;
}
Future<void> initDynamicLinks(BuildContext context) async {
// 앱이 종료된 상태에서 딥 링크로 열린 경우
final initialLink = await FirebaseDynamicLinks.instance.getInitialLink();
if (initialLink != null) {
_handleDynamicLink(initialLink.link, context);
}
// 앱이 백그라운드 또는 실행 중일 때 링크로 열린 경우
FirebaseDynamicLinks.instance.onLink.listen(
(pendingDynamicLinkData) {
_handleDynamicLink(pendingDynamicLinkData.link, context);
},
onError: (e) {
print('동적 링크 오류: $e');
},
);
}
void _handleDynamicLink(Uri deepLink, BuildContext context) {
// URI 경로를 앱 내 경로로 변환하여 처리
final path = deepLink.path;
if (path.startsWith('/details/')) {
final id = path.substring('/details/'.length);
Navigator.of(context).pushNamed('/details', arguments: id);
} else if (path == '/settings') {
Navigator.of(context).pushNamed('/settings');
}
// 기타 경로 처리
}
}
6. 딥 링크 테스트하기
6.1 명령줄에서 테스트
Android에서 adb를 사용한 테스트:
// URI 스킴 링크 테스트
adb shell am start -a android.intent.action.VIEW -d "myapp://details/123" com.myapp.android
// 앱 링크 테스트
adb shell am start -a android.intent.action.VIEW -d "https://myapp.com/details/123" com.myapp.android
iOS에서는 시뮬레이터의 Safari에서 링크를 열거나, 다음과 같은 스크립트를 사용할 수 있습니다:
xcrun simctl openurl booted "myapp://details/123"
6.2 QR 코드 생성하여 실제 기기에서 테스트
딥 링크 URL을 QR 코드로 변환하고 실제 기기에서 스캔하여 테스트할 수 있습니다.
7. 실제 응용 사례
7.1 사용자 초대 및 리퍼럴 시스템
Future<void> inviteUser(String referralCode) async {
final dynamicLinkService = DynamicLinkService();
final dynamicLink = await dynamicLinkService.createDynamicLink(
path: '/referral/$referralCode',
title: '친구 초대 - 회원가입 시 5000원 쿠폰 지급!',
description: '지금 가입하고 특별 할인 혜택을 받아보세요.',
imageUrl: 'https://myapp.com/images/referral_banner.jpg',
);
Share.share('친구 초대 링크: $dynamicLink');
}
7.2 푸시 알림에서 딥 링크 사용
Future<void> sendNotificationWithDeepLink(String userId, String orderId) async {
final deepLink = 'https://myapp.com/orders/$orderId';
final message = {
'notification': {
'title': '주문 상태 업데이트',
'body': '주문이 배송 중입니다. 자세히 보려면 클릭하세요.',
},
'data': {
'deep_link': deepLink,
},
'token': userId,
};
// FCM을 통해 메시지 전송
await FirebaseMessaging.instance.send(message);
}
7.3 소셜 미디어 공유 콘텐츠
Future<void> shareProductToSocial(Product product) async {
final dynamicLinkService = DynamicLinkService();
final dynamicLink = await dynamicLinkService.createDynamicLink(
path: '/products/${product.id}',
title: product.name,
description: product.description,
imageUrl: product.imageUrl,
);
// 소셜 미디어 공유 옵션
final shareOptions = [
Share.share('${product.name} 확인해보세요! $dynamicLink'),
// 또는 특정 플랫폼에 맞는 공유 방법 구현
];
// 공유 방법 선택 다이얼로그 표시
showDialog(
context: context,
builder: (context) => ShareDialog(shareOptions: shareOptions),
);
}
8. 딥 링크 고급 기능 구현
8.1 딥 링크 분석 추적
딥 링크를 통한 유입을 분석하려면 Firebase Analytics와 같은 도구를 사용할 수 있습니다:
void trackDeepLinkOpening(Uri deepLink) {
final analytics = FirebaseAnalytics.instance;
analytics.logEvent(
name: 'deep_link_open',
parameters: {
'full_url': deepLink.toString(),
'path': deepLink.path,
'query_params': deepLink.queryParameters.toString(),
'source': deepLink.queryParameters['utm_source'] ?? 'direct',
'medium': deepLink.queryParameters['utm_medium'] ?? 'none',
'campaign': deepLink.queryParameters['utm_campaign'] ?? 'none',
},
);
}
8.2 딥 링크 인증 관리
보안이 필요한 딥 링크(예: 비밀번호 재설정)의 경우:
Future<bool> validateSecureDeepLink(Uri deepLink) async {
if (deepLink.path.startsWith('/reset-password/')) {
final token = deepLink.pathSegments.last;
try {
// 서버에 토큰 유효성 확인
final response = await http.get(
Uri.parse('https://api.myapp.com/validate-reset-token?token=$token'),
);
final data = jsonDecode(response.body);
return data['valid'] == true;
} catch (e) {
print('토큰 검증 오류: $e');
return false;
}
}
return true; // 비보안 링크는 항상 유효
}
8.3 앱 상태 보존 및 복원
딥 링크로 특정 화면으로 이동할 때 이전 화면 스택이 유지되도록 처리:
void navigateFromDeepLink(Uri deepLink, BuildContext context) {
if (deepLink.path.startsWith('/details/')) {
final id = deepLink.pathSegments.last;
// 홈 화면을 기본으로 유지하고 그 위에 상세 화면 추가
Navigator.of(context).pushNamedAndRemoveUntil(
'/',
(route) => false, // 모든 화면 제거
);
// 딜레이를 주고 새 화면으로 이동 (화면 전환 애니메이션을 위함)
Future.delayed(Duration(milliseconds: 100), () {
Navigator.of(context).pushNamed('/details', arguments: id);
});
}
}
9. 딥 링크 모범 사례 및 주의사항
URL 구조 표준화: 딥 링크의 경로와 쿼리 파라미터를 일관성 있게 설계하고 문서화하세요.
URL 검증: 딥 링크의 경로와 파라미터를 항상 검증하여 오류를 방지하세요.
bool isValidPath(String path) {
// 허용된 경로 패턴 목록
final validPaths = [
RegExp(r'^/details/\d+$'),
RegExp(r'^/settings$'),
RegExp(r'^/profile/[a-zA-Z0-9]+$'),
];
return validPaths.any((pattern) => pattern.hasMatch(path));
}
대체 경로 제공: 딥 링크의 목적지가 앱에서 사용할 수 없는 경우(예: 콘텐츠 삭제) 사용자를 적절한 페이지로 리디렉션하세요.
딥 링크 테스트 자동화: 주요 딥 링크 시나리오에 대한 자동화된 테스트를 구현하여 회귀 문제를 방지하세요.
앱 미설치 시나리오 처리: 유니버설 링크나 Firebase Dynamic Links를 사용하여 앱이 설치되지 않은 경우 앱 스토어나 적절한 웹 페이지로 리디렉션하세요.
결론
Flutter에서 딥 링크를 구현하는 것은 사용자 경험을 크게 향상시킬 수 있는 강력한 기능입니다. 앱 콘텐츠로의 직접 접근을 제공함으로써 사용자 참여도를 높이고 마케팅 및 공유 기능을 강화할 수 있습니다. 위의 단계와 예제를 따라 구현하면 효과적인 딥 링크 시스템을 구축할 수 있습니다.
특히 Firebase Dynamic Links와 같은 도구를 활용하면 앱 설치 여부에 관계없이 일관된 사용자 경험을 제공할 수 있으므로, 사용자 획득 및 유지에 큰 도움이 됩니다.