WebView 앱에서 "링크를 누르면 브라우저로 가는 문제"를 해결하기까지, 그리고 그 과정에서 이해하게 된 것들을 정리합니다.
저는 주로 Next.js와 React로 웹 개발을 해온 프론트엔드 개발자입니다.
현재 팀에서는 iOS/Android 네이티브 앱 안에 WebView를 띄워서 서비스를 운영하고 있습니다.
웹뷰 기반이기 때문에 제가 만든 웹 페이지가 곧 앱의 화면이 되는 구조죠.
어느 날 팀원분이 이런 이야기를 했습니다.
"우리 서비스는 왜 앱이 있는데 링크를 클릭하면 브라우저로 가는거에요?
유튜브는 앱으로 이동해서 사용성이 더 좋은 거 같아요..!"
당연한 이야기인데, 막상 사용자 입장에서 생각하면 정말 아쉬운 부분이었습니다.
앱이 깔려 있는데 왜 브라우저로 가야 하지?
이 문제를 파고들면서 Universal Link라는 기술을 알게 되었고,
결국 앱 심사를 통과해 배포까지 성공했습니다.
이 글에서는 단순히 "이렇게 하면 됩니다"가 아니라,
구현하면서 부딪힌 문제들과 이 기술이 어떤 원리로 동작하는지를
정리해보겠습니다.
Universal Link 이전에는 myapp:// 같은 Custom URL Scheme을 사용해서 앱을 열었습니다.
하지만 이 방식에는 근본적인 문제가 있습니다.
myapp://이라는 프로토콜을 모르기 때문에 아무 일도 일어나지 않거나 에러 페이지가 뜹니다.myapp://이라는 스킴을 다른 앱도 등록할 수 있어서 보안에 취약합니다.Universal Link는 https://~~~.com/some-page처럼 일반적인 웹 URL 자체가 앱을 여는 링크가 됩니다.
핵심 원리는 이렇습니다:
Associated Domains 설정을 확인합니다./.well-known/apple-app-site-association (AASA) 파일을 Apple의 CDN이 가져갑니다.앱이 설치되지 않았다면? 그냥 평소처럼 웹 브라우저에서 해당 URL이 열립니다.
fallback이 자연스럽게 내장되어 있는 것이죠.

가장 먼저 해야 하는 것은 웹 서버에 apple-app-site-association 파일을 배포하는 것입니다.
https://~~~.com/.well-known/apple-app-site-association
{
"applinks": {
"apps": [],
"details": [
{
"appID": "<TEAM_ID>.com.~~~.~~~",
"paths": ["*"]
}
]
}
}
여기서 중요한 점들:
Content-Type은 application/json이어야 합니다.
앱 쪽에서는 MyApp.entitlements 파일에 Associated Domains를 등록했습니다.
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:~~.com</string>
</array>
applinks: 접두사가 "이 도메인의 Universal Link를 처리하겠다"는 선언입니다.
여기가 가장 까다로웠습니다.
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이 들어오는지부터 파악해야 합니다.
// 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와 구분하기 위해서죠.
// 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 이벤트를 리스닝하는 것과 비슷합니다.
// 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.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와 동일 흐름)
AASA 파일을 수정했는데 변경이 반영되지 않는 문제가 있었습니다.
Apple은 AASA 파일을 자체 CDN을 통해 캐싱합니다.
앱이 설치될 때(또는 업데이트될 때) Apple CDN에서 가져가는 구조이기 때문에,
서버에서 파일을 수정하더라도 즉시 반영되지 않습니다.
해결: 개발 중에는 ?mode=developer를 붙여서 Associated Domain을 등록하면,
Apple CDN을 거치지 않고 직접 서버에서 AASA를 가져옵니다.
<string>applinks:~~~.com?mode=developer</string>
이건 개발 시에만 사용하고, 프로덕션 빌드에서는 제거해야 합니다.
로컬이나 스테이징 환경에서 Universal Link를 테스트하려면 HTTPS가 적용된 실제 도메인이 필요합니다.
개발 중에 Cloudflare Tunnel 같은 도구를 사용해서 임시 도메인을 만들어 테스트했습니다.
// 개발 중 임시로 사용한 터널 URL
private static let STAGING_URL = "https://my-tunnel-url.trycloudflare.com"
하지만 터널 URL은 매번 바뀌기 때문에, Entitlements와 AASA 파일을 동시에 업데이트해야 하는 번거로움이 있었습니다.
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에서도 확인할 수 있는 실제 변경사항입니다.
사소해 보이지만, 이 순서가 잘못되면 딥링크가 아예 무시되는 현상이 발생했습니다.
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은 "브라우저가 리소스를 요청하는 주소"입니다. 하지만 Universal Link에서 URL은 "운영체제가 앱을 라우팅하는 식별자"가 됩니다.
같은 https://example.com/page라는 URL이:
이 이중적 성격 때문에, 서버에서 제공하는 웹 페이지와 앱에서 보여주는 화면이 같은 URL로 접근 가능해야 합니다.
WebView 기반 앱에서는 이게 자연스럽게 해결되지만, 네이티브 앱이라면 URL별 화면 매핑을 직접 구현해야 합니다.
웹 앱은 탭을 닫으면 끝입니다. 하지만 iOS 앱은 종료, 백그라운드, 비활성, 활성 등 여러 상태를 오갑니다.
딥링크 구현에서 가장 핵심적인 배움은 이 생명주기에 따라 데이터 전달 방식이 달라진다는 것이었습니다.
웹으로 비유하면:
popstate 이벤트 수신 (이미 로드된 상태에서 라우트 변경)window.__INITIAL_DATA__ 같은 패턴)dispatchEvent / addEventListener)웹은 브라우저 주소창에 URL을 치면 바로 확인할 수 있습니다. 하지만 Universal Link는:
이 피드백 루프의 길이가 웹 개발과 가장 큰 차이였습니다.
프론트엔드 개발자로서 처음 iOS 네이티브 코드를 수정하는 건 분명 부담스러웠습니다.
Swift 문법도 익숙하지 않았고, Xcode도 낯설었습니다.
하지만 Universal Link를 구현하면서 깨달은 건, 결국 핵심 문제는 동일하다는 것입니다.
기술 스택이 달라져도 문제의 본질은 크게 다르지 않았습니다.
다만 그 본질에 도달하기 위해 플랫폼이 제공하는 생명주기, 보안 모델, 캐싱 정책 같은 컨텍스트를 이해해야 했고,
그 과정 자체가 성장이었다고 생각합니다.
링크 하나 클릭했을 때 앱이 열리는 그 단순한 경험 뒤에,
서버의 AASA 파일 → Apple CDN 캐싱 → iOS의 도메인 검증 → 앱 생명주기에 따른 분기 처리 → WebView 로드까지,
생각보다 많은 레이어가 있었습니다.
그리고 그 레이어 하나하나를 이해하는 과정이, "되게 만드는 것"과 "이해하고 만드는 것"의 차이를 만들어주었습니다.