프론트엔드 개발자가 iOS Universal Link를 구현하며 배운 것들

honney·2026년 3월 26일

WebView 앱에서 "링크를 누르면 브라우저로 가는 문제"를 해결하기까지, 그리고 그 과정에서 이해하게 된 것들을 정리합니다.

배경: 왜 이걸 하게 됐는가

저는 주로 Next.js와 React로 웹 개발을 해온 프론트엔드 개발자입니다.
현재 팀에서는 iOS/Android 네이티브 앱 안에 WebView를 띄워서 서비스를 운영하고 있습니다.
웹뷰 기반이기 때문에 제가 만든 웹 페이지가 곧 앱의 화면이 되는 구조죠.

어느 날 팀원분이 이런 이야기를 했습니다.

"우리 서비스는 왜 앱이 있는데 링크를 클릭하면 브라우저로 가는거에요?
유튜브는 앱으로 이동해서 사용성이 더 좋은 거 같아요..!"

당연한 이야기인데, 막상 사용자 입장에서 생각하면 정말 아쉬운 부분이었습니다.
앱이 깔려 있는데 왜 브라우저로 가야 하지?
이 문제를 파고들면서 Universal Link라는 기술을 알게 되었고,
결국 앱 심사를 통과해 배포까지 성공했습니다.

이 글에서는 단순히 "이렇게 하면 됩니다"가 아니라,
구현하면서 부딪힌 문제들이 기술이 어떤 원리로 동작하는지
정리해보겠습니다.


Universal Link란 무엇인가

기존 방식: Custom URL Scheme의 한계

Universal Link 이전에는 myapp:// 같은 Custom URL Scheme을 사용해서 앱을 열었습니다.
하지만 이 방식에는 근본적인 문제가 있습니다.

  • 앱이 설치되지 않으면 에러가 발생합니다.
    브라우저는 myapp://이라는 프로토콜을 모르기 때문에 아무 일도 일어나지 않거나 에러 페이지가 뜹니다.
  • 스킴 충돌 가능성이 있습니다.
    myapp://이라는 스킴을 다른 앱도 등록할 수 있어서 보안에 취약합니다.
  • 사파리에서 확인 팝업이 뜹니다.
    "이 페이지에서 앱을 열려고 합니다" 같은 얼럿이 먼저 나타나 사용자 경험이 끊깁니다.

Universal Link는 https://~~~.com/some-page처럼 일반적인 웹 URL 자체가 앱을 여는 링크가 됩니다.

핵심 원리는 이렇습니다:

  1. 앱 설치 시, iOS가 앱의 Associated Domains 설정을 확인합니다.
  2. 등록된 도메인의 /.well-known/apple-app-site-association (AASA) 파일을 Apple의 CDN이 가져갑니다.
  3. 이 파일에 "이 도메인의 URL은 이 앱이 처리한다"는 매핑 정보가 담겨 있습니다.
  4. 이후 사용자가 해당 URL을 탭하면, iOS는 브라우저를 열지 않고 바로 앱을 실행합니다.

앱이 설치되지 않았다면? 그냥 평소처럼 웹 브라우저에서 해당 URL이 열립니다.
fallback이 자연스럽게 내장되어 있는 것이죠.


구현: 실제로 무엇을 했는가

1단계: 서버 — AASA 파일 배포

가장 먼저 해야 하는 것은 웹 서버에 apple-app-site-association 파일을 배포하는 것입니다.

https://~~~.com/.well-known/apple-app-site-association
{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "<TEAM_ID>.com.~~~.~~~",
        "paths": ["*"]
      }
    ]
  }
}

여기서 중요한 점들:

  • Content-Typeapplication/json이어야 합니다.
    서명 없이 JSON으로 제공해야 합니다 (iOS 9 이후).
  • HTTPS 필수입니다.
    인증서가 유효해야 하고, 리다이렉트 없이 직접 응답해야 합니다.
  • Apple CDN이 캐싱합니다.
    파일을 수정해도 즉시 반영되지 않습니다. 앱을 재설치해야 갱신되는 경우도 있습니다.

2단계: iOS 앱 — Entitlements 설정

앱 쪽에서는 MyApp.entitlements 파일에 Associated Domains를 등록했습니다.

<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:~~.com</string>
</array>

applinks: 접두사가 "이 도메인의 Universal Link를 처리하겠다"는 선언입니다.

3단계: iOS 앱 — 딥링크 수신 처리

