iOS WKWebView에서 특정 도메인만 Custom User-Agent 예외 처리하기 — 카카오 로그인 이슈

WKWebView에 Custom User-Agent를 붙였더니 accounts.kakao.com이 모바일 환경을 인식 못 해 카카오 로그인이 깨졌다. decidePolicyFor에서 UA를 바꾸면 왜 늦는지, 요청을 취소하고 재발행해 어떻게 해결했는지 정리.

TL;DR

  • WKWebView에 Custom User-Agent(jiny_ios)를 붙였더니 accounts.kakao.com이 모바일을 인식 못 해 카카오 로그인이 깨졌다.
  • 해당 도메인만 원래 모바일 UA를 쓰도록 예외 처리하면 될 줄 알았는데, decidePolicyFor에서 UA를 바꾸면 현재 요청엔 이미 늦는다는 게 진짜 함정이었다.
  • 해결: (1) 앱이 직접 로드할 땐 load 전에 UA를 먼저 세팅하고, (2) navigation 도중 UA가 달라져야 하면 현재 요청을 취소하고 같은 요청을 재발행한다. 단, UA가 실제로 바뀐 경우에만 재발행해 무한 루프를 막는다.

배경: 왜 Custom User-Agent를 붙였나

앱에서 WKWebView를 쓸 때, 서비스 식별이나 서버 분기를 위해 Custom User-Agent를 붙이는 경우가 있다. 예를 들어 iOS 기본 WebView User-Agent 뒤에 jiny_ios 같은 문자열을 덧붙이는 식이다. 서버는 이 문자열을 보고 “우리 앱 WebView에서 들어온 요청”임을 구분한다.

대부분의 웹 페이지에서는 문제가 없다. 하지만 일부 외부 인증 서비스는 User-Agent 문자열을 보고 모바일 환경인지 판단한다. 우리가 덧붙인 jiny_ios 때문에 카카오가 이 요청을 “모바일이 아닌 무언가”로 보면서 로그인 플로우가 기대대로 동작하지 않았다.

그래서 카카오 로그인 도메인인 accounts.kakao.com에서는 Custom UA를 붙이지 않고, 원래 iOS 모바일 User-Agent를 그대로 쓰도록 예외 처리를 추가하기로 했다.

문제: 카카오 로그인이 모바일을 인식 못 함

기본 동작은 이랬다.

  • 모든 웹 요청에 jiny_ios가 붙는다.
  • 일부 하드코딩된 URL만 원래 User-Agent를 쓴다.

문제는 카카오 로그인 페이지인 accounts.kakao.com이 그 예외 목록에 없었다는 점이다. 그래서 로그인 페이지에도 jiny_ios가 붙어 나갔고, 카카오는 모바일 환경으로 처리하지 않았다.

1차 시도: 예외 도메인 추가

처음엔 단순히 accounts.kakao.com을 예외 목록에 넣으면 끝일 줄 알았다.

let defaultExceptHosts = ["pf.kakao.com", "accounts.kakao.com"]
let host = url.host?.lowercased()

let isDefaultExceptHost = defaultExceptHosts.contains { exceptHost in
    host == exceptHost || host?.hasSuffix(".\(exceptHost)") == true
}

이러면 accounts.kakao.com이나 그 하위 도메인에 접근할 때 원래 UA를 쓰도록 만들 수 있다. 그런데 예외 목록에 넣었는데도 첫 요청은 여전히 jiny_ios로 나가는 현상이 남았다. 여기서 진짜 원인을 만났다.

진짜 원인: decidePolicyFor에서 UA를 바꾸면 이미 늦다

처음 예외 처리는 WKNavigationDelegatedecidePolicyFor navigationAction 안에서 하고 있었다.

func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationAction: WKNavigationAction,
    decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
    if let url = navigationAction.request.url {
        configureUserAgentForURL(webView, url)
        decisionHandler(.allow)
    }
}

겉보기엔 자연스럽다. 요청 URL을 보고, 거기에 맞는 UA를 설정한 뒤, 요청을 허용한다.

하지만 이 시점은 이미 늦다. decidePolicyFor navigationAction이 호출될 때는 이미 그 navigation request가 만들어진 상태다. navigationAction.request는 “이미 이 헤더로 나가기로 결정된” 요청이고, 이 안에서 webView.customUserAgent를 바꿔도 진행 중인 요청이 새 UA를 쓴다는 보장이 없다. customUserAgent는 보통 “다음 요청을 만들 때” 반영되기 때문이다.

그래서 실제로는 이런 순서가 된다.

  1. 사용자가 카카오 로그인 버튼을 누른다.
  2. accounts.kakao.com 요청이 생성된다. (이때 UA는 아직 jiny_ios)
  3. decidePolicyFor가 호출된다.
  4. 여기서 customUserAgent를 원래 UA로 바꾼다.
  5. 하지만 현재 요청은 이미 jiny_ios가 붙은 채로 나간다.
  6. 카카오는 모바일로 인식하지 못한다.

