웹앱 로그인 유지 트러블 슈팅
SwiftUI WebView 앱에서 쿠키 기반 로그인 유지하기
WKWebView의 쿠키가 사라지는 문제를 UserDefaults를 활용해 해결한 트러블슈팅 기록
문제 상황
SwiftUI로 웹뷰 기반 앱을 만들었다. 웹에서 로그인하면 정상적으로 동작하지만, 앱이 백그라운드로 갔다가 돌아오면 로그인이 풀려 있었다. 심한 경우 앱이 메모리에서 완전히 해제되면서 웹뷰가 재생성되고, 이때 쿠키가 모두 날아가는 현상이 반복됐다.
이 글에서는 이 문제의 원인을 분석하고, WKHTTPCookieStore에서 쿠키를 추출해 UserDefaults에 저장하는 방식으로 로그인 상태를 유지한 과정을 정리한다. 관련된 쿠키의 개념, iOS WebView의 쿠키 관리 구조, 그리고 구현 중 마주친 이슈들을 함께 다룬다.
쿠키(Cookie)의 기본 개념
쿠키란 무엇인가
쿠키는 웹 서버가 클라이언트(브라우저)에 저장하도록 요청하는 작은 텍스트 데이터다. HTTP는 본래 무상태(stateless) 프로토콜이기 때문에, 서버는 각 요청이 어떤 사용자에게서 온 것인지 알 수 없다. 쿠키는 이 한계를 보완하여 서버가 클라이언트를 식별하고 상태를 유지할 수 있게 해준다.
서버가 HTTP 응답 헤더에 Set-Cookie를 포함하면, 클라이언트는 해당 쿠키를 저장하고 이후 같은 도메인으로 요청을 보낼 때마다 Cookie 헤더에 포함시킨다.
쿠키의 주요 속성
쿠키는 단순한 key-value 쌍이 아니라 여러 속성을 가진다. 이 속성들을 이해해야 쿠키를 올바르게 저장하고 복원할 수 있다.
- name / value: 쿠키의 이름과 값. 예를 들어
session_id=abc123에서 name은session_id, value는abc123이다. - domain: 쿠키가 전송될 도메인.
.example.com으로 설정되면sub.example.com에도 전송된다. - path: 쿠키가 유효한 URL 경로.
/api로 설정되면/api/users에는 전송되지만/web에는 전송되지 않는다. - expires / max-age: 쿠키의 만료 시점. 지정하지 않으면 세션 쿠키(session cookie)가 되어 브라우저 종료 시 삭제된다. 지정하면 영속 쿠키(persistent cookie)가 되어 만료 시점까지 유지된다.
- secure: 이 플래그가 설정되면 HTTPS 연결에서만 쿠키가 전송된다.
- httpOnly: JavaScript에서
document.cookie로 접근할 수 없게 한다. XSS 공격 방어에 사용된다. - sameSite: 크로스 사이트 요청에서 쿠키 전송 여부를 제어한다.
Strict,Lax,None값을 가진다.
세션 쿠키 vs 영속 쿠키
로그인 유지 문제를 이해하려면 이 구분이 중요하다.
세션 쿠키는 expires가 설정되지 않은 쿠키다. 원래 “브라우저를 닫으면 삭제”되는 것이 표준 동작이지만, 모바일 앱에서는 “브라우저를 닫는다”는 개념이 명확하지 않다. iOS의 WKWebView에서 세션 쿠키는 웹뷰 프로세스가 종료되면 사라진다.
영속 쿠키는 expires나 max-age가 설정된 쿠키로, 디스크에 저장되어 만료 전까지 유지된다. WKWebView에서도 영속 쿠키는 앱이 재시작되어도 살아남을 수 있다. 단, 이것이 보장되지는 않는다.
iOS WebView의 쿠키 관리 구조
WKWebView와 쿠키 저장소
iOS에서 웹뷰를 구현할 때 사용하는 WKWebView는 Safari와 동일한 WebKit 엔진을 사용하지만, 쿠키 저장소는 독립적으로 관리된다. 쿠키 관련 핵심 객체들의 관계는 다음과 같다.
1
2
3
4
WKWebView
└── WKWebViewConfiguration
└── WKWebsiteDataStore
└── WKHTTPCookieStore ← 쿠키에 접근하는 진입점
WKWebViewConfiguration: 웹뷰의 설정을 담는 객체다. 여기에 연결된 WKWebsiteDataStore가 쿠키를 포함한 웹사이트 데이터의 저장소 역할을 한다.
WKWebsiteDataStore: 쿠키, 캐시, 로컬 스토리지 등 웹사이트 데이터를 관리한다. .default()를 사용하면 앱 전체에서 공유되는 기본 저장소를 사용하고, .nonPersistent()를 사용하면 메모리에만 존재하는 임시 저장소(시크릿 모드)를 생성한다. 로그인 유지가 목적이라면 반드시 .default()를 사용해야 한다.
WKHTTPCookieStore: WKWebsiteDataStore가 관리하는 쿠키에 프로그래밍 방식으로 접근할 수 있게 해주는 객체다. iOS 11에서 도입되었으며, 쿠키 조회, 추가, 삭제, 변경 감지를 지원한다.
HTTPCookie 객체
Swift에서 개별 쿠키는 HTTPCookie 클래스로 표현된다. 앞서 설명한 쿠키 속성들이 이 객체의 프로퍼티에 대응된다.
1
2
3
4
5
6
7
8
9
10
11
12
let cookie: HTTPCookie
cookie.name // String - 쿠키 이름
cookie.value // String - 쿠키 값
cookie.domain // String - 도메인
cookie.path // String - 경로
cookie.expiresDate // Date? - 만료 시점 (nil이면 세션 쿠키)
cookie.isSecure // Bool - Secure 플래그
cookie.isHTTPOnly // Bool - HttpOnly 플래그
// 쿠키의 모든 속성을 딕셔너리로 추출
cookie.properties // [HTTPCookiePropertyKey: Any]?
HTTPCookie는 properties 딕셔너리로부터 생성할 수 있고, 반대로 기존 쿠키에서 properties를 추출할 수도 있다. 이 특성이 쿠키 직렬화/역직렬화의 핵심이다.
URLSession의 HTTPCookieStorage와의 차이
iOS에는 쿠키 저장소가 두 종류 있다는 점에 주의해야 한다.
HTTPCookieStorage.shared:URLSession이 사용하는 쿠키 저장소. 네이티브 네트워크 요청에서 사용된다.WKHTTPCookieStore:WKWebView가 사용하는 쿠키 저장소. 웹뷰 내부의 HTTP 요청에서 사용된다.
이 둘은 서로 다른 저장소이므로 자동으로 동기화되지 않는다. 웹뷰의 로그인 쿠키를 다루려면 반드시 WKHTTPCookieStore를 사용해야 한다.
쿠키가 사라지는 원인 분석
왜 백그라운드 진입 후 로그인이 풀리는가
WKWebView는 앱의 메인 프로세스와 별도의 웹 콘텐츠 프로세스에서 동작한다. 이 구조에서 쿠키 소실이 발생하는 주요 시나리오는 다음과 같다.
1. 웹 콘텐츠 프로세스 종료: iOS는 메모리 압박 상황에서 백그라운드에 있는 앱의 웹 콘텐츠 프로세스를 종료할 수 있다. 이때 세션 쿠키는 소실된다. WKNavigationDelegate의 webViewWebContentProcessDidTerminate(_:) 메서드가 호출되면 이 상황이 발생한 것이다.
2. WKWebView 인스턴스 재생성: SwiftUI의 뷰 라이프사이클 특성상, 상태 관리를 제대로 하지 않으면 UIViewRepresentable로 감싼 WKWebView가 뷰 업데이트 시 재생성될 수 있다. 새 인스턴스가 같은 WKWebsiteDataStore.default()를 사용한다면 영속 쿠키는 유지되지만, 세션 쿠키는 잃게 된다.
3. 세션 쿠키의 본질적 한계: 많은 웹 서비스가 로그인 토큰을 세션 쿠키로 발급한다. 서버 측에서 expires를 설정하지 않은 경우, 앱 개발자가 직접 이 쿠키를 영속적으로 보관하는 로직을 추가해야 한다.
해결 방법: UserDefaults를 이용한 쿠키 영속화
전체 흐름
1
2
3
4
5
6
7
8
9
10
[앱 → 백그라운드]
WKHTTPCookieStore에서 쿠키 조회
→ HTTPCookie를 딕셔너리로 변환
→ UserDefaults에 저장
[앱 → 포그라운드]
UserDefaults에서 쿠키 데이터 로드
→ HTTPCookie 객체 복원
→ WKHTTPCookieStore에 삽입
→ 웹뷰 리로드
구현
1. WebView를 UIViewRepresentable로 감싸기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import SwiftUI
import WebKit
struct WebView: UIViewRepresentable {
let url: URL
@Binding var webView: WKWebView?
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
// 기본 데이터 스토어를 사용해야 영속 쿠키가 유지됨
configuration.websiteDataStore = .default()
let wkWebView = WKWebView(frame: .zero, configuration: configuration)
wkWebView.navigationDelegate = context.coordinator
DispatchQueue.main.async {
self.webView = wkWebView
}
// 저장된 쿠키 복원 후 페이지 로드
CookieManager.shared.restoreCookies(to: wkWebView) {
wkWebView.load(URLRequest(url: self.url))
}
return wkWebView
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, WKNavigationDelegate {
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
// 웹 프로세스가 종료된 경우 쿠키 복원 후 리로드
CookieManager.shared.restoreCookies(to: webView) {
webView.reload()
}
}
}
}
2. CookieManager 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import WebKit
final class CookieManager {
static let shared = CookieManager()
private let cookieKey = "savedWebViewCookies"
private init() {}
// MARK: - 쿠키 저장
func saveCookies(from webView: WKWebView, completion: (() -> Void)? = nil) {
let cookieStore = webView.configuration.websiteDataStore.httpCookieStore
cookieStore.getAllCookies { cookies in
// 로그인 관련 쿠키만 필터링 (선택사항)
let targetCookies = cookies.filter { cookie in
// 필요한 도메인의 쿠키만 저장하거나, 전체를 저장
// cookie.domain.contains("example.com")
true
}
let cookieDicts: [[String: Any]] = targetCookies.compactMap { cookie in
// HTTPCookie의 properties를 딕셔너리로 변환
guard let properties = cookie.properties else { return nil }
// properties의 키를 String으로 변환하여 직렬화 가능하게 만듦
var dict: [String: Any] = [:]
for (key, value) in properties {
// Date는 TimeInterval로 변환
if let date = value as? Date {
dict[key.rawValue] = date.timeIntervalSince1970
} else {
dict[key.rawValue] = value
}
}
return dict
}
UserDefaults.standard.set(cookieDicts, forKey: self.cookieKey)
completion?()
}
}
// MARK: - 쿠키 복원
func restoreCookies(to webView: WKWebView, completion: @escaping () -> Void) {
guard let cookieDicts = UserDefaults.standard.array(forKey: cookieKey)
as? [[String: Any]] else {
completion()
return
}
let cookieStore = webView.configuration.websiteDataStore.httpCookieStore
let dispatchGroup = DispatchGroup()
for dict in cookieDicts {
// String 키를 HTTPCookiePropertyKey로 변환
var properties: [HTTPCookiePropertyKey: Any] = [:]
for (key, value) in dict {
let propertyKey = HTTPCookiePropertyKey(key)
// Expires 필드는 Date로 복원
if propertyKey == .expires, let timestamp = value as? TimeInterval {
properties[propertyKey] = Date(timeIntervalSince1970: timestamp)
} else {
properties[propertyKey] = value
}
}
if let cookie = HTTPCookie(properties: properties) {
dispatchGroup.enter()
cookieStore.setCookie(cookie) {
dispatchGroup.leave()
}
}
}
dispatchGroup.notify(queue: .main) {
completion()
}
}
// MARK: - 저장된 쿠키 삭제 (로그아웃 시)
func clearSavedCookies() {
UserDefaults.standard.removeObject(forKey: cookieKey)
}
}
3. 앱 라이프사이클에 연결
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import SwiftUI
@main
struct MyApp: App {
@State private var webView: WKWebView?
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
WebView(
url: URL(string: "https://example.com")!,
webView: $webView
)
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
case .background:
// 백그라운드 진입 시 쿠키 저장
if let webView = webView {
CookieManager.shared.saveCookies(from: webView)
}
case .active:
// 포그라운드 복귀 시 쿠키 복원
if let webView = webView {
CookieManager.shared.restoreCookies(to: webView) {
// 필요시 리로드
// webView.reload()
}
}
default:
break
}
}
}
}
}
트러블슈팅: 구현 중 마주친 이슈들
1. getAllCookies의 비동기 타이밍 문제
WKHTTPCookieStore.getAllCookies는 비동기로 동작한다. 앱이 백그라운드로 전환될 때 이 비동기 작업이 완료되기 전에 앱이 suspend 상태에 들어가면 쿠키가 저장되지 않을 수 있다.
해결: scenePhase가 .inactive로 변경되는 시점에도 저장을 시도하거나, UIApplication의 beginBackgroundTask를 사용하여 백그라운드에서 작업 완료 시간을 확보한다.
1
2
3
4
5
6
7
8
9
10
case .background:
if let webView = webView {
var backgroundTask: UIBackgroundTaskIdentifier = .invalid
backgroundTask = UIApplication.shared.beginBackgroundTask {
UIApplication.shared.endBackgroundTask(backgroundTask)
}
CookieManager.shared.saveCookies(from: webView) {
UIApplication.shared.endBackgroundTask(backgroundTask)
}
}
2. HTTPCookie properties 직렬화 실패
HTTPCookie.properties에 포함된 값 중 일부는 UserDefaults에 직접 저장할 수 없는 타입일 수 있다. 특히 Date 객체는 PropertyList 직렬화에서 문제를 일으키기도 한다.
해결: Date를 TimeInterval(Double)로 변환하여 저장하고, 복원 시 다시 Date로 변환한다. 위 코드에서 이미 이 처리가 포함되어 있다.
3. 쿠키 복원 후에도 로그인이 풀리는 경우
쿠키를 정상적으로 복원했는데도 웹 서버가 로그인 상태를 인식하지 못하는 경우가 있었다.
원인 1 — 세션 쿠키의 expires 누락: 원래 세션 쿠키였던 것을 저장/복원하면 expires가 nil인 상태로 복원된다. 일부 WKWebView 버전에서 이런 쿠키가 즉시 만료되는 것처럼 동작하는 경우가 있다.
해결: 세션 쿠키를 저장할 때 임의의 만료 시점(예: 현재로부터 24시간 후)을 부여하여 영속 쿠키로 변환한다.
1
2
3
4
5
6
// 세션 쿠키에 만료 시점을 부여
if cookie.expiresDate == nil {
var props = properties
props[.expires] = Date().addingTimeInterval(86400) // 24시간
properties = props
}
원인 2 — httpOnly 쿠키 접근 제한: httpOnly 플래그가 설정된 쿠키는 JavaScript에서 접근할 수 없지만, WKHTTPCookieStore API를 통해서는 접근할 수 있다. 이 부분은 네이티브 API를 사용하는 한 문제가 되지 않는다.
원인 3 — 쿠키 삽입 시점과 페이지 로드 순서: 쿠키를 setCookie로 삽입하는 것은 비동기 작업이다. 모든 쿠키 삽입이 완료되기 전에 페이지를 로드하면 서버가 쿠키를 받지 못한다.
해결: DispatchGroup을 사용하여 모든 쿠키 삽입이 완료된 후에 페이지를 로드한다. 위 restoreCookies 구현에서 이 패턴을 사용하고 있다.
4. SwiftUI에서 WKWebView 인스턴스가 재생성되는 문제
SwiftUI의 UIViewRepresentable은 뷰가 다시 그려질 때 makeUIView가 재호출될 수 있다. 이때 새 WKWebView가 생성되면 메모리의 세션 쿠키가 사라진다.
해결: @StateObject를 사용하여 WKWebView 인스턴스를 SwiftUI의 상태로 관리하거나, WKWebViewConfiguration을 싱글톤으로 공유하여 같은 WKWebsiteDataStore를 사용하도록 보장한다.
1
2
3
4
5
6
7
8
9
class WebViewStore: ObservableObject {
let webView: WKWebView
init() {
let config = WKWebViewConfiguration()
config.websiteDataStore = .default()
self.webView = WKWebView(frame: .zero, configuration: config)
}
}
5. 보안 고려사항
UserDefaults는 암호화되지 않은 plist 파일에 데이터를 저장한다. 로그인 세션 토큰을 여기에 저장하는 것은 보안 관점에서 이상적이지 않다.
권장 대안: 민감한 쿠키는 Keychain에 저장하는 것이 더 안전하다. KeychainAccess 같은 라이브러리를 사용하거나 Security 프레임워크를 직접 사용할 수 있다. 다만 Keychain은 동기 API이고 데이터 크기 제한이 있으므로, 저장할 쿠키의 양과 보안 요구 수준을 고려하여 선택한다.
정리
| 항목 | 내용 |
|---|---|
| 문제 | WKWebView가 백그라운드에서 종료되면 세션 쿠키 소실 |
| 원인 | 웹 콘텐츠 프로세스 종료 시 메모리 기반 세션 쿠키가 함께 삭제됨 |
| 해결 | WKHTTPCookieStore → UserDefaults 저장 → 복원 → WKHTTPCookieStore 삽입 |
| 핵심 API | WKHTTPCookieStore.getAllCookies, HTTPCookie.properties, setCookie |
| 주의사항 | 비동기 타이밍, 세션 쿠키 만료 처리, 보안(Keychain 고려) |
이 접근 방식은 서버 측 변경 없이 클라이언트에서만 로그인 유지를 구현할 수 있다는 점이 가장 큰 장점이다. 서버에서 토큰 갱신 API를 제공하거나, OAuth refresh token을 지원하는 경우에는 쿠키 영속화 대신 네이티브 토큰 관리 방식을 사용하는 것이 더 깔끔한 해결책일 수 있다. 상황에 맞는 방법을 선택하면 된다.