티스토리 뷰
📍문제원인
현재 진행하고 있는 프로젝트는 유저가 산책하며 찍은 사진을 좌표와 함께 저장하여 맵에서 사진을 표시해 준다. 사진에는 집 주변 풍경이 찍히고 해당 사진 데이터와 좌표가 누적되면 생활 반경과 동선을 특정할 수 있어 이 사람이 어디 사는지, 어느 동네에 사는지 특정할 수 있는 데이터가 되기 때문에 데이터의 보호가 필요하다고 판단했다.
그래서 파일 보호 등급에 대해 알아보게 되었고 이 데이터를 보호하기 위해서는 앱이 정상적으로 실행되는 상황뿐 아니라, 기기를 분실하거나 잠금 상태에서 내부 데이터에 접근하려는 상황도 함께 고려가 필요하다고 생각하였다.
iOS는 파일별 데이터 보호 등급을 제공하며, 이를 통해 기기가 잠겨 있는 상태에서의 파일 접근 조건을 설정할 수 있다. 앱 개발자가 모든 공격 상황을 직접 방어할 수는 없지만, 운영체제가 제공하는 보안 기능을 활용해 민감한 데이터의 접근 범위를 제한할 수 있다.
따라서 해당 범위 안에서 사진을 저장할 때 기본 보호 등급을 그대로 사용하는 대신, 프로젝트이 데이터 접근 방식에 맞는 파일 보호 등급을 적용해보고자 하였다.
✏️ iOS의 Data Protection이란?
iOS는 파일을 저장할 때 데이터 보호 클래스를 지정할 수 있다. 이 클래스는 파일에 접근할 수 있는 시점을 의미하며 단순히 파일을 암호화하는 것만이 아니라 기기가 잠겨 있는지, 사용자가 기기를 한 번이라도 잠금을 해제했는지에 따라 파일을 읽거나 쓸 수 있는 조건을 다르게 설정할 수 있다.