예외 처리는 됐지만 “한 박자 늦게” 적용되는 셈이다. 바뀐 UA는 그다음 요청부터 적용된다.

해결 방향

두 갈래로 나눠서 처리했다.

  1. 앱에서 직접 URL을 로드하는 경우 → 로드 전에 UA를 먼저 설정한다.
  2. 클릭/리다이렉트처럼 navigation 도중 URL이 바뀌는 경우 → UA 변경이 필요하면 현재 요청을 취소하고 같은 요청을 다시 로드한다.

해결 1: 로드 전에 URL별 User-Agent 설정

앱 내부에서 URL을 로드하는 공통 함수가 있었다.

func loadURLWithDiskCookies(_ url: URL, in webView: WKWebView? = nil) {
    let targetWebView = webView ?? currentWebView

    guard let domain = cookieDomain(for: url) else {
        targetWebView.load(URLRequest(url: url))
        return
    }

    targetWebView.loadDiskCookies(for: domain) {
        targetWebView.load(URLRequest(url: url))
    }
}

여기에 로드 직전 UA 설정 한 줄을 추가했다.

func loadURLWithDiskCookies(_ url: URL, in webView: WKWebView? = nil) {
    let targetWebView = webView ?? currentWebView
    updateUserAgentIfNeeded(targetWebView, url)   // ← 요청이 만들어지기 전에 UA 확정

    guard let domain = cookieDomain(for: url) else {
        targetWebView.load(URLRequest(url: url))
        return
    }

    targetWebView.loadDiskCookies(for: domain) {
        targetWebView.load(URLRequest(url: url))
    }
}

이제 앱이 직접 로드하는 URL은 요청이 만들어지기 전에 알맞은 UA가 먼저 세팅된다. 첫 요청부터 올바른 UA로 나간다.

해결 2: navigation 도중 UA 변경이 필요하면 요청 재발행

문제는 사용자가 페이지 안에서 링크를 누르거나, 서버가 리다이렉트를 걸 때다. 이때는 앱의 로드 함수를 거치지 않고 WebView가 알아서 요청을 만든다. 그래서 decidePolicyFor에서는 UA를 변경만 하고 바로 .allow 하지 않도록 바꿨다.

현재 URL에 필요한 UA와 WebView에 설정된 UA가 다르면, 현재 요청을 취소(.cancel)하고 같은 request를 다시 로드한다.

if updateUserAgentIfNeeded(webView, for: url) {
    webView.load(navigationAction.request)  // 새 UA 상태로 같은 요청 재발행
    decisionHandler(.cancel)                // 기존(잘못된 UA) 요청은 취소
    return
}

decisionHandler(.allow)

순서가 핵심이다. updateUserAgentIfNeeded먼저 customUserAgent를 바꾸고 true를 반환하면, 그 상태에서 load(navigationAction.request)로 같은 요청을 새로 만든다. 새로 만들어진 요청은 이제 올바른 UA를 쓴다. 마지막으로 원래 요청은 .cancel로 버린다.

이렇게 하면 accounts.kakao.com으로 이동하는 순간, 카카오 로그인 요청이 첫 요청부터 원래 모바일 UA로 나간다.

User-Agent 결정 로직 정리

최종적으로 UA 관련 로직은 “바꿔야 하나?”와 “어떤 UA여야 하나?”를 분리했다.

func updateUserAgentIfNeeded(_ webView: WKWebView, _ url: URL) -> Bool {
    updateUserAgentIfNeeded(webView, for: url)
}

func updateUserAgentIfNeeded(_ webView: WKWebView, for url: URL) -> Bool {
    let userAgent = userAgent(for: url)
    guard webView.customUserAgent != userAgent else { return false }

    webView.customUserAgent = userAgent
    return true
}

updateUserAgentIfNeededUA가 실제로 바뀌었는지를 Bool로 반환한다.

  • false: 이미 맞는 UA가 설정돼 있음 → 그대로 요청 허용
  • true: UA가 변경됨 → 현재 요청 취소 후 재로드 필요

이 반환값이 뒤에서 무한 루프를 막는 열쇠가 된다.

URL별 UA 결정은 별도 함수로 뺐다.

