← Swift & Foundation 로드맵 26 / 33

26 · UserDefaults와 저장소 선택

UserDefaults의 실체는 인메모리 캐시 뒤의 진짜 plist 파일이라는 것부터, synchronize()가 왜 사실상 no-op인지, App Group 공유가 정확히 무엇을 바꾸는지, Keychain·파일·SwiftData 중 무엇을 골라야 하는지까지 직접 값을 쓰고 파일을 덤프해 확인한다.
진행률
0 / 0 완료

UserDefaults는 set 한 줄로 끝나는 만만한 API처럼 보이지만, 그 뒤에는 인메모리 캐시와 데몬, 그리고 진짜 plist 파일이 있다. 이 챕터는 그 실체를 직접 눈으로 확인한다 — 값을 쓰고, 디스크의 plist를 찾아 덤프하고, synchronize()를 부르든 안 부르든 다른 프로세스에서 값이 그대로 보이는지 실측한다. 마지막엔 App Group 공유가 정확히 무엇을 바꾸는지, 그리고 UserDefaults·Keychain·파일·SwiftData 중 무엇을 써야 하는지 결정 기준까지 정리한다.

Q1. UserDefaults의 실체는 무엇이고, 등록 도메인(registration domain)은 왜 필요한가?

🔑 30초 답변

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를 찾아 덤프하면 등록 도메인이 정말 디스크에 안 쓰였는지 눈으로 확인할 수 있다.

진짜 plist 파일을 찾아 덤프 — fontSize는 어디에도 없다
$ 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 파일은 바로 삭제했다.

검색 도메인 목록 — 위에서부터 값을 찾는다 1. NSArgumentDomain 런치 인자 -key value (최우선, 디버깅용) 2. Application (앱 도메인) set(_:forKey:)로 쓴 값 — 여기가 plist에 저장된다 3. NSGlobalDomain 모든 앱 공통 시스템 설정 4. Languages 사용자 선호 언어 목록 5. Registration (register(defaults:)) 최하위 폴백 — 휘발성, 디스크에 안 쓴다 register(defaults: ["fontSize": 14]) 이후 실측 theme(직접 set함) → 앱 도메인 값 "dark" 반환, plist에도 기록됨 fontSize(set 안 함) → 등록 도메인 값 14는 반환하지만, plist 덤프엔 없음
검색은 위에서 아래로 진행되고, 등록 도메인은 값 조회에는 참여하지만 디스크의 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가 표현 못 하는 커스텀 타입은 CodableData 아카이브해서 넣는다.

커스텀 struct는 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:)를 여러 번 다른 값으로 부르면 마지막 호출이 이기는가?
그렇다. 등록 도메인은 하나뿐이라, 같은 키를 다른 값으로 여러 번 register하면 마지막에 등록된 값이 그 키의 등록 도메인 값이 된다. mjtsai.com이 지적한 "footgun"이 정확히 이 지점이다 — 여러 모듈이 각자 register(defaults:)를 부르는 코드베이스에서, 나중에 실행되는 모듈의 등록값이 먼저 등록된 모듈의 초기값을 조용히 덮어써 버그로 이어질 수 있다.
쉽게 말하면 문에 메모를 여러 장 겹쳐 붙이면 맨 위에 붙인 메모만 보이는 것과 같다. 누가 마지막에 뭘 붙였는지 신경 안 쓰면 엉뚱한 메모를 읽게 된다.
NSGlobalDomain에는 어떤 값이 들어있고 왜 앱 도메인보다 아래인가?
NSGlobalDomain은 키보드 설정, 날짜/시간 표시 형식처럼 특정 앱 하나가 아니라 시스템 전체에 걸쳐 적용되는 설정을 담는다. 검색 순서에서 앱 도메인보다 아래인 이유는 명확하다 — 앱이 자기 목적에 맞게 같은 키를 직접 오버라이드하고 싶을 때, 전역 설정이 그걸 가로막으면 안 되기 때문이다. 앱 도메인이 비어 있을 때만 전역 값이 폴백으로 쓰인다.
쉽게 말하면 학교 전체 규칙(전역 설정)보다 우리 반 담임 선생님이 정한 규칙(앱 도메인)을 먼저 따르고, 담임이 따로 정한 게 없을 때만 학교 규칙을 따르는 것과 같다.
커스텀 타입을 Data로 아카이브하지 않고 그냥 set하면 무슨 일이 일어나는가?
크래시는 나지 않지만 값이 조용히 저장되지 않는다. UserDefaults의 set(_:forKey:)는 Any?를 받지만 내부적으로 plist 호환 타입인지 검사하고, 호환되지 않는 타입이면 콘솔에 경고를 남기고 그 write를 무시한다 — 이후 get은 nil이거나 이전 값을 그대로 반환한다. 컴파일 타임에는 전혀 안 잡히는 실수라 실기기 로그에서 경고를 놓치면 오래 방치되기 쉽다.
쉽게 말하면 서랍에 들어갈 수 없는 크기의 물건(냉장고 같은)을 억지로 넣으려 하면 서랍이 부서지는 게 아니라 그냥 "이건 못 넣어요"라고 조용히 무시하고 원래 있던 것만 그대로 두는 것과 같다.