여기가 가장 까다로웠습니다.
iOS에서 Universal Link를 수신하는 경로가 앱의 상태에 따라 다르기 때문입니다.


가장 어려웠던 부분: 앱 생명주기와 딥링크

왜 경로가 여러 개인가

iOS 앱은 단순히 "켜져 있다 / 꺼져 있다"가 아닙니다. 최소 세 가지 상태를 구분해야 합니다:

상태설명딥링크 수신 위치
Cold Start앱이 완전히 종료된 상태에서 링크로 실행SceneDelegate.scene(_:willConnectTo:options:)
Warm Start앱이 백그라운드에 있다가 링크로 복귀SceneDelegate.scene(_:continue:)
Pre-Scene (iOS 12 이하)Scene lifecycle 미지원 기기AppDelegate.application(_:continue:restorationHandler:)

웹 개발자 입장에서 이건 완전히 새로운 개념이었습니다.
웹에서는 URL이 바뀌면 라우터가 알아서 처리하지만,
네이티브 앱은 어떤 생명주기 메서드로 URL이 들어오는지부터 파악해야 합니다.

Cold Start: 앱이 꺼져 있을 때

// SceneDelegate.swift
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
    guard let _ = (scene as? UIWindowScene) else { return }

    // Universal Link - cold start
    if let userActivity = connectionOptions.userActivities.first(where: {
        $0.activityType == NSUserActivityTypeBrowsingWeb
    }),
       let url = userActivity.webpageURL {
        SceneDelegate.DEEP_URL = url.absoluteString
        SceneDelegate.DEEP_FLAG = true
    }
}

앱이 완전히 종료된 상태에서 Universal Link를 탭하면,
iOS는 앱을 실행하면서 connectionOptions.userActivities에 URL 정보를 담아줍니다.
이때 아직 ViewController가 로드되지 않았으므로,
static 변수에 URL을 저장해두고 나중에 처리합니다.

NSUserActivityTypeBrowsingWeb — 이 타입을 필터링하는 것이 핵심입니다. 다른 종류의 userActivity와 구분하기 위해서죠.

Warm Start: 앱이 백그라운드에 있을 때

// SceneDelegate.swift
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else { return }

    NotificationCenter.default.post(
        name: Notification.Name("ActionOnForeground"),
        object: nil,
        userInfo: ["url": url.absoluteString]
    )
}

앱이 이미 백그라운드에 있으면 ViewController가 살아있습니다.
이 경우에는 static 변수 대신 NotificationCenter를 사용해서 ViewController에 직접 URL을 전달합니다.

웹으로 비유하면, Cold Start는 window.onload 시점에 URL을 읽는 것이고,
Warm Start는 popstate 이벤트를 리스닝하는 것과 비슷합니다.

Pre-Scene Fallback: iOS 12 이하 대응

// AppDelegate.swift
func application(_ application: UIApplication,
                 continue userActivity: NSUserActivity,
                 restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else { return false }

    SceneDelegate.DEEP_URL = url.absoluteString
    SceneDelegate.DEEP_FLAG = true
    return true
}

iOS 13부터 도입된 Scene lifecycle를 지원하지 않는 기기를 위한 fallback입니다.
이전 방식인 AppDelegate에서 동일한 로직을 처리합니다.

ViewController: 최종적으로 WebView에 URL 로드

// ViewController.swift - viewDidLoad
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    if let url = SceneDelegate.DEEP_URL, !url.isEmpty, SceneDelegate.DEEP_FLAG {
        SceneDelegate.DEEP_FLAG = false
        self.moveAction(url: url)
        SceneDelegate.DEEP_URL = nil
    }
}

// Warm start 시 NotificationCenter 옵저버
@objc func ActionOnForeground(_ notification: Notification) {
    if let info = notification.userInfo,
       let url = info["url"] as? String {
        moveAction(url: url)
    }
}

func moveAction(url: String) {
    DispatchQueue.main.async {
        let encodedString = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
        let url = URL(string: encodedString)!
        let request = URLRequest(url: url)
        self.webview.load(request)
    }
}

moveAction은 결국 WebView에 해당 URL을 로드하는 것입니다.
WebView 기반 앱이기 때문에, 딥링크로 받은 URL을 그대로 웹뷰에 넘기면 웹 앱의 라우터가 나머지를 처리합니다.

