맥을 오래 쓰면 ~/Library/Caches에 수 GB가 쌓이고, 지운 줄 알았던 앱의 설정 파일이 여기저기 남고, 사진 폴더 어딘가에 같은 파일이 세 벌씩 존재한다. 이걸 해결해준다는 앱은 원래 유료 시장이었다 — CleanMyMac이 연 $39.95다. Mac Sai는 그 기능 목록을 거의 그대로 복제해 무료·오픈소스로 내놓았고, 여기에 CleanMyMac에 없는 디스크 트리맵 시각화(Space Lens)를 얹었다.
집 대청소 서비스를 생각하면 된다. 청소업체는 "쓰레기 버리기(캐시 청소)", "이사 간 사람 짐 완전 정리(앱 삭제 잔여물)", "창고에 똑같은 물건 세 개 있는지 확인(중복 파일)", "어느 방이 짐으로 꽉 찼는지 평면도로 보여주기(트리맵)"를 한다. 문제는 이 업체에게 집 열쇠 전체를 줘야 한다는 것이다. Mac Sai가 README에서 "서명·공증"을 집요하게 강조하는 이유가 정확히 이것이다.
프로젝트가 Mac Clean → Mac Sai로 개명됐는데 코드는 따라오지 않았다. Package.swift의 프로덕트 이름은 여전히 MacClean, 공유 설정 저장소는 com.macclean.shared, 캐시 DB는 ~/Library/Application Support/MacClean/cache.sqlite다. 소스를 읽을 때 두 이름이 같은 것임을 알고 있어야 헷갈리지 않는다.
README에 CleanMyMac·Pearcleaner·PureMac·OnyX·Mole와의 비교표가 있다. 마케팅 메시지가 명확하다는 뜻이고, 트렌딩에 필요한 "한 줄로 설명되는 차별점"을 확보했다는 뜻이다.
| 축 | Mac Sai | CleanMyMac | Pearcleaner |
|---|---|---|---|
| 가격 | 무료 (BSD-3) | $39.95/년 | 무료 |
| 스마트 스캔 | 있음 | 있음 | 없음 |
| 앱 완전 삭제 | 있음 | 있음 | 있음(주력) |
| 중복 파일 | 있음 | 있음 | 없음 |
| 디스크 트리맵 | 있음 | 없음 | 없음 |
| 유지보수 스크립트 깊이 | 보통 | 보통 | — |
※ OnyX는 유지보수 스크립트 항목에서 Mac Sai보다 깊다고 저장소가 스스로 인정한다.
이 부분이 진짜 인상적이다. "우리는 텔레메트리(사용 통계 수집)를 하지 않는다"는 주장은 누구나 할 수 있다. Mac Sai는 .github/workflows/ci.yml에 security-audit 잡을 두고, UpdaterModule.swift 바깥에서 URLSession·NSURLConnection 사용이 발견되면 경고·실패시킨다.
"우리는 X를 하지 않습니다"라는 정책은 시간이 지나면 무너진다. 새 기여자가 아무 생각 없이 네트워크 호출을 추가하면 끝이다. 정책을 grep 한 줄짜리 CI 잡으로 바꾸면, 정책 위반이 곧 빌드 실패가 된다. 이 패턴은 언어와 무관하게 훔쳐 쓸 가치가 있다.
brew install --cask mac-sai — 개인 tap이 아니라 공식 Homebrew cask다. 7주 된 프로젝트가 공식 cask에 올라갔다는 건 배포·버전 관리가 실제로 굴러간다는 신호다. 릴리스는 태그 트리거(release.yml)로 서명·공증까지 자동화돼 있다.
커밋 로그에 rarnu, lukman48 같은 외부 기여자의 머지가 보인다. Space Lens의 파일 타입별 색상 구분은 PR #110(2026-07-11)로 들어온 외부 기여다. "한 사람 취미 프로젝트"에서 "커뮤니티 프로젝트"로 넘어가는 구간에 있다.
솔직히 말하면 이 저장소가 트렌딩한 1차 이유는 "$40짜리를 공짜로"라는 가격 서사다. 기술적으로 새로운 것을 발명한 프로젝트가 아니다. 다만 그 서사를 지탱하려고 만든 안전 장치들(SafetyGuard, 테스트 8,764줄, CI 보안 감사)이 학습 자료로서는 본론보다 값지다. 이 문서가 3장 이후에서 파고드는 지점이 거기다.
| 층 | 선택 | 근거 파일 |
|---|---|---|
| 언어 | Swift 6.0 (swift-tools-version: 6.0) | Package.swift |
| 최소 OS | macOS 14 Sonoma (.macOS(.v14)) | Package.swift |
| UI | SwiftUI 주력 + AppKit 혼용 (SwiftUI import 35개 / AppKit 26개) | Sources/MacClean/Views/ |
| 빌드 | Swift Package Manager (Xcode 프로젝트 파일 없음) | Package.swift, scripts/build-dmg.sh |
| 외부 의존성 | GRDB.swift 7.0.0+ 하나뿐 | Package.swift |
| DB | SQLite (GRDB DatabasePool, WAL 모드) | Core/Cache/Database.swift |
| 해싱 | CryptoKit SHA-256 | Modules/Duplicates/DuplicatesModule.swift |
| 테스트 | XCTest, 84개 파일 / 8,764줄 (README 기준 486개 통과) | Tests/ |
| CI | GitHub Actions, macOS 15 러너 (PR 시 빌드·테스트, 태그 시 릴리스) | .github/workflows/ |
| 배포 | Developer ID 서명 + 공증 DMG, Homebrew cask | Casks/mac-sai.rb, release.yml |
25,000줄 앱에 서드파티 라이브러리가 GRDB 하나다. 트리맵 알고리즘도, 중복 탐지 파이프라인도, 시스템 통계 수집도 전부 직접 짰다. 청소 앱은 공급망 공격(supply-chain attack)의 표적이 되기 딱 좋은 카테고리 — 디스크 전체 접근 권한을 가진 비샌드박스 앱에 악성 의존성이 하나만 섞여도 재앙이다. 의존성을 최소화한 건 게으름이 아니라 위협 모델에 맞춘 결정으로 읽는 게 옳다.
// Package.swift — 실제 구성
products: [
.executable(name: "MacClean", targets: ["MacClean"]), // 본체 GUI 앱
.executable(name: "MacCleanMenu", targets: ["MacCleanMenu"]), // 메뉴바 위젯 (별도 프로세스)
.library( name: "MacCleanKit", targets: ["MacCleanKit"]), // 순수 로직 공유 프레임워크
],
dependencies: [
.package(url: "https://github.com/groue/GRDB.swift.git", from: "7.0.0")
]
DatabaseQueue(단일 접근)가 아니라 DatabasePool(WAL 모드, 다중 읽기 + 단일 쓰기)을 썼는데, 스캔 결과를 쓰면서 동시에 UI가 읽어야 하기 때문이다.코드베이스가 Combine 시대의 ObservableObject에서 Swift의 새 @Observable 매크로로 옮겨가는 중간에 멈춰 있다. 학습자에게는 오히려 좋은 표본이다 — 한 저장소 안에서 옛 방식과 새 방식을 나란히 비교할 수 있다.
// ViewModels/FileHandlerViewModel.swift — 구(Combine) 방식
@MainActor
public final class FileHandlerViewModel: ObservableObject { ... }
// ViewModels/SystemJunkViewModel.swift — 신(Observation 프레임워크) 방식
@MainActor @Observable
final class SystemJunkViewModel { ... }
ObservableObject는 프로퍼티마다 @Published를 붙여야 하고 뷰가 객체 전체 변경에 반응해 불필요한 재렌더가 잦았다. 새 @Observable은 매크로가 자동으로 추적 코드를 심어주고, 실제로 읽은 프로퍼티가 바뀔 때만 뷰를 갱신한다.이 코드베이스에서 가장 일관되게 지켜지는 규칙이다. 판단은 MacCleanKit에, 실제 파일 I/O는 MacClean/Modules에 둔다.
| 기능 | 순수 판단 (Kit) | 부작용 실행 (App) |
|---|---|---|
| 중복 파일 | DuplicateDetection.swift — 어느 쪽을 원본으로 볼지 | DuplicatesModule.swift — 실제 해싱·삭제 |
| 트리맵 | SquarifiedTreemap.swift — 사각형 좌표 계산 | FileTreeScanner.swift — 디스크 순회 |
| 앱 삭제 | AppMatching.swift — 잔여물 매칭 규칙 | UninstallerModule.swift — 파일 제거 |
| 유지보수 | MaintenanceTask.swift — 위험도·관리자권한 필요 여부 | MaintenanceModule.swift — osascript 실행 |
"이 파일을 지워도 되는가"를 판정하는 코드가 실제로 파일을 지우지 않으면, 그 코드는 임시 디렉토리도 mock도 없이 순수 함수로 테스트할 수 있다. Tests 디렉토리가 소스의 어떤 단일 영역보다 큰 건 우연이 아니라 이 분리의 결과다. 파괴적 동작을 하는 도구를 만들 때 그대로 베껴 쓸 구조다.
순진하게 생각하면 "파일 순회를 병렬화"할 것 같지만 반대다. FileTreeScanner는 단일 액터로 순차 순회하고, 병렬화는 해싱 단계에서만 일어난다.
// Core/Scanner/FileTreeScanner.swift (요지)
actor FileTreeScanner {
nonisolated func scan(...) -> AsyncStream<FileItem> {
// FileManager.enumerator 로 순차 순회 (getattrlistbulk 아님)
// 매 반복마다 Task.isCancelled 확인 → 취소 즉시 응답
// 200개마다 await Task.yield() → UI 멈춤 방지
}
}
도서관에서 책을 세는 일과, 책마다 지문 감식을 하는 일은 다르다. 책장을 훑는 건 순서대로 한 사람이 하는 게 빠르다(디스크는 순차 읽기에 유리하고, 여러 스레드가 같은 디렉토리를 헤집으면 오히려 경합이 생긴다). 반면 지문 감식(SHA-256 해싱)은 CPU 작업이라 여러 명이 나눠 하면 그대로 배수로 빨라진다. Mac Sai는 정확히 이 분업을 한다.
본체 앱과 메뉴바 위젯은 별개 프로세스다. 마지막 악성코드 스캔 시각 같은 상태를 공유해야 하는데, 여기서 정석을 버리고 실용을 택했다. 그리고 그 선택을 코드 주석에 대놓고 적어뒀다.
// MacCleanKit/SharedAppState.swift
/// 두 프로세스가 같은 suite 를 이름으로 열면, macOS 가
/// ~/Library/Preferences/com.macclean.shared.plist 하나로 뒷받침해준다.
/// App Group 이 macOS 정석 패턴이지만, 이름 붙인 suite 가
/// 더 단순하고 서명 구성과 무관하게 동작한다.
public enum SharedAppState {
public static let suiteName = "com.macclean.shared"
public static var defaults: UserDefaults { UserDefaults(suiteName: suiteName) ?? .standard }
}
이건 푸시가 아니라 폴링이다. 한쪽이 값을 써도 다른 쪽은 다음에 읽을 때까지 모른다. 실시간성이 필요한 데이터라면 부적절하다. 여기 담기는 게 "마지막 스캔 날짜", "팁 30일 숨김 타임스탬프", "알림 억제 상태" 정도라 문제가 없을 뿐이다. 기술 선택을 볼 때는 "무엇을 골랐나"보다 "무엇을 담기로 했기에 그걸 골라도 됐나"를 봐야 한다.
이 문서에서 가장 중요한 대목이다. README의 아키텍처 다이어그램은 이렇게 말한다:
그런데 저장소에는 MacCleanHelper라는 타깃도, 디렉토리도, NSXPCListener 사용처도 없다. 실제 특권 상승 경로는 이것이다:
// Modules/Maintenance/MaintenanceModule.swift
let script = "do shell script \"\(escaped)\" with administrator privileges"
let result = await runProcess(task: task, command: "/usr/bin/osascript", args: ["-e", script])
// 사용자가 취소하면 osascript 가 -128 / "User canceled." 반환
XPC + 코드서명 요구사항은 커널이 "이 서명을 가진 프로세스만 나에게 명령할 수 있다"를 강제한다. osascript 관리자 프롬프트는 사용자에게 비밀번호를 물어 그 순간 root 셸을 여는 방식이다. 후자가 반드시 위험한 건 아니고(명령이 하드코딩돼 있어 주입 위험은 없다) 소규모 앱에 흔한 선택이지만, README만 읽은 사람은 실제보다 훨씬 강한 특권 분리 보장을 있다고 믿게 된다.
배울 점: 오픈소스를 평가할 때 README의 아키텍처 주장은 코드로 검증해야 한다. grep -rl "NSXPCListener" Sources/ 한 줄이면 끝나는 검증이었다.
MacCleanKit/MaintenanceShell.swift가 POSIX 작은따옴표 이스케이프를 중앙에서 처리하고, 실행되는 명령은 tmutil·periodic·mdutil·lsregister로 전부 하드코딩돼 있다. 사용자 입력이 셸 문자열에 섞여 들어갈 통로가 없다. 특권 경로 설계는 아쉬워도, 그 경로 안의 위생은 지켰다.
MacCleanKit/SquarifiedTreemap.swift는 Bruls·Huizing·van Wijk(2000)의 알고리즘을 처음부터 구현한다. 학술 논문의 레이아웃 알고리즘을 순수 함수로 옮기는 훌륭한 표본이다.
// MacCleanKit/SquarifiedTreemap.swift
public static func layout(nodes: [TreemapNode], in rect: CGRect) -> [TreemapRect] {
guard !nodes.isEmpty, rect.width > 0, rect.height > 0 else { return [] }
let totalSize = nodes.reduce(0.0) { $0 + Double($1.size) }
let sorted = nodes.sorted { $0.size > $1.size } // 큰 것부터
let areas = sorted.map { Double($0.size) / totalSize * Double(rect.width * rect.height) }
var results: [TreemapRect] = []
var remaining = Array(zip(sorted, areas))
var currentRect = rect
while !remaining.isEmpty {
let (row, rest, newRect) = squarify(remaining: remaining, in: currentRect)
results.append(contentsOf: row); remaining = rest; currentRect = newRect
}
return results
}
바닥에 타일을 까는데 타일이 최대한 정사각형에 가깝게 하고 싶다. 길쭉한 직사각형은 눈으로 넓이를 비교하기 어렵기 때문이다. 이 알고리즘은 큰 것부터 한 줄(row)에 하나씩 넣어보면서, "이걸 더 넣으면 가장 못생긴 타일의 가로세로 비가 나아지는가"를 따진다. 나빠지기 시작하는 순간 그 줄을 확정하고, 남은 공간의 짧은 변을 따라 다음 줄을 시작한다. 반복 개선이 아니라 worstAspectRatio라는 닫힌 수식으로 한 번에 판정한다.
모든 파일 쌍을 비교하면 파일 10만 개에 50억 번 비교다. Mac Sai는 점점 비싼 검사로 좁혀간다.
// Modules/Duplicates/DuplicatesModule.swift — 2단계 병렬 부분 해싱
let partialResults = await withTaskGroup(of: (key: String, item: FileItem)?.self) {
group -> [(key: String, item: FileItem)] in
for sizeGroup in candidates {
for item in sizeGroup {
group.addTask {
guard let hash = Self.partialHash(item.url) else { return nil }
return (key: "\(item.size)-\(hash)", item: item)
}
}
}
var out: [(key: String, item: FileItem)] = []
for await r in group { if let r { out.append(r) } }
return out
}
DuplicateDetection.chooseOriginal은 결정론적 규칙으로 원본을 고른다: ① 이름에 "backup"·"copy"·"(1)"이 없는 것 우선 → ② 경로가 얕은 것 → ③ 생성일이 오래된 것 → ④ 사전순. 덕분에 사용자가 실수로 "전부 선택"을 눌러도 그룹마다 원본 하나는 절대 지워지지 않는다. 파괴적 UI에서 "안전한 기본값"을 코드로 보장하는 방법이다.
macOS Catalina 이후 시스템 볼륨이 읽기 전용으로 분리되면서 /var와 /private/var가 같은 위치를 가리키는 기묘한 이중 표기가 생겼다. MacCleanKit/SafetyGuard.swift의 canonicalizeMacOSFirmlinks가 이를 통일한다.
"/private/var는 지우면 안 됨"이라는 금지 목록을 만들었는데, FileManager가 어떤 경로에서는 /var/... 형태로 값을 돌려준다면? 문자열 비교가 실패하고 금지 목록이 그냥 통과된다. /tmp↔/private/tmp, /etc↔/private/etc도 마찬가지다. 파일 경로를 다루는 macOS 도구를 만든다면 이 정규화는 선택이 아니다.
~/Library/Caches/foo가 안전하다고 확인한 직후, 공격자가 그 경로를 /System을 가리키는 심볼릭 링크로 바꿔치기한다. 앱은 "안전 확인 완료"라고 믿고 시스템을 지운다.SafetyGuard.validatePath는 삭제 직전에 심볼릭 링크를 다시 해석하고 경로 구성요소 접두사를 비교해 이 공격을 막는다. 안전 검사를 "한 번 하고 결과를 캐싱"하지 않는다는 규율이 핵심이다.
클론 시점의 HEAD 커밋 b7cb859는 이렇다: "Shredder: 실제로 덮어쓰지 않은 안전 삭제를 성공으로 보고하지 않기 (#112)".
읽기 전용 파일(권한 0444)은 쓰기용으로 열리지 않는다. 예전 코드는 이때 secureOverwrite가 조용히 아무것도 안 하고 리턴했고, 호출부는 그대로 파일을 삭제한 뒤 "안전 삭제 완료"라고 표시했다. 즉 0바이트를 덮어쓰고 사용자에게는 복구 불가능하게 지웠다고 말한 것이다. 수정은 간단하다 — 열기 실패 시 throw. 오류를 삼키는 코드가 어떻게 보안 거짓말이 되는지 보여주는 완벽한 사례다.
// Modules/Shredder/ShredderModule.swift (수정 후)
private func secureOverwrite(_ url: URL) throws {
// SSD 에서는 삭제 후 TRIM 이 물리 블록을 알아서 0으로 만든다.
// 이 덮어쓰기는 best-effort — 진짜 보안이 필요하면 FileVault 를 권장.
guard values.isSymbolicLink != true else {
throw SafetyGuard.SafetyError.symlinkTarget(url.path(percentEncoded: false))
}
let handle = try FileHandle(forWritingTo: url) // 실패를 전파 — 조용히 성공하지 않음
...
}
| 주제 | 파일 | 배울 것 |
|---|---|---|
| 액터 + AsyncStream | Core/Scanner/FileTreeScanner.swift | 액터 메서드가 nonisolated로 스트림을 반환하는 패턴, @unchecked Sendable을 정당화 주석과 함께 쓰는 법 |
| GRDB 스키마 버저닝 | Core/Cache/Database.swift | DatabaseMigrator로 v1_create_tables 등록, scans/cachedFiles/installedApps 테이블에 명시적 보조 인덱스 부여 |
| 위험도를 타입으로 | MacCleanKit/MaintenanceTask.swift | Severity{safe, advanced}·requiresAdmin·sideEffects를 데이터로 두어 뷰에 조건문이 흩어지지 않게 함 |
| 휴리스틱의 함정 | MacCleanKit/AppMatching.swift | 10단계 매칭 중 8단계(회사명)를 기본 비활성화한 이유 — 텐센트 위안바오를 지우면 위챗 파일까지 매칭된 실제 이슈(#98) |
| 구/신 상태관리 비교 | ViewModels/ 두 파일 | 한 저장소 안의 ObservableObject vs @Observable 대조군 |
| 테스트 지원 계층 | Tests/MacCleanTestSupport/ | withTempHome·withFakeApp·MockClock — 파일시스템을 건드리는 테스트를 격리하는 헬퍼 설계 |
| 항목 | 요구사항 |
|---|---|
| 실행 OS | macOS 14 Sonoma 이상 (Package.swift의 .macOS(.v14)) |
| 빌드 도구 | Xcode 16+ / Swift 6 툴체인 |
| 앱 크기 | DMG 약 12.7 MB (v1.16.0) |
| RAM/디스크 | 별도 명시 없음 — Sonoma가 돌면 충분 |
| 필요 권한 ① | 전체 디스크 접근(Full Disk Access) — 메일 첨부파일·프라이버시·악성코드 모듈이 보호 영역을 읽으려면 필수 |
| 필요 권한 ② | 관리자 비밀번호 — 유지보수 10개 작업 중 5개(freeUpRAM, freeUpPurgeableSpace, runMaintenanceScripts, reindexSpotlight, thinTimeMachineSnapshots) |
| 샌드박스 | 없음 — .entitlements 파일이 저장소에 아예 존재하지 않음. Developer ID 서명 + 공증 방식(App Store 외 배포) |
이 조합은 앱이 사용자 권한으로 할 수 있는 거의 모든 일을 할 수 있다는 뜻이다. 청소 앱이라는 기능상 불가피하지만, 런타임 격리가 전혀 없다는 것도 사실이다. 유일한 방어선이 서명·공증이므로, 반드시 공식 릴리스나 Homebrew cask에서만 받아야 한다. 임의 포크의 빌드를 실행하는 건 다른 위험 등급이다.
# 1) 일반 사용자 — Homebrew (공식 cask, 개인 tap 아님)
brew install --cask mac-sai
# 예전에 개인 tap 을 썼다면 먼저 정리
brew untap iliyami/macsai
# 2) 소스에서 빌드 (코드 읽기가 목적이라면 이쪽)
git clone --depth 1 https://github.com/iliyami/MacSai
cd MacSai
swift build # 의존성은 GRDB 하나만 받아온다
swift test # 486개 테스트 — 여기가 학습 시작점으로 좋다
./scripts/build-dmg.sh # 배포용 DMG (서명 인증서 필요)
MacCleanKit/SquarifiedTreemap.swift는 순수 함수라 파일시스템도 UI도 필요 없다. 이걸 Python이나 TypeScript로 옮기고, 임의의 {이름, 크기} 목록을 넣어 SVG로 그려보라.
확인 방법: 크기가 1000·500·500·250·250인 다섯 노드를 정사각형에 배치했을 때, 가장 못생긴 사각형의 가로세로 비를 계산해보라. 같은 데이터를 "그냥 순서대로 왼쪽부터 세로로 자르기"(slice-and-dice) 방식과 비교하면 왜 squarify가 필요한지 눈으로 보인다.
이 문서가 짚은 두 가지(XPC 헬퍼 부재, 악성코드 서명 19개)를 내 손으로 재현해보라. 남이 정리해준 결론을 읽는 것과, 검증 명령을 스스로 굴려보는 것은 다른 기술이다.
grep -rl "NSXPCListener" Sources/ # 결과 0줄
find . -iname '*.entitlements' # 결과 없음
cat Sources/MacCleanKit/MalwareSignatures.swift
ls Sources/ | grep -i helper # MacCleanHelper 없음
확장: 관심 있는 다른 오픈소스에도 같은 감사를 해보라. "아키텍처 다이어그램에 있는 컴포넌트가 실제 코드에 있는가"는 놀랄 만큼 자주 실패하는 검사다.
DuplicatesModule의 각 단계(크기 그룹핑 → 4KB 해시 → 전체 해시 → inode)에 통과 파일 수와 소요 시간을 찍는 로그를 넣고 실제 폴더에 돌려보라.
확인할 것: 2단계에서 몇 %가 탈락하는가? 만약 4KB 부분 해시를 없애고 곧장 전체 해시로 가면 몇 배 느려지는가? 반대로 부분 해시 크기를 4KB에서 64KB로 키우면 나아지는가 나빠지는가? — 파일 종류(사진 vs 동영상 vs 코드)에 따라 답이 달라지는 게 핵심이다.
Tests/MacCleanTestSupport/의 withTempHome 헬퍼를 써서 격리된 임시 홈 디렉토리를 만들고, SafetyGuard에 대한 공격 테스트를 직접 작성하라: ① /var vs /private/var 표기 혼용, ② 안전한 경로를 위험한 곳으로 가리키는 심볼릭 링크, ③ 경로에 .. 삽입.
규칙: 절대 실제 홈 디렉토리에서 돌리지 말 것. 이 과제의 절반은 "파괴적 코드를 안전하게 테스트하는 환경을 만드는 법"을 배우는 것이다.
getattrlistbulk로 바꿔 벤치마크현재 FileTreeScanner는 FileManager.enumerator를 쓴다. 편하지만 파일 하나마다 여러 번의 시스템 호출이 발생한다. BSD 레벨의 getattrlistbulk(2)는 한 번의 호출로 여러 항목의 속성을 한꺼번에 가져온다.
도전: 10만 개 이상 파일이 있는 디렉토리에서 두 방식의 소요 시간을 비교하라. 그리고 "얼마나 빨라져야 이 복잡도를 감수할 가치가 있는가"를 스스로 판정해보라. 실제 엔지니어링 판단은 "더 빠르다"가 아니라 "이 정도 빠름이 이 정도 복잡도를 정당화하는가"다.
README가 약속했지만 없는 것을 직접 만들어보라: SMAppService.daemon으로 등록되는 XPC 헬퍼를 추가하고, NSXPCListener.setCodeSigningRequirement로 본체 앱의 서명만 접속을 허용하게 한 뒤, 유지보수 명령을 osascript 대신 이 헬퍼로 라우팅한다.
왜 어려운가: 코드 서명, 헬퍼 설치 권한, 프로세스 생명주기, 인터페이스 프로토콜 정의가 전부 얽힌다. 완성하면 macOS 보안 모델을 몸으로 이해하게 된다. 그리고 업스트림에 PR을 낼 가치가 있는 기여다.
| 주차 | 주제 | 구체적 행동 |
|---|---|---|
| 1주차 | Swift 6 동시성 | actor / @Sendable / TaskGroup / AsyncStream 문법을 익힌 뒤, FileTreeScanner.swift와 DuplicatesModule.swift를 한 줄씩 읽는다. 특히 @unchecked Sendable이 왜 필요했고 왜 안전한지를 주석과 함께 이해할 것. |
| 2주차 | macOS 파일시스템 | FileManager resource key(.totalFileAllocatedSize 등), APFS의 firmlink·하드링크·inode 개념, 심볼릭 링크와 TOCTOU. SafetyGuard.swift를 교재로 삼고 MacCleanKitTests의 대응 테스트를 함께 읽는다. |
| 3주차 | SwiftUI 상태 관리 | Observation 프레임워크(@Observable)와 구 ObservableObject의 차이를 문서로 익힌 뒤, 이 저장소의 두 ViewModel을 대조한다. 그다음 Views/SmartScan/을 읽으며 "긴 비동기 작업의 진행 상황을 UI에 흘리는" 패턴을 본다. |
| 4주차 | 배포·서명·신뢰 | Developer ID 서명, 공증, Gatekeeper, App Sandbox vs 비샌드박스의 차이. release.yml·verify-signing.yml·build-dmg.sh를 읽고, 가능하면 자기 작은 앱을 서명·공증까지 해본다. 여기가 대부분의 개인 개발자가 멈추는 지점이라 차별점이 크다. |
이 저장소는 테스트가 8,764줄이고 Kit 소스 파일마다 거의 1:1로 테스트 파일이 있다. 낯선 코드베이스를 읽을 때 가장 빠른 입구는 테스트다 — "이 함수는 무엇을 보장하기로 했는가"가 실행 가능한 형태로 적혀 있기 때문이다. Tests/MacCleanKitTests/SafetyGuardTests류를 먼저 읽고 소스로 넘어가는 순서를 권한다.
FileTreeScanner가 이걸로 스캔 상태를 보호한다.Sendable은 "이 타입은 스레드 사이를 안전하게 오갈 수 있다"는 컴파일러 보증. @unchecked Sendable은 "내가 책임질 테니 검사하지 말라"는 수동 탈출구다. FileNode가 이 케이스인데, 실제로는 스캐너 액터 한 곳에서만 쓰기 때문에 안전하다.withTaskGroup 블록을 벗어나면 자식 작업이 반드시 끝나거나 취소된다(구조적 동시성). 중복 탐지의 병렬 해싱이 이걸 쓴다.for await item in stream으로 소비한다. 수십만 개 파일을 배열에 다 모아 반환하는 대신 발견 즉시 흘려보내 메모리와 체감 속도를 모두 잡는다./var가 실은 /private/var인 식의 이중 표기를 만든다. 경로 기반 안전 검사를 우회당하지 않으려면 반드시 정규화해야 한다.setCodeSigningRequirement로 "이 서명을 가진 앱만 나에게 명령 가능"을 커널 수준에서 강제할 수 있다. Mac Sai의 README는 이걸 쓴다고 하지만 실제 코드에는 없다..mainApp(로그인 시 앱 실행)에만 쓰고, .daemon(특권 데몬)에는 쓰지 않는다.DatabasePool이 이 모드를 쓴다.brew install --cask mac-saiSources/MacCleanKit/SquarifiedTreemap.swift — 트리맵 알고리즘Sources/MacCleanKit/SafetyGuard.swift — firmlink 정규화 · TOCTOU 방어Sources/MacCleanKit/DuplicateDetection.swift — 원본 선택 규칙Sources/MacCleanKit/MalwareSignatures.swift — 하드코딩 패턴 19개Sources/MacCleanKit/SharedAppState.swift — 프로세스 간 상태 공유Sources/MacClean/Core/Scanner/FileTreeScanner.swift — 액터 스캐너Sources/MacClean/Modules/Duplicates/DuplicatesModule.swift — 병렬 해싱Sources/MacClean/Modules/Shredder/ShredderModule.swift — 안전 삭제Sources/MacClean/Modules/Maintenance/MaintenanceModule.swift — 특권 실행 경로.github/workflows/ci.yml — security-audit 잡