본문 바로가기
iOS/트러블슈팅

앱 생명주기를 고려한 딥링크 흐름 만들기

by hong7 2026. 4. 15.

두게더 iOS 프로젝트를 개발하면서, 그룹 초대 경험을 더 자연스럽게 만들기 위해 딥링크를 적용했던 경험을 정리해보려고 해요.

 

기존 그룹 초대 기능은 초대 메시지를 받은 사용자가 앱에 직접 들어가 초대코드를 붙여넣어야 하는 구조였어요. 기능은 동작했지만, 초대 메시지에서 실제 참여 흐름으로 이어지는 경험은 매끄럽지 않았습니다.

 

그래서 이번 작업에서는 Universal Link 기반 딥링크를 적용해 그룹 초대 흐름을 개선했고, 그 과정에서 앱 생명주기와 화면 전환 구조를 함께 정리하게 됐어요.

문제의 발견

처음에는 딥링크도 결국 “링크를 누르면 원하는 화면으로 보내면 되는 것 아닌가?”라고 생각하기 쉬워요.

 

하지만 실제 앱에서는 그렇게 단순하지 않았어요. 딥링크는 앱이 어떤 상태에서 열리느냐에 따라 처리 시점과 진입 경로가 달라졌고, 이 차이를 무시하면 화면 전환이 쉽게 꼬일 수 있었거든요.

 

두게더에서 실제로 문제였던 건 크게 세 가지였어요.

  • 앱이 완전히 종료된 상태에서 링크로 처음 실행되는 경우
  • 앱이 이미 실행 중인 상태에서 외부 링크로 재진입하는 경우
  • 아직 스플래시, 강제 업데이트, 로그인, 온보딩 같은 초기화 플로우가 끝나지 않은 경우

이 상태들을 구분하지 않고 링크가 들어오는 즉시 그룹가입하기 화면으로 보내면 문제가 생겼어요. 정상 플로우를 우회한 진입이 가능해지고, 아직 준비되지 않은 네비게이션 스택 위에 화면을 올리게 될 수도 있었기 때문입니다.

 

또 하나 실제로 겪었던 문제는 중복 push였어요. 이미 그룹가입하기 화면에 있는 상태에서 같은 딥링크가 다시 들어오면, 같은 화면이 네비게이션 스택에 한 번 더 쌓이는 상황이 있었습니다. 이 경우 사용자는 뒤로가기를 눌렀을 때 이전 화면이 아니라 또 다른 그룹가입하기 화면을 만나게 됐고, 딥링크가 화면 이동을 돕는 게 아니라 스택을 꼬이게 만드는 경로가 되기도 했어요.

 

즉, 딥링크에서 중요한 건 “링크를 잘 여는가”보다 언제, 어떤 상태에서, 어떤 방식으로 진입시키느냐였어요.

왜 Universal Link였을까

그룹 초대 링크를 앱 진입 흐름으로 연결하기로 하면서, 가장 먼저 고민한 건 어떤 링크 방식을 선택할지였어요.

 

iOS에서 외부 링크로 앱을 여는 방법은 크게 URL Scheme과 Universal Link가 있는데, 두 방식은 비슷해 보여도 실제 사용자 경험에서는 차이가 있었어요.

 

URL Scheme은 앱 내부에서 특정 스킴을 등록해 두고, 예를 들어 dogether://group?code=1234 같은 형태로 앱을 여는 방식이에요. 앱이 설치되어 있을 때는 단순하게 동작하지만, 앱이 설치되어 있지 않을 때의 처리나 외부 공유 경험은 상대적으로 제한적이었습니다.

 

반면 Universal Link는 일반적인 https 링크를 기반으로 동작해요. 사용자가 보기에는 웹 링크처럼 자연스럽고, 앱이 설치되어 있으면 앱으로 열리고, 그렇지 않으면 웹에서 처리할 수도 있죠.

 