Cold Start 시 asyncAfter(deadline: .now() + 1) — 1초 딜레이를 준 이유는
WebView가 완전히 초기화되기 전에 URL을 로드하면 무시될 수 있기 때문입니다.
이 부분은 실제로 겪었던 문제인데, 타이밍 이슈를 해결하기 위한 실용적인 방법이었습니다.


전체 흐름 정리

사용자가 https://~~~.com/some-page 링크 탭
         │
         ├─ 앱 미설치 → 사파리에서 웹페이지 열림 (자연스러운 fallback)
         │
         └─ 앱 설치됨 → iOS가 앱 실행
                │
                ├─ 앱 종료 상태 (Cold Start)
                │   └─ SceneDelegate.scene(_:willConnectTo:options:)
                │       └─ static 변수에 URL 저장
                │           └─ ViewController.viewDidLoad() 에서 1초 후 확인
                │               └─ moveAction() → WebView에 URL 로드
                │
                ├─ 백그라운드 상태 (Warm Start)
                │   └─ SceneDelegate.scene(_:continue:)
                │       └─ NotificationCenter로 URL 전달
                │           └─ ActionOnForeground() → moveAction()
                │
                └─ iOS 12 이하
                    └─ AppDelegate.application(_:continue:restorationHandler:)
                        └─ static 변수에 URL 저장 (Cold Start와 동일 흐름)

구현하면서 부딪힌 문제들

1. AASA 파일 캐싱 문제

AASA 파일을 수정했는데 변경이 반영되지 않는 문제가 있었습니다.
Apple은 AASA 파일을 자체 CDN을 통해 캐싱합니다.
앱이 설치될 때(또는 업데이트될 때) Apple CDN에서 가져가는 구조이기 때문에,
서버에서 파일을 수정하더라도 즉시 반영되지 않습니다.

해결: 개발 중에는 ?mode=developer를 붙여서 Associated Domain을 등록하면,
Apple CDN을 거치지 않고 직접 서버에서 AASA를 가져옵니다.

<string>applinks:~~~.com?mode=developer</string>

이건 개발 시에만 사용하고, 프로덕션 빌드에서는 제거해야 합니다.

2. 개발 환경에서의 도메인 문제

로컬이나 스테이징 환경에서 Universal Link를 테스트하려면 HTTPS가 적용된 실제 도메인이 필요합니다.
개발 중에 Cloudflare Tunnel 같은 도구를 사용해서 임시 도메인을 만들어 테스트했습니다.

// 개발 중 임시로 사용한 터널 URL
private static let STAGING_URL = "https://my-tunnel-url.trycloudflare.com"

하지만 터널 URL은 매번 바뀌기 때문에, Entitlements와 AASA 파일을 동시에 업데이트해야 하는 번거로움이 있었습니다.

3. WebView 초기화 타이밍

Cold Start 시 WebView가 아직 준비되지 않은 상태에서 딥링크 URL을 로드하려고 하면 제대로 동작하지 않았습니다.
WebView의 configuration이 완료된 후에 URL을 로드해야 했고,
이를 위해 기존 코드에서 URL 로드 시점을 WebView 설정 완료 후로 이동시켰습니다.

// 변경 전: WebView 설정 전에 URL 로드
webview.load(request)
// ... WebView 설정 코드 ...

// 변경 후: WebView 설정이 모두 끝난 후 URL 로드
// ... WebView 설정 코드 ...
webview.configuration.preferences = preferences

let urlString = BASE_URL
let encodedString = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
let url = URL(string: encodedString)!
let request = URLRequest(url: url)
webview.load(request)

이 순서 변경은 diff에서도 확인할 수 있는 실제 변경사항입니다.
사소해 보이지만, 이 순서가 잘못되면 딥링크가 아예 무시되는 현상이 발생했습니다.

4. 푸시 알림과 딥링크의 통합

업로드중..

Universal Link만 따로 동작하는 게 아니라, 푸시 알림에서도 같은 딥링크 패턴을 사용해야 했습니다.
앱에서는 이미 ActionOnForeground라는 NotificationCenter 이벤트를 사용하고 있었기 때문에,
이 패턴을 Universal Link에서도 그대로 활용했습니다.