Q2. synchronize()를 불러야 저장되는가? — 애플 공식 입장

🔑 30초 답변

아니다. 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 자체의 배치/커밋 스케줄이 별도로 있다.

d.set(bigBlob, forKey: "bigBlob"); d.synchronize() 시간 → t=0 set() 호출 메모리 즉시 갱신 t≈수ms synchronize() 반환 t≈2초 (실측) plist 파일 크기 실제로 자람 synchronize() 반환 시점과 실제 디스크 파일 반영 시점은 다르다 — "기다린다"는 게 "즉시 완료"의 동의어가 아니다
메모리 갱신은 즉시, synchronize() 반환도 곧이지만, 실제 디스크 파일 크기 변화는 그보다 늦게 실측됐다.

실험 · 도구

synchronize()를 부르든 안 부르든 값이 다음 프로세스에서 그대로 보이는지가 가장 직접적인 증거다. 두 개의 독립된 swift 프로세스로 확인했다.

프로세스 A: 값만 쓰고 synchronize() 없이 종료
// q2a-write.swift
let d = UserDefaults(suiteName: "com.example.app.demo")!
d.set("without-calling-synchronize", forKey: "proof")
// synchronize()를 절대 호출하지 않고 그냥 종료
프로세스 B: 새 프로세스에서 읽기 — 실측 결과 그대로 보인다
// q2b-read.swift, 프로세스 A가 완전히 끝난 뒤 별도 실행
let d = UserDefaults(suiteName: "com.example.app.demo")!
print(d.string(forKey: "proof") ?? "nil")
// 실제 출력: without-calling-synchronize

swift q2a-write.swift를 먼저 실행해 프로세스를 완전히 종료시킨 뒤 swift q2b-read.swift를 새로 실행하면, synchronize()를 한 번도 부르지 않았는데도 값이 그대로 읽힌다 — 시스템이 알아서 영속시켰다는 직접 증거다.

프로젝트 적용