두게더의 그룹 초대는 내부 구현 편의성보다 공유 경험이 더 중요한 기능이었어요. 메시지 안에 담긴 링크가 웹 링크처럼 자연스럽게 보여야 했고, 앱 설치 여부에 따라 유연하게 동작할 수 있어야 했습니다. 그래서 URL Scheme보다 Universal Link가 더 적합하다고 판단했어요.

 

즉, 이 작업에서 중요했던 건 단순히 “앱을 열 수 있느냐”보다, 사용자가 초대 메시지를 받고 앱으로 들어오는 전체 경험이 자연스러운가였어요.

링크를 어떻게 만들었을까

Universal Link를 쓰기로 정한 뒤에는 링크 생성과 해석 방식을 함께 정리해야 했어요. 두게더에서는 이 부분에 ChottuLink SDK를 사용했습니다.

 

그룹 초대 기능에서는 단순히 앱만 여는 게 아니라, 어떤 그룹으로 진입해야 하는지 식별할 수 있는 정보까지 함께 전달해야 했어요. 예를 들어 초대코드가 링크 안에 포함되어야 하고, 앱은 그 값을 다시 읽어서 적절한 화면으로 연결해야 했습니다.

 

두게더에서는 초대 버튼을 누르는 순간 SystemManager.inviteGroup()에서 그룹 이름과 초대코드를 바탕으로 destination URL을 만들고, 이를 ChottuLink SDK를 통해 Dynamic Link로 변환했습니다.

extension SystemManager {
    static func inviteGroup(groupName: String, joinCode: String) async throws -> [Any] {
        // 실제 앱 내부 목적지로 사용할 URL입니다.
        // 초대코드를 query parameter로 함께 담아 어떤 그룹으로 들어와야 하는지 전달합니다.
        let destinationURL =
        "<https://dogether.site/invite?code=\\\\(joinCode)>"

        // 위 목적지 URL을 실제 공유용 Dynamic Link로 변환합니다.
        let builder = CLDynamicLinkBuilder(
            destinationURL: destinationURL,
            domain: "dogether-app.chottu.link"
        )
        .setIOSBehaviour(CLDynamicLinkBehaviour.app)
        .setAndroidBehaviour(CLDynamicLinkBehaviour.app)
        .build()

        // 링크 생성이 끝날 때까지 로딩을 유지합니다.
        LoadingManager.shared.showLoading()
        defer { LoadingManager.shared.hideLoading() }

        let shortURL = try await ChottuLink.createDynamicLink(for: builder)

        // 초대코드와 링크를 함께 메시지에 담아 공유합니다.
        return ["""
        ✨ [\\\\(groupName)]에서 당신의 참여를 기다리고 있어요

        작심삼일도 괜찮아요.
        투두 챌린지 서비스 두게더에서
        팀원들과 함께 목표 달성을 시작해보세요 💪

        👉 초대코드: \\\\(joinCode) (\\\\(shortURL!))
        """]
    }
}

여기서 중요했던 건 딥링크가 단순히 앱을 여는 링크가 아니라, 초대코드가 담긴 진입 링크였다는 점이었어요. 사용자가 링크를 눌렀을 때 앱만 열리는 게 아니라, 앱이 “어떤 그룹 초대 흐름으로 들어와야 하는지”까지 함께 가져가야 했습니다.

Universal Link는 앱 설정만으로 끝나지 않았어요

Universal Link를 쓰려면 앱 코드만으로 끝나지 않아요. iOS가 특정 도메인을 “이 앱이 처리할 수 있는 링크”로 인식하도록 연결해줘야 합니다.

 

두게더에서는 entitlements에 아래처럼 Associated Domains를 설정했습니다.

<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:dogether-app.chottu.link</string>
</array>

여기서 중요한 건 applinks:로 등록한 도메인이 실제 Universal Link 진입 도메인이라는 점이에요. 두게더는 dogether-app.chottu.link를 Universal Link 도메인으로 사용했고, 이 도메인을 통해 앱이 링크 유입을 받을 수 있도록 구성했습니다.

 

