← Study Hub

03 · 운영체제와 프로세스

iOS 앱이 커널 위에서 프로세스로 살아가는 방식 — 표현·권한·생명주기·종료·격리
진행률
0 / 0 완료

← 챕터 목록

앱을 짜다 보면 "백그라운드로 갔다", "메모리 부족으로 죽었다", "샌드박스 때문에 접근 안 된다" 같은 말을 자주 쓴다. 이 챕터는 그 말들이 운영체제 수준에서 실제로 무슨 일인지를 CS 원리와 Darwin/XNU 구현 양쪽에서 정확히 맞춰 본다.

Q1. iOS 앱은 운영체제에서 Process, Task, Thread 중 무엇으로 표현되는가?

🔑 30초 답변

실행 중인 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 커널 XNUMach 마이크로커널 + 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는 커널 스레드가 아니라 그 위에서 스케줄되는 작업 추상화이며, 런타임이 이를 실제 스레드 풀에 매핑한다.

BSD process — struct proc PID · 시그널 · POSIX 파일 디스크립터 테이블 (유닉스 의미론) Mach task — task_t 가상 주소공간 · Mach 포트 권한 · 메모리 자원의 소유자 thread 0 main thread thread 1 PC·레지스터·스택 thread N 스케줄 단위
앱 하나 = BSD proc 1개(= Mach task 1개) + 그 안의 여러 Mach thread. 바깥 그릇은 자원, 안쪽 상자는 실행 흐름이다.

실험 · 도구

Mach 자원과 스레드를 직접 들여다볼 수 있다. LLDB에서 image list로 로드된 다이립을, thread list로 현재 스레드를, Xcode의 Debug Navigator로 스레드 목록과 스택을 본다. 코드로는 Mach API가 그대로 열려 있다.