- 클래스 A: 기기가 잠기면 일정 시간이 지난 뒤 키 접근이 차단된다.
- 클래스 B: 파일이 열린 상태와 닫힌 상태에 따라 접근 조건이 달라진다.
- 클래스 C: 최초 잠금 해제 이후에는 기기가 다시 잠겨도 키가 메모리에 유지된다.
- 클래스 D: 보호를 진행하지 않는다.
이때 apple에서는 데이터 보호 클래스를 지정하지 않은 타사 앱 데이터의 기본 클래스로 클래스 C를 설명한다. 이 클래스 C는 NSFileProtectionCompleteUntilFirstUserAuthentication이다.
해당 클래스는 기기가 부팅된 후 사용자가 최초로 잠금 해제를 완료하기 전까지는 파일에 접근할 수 없도록 한다. 하지만 한 번 잠금 해제를 한 경우에 이후 기기가 다시 잠기더라도 해당 클래스의 키가 메모리에 유지된다.
여기서 말하는 잠금 해제는 아이폰을 재부팅 한 뒤, 부팅 과정에서 처음으로 암호를 입력해 잠금을 해제하는 것을 의미하며, 재부팅하지 않고 평소처럼 아이폰을 사용하거나 잠그는 경우에는 해당 파일에 계속 접근할 수 있다는 것을 의미한다.
이걸 보안에서는 AFU(After First Unlock)이라고 부른다.
❓그럼 키가 메모리에 남는게 왜 위험할까?
일반적으로 유저 폰은 거의 항상 After First Unlock(AFU) 상태이다. 따라서 기기가 잠겨 있어도 클래스 C의 키는 메모리에 유지되어 있기 때문에 공격자가 잠금 화면을 우회하는 취약점이나 포렌식 장비로 기기 내부에 접근하면, 암호를 몰라도 클래스 C 파일은 복호화된 상태로 읽히기 때문에 위험하다.
(물론 키가 메모리에 남아 있다는 것만으로 공격자가 파일을 즉시 읽을 수 있다는 건 아니지만 해당 프로젝트에서는 이러한 위험을 고려해 사진 파일에 기본 클래스보다 강한 보호 조건을 작용하기로 하였다.)
클래스 A - 잠기면 접근 차단
클래스 A는 NSFileProtectionComplete를 사용한다.
기기가 잠기면 암호화 해제된 클래스 키가 폐지되고, 사용자가 다시 인증할 떄 까지 해당 클래스의 데이터에 접근할 수 없도록 한다. 암호 요구 설정이 '즉시'인 경우에도 키 폐기까지는 잠금 후 약 10초 정도의 유에가 있을 수도 있다.
따라서 기기가 잠겨 있는 동안 파일에 접근할 필요가 없는 데이터라면 클래스 A가 적합한 선택이 될 수 있다. 다만 파일을 잠금 상태에서도 사용해야 하는 기능이 있다면 파일 접근 시점과 앱의 실행흐름을 함께 고려해야 한다.
예를 들어 widgetkit을 사용하여 잠금 화면 위젯을 표시하는 경우, live activity나 dynamic Island를 활용하여 진행 중인 작업의 데이터를 잠금 상태에서도 처리해야 하는 경우(배민-배달중) 영향을 줄 수 있다.
현재 개인 프로젝트로 진행하고 있는 것고 widgetkit을 사용하는데 이때 잠금 화면에 오늘의 미션 색상이나 사진 썸네일을 표시하려고 할 때 widgetkit이 업데이트되면서 appGroup에 저장된 파일을 읽어야 한다. 이때 그 파일의 보호 등급이 잠금 상태에서의 접근 가능 여부에 영향을 줄 수 있다.
클래스 B - 열려 있는 파일은 계속 사용 가능
클래스 B는 NSFileProtectionCompletionUnlessOpen을 사용한다.
이 클래스는 잠금 상태에서도 파일을 열어 둔 채 작업을 이어가야 하는 상황등을 고려한 보호 방식이다. Apple은 파일별 키를 보호하기 위해 Curve25519를 통한 비대칭 타원곡선 암호화(ECDH)를 통해 실행된다고 한다.
예를 들어 아이폰이 잠겨 있는 동안 이메일 첨부 파일을 백그라운드에서 다운로드하는 상황이다
사용자가 다운로드를 시작하고 아이폰을 잠근다. 이때 다운로드 작업은 계속 진행되어야 할 수 있는데 파일을 잠금 상태에서 새로 열 수 없도록 보호한다면 작업이 진행되지 않아 문제가 발생할 수 있다. 따라서 ClassB는 잠금 전에 열어 둔 파일을 계속 사용할 수 있도록 허용하여 파일을 닫으면 파일별 키가 메모리에서 제거되고 잠금 상태에서는 해당 파일을 다시 열 수 없도록 하는 게 ClassB의 핵심 동작이다.
여기서 말하는 파일은 사진, 문서, 다운로드 파일 등이 해당된다.
클래스 B가 클래스 A보다 보안이 약한건가? 라는 생각이 들 수도 있지만 둘을 비교하기보다는 파일의 접근 시점과 열림 상태에 따라 다른 동작을 제공하도록 하여 필요한 상황에 맞는 클래스 등급을 적용하도록 한다는 식으로 이해하는 게 좋을 거 같다.
⚫️ 실제 적용 범위
현재 프로젝트에서는의 클래스 등급 별 적용은 아래와 같다.
- 클래스 B: 원본 사진(Documents), 스티커(Documents/stickers)
- 클래스 C: 위젯 썸네일(AppGroup), RealmDB(좌표 포함)
현재 프로젝트에서는 live activity 및 위젯 기능을 제공하고 있기 때문에 일반적인 앱 화면에서만 사용하는 사진과 잠금 화면 위젯에서 사용하는 썸네일은 접근 조건이 다르다고 판단했다. 위젯이 잠금 상태에서도 데이터를 읽어서 찍은 사진이 있다면 제일 최근 사진으로 반영해 위젯에 업데이트를 진행해야 됐기 때문에 클래스 C를 적용했다.
반면 일반 사진이나 스티커 생성 같은 경우에서는 클래스 B등급을 적용하였다.
새로 저장한 파일에 보호 속성을 적용하는 경우
// 변경 전
try data.write(to: url)
// 변경 후
try data.write(to: url, options: [.atomic, .completeFileProtectionUnlessOpen])
completeFileProtectionUnlessOpen 옵션을 줘서 파일에 클래스 B에 해당하는 보호 조건을 적용하도록 하였다.
이렇게 하면 해당 코드 경로에서 새로 저장되는 파일에 대해 의도한 데이터 보호 정책을 적용할 수 있다.
추가적으로 atomic을 적용하여 파일을 쓰는 과정에서 임시 파일에 먼저 기록한 뒤 완료된 파일로 교체하는 방식으로 쓰기 도중 불완전한 파일이 노출되는 문제를 줄이고자 하였다.
기존 파일의 보호 속성을 변경하는 경우
// ImageFileManger에서 마이그레이션 코드 추가
func migrateExistingImages() -> (total: Int, succeeded: Int, failed: [String]) {
let fileManager = FileManager.default
let directory = getDocumentsDirectory()
let files: [URL]
do {
files = try fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.isRegularFileKey]
).filter { url in
let values = try? url.resourceValues(forKeys: [.isRegularFileKey])
return values?.isRegularFile == true
&& ["jpg", "jpeg", "png"].contains(url.pathExtension.lowercased())
}
} catch {
print("[Migration] 디렉터리 조회 실패: \(error)")
return (0, 0, ["디렉터리 조회 실패: \(error.localizedDescription)"])
}
var succeeded = 0
var failed: [String] = []
for url in files {
do {
// 기존 파일에 보호 속성 적용
try fileManager.setAttributes(
[.protectionKey: FileProtectionType.completeUnlessOpen],
ofItemAtPath: url.path
)
// 실제 속성을 다시 조회해 검증
let attributes = try fileManager.attributesOfItem(atPath: url.path)
guard let protection = attributes[.protectionKey] as? FileProtectionType,
protection == .completeUnlessOpen else {
failed.append("\(url.lastPathComponent): 보호 속성 불일치")
continue
}
succeeded += 1
} catch {
failed.append("\(url.lastPathComponent): \(error.localizedDescription)")
}
}
print("""
[Migration] 기존 사진 보호 속성 점검
전체: \(files.count)
성공: \(succeeded)
실패: \(failed.count)
실패 목록: \(failed)
""")
return (files.count, succeeded, failed)
}
// AppDelegate - app 시작 할 때 호출
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication
.LaunchOptionsKey: Any]?
) -> Bool {
...
// 기존 사진 파일 보호 속성 마이그레이션
let result = ImageFileManager.shared.migrateExistingImages()
...
}
이미 앱을 사용하고 있는 유저들이 있는 도중에 보호 기능이 적용되어야 한다면 기존 파일에도 보호 속성이 적용되어야 한다. 따라서 마이그레이션 코드를 작성하고 AppDelegate에서 앱 첫 실행 지점에 호출되도록 하였다.
🟢 현재 파일 보호 상태
현재 파일 보호 상태를 로그로 확인해 보았다.