코드베이스에 남아있는 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()가 옛날에는 정말 필요했던 적이 있는가?
역사적으로 정당했던 유일한 경우는 App Group 도입 초기, 프로세스 간 KVO/알림 메커니즘이 지금만큼 성숙하지 않았을 때 "다른 프로세스(익스텐션 등)에 값을 넘기기 직전 지금 당장 디스크에 반영해두고 싶다"는 핸드오프 시나리오였다. 지금은 시스템의 비동기 반영이 충분히 빠르고 신뢰할 수 있어 이 정당성 자체가 사라졌다.
쉽게 말하면 예전엔 우체국 시스템이 지금만큼 안 빨라서 "직접 들고 가서 확인시켜야" 안심됐지만, 지금은 우체국이 알아서 빠르고 확실하게 처리해줘서 그럴 필요가 없어진 것과 같다.
synchronize()의 반환값 Bool은 뭘 의미하는가?
공식 문서는 "데이터가 디스크에 성공적으로 저장됐으면 true, 아니면 false"라고 설명한다. 다만 이 값을 실무에서 체크해 저장 실패에 대응하는 패턴은 거의 없다 — 애초에 이 메서드를 호출할 이유 자체가 없다는 게 공식 입장이라, 반환값을 근거로 로직을 분기하는 건 지양해야 한다.
쉽게 말하면 우체통 앞에서 기다리다가 "트럭 왔다 갔음" 팻말을 보는 것과 비슷한데, 애초에 그 팻말을 보러 우체통 앞에 서 있을 이유가 없다는 뜻이다.
메모리 갱신이 즉시라면, 앱을 강제 종료(kill)해도 방금 쓴 값이 살아남는가?
일반적인 set 직후 곧바로 강제 종료되는 극단적 타이밍이라면, 아직 디스크에 반영되지 않은 아주 최근 값은 손실될 수 있다 — 이건 synchronize()를 부르든 안 부르든 근본적으로 같은 리스크다(반환이 진짜 완료를 보장 안 함을 위에서 실측했다). 실무에서 이 리스크는 대체로 무시할 만한 수준이지만, 정말로 "이 값만은 절대 잃으면 안 된다"면 UserDefaults 자체가 그 보장을 설계 목적으로 삼지 않는다는 걸 인지하고 파일 시스템의 원자적 쓰기(25장)나 트랜잭션이 있는 저장소를 고려해야 한다.
쉽게 말하면 편지를 우체통에 넣자마자 우체통이 통째로 없어지면 그 편지도 같이 사라지는 것과 비슷하다 — 아주 드물지만 이론적으로 가능한 타이밍이다.

Q3. App Group으로 UserDefaults를 공유하면 무엇이 달라지는가?

🔑 30초 답변

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이 다르면 다른 저장소다.

내부 동작

이 "다른 저장소"라는 말을 실제로 확인해봤다.

.standard와 group suite는 서로 완전히 독립된 저장소다
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.plistgroup.com.example.app.demo.plist는 서로의 존재를 전혀 모르는 두 개의 독립된 파일이다. 실제 기기/시뮬레이터에서는 App Group 컨테이너가 ~/Library/Preferences/가 아니라 그룹 공유 컨테이너 경로 아래 위치하지만, "suiteName이 다르면 다른 파일"이라는 핵심 성질은 동일하다.

.standard(각자) vs App Group suite(공유) 앱 프로세스 UserDefaults.standard 위젯 익스텐션 UserDefaults.standard 서로 다른 plist — 절대 자동으로 안 보임 (실측 확인) App Group 컨테이너 group.com.company.app — 앱·익스텐션 공유 UserDefaults(suiteName: "group...") 위젯은 스냅샷 시점에만 읽는다 → 값이 바뀌면 reloadTimelines(ofKind:)로 직접 깨워야 갱신
App Group suite는 앱과 익스텐션이 함께 보는 별도의 저장소이고, 각자의 .standard와는 완전히 다른 파일이다.

실험 · 도구

실기기·시뮬레이터에서는 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 초기화는 실패할 수 있으므로(엔타이틀먼트 누락, 오타) 강제 언래핑 대신 방어적으로 다룬다.

App Group UserDefaults 안전 초기화
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 컨테이너에 큰 파일(이미지 등)도 넣을 수 있는가?
그렇다. App Group은 UserDefaults뿐 아니라 FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)로 얻는 공유 파일 컨테이너도 함께 제공한다. 큰 데이터를 익스텐션과 공유해야 한다면 UserDefaults가 아니라 이 공유 파일 컨테이너에 파일로 저장하고, UserDefaults(같은 App Group suite)에는 그 파일의 경로나 메타데이터 정도만 가벼운 값으로 남기는 조합이 일반적이다.
쉽게 말하면 거실 냉장고 안에 메모지(작은 값)만 두는 게 아니라, 거실 자체(공유 폴더)에 큰 짐도 같이 놓을 수 있다는 뜻이다.
App Group을 두 앱(서로 다른 개발사) 사이에도 쓸 수 있는가?
아니다. App Group 식별자는 하나의 Apple Developer 팀(같은 팀 ID) 안에서 발급되고 관리된다. 서로 다른 개발사의 앱은 같은 App Group을 공유할 수 없다 — 이는 임의의 앱이 다른 개발사 앱의 데이터를 몰래 들여다보는 걸 막는 근본적인 보안 경계다.
쉽게 말하면 거실 공용 냉장고는 같은 가족(같은 개발팀)끼리만 같이 쓸 수 있지, 옆집 사람(다른 개발사)이 마음대로 열어볼 수는 없는 것과 같다.
Darwin notification은 KVO나 NotificationCenter와 어떻게 다른가?
CFNotificationCenterGetDarwinNotifyCenter()가 반환하는 Darwin notification center는 프로세스 경계를 넘어 동작하도록 설계된 시스템 전역 알림 메커니즘으로, 커널 수준의 notify(3) 메커니즘 위에 만들어져 있다. 반면 일반 NotificationCenter와 KVO는 애초에 한 프로세스 안에서의 관찰을 전제로 설계됐다 — 그래서 App Group으로 프로세스 두 개가 같은 저장소를 봐도, "저장소가 바뀌었다"는 사실 자체를 상대 프로세스에 알리려면 이 프로세스 간 전용 메커니즘이 따로 필요하다. Darwin notification은 다만 페이로드 없이 "이름이 붙은 신호"만 보낼 수 있어, 신호를 받은 쪽이 다시 UserDefaults를 직접 읽어 실제 값을 가져와야 한다.
쉽게 말하면 일반 초인종(NotificationCenter)은 같은 집 안에서만 들리지만, Darwin notification은 옆집까지 들리는 사이렌 같은 것이다. 다만 사이렌은 "무슨 일이 생겼다"는 신호만 줄 뿐 내용까지 전달하진 않아서, 소리를 들은 쪽이 직접 냉장고를 열어봐야 한다.

Q4. UserDefaults / Keychain / 파일 / SwiftData는 무엇을 기준으로 고르는가?

🔑 30초 답변

세 가지 축으로 가른다 — 민감도(토큰·비밀번호는 UserDefaults에 절대 넣지 않는다), 크기(수 KB를 넘는 블롭은 파일로), 질의 필요성(찾고 정렬하고 필터링해야 하는 다량의 레코드는 SwiftData/Core Data로). UserDefaults는 가벼운 사용자 설정·플래그 전용이다. plist는 암호화되지 않은 평문이라 탈옥 기기나 백업 파일에서 그대로 읽히므로, 비밀은 OS가 관리하는 암호화 저장소인 Keychain에 넣어야 한다.

원리

네 저장소는 애초에 서로 다른 문제를 풀도록 설계됐다. UserDefaults는 "이름-값 쌍으로 된 가벼운 설정"을 위한 저장소이고, Apple 문서 자체가 Keychain services를 "사용자를 대신해 작은 비밀 조각을 안전하게 저장"하는 암호화 데이터베이스로 정의한다. 파일 시스템은 크기 제한 없이 대용량 블롭을 다루도록 만들어졌고(25장), SwiftData/Core Data는 여러 레코드 사이의 관계를 querying하는 구조화된 저장을 위한 것이다. 넷 다 "값을 저장한다"는 표면은 같지만 최적화 지점이 다르다.

내부 동작

저장소적합한 데이터암호화크기 기준
UserDefaultsUI 상태, 사용자 설정, 가벼운 플래그없음 — 평문 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에서 다룬다.

저장할 데이터는? 토큰·비밀번호 → Keychain 큰 바이너리 → 파일(FileManager) 쿼리 필요한 레코드 → SwiftData/Core Data 가벼운 설정·플래그 → UserDefaults 프로세스 간(앱↔익스텐션) 공유가 필요하면 App Group suite/컨테이너에 위 기준을 그대로 적용한다 (큰 데이터는 App Group 파일 컨테이너로, 가벼운 값만 App Group UserDefaults로 — Q3)
민감도·크기·질의 필요성 세 기준이 저장소 선택을 가른다.

