UserDefaults는 set 한 줄로 끝나는 만만한 API처럼 보이지만, 그 뒤에는 인메모리 캐시와 데몬, 그리고 진짜 plist 파일이 있다. 이 챕터는 그 실체를 직접 눈으로 확인한다 — 값을 쓰고, 디스크의 plist를 찾아 덤프하고, synchronize()를 부르든 안 부르든 다른 프로세스에서 값이 그대로 보이는지 실측한다. 마지막엔 App Group 공유가 정확히 무엇을 바꾸는지, 그리고 UserDefaults·Keychain·파일·SwiftData 중 무엇을 써야 하는지 결정 기준까지 정리한다.
Q1. UserDefaults의 실체는 무엇이고, 등록 도메인(registration domain)은 왜 필요한가?
UserDefaults는 인메모리 캐시 위에 cfprefsd 데몬이 관리하는 진짜 property list 파일이 뒤에 있는 저장소다. set은 메모리를 즉시 갱신하고 디스크 반영은 비동기로 미룬다. 담을 수 있는 값은 plist가 표현 가능한 타입(String·Number·Bool·Date·Data·URL·Array·Dictionary)뿐이라 커스텀 타입은 먼저 Data로 아카이브해야 한다. 값을 찾는 검색 도메인은 argument → application → global → language → registration 순서이고, register(defaults:)가 채우는 등록 도메인은 그 맨 아래 폴백이자 휘발성이라 앱을 켤 때마다 다시 등록해야 한다.
원리
Apple 문서는 UserDefaults를 "앱별·시스템 전역 설정을 위한 영속 저장소"로 정의하고, "기본값을 쓸 때 객체는 인메모리 버전을 즉시 갱신하고, 값은 비동기로 디스크에 쓴다"고 명시한다. 같은 문서는 "defaults 데이터베이스의 파일에 파일 시스템에서 직접 접근하지 말라 — 직접 수정하면 데이터 손실, 반영 지연, 크래시가 날 수 있다. macOS에서는 defaults 커맨드라인 유틸리티를 쓰라"고 경고한다. 즉 UserDefaults는 "그냥 파일 읽고 쓰기"가 아니라, 그 파일을 대신 관리해주는 데몬(cfprefsd)과 프로세스 인메모리 캐시가 앞단에 있는 시스템이다.
내부 동작
register(defaults:) 문서는 "이 메서드가 넘긴 키-값 쌍을 등록 도메인(registration domain)에 할당하며, 등록 도메인은 보통 검색 리스트의 마지막 도메인"이라고 밝힌다. "등록 도메인은 휘발성이라 앱이 실행될 때마다 기본값 세트를 다시 등록해야 한다"는 문장이 핵심이다 — 이 도메인은 디스크에 쓰이지 않고 그 프로세스가 살아있는 동안만 메모리에 존재한다. 실제로 확인해보자.
let suiteName = "com.example.app.demo"
let defaults = UserDefaults(suiteName: suiteName)!
// 1) 등록 도메인 — 다른 도메인에 값이 없을 때만 쓰이는 최하위 폴백
defaults.register(defaults: ["theme": "light", "fontSize": 14])
print(defaults.string(forKey: "theme") ?? "nil") // "light" — 등록 도메인 값
// 2) 실제 set은 앱(application) 도메인에 값을 쓰고, 등록 도메인보다 우선한다
defaults.set("dark", forKey: "theme")
print(defaults.string(forKey: "theme") ?? "nil") // "dark"
// 3) fontSize는 한 번도 set 안 했으므로 여전히 등록 도메인 값
print(defaults.integer(forKey: "fontSize")) // 14이 스크립트를 swift 파일명.swift로 실행한 뒤, macOS에서 실제 백킹 plist를 찾아 덤프하면 등록 도메인이 정말 디스크에 안 쓰였는지 눈으로 확인할 수 있다.
$ find ~/Library/Preferences -iname "*com.example.app.demo*"
/Users/you/Library/Preferences/com.example.app.demo.plist
$ file ~/Library/Preferences/com.example.app.demo.plist
.../com.example.app.demo.plist: Apple binary property list
$ plutil -p ~/Library/Preferences/com.example.app.demo.plist
{
"theme" => "dark"
}
# fontSize는 등록 도메인에만 있던 값이라 plist에 없다 — register(defaults:)가
# "휘발성"이라는 게 바로 이 뜻이다실제로 이 실험을 이 챕터를 쓰며 com.doubleonstudyhub.sf26.scratch라는 격리된 스크래치 도메인에서 그대로 돌려봤고, defaults read·plutil -p 결과 모두 theme와 (Date·Data로 같이 넣은) lastOpened·blob만 있었을 뿐 fontSize는 존재하지 않았다. 확인 후 도메인과 plist 파일은 바로 삭제했다.
실험 · 도구
위 q1.swift를 실제로 실행하고 macOS의 defaults·plutil 명령으로 백킹 파일을 그대로 열어보면, "UserDefaults가 정말 plist냐"는 질문에 코드가 아니라 파일 자체로 답할 수 있다. defaults read <domain>은 cfprefsd를 거쳐 읽고, plutil -p <path>는 디스크의 원본 바이너리를 직접 파싱해 보여준다 — 이 챕터를 쓰며 두 경로 모두로 같은 값을 확인했다. 실무에서 새 키가 기대한 값으로 안 읽힐 때는 defaults find <key>로 그 키가 실제 어느 도메인에 들어가 있는지부터 확인하면 디버깅이 빨라진다.
프로젝트 적용
① 등록 도메인은 휘발성이므로 앱 시작 시점(App.init 또는 AppDelegate)에 한 번, 매 실행마다 등록해야 한다. 이미 사용자가 바꾼 값은 앱 도메인이 우선하므로 register(defaults:)를 여러 번 불러도 사용자 설정을 덮어쓰지 않는다.
// App.init() 또는 AppDelegate의 applicationDidFinishLaunching 초반에서 한 번
UserDefaults.standard.register(defaults: [
"autoPlayEnabled": true,
"preferredFeedSort": "latest"
])
// 사용자가 이미 autoPlayEnabled를 false로 바꿔놨다면, 이 register 호출은
// 그 값을 덮어쓰지 않는다 — 앱 도메인이 등록 도메인보다 우선하기 때문이다② plist가 표현 못 하는 커스텀 타입은 Codable로 Data 아카이브해서 넣는다.
struct RecentSearch: Codable {
let query: String
let searchedAt: Date
}
func saveRecent(_ items: [RecentSearch]) {
// JSONEncoder로 Data로 바꿔야만 UserDefaults가 받아준다 — 구조체 그대로는 안 됨
guard let data = try? JSONEncoder().encode(items) else { return }
UserDefaults.standard.set(data, forKey: "recentSearches")
}
func loadRecent() -> [RecentSearch] {
guard let data = UserDefaults.standard.data(forKey: "recentSearches"),
let items = try? JSONDecoder().decode([RecentSearch].self, from: data) else { return [] }
return items
}"UserDefaults.standard 자체가 그 plist 파일이다"는 정확하지 않다. 실제로는 인메모리 캐시 + cfprefsd 데몬이 그 파일 앞을 지키고 있고, set 직후 get이 바로 새 값을 돌려주는 건 파일을 다시 읽어서가 아니라 메모리 캐시가 이미 갱신됐기 때문이다. "register(defaults:)로 등록한 기본값도 결국 plist에 저장된다"도 틀렸다 — 위 실측처럼 등록 도메인 값은 검색에는 쓰이지만 디스크에는 전혀 안 남는다.
UserDefaults는 내 방 서랍이다. 서랍 안에 옷(값)이 있으면 그걸 꺼내 입고, 서랍이 비어 있으면 문 안쪽에 붙여둔 "옷 없으면 이렇게 입어" 메모(등록 도메인)를 참고한다. 그런데 이 메모는 진짜 서랍 안 물건 목록에는 절대 안 적힌다 — 매일 아침 새로 방에 들어올 때마다(앱 실행마다) 그 메모를 다시 문에 붙여야 한다. 비유가 깨지는 곳: 진짜 메모는 한 번 붙이면 누가 떼기 전까진 계속 붙어 있지만, 등록 도메인은 프로세스가 끝나는 순간 정말로 통째로 사라진다 — 그래서 "재부팅해도 메모가 남아있겠지"라고 기대하면 틀린다.
꼬리 질문
register(defaults:)를 여러 번 다른 값으로 부르면 마지막 호출이 이기는가?
NSGlobalDomain에는 어떤 값이 들어있고 왜 앱 도메인보다 아래인가?
커스텀 타입을 Data로 아카이브하지 않고 그냥 set하면 무슨 일이 일어나는가?
Q2. synchronize()를 불러야 저장되는가? — 애플 공식 입장
아니다. iOS 12부터 synchronize()는 사실상 아무 일도 하지 않는 메서드이고, Apple 공식 문서는 "이 메서드는 불필요하며 쓰면 안 된다(unnecessary and shouldn't be used)"고 명시한다. 값은 set 순간 메모리에 즉시 반영되고, 디스크로의 실제 반영은 시스템이 알아서 적절한 시점에 처리한다. "synchronize를 안 부르면 값을 잃는다"는 잔존 미신을 따라 메인 스레드에서 이 메서드를 부르면, 실제로 이득은 없고 불필요한 블로킹 대기만 만든다.
원리
UserDefaults의 쓰기 경로는 "메모리는 동기, 디스크는 비동기"로 설계돼 있다. set 직후 같은 프로세스에서 get을 호출하면 항상 방금 쓴 값이 나오는데, 이는 파일을 다시 읽어서가 아니라 인메모리 캐시가 이미 그 값으로 갱신됐기 때문이다. 디스크 반영은 cfprefsd 데몬이 백그라운드에서 배치로 처리하며, 이 스케줄을 개발자가 직접 강제할 이유가 설계상 없다.
내부 동작
synchronize()가 실제로 하는 일은 문서에 정확히 적혀 있다 — "보류 중인 비동기 업데이트를 기다렸다가 반환한다." 즉 디스크 쓰기를 일으키는 게 아니라, 이미 진행 중인 비동기 쓰기 큐가 비워질 때까지 대기만 한다. 이 메서드는 반환값으로 Bool을 주지만("데이터가 디스크에 성공적으로 저장됐으면 true"), 호출하지 않아도 그 저장은 어차피 일어난다 — 그래서 "불필요하다"는 표현이 정확하다.
더 흥미로운 지점은, synchronize()가 반환됐다고 해서 디스크의 실제 파일이 그 순간 완전히 갱신됐다는 보장도 아니라는 것이다. 이 챕터를 쓰며 2MB짜리 Data 값을 set한 뒤 synchronize()까지 부르고 바로 백킹 plist 파일 크기를 확인했더니, 파일은 아직 원래 크기(수십 바이트)에 머물러 있었다. 2초쯤 지나서야 파일 크기가 2,000,093바이트로 실제 데이터를 반영했다. synchronize()가 "보류된 비동기 업데이트"를 기다린다는 것과, 그 업데이트가 실제로 이 특정 파일에 이 순간 다 쓰였다는 것은 별개다 — cfprefsd 자체의 배치/커밋 스케줄이 별도로 있다.
실험 · 도구
synchronize()를 부르든 안 부르든 값이 다음 프로세스에서 그대로 보이는지가 가장 직접적인 증거다. 두 개의 독립된 swift 프로세스로 확인했다.
// q2a-write.swift
let d = UserDefaults(suiteName: "com.example.app.demo")!
d.set("without-calling-synchronize", forKey: "proof")
// synchronize()를 절대 호출하지 않고 그냥 종료// q2b-read.swift, 프로세스 A가 완전히 끝난 뒤 별도 실행
let d = UserDefaults(suiteName: "com.example.app.demo")!
print(d.string(forKey: "proof") ?? "nil")
// 실제 출력: without-calling-synchronizeswift q2a-write.swift를 먼저 실행해 프로세스를 완전히 종료시킨 뒤 swift q2b-read.swift를 새로 실행하면, synchronize()를 한 번도 부르지 않았는데도 값이 그대로 읽힌다 — 시스템이 알아서 영속시켰다는 직접 증거다.
프로젝트 적용
① 코드베이스에 남아있는 synchronize() 호출은 대부분 제거해도 안전하다. 특히 메인 스레드에서 저장 직후 부르는 패턴은 순수한 손해다.
// ❌ 흔한 옛날 패턴 — 저장할 때마다 메인 스레드를 블로킹
func saveSetting(_ value: Bool) {
UserDefaults.standard.set(value, forKey: "notificationsEnabled")
UserDefaults.standard.synchronize() // 불필요 — 대기만 하고 얻는 게 없다
}
// ✅ 그냥 지운다 — 동작은 동일하고 블로킹만 사라진다
func saveSetting(_ value: Bool) {
UserDefaults.standard.set(value, forKey: "notificationsEnabled")
}② "앱이 곧 종료될 것 같으니 지금 당장 디스크에 반영하고 싶다"는 요구가 실제로 있다면, synchronize()로는 해결되지 않는다(위 실측처럼 반환이 곧 디스크 반영 완료를 뜻하지 않는다). 진짜 프로세스 간 핸드오프가 시간에 민감하다면 UserDefaults 자체보다 App Group + Darwin notification 같은 명시적 통지 메커니즘을 쓰는 게 맞다 — Q3에서 다룬다.
"synchronize()를 안 부르면 값을 잃을 수 있다"는 틀렸다 — 시스템이 알아서 적절한 시점에 디스크에 반영하며, 위 두 프로세스 실험이 그 근거다. 반대로 "synchronize()는 완전히 아무 효과도 없다"는 것도 과장이다 — 문서상 이 메서드는 보류된 비동기 업데이트가 끝날 때까지 실제로 대기는 하므로, 순수 no-op은 아니고 "블로킹 대기를 굳이 만들 이유가 없는 메서드"라는 게 더 정확하다. 참고로 이 메서드는 문서상 "legacy" 취급이지만 @available(*, deprecated) 마크가 붙은 공식 deprecated는 아니다 — 여전히 호출은 되지만 쓸 이유가 없는 경우다.
값을 저장하는 건 편지를 우체통에 넣는 것과 비슷하다. 편지를 우체통(메모리)에 넣는 순간 이미 "보낸 것"으로 확정되고, 실제 배달(디스크에 쓰기)은 우체국(시스템)이 알아서 트럭을 몰고 처리한다. synchronize()를 부르는 건 우체통 앞에 서서 "저 트럭 언제 와요?"하고 계속 기다리는 것과 같다 — 기다린다고 트럭이 더 빨리 오지 않고, 나만 그 자리에 발이 묶인다. 비유가 깨지는 곳: 진짜 우체국은 "트럭이 이미 출발해서 곧 도착한다"는 확답을 주지만, synchronize()는 "보류된 업무가 끝날 때까지 기다린다"고만 할 뿐 그 업무가 지금 이 파일까지 다 끝냈다는 보장은 실측상으로도 없다.
꼬리 질문
synchronize()가 옛날에는 정말 필요했던 적이 있는가?
synchronize()의 반환값 Bool은 뭘 의미하는가?
메모리 갱신이 즉시라면, 앱을 강제 종료(kill)해도 방금 쓴 값이 살아남는가?
set 직후 곧바로 강제 종료되는 극단적 타이밍이라면, 아직 디스크에 반영되지 않은 아주 최근 값은 손실될 수 있다 — 이건 synchronize()를 부르든 안 부르든 근본적으로 같은 리스크다(반환이 진짜 완료를 보장 안 함을 위에서 실측했다). 실무에서 이 리스크는 대체로 무시할 만한 수준이지만, 정말로 "이 값만은 절대 잃으면 안 된다"면 UserDefaults 자체가 그 보장을 설계 목적으로 삼지 않는다는 걸 인지하고 파일 시스템의 원자적 쓰기(25장)나 트랜잭션이 있는 저장소를 고려해야 한다.Q3. App Group으로 UserDefaults를 공유하면 무엇이 달라지는가?
UserDefaults(suiteName:)에 App Group 식별자(group.com.company.app 형태)를 넘기면, 앱과 익스텐션(위젯, 공유 시트, 알림 서비스 등)이 함께 읽고 쓰는 완전히 별도의 저장소가 생긴다. 모든 타깃에 같은 App Group capability와 같은 팀 서명이 필요하다. 실측으로 확인했듯 이 suite는 .standard와 다른 파일이라 서로 절대 자동으로 안 보이고, 위젯은 이 값을 실시간이 아니라 스냅샷 시점에만 읽으므로 WidgetCenter.shared.reloadTimelines(ofKind:)를 직접 불러야 갱신되며, 프로세스 간 변경 통지는 신뢰할 수 없어 폴링이나 Darwin notification으로 보완해야 한다.
App Group 컨테이너 자체의 앱 샌드박스 격리 개념은 iOS CS 로드맵 계열에서 다루는 프로세스 격리 이야기와 맞닿아 있지만, 여기서는 "UserDefaults가 그 컨테이너를 어떻게 쓰는가"에 집중한다.
원리
App Sandbox는 기본적으로 각 앱과 익스텐션을 서로 다른 컨테이너에 격리한다. App Group은 그 격리에 대한 명시적 예외로, Apple Developer Portal에서 발급한 App Group 식별자를 여러 타깃이 함께 엔타이틀먼트로 갖고 있으면 그 식별자로 만든 공유 컨테이너 하나에 다 같이 접근할 수 있게 해준다. UserDefaults(suiteName:) 문서는 "nil을 넘기면 현재 앱의 표준 설정을 반환한다"고 밝히는데, 뒤집어 말하면 다른 문자열을 넘기면 완전히 다른 도메인을 만든다는 뜻이다 — App Group 식별자든 뭐든, suiteName이 다르면 다른 저장소다.
내부 동작
이 "다른 저장소"라는 말을 실제로 확인해봤다.
let groupSuite = "group.com.example.app.demo"
let group = UserDefaults(suiteName: groupSuite)!
group.set("group-value", forKey: "sharedKey")
UserDefaults.standard.set("standard-only-value", forKey: "sharedKey")
print(UserDefaults.standard.string(forKey: "sharedKey") ?? "nil") // standard-only-value
print(group.string(forKey: "sharedKey") ?? "nil") // group-value
// 같은 키인데도 서로 다른 값 — 애초에 다른 저장소이기 때문이다실제로 실행해보면 각 suite마다 ~/Library/Preferences/ 아래 별도의 plist 파일이 생긴다 — com.example.app.demo.plist와 group.com.example.app.demo.plist는 서로의 존재를 전혀 모르는 두 개의 독립된 파일이다. 실제 기기/시뮬레이터에서는 App Group 컨테이너가 ~/Library/Preferences/가 아니라 그룹 공유 컨테이너 경로 아래 위치하지만, "suiteName이 다르면 다른 파일"이라는 핵심 성질은 동일하다.
실험 · 도구
실기기·시뮬레이터에서는 defaults read group.com.company.app로는 App Group 컨테이너 안의 plist가 안 보일 수 있다(위치가 ~/Library/Preferences/가 아니라 그룹 컨테이너 경로이기 때문). 대신 Xcode의 Devices and Simulators 창에서 앱 컨테이너를 다운로드하거나, 시뮬레이터라면 xcrun simctl get_app_container <device> <bundle-id> groups로 그룹 컨테이너 경로를 직접 찾아 그 안의 Library/Preferences/를 살펴보면 실제 파일을 확인할 수 있다.
프로젝트 적용
① App Group suite 초기화는 실패할 수 있으므로(엔타이틀먼트 누락, 오타) 강제 언래핑 대신 방어적으로 다룬다.
enum SharedDefaults {
static let suiteName = "group.com.company.myapp"
// 엔타이틀먼트가 안 맞으면 nil이 온다 — 강제 언래핑하면 그 자리에서 크래시
static var store: UserDefaults? {
UserDefaults(suiteName: suiteName)
}
}
// 위젯 쪽에서 값을 읽을 때
let unreadCount = SharedDefaults.store?.integer(forKey: "unreadCount") ?? 0② 위젯이 보는 값을 바꿨다면, 그 변경을 위젯에 명시적으로 알려야 한다 — 안 그러면 다음 시스템 스케줄까지 옛 스냅샷이 그대로 보인다.
import WidgetKit
func markMessageAsRead(id: String) {
SharedDefaults.store?.set(true, forKey: "read.\(id)")
// 값만 바꾸고 끝내면 위젯은 다음 시스템 갱신 때까지 옛 상태를 그대로 보여준다
WidgetCenter.shared.reloadTimelines(ofKind: "UnreadCountWidget")
}".standard에 이미 저장해둔 값이 App Group을 설정하면 자동으로 익스텐션에도 보인다"는 완전히 틀렸다 — 위 실측처럼 둘은 처음부터 별개의 저장소였고, App Group을 나중에 추가해도 기존 .standard 값이 자동으로 옮겨가지 않는다(마이그레이션은 직접 짜야 한다). "App Group을 설정하면 KVO나 didChangeNotification이 프로세스를 넘어 실시간으로 온다"도 틀렸다 — 프로세스 간 통지는 신뢰도가 낮아, 정말 필요하면 CFNotificationCenterGetDarwinNotifyCenter() 기반의 Darwin notification이나 주기적 폴링으로 직접 보완해야 한다.
내 방 냉장고(.standard)와 거실 공용 냉장고(App Group)는 완전히 다른 냉장고다. 내 방 냉장고에 우유를 넣어둬도 거실 냉장고엔 우유가 없다. 위젯은 이 거실 냉장고를 실시간 CCTV로 보는 게 아니라, 가끔 지나가면서 사진 한 장 찍어두고 그 사진만 계속 보여주는 사람과 같다 — 냉장고에 새로 뭘 넣었으면 "다시 사진 찍어줘!"(reloadTimelines)라고 직접 알려야 사진이 갱신된다. 비유가 깨지는 곳: 진짜 거실 냉장고는 누구나 아무 때나 열어보면 최신 상태가 보이지만, 위젯의 "사진"은 내가 알려주기 전까진 아무리 오래 지나도 절대 스스로 새로고침하지 않는다.
꼬리 질문
App Group 컨테이너에 큰 파일(이미지 등)도 넣을 수 있는가?
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)로 얻는 공유 파일 컨테이너도 함께 제공한다. 큰 데이터를 익스텐션과 공유해야 한다면 UserDefaults가 아니라 이 공유 파일 컨테이너에 파일로 저장하고, UserDefaults(같은 App Group suite)에는 그 파일의 경로나 메타데이터 정도만 가벼운 값으로 남기는 조합이 일반적이다.App Group을 두 앱(서로 다른 개발사) 사이에도 쓸 수 있는가?
Darwin notification은 KVO나 NotificationCenter와 어떻게 다른가?
CFNotificationCenterGetDarwinNotifyCenter()가 반환하는 Darwin notification center는 프로세스 경계를 넘어 동작하도록 설계된 시스템 전역 알림 메커니즘으로, 커널 수준의 notify(3) 메커니즘 위에 만들어져 있다. 반면 일반 NotificationCenter와 KVO는 애초에 한 프로세스 안에서의 관찰을 전제로 설계됐다 — 그래서 App Group으로 프로세스 두 개가 같은 저장소를 봐도, "저장소가 바뀌었다"는 사실 자체를 상대 프로세스에 알리려면 이 프로세스 간 전용 메커니즘이 따로 필요하다. Darwin notification은 다만 페이로드 없이 "이름이 붙은 신호"만 보낼 수 있어, 신호를 받은 쪽이 다시 UserDefaults를 직접 읽어 실제 값을 가져와야 한다.Q4. UserDefaults / Keychain / 파일 / SwiftData는 무엇을 기준으로 고르는가?
세 가지 축으로 가른다 — 민감도(토큰·비밀번호는 UserDefaults에 절대 넣지 않는다), 크기(수 KB를 넘는 블롭은 파일로), 질의 필요성(찾고 정렬하고 필터링해야 하는 다량의 레코드는 SwiftData/Core Data로). UserDefaults는 가벼운 사용자 설정·플래그 전용이다. plist는 암호화되지 않은 평문이라 탈옥 기기나 백업 파일에서 그대로 읽히므로, 비밀은 OS가 관리하는 암호화 저장소인 Keychain에 넣어야 한다.
원리
네 저장소는 애초에 서로 다른 문제를 풀도록 설계됐다. UserDefaults는 "이름-값 쌍으로 된 가벼운 설정"을 위한 저장소이고, Apple 문서 자체가 Keychain services를 "사용자를 대신해 작은 비밀 조각을 안전하게 저장"하는 암호화 데이터베이스로 정의한다. 파일 시스템은 크기 제한 없이 대용량 블롭을 다루도록 만들어졌고(25장), SwiftData/Core Data는 여러 레코드 사이의 관계를 querying하는 구조화된 저장을 위한 것이다. 넷 다 "값을 저장한다"는 표면은 같지만 최적화 지점이 다르다.
내부 동작
| 저장소 | 적합한 데이터 | 암호화 | 크기 기준 |
|---|---|---|---|
| UserDefaults | UI 상태, 사용자 설정, 가벼운 플래그 | 없음 — 평문 plist | 수 KB 이내 |
| Keychain | 토큰, 자격증명, 대칭·비대칭 키 | OS가 관리(기기 패스코드 기반) | ~1KB 권장, 크면 파일+해시 |
| 파일(FileManager) | 이미지·동영상·문서 등 대용량 블롭 | 필요시 FileProtectionType으로 개별 지정 | 제한 없음 |
| SwiftData / Core Data | 관계가 있고 쿼리가 필요한 구조화 데이터 | 필요시 파일 자체 protection | 대량 레코드 |
UserDefaults가 왜 "수 KB 이내"로 제한되는지는 구조를 보면 자연스럽다 — 전체 도메인이 하나의 plist 문서이고, 시스템은 이 문서를 앱 시작 시 인메모리로 올려두는 구조라 커지면 접근·시작 지연으로 이어질 수 있다. 실제로 값이 일정 크기를 넘으면 UserDefaults.sizeLimitExceededNotification이라는 전용 알림까지 존재한다 — "너무 커지면 안 된다"는 게 문서에 근거 없는 통념이 아니라 API 설계 자체에 박혀 있는 신호다.
이 챕터를 쓰며 2MB짜리 Data 값을 넣고 set + synchronize()가 반환된 직후 백킹 plist 파일 크기를 재보니, 파일은 곧바로 커지지 않고 2초 가량 지나서야 2,000,093바이트로 자랐다(Q2에서 다룬 것과 같은 관찰). 이건 "UserDefaults는 그 도메인 전체를 하나의 단위로 다룬다"는 설계를 방증한다 — 키 하나만 따로 부분 갱신하는 구조가 아니라, 도메인 전체를 다시 직렬화해서 통째로 쓰는 모델에 가깝다는 뜻이다. Caches와 Application Support처럼 대용량 파일을 어디에 둘지의 기준은 25장 FileManager와 파일 I/O에서 다룬다.
실험 · 도구
어떤 값이 실제로 어느 저장소에 있는지 헷갈릴 때는 defaults find <key>로 UserDefaults 쪽부터 훑고, Keychain은 security find-generic-password -a <account>(macOS)로 존재 여부만 빠르게 확인할 수 있다. 파일 크기 기준을 감으로 정하지 말고, 실제로 넣으려는 값의 Data.count를 로그로 찍어 "이게 몇 KB인지" 먼저 재보는 습관이 결정을 더 명확하게 만든다.
프로젝트 적용
① 큰 데이터는 파일에 저장하고, UserDefaults에는 그 파일을 가리키는 가벼운 메타데이터(경로·해시·타임스탬프)만 남긴다.
func cacheProfileImage(_ data: Data, userID: String) throws {
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let fileURL = dir.appendingPathComponent("profile-\(userID).jpg")
try data.write(to: fileURL, options: .atomic)
// UserDefaults엔 이미지 자체가 아니라 "언제 캐시됐는지"만 가벼운 값으로 남긴다
UserDefaults.standard.set(Date(), forKey: "profileImageCachedAt.\(userID)")
}② 민감정보는 처음부터 UserDefaults 후보에서 제외한다. 실수를 코드 리뷰에서 걸러내려면 이름부터 명확히 분리해두는 편이 낫다.
// ❌ 절대 이렇게 저장하지 않는다 — 평문 plist는 백업·탈옥 기기에서 그대로 읽힌다
UserDefaults.standard.set(refreshToken, forKey: "refreshToken")
// ✅ Keychain 래퍼(직접 구현하거나 서드파티 라이브러리)로 분리
try KeychainStore.save(refreshToken, service: "com.company.app.auth", account: "refreshToken")"Keychain은 뭐든 안전하게 무제한으로 저장할 수 있다"는 과장이다 — Keychain도 큰 데이터 저장용으로 설계되지 않았고(커뮤니티 통설상 대략 1KB를 넘어가면 파일+해시 조합을 권장), 억지로 큰 값을 넣으면 성능이 나빠진다. "UserDefaults도 결국 앱 컨테이너 안에 있으니 어차피 안전하다"도 틀렸다 — 컨테이너 격리는 다른 앱으로부터의 보호일 뿐, plist 파일 자체는 암호화되지 않은 평문이라 기기 백업이나 탈옥된 기기의 파일 시스템에서는 그대로 읽힌다.
네 저장소는 각각 책상 서랍, 금고, 창고, 도서관 카드 목록과 같다. 서랍(UserDefaults)엔 자주 꺼내 쓰는 메모를 넣고, 금고(Keychain)엔 진짜 비밀번호를 넣고, 창고(파일)엔 부피 큰 짐을 두고, 도서관 카드 목록(SwiftData)엔 "제목으로 찾기, 저자로 정렬하기"처럼 검색이 필요한 자료 수천 권의 정보를 정리해둔다. 비유가 깨지는 곳: 진짜 서랍은 자물쇠 없이도 어느 정도 안전해 보이지만, plist "서랍"은 겉보기와 달리 잠금이 전혀 없는 투명 상자다 — 그 안에 비밀번호를 넣으면 누구든 열어서 바로 읽을 수 있다.
꼬리 질문
구조화된 데이터인데 레코드가 몇 개 안 되면 SwiftData 대신 UserDefaults에 Codable 배열을 통째로 넣어도 되는가?
iCloud로 기기 간 설정을 동기화하고 싶으면 UserDefaults를 그대로 쓰면 되는가?
NSUbiquitousKeyValueStore라는 별도 타입을 제공한다. UserDefaults와 인터페이스는 비슷하지만 내부적으로 iCloud와 동기화되는 완전히 다른 저장소이므로, "설정을 다른 기기에도 반영하고 싶다"는 요구가 있다면 처음부터 이 타입을 검토해야 한다.