위 로그를 표로 정리해 보면 아래와 같다.
| 대상 | 확인된 보호 속성 | - |
| 원본 사진 2개 | NSFileProtectionCompleteUnlessOpen | 두 파일 모두 의도한 보호 등급 |
| 위젯 썸네일 1개 | NSFileProtectionCompleteUntilFirstUserAuthentication | 최초 잠금 해제 이후 기기가 잠겨도 접근 가능할 수 있음 |
| Realm DB | NSFileProtectionCompleteUntilFirstUserAuthentication | 위젯 썸네일과 같은 보호 등급 |
| 스티커 | 0개 | 검증할 파일 없음 |
| 위젯 폴더 백업 제외 | YES | 해당 폴더가 iCloud 백업 제외 대상으로 표시됨 |
기존에 저장된 사진 파일을 순회하며 NSFileProtectionCompleteUnlessOpen을 적용하고, 변경 후 파일 속성을 다시 조회해 검증했다.
테스트 당시 Documents 디렉터리에서 발견된 사진은 총 1개였으며, 검증 결과 성공 1개 실패 0개로 확인됐다.
또한 신규 저장 파일에서도 동일한 보호 속성이 적용되는 것을 확인할 수 있었다. 이를 통해 테스트 당시 확인한 기존 파일과 신규 파일이 모두 데이터 보호 등급이 적용됐다는 것을 확인할 수 있었다.
참고
https://support.apple.com/ko-kr/guide/security/secb010e978a/web
https://developer.apple.com/documentation/uikit/encrypting-your-app-s-files
'iOS' 카테고리의 다른 글
| [iOS] SwiftUI에서 Localization 적용하기 (0) | 2025.12.03 |
|---|---|
| [iOS] Swift Format 적용기 + husky 배포까지 (0) | 2025.11.22 |
| [iOS] Kingfisher를 활용한 이미지 캐싱 최적화 (0) | 2025.11.09 |
| [Xcode] internal import Combine (0) | 2025.10.16 |
| [iOS] Moya를 활용한 네트워킹 (1) (0) | 2025.10.11 |
- Total
- Today
- Yesterday
- Swift Concurrency
- Swift Format
- Fastlane
- 클로저
- awakeFromNib
- 백준
- 코딩테스트
- combine
- Xcode
- copyable
- UITest
- swiftUI
- App Sandbox
- 프로그래머스
- PrivacyManifest
- SWIFT
- prepareForReuse
- macos
- Task
- CoreData
- 스위프트
- SwiftOwnership
- rxswift
- ios
- closure
- ObservableObject
- AppGroup
- GCD
- UIKit
- XCTest
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |