Flutter 앱에서 딥 링크(Deep Linking)를 어떻게 구현하나요?

질문

Flutter 앱에서 딥 링크(Deep Linking)를 구현하는 방법과 실제 사례에 대해 설명해주세요.

답변

Flutter 앱에서 딥 링크를 구현하는 것은 사용자가 URL을 통해 앱의 특정 화면이나 콘텐츠로 직접 이동할 수 있게 하는 중요한 기능입니다. 이는 마케팅 캠페인, 알림, 이메일 등에서 유용하게 활용됩니다. 딥 링크 구현 방법과 그 응용에 대해 자세히 알아보겠습니다.

1. 딥 링크의 종류

Flutter에서는 두 가지 유형의 딥 링크를 구현할 수 있습니다:

  1. URI 스킴 링크(URL Scheme Links): myapp://details/1과 같은 커스텀 스킴을 사용하는 전통적인 딥 링크
  2. 유니버설 링크(iOS)/앱 링크(Android): https://myapp.com/details/1과 같이 일반 웹 URL과 동일한 형식의 딥 링크로, 앱이 설치되어 있으면 앱에서 열리고, 그렇지 않으면 웹 브라우저에서 열립니다.

딥 링크를 구현하는 가장 간단한 방법은 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>

유니버설 링크 설정:

  1. apple-app-site-association 파일을 웹 서버에 업로드
  2. 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를 사용하면 앱 설치 여부에 관계없이 효과적으로 사용자를 올바른 장소로 유도할 수 있습니다:

  1. Firebase Dynamic Links 설정:
dependencies:
  firebase_core: ^2.15.0
  firebase_dynamic_links: ^5.3.4
  1. 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. 딥 링크 모범 사례 및 주의사항

  1. URL 구조 표준화: 딥 링크의 경로와 쿼리 파라미터를 일관성 있게 설계하고 문서화하세요.

  2. 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));
}
  1. 대체 경로 제공: 딥 링크의 목적지가 앱에서 사용할 수 없는 경우(예: 콘텐츠 삭제) 사용자를 적절한 페이지로 리디렉션하세요.

  2. 딥 링크 테스트 자동화: 주요 딥 링크 시나리오에 대한 자동화된 테스트를 구현하여 회귀 문제를 방지하세요.

  3. 앱 미설치 시나리오 처리: 유니버설 링크나 Firebase Dynamic Links를 사용하여 앱이 설치되지 않은 경우 앱 스토어나 적절한 웹 페이지로 리디렉션하세요.

결론

Flutter에서 딥 링크를 구현하는 것은 사용자 경험을 크게 향상시킬 수 있는 강력한 기능입니다. 앱 콘텐츠로의 직접 접근을 제공함으로써 사용자 참여도를 높이고 마케팅 및 공유 기능을 강화할 수 있습니다. 위의 단계와 예제를 따라 구현하면 효과적인 딥 링크 시스템을 구축할 수 있습니다.

특히 Firebase Dynamic Links와 같은 도구를 활용하면 앱 설치 여부에 관계없이 일관된 사용자 경험을 제공할 수 있으므로, 사용자 획득 및 유지에 큰 도움이 됩니다.

results matching ""

    No results matching ""