앱을 짜다 보면 "백그라운드로 갔다", "메모리 부족으로 죽었다", "샌드박스 때문에 접근 안 된다" 같은 말을 자주 쓴다. 이 챕터는 그 말들이 운영체제 수준에서 실제로 무슨 일인지를 CS 원리와 Darwin/XNU 구현 양쪽에서 정확히 맞춰 본다.
Q1. iOS 앱은 운영체제에서 Process, Task, Thread 중 무엇으로 표현되는가?
실행 중인 iOS 앱 하나는 BSD 프로세스 1개 = Mach task 1개이고, 그 안에 여러 Mach thread가 들어 있다. Process/Task는 "자원을 담는 그릇"(주소공간·포트권한·파일 디스크립터)이고, Thread는 "실제로 CPU에서 도는 실행 단위"다. XNU는 커널 아래층에 Mach의 task, 위층에 BSD의 proc를 두는 하이브리드라서 두 이름이 사실상 같은 대상을 가리킨다. 주의: Swift의 Task { }는 이것과 전혀 다른, 비동기 작업 단위다.
CS 원리
전통적 OS 이론에서 프로세스는 실행 중인 프로그램의 인스턴스이며 주소공간·열린 파일·시그널 상태 같은 자원의 소유 단위다. 스레드는 그 프로세스 안에서 프로그램 카운터·레지스터·스택을 가진 스케줄링(실행)의 단위다. 한 프로세스는 자원을 공유하는 여러 스레드를 담을 수 있다. "Task"는 표준 용어가 아니라 OS마다 다르게 쓰인다 — 어떤 곳에선 프로세스의 동의어, 어떤 곳(리눅스 커널 내부)에선 스레드까지 포함한 스케줄 가능한 실체를 task_struct로 부른다.
iOS에서는
iOS 커널 XNU는 Mach 마이크로커널 + BSD의 하이브리드다. 자원 컨테이너는 Mach의 task(가상 주소공간, Mach 포트 권한, 메모리 자원의 소유자)이고, 실행 단위는 Mach의 thread다. 그 위에 BSD 계층이 struct proc를 씌워 PID·시그널·POSIX 파일 디스크립터 같은 유닉스 의미론을 붙인다. 그래서 실행 중인 앱 하나는 "BSD proc ↔ Mach task"가 1:1로 대응하고, 최소 1개의 스레드(main thread)를 갖는다. GCD의 dispatch queue나 Swift Concurrency의 Task는 커널 스레드가 아니라 그 위에서 스케줄되는 작업 추상화이며, 런타임이 이를 실제 스레드 풀에 매핑한다.
실험 · 도구
Mach 자원과 스레드를 직접 들여다볼 수 있다. LLDB에서 image list로 로드된 다이립을, thread list로 현재 스레드를, Xcode의 Debug Navigator로 스레드 목록과 스택을 본다. 코드로는 Mach API가 그대로 열려 있다.
import Darwin
var threadList: thread_act_array_t?
var count: mach_msg_type_number_t = 0
// mach_task_self_가 곧 "이 앱 프로세스" = 현재 Mach task
let kr = task_threads(mach_task_self_, &threadList, &count)
if kr == KERN_SUCCESS, let threadList {
print("이 앱 task의 커널 스레드 수: \(count)")
// ⚠️ task_threads는 배열 메모리뿐 아니라 스레드마다 '커널 포트 send right'도 준다.
// 포트를 반납하지 않으면 호출할 때마다 포트 이름이 누수되고, 반복하면
// "Exceeded system-wide per-process Port Limit"으로 앱이 죽는다.
for i in 0..<Int(count) { mach_port_deallocate(mach_task_self_, threadList[i]) }
vm_deallocate(mach_task_self_,
vm_address_t(UInt(bitPattern: threadList)),
vm_size_t(Int(count) * MemoryLayout<thread_t>.size))
}
// 관찰 지표: 앱 시작 직후엔 main + GCD/런타임 워커 몇 개 →
// URLSession/이미지 디코딩이 돌면 스레드 수가 늘었다 줄었다 한다.프로젝트 적용
① "스레드를 직접 만든다"(Thread/pthread)보다 GCD·Swift Concurrency로 작업을 던지고 스레드 관리는 런타임에 맡긴다. 스레드는 비싼 커널 자원(보조 스레드 기본 스택 512KB, main은 1MB급)이라 수백 개를 만들면 thread explosion으로 컨텍스트 스위칭이 앱을 잡아먹는다.
import Foundation
// 스레드는 비싼 커널 자원이다. 보조 스레드 기본 스택 512KB, 메인 1MB.
// 수백 개를 만들면 스택만으로 수백 MB에 스케줄링 비용까지 붙는다.
// ❌ 작업 하나당 스레드 하나
func downloadAllBad(_ urls: [URL]) {
for url in urls {
Thread.detachNewThread {
_ = try? Data(contentsOf: url)
}
}
}
// ✅ 작업만 던지고 스레드 관리는 런타임에 맡긴다
func downloadAll(_ urls: [URL]) async throws -> [Data] {
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask { try await URLSession.shared.data(from: url).0 }
}
var out: [Data] = []
for try await d in group { out.append(d) }
return out
}
}
// 현재 이 프로세스의 커널 스레드 수를 직접 확인해 보면 차이가 보인다
func threadCount() -> Int {
var count: mach_msg_type_number_t = 0
var list: thread_act_array_t?
guard task_threads(mach_task_self_, &list, &count) == KERN_SUCCESS, let list else { return 0 }
// ① 스레드 포트 권한부터 반납 — 안 하면 호출마다 포트 누수
for i in 0..<Int(count) { mach_port_deallocate(mach_task_self_, list[i]) }
// ② 그다음 배열 메모리 해제
vm_deallocate(mach_task_self_,
vm_address_t(UInt(bitPattern: list)),
vm_size_t(Int(count) * MemoryLayout<thread_t>.size))
return Int(count)
}
// Thread를 직접 써야 하는 경우는 사실상 하나뿐이다:
// 스레드에 묶인 레거시(전용 RunLoop, 일부 C 라이브러리 컨텍스트)를 가둘 때.② UIKit/SwiftUI 갱신은 반드시 main thread(= thread 0). 백그라운드 스레드에서 UI를 건드리면 크래시 또는 미정의 동작.
import UIKit
// ❌ 런타임 규칙에만 의존 — 백그라운드에서 UI를 만지면 크래시나 미정의 동작
final class OldLoader {
func load(into label: UILabel) {
URLSession.shared.dataTask(with: url) { data, _, _ in
label.text = "완료" // 💥 백그라운드 스레드
}.resume()
}
private let url = URL(string: "https://x")!
}
// ✅ @MainActor로 격리하면 잘못된 호출이 컴파일 에러가 된다
@MainActor
final class Loader {
func load(into label: UILabel) async throws {
let (data, _) = try await URLSession.shared.data(from: url)
label.text = String(data: data, encoding: .utf8) // ✅ 항상 메인
}
private let url = URL(string: "https://x")!
}
// ✅ 레거시 콜백 경계에서는 명시적으로 hop한다
func legacyBridge(_ label: UILabel) {
someLegacyAPI { text in
Task { @MainActor in
label.text = text
}
}
}
// ✅ 개발 중 방어선 — 서드파티 콜백이 어느 스레드로 오는지 모를 때
func applyUnknownThread(_ label: UILabel, _ text: String) {
dispatchPrecondition(condition: .onQueue(.main))
label.text = text
}
// Xcode의 Main Thread Checker(Scheme → Diagnostics)를 켜 두면
// 백그라운드에서의 UIKit 호출을 런타임에 즉시 잡아 준다.
func someLegacyAPI(_ done: @escaping (String) -> Void) {}③ 변수명·로그에서 Mach task와 Swift Task를 섞어 쓰지 말 것. 리뷰에서 혼란의 원천이다.
import Foundation
// 같은 단어가 전혀 다른 두 개념을 가리킨다:
// Mach task = 커널 자원 컨테이너 (프로세스 하나 = task 하나)
// Swift Task = 비동기 작업 단위 (수천 개가 스레드 몇 개 위에서 돈다)
// ❌ 이런 코드가 한 파일에 섞여 있으면 리뷰에서 반드시 혼선이 생긴다
func confusing() {
let task = Task { await work() } // Swift Task
_ = task
let t = mach_task_self_ // Mach task
_ = t
}
// ✅ 이름으로 층위를 드러낸다
func clear() {
let refreshJob = Task { await work() } // "Job" — 비동기 작업
_ = refreshJob
let currentMachTask = mach_task_self_ // "MachTask" — 커널 자원
_ = currentMachTask
}
// ✅ 로그 문자열에서도 마찬가지
func logging() {
let job = Task { await work() }
print("[job] 시작") // Swift Task
_ = job
var info = task_vm_info_data_t()
var count = mach_msg_type_number_t(
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size)
_ = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
}
}
print("[mach-task] footprint \(info.phys_footprint / 1024 / 1024)MB")
}
// 팀 컨벤션 예시:
// Swift Task 변수 → xxxJob / xxxTask는 지양
// Mach 관련 → machXxx 접두
func work() async {}"iOS는 프로세스 개념이 없고 스레드만 있다"는 틀렸다. 앱은 엄연히 독립 프로세스(각자 PID·주소공간)다. 또 "Swift의 Task가 곧 Mach task"라는 것도 틀렸다 — Swift Task는 커널 자원이 아니라 async 작업 단위이고, 여러 Task가 스레드 하나를 나눠 쓰거나 한 Task가 여러 스레드를 오갈 수도 있다.
컴퓨터 안에 작업실이 하나 있다고 생각해 보자. 방, 책상, 서랍, 창고 열쇠는 전부 그 작업실에 딸린 물건이고 — 이게 프로세스(Mach task)다. 그런데 방은 스스로 일하지 않는다. 실제로 손을 움직여 일하는 건 그 안을 돌아다니는 직원들이고, 그게 thread다. 직원이 몇 명이든 서랍은 하나뿐이라 서로 같은 물건을 꺼내 쓴다 — 스레드끼리 메모리를 공유한다는 말이 딱 이 뜻이다. 그럼 BSD proc은 뭐냐면, 이 작업실에 붙은 사업자등록증 같은 거다. 실체(방)는 Mach task 하나인데, 유닉스 세계가 알아볼 수 있도록 PID라는 번호표를 붙여 준 것뿐이라 결국 같은 대상을 두 이름으로 부르는 셈이다.
여기서 제일 많이 걸려 넘어지는 게 Swift의 Task { }다. 이건 방도 아니고 직원도 아니고, 책상 위에 붙여 둔 "할 일 쪽지"에 가깝다. 쪽지 한 장을 직원 여럿이 번갈아 처리할 수도 있고, 직원 한 명이 쪽지 여러 장을 왔다 갔다 하며 처리할 수도 있다. 이름만 같을 뿐 층이 완전히 다르다.
꼬리 질문
WKWebView를 띄운 앱은 실제로 몇 개의 Mach task(프로세스)가 관여하나 — 웹 콘텐츠 렌더링(WebContent)과 네트워킹이 왜 앱 본체와 분리된 별도 프로세스에서 도나?
WKWebView 하나를 띄우면 앱 본체(Mach task) 말고도 웹 콘텐츠를 파싱하고 JS를 실행·렌더링하는 WebContent 프로세스, 그리고 그 네트워크 요청을 대신 처리하는 Networking 프로세스가 함께 뜬다. 개수는 고정이 아니다 — Networking은 보통 앱당 하나를 공유하지만 WebContent는 프로세스 풀 구성에 따라 웹뷰마다 여러 개가 될 수 있고, 최신 WebKit은 미디어·캔버스/WebGL 합성을 맡는 GPU 프로세스를 필요할 때 하나 더 띄운다. WebKit이 이렇게 쪼갠 근거는 권한 분리(privilege separation)다 — 임의의 웹 JS·HTML은 신뢰할 수 없는 코드라, 이를 앱 주소공간 밖의 별도 task에서 극도로 좁은 샌드박스 프로필로 돌려 렌더러가 익스플로잇당해도 앱 본체 메모리나 키체인에 곧바로 닿지 못하게 막는다. 그래서 웹페이지가 크래시하거나 자기 메모리 한도를 넘겨도 그 WebContent 프로세스만 죽어 흰 화면이 남을 뿐 앱 자체는 살아 있는데, 이는 Q1의 '앱 하나 = task 하나'가 어디까지나 앱 자기 코드에 한정된 이야기임을 드러낸다.
fork()가 iOS 앱에서 사실상 금지된 이유는 Mach task 구조와 어떻게 얽혀 있나?
Mach task는 포트 권한·스레드·디스패치 큐 상태 같은 복잡한 커널 자원을 품는데, fork()는 이 중 스레드는 호출 스레드 하나만 복제하고 나머지 상태(특히 GCD 워커나 락)는 어정쩡하게 남긴다. 그래서 fork() 직후 곧바로 exec()하지 않고 Objective-C 런타임이나 GCD가 얽힌 라이브러리 코드를 부르면 락이 깨진 채라 데드락·크래시가 나기 쉽다. 게다가 앱 샌드박스는 임의 자식 프로세스 생성 자체를 막고, 정식 프로세스 생성 경로는 posix_spawn뿐이라 사실상 쓸 자리가 없다.
fork()는 작업실을 통째로 복사하는데, 그 안에서 일하던 직원은 복사를 명령한 한 명만 따라간다. 문제는 사라진 직원이 잠가 둔 문(락)이 잠긴 채로 복사된다는 것이다. 복사본 쪽에서는 그 문을 열어 줄 사람이 이미 없으니 영영 안 열린다 — 이게 fork() 뒤에 아무 라이브러리나 부르면 멈춰 버리는 이유다. 게다가 샌드박스가 자식 프로세스 만들기 자체를 막아서 iOS 앱에는 애초에 쓸 자리가 없다.GCD의 dispatch queue가 늘 새 스레드를 만들지 않는데, 그럼 큐와 스레드의 관계는 몇 대 몇인가?
고정 대응이 아닌 다대다(M:N)다. 큐는 그저 논리적 작업 대기열이고, GCD는 내부 스레드 풀에서 필요할 때 워커 스레드를 꺼내 큐에 쌓인 블록을 실행한다. 동시 큐 하나가 여러 스레드에서 병렬로 돌 수도, 스레드 하나가 여러 큐를 번갈아 처리할 수도 있다. GCD는 활성 코어 수에 맞춰 스레드를 아끼려 하지만, 블로킹 호출로 워커가 묶이면 새 스레드를 계속 만들어 thread explosion이 날 수 있다.
Q2. User mode와 Kernel mode는 왜 분리되어 있는가?
아무 코드나 하드웨어·다른 프로세스 메모리·전체 물리 메모리를 직접 만지게 두면 버그 하나가 시스템 전체를 망가뜨린다. 그래서 CPU가 특권 수준을 하드웨어로 강제한다. 유저 코드는 낮은 특권(제한된 명령·자기 주소공간만)에서 돌고, 커널만 특권 명령을 쓴다. 경계를 넘으려면 반드시 trap(system call·인터럽트·예외)을 통해 커널이 검사하는 정문으로만 들어가야 한다. iOS(ARM64)에선 이게 Exception Level EL0(앱) / EL1(XNU 커널)로 구현된다.
CS 원리
보호(protection)의 핵심은 격리 + 매개된 접근이다. CPU는 최소 두 개의 특권 수준을 갖고, 현재 수준을 나타내는 상태 비트를 하드웨어가 관리한다. 유저 수준에서는 I/O 명령, MMU 페이지 테이블 조작, 인터럽트 마스킹 같은 특권 명령이 금지된다. 시도하면 CPU가 예외를 발생시켜 커널로 제어가 넘어간다. 덕분에 (1) 프로세스 간 메모리 격리(MMU가 각 프로세스에 다른 페이지 테이블), (2) 자원 중재(커널이 스케줄러·파일·네트워크 게이트키퍼), (3) 안정성(유저 크래시가 커널을 죽이지 못함)이 성립한다.
iOS에서는
ARM64(AArch64)는 4개의 Exception Level을 정의한다.
| Level | 무엇이 도는가 | 권한 |
|---|---|---|
| EL0 | 모든 앱·App Extension (유저 공간) | 특권 명령·MMU 조작 불가 |
| EL1 | XNU 커널·드라이버 | 페이지 테이블·스케줄러·디바이스 |
| EL2 | 하이퍼바이저 (가상화) | EL1 위 관리 |
| EL3 | Secure Monitor (TrustZone) | 보안/일반 세계 전환 |
앱 코드는 EL0에서만 돈다. 커널로 들어가려면 SVC(Supervisor Call) 명령으로 동기 예외를 일으켜 EL1의 예외 벡터로 점프한다. Apple은 여기에 하드웨어 방어를 더 쌓는다: PAN(Privileged Access Never — 커널이 실수로 유저 메모리를 직접 못 읽게), PAC(Pointer Authentication — 리턴 주소 위변조 탐지), KTRR/CTRR(부팅 후 커널 텍스트를 하드웨어로 read-only 고정)가 대표적이다. 이것이 iOS 커널을 통째로 갈아엎기 어렵게 만드는 근간이다.
실험 · 도구
모드 전환 비용은 실측 가능하다. 아무 일도 안 하는 syscall을 수백만 번 반복해 평균 시간을 재면 user↔kernel 왕복 오버헤드의 하한이 나온다. 단 getpid()는 Darwin이 libSystem에서 값을 캐시해 두 번째 호출부터 trap이 일어나지 않으므로 측정용으로 쓰면 안 되고, 캐시되지 않는 getppid()나 close(-1) 같은 호출을 써야 한다. Instruments의 System Trace 템플릿은 각 syscall과 VM fault, 스레드 상태 전환을 타임라인에 그려 준다.
프로젝트 적용
① syscall은 공짜가 아니다. 타이트한 루프에서 read()를 바이트 단위로 부르지 말고 버퍼링하라 — 모드 전환 횟수를 줄이는 게 곧 성능이다.
import Foundation
// 모드 전환(EL0→EL1)은 한 번당 수백 ns~µs다. 데이터 양보다 '횟수'가 비용을 정한다.
// ❌ 한 바이트마다 syscall — 1MB 파일이면 100만 번의 왕복
func readByteByByte(_ url: URL) throws -> Int {
let fh = try FileHandle(forReadingFrom: url)
defer { try? fh.close() }
var count = 0
while let b = try fh.read(upToCount: 1), !b.isEmpty { count += b.count }
return count
}
// ✅ 큼직하게 읽는다 — 1MB 파일이면 read는 1~2번(+open·close)
func readBuffered(_ url: URL) throws -> Int {
let fh = try FileHandle(forReadingFrom: url)
defer { try? fh.close() }
var count = 0
while let chunk = try fh.read(upToCount: 1 << 20), !chunk.isEmpty {
count += chunk.count
}
return count
}
// 차이를 직접 재 보면 자릿수가 다르다
func compare(_ url: URL) throws {
let clock = ContinuousClock()
let a = try clock.measure { _ = try readByteByByte(url) }
let b = try clock.measure { _ = try readBuffered(url) }
print("바이트 단위 \(a) vs 버퍼드 \(b)")
}
// 같은 원리가 쓰기·로그에도 적용된다
// ❌ 루프 안에서 write
// ✅ 메모리에 모았다가 한 번에
func writeLog(_ lines: [String], to url: URL) throws {
let blob = lines.joined(separator: "\n")
try blob.write(to: url, atomically: true, encoding: .utf8)
}
// 네트워크도 마찬가지다 — 작은 요청 100개보다 배치 1개가 거의 항상 빠르다② 앱은 EL0라서 커널 메모리·다른 앱 메모리를 절대 직접 못 본다. "옆 앱 데이터를 메모리에서 읽자" 같은 발상은 하드웨어가 막는다(→ Q7 샌드박스와 결합).
import Foundation
// 앱은 EL0에서 돈다. 커널 메모리도, 다른 앱의 메모리도 직접 볼 수 없다.
// "메모리에서 옆 앱 데이터를 읽자" 같은 발상은 하드웨어가 막는다.
// ❌ 다른 앱 컨테이너에 접근 시도 — 존재해도 열리지 않는다
func tryReadOtherApp() {
let path = "/var/mobile/Containers/Data/Application/OTHER-UUID/Documents/db.sqlite"
do {
_ = try Data(contentsOf: URL(fileURLWithPath: path))
} catch {
print("차단됨: \(error)") // MAC(샌드박스 프로필)이 open을 거부
}
}
// ✅ 데이터를 나눠야 하면 OS가 제공하는 정식 통로를 쓴다
func sharedContainer() -> URL? {
FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.company.app")
}
// ✅ 자격증명 공유는 Keychain access group
func sharedKeychainQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccessGroup as String: "TEAMID.com.company.shared",
kSecAttrAccount as String: "token"
]
}
// ✅ 다른 앱과의 상호작용은 URL Scheme / Universal Link / 공유 시트
func openPartnerApp() {
if let url = URL(string: "partnerapp://open?ref=abc") {
// UIApplication.shared.open(url)
_ = url
}
}
// 실무 함의: "권한을 우회하는 방법"을 찾는 대신
// 처음부터 App Group·Keychain group·확장을 전제로 아키텍처를 잡는다."user/kernel 분리는 소프트웨어(OS)가 소프트웨어적으로 지키는 규칙"이라는 오해. 실제로는 CPU 하드웨어가 강제한다. EL0에서 특권 명령을 실행하려 하면 OS가 착해서가 아니라 칩이 즉시 예외를 던진다. 또 "커널 모드면 뭐든 다 할 수 있다"도 반쪽이다 — Apple은 KTRR·PAC 등으로 커널조차 못 하는 것(자기 코드 수정 등)을 하드웨어로 만들어 뒀다.
여객기를 떠올려 보자. 승객은 객실(EL0)에만 있을 수 있고 조종실(EL1) 문은 잠겨 있다. 중요한 건 승객이 예의가 발라서 안 들어가는 게 아니라는 점이다 — 문이 진짜 방탄문이라 손잡이를 아무리 당겨도 안 열린다. "특권 수준을 CPU 하드웨어가 강제한다"는 말이 바로 이 얘기다. 그래도 승객이 뭔가 필요하면(물 한 잔, 파일 읽기) 호출 버튼을 누른다 — 그게 SVC 명령이다. 버튼을 누르면 요청이 정해진 통로로만 전달되고, 받은 쪽이 "이 사람이 이걸 요구해도 되는가"를 확인한 뒤 처리해서 다시 객실로 돌려보낸다(ERET). 드나드는 문을 하나로 몰아 둬야 검사도 한 곳에서 끝나기 때문이다.
비유가 깨지는 지점도 하나 짚어 두자. 실제 비행기 조종사는 조종실 안에서 사실상 뭐든 할 수 있지만, iOS 커널은 그렇지 않다. PAC(리턴 주소가 위조됐는지 하드웨어가 서명으로 확인하는 장치)나 KTRR(부팅 후 커널 코드 영역을 칩 차원에서 읽기 전용으로 굳혀 버리는 장치) 때문에, 커널조차 자기 코드를 고치지 못한다. 조종실 안에 또 잠긴 서랍이 있는 셈이다.
꼬리 질문
인터럽트(하드웨어)와 system call(소프트웨어)은 둘 다 EL1로 trap하는데, 커널은 이 둘을 어떻게 구분해 처리하나?
ARM64는 예외 종류를 하드웨어적으로 갈라 준다. SVC로 인한 syscall은 실행 흐름이 스스로 일으키는 동기 예외라 예외 벡터 테이블(VBAR_EL1)의 synchronous 엔트리로 들어오고, 하드웨어 인터럽트(IRQ/FIQ)는 실행과 무관하게 바깥에서 오는 비동기 신호라 별도 벡터로 진입한다. 동기 예외는 다시 ESR_EL1의 exception class 필드로 SVC인지 페이지 폴트인지까지 세분되므로, 커널은 벡터 오프셋 + ESR만 보고 적절한 핸들러로 분기한다.
ESR_EL1이라는 "무슨 일로 왔는지 적힌 쪽지"를 들고 오기 때문에, 커널은 어느 문 + 쪽지 두 가지만 보면 담당자를 바로 찾는다. 사람이 눈치로 구분하는 게 아니라 하드웨어가 미리 갈라서 넣어 준다.Secure Enclave는 EL3에서 도는가, 아니면 별도 코프로세서인가?
메인 CPU의 EL3 소프트웨어가 아니라 물리적으로 분리된 별도 코프로세서(SEP)다. 자체 프로세서·암호화된 메모리·보안 부트를 가지며 애플리케이션 프로세서와는 하드웨어 메일박스 IPC로만 통신하고, 키 원본은 SEP 밖으로 절대 나오지 않는다. TrustZone의 EL3(Secure Monitor)은 같은 CPU 안에서 secure world와 normal world를 전환하는 별개 개념이라 SEP와 혼동하면 안 된다.
Rosetta나 시뮬레이터처럼 x86 코드가 도는 환경에서 특권 경계는 어떻게 유지되나?
특권 경계는 여전히 하드웨어 EL0/EL1로 그대로 유지된다. Rosetta 2는 x86_64 명령을 ARM64로 번역(AOT+JIT)해 실행할 뿐이고, 번역된 코드도 결국 EL0에서 도는 유저 명령이라 커널 진입은 ARM64 SVC로 이뤄진다 — Rosetta 런타임이 x86 syscall 규약을 ARM64 syscall로 바꿔 준다. iOS 시뮬레이터는 아예 에뮬레이션이 아니라 Mac 커널 위에서 도는 네이티브 macOS 프로세스라, iOS 커널이 아니라 macOS의 EL0/EL1 경계를 빌려 쓴다.
SVC다. 시뮬레이터는 더 싱겁다 — iOS 기기를 흉내 내는 게 아니라 그냥 맥에서 도는 맥 프로그램이라, 맥이 원래 쓰던 경계를 그대로 빌려 쓴다.Q3. System call은 무엇이며 Foundation API와 어떤 관계인가?
System call은 유저 코드가 커널 서비스(파일·네트워크·메모리·시간)를 요청하는 유일한 공식 관문이다. ARM64에선 SVC #0x80 명령으로 EL0→EL1 trap해서 들어간다. Foundation은 이 syscall을 직접 부르지 않는다. Foundation → libSystem(특히 libsystem_kernel.dylib)의 얇은 래퍼 → SVC → XNU 순서다. 즉 FileManager.copyItem 한 줄 뒤에는 open/read/write 같은 BSD syscall이 줄줄이 깔려 있다. 앱이 raw svc 명령을 손으로 만들 일은 없고, open 같은 POSIX 함수는 공개 API라 써도 되지만 보통은 Foundation 같은 상위 프레임워크를 쓴다.
CS 원리
커널은 자기가 관리하는 자원을 함부로 못 만지게 하면서도 유저에게 서비스는 줘야 한다. 그 타협이 system call — 미리 정의된 번호가 붙은 진입점 집합이다. 유저는 (1) syscall 번호와 인자를 약속된 레지스터에 채우고 (2) trap 명령을 실행한다. 커널은 벡터에서 받아 번호로 핸들러를 찾고, 인자를 검증한 뒤 수행하고, 결과를 레지스터에 담아 유저로 복귀시킨다. 이 "번호+검증"이 API 경계이자 보안 경계다.
iOS에서는
XNU의 syscall은 두 갈래다. BSD syscall(양수 번호 — open, read, mmap 등 POSIX)과 Mach trap(음수 번호 — mach_msg, 포트 조작 등 IPC). 앱은 이 중 어느 것도 손으로 svc를 쓰지 않는다. 대신 Foundation이 오브젝트 지향 API를 제공하고, 그 내부(iOS에서는 Apple의 Foundation 구현 — 과거 ObjC 기반, 최신 OS는 새 swift-foundation; swift-corelibs-foundation은 Linux 등 비-Apple 플랫폼용)가 C 라이브러리와 libSystem을 호출하며, 최종적으로 libsystem_kernel.dylib의 스텁이 svc #0x80을 실행한다. 예: Data(contentsOf:)는 open 후 파일 크기에 따라 read 또는 mmap으로, URLSession은 socket/connect 계열로 내려간다. 중요한 구분: open·read·socket 같은 POSIX libc 함수는 공개 API라 App Store 앱에서도 정상적으로 쓸 수 있다. 하지만 그 아래 raw syscall 번호와 svc ABI 자체는 안정된 계약이 아니어서 Apple이 언제든 바꿀 수 있으니, 앱은 손으로 trap을 만들지 말고 libSystem 함수나 상위 프레임워크를 통해서만 접근해야 한다.
실험 · 도구
Foundation 호출이 어떤 syscall로 내려가는지 직접 볼 수 있다. LLDB에서 저수준 함수에 심볼릭 브레이크포인트를 걸면 Foundation → C 함수 호출 스택이 드러난다.
# open(2) syscall 스텁에 브레이크포인트
(lldb) breakpoint set -n open
(lldb) continue
# FileManager.default.copyItem(...) 이 실행되는 순간 멈춘다.
(lldb) bt # 백트레이스: open ← ... ← Foundation ← 내 코드
# 관찰: Foundation 한 호출이 open/fstat/read/close로 분해됨macOS라면 dtruss -f ./myapp(dtrace 기반)로 프로세스가 발생시키는 모든 syscall을 실시간으로 덤프해 같은 사실을 확인할 수 있다. fs_usage도 같은 용도로 쓸 수 있는데, 둘 다 맥 로컬 커널을 대상으로 도는 CLI라 iOS 기기에는 없다. 기기에서는 Instruments의 System Trace·File Activity 템플릿이 그 역할을 대신한다.
프로젝트 적용
① syscall은 왕복 비용이 크다(Q2). 루프에서 파일을 한 바이트씩 읽지 말고 Data(contentsOf:)나 버퍼드 스트림으로 syscall 횟수 자체를 줄여라.
import Foundation
// FileManager.copyItem 한 줄 뒤에는 open/read/write/close가 줄줄이 깔린다.
// API가 편하다고 호출 횟수까지 공짜인 건 아니다.
// ❌ 루프에서 파일 속성을 매번 조회 — 파일 수 × syscall 수
func totalSizeSlow(_ urls: [URL]) throws -> Int {
var total = 0
for url in urls {
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
total += (attrs[.size] as? Int) ?? 0 // 파일마다 stat()
}
return total
}
// ✅ 한 번의 열거로 필요한 키를 함께 가져온다
func totalSizeFast(_ directory: URL) throws -> Int {
let keys: [URLResourceKey] = [.fileSizeKey]
let urls = try FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: keys)
return try urls.reduce(0) { sum, url in
sum + (try url.resourceValues(forKeys: Set(keys)).fileSize ?? 0)
}
}
// ✅ 디렉터리 순회는 enumerator에 키를 미리 지정하면 stat 횟수가 준다
func walk(_ root: URL) throws {
let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey]
guard let e = FileManager.default.enumerator(
at: root, includingPropertiesForKeys: keys) else { return }
for case let url as URL in e {
let v = try url.resourceValues(forKeys: Set(keys)) // 캐시된 값을 읽는다
_ = v.fileSize
}
}
// 확인 방법: LLDB로 syscall에 브레이크포인트를 걸어 호출 횟수를 센다
// (lldb) breakpoint set -n open
// (lldb) breakpoint command add -o "bt 3" -o "continue"② 비공개 프레임워크나 dlsym으로 문서화되지 않은 심볼을 직접 부르지 말 것 — App Store 리뷰 거부 + iOS 버전마다 깨진다. 공개 프레임워크·공개 POSIX API가 유일한 안정 계약이다.
import Foundation
// ❌ dlsym으로 문서화되지 않은 심볼을 부르는 코드
// → App Store 심사 거부 + iOS 버전마다 깨진다
func privateSymbolCall() {
guard let handle = dlopen(nil, RTLD_NOW) else { return }
defer { dlclose(handle) }
if let sym = dlsym(handle, "__undocumented_internal_api") {
_ = sym // 다음 iOS에서 사라지면 그날로 앱이 죽는다
}
}
// ❌ 비공개 프레임워크 헤더를 복사해 쓰는 것도 같은 문제다
// (심사 자동 스캐너가 심볼 이름으로 잡아낸다)
// ✅ 안정된 계약은 두 가지뿐이다: 공개 프레임워크와 공개 POSIX API
func supportedWays() throws {
// ① 공개 프레임워크 (권장)
let docs = try FileManager.default.url(
for: .documentDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
_ = docs
// ② 공개 POSIX — 문서화돼 있고 써도 된다
let fd = open("/dev/null", O_RDONLY)
if fd >= 0 { close(fd) }
// ③ 공개 Darwin API
print(ProcessInfo.processInfo.physicalMemory)
}
// ✅ 원하는 기능이 공개 API에 없다면
// · Feedback Assistant로 요청하고
// · 그동안은 다른 설계로 우회한다
// (비공개 API로 우회하면 심사와 OS 업데이트라는 두 폭탄을 동시에 안는다)
// raw syscall 번호와 svc ABI 자체도 안정된 계약이 아니다 —
// 앱은 반드시 libSystem 래퍼(open/read/…)를 통해서만 접근해야 한다."Foundation은 순수 Swift/Objective-C로 자족적으로 구현돼 있다"는 오해. Foundation의 파일·네트워크·시간·프로세스 기능은 결국 C 라이브러리와 syscall 위에 얹힌 얇은 껍데기다. 반대로 "그럼 raw syscall을 직접 만들면 더 빠르겠네"도 오해다 — 오버헤드는 대부분 trap 자체가 아니라 실제 I/O에 있고, libSystem을 우회해 손으로 svc를 쓰면 이식성·안정성만 해친다.
식당에서 "김치찌개 하나요"라고 말하는 장면을 떠올리면 된다. 손님(앱)은 주방(커널)에 들어갈 수 없다. 대신 메뉴판에 적힌 이름만 말하면 되는데, 그 메뉴판이 Foundation이다. FileManager.copyItem 한 줄은 메뉴 이름 하나일 뿐이고, 주방 안에서는 냄비 꺼내고 불 켜고 재료 넣는 여러 동작 — open, read, write, close — 으로 쪼개진다. 그 사이에서 주문을 받아 주방 창구로 밀어 넣어 주는 종업원이 libSystem이고, 창구에 밀어 넣는 그 동작 자체가 SVC #0x80이다.
여기서 흔한 착각이 "종업원 건너뛰고 내가 주방에 대고 직접 소리치면 더 빠르지 않나"인데, 대개 아니다. 오래 걸리는 건 주문 전달이 아니라 요리 자체(실제 디스크·네트워크 I/O)라서 그렇다. 대신 진짜로 이득인 건 주문 횟수를 줄이는 쪽이다. 반찬 하나하나를 따로 시키면 창구를 백 번 왔다 갔다 해야 하지만, 한 상 차림으로 한 번에 시키면 왕복이 한 번이다. 파일을 1바이트씩 읽지 말고 버퍼로 읽으라는 조언이 정확히 이 얘기다.
꼬리 질문
mach_msg가 왜 Mach trap(음수)이고 read는 BSD syscall(양수)인가 — 이 구분이 XNU 구조와 어떻게 연결되나?
XNU가 Mach와 BSD를 합친 하이브리드라 syscall 네임스페이스가 둘로 갈리기 때문이다. 양수 번호는 BSD 계층의 POSIX syscall(read·open 등), 음수 번호는 Mach 계층의 trap(mach_msg·포트 조작 등 IPC/VM)이다. 커널은 SVC 진입 시 요청 번호의 부호를 보고 어느 디스패치 테이블(BSD의 sysent vs Mach trap table)로 보낼지 라우팅한다 — 부호는 우선순위가 아니라 "이 요청이 어느 세계 것이냐"를 가르는 태그다.
vDSO/commpage처럼 trap 없이 커널 데이터를 읽는 최적화(예: 시간 조회)가 iOS에도 있나?
있다. iOS/macOS는 모든 프로세스 주소공간에 커널이 값을 채워 주는 읽기 전용 commpage를 매핑해 두고, 시각이나 CPU 능력 비트처럼 자주 읽는 값을 SVC 없이 바로 읽게 한다. 리눅스 vDSO와 같은 발상으로, 순수 읽기 연산에서 user↔kernel 왕복 비용을 없애 훨씬 싸다. 그래서 mach_absolute_time 계열은 실제로는 syscall이 아니라 commpage 접근에 가깝다.
SVC)을 넘을 필요가 없어 훨씬 싸다. 대신 이 시계는 읽기만 되는 게시판이라, 뭔가를 시키려면 여전히 창구로 가야 한다.Swift Concurrency의 await가 걸린 I/O는 어느 시점에 syscall로 내려가고, 그동안 스레드는 무엇을 하나?
await 자체는 syscall이 아니라 잠재적 suspension point 표시일 뿐이다. 실제 syscall은 URLSession이나 파일 API가 내부에서 논블로킹 소켓·kqueue·dispatch_source로 I/O를 걸 때 내려간다. 데이터가 아직 안 왔으면 그 스레드는 블록되지 않고 협력 스레드 풀에 반납되어 다른 Task를 실행하고, I/O 완료 이벤트가 오면 continuation이 다시 스케줄돼 (같을 필요 없는) 스레드에서 이어진다 — 스레드를 잡고 자는 게 아니라 놓아주는 게 핵심이다.
await는 "여기서 잠깐 멈출 수도 있음"이라고 붙여 둔 표시일 뿐, 그 줄 자체가 커널에 뭘 시키지는 않는다. 진짜 요청은 URLSession 같은 게 안에서 소켓을 걸 때 나간다. 그리고 답을 기다리는 동안 그 스레드는 자리에 앉아 조는 게 아니라 일어나서 다른 Task를 하러 간다. 응답이 오면 멈췄던 지점(continuation)이 다시 줄을 서고, 그때 그 일을 이어받는 사람이 아까 그 사람이 아니어도 상관없다. 그래서 await를 많이 써도 스레드가 늘어나지 않는다.Q4. 앱이 Active, Background, Suspended 상태가 될 때 Process와 Thread는 어떻게 되는가?
앱 생명주기 상태는 OS의 스케줄링 정책일 뿐, 프로세스의 생사와 다르다. Active·Background·Suspended 모두 프로세스(Mach task)는 메모리에 그대로 살아 있다. 차이는 스레드에 CPU를 주느냐다. Active/Background에선 스레드가 실행되고(Background는 제한된 시간·용도), Suspended에선 모든 스레드가 얼어붙어 CPU를 한 톨도 못 받는다(task_suspend 유사, SIGSTOP처럼). 메모리 이미지는 보존되므로 재개(resume)는 즉각적이다.
CS 원리
스케줄러 관점에서 스레드는 여러 상태를 오간다: Running(CPU 점유), Runnable/Ready(줄 서 있음), Blocked/Waiting(I/O 등 대기). 여기에 OS는 정지(stopped/suspended)라는 상태를 추가로 둘 수 있다 — 프로세스는 메모리에 존재하지만 스케줄러가 선택 대상에서 아예 제외하는 상태다. 유닉스의 SIGSTOP/SIGCONT가 고전적 예다. 이때 주소공간·레지스터·스택은 그대로 보존되므로 재개 시 끊긴 지점부터 이어진다.
iOS에서는
UIKit 생명주기 상태는 Not running → Inactive → Active → Background → Suspended다(SwiftUI의 ScenePhase는 .active/.inactive/.background로 이를 노출).
| 상태 | 프로세스(task) | 스레드 |
|---|---|---|
| Active (foreground) | 메모리에 상주 | 정상 스케줄, 이벤트 수신 |
| Background | 메모리에 상주 | 제한적 실행 — 보통 몇 초, background mode/BGTask면 연장 |
| Suspended | 메모리에 상주 (동결) | 전면 정지 — CPU 배정 없음 |
| Not running | 메모리에 없음 | 없음 |
포그라운드→백그라운드 전환 시 sceneDidEnterBackground(_:)가 불리고, 남은 정리를 할 짧은 시간이 주어진다. 이 시간이 끝나면 OS가 앱을 Suspended로 내려 모든 스레드를 동결한다. 음악 재생·위치 추적 같은 특정 background mode나 beginBackgroundTask로 연장을 요청한 경우에만 스레드가 계속 돈다.
프로젝트 적용
① 백그라운드 진입 콜백에서 오래 걸리는 작업을 새로 시작하지 말 것. 곧 Suspended로 동결되어 작업이 중간에 멈춘다. 진짜 필요하면 BGTaskScheduler로 OS에 정식 예약하라.
import UIKit
import BackgroundTasks
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// ❌ 여기서 새 작업을 시작하면 곧 Suspended로 얼어 중간에 멈춘다
func sceneDidEnterBackgroundBad(_ scene: UIScene) {
Task { await syncEverything() } // 몇 초 뒤 동결
}
// ✅ ① 짧은 마무리만 하고, 필요하면 유예 시간을 명시적으로 요청한다
func sceneDidEnterBackground(_ scene: UIScene) {
var bgTask: UIBackgroundTaskIdentifier = .invalid
bgTask = UIApplication.shared.beginBackgroundTask(withName: "flush") {
// 시간이 다 되면 여기가 불린다 — 반드시 정리하고 end를 부른다
UIApplication.shared.endBackgroundTask(bgTask)
bgTask = .invalid
}
Task {
await store.flushPending() // 수십 초 이내의 마무리 작업만
UIApplication.shared.endBackgroundTask(bgTask)
bgTask = .invalid
}
}
// ✅ ② 긴 작업은 OS에 정식으로 예약한다
func scheduleSync() {
let request = BGProcessingTaskRequest(identifier: "com.app.sync")
request.requiresNetworkConnectivity = true
request.requiresExternalPower = false
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
try? BGTaskScheduler.shared.submit(request)
}
}
// AppDelegate에서 핸들러를 등록해 둬야 한다
func registerTasks() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.sync", using: nil
) { task in
Task {
await syncEverything()
task.setTaskCompleted(success: true)
}
task.expirationHandler = { /* 정리 */ }
}
}
enum store { static func flushPending() async {} }
func syncEverything() async {}② Suspended 직전에 열어 둔 파일·SQLite 락·소켓을 정리하라. 이걸 안 쥐고 있으면 종료 사유가 된다(→ Q6의 0xdead10cc).
import UIKit
import SQLite3
// suspend 중에 파일 락·SQLite 락·소켓을 쥐고 있으면 0xdead10cc로 종료된다.
// "다른 프로세스가 쓰려는데 얼어붙은 앱이 붙들고 있다"는 이유다.
final class Database {
private var db: OpaquePointer?
func open(_ path: String) {
sqlite3_open(path, &db)
}
// ✅ 백그라운드 진입 전에 트랜잭션을 끝내고 연결을 닫는다
func prepareForBackground() {
if let db {
sqlite3_exec(db, "COMMIT;", nil, nil, nil) // 열린 트랜잭션 종료
sqlite3_close_v2(db) // 락 해제
self.db = nil
}
}
}
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
let database = Database()
var fileHandle: FileHandle?
var socket: FileHandle?
func sceneDidEnterBackground(_ scene: UIScene) {
// ① 쥐고 있던 것들을 순서대로 놓는다
database.prepareForBackground()
try? fileHandle?.close(); fileHandle = nil
try? socket?.close(); socket = nil
// ② Core Data라면 저장 후 컨텍스트를 비운다
// try? viewContext.save(); viewContext.reset()
}
func sceneWillEnterForeground(_ scene: UIScene) {
database.open(dbPath) // 돌아올 때 다시 연다
}
private let dbPath = NSTemporaryDirectory() + "app.sqlite"
}
// ⚠️ 파일 보호 클래스가 .complete인 파일을 열어 둔 채 기기가 잠기면
// 접근이 막혀 같은 종류의 문제가 생긴다 — 잠금 전에 닫는 설계가 필요하다.③ 타이머·DispatchSourceTimer는 Suspended 동안 진행되지 않는다. "백그라운드에서 N초마다"를 스레드로 구현하려 하지 말 것.
import Foundation
import BackgroundTasks
// Suspended 상태에서는 스레드가 한 톨의 CPU도 못 받는다.
// Timer·DispatchSourceTimer·Thread 어느 것도 진행되지 않는다.
// ❌ "백그라운드에서 5분마다 동기화" — 앱이 포그라운드일 때만 돈다
func brokenPeriodicSync() {
Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { _ in
Task { await sync() }
}
}
// ✅ ① 주기 작업은 OS에 맡긴다 (실행 시점은 시스템이 정한다)
func scheduleRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
try? BGTaskScheduler.shared.submit(request)
}
func handleRefresh(_ task: BGAppRefreshTask) {
// ⚠️ 다음 예약은 지금 바로 걸어 둔다 — 안 그러면 다시는 안 불린다
scheduleRefresh()
let work = Task {
await sync()
task.setTaskCompleted(success: true)
}
task.expirationHandler = { work.cancel() }
}
// ✅ ② 서버가 먼저 알려야 하는 일이면 푸시(silent push)를 쓴다
// content-available: 1 → application(_:didReceiveRemoteNotification:fetchCompletionHandler:)
// (2-인자 버전은 iOS 10에서 deprecated고, 백그라운드 푸시 콜백이 아니다)
// ✅ ③ 포그라운드 복귀 시 '경과 시간'을 계산해 한 번에 따라잡는다
func catchUpOnForeground() async {
let last = UserDefaults.standard.object(forKey: "lastSync") as? Date ?? .distantPast
if Date().timeIntervalSince(last) > 300 {
await sync()
UserDefaults.standard.set(Date(), forKey: "lastSync")
}
}
// ⚠️ BGTaskScheduler는 "언제 돌지"를 보장하지 않는다. 사용자 습관·배터리·
// 네트워크 상태에 따라 몇 시간 뒤일 수도, 아예 안 돌 수도 있다.
// "정확히 N분마다"가 필요한 설계라면 그 요구사항 자체를 재검토해야 한다.
func sync() async {}"백그라운드로 가면 앱이 계속 코드를 돈다"는 오해. 기본값은 정반대다 — 짧은 유예 시간 뒤 Suspended로 동결되어 스레드가 한 줄도 실행되지 않는다. 반대로 "Suspended면 앱이 죽은 것"도 오해다(→ Q5) — 프로세스는 메모리에 그대로 있고 즉시 되살아난다.
게임을 하다가 일시정지를 누른 장면을 떠올려 보자. Active는 게임이 돌아가는 중이고, Background는 잠깐 화면 밖으로 밀려났지만 아직 몇 초쯤 움직일 수 있는 상태, Suspended는 완전 일시정지다. 일시정지를 눌러도 게임이 지워지지는 않는다 — 캐릭터 위치, 남은 목숨, 진행도는 전부 메모리에 그대로 있다. 다만 화면 속 무엇도 한 프레임조차 움직이지 않는다. 앱도 똑같아서 Suspended가 되면 타이머도, 도착한 네트워크 응답을 처리하려던 코드도, 백그라운드 스레드도 전부 그 자리에서 굳는다.
그래서 "백그라운드로 갈 때 오래 걸리는 작업을 시작해 두자"는 생각은 일시정지 버튼을 누르기 직전에 새 스테이지를 시작하는 것과 같다 — 시작하자마자 얼어붙는다. 정말 나중에 실행돼야 하는 일이라면 내 손으로 돌리지 말고 BGTaskScheduler로 OS에 "나중에 깨워 달라"고 예약을 걸어야 한다. 반대로 다시 앱을 열면 게임이 멈춘 그 프레임부터 이어지듯 앱도 멈춘 그 지점에서 이어지고, 그래서 재개가 그렇게 빠른 것이다.
꼬리 질문
Suspended 상태에서 푸시 알림이 오면 스레드는 어떻게 다시 깨어나 코드를 실행하나?
백그라운드 푸시(content-available)나 PushKit이 도착하면 APNs 데몬을 거쳐 시스템이 동결됐던 앱 task를 다시 runnable로 되돌리고(스레드 동결 해제) 짧은 실행 창을 준다. 앱은 application(_:didReceiveRemoteNotification:fetchCompletionHandler:) 같은 콜백에서 제한된 시간 안에 일을 마치고 completion handler를 불러야 하며, 늑장 부리면 다시 Suspended되거나 watchdog에 걸린다. 만약 프로세스가 이미 종료됐던 상태라면 warm resume이 아니라 cold launch로 새로 떠서 같은 콜백을 받는다.
beginBackgroundTask(expirationHandler:)의 expiration handler는 왜 "반드시 빨리 끝나야" 하나?
expiration handler는 "빌린 배경 실행 시간이 다 됐다"는 마지막 통보라, 여기서 짧은 시간 안에 endBackgroundTask로 정리를 마치지 못하면 OS가 앱을 강제 종료한다. 즉 새 작업을 시작하는 자리가 아니라 진행 중이던 걸 안전하게 접고 열린 파일·소켓·트랜잭션을 반납하는 자리다. 무거운 로직을 넣으면 정리를 끝내기도 전에 죽어 오히려 0xdead10cc 같은 종료를 부른다.
0xdead10cc가 된다.SwiftUI ScenePhase에는 왜 별도의 "suspended" 케이스가 없고 .background만 있나?
ScenePhase는 앱이 실제로 코드를 돌릴 수 있는 상태만 노출하기 때문이다. Suspended는 정의상 모든 스레드가 동결돼 아무 코드도 못 도는 상태라, 그 전이를 관측하고 콜백으로 받을 주체 자체가 앱 안에 없다. 그래서 앱이 실질적으로 마지막으로 손쓸 수 있는 시점인 .background 진입에서 저장·정리를 하도록 설계돼 있고, Suspended는 그 뒤 조용히 일어나는 OS 내부 상태로만 존재한다.
ScenePhase는 앱이 실제로 손쓸 수 있는 마지막 순간인 .background까지만 알려 주고, 그 뒤 얼어붙는 일은 OS가 앱 모르게 조용히 처리한다.Q5. Suspended 상태와 종료된 상태는 무엇이 다른가?
한 문장: Suspended는 "잠든" 것이고 종료(Terminated)는 "사라진" 것이다. Suspended면 프로세스(Mach task)가 RAM에 그대로 상주하고 주소공간·스레드 스택·전역 상태가 다 보존되어, 되돌아올 때 warm resume(수 ms)으로 원래 지점에서 이어진다. 종료면 프로세스가 메모리에서 완전히 제거되어 PCB/task도 없고, 다시 켜면 cold launch(process spawn → 다시 초기화)를 해야 한다. 그래서 사용자에겐 둘 다 "다시 앱 화면"처럼 보여도 속에서 벌어지는 일이 완전히 다르다.
CS 원리
Suspended는 스케줄링 상태다 — 프로세스 제어 블록(PCB), 페이지 테이블, 매핑된 메모리가 전부 유지된 채 스케줄러 대상에서만 빠진다. 종료는 존재 상태의 변화다 — 커널이 자원을 회수하고(메모리 해제, fd 닫기, PCB 파기) 그 프로세스는 더 이상 존재하지 않는다. 핵심 비대칭: Suspended→재개는 상태 복원이 필요 없지만, 종료→재실행은 처음부터 다시 만들어야 한다.
iOS에서는
| 구분 | Suspended | Terminated |
|---|---|---|
| 메모리(RAM) | 그대로 상주 | 회수됨 |
| 주소공간·전역 변수·스택 | 보존 | 사라짐 |
| 재진입 비용 | warm resume, 수 ms | cold launch, 재초기화 |
applicationWillTerminate | 해당 없음 | Suspended에서 죽으면 대개 안 불림 |
| 다음 진입 콜백 | willEnterForeground | didFinishLaunching |
가장 자주 깨지는 포인트: 앱이 Suspended 상태에서 시스템이 메모리 회수를 위해 죽이면 applicationWillTerminate(_:)는 호출되지 않는다. 이 콜백은 앱이 실행 중(비-Suspended)일 때 시스템이 종료시키는 드문 경로에서만 불린다. 앱 스위처 스와이프는 보통 이미 Suspended된 앱을 죽이는 것이라 호출되지 않는다. 그래서 "종료 직전에 저장하자"는 applicationWillTerminate에만 의존하면 데이터를 잃는다. 대신 백그라운드 진입 시점에 저장하고, State Restoration(NSUserActivity, scene 복원)으로 종료 후에도 "이어서 하는 것처럼" 보이게 설계한다.
실험 · 도구
둘을 눈으로 구분하려면: 디버거가 붙어 있으면 시스템이 앱을 Suspended로 내리지 않는다. 그러니 Xcode에서 Run 후 Stop하고, 홈 화면에서 앱을 직접 실행한 다음 홈으로 보내야 실제 Suspended가 재현된다(다시 열면 이어짐). 반면 Xcode의 Stop 또는 앱 스위처에서 스와이프로 죽이면 Terminated다. 로그에 didFinishLaunching가 다시 찍히면 cold launch(=종료됐던 것)라는 증거다. Instruments의 App Launch 템플릿으로 warm vs cold 시간 차를 정량 비교할 수 있다.
프로젝트 적용
① 중요한 상태 저장은 백그라운드 진입 콜백(sceneDidEnterBackground)에서 하라. applicationWillTerminate는 "올 수도 있는 보너스"로만 취급.
import UIKit
// applicationWillTerminate는 "올 수도 있는 보너스"다.
// Suspended 상태에서 시스템이 조용히 죽이면 절대 불리지 않는다.
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// ✅ 여기가 '마지막으로 코드를 돌릴 수 있는 것이 보장된' 지점이다
func sceneDidEnterBackground(_ scene: UIScene) {
DraftStore.shared.saveAll() // 작성 중이던 글
UserDefaults.standard.set(scrollOffset, forKey: "feed.offset")
try? CoreDataStack.shared.viewContext.save()
}
// ⚠️ 여기에만 의존하면 대부분의 종료에서 저장이 날아간다
func sceneDidDisconnect(_ scene: UIScene) {
// 보조 수단으로만 취급
}
private var scrollOffset: Double = 0
}
// ✅ 더 안전한 설계: 입력 중에 주기적으로 draft를 남긴다
@MainActor
final class ComposeViewModel {
private var saveTask: Task<Void, Never>?
var text: String = "" { didSet { scheduleAutosave() } }
private func scheduleAutosave() {
saveTask?.cancel()
saveTask = Task {
try? await Task.sleep(for: .seconds(2)) // 디바운스
guard !Task.isCancelled else { return }
DraftStore.shared.save(text)
}
}
}
enum DraftStore {
static let shared = DraftStoreImpl()
}
struct DraftStoreImpl {
func saveAll() {}
func save(_ text: String) {}
}
enum CoreDataStack {
static let shared = CoreDataStackImpl()
}
struct CoreDataStackImpl {
struct Context { func save() throws {} }
var viewContext: Context { Context() }
}② 사용자 경험 목표는 "Suspended였든 Terminated였든 이어서 하는 것처럼": 스크롤 위치·입력 중이던 폼을 복원하도록 state restoration을 붙여라.
import UIKit
// 사용자에게는 Suspended였는지 Terminated였는지가 보이지 않아야 한다.
// 둘 다 "아까 보던 그 자리"로 돌아와야 한다.
// ✅ ① 무엇을 복원할지 값 타입으로 정의한다
struct FeedState: Codable {
var scrollOffset: Double
var selectedFilter: String
var draftText: String
}
// ✅ ② 백그라운드 진입 때 저장
final class FeedViewController: UIViewController {
private var filter = "all"
private var draft = ""
func persist() {
let state = FeedState(scrollOffset: tableView.contentOffset.y,
selectedFilter: filter,
draftText: draft)
if let data = try? JSONEncoder().encode(state) {
UserDefaults.standard.set(data, forKey: "feed.state")
}
}
// ✅ ③ 실행 시 복원 — cold launch든 warm resume이든 같은 경로
func restore() {
guard let data = UserDefaults.standard.data(forKey: "feed.state"),
let state = try? JSONDecoder().decode(FeedState.self, from: data)
else { return }
filter = state.selectedFilter
draft = state.draftText
// 레이아웃이 끝난 뒤 오프셋을 적용해야 실제로 반영된다
view.layoutIfNeeded()
tableView.setContentOffset(CGPoint(x: 0, y: state.scrollOffset), animated: false)
}
private let tableView = UITableView()
}
// ✅ ④ UIKit의 정식 메커니즘을 쓰면 시스템이 시점을 관리해 준다
// · UISceneDelegate.stateRestorationActivity(for:)
// · scene(_:willConnectTo:options:) 의 session.stateRestorationActivity
// SwiftUI라면 @SceneStorage가 같은 역할을 한다.③ cold launch 시간(첫 화면까지)을 예산으로 관리하라 — 종료 후 재실행은 사용자가 체감하는 "느린 앱"의 주범이다.
import UIKit
import os.signpost
// 종료 후 재실행(cold launch)이 사용자가 체감하는 "느린 앱"의 주범이다.
// 감으로 고치지 말고 구간을 나눠 측정한다.
let launchLog = OSLog(subsystem: "com.app.launch", category: .pointsOfInterest)
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ app: UIApplication,
didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
os_signpost(.begin, log: launchLog, name: "didFinishLaunching")
defer { os_signpost(.end, log: launchLog, name: "didFinishLaunching") }
// ✅ 첫 화면에 필요 없는 초기화는 전부 뒤로 미룬다
Task.detached(priority: .utility) {
await Analytics.configure()
await RemoteConfig.fetch()
}
// ❌ 여기서 동기 I/O·DB 마이그레이션·큰 JSON 파싱을 하면
// 워치독 감시가 특히 엄격한 구간이라 hang이 아니라 강제 종료가 된다
return true
}
}
// ✅ 예산을 코드로 못 박고 회귀를 잡는다
func assertLaunchBudget(_ elapsed: Duration) {
let budget = Duration.milliseconds(400)
if elapsed > budget {
assertionFailure("콜드 런치 예산 초과: \(elapsed) > \(budget)")
}
}
/* 측정 도구
· Instruments → App Launch 템플릿 (pre-main / post-main 구간 분리)
· DYLD_PRINT_STATISTICS는 iOS 15의 새 dyld(dyld4)부터 제거되어 아무것도 출력하지 않는다.
무엇이 로드되고 어떤 초기화가 도는지는 DYLD_PRINT_LIBRARIES / DYLD_PRINT_INITIALIZERS로 본다
· MetricKit의 MXAppLaunchMetric (실사용자 분포, p50/p90) */
enum Analytics { static func configure() async {} }
enum RemoteConfig { static func fetch() async {} }"앱이 종료될 땐 항상 applicationWillTerminate가 불린다"는 위험한 오해다. Suspended 상태에서 시스템이 조용히 죽이는 경우엔 불리지 않는다. 또 "앱 스위처에 카드가 보이면 그 앱은 실행 중"도 오해 — 카드는 최근 앱 목록일 뿐, 실제로는 Suspended거나 이미 종료된 상태일 수 있다.
공부하던 책상을 떠올려 보자. Suspended는 책을 펼친 채, 형광펜도 그 줄에 얹어 둔 채 잠깐 자리를 비운 것이다. 돌아오면 읽던 줄부터 바로 이어 읽는다(warm resume). Terminated는 그사이 누가 그 책상을 싹 치워 버린 것이다. 다시 공부하려면 책을 꺼내고, 펼치고, 어디까지 읽었는지 찾는 일을 처음부터 다시 해야 한다(cold launch). 사용자 눈에는 둘 다 그냥 "앱을 다시 연 것"으로 보이지만, 속에서 하는 일의 양이 이만큼 다르다.
사람들이 제일 많이 데는 지점은 applicationWillTerminate다. 이건 "치우기 전에 미리 알려 줄게"라는 약속이 아니다. 자리를 비운 사이(Suspended)에 치워질 때는 아무 연락 없이 그냥 치워진다. 그러니 저장은 치워지는 순간이 아니라 자리를 뜨는 순간, 즉 백그라운드 진입 콜백에서 해 둬야 한다. 앱 스위처에 카드가 남아 있는 것도 책상이 그대로라는 증거가 못 된다 — 그건 그냥 "최근에 앉았던 자리" 목록이라, 카드 뒤의 책상은 이미 치워졌을 수 있다.
꼬리 질문
사용자가 앱 스위처에서 스와이프로 앱을 죽이는 것과, 시스템이 메모리 때문에 죽이는 것은 이후 백그라운드 갱신·푸시 동작에서 어떻게 다른가?
스와이프 종료는 OS가 "사용자가 이 앱을 당분간 원치 않는다"는 명시적 신호로 받아, 다음번 사용자가 직접 열기 전까지는 백그라운드 앱 리프레시나 silent 백그라운드 푸시로 깨우지 않는다. 반면 jetsam이 메모리 압박으로 죽인 건 앱 잘못이 아니라, 정책상 다시 백그라운드로 되살아나거나 푸시로 깨어날 수 있다. 다만 PushKit VoIP나 사용자가 알림 배너를 탭하는 것처럼 명시적 재실행 경로는 스와이프 종료였더라도 앱을 다시 띄운다.
State restoration으로 복원한 화면이 실제 데이터와 불일치하면(그새 서버 데이터가 바뀜) 어떤 전략으로 무결성을 지키나?
복원된 화면은 "마지막으로 보던 UI 스냅샷"일 뿐 신뢰할 데이터 원본이 아니라고 전제하는 게 출발점이다. 복원 직후 서버·로컬 저장소에서 실제 상태를 다시 검증(refetch/revalidate)하고, 캐시된 화면은 체감 속도를 위해 먼저 그리되 값이 달라지면 조용히 갱신하거나 충돌을 알린다. 가장 안전한 건 콘텐츠 자체가 아니라 식별자(문서 ID·버전/etag)만 복원하고 실제 데이터는 항상 최신으로 다시 불러오는 방식이다.
warm resume인데도 화면이 하얗게 다시 그려진다면 어디를 의심해야 하나?
먼저 진짜 warm이 맞는지 의심한다 — 그새 종료됐다가 cold launch된 걸 warm으로 오인한 경우가 흔하므로 로그에서 didFinishLaunching이 다시 찍혔는지 확인한다. 진짜 warm인데도 하얗다면, 시스템이 앱 스냅샷을 로딩/빈 상태에서 찍었거나, 백그라운드 진입 시 뷰를 스스로 비워 놓고 복원하지 않은 코드, 또는 민감정보 가림용 오버레이가 복귀 시 안 걷힌 경우를 살핀다.
didFinishLaunching이 다시 찍혔다면 이어 연 게 아니라 새로 켜진 것이라, 하얀 건 당연하다. 진짜 이어 연 건데도 하얗다면 범인은 대개 셋 중 하나다 — 앱 스위처용 스냅샷을 하필 로딩 중일 때 찍었거나, 백그라운드로 갈 때 화면을 비워 놓고 돌아와서 다시 안 채웠거나, 내용을 가리려고 덮어 둔 뚜껑을 안 걷었거나.Q6. iOS는 어떤 기준으로 앱 Process를 종료하는가?
대부분은 메모리 압박 때문이고, 이를 처리하는 커널 메커니즘이 jetsam(memorystatus)이다. jetsam은 무작위가 아니라 우선순위 밴드로 죽인다: 포그라운드 앱은 맨 나중, Suspended 백그라운드 앱이 맨 먼저. 같은 밴드에선 메모리를 많이 먹는 놈과 오래 안 쓴 놈(LRU)이 먼저다. 메모리 외에도 (1) watchdog(메인 스레드가 너무 오래 멈춤 → 0x8badf00d), (2) 과열(0xc00010ff), (3) suspend 중 시스템 자원(파일/SQLite 락)을 쥐고 있음(0xdead10cc), (4) 크래시, (5) 사용자 스와이프 종료가 있다.
CS 원리
메모리는 유한한데 요구는 무한할 수 있다. 데스크톱 OS는 부족하면 디스크로 스왑해서 버틴다. 하지만 스왑은 플래시 수명·지연·전력 측면에서 모바일에 불리하다. 그래서 iOS는 (적어도 iPhone에선) 앱 메모리를 디스크로 페이징하지 않는다. 대신 먼저 잘 안 쓰는 페이지를 RAM 안에서 압축(memory compressor)해 자리를 만들고, 그래도 모자라면 덜 중요한 프로세스를 골라 통째로 종료해 물리 메모리를 확보한다. 이때 "누구를 죽일까"를 정하는 게 우선순위 기반 회수 정책이다.
iOS에서는
XNU의 memorystatus/jetsam 서브시스템이 각 프로세스에 우선순위 밴드를 부여한다. 포그라운드일수록 높은(=오래 살아남는) 밴드, Suspended 백그라운드일수록 낮은 밴드다. 시스템 메모리가 임계에 닿으면 낮은 밴드부터, 같은 밴드 안에서는 대체로 그 밴드에 먼저 내려간(=덜 최근에 쓴) 프로세스부터 SIGKILL 유사 경로로 즉시 죽인다(정리 콜백 없이). 메모리를 많이 쓰는 앱이 먼저 죽는 건 밴드 내 순서 때문이 아니라 앱마다 걸려 있는 개별 메모리 한도(기기·상황에 따라 다르며 확장앱은 훨씬 빡빡함) 때문으로, 이를 넘으면 밴드와 무관하게 그 앱만 콕 집어 죽인다. 종료 사유는 크래시 리포트의 Exception 코드로 남는다.
| 코드 | 별명 | 의미 |
|---|---|---|
0x8badf00d | "ate bad food" | Watchdog — 메인 스레드가 시간 안에 응답 못 함(런치/전환 hang) |
0xdead10cc | "dead lock" | Suspend 중 파일/SQLite 등 시스템 자원 락을 쥐고 있었음 |
0xc00010ff | "cool off" | 기기 과열로 열 관리 차원의 종료 |
0xbaaaaaad | — | 전체 메모리 stackshot(크래시 아님, 진단용 스냅샷) |
실험 · 도구
남은 메모리 여유는 코드에서 직접 조회한다.
import os
// 이 프로세스가 죽기 전까지 더 쓸 수 있는 대략적 바이트
let headroom = os_proc_available_memory()
print("남은 여유: \(headroom / 1024 / 1024) MB")
// 임계에 다가가면 UIKit이 이 콜백을 준다 → 캐시 비우기
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
imageCache.removeAll() // 여기서 안 비우면 다음 차례는 jetsam
}실제 종료가 나면 설정 앱의 개인정보 보호 및 보안 → 분석 및 향상 → 분석 데이터에 JetsamEvent 리포트가 쌓인다. Xcode Organizer의 크래시 목록에서도 종료 사유(위 표의 코드)를 확인한다. WWDC18 iOS Memory Deep Dive가 footprint 측정과 최적화의 표준 참고다.
프로젝트 적용
① 메모리 한도를 넘기지 마라 — 대형 이미지는 원본 픽셀이 아니라 표시 크기로 다운샘플링해서 로드. 화면 밖 캐시는 didReceiveMemoryWarning에서 비운다.
import UIKit
import ImageIO
// jetsam은 무작위가 아니다. 우선순위 밴드 안에서 '많이 쓰는 놈'부터 죽는다.
// ✅ ① 이미지는 원본 픽셀이 아니라 표시 크기로 디코딩한다
func thumbnail(_ url: URL, size: CGSize, scale: CGFloat) -> UIImage? {
let srcOpt = [kCGImageSourceShouldCache: false] as CFDictionary
guard let src = CGImageSourceCreateWithURL(url as CFURL, srcOpt) else { return nil }
let opt = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height) * scale
] as CFDictionary
return CGImageSourceCreateThumbnailAtIndex(src, 0, opt).map(UIImage.init)
}
// ✅ ② 화면 밖 캐시는 경고 때 비운다
final class Screen: UIViewController {
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
ThumbCache.shared.removeAll()
offscreenSnapshots.removeAll()
}
private var offscreenSnapshots: [UIImage] = []
}
// ✅ ③ 큰 작업 전에 남은 예산을 확인하고 규모를 조절한다
func safeBatchSize(itemBytes: Int) -> Int {
let remaining = os_proc_available_memory()
let usable = Int(Double(remaining) * 0.5) // 절반만 쓴다
return max(1, usable / max(1, itemBytes))
}
// ✅ ④ 사후 확인 — Xcode Organizer의 Jetsam event reports에서
// per-process-limit(내 앱이 상한 초과) vs vm-pageshortage(시스템 전체 부족)를 구분한다
enum ThumbCache {
static let shared = ThumbCacheImpl()
}
struct ThumbCacheImpl { func removeAll() {} }② 메인 스레드를 절대 오래 막지 마라(watchdog 0x8badf00d). 무거운 파싱·디스크 I/O는 백그라운드로.
import UIKit
// watchdog(0x8badf00d)은 "메인이 너무 오래 응답 없음"으로 앱을 죽인다.
// 특히 런치·화면 전환·백그라운드 전환 구간에서 감시가 엄격하다.
// ❌ 메인에서 큰 파싱 + 동기 I/O
final class BadViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let data = try! Data(contentsOf: bigURL) // 동기 I/O
items = try! JSONDecoder().decode([Item].self, from: data) // 큰 파싱
}
private var items: [Item] = []
private let bigURL = URL(fileURLWithPath: "/big.json")
}
// ✅ 무거운 일은 메인 밖에서, 반영만 메인에서
@MainActor
final class FeedViewController: UIViewController {
private var items: [Item] = []
override func viewDidLoad() {
super.viewDidLoad()
Task { await load() }
}
private func load() async {
let decoded: [Item] = await Task.detached(priority: .userInitiated) {
guard let data = try? Data(contentsOf: bigURL) else { return [] }
return (try? JSONDecoder().decode([Item].self, from: data)) ?? []
}.value
items = decoded // ✅ 메인에서는 반영만
tableView.reloadData()
}
private let tableView = UITableView()
}
// ✅ 메인 블로킹을 개발 중에 자동으로 잡는 감시자
final class MainThreadWatchdog {
private var timer: DispatchSourceTimer?
func start(threshold: TimeInterval = 0.25) {
let t = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
t.schedule(deadline: .now(), repeating: .milliseconds(100))
t.setEventHandler {
// 지역 var를 두 실행 흐름이 나눠 쓰면 데이터 레이스이고,
// main.async의 클로저는 @Sendable이라 Swift 6에서는 컴파일 에러다.
let responded = OSAllocatedUnfairLock(initialState: false)
DispatchQueue.main.async { responded.withLock { $0 = true } }
Thread.sleep(forTimeInterval: threshold)
if !responded.withLock({ $0 }) { print("⚠️ 메인 스레드 \(threshold)초 이상 정지") }
}
t.resume()
timer = t
}
}
struct Item: Decodable {}
let bigURL = URL(fileURLWithPath: "/big.json")③ 백그라운드 진입 전 파일·DB 트랜잭션·락을 정리하라(0xdead10cc 예방).
import UIKit
import SQLite3
// 0xdead10cc = "dead lock". suspend 중에 시스템 자원(파일 락, SQLite 락,
// 보호된 파일)을 쥐고 있으면 그 이유로 종료된다.
final class Store {
private var db: OpaquePointer?
private var openTransaction = false
private var lockFile: FileHandle?
func beginBatch() {
sqlite3_exec(db, "BEGIN;", nil, nil, nil)
openTransaction = true
}
// ✅ 백그라운드 진입 시 반드시 호출한다
func prepareForSuspension() {
// ① 열린 트랜잭션을 닫는다 — 이게 가장 흔한 원인이다
if openTransaction {
sqlite3_exec(db, "COMMIT;", nil, nil, nil)
openTransaction = false
}
// ② WAL 체크포인트로 -wal 파일을 정리한다
sqlite3_exec(db, "PRAGMA wal_checkpoint(TRUNCATE);", nil, nil, nil)
// ③ 파일 락·핸들을 놓는다
try? lockFile?.close()
lockFile = nil
// ④ 연결 자체를 닫는다
if let db { sqlite3_close_v2(db); self.db = nil }
}
}
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
let store = Store()
func sceneDidEnterBackground(_ scene: UIScene) {
store.prepareForSuspension()
}
}
// ⚠️ 함정: 백그라운드에서 '조금만 더' 하려고 beginBackgroundTask를 쓰면서
// 그 안에서 트랜잭션을 새로 열면, 유예 시간이 끝날 때 같은 상태가 된다.
// 유예 작업도 반드시 endBackgroundTask 전에 정리를 끝내야 한다.
// 진단: 크래시 리포트의 Exception Codes에 0xdead10cc가 찍히고,
// Termination Description에 어떤 자원인지("SQLite file lock" 등)가 나온다."메모리 부족하면 iOS가 아무 앱이나 랜덤으로 죽인다"는 오해다. jetsam은 우선순위 밴드 순서로 죽이며, 같은 밴드에선 메모리 사용량이 큰 쪽을 고른다 — 예측 가능한 정책이다. 또 "메모리 때문에 죽는 것만 조심하면 된다"도 반쪽 — watchdog(hang), 과열, suspend 중 락 유지 같은 비-메모리 종료가 크래시 리포트에선 오히려 흔하다.
만원 버스에서 자리가 모자랄 때 기사가 아무나 손가락으로 찍어 내리게 하지는 않는다. jetsam도 마찬가지로 순서가 정해져 있다. 지금 기사와 이야기 중인 사람(포그라운드 앱)이 맨 마지막이고, 뒷자리에서 자고 있는 사람(Suspended 앱)이 맨 먼저다. 처지가 같으면 짐이 큰 사람, 그러니까 메모리를 많이 쓰는 앱부터 내리게 한다. 그래서 "운이 나빠서 죽었다"가 아니라 짐을 줄이면 살아남을 확률이 올라가는, 규칙이 뻔히 보이는 게임이다.
자리 문제가 아니어도 내려야 하는 경우가 몇 가지 더 있다.
- 말을 걸어도 한참 대답이 없음 → watchdog,
0x8badf00d(메인 스레드가 막혀 있다는 뜻) - 차 안이 너무 뜨거워짐 → 과열,
0xc00010ff - 내리면서 버스 공용 물건을 손에 쥔 채 굳어 있음 →
0xdead10cc
크래시 리포트에서 이 숫자만 봐도 "왜 내려졌는지"가 거의 바로 읽힌다. 참고로 이 코드들은 16진수로 영어 단어처럼 읽히도록 애플이 일부러 고른 말장난이다 — 0x8badf00d는 "ate bad food"(상한 걸 먹었다).
꼬리 질문
App Extension(위젯·공유 시트)의 메모리 한도가 본 앱보다 훨씬 작은 이유는 무엇인가?
확장은 사용자를 대신해 잠깐 뜨는 보조 프로세스이고, 시스템은 정작 사용자가 보고 있는 포그라운드 호스트(위젯을 품은 홈 화면, 공유 시트를 띄운 원본 앱)에 메모리를 남겨 둬야 한다. 확장에 후한 한도를 주면 그 중요한 앱이 jetsam으로 죽을 위험이 커지므로, 확장은 종류마다 한도가 다르되(위젯은 특히 빡빡해 수십 MB 수준, 공유·액션 확장은 그보다 넉넉하지만 어느 쪽이든 본 앱보다 훨씬 좁다) 넘으면 확장만 콕 집어 죽인다. 결국 "확장은 짧고 가볍게"라는 설계를 메모리 한도로 강제하는 셈이다.
같은 우선순위 밴드에 앱 둘이 있고 메모리 사용량도 비슷하면 무엇으로 종료 순서를 가르나?
밴드가 같으면 최근 사용성(LRU)과 세부 aging이 순서를 가른다 — jetsam은 마지막 활성 시점·idle 시간 같은 보조 지표로 더 오래 안 쓴 프로세스를 먼저 고른다. 밴드·메모리·나이까지 사실상 동률이면 구현상 내부 리스트 순서(대체로 먼저 진입한 쪽)로 결정된다. 다만 이 순서는 앱이 의존할 계약이 아니므로, "누가 먼저 죽나"를 예측하려 들지 말고 그냥 메모리를 적게 쓰는 게 답이다.
0x8badf00d(watchdog) 크래시 리포트를 받았을 때 어느 스레드를 먼저 봐야 하며, 메인 스레드가 연산으로 바쁜 hang과 락·세마포어 대기로 블록된 hang을 스택에서 어떻게 구분해 진단하나?
watchdog은 앱이 생명주기 이벤트(런치·씬 전환 등)에 제때 응답하지 못해 터지고 그 응답은 메인 스레드가 하므로, Triggered by Thread 값이 무엇으로 찍혔든 thread 0(메인)의 백트레이스를 맨 먼저 읽는다. 1차 단서는 스택보다 Termination Description에 함께 찍히는 경과 CPU 시간이다 — "Elapsed total CPU time … , 98% CPU"처럼 CPU 점유가 높으면 연산으로 바쁜 hang, 0%에 가까우면 어딘가에서 기다리는 hang이다. 그다음 메인 스택 꼭대기를 본다: 무거운 파싱·이미지 디코드·정규식이면 꼭대기가 내 계산 코드이고, 블록된 경우엔 __psynch_mutexwait(pthread 뮤텍스)·semaphore_wait_trap/_dispatch_sema4_wait(dispatch_semaphore_wait)·__ulock_wait 같은 커널 대기 프레임이 보인다. 주의할 함정은 mach_msg_trap(요즘은 mach_msg2_trap)인데, 이건 런루프가 유휴 상태로 이벤트를 기다릴 때도 항상 나오는 정상 프레임이라 그 자체로는 증거가 못 되고 바로 아래 호출자가 CFRunLoopRun인지(정상 대기) 동기 XPC/IPC 호출인지(진짜 블록)를 봐야 갈린다. 블록으로 판명되면 그 자원을 쥔 스레드를 다른 스레드 스택에서 찾아 데드락이나 우선순위 역전을 추적하며, 메인이 백그라운드 작업 완료를 세마포어로 동기 대기하는 패턴이 전형이다.
__psynch_mutexwait나 semaphore_wait_trap 같은 "대기 중" 표시가 보인다. 함정은 mach_msg_trap인데, 이건 앱이 평소 이벤트를 기다릴 때도 늘 보이는 정상 프레임이라 그것만 보고 범인으로 몰면 안 된다.Q7. App Sandbox는 운영체제 수준에서 무엇을 격리하는가?
App Sandbox는 각 앱을 자기만의 컨테이너 안에 가두는 커널 강제 격리다. 핵심은 파일 시스템 격리(앱마다 고유한 홈 디렉터리, 남의 컨테이너 접근 불가), IPC/네트워크·하드웨어 접근 제한(카메라·연락처 등은 Info.plist 용도 설명 문구 + 사용자 동의가 필요하고, App Group·Keychain 그룹 같은 특권 capability는 별도 entitlement가 필요)이다. 놀라운 점: iOS의 서드파티 앱은 전부 같은 유닉스 사용자(mobile)로 실행되므로 격리가 유닉스 파일 권한(uid)으로 되는 게 아니라, TrustedBSD MAC 기반 Sandbox(프로필) + 앱별 컨테이너 + 코드 서명/entitlement로 이뤄진다.
CS 원리
보안 설계의 기본 원칙은 최소 권한(least privilege)이다 — 각 컴포넌트는 자기 일에 꼭 필요한 권한만 가져야 한다. 전통 유닉스는 임의 접근 제어(DAC), 즉 파일 소유자(uid/gid)가 권한을 정하는 방식인데, 이건 "같은 사용자면 서로의 파일에 접근 가능"이라 앱 격리엔 부족하다. 그래서 현대 OS는 강제 접근 제어(MAC)를 얹는다 — 사용자가 못 풀고 시스템이 강제하는 정책(프로필)으로, 프로세스가 무엇을 할 수 있는지를 커널이 매 접근마다 검사한다.
iOS에서는
iOS 샌드박스는 TrustedBSD MAC 프레임워크 위의 Sandbox 커널 확장(Seatbelt)으로 구현된다. 앱이 open 같은 syscall을 부르면, 커널은 DAC(파일 권한) 검사에 더해 MAC 훅에서 그 프로세스의 샌드박스 프로필에 이 동작이 허용되는지 물어본다. 하나라도 거부하면 접근 실패다. 서드파티 앱은 모두 mobile 사용자로 도는데도 서로를 못 건드리는 이유가 여기 있다 — 격리는 uid가 아니라 프로세스별로 적용된 프로필에서 온다. 무엇을 격리하나:
| 대상 | 앱이 접근 가능 | 격리·차단 |
|---|---|---|
자기 데이터 컨테이너Documents/Library/tmp | 자유롭게 읽고 씀 | |
| 다른 앱의 컨테이너 | 경로를 알아도 접근 불가 | |
앱 번들(.app) | 읽기 전용 실행 | 쓰기 불가(코드 서명 무결성) |
| 카메라·연락처·위치 | entitlement + 사용자 동의 시 | 기본 차단 |
| 다른 앱과 데이터 공유 | App Group·Keychain 그룹으로만 | 임의 IPC 불가 |
각 앱은 설치 시 무작위 UUID로 된 컨테이너를 받는다: 데이터는 /var/mobile/Containers/Data/Application/<UUID>/, 번들은 /var/containers/Bundle/Application/<UUID>/ 아래에 놓이고, 앱은 대개 NSHomeDirectory() 기준 상대 경로만 안다. 코드 서명·entitlement가 "이 앱이 어떤 프로필을 받을지"와 "어떤 특권 capability를 쓸 수 있는지"를 정하고, Data Protection은 이 위에 파일 단위 암호화 계층을 더한다.
실험 · 도구
격리를 코드로 체감해 보라. 자기 컨테이너 경로는 열리지만, 컨테이너 밖 경로는 파일이 실제로 존재해도 open이 실패한다.
// 내 컨테이너 홈 — 접근 OK
print(NSHomeDirectory())
// 예: /var/mobile/Containers/Data/Application/<UUID>
// 컨테이너 밖은 차단 — 존재해도 못 읽음
let outside = "/var/mobile/Library/Preferences/com.apple.springboard.plist"
do {
_ = try Data(contentsOf: URL(fileURLWithPath: outside))
} catch {
print("차단됨: \(error)") // MAC 정책이 open을 거부
}프로젝트 적용
① 파일 경로를 하드코딩하지 마라. 컨테이너 UUID는 앱 업데이트·재설치 때 바뀔 수 있으므로 FileManager.url(for:in:...)로 그때그때 얻어라.
import Foundation
// 컨테이너 UUID는 앱 업데이트·재설치 때 바뀐다. 저장해 두면 다음 실행에서 깨진다.
// ❌ 절대 경로를 상수로
let badPath = "/var/mobile/Containers/Data/Application/1A2B-3C4D/Documents/db.sqlite"
// ❌ 더 흔한 실수: 전체 URL을 UserDefaults·DB에 저장
func saveBad(_ url: URL) {
UserDefaults.standard.set(url.path, forKey: "dbPath") // 다음 실행에 무효
}
// ✅ 매번 시스템에 물어본다
func databaseURL() throws -> URL {
let appSupport = try FileManager.default.url(
for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
return appSupport.appendingPathComponent("db.sqlite")
}
// ✅ 저장해야 한다면 '컨테이너 기준 상대 경로'만 저장한다
func saveRelative(_ url: URL) throws {
let docs = try FileManager.default.url(
for: .documentDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
let relative = url.path.replacingOccurrences(of: docs.path + "/", with: "")
UserDefaults.standard.set(relative, forKey: "note.relativePath")
}
func resolveRelative() throws -> URL? {
guard let rel = UserDefaults.standard.string(forKey: "note.relativePath")
else { return nil }
let docs = try FileManager.default.url(
for: .documentDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
return docs.appendingPathComponent(rel)
}
// ✅ 사용자가 고른 외부 파일은 security-scoped bookmark로 저장한다
func bookmark(_ url: URL) throws -> Data {
try url.bookmarkData(options: .minimalBookmark,
includingResourceValuesForKeys: nil, relativeTo: nil)
}② 다른 앱·확장과 데이터를 나누려면 임의 IPC가 아니라 App Group 공유 컨테이너나 Keychain access group을 쓰고, entitlement에 명시하라.
import Foundation
// 앱과 확장(위젯·공유시트·알림)은 서로 다른 프로세스이고 컨테이너도 분리돼 있다.
// 임의 IPC는 막혀 있으므로 OS가 제공하는 통로를 써야 한다.
// ✅ ① 파일·DB 공유 — App Group 컨테이너 (entitlement 필요)
enum SharedStorage {
static let groupID = "group.com.company.app"
static var container: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID)
}
static var databaseURL: URL? {
container?.appendingPathComponent("shared.sqlite")
}
}
// ✅ ② 설정 공유 — App Group을 스위트 이름으로 쓰는 UserDefaults
extension UserDefaults {
static let shared = UserDefaults(suiteName: SharedStorage.groupID)!
}
func writeSharedFlag() {
UserDefaults.shared.set(true, forKey: "widget.enabled")
}
// ✅ ③ 자격증명 공유 — Keychain access group
func sharedTokenQuery(account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccessGroup as String: "TEAMID.com.company.shared",
kSecAttrAccount as String: account,
// 확장이 잠금 상태에서도 읽어야 하면 접근성도 맞춰야 한다
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
}
/* entitlement에 반드시 선언해야 한다 (양쪽 타깃 모두):
com.apple.security.application-groups → group.com.company.app
keychain-access-groups → $(AppIdentifierPrefix)com.company.shared
⚠️ App Group 컨테이너의 SQLite를 앱과 확장이 동시에 쓰면 락 경합이 생긴다.
WAL 모드를 켜고, 쓰기는 한쪽으로 몰거나 조율 계층을 둔다. */③ 필요 없는 권한(entitlement)은 요청하지 마라 — 최소 권한 원칙이자 심사·프라이버시 라벨에 직결된다.
import Foundation
import AVFoundation
import CoreLocation
// entitlement·권한은 많을수록 심사 리스크와 프라이버시 라벨 부담이 커진다.
// 최소 권한 원칙은 보안 원칙이자 출시 실무다.
// ✅ ① 권한은 '실제로 그 기능을 쓰는 순간'에 요청한다
final class CameraFeature {
func startScanning() async -> Bool {
// 앱 시작 시가 아니라, 사용자가 스캔 버튼을 눌렀을 때
await AVCaptureDevice.requestAccess(for: .video)
}
}
// ✅ ② 정밀도가 낮아도 되면 낮은 권한을 고른다
final class NearbyStores: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
func request() {
// 항상 허용이 아니라 사용 중 허용
manager.requestWhenInUseAuthorization()
// 정확한 위치가 필요 없으면 애초에 '대략적 위치' 권한만 요청한다
// (Info.plist에 NSLocationDefaultAccuracyReduced = YES, 또는 아래 상수)
manager.desiredAccuracy = kCLLocationAccuracyReduced
// 참고: kCLLocationAccuracyKilometer 같은 값은 권한 수준이 아니라
// 받을 데이터의 정밀도·배터리 트레이드오프 설정이다
}
}
// ✅ ③ Info.plist 사용 설명 문구는 '왜 필요한지'를 구체적으로 적는다
// (심사에서 거부되는 가장 흔한 이유 중 하나가 모호한 설명이다)
// NSCameraUsageDescription:
// "영수증을 촬영해 지출 내역으로 자동 등록하기 위해 카메라를 사용합니다."
// ✅ ④ 주기적으로 미사용 권한을 정리한다
// · Info.plist의 Usage Description 중 코드에서 안 쓰는 것
// · entitlements의 App Group·Keychain group 중 실제 공유가 없는 것
// · Background Modes 중 실제로 안 쓰는 항목
//
// 확인: codesign -d --entitlements - MyApp.app
// grep -r "UsageDescription" Info.plist
// ⚠️ Background Modes를 켜 두고 실제로 쓰지 않으면 심사에서 지적된다.
// "필요해 보여서" 미리 켜 두지 않는다."iOS 앱은 유닉스 파일 권한(uid/gid)으로 서로 격리된다"는 오해다. 서드파티 앱은 전부 같은 mobile 사용자로 돌기 때문에 uid만으로는 격리가 안 된다. 실제 격리는 커널 MAC(샌드박스 프로필) + 앱별 컨테이너 + 코드 서명의 조합이다. 또 "샌드박스는 앱을 실행 후에만 감시한다"도 오해 — 프로필은 프로세스 생성 시점부터 적용되어 정책 대상 접근(파일/IPC/하드웨어/네트워크)마다 MAC 훅에서 검사된다.
호텔을 생각해 보자. 투숙객은 전부 똑같이 "투숙객" 신분이다 — iOS에서 모든 서드파티 앱이 mobile이라는 같은 유닉스 사용자로 도는 것과 같다. 옛날 방식(유닉스 파일 권한, DAC)만 있었다면 문 앞에서 "투숙객 맞으시죠?"만 확인하고 열어 줬을 테니, 302호 손님이 305호에 들어가는 걸 막지 못한다. 그래서 iOS는 문마다 경비원을 한 명 더 세운다. 그 경비원이 커널의 MAC 훅이고, 손에 든 명단(샌드박스 프로필)에 "이 사람은 302호만"이라고 적혀 있어서 신분이 맞아도 남의 방은 안 열어 준다.
이 경비원은 객실 문 앞에만 있는 게 아니다. 카메라·연락처·마이크 문 앞에도 서 있고, entitlement는 체크인할 때 프런트에서 미리 받아 둔 출입 허가 목록이다. 그래서 "경로만 알아내면 읽을 수 있는 거 아냐?"가 iOS에서는 안 통한다 — 경로를 아는 건 방 번호를 아는 것일 뿐이고, 문이 열리는 것과는 완전히 다른 얘기다. 앱을 지웠다 다시 깔면 컨테이너 UUID가 바뀌는 것도, 체크아웃하고 다시 들어오면 방 번호가 새로 배정되는 것과 비슷하다.
꼬리 질문
같은 개발팀의 두 앱이 App Group으로 파일을 공유할 때, 그 공유 컨테이너는 어느 앱의 샌드박스에 속하나?
어느 한 앱의 개인 컨테이너에 속하는 게 아니라, App Group ID(group.com...)로 식별되는 별도 컨테이너다. 그룹에 속한 각 앱·확장의 샌드박스 프로필에 이 그룹 컨테이너 접근이 entitlement로 추가돼, 멤버 전원이 "자기 개인 컨테이너 + 공유 그룹 컨테이너"를 함께 볼 수 있게 된다. 즉 소유권이 특정 앱에 있는 게 아니라 그룹 멤버들의 프로필에 공통으로 허가된 영역이다.
탈옥(jailbreak)은 이 격리 모델의 어느 고리(MAC? 코드 서명? 커널 특권?)를 깨는 것인가?
어느 하나가 아니라 사슬을 끝까지 끊는 것이다. 보통 유저 공간 취약점으로 코드 실행을 얻고 → 커널 취약점으로 EL1 권한(임의 read/write)을 탈취해 → 커널 데이터 구조를 조작함으로써 샌드박스 MAC 정책 검사와 코드 서명 강제(AMFI)를 무력화한다. 코드 서명·MAC·커널 특권이 서로 엮여 있어 하나만 깬다고 되지 않으며, PAC·KTRR 같은 하드웨어 방어가 각 고리를 점점 더 어렵게 만든다.
App Extension은 자기만의 샌드박스를 갖는가, 아니면 호스트 앱의 컨테이너를 공유하는가?
별도 프로세스인 만큼 자기만의 샌드박스와 컨테이너를 갖는다. 호스트 앱의 컨테이너를 자동으로 공유하지 않으며, 데이터를 나누려면 둘 다 같은 App Group에 넣어 공유 컨테이너를 명시적으로 써야 한다. 그래서 확장은 대개 본 앱보다 더 좁은 권한과 더 작은 메모리 한도를 가진 독립 샌드박스에서 돈다.