Ssul's Blog

ios(swift)개발, 다른 앱에서 내앱 호출하기(딥링크) 본문

dev/기능구현

ios(swift)개발, 다른 앱에서 내앱 호출하기(딥링크)

Ssul 2025. 5. 2. 13:39

ios앱을 개발중이다. 이번 목표는 아래와 같다.

 

유튜브 앱에서 공유하기 버튼을 누른다. 공유 가능한 앱에 내 앱아이콘이 등장한다.
해당 앱 아이콘을 클릭하면, 내 앱으로 이동한다.
내앱에 들어가서, 내가 원하는 메뉴를 띄우고, 공유하는 유튜브 링크를 붙여넣는다.

 

위 3가지 포인트가 완성되어야 한다. 어떻게 해야 할까?

 

#1. ShareExtension(내 앱 아이콘이 등장 > 내 앱으로 이동 전까지)

ShareExtension은 다른 앱에서 공유하기 눌렀을때, 내 앱아이콘이 뜨고, 그걸 클릭했을때 연결해주는 역할을 하는 녀석임.

 

1-1. Xcode → File ▸ New ▸ Target ▸ iOS ▸ Share Extension클릭

- 적절한 이름을 설정하고 추가

이렇게 폴더가 하나 추가된다

1-2. bundle Identifier설정

새로 ShareExtension생성

- 새로생긴 ShareExtension클릭 > Singing & Capabilities > bundle identifier설정

- com.mycompanay.myapp.share

 

1-3. Targets > myappShareExtension > Signing & Capabilities ▸ + Capability ▸ App Groups

- 호스트-앱과 같은 ID (group.com.mycompany.myapp) 추가

 

1-4. Targets > myapp > Signing & Capabilities ▸ + Capability ▸ App Groups

- ShareExtension과 같은 ID (group.com.mycompany.myapp) 추가

 

1-5. ShareExtension 코드구성(myappShareExtension/info.plist)

- 다른앱에서 나의 앱으로 진입하는 지점이다

- 해당 진입지적을 $(PRODUCT_MODULE_NAME).ShareExtensionHandler로 설정

(*storyboard와 ShareViewController를 사용한다면 $(PRODUCT_MODULE_NAME).ShareViewController로 설정)

- myappShareExtension/info.plist를 설정. app groups와 principalClass잘 설정해주기

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.application-groups</key>
	<array>
    	<!-- 여기 여러분의 그룹으로 -->
		<string>group.com.mycompany.myapp</string>
	</array>
	<key>NSExtension</key>
	<dict>
		<key>NSExtensionAttributes</key>
		<dict>
			<!-- "URL 1개" 받을 때만 Share Sheet에 노출 -->
			<key>NSExtensionActivationRule</key>
			<dict>
				<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
				<integer>1</integer>
				<!-- 유튜브 URL 필터 추가 -->
				<key>NSExtensionActivationWebURLPredicate</key>
				<string>SUBQUERY(absoluteURL, $url, $url CONTAINS 'youtube.com' OR $url CONTAINS 'youtu.be').@count > 0</string>
			</dict>
		</dict>
		<key>NSExtensionPointIdentifier</key>
		<string>com.apple.share-services</string>
		<key>NSExtensionPrincipalClass</key>
		<string>$(PRODUCT_MODULE_NAME).ShareExtensionHandler</string>
	</dict>
</dict>
</plist>

