앱
분석 소스는 d4ccd8a71b4731b910e8bd893ef11a8095cc56f4입니다. Package.swift의 Swift 6·macOS 14·GRDB 및 세 제품 구조는 유지됩니다. 새 MalwareDatabase.loadCurrent()는 두 로컬 XProtect 번들에서 버전이 높은 데이터를 선택하고 해시 색인을 만듭니다. 기존 이름 패턴 검사는 남아 있으므로 한 방식으로 다른 방식을 대체했다고 읽지 않아야 합니다.
XProtectDatabase.swift는 독립적으로 성립하는 SHA-256·SHA-1 서명과 Safari 확장 차단 목록을 읽습니다. 문자열·바이트 패턴 YARA 규칙 전체를 실행하지 않으며, 다른 조건과 결합된 해시·파일 일부의 해시는 그대로 독립 서명으로 쓰지 않습니다. 오탐으로 파일을 지우지 않기 위해 지원하는 조건을 좁힌 설계입니다. 데이터를 읽지 못하면 빈 스냅샷을 반환하므로 탐지 0건이 악성코드 부재를 보장하지 않습니다.
MalwareModule은 일반 파일인지 확인하고 크기·헤더로 후보를 줄인 다음 필요한 해시를 읽습니다. quick 외 모드에서는 설치 앱 실행 파일과 Safari 확장 검사도 넓힙니다. Safari 항목은 bundle ID와 서명 team ID를 함께 비교하고, 호스트 앱을 확인할 수 있을 때 검토 대상으로만 제시하며 미리 선택하지 않습니다. 이 기능은 전체 백신을 대체하지 않는다고 README도 명시합니다.
이 개정은 위 변경과 현재 설치 조건·CI 코드를 대조한 결과입니다. 아래 최초 분석에서 남겨 둔 코드 줄 수·파일 수·가격 비교는 당시 기록이며 현재 규모·경쟁 제품 가격의 재검증이 아닙니다. 검증용 소스·실제 악성 파일·앱 설치·삭제는 실행하지 않았습니다.
맥을 오래 쓰면 ~/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보다 깊다고 저장소가 스스로 인정한다.
현재 .github/workflows/ci.yml의 Build & Test는 빌드 후 scripts/check-network-surface.sh를 실행합니다. 소스의 URLSession 문자열만 찾던 설명 대신, 실제 바이너리의 nm -u와 strings 결과에서 금지 네트워크 심볼과 추적 SDK·도메인 패턴을 검사합니다. 공개한 업데이트 주소가 없으면 경고하며 발견한 URL도 출력합니다.
코드가 모든 미공개 URL을 허용 목록과 엄격히 대조하는 것은 아닙니다. 특정 패턴을 잡는 검사이므로 텔레메트리 부재를 완전히 증명한다고 해석하면 안 됩니다. 별도 security-audit 잡의 삭제 경로 검색도 일부는 경고 수준입니다. 검사 구현과 README의 주장 사이 경계를 읽는 것이 학습 포인트입니다.
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. 현재 README는 1,048개 통과를 보고하며 이 분석에서 실행하지 않음 | 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 자산 확인. 옛 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 # 테스트 실행 예시. 이번 분석에서는 실행하지 않음
./scripts/build-dmg.sh # 배포용 DMG (서명 인증서 필요)
MacCleanKit/SquarifiedTreemap.swift는 순수 함수라 파일시스템도 UI도 필요 없다. 이걸 Python이나 TypeScript로 옮기고, 임의의 {이름, 크기} 목록을 넣어 SVG로 그려보라.
확인 방법: 크기가 1000·500·500·250·250인 다섯 노드를 정사각형에 배치했을 때, 가장 못생긴 사각형의 가로세로 비를 계산해보라. 같은 데이터를 "그냥 순서대로 왼쪽부터 세로로 자르기"(slice-and-dice) 방식과 비교하면 왜 squarify가 필요한지 눈으로 보인다.
과거의 “고정 문자열 목록뿐”이라는 결론이 현재에도 맞는지 확인해 보세요. 아래 고정 소스를 읽고 휴리스틱, 정확한 해시, Safari 확장 차단 목록을 각각 구분합니다.
PIN=d4ccd8a71b4731b910e8bd893ef11a8095cc56f4
git show "$PIN:Sources/MacCleanKit/MalwareSignatures.swift"
git show "$PIN:Sources/MacCleanKit/XProtectDatabase.swift"
git show "$PIN:Sources/MacClean/Modules/Malware/MalwareModule.swift"
완료 기준: 전체 YARA 엔진을 쓰지 않는 이유, quick과 그 밖의 검사 범위 차이, Safari 탐지 항목을 자동 선택하지 않는 이유를 각각 소스 함수와 연결합니다. 실제 파일을 검사하거나 삭제할 필요는 없습니다.
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 — 휴리스틱 + XProtect 해시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 잡