이 설정이 빠지면 링크는 웹 링크로만 열리고 앱 진입으로 이어지지 않습니다.

링크를 어떻게 해석했을까

링크를 생성하는 것만으로 초대 경험이 완성되진 않았어요. 문제는 사용자가 링크를 눌러 앱에 들어온 뒤였어요.

 

두게더에서는 외부 링크가 앱으로 들어오면 바로 화면 전환에 쓰지 않고, 먼저 DeepLinkManager에서 링크를 해석한 뒤 필요한 값만 저장하도록 구성했습니다.

final class DeepLinkManager {
    static let shared = DeepLinkManager()

    // 앱이 아직 준비되지 않은 상황을 위해 초대코드를 잠시 보관합니다.
    private var pendingInviteCode: String?

    func resolveUrl(userActivity: NSUserActivity) async throws {
        if let url = userActivity.webpageURL {
            // Universal Link를 SDK로 해석해 실제 destination URL을 복원합니다.
            let resolved = try await ChottuLink.getAppLinkDataFromUrl(from: url.absoluteString)

            if let destinationURL = resolved.link,
               let components = URLComponents(url: destinationURL, resolvingAgainstBaseURL: false),
               let code = components.queryItems?.first(where: { $0.name == "code" })?.value {
                // 여기서는 화면 이동을 하지 않고, 초대코드만 저장합니다.
                pendingInviteCode = code
            }
        }
    }

    func consumeInviteCode() -> String? {
        // 한 번 사용한 초대코드는 다시 소비되지 않도록 바로 비웁니다.
        defer { pendingInviteCode = nil }
        return pendingInviteCode
    }
}

여기서 핵심은 딥링크 자체가 그룹가입하기 화면을 직접 여는 게 아니라는 점이었어요.

 

딥링크는 어디까지나 초대코드가 담긴 진입 정보이고, 앱은 이 값을 해석해 pendingInviteCode로 잠깐 보관한 뒤, 적절한 시점에만 실제 화면 전환에 사용합니다.

즉:

  • 링크는 데이터
  • 라우팅은 앱 코드

이렇게 역할을 분리한 거예요.

앱 생명주기에 따라 진입 경로를 분리했어요

딥링크를 다루다 보니 iOS에서는 링크가 들어오는 경로가 하나가 아니라는 점이 중요했어요.

 

앱이 완전히 종료된 상태에서 링크로 실행될 때는 scene(_:willConnectTo:options:)로 들어오고, 이미 실행 중인 상태에서 외부 링크로 재진입할 때는 scene(_:continue:)로 들어옵니다.

 

두게더에서는 이 둘을 분리해서 처리했어요.

 

앱이 꺼져 있다가 딥링크로 들어오는 경우에는 먼저 링크를 해석하고, 그 다음 스플래시부터 정상 초기화 플로우를 타도록 구성했습니다.

func scene(
    _ scene: UIScene,
    willConnectTo session: UISceneSession,
    options connectionOptions: UIScene.ConnectionOptions
) {
    ...

    Task { @MainActor in
        if let userActivity = connectionOptions.userActivities.first {
            // 앱이 꺼져 있다가 들어온 딥링크는 우선 해석만 해둡니다.
            try await DeepLinkManager.shared.resolveUrl(userActivity: userActivity)
        }

        // 초기화 플로우는 기존 진입 순서대로 시작합니다.
        coordinator?.setNavigationController(SplashViewController())
    }
}

반대로 앱이 이미 실행 중인 상태에서는 딥링크가 실제로 들어온 이벤트이기 때문에, 링크를 해석한 뒤 바로 처리 가능한지 판단하도록 했습니다.

func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    Task { @MainActor in
        // 실행 중 재진입은 링크를 해석한 뒤
        try await DeepLinkManager.shared.resolveUrl(userActivity: userActivity)
        // 현재 앱 상태에서 바로 처리 가능한지 확인합니다.
        coordinator?.handleDeepLinkIfNeeded()
    }
}