[다른앱에서 공유하기를 누르면 나의 앱이 보인다 + 해당 앱 아이콘을 클릭하면 ShareExtensionHandler를 실행한다

 

1-6. ShareExtensionHandler.swift구성(앱 아이콘 클릭시 실행되어야 할 코드)

- 추가된 폴더 안에 ShareExtensionHandler.swift를 생성합니다

(*이 파일은 유튜브앱에서 내 앱 아이콘을 클릭시 실행되는 코드가 들어갑니다.)

(**storyboard+ShareViewController로 구현이 가능하지만, 저는 특별히 화면이 필요없고, 클릭시 바로 내 앱으로 넘어가면 되기 때문에 ShareExtensionHandler사용)

//
//  ShareExtensionHandler.swift
//  InboxShareExtension
//
//

// InboxShareExtension/ShareExtensionHandler.swift
import UIKit
import MobileCoreServices
import UniformTypeIdentifiers

final class ShareExtensionHandler: UIViewController {

    private let appGroupID = "group.com.mycompany.myapp"
    private let mainAppURLScheme = "deeplinkmyapp://"

    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 번들 ID 확인
        let bundleID = Bundle.main.bundleIdentifier ?? "없음"
        print("🔍 ShareExtension 번들 ID: \(bundleID)")
        
        // App Group ID 확인
        let defaults = UserDefaults(suiteName: appGroupID)
        let canAccessAppGroup = defaults != nil
        print("🔍 App Group 접근 가능: \(canAccessAppGroup)")
        
        extractURLAndShareToMainApp()
    }
    
    private func extractURLAndShareToMainApp() {
        print("🔄 ShareExtension: extractURLAndShareToMainApp 시작")
        
        // 첫 번째 extensionItem 가져오기
        guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem else {
            print("❌ ShareExtension: extensionItem 없음")
            complete(shouldOpenMainApp: false)
            return
        }
        
        // 디버깅 정보
        print("🔍 ShareExtension: extensionItem = \(extensionItem)")
        if let attachments = extensionItem.attachments {
            print("🔍 ShareExtension: attachments = \(attachments.count)개")
            
            // 각 첨부파일 타입 상세 출력
            for (i, attachment) in attachments.enumerated() {
                print("🔍 첨부파일 #\(i+1) 지원 타입:")
                attachment.registeredTypeIdentifiers.forEach { type in
                    print("   - \(type)")
                }
            }
        }
        
        // 지원하는 URL 타입 목록
        let urlTypeIdentifiers = [
            UTType.url.identifier,           // "public.url"
            kUTTypeURL as String,            // "public.url"
            "public.url",
            UTType.plainText.identifier,     // "public.plain-text" (텍스트로 URL이 공유될 수 있음)
            "public.plain-text",
            "public.text"
        ]
        
        // 사용할 첨부파일과 타입 식별자 찾기
        var selectedProvider: NSItemProvider? = nil
        var selectedTypeIdentifier: String? = nil
        
        // 모든 첨부파일 확인
        if let attachments = extensionItem.attachments {
            for provider in attachments {
                for typeID in urlTypeIdentifiers {
                    if provider.hasItemConformingToTypeIdentifier(typeID) {
                        selectedProvider = provider
                        selectedTypeIdentifier = typeID
                        print("✅ ShareExtension: 타입 \(typeID)를 지원하는 첨부파일 발견")
                        break
                    }
                }
                if selectedProvider != nil { break }
            }
        }
        
        guard let provider = selectedProvider, let typeID = selectedTypeIdentifier else {
            print("❌ ShareExtension: URL 또는 텍스트 형식의 첨부파일 없음")
            complete(shouldOpenMainApp: false)
            return
        }
        
        // 첨부파일에서 데이터 로드
        provider.loadItem(forTypeIdentifier: typeID, options: nil) { [weak self] data, error in
            if let error = error {
                print("❌ ShareExtension: URL 로드 에러: \(error.localizedDescription)")
                self?.complete(shouldOpenMainApp: false)
                return
            }
            
            print("🔍 ShareExtension: 로드된 데이터 타입: \(type(of: data))")
            
            // 다양한 형식의 데이터 처리
            if let url = data as? URL {
                print("✅ ShareExtension: URL 직접 추출 성공: \(url.absoluteString)")
                self?.handleURL(url)
            } else if let text = data as? String {
                print("🔍 ShareExtension: 텍스트로 받음: \(text)")
                
                // 텍스트에서 URL 추출 시도
                if let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) {
                    print("✅ ShareExtension: 텍스트에서 URL 추출 성공: \(url.absoluteString)")
                    self?.handleURL(url)
                } else {
                    print("❌ ShareExtension: 텍스트에서 URL 추출 실패")
                    self?.complete(shouldOpenMainApp: false)
                }
            } else {
                print("❌ ShareExtension: 지원하지 않는 데이터 형식: \(String(describing: data))")
                self?.complete(shouldOpenMainApp: false)
            }
        }
    }
    
    private func handleURL(_ url: URL) {
        print("✅ ShareExtension: URL 추출 성공: \(url.absoluteString)")
        
        // 유튜브 URL인지 확인 - 다양한 패턴 지원
        let urlString = url.absoluteString.lowercased()
        let isYoutubeURL = 
            url.host?.contains("youtube.com") == true || 
            url.host?.contains("youtu.be") == true ||
            urlString.contains("youtube.com") || 
            urlString.contains("youtu.be")
        
        guard isYoutubeURL else {
            print("ℹ️ ShareExtension: 유튜브 URL이 아님: \(url.absoluteString)")
            complete(shouldOpenMainApp: false)
            return
        }
        
        print("✅ ShareExtension: 유튜브 URL 확인됨")
        
        // App-Group에 URL 저장
        let defaults = UserDefaults(suiteName: self.appGroupID)
        defaults?.set(url.absoluteString, forKey: "sharedURL")
        defaults?.synchronize()
        print("✅ ShareExtension: UserDefaults에 URL 저장 완료")
        
        // 메인 앱으로 돌아가기 위한 플래그 설정
        defaults?.set(true, forKey: "pendingYoutubeShare")
        defaults?.synchronize()
        
        // 메인 앱으로 전환 (0.5초 지연으로 저장 완료 보장)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
            self?.complete(shouldOpenMainApp: true)
        }
    }

    private func complete(shouldOpenMainApp: Bool) {
        print("🔄 ShareExtension: complete(shouldOpenMainApp: \(shouldOpenMainApp))")
        
        if shouldOpenMainApp {
            // 메인 앱으로 돌아가기 위한 URL
            if let url = URL(string: "\(mainAppURLScheme)open-from-share?t=\(Date().timeIntervalSince1970)") {
                // 캐시 방지를 위해 타임스탬프 추가
                var responder: UIResponder? = self
                var targetApplication: UIApplication?
                
                while responder != nil && targetApplication == nil {
                    if let app = responder as? UIApplication {
                        targetApplication = app
                    }
                    responder = responder?.next
                }
                
                // URL 열기 시도
                self.extensionContext?.completeRequest(returningItems: [], completionHandler: { _ in
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                        _ = targetApplication?.open(url)
                        print("✅ ShareExtension: 메인 앱 열기 시도 완료")
                    }
                })
            } else {
                print("❌ ShareExtension: URL 생성 실패")
                self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
            }
        } else {
            // 그냥 Extension 종료
            self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
            print("ℹ️ ShareExtension: 메인 앱으로 전환하지 않고 종료")
        }
    }
}

- Info.plist NSExtensionPrincipalClass ShareExtensionHandler 설정되어 있으므로 iOS 클래스의 인스턴스를 생성

- viewDidLoad()가 호출되고,

- 유튜브 URL을 추출합니다.

- 추출한 URL을 App Group의 UserDefaults에 저장합니다(*이 Group UserDefault가 내 앱과 정보를 공유하는 개념)

- 나의 앱과 ShareExtension은 따로 있다고 생각하는게 편함. ShareExtension은 다른 앱에서 노출되는 나의 앱 아이콘, App Group으로 데이터 공유.. 이정도의 개념으로

- shouldOpenMainApp이 호출되면, 설정한 메인앱 딥링크로 호스트(나의 앱)앱 호출

(*해당 코드에서는 deeplinkmyapp://)

 

#2. 메인앱이 응답하게 하기(내 앱으로 이동)

2-1. AppDelegate 설정

- 동일한 딥링크를 사용하고, app Group사용

//
//  AppDelegate.swift
//

import SwiftUI
import GoogleSignIn

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
        // 번들 ID 확인
        let bundleID = Bundle.main.bundleIdentifier ?? "없음"
        print("🔍 메인 앱 번들 ID: \(bundleID)")
        
        // App Group ID 확인
        let defaults = UserDefaults(suiteName: SharedURLRouter.appGroupID)
        let canAccessAppGroup = defaults != nil
        print("🔍 메인 앱 App Group 접근 가능: \(canAccessAppGroup)")
        
        // 2️⃣ Share Extension 으로부터 전달된 URL 소비
        consumeSharedURLIfNeeded()

        return true
    }
    
    func application(
        _ app: UIApplication,
        open url: URL,
        options: [UIApplication.OpenURLOptionsKey : Any] = [:]
    ) -> Bool {
        print("🔄 AppDelegate: URL 수신됨: \(url)")

        // 2️⃣ 아니면 커스텀 스킴 -> 라우터
        if url.scheme == "deeplinkmyapp" {
            print("✅ AppDelegate: 커스텀 스킴 URL 감지됨: \(url)")
            
            // open-from-share 패스인 경우 SharedURLRouter를 통해 공유된 URL을 가져옴
            if url.host == "open-from-share" {
                print("✅ AppDelegate: open-from-share 경로 감지됨")
                if let sharedURL = SharedURLRouter.consumeSharedURL() {
                    print("✅ AppDelegate: 공유된 URL 발견 및 처리: \(sharedURL)")
                    Router.shared.route(to: .openSharedURL(sharedURL))
                } else {
                    print("⚠️ AppDelegate: open-from-share 요청이지만 공유된 URL이 없음")
                }
            } else {
                // 다른 딥링크 경로인 경우 URL 자체를 라우터에 전달
                Router.shared.route(to: .openSharedURL(url))
            }
            return true
        }
        
        print("❌ AppDelegate: 매칭되는 URL 처리자 없음")
        return false
    }
    
    // 앱이 백그라운드에서 포그라운드로 돌아올 때 호출됨
    func applicationWillEnterForeground(_ application: UIApplication) {
        print("🔄 AppDelegate: applicationWillEnterForeground")
        // 공유된 URL이 있는지 확인하고 처리
        consumeSharedURLIfNeeded()
    }
    
    // 앱이 활성화될 때 호출됨
    func applicationDidBecomeActive(_ application: UIApplication) {
        print("🔄 AppDelegate: applicationDidBecomeActive")
        // 공유된 URL이 있는지 다시 한번 확인
        consumeSharedURLIfNeeded()
    }
    
    // MARK: - Private Helpers

    /// App-Group UserDefaults 에 저장된 공유 URL 소비 후 삭제
    private func consumeSharedURLIfNeeded() {
        print("🔄 AppDelegate: consumeSharedURLIfNeeded 시작")
        
        // 1. pendingYoutubeShare 플래그 확인
        let defaults = UserDefaults(suiteName: SharedURLRouter.appGroupID)
        let hasPendingShare = defaults?.bool(forKey: "pendingYoutubeShare") ?? false
        
        if hasPendingShare {
            print("✅ AppDelegate: pendingYoutubeShare 플래그 감지됨")
            // 플래그 초기화
            defaults?.removeObject(forKey: "pendingYoutubeShare")
            defaults?.synchronize()
            
            // sharedURL 확인 및 처리
            if let url = SharedURLRouter.consumeSharedURL() {
                print("✅ AppDelegate: 공유된 URL 발견: \(url)")
                
                // 홈 화면으로 이동하고 유튜브 링크 뷰 표시 (1초 지연으로 앱 초기화 완료 보장)
                DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
                    Router.shared.route(to: .openSharedURL(url))
                    print("✅ AppDelegate: 공유된 URL 라우팅 완료")
                }
                return
            }
        }
        
        // 2. 기존 로직 - URL만 단순 확인
        if let url = SharedURLRouter.consumeSharedURL() {
            print("✅ AppDelegate: 공유된 URL 발견: \(url)")
            Router.shared.route(to: .openSharedURL(url))
        } else {
            print("ℹ️ AppDelegate: 공유된 URL 없음")
        }
    }
}

- ShareExtensionHandler.swift에서 내 앱을 딥링크(deeplinkmyapp://으로 호출)

- appdelegate가 캐치하여 주요로직 함수 실행

 

2-2. app group에 공유된 유튜브 링크를 확인하고 가져오기

// General/SharedURLRouter.swift
import Foundation

enum SharedURLRouter {
    static let appGroupID    = "group.com.mycompany.myapp"
    static let sharedKey     = "sharedURL"

    /// App-Group 에 저장된 URL 있으면 반환 후 바로 삭제
    static func consumeSharedURL() -> URL? {
        print("🔄 SharedURLRouter: UserDefaults에서 URL 검색 시작")
        let defaults = UserDefaults(suiteName: appGroupID)
        defer { 
            defaults?.removeObject(forKey: sharedKey) 
            print("🔄 SharedURLRouter: UserDefaults에서 URL 삭제됨")
        }
        
        let urlString = defaults?.string(forKey: sharedKey)
        print(urlString != nil ? "✅ SharedURLRouter: URL 문자열 발견: \(urlString!)" : "ℹ️ SharedURLRouter: URL 문자열 없음")
        
        guard let urlString = urlString, let url = URL(string: urlString) else {
            return nil
        }
        
        print("✅ SharedURLRouter: URL 파싱 성공: \(url)")
        return url
    }
}

// General/Router.swift
import Foundation
import Combine

/// 앱 전역 내비게이션/딥링크 허브
@MainActor
final class Router: ObservableObject {

    /// 단일 인스턴스
    static let shared = Router()
    private init() { }

    /// 현재 라우팅 명령
    @Published var route: Route?

    /// 화면 전환‧로직 실행
    func route(to destination: Route) {
        print("🔄 Router: 라우팅 요청 - \(destination)")
        self.route = destination
        
        // 추가 로그
        Task { @MainActor in
            print("✅ Router: route 속성 설정됨: \(String(describing: self.route))")
        }
    }
}

/// 라우팅 목적지 정의
enum Route: Equatable {
    case openSharedURL(URL)   // 📌 Share Extension으로 받은 링크
    // case authCallback(...)  // 필요하면 계속 추가
    
    static func == (lhs: Route, rhs: Route) -> Bool {
        switch (lhs, rhs) {
        case (.openSharedURL(let lhsURL), .openSharedURL(let rhsURL)):
            return lhsURL == rhsURL
        }
    }
}

- 전역에서 사용할수 있는 Router를 생성하여, 향후 내가 원하는 페이지에서 Router의 변화를 감지하여, 실행할수 있게 셋팅

 

 

#3. 내가 원하는 페이지에서 Router변화 체크 > 들어온 유튜브링크를 원하는 페이지에서 공유

- 전역으로 선언된 Router를 감지합니다

// UserDefaults에서 공유된 URL 확인
    private func checkForSharedURL() {
        print("🔄 HomeViewModel: UserDefaults에서 공유된 URL 확인")
        if let url = SharedURLRouter.consumeSharedURL() {
            print("✅ HomeViewModel: 공유된 URL 발견: \(url)")
            
            // 유튜브 URL인지 확인
            if url.host?.contains("youtube.com") == true || url.host?.contains("youtu.be") == true {
                print("✅ HomeViewModel: 유튜브 URL 감지됨: \(url)")
                
                // 메인 스레드에서 UI 업데이트 보장
                DispatchQueue.main.async {
                    self.inputYoutubeLink = url.absoluteString
                    self.showYoutubeLinkInput = true
                    print("✅ HomeViewModel: showYoutubeLinkInput = true 설정됨")
                }
            } else {
                print("ℹ️ HomeViewModel: 유튜브 URL이 아님: \(url)")
            }
        } else {
            print("ℹ️ HomeViewModel: 공유된 URL 없음")
        }
    }

 

- 내가 원하는 페이지인 YoutubeLinkInputView.swift가 자동으로 띄워지고, 공유된 링크를 삽입합니다

self.inputYoutubeLink = url.absoluteString #공유된 링크 삽입
self.showYoutubeLinkInput = true #창 띄우기

 

 

#4. 다른 앱에서 내앱으로 링크 공유하고, 받은 링크 원하는 페이지에 띄우는 방법 정리

1. ShareExtension을 추가한다

2. shareExtension과 나의 앱에 동일한 App Group을 설정한다

3. ShareExtensionHandler가 딥링크를 통해 내 앱을 호출한다

4. 내앱은 호출되어서, App Group의 데이터를 확인한다

5. Router를 전역으로 설정하여, 공유된 링크를 실시간으로 확인한다

6. Router의 변화를 감지하면, 내가 원하는 페이지에서 반응하게 한다.