실험 · 도구

어떤 값이 실제로 어느 저장소에 있는지 헷갈릴 때는 defaults find <key>로 UserDefaults 쪽부터 훑고, Keychain은 security find-generic-password -a <account>(macOS)로 존재 여부만 빠르게 확인할 수 있다. 파일 크기 기준을 감으로 정하지 말고, 실제로 넣으려는 값의 Data.count를 로그로 찍어 "이게 몇 KB인지" 먼저 재보는 습관이 결정을 더 명확하게 만든다.

프로젝트 적용

큰 데이터는 파일에 저장하고, UserDefaults에는 그 파일을 가리키는 가벼운 메타데이터(경로·해시·타임스탬프)만 남긴다.

큰 데이터는 파일에, 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 후보에서 제외한다. 실수를 코드 리뷰에서 걸러내려면 이름부터 명확히 분리해두는 편이 낫다.

비밀은 애초에 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 배열을 통째로 넣어도 되는가?
레코드가 아주 적고(수십 개 이내) 앱 실행마다 통째로 읽고 쓰는 게 문제없는 규모라면 실무에서 종종 그렇게 한다 — SwiftData를 도입하는 비용이 이득보다 클 수 있기 때문이다. 다만 그 배열이 점점 커지거나, 부분 업데이트·정렬·필터링이 잦아지면 매번 배열 전체를 인코딩/디코딩하는 비용이 늘어나므로 그 시점에 SwiftData로 옮기는 게 맞다. 기준은 "지금 데이터 크기"가 아니라 "커질 방향과 쿼리 패턴"이다.
쉽게 말하면 메모 몇 장은 서랍에 대충 넣어놔도 찾을 수 있지만, 메모가 수백 장으로 늘어나면 서랍을 뒤지는 대신 목록이 있는 도서관식 정리가 필요해지는 것과 같다.
iCloud로 기기 간 설정을 동기화하고 싶으면 UserDefaults를 그대로 쓰면 되는가?
아니다. UserDefaults는 문서상 기기별로 로컬에 저장되며 기기 간 공유를 위한 API가 아니다 — Apple은 이 목적을 위해 NSUbiquitousKeyValueStore라는 별도 타입을 제공한다. UserDefaults와 인터페이스는 비슷하지만 내부적으로 iCloud와 동기화되는 완전히 다른 저장소이므로, "설정을 다른 기기에도 반영하고 싶다"는 요구가 있다면 처음부터 이 타입을 검토해야 한다.
쉽게 말하면 내 방 서랍(UserDefaults)은 이 방에만 있는 서랍이라, 다른 집(다른 기기)에 있는 서랍과는 자동으로 연결되지 않는다. 여러 집 서랍을 동기화하려면 애초에 그런 용도로 만들어진 다른 서비스가 필요하다.
파일과 SwiftData 둘 다 후보인 경우(예: 다운로드한 문서 + 그 메타데이터)는 어떻게 나누는가?
전형적인 패턴은 "실제 바이트는 파일, 그 파일을 설명하는 구조화된 정보는 SwiftData"로 나누는 것이다 — 파일 자체(PDF, 이미지 원본)는 FileManager로 디스크에 두고, SwiftData 모델에는 파일명·경로·크기·업로드 날짜·태그처럼 검색·정렬·필터링 대상이 되는 필드만 저장한다. 이렇게 하면 "이 조건에 맞는 문서 찾기"는 SwiftData 쿼리로 빠르게 처리하고, 실제 무거운 바이트 입출력은 필요한 순간에만 파일 시스템에서 일어난다.
쉽게 말하면 도서관은 책(파일) 자체를 서가에 두고, 카드 목록(SwiftData)에는 "몇 번 서가, 몇 층"이라는 가벼운 정보만 적어둔다. 카드 목록만 보고도 책을 찾을 수 있지, 카드 목록 안에 책 전체 내용을 옮겨 적어두지 않는다.

출처 · 참고자료