현재 task의 스레드 개수 읽기 (Swift + 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 {
    print("이 앱 task의 커널 스레드 수: \(count)")
}
// 관찰 지표: 앱 시작 직후엔 main + GCD/런타임 워커 몇 개 →
// URLSession/이미지 디코딩이 돌면 스레드 수가 늘었다 줄었다 한다.

프로젝트 적용

⚠️ 흔한 오해

"iOS는 프로세스 개념이 없고 스레드만 있다"는 틀렸다. 앱은 엄연히 독립 프로세스(각자 PID·주소공간)다. 또 "Swift의 Task가 곧 Mach task"라는 것도 틀렸다 — Swift Task는 커널 자원이 아니라 async 작업 단위이고, 여러 Task가 스레드 하나를 나눠 쓰거나 한 Task가 여러 스레드를 오갈 수도 있다.

꼬리 질문

Q2. User mode와 Kernel mode는 왜 분리되어 있는가?

🔑 30초 답변

아무 코드나 하드웨어·다른 프로세스 메모리·전체 물리 메모리를 직접 만지게 두면 버그 하나가 시스템 전체를 망가뜨린다. 그래서 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 조작 불가
EL1XNU 커널·드라이버페이지 테이블·스케줄러·디바이스
EL2하이퍼바이저 (가상화)EL1 위 관리
EL3Secure Monitor (TrustZone)보안/일반 세계 전환

앱 코드는 EL0에서만 돈다. 커널로 들어가려면 SVC(Supervisor Call) 명령으로 동기 예외를 일으켜 EL1의 예외 벡터로 점프한다. Apple은 여기에 하드웨어 방어를 더 쌓는다: PAN(Privileged Access Never — 커널이 실수로 유저 메모리를 직접 못 읽게), PAC(Pointer Authentication — 리턴 주소 위변조 탐지), KTRR/CTRR(부팅 후 커널 텍스트를 하드웨어로 read-only 고정)가 대표적이다. 이것이 iOS 커널을 통째로 갈아엎기 어렵게 만드는 근간이다.

EL0 · 유저 공간 앱 · App Extension — 특권 명령 금지 EL1 · XNU 커널 스케줄러 · MMU 페이지 테이블 · 드라이버 EL2 하이퍼바이저 가상화 EL3 Secure Monitor TrustZone SVC ERET trap / 복귀
앱은 EL0에 갇혀 있고, 커널 서비스가 필요하면 SVC로 EL1에 trap한 뒤 ERET로 돌아온다. 경계를 넘는 유일한 정문이다.

실험 · 도구

모드 전환 비용은 실측 가능하다. 아무 일도 안 하는 순수 syscall(예: getpid())을 수백만 번 반복해 평균 시간을 재면 user↔kernel 왕복 오버헤드의 하한이 나온다. Instruments의 System Trace 템플릿은 각 syscall과 VM fault, 스레드 상태 전환을 타임라인에 그려 준다.

프로젝트 적용

⚠️ 흔한 오해

"user/kernel 분리는 소프트웨어(OS)가 소프트웨어적으로 지키는 규칙"이라는 오해. 실제로는 CPU 하드웨어가 강제한다. EL0에서 특권 명령을 실행하려 하면 OS가 착해서가 아니라 칩이 즉시 예외를 던진다. 또 "커널 모드면 뭐든 다 할 수 있다"도 반쪽이다 — Apple은 KTRR·PAC 등으로 커널조차 못 하는 것(자기 코드 수정 등)을 하드웨어로 만들어 뒀다.

꼬리 질문

Q3. System call은 무엇이며 Foundation API와 어떤 관계인가?

🔑 30초 답변

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으로, URLSessionsocket/connect 계열로 내려간다. 중요한 구분: open·read·socket 같은 POSIX libc 함수는 공개 API라 App Store 앱에서도 정상적으로 쓸 수 있다. 하지만 그 아래 raw syscall 번호와 svc ABI 자체는 안정된 계약이 아니어서 Apple이 언제든 바꿀 수 있으니, 앱은 손으로 trap을 만들지 말고 libSystem 함수나 상위 프레임워크를 통해서만 접근해야 한다.

앱 코드 · Foundation FileManager · Data · URLSession libSystem / libsystem_kernel.dylib syscall 스텁 (얇은 래퍼) SVC #0x80 EL0 (유저) ↑ / EL1 (커널) ↓ BSD syscall (양수) open · read · write mmap · socket · stat POSIX 파일/네트워크 Mach trap (음수) mach_msg 포트 권한 조작 IPC · VM
Foundation 한 줄 아래로 libSystem 래퍼 → SVC trap → XNU의 BSD/Mach 두 갈래 syscall이 깔린다. 점선이 하드웨어 특권 경계다.

실험 · 도구

Foundation 호출이 어떤 syscall로 내려가는지 직접 볼 수 있다. LLDB에서 저수준 함수에 심볼릭 브레이크포인트를 걸면 Foundation → C 함수 호출 스택이 드러난다.

FileManager가 open을 부르는지 LLDB로 확인 (lldb)
# 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을 실시간으로 덤프해 같은 사실을 확인할 수 있다. iOS 기기에선 fs_usage/System Trace가 유사 역할을 한다.

프로젝트 적용

⚠️ 흔한 오해

"Foundation은 순수 Swift/Objective-C로 자족적으로 구현돼 있다"는 오해. Foundation의 파일·네트워크·시간·프로세스 기능은 결국 C 라이브러리와 syscall 위에 얹힌 얇은 껍데기다. 반대로 "그럼 raw syscall을 직접 만들면 더 빠르겠네"도 오해다 — 오버헤드는 대부분 trap 자체가 아니라 실제 I/O에 있고, libSystem을 우회해 손으로 svc를 쓰면 이식성·안정성만 해친다.

꼬리 질문

Q4. 앱이 Active, Background, Suspended 상태가 될 때 Process와 Thread는 어떻게 되는가?

🔑 30초 답변

앱 생명주기 상태는 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 modebeginBackgroundTask로 연장을 요청한 경우에만 스레드가 계속 돈다.

Not running Inactive Active Background Suspended스레드 동결 resign active → resume ↺ 메모리 압박 시 OS가 종료 → Not running (재실행은 cold launch)
Active·Background·Suspended 사이에선 프로세스가 살아 있고 왕복이 즉각적이다. 강조된 점선(종료)만이 프로세스를 메모리에서 없앤다.

프로젝트 적용

⚠️ 흔한 오해

"백그라운드로 가면 앱이 계속 코드를 돈다"는 오해. 기본값은 정반대다 — 짧은 유예 시간 뒤 Suspended로 동결되어 스레드가 한 줄도 실행되지 않는다. 반대로 "Suspended면 앱이 죽은 것"도 오해다(→ Q5) — 프로세스는 메모리에 그대로 있고 즉시 되살아난다.

꼬리 질문

Q5. Suspended 상태와 종료된 상태는 무엇이 다른가?

🔑 30초 답변

한 문장: Suspended는 "잠든" 것이고 종료(Terminated)는 "사라진" 것이다. Suspended면 프로세스(Mach task)가 RAM에 그대로 상주하고 주소공간·스레드 스택·전역 상태가 다 보존되어, 되돌아올 때 warm resume(수 ms)으로 원래 지점에서 이어진다. 종료면 프로세스가 메모리에서 완전히 제거되어 PCB/task도 없고, 다시 켜면 cold launch(process spawn → 다시 초기화)를 해야 한다. 그래서 사용자에겐 둘 다 "다시 앱 화면"처럼 보여도 속에서 벌어지는 일이 완전히 다르다.

CS 원리

Suspended는 스케줄링 상태다 — 프로세스 제어 블록(PCB), 페이지 테이블, 매핑된 메모리가 전부 유지된 채 스케줄러 대상에서만 빠진다. 종료는 존재 상태의 변화다 — 커널이 자원을 회수하고(메모리 해제, fd 닫기, PCB 파기) 그 프로세스는 더 이상 존재하지 않는다. 핵심 비대칭: Suspended→재개는 상태 복원이 필요 없지만, 종료→재실행은 처음부터 다시 만들어야 한다.

iOS에서는

구분SuspendedTerminated
메모리(RAM)그대로 상주회수됨
주소공간·전역 변수·스택보존사라짐
재진입 비용warm resume, 수 mscold launch, 재초기화
applicationWillTerminate해당 없음Suspended에서 죽으면 대개 안 불림
다음 진입 콜백willEnterForegrounddidFinishLaunching

가장 자주 깨지는 포인트: 앱이 Suspended 상태에서 시스템이 메모리 회수를 위해 죽이면 applicationWillTerminate(_:)호출되지 않는다. 이 콜백은 앱이 실행 중(비-Suspended)일 때 시스템이 종료시키는 드문 경로에서만 불린다. 앱 스위처 스와이프는 보통 이미 Suspended된 앱을 죽이는 것이라 호출되지 않는다. 그래서 "종료 직전에 저장하자"는 applicationWillTerminate에만 의존하면 데이터를 잃는다. 대신 백그라운드 진입 시점에 저장하고, State Restoration(NSUserActivity, scene 복원)으로 종료 후에도 "이어서 하는 것처럼" 보이게 설계한다.

실험 · 도구

둘을 눈으로 구분하려면: Xcode로 앱을 띄워 홈으로 보내면 Suspended(디버거 연결 유지, 다시 열면 이어짐). 반면 Xcode의 Stop 또는 앱 스위처에서 스와이프로 죽이면 Terminated다. 로그에 didFinishLaunching가 다시 찍히면 cold launch(=종료됐던 것)라는 증거다. Instruments의 App Launch 템플릿으로 warm vs cold 시간 차를 정량 비교할 수 있다.

프로젝트 적용

⚠️ 흔한 오해

"앱이 종료될 땐 항상 applicationWillTerminate가 불린다"는 위험한 오해다. Suspended 상태에서 시스템이 조용히 죽이는 경우엔 불리지 않는다. 또 "앱 스위처에 카드가 보이면 그 앱은 실행 중"도 오해 — 카드는 최근 앱 목록일 뿐, 실제로는 Suspended거나 이미 종료된 상태일 수 있다.

꼬리 질문

Q6. iOS는 어떤 기준으로 앱 Process를 종료하는가?

🔑 30초 답변

대부분은 메모리 압박 때문이고, 이를 처리하는 커널 메커니즘이 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(크래시 아님, 진단용 스냅샷)
높은 우선순위 — 마지막에 종료 포그라운드 앱 (Active) 포그라운드 확장 · 실행 중 background mode 백그라운드에서 도는 앱 (제한 작업) Suspended 백그라운드 앱 낮은 우선순위 — 가장 먼저 종료 메모리 압박이 밀어 올림 여기부터
jetsam은 아래(가장 덜 중요한 Suspended 앱)부터 죽인다. 같은 밴드 안에선 메모리를 많이 쓰는 프로세스가 먼저 희생된다.

실험 · 도구

남은 메모리 여유는 코드에서 직접 조회한다.

jetsam 전에 남은 메모리 여유 확인 (Swift)
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 측정과 최적화의 표준 참고다.

프로젝트 적용

⚠️ 흔한 오해

"메모리 부족하면 iOS가 아무 앱이나 랜덤으로 죽인다"는 오해다. jetsam은 우선순위 밴드 순서로 죽이며, 같은 밴드에선 메모리 사용량이 큰 쪽을 고른다 — 예측 가능한 정책이다. 또 "메모리 때문에 죽는 것만 조심하면 된다"도 반쪽 — watchdog(hang), 과열, suspend 중 락 유지 같은 비-메모리 종료가 크래시 리포트에선 오히려 흔하다.

꼬리 질문

Q7. App Sandbox는 운영체제 수준에서 무엇을 격리하는가?

🔑 30초 답변

App Sandbox는 각 앱을 자기만의 컨테이너 안에 가두는 커널 강제 격리다. 핵심은 파일 시스템 격리(앱마다 고유한 홈 디렉터리, 남의 컨테이너 접근 불가), IPC/네트워크·하드웨어 접근 제한(카메라·연락처 등은 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은 이 위에 파일 단위 암호화 계층을 더한다.

App A Container A Documents/Library/tmp uid = mobile App B Container B 격리된 홈 디렉터리 uid = mobile App C Container C 코드 서명 · entitlement uid = mobile XNU 커널 — TrustedBSD MAC + Sandbox 프로필 모든 파일/IPC/하드웨어 접근을 프로세스별 프로필로 MAC 훅에서 검사 → 같은 uid여도 서로의 컨테이너 접근 차단(×)
세 앱이 같은 mobile 사용자로 돌아도, 커널의 MAC 프로필이 각 프로세스를 자기 컨테이너에 가둔다. 격리의 근거는 uid가 아니라 프로필이다.

실험 · 도구

격리를 코드로 체감해 보라. 자기 컨테이너 경로는 열리지만, 컨테이너 밖 경로는 파일이 실제로 존재해도 open이 실패한다.

샌드박스 경계 확인 (Swift)
// 내 컨테이너 홈 — 접근 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을 거부
}

프로젝트 적용

⚠️ 흔한 오해

"iOS 앱은 유닉스 파일 권한(uid/gid)으로 서로 격리된다"는 오해다. 서드파티 앱은 전부 같은 mobile 사용자로 돌기 때문에 uid만으로는 격리가 안 된다. 실제 격리는 커널 MAC(샌드박스 프로필) + 앱별 컨테이너 + 코드 서명의 조합이다. 또 "샌드박스는 앱을 실행 후에만 감시한다"도 오해 — 프로필은 프로세스 생성 시점부터 적용되어 정책 대상 접근(파일/IPC/하드웨어/네트워크)마다 MAC 훅에서 검사된다.

꼬리 질문

출처 · 참고자료