즉,

  • 실행 중 재진입은 딥링크 이벤트가 발생한 시점에 처리
  • 앱 최초 실행은 앱 초기화가 끝난 뒤 처리

이렇게 진입 경로를 나눴습니다.

초기화 전 진입은 제한했어요

두게더에서 중요했던 기준은 “딥링크가 들어오면 바로 이동한다”가 아니라, 앱이 아직 초기화되지 않았다면 진입을 보류한다였어요.

 

강제 업데이트, 로그인, 온보딩처럼 선행되어야 하는 흐름보다 딥링크가 앞서면 정상 플로우를 우회할 수 있었기 때문입니다.

 

그래서 앱 최초 실행에서는 먼저 스플래시를 진입점으로 삼고, 초기화 흐름이 끝난 뒤에만 딥링크를 처리하도록 정리했습니다.

extension SplashViewController {
    private func onAppear() {
        Task {
            try await viewModel.launchApp()

            // 강제 업데이트가 필요하면 딥링크보다 업데이트가 우선입니다.
            if try await viewModel.checkUpdate() {
                coordinator?.setNavigationController(UpdateViewController())
                return
            }

            // 로그인/온보딩이 필요한 상태도 먼저 선행합니다.
            if viewModel.checkLogin() {
                coordinator?.setNavigationController(OnboardingViewController())
                return
            }

            if try await viewModel.checkParticipating() {
                // 초기 진입 화면을 먼저 결정한 뒤
                coordinator?.setNavigationController(StartViewController())
                // 그 다음에만 딥링크를 한 번 처리합니다.
                coordinator?.handleDeepLinkIfNeeded()
                return
            }

            coordinator?.setNavigationController(MainViewController())
            // 메인 진입 케이스도 동일하게, 화면 결정 후 딥링크를 처리합니다.
            coordinator?.handleDeepLinkIfNeeded()
        }
    }
}

이 코드에서 중요했던 건 setNavigationController(...) 다음에 바로 handleDeepLinkIfNeeded()를 호출했다는 점이었어요.

 

즉, 앱이 어떤 초기 화면으로 진입할지는 먼저 결정하되, 그 결정이 끝난 뒤에만 딥링크를 처리하도록 순서를 명확히 만든 거예요.

화면 전환 책임을 다시 정리했어요

초기에는 pending 딥링크를 StartViewController, MainViewController 진입 시점마다 확인하는 구조였어요. 동작 자체는 잘 했지만, 시간이 지나서 다시 코드를 보면 흐름을 따라가기가 쉽지 않았습니다.

 

예를 들면 이런 의문이 자연스럽게 생겼어요.

  • 왜 메인 화면이 뜰 때마다 딥링크를 확인하지?
  • 딥링크는 어디서 소비되고 있지?
  • 어느 화면에서 실제 라우팅이 일어나지?

즉, 기능은 동작하고 있었지만 딥링크 처리 흐름이 화면 곳곳에 흩어져 있었어요. 그러다 보니 코드를 다시 읽을 때도 진입 시점과 소비 시점을 한 번에 파악하기가 어려웠습니다.

 

또 하나 실제로 겪었던 문제는 중복 push였어요. 이미 그룹가입하기 화면에 있는 상태에서 같은 딥링크가 다시 들어오면, 같은 화면이 네비게이션 스택에 한 번 더 쌓이는 상황이 있었습니다. 이 경우 사용자는 뒤로가기를 눌렀을 때 이전 화면이 아니라 또 다른 그룹가입하기 화면을 만나게 됐고, 딥링크가 화면 이동을 돕는 게 아니라 스택을 꼬이게 만드는 경로가 되기도 했어요.

 

이 지점에서 다시 기준을 정리하게 됐어요. 딥링크는 화면이 나타날 때마다 확인해야 하는 상태라기보다, 외부에서 들어오는 1회성 이벤트에 더 가깝다고 봤습니다. 그렇다면 중요한 건 어느 화면에서 확인하느냐보다, 언제 처리해야 가장 안전한가였어요.

 

실제 라우팅 책임은 NavigationCoordinator에 모았습니다.

func handleDeepLinkIfNeeded() {
    // 아직 초기화가 끝나지 않은 화면에서는 딥링크를 처리하지 않습니다.
    if checkCurrentViewController(
        SplashViewController.self,
        UpdateViewController.self,
        OnboardingViewController.self
    ) { return }

    // 보관해둔 초대코드가 있을 때만 실제 라우팅을 수행합니다.
    guard let code = DeepLinkManager.shared.consumeInviteCode() else { return }

    let groupJoinViewController = GroupJoinViewController()
    // 그룹가입하기 화면이 뜨는 순간 코드가 이미 세팅된 상태가 되도록 전달합니다.
    let groupJoinViewDatas = GroupJoinViewDatas(code: code)
    pushViewController(groupJoinViewController, datas: groupJoinViewDatas)
}

이 메서드는 세 가지 역할을 합니다.

  • 아직 딥링크를 처리하면 안 되는 화면인지 확인
  • 보류 중인 초대코드가 있는지 확인
  • 있으면 그룹가입하기 화면으로 push

즉, 딥링크를 “링크가 왔으니 무조건 즉시 이동”으로 처리하지 않고, 지금 라우팅해도 안전한 상태인지 확인한 뒤 1회 소비하는 구조로 만든 거예요.

결과

이 구조를 적용한 뒤에는 딥링크가 초기화 플로우를 우회하지 않도록 만들 수 있었고, 앱이 실행 중인지 종료 상태인지에 따라 진입 흐름도 더 일관되게 정리할 수 있었어요.

  • 초기화 플로우 우회 없는 안전한 딥링크 진입 구조를 만들 수 있었어요.
  • 앱 실행 상태에 따라 진입 흐름 정합성과 화면 전환 안정성을 확보할 수 있었어요.
  • 생명주기와 라우팅을 함께 고려한 딥링크 처리 기준을 정리할 수 있었어요.

무엇보다 초대 경험 자체가 훨씬 단순해졌어요. 이제 초대받은 사용자는 초대 메시지 안의 링크를 누르는 것만으로 앱의 그룹가입하기 화면으로 진입할 수 있고, 초대코드도 자동으로 입력된 상태에서 바로 참여 흐름을 이어갈 수 있게 되었습니다.

 

기존에는 초대코드를 직접 복사하고 붙여넣어야 했다면, 딥링크 도입 이후에는 그 과정을 링크와 라우팅 구조가 대신하게 된 거예요.

마치며

딥링크는 처음엔 “링크를 누르면 원하는 화면으로 가는 기능”처럼 보였어요. 하지만 실제로 구현하고 운영해보니, 링크를 여는 것보다 어떤 상태에서 어떻게 진입시키는가가 훨씬 중요했습니다.

 

두게더에서는 Universal Link와 ChottuLink SDK를 활용해 그룹 초대 링크를 만들고, 앱 생명주기와 초기화 플로우를 함께 고려해 필요한 시점에 한 번만 처리되는 구조로 정리하면서 흐름을 안정화할 수 있었어요. 이 과정은 단순히 딥링크 하나를 붙이는 작업이라기보다, 초대 메시지 생성부터 링크 해석, 초기화 이후의 진입 시점, 그리고 실제 화면 전환 책임까지 초대 경험 전체를 다시 설계하는 작업에 더 가까웠습니다.

 

특히 이번 작업을 통해 느낀 건, 외부에서 앱으로 들어오는 경험은 링크 하나로 끝나는 문제가 아니라는 점이었어요. 사용자 입장에서는 자연스럽게 앱에 들어와 원하는 화면으로 이어지는 단순한 경험처럼 보이지만, 그 뒤에서는 생명주기, 초기화 상태, 라우팅 흐름을 함께 고려한 기준이 필요했습니다.