// AppDelegate.swift - 푸시 알림에서도 동일한 패턴 사용
func userNotificationCenter(_ center: UNUserNotificationCenter,
                            didReceive response: UNNotificationResponse, ...) {
    if let url = notification["action_url"] as? String {
        if application.applicationState == .active || application.applicationState == .inactive {
            // Warm: NotificationCenter로 전달
            NotificationCenter.default.post(name: Notification.Name("ActionOnForeground"),
                                          object: nil, userInfo: ["url": url])
        } else {
            // Cold: static 변수에 저장
            SceneDelegate.DEEP_URL = url
            SceneDelegate.DEEP_FLAG = true
        }
    }
}

결국 "앱에 URL을 전달한다"는 하나의 문제를, 입구(Universal Link, 푸시 알림)에 관계없이 동일한 출구(moveAction)로 처리하는 구조를 만든 것입니다.


웹 개발자 관점에서 이해한 것들

URL의 의미가 달라진다

웹에서 URL은 "브라우저가 리소스를 요청하는 주소"입니다. 하지만 Universal Link에서 URL은 "운영체제가 앱을 라우팅하는 식별자"가 됩니다.

같은 https://example.com/page라는 URL이:

  • 앱이 없으면 → 웹 서버로 HTTP 요청
  • 앱이 있으면 → iOS가 가로채서 앱으로 전달

이 이중적 성격 때문에, 서버에서 제공하는 웹 페이지와 앱에서 보여주는 화면이 같은 URL로 접근 가능해야 합니다.
WebView 기반 앱에서는 이게 자연스럽게 해결되지만, 네이티브 앱이라면 URL별 화면 매핑을 직접 구현해야 합니다.

앱 생명주기라는 개념

웹 앱은 탭을 닫으면 끝입니다. 하지만 iOS 앱은 종료, 백그라운드, 비활성, 활성 등 여러 상태를 오갑니다.
딥링크 구현에서 가장 핵심적인 배움은 이 생명주기에 따라 데이터 전달 방식이 달라진다는 것이었습니다.

웹으로 비유하면:

  • Cold Start ≈ 새 탭에서 URL 직접 입력 (초기 로드 시 URL 파싱)
  • Warm Start ≈ SPA에서 popstate 이벤트 수신 (이미 로드된 상태에서 라우트 변경)
  • Static 변수 ≈ 전역 상태 (window.__INITIAL_DATA__ 같은 패턴)
  • NotificationCenter ≈ Custom Event (dispatchEvent / addEventListener)

검증의 어려움

웹은 브라우저 주소창에 URL을 치면 바로 확인할 수 있습니다. 하지만 Universal Link는:

  • 실제 디바이스에서만 테스트 가능 (시뮬레이터 제한적)
  • 메모/메시지 앱에서 링크를 탭해야 동작 (사파리 주소창 직접 입력은 동작하지 않음)
  • AASA 파일 캐싱 때문에 변경 후 바로 확인이 안 됨
  • Apple Developer 계정의 도메인 연결 설정이 필요

이 피드백 루프의 길이가 웹 개발과 가장 큰 차이였습니다.


마치며

프론트엔드 개발자로서 처음 iOS 네이티브 코드를 수정하는 건 분명 부담스러웠습니다.
Swift 문법도 익숙하지 않았고, Xcode도 낯설었습니다.
하지만 Universal Link를 구현하면서 깨달은 건, 결국 핵심 문제는 동일하다는 것입니다.

  • "URL을 받아서 적절한 화면으로 라우팅한다" — 웹 라우터와 같은 문제
  • "앱 상태에 따라 다른 경로로 데이터를 전달한다" — 상태 관리 문제
  • "서버와 클라이언트가 약속된 형식으로 통신한다" — AASA 파일은 결국 API 계약

기술 스택이 달라져도 문제의 본질은 크게 다르지 않았습니다.
다만 그 본질에 도달하기 위해 플랫폼이 제공하는 생명주기, 보안 모델, 캐싱 정책 같은 컨텍스트를 이해해야 했고,
그 과정 자체가 성장이었다고 생각합니다.

링크 하나 클릭했을 때 앱이 열리는 그 단순한 경험 뒤에,
서버의 AASA 파일 → Apple CDN 캐싱 → iOS의 도메인 검증 → 앱 생명주기에 따른 분기 처리 → WebView 로드까지,
생각보다 많은 레이어가 있었습니다.
그리고 그 레이어 하나하나를 이해하는 과정이, "되게 만드는 것"과 "이해하고 만드는 것"의 차이를 만들어주었습니다.

profile
보이지 않은 것을 보이게 할 때 기쁨을 느낍니다

0개의 댓글