private func userAgent(for url: URL) -> String {
    let originalUserAgent = resolvedOriginalUserAgent()
    let exceptList = ConfigManager.shared.config?.customAgent.exceptList ?? []
    let defaultExceptHosts = ["pf.kakao.com", "accounts.kakao.com"]
    let host = url.host?.lowercased()

    let isDefaultExceptHost = defaultExceptHosts.contains { exceptHost in
        host == exceptHost || host?.hasSuffix(".\(exceptHost)") == true
    }

    if isDefaultExceptHost || exceptList.contains(where: url.absoluteString.contains) {
        return originalUserAgent
    }

    let customUserAgent = ConfigManager.shared.config?.customAgent.userAgent ?? ""
    return "\(originalUserAgent)\(customUserAgent != "" ? " " + customUserAgent : customUserAgent)"
}

여기서 중요한 건 accounts.kakao.com단순 문자열 포함이 아니라 url.host 기준으로 검사했다는 점이다.

host == exceptHost || host?.hasSuffix(".\(exceptHost)") == true

이렇게 하면

  • accounts.kakao.com
  • sub.accounts.kakao.com

는 잡으면서, path나 query에 우연히 accounts.kakao.com 문자열이 들어간 URL(예: https://example.com/?next=accounts.kakao.com)까지 예외 처리되는 사고는 막을 수 있다. 인증 도메인 예외에서 호스트 기준 검사는 그냥 깔끔함의 문제가 아니라 보안/정확성 문제다.

함정: originalUserAgent가 아직 준비 안 됐을 수 있다

또 하나의 함정. 예외 도메인에서 돌려줄 “원래 UA”가 정작 아직 비어 있을 수 있다.

기존엔 초기 로드가 끝난 뒤에야 originalUserAgent를 세팅하고 있었다.

self.originalUserAgent = UserAgentModifier.modifyUserAgent(
    originalUA: WKWebView().value(forKey: "userAgent") as! String
)

참고: iOS에는 “기본 User-Agent를 직접 돌려주는” 공식 API가 없어서, 임시 WKWebView를 만들어 value(forKey: "userAgent")(KVC)로 읽어오는 우회법을 흔히 쓴다.

문제는 초기 로드나 빠른 리다이렉트에서는 이 값이 아직 비어 있을 수 있다는 것. 그래서 UA가 필요한 시점에 비어 있으면 그 자리에서 즉시 계산하도록 보강했다.

private func resolvedOriginalUserAgent() -> String {
    if originalUserAgent.isEmpty,
       let userAgent = WKWebView().value(forKey: "userAgent") as? String {
        originalUserAgent = UserAgentModifier.modifyUserAgent(originalUA: userAgent)
    }

    return originalUserAgent
}

이러면 초기 로드, 딥링크 로드, 리다이렉트 등 어떤 진입점에서도 원래 UA를 안정적으로 쓸 수 있다. (KVC value(forKey:)로 강제 언래핑하던 as!도 옵셔널 바인딩으로 바꿔 크래시 여지를 없앴다.)

주의: 무한 재로드 막기

이 방식에서 제일 위험한 부분은 무한 재로드다. decidePolicyFor에서 매번 webView.load(navigationAction.request)를 호출하면, 그 재로드가 다시 decidePolicyFor를 부르고, 또 재로드하고… 루프가 생긴다.

그래서 UA가 실제로 바뀐 경우에만 취소+재로드해야 한다. 앞의 guard가 바로 그 안전장치다.

guard webView.customUserAgent != userAgent else { return false }

이미 맞는 UA라면 false를 반환하고, 재로드 없이 기존 요청을 그대로 .allow 한다. 재발행된 요청이 두 번째로 decidePolicyFor에 들어올 땐 이미 UA가 맞춰져 있으니 false.allow로 정상 통과한다.

정리

이번 이슈의 핵심은 “특정 도메인을 UA 예외 처리한다”가 아니었다. 진짜 핵심은 **“그 예외 처리가 요청이 만들어지기 전에 적용돼야 한다”**는 점이었다.

WKWebView에서 customUserAgent를 바꾸는 시점은 생각보다 중요하다. decidePolicyFor 안에서 UA를 바꾸고 바로 .allow 하면, 현재 요청엔 그 값이 반영되지 않을 수 있다.

체크리스트로 정리하면:

  1. 앱에서 직접 URL을 로드할 때는 load 전에 UA를 먼저 설정한다.
  2. navigation 도중 URL이 바뀌었고 UA 변경이 필요하면 현재 요청을 취소한다.
  3. 변경된 UA 상태에서 같은 요청을 다시 로드한다.
  4. 무한 루프를 막기 위해 UA가 실제로 바뀐 경우에만 재로드한다.
  5. 외부 인증 도메인은 설정 기반 예외 + 코드 레벨 기본 예외를 함께 두고, url.host 기준으로 검사한다.

이 수정으로 accounts.kakao.com은 첫 요청부터 원래 iOS 모바일 User-Agent를 쓰게 됐고, 카카오 로그인 페이지가 모바일 환경을 정상적으로 인식하게 됐다.