트렌딩 딥다이브 · 2026-07-19 · OSSInsight 24h #46

iliyami/MacSai 딥다이브
— 공짜 CleanMyMac을 표방한 Swift 6 네이티브 앱, 그 안의 진짜 설계

Mac Sai"연 $39.95짜리 CleanMyMac이 하는 일을 BSD-3 오픈소스로, 애플 공증(notarize)까지 받아서 공짜로" 하겠다는 macOS 네이티브 유틸리티다. 캐시·로그 청소, 앱 완전 삭제, 중복 파일 탐지, 디스크 트리맵 시각화, 안전 삭제(shredder), 악성코드 스캔, 유지보수 스크립트까지 열 갈래 기능을 하나의 SwiftUI 앱에 담았다. 이 문서가 관심 있는 건 기능 소개가 아니다 — 약 25,000줄짜리 Swift 6 코드베이스가 "파일을 지우는 도구"라는 위험한 일을 어떻게 안전하게 설계했는가이다. 논문에서 가져온 스쿼리파이드 트리맵, 4단계 점진적 해싱 중복 탐지, 액터로 감싼 스캐너, 심볼릭 링크 TOCTOU 방어 — 배울 게 실제로 두껍다. 동시에 README가 광고하는 아키텍처와 실제 코드가 어긋나는 지점도 정직하게 짚는다. "XPC 특권 헬퍼"는 코드에 없고, "악성코드 스캐너"는 하드코딩된 19개 문자열 목록이다. 이 간극을 읽어내는 것 자체가 오픈소스를 읽는 훈련이다. (저장소: iliyami/MacSai · Swift 6 / SwiftUI · BSD-3-Clause · 최신 릴리스 v1.16.0(2026-07-11), main은 v1.16.1 · 별 1,245 · 생성 2026-05-27 · 이번 회차는 TrendShift Daily·Weekly·Monthly가 전부 기분석 소진되어 OSSInsight 순위로 폴백)
목차
  1. 프로젝트 한 줄 요약
  2. 왜 지금 주목받는가
  3. 기술 스택 전체 지도
  4. 아키텍처 심화 분석
  5. 디렉토리 구조 해부
  6. 학습 포인트
  7. 시스템 요구사항 · 설치
  8. 직접 해볼 수 있는 실습 과제
  9. 관련 기술 심화 학습 로드맵
  10. 핵심 키워드 사전
  11. 참고 링크

1프로젝트 한 줄 요약

"오픈소스 CleanMyMac" — Swift 6/SwiftUI로 쓴, 서명·공증된 무료 맥 청소·보호 유틸리티

맥을 오래 쓰면 ~/Library/Caches에 수 GB가 쌓이고, 지운 줄 알았던 앱의 설정 파일이 여기저기 남고, 사진 폴더 어딘가에 같은 파일이 세 벌씩 존재한다. 이걸 해결해준다는 앱은 원래 유료 시장이었다 — CleanMyMac이 연 $39.95다. Mac Sai는 그 기능 목록을 거의 그대로 복제해 무료·오픈소스로 내놓았고, 여기에 CleanMyMac에 없는 디스크 트리맵 시각화(Space Lens)를 얹었다.

비유

집 대청소 서비스를 생각하면 된다. 청소업체는 "쓰레기 버리기(캐시 청소)", "이사 간 사람 짐 완전 정리(앱 삭제 잔여물)", "창고에 똑같은 물건 세 개 있는지 확인(중복 파일)", "어느 방이 짐으로 꽉 찼는지 평면도로 보여주기(트리맵)"를 한다. 문제는 이 업체에게 집 열쇠 전체를 줘야 한다는 것이다. Mac Sai가 README에서 "서명·공증"을 집요하게 강조하는 이유가 정확히 이것이다.

용어
공증(Notarization)
개발자가 앱을 애플 서버에 올려 자동 악성코드 검사를 받고 "애플이 확인함" 도장을 받는 절차. App Store 심사와는 다르다. 이게 없으면 macOS Gatekeeper가 "확인되지 않은 개발자" 경고를 띄운다. 디스크 전체를 뒤지는 청소 앱에게는 신뢰의 최소 조건이다.
먼저 알아둘 것
내부 이름은 아직 "MacClean"이다

프로젝트가 Mac Clean → Mac Sai로 개명됐는데 코드는 따라오지 않았다. Package.swift의 프로덕트 이름은 여전히 MacClean, 공유 설정 저장소는 com.macclean.shared, 캐시 DB는 ~/Library/Application Support/MacClean/cache.sqlite다. 소스를 읽을 때 두 이름이 같은 것임을 알고 있어야 헷갈리지 않는다.

2왜 지금 주목받는가

생성 7주 만에 별 1,245개 — 가격 파괴 + 신뢰 설계 + 활발한 외부 기여

2-1. 경쟁 구도를 저장소가 직접 표로 그려놨다

README에 CleanMyMac·Pearcleaner·PureMac·OnyX·Mole와의 비교표가 있다. 마케팅 메시지가 명확하다는 뜻이고, 트렌딩에 필요한 "한 줄로 설명되는 차별점"을 확보했다는 뜻이다.

Mac SaiCleanMyMacPearcleaner
가격무료 (BSD-3)$39.95/년무료
스마트 스캔있음있음없음
앱 완전 삭제있음있음있음(주력)
중복 파일있음있음없음
디스크 트리맵있음없음없음
유지보수 스크립트 깊이보통보통

※ OnyX는 유지보수 스크립트 항목에서 Mac Sai보다 깊다고 저장소가 스스로 인정한다.

2-2. "신뢰"를 문서가 아니라 CI로 증명한다

이 부분이 진짜 인상적이다. "우리는 텔레메트리(사용 통계 수집)를 하지 않는다"는 주장은 누구나 할 수 있다. Mac Sai는 .github/workflows/ci.ymlsecurity-audit을 두고, UpdaterModule.swift 바깥에서 URLSession·NSURLConnection 사용이 발견되면 경고·실패시킨다.

배울 점
주장을 자동화로 잠근다

"우리는 X를 하지 않습니다"라는 정책은 시간이 지나면 무너진다. 새 기여자가 아무 생각 없이 네트워크 호출을 추가하면 끝이다. 정책을 grep 한 줄짜리 CI 잡으로 바꾸면, 정책 위반이 곧 빌드 실패가 된다. 이 패턴은 언어와 무관하게 훔쳐 쓸 가치가 있다.

2-3. 배포 경로가 이미 정돈됨

brew install --cask mac-sai — 개인 tap이 아니라 공식 Homebrew cask다. 7주 된 프로젝트가 공식 cask에 올라갔다는 건 배포·버전 관리가 실제로 굴러간다는 신호다. 릴리스는 태그 트리거(release.yml)로 서명·공증까지 자동화돼 있다.

2-4. 외부 기여가 실제로 들어온다

커밋 로그에 rarnu, lukman48 같은 외부 기여자의 머지가 보인다. Space Lens의 파일 타입별 색상 구분은 PR #110(2026-07-11)로 들어온 외부 기여다. "한 사람 취미 프로젝트"에서 "커뮤니티 프로젝트"로 넘어가는 구간에 있다.

균형 잡기
트렌딩 이유 ≠ 기술적 우수성

솔직히 말하면 이 저장소가 트렌딩한 1차 이유는 "$40짜리를 공짜로"라는 가격 서사다. 기술적으로 새로운 것을 발명한 프로젝트가 아니다. 다만 그 서사를 지탱하려고 만든 안전 장치들(SafetyGuard, 테스트 8,764줄, CI 보안 감사)이 학습 자료로서는 본론보다 값지다. 이 문서가 3장 이후에서 파고드는 지점이 거기다.

3기술 스택 전체 지도

외부 의존성이 딱 하나인 순수 애플 생태계 스택

3-1. 한눈에

선택근거 파일
언어Swift 6.0 (swift-tools-version: 6.0)Package.swift
최소 OSmacOS 14 Sonoma (.macOS(.v14))Package.swift
UISwiftUI 주력 + 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
DBSQLite (GRDB DatabasePool, WAL 모드)Core/Cache/Database.swift
해싱CryptoKit SHA-256Modules/Duplicates/DuplicatesModule.swift
테스트XCTest, 84개 파일 / 8,764줄 (README 기준 486개 통과)Tests/
CIGitHub Actions, macOS 15 러너 (PR 시 빌드·테스트, 태그 시 릴리스).github/workflows/
배포Developer ID 서명 + 공증 DMG, Homebrew caskCasks/mac-sai.rb, release.yml
설계 판단
의존성 1개라는 선택

25,000줄 앱에 서드파티 라이브러리가 GRDB 하나다. 트리맵 알고리즘도, 중복 탐지 파이프라인도, 시스템 통계 수집도 전부 직접 짰다. 청소 앱은 공급망 공격(supply-chain attack)의 표적이 되기 딱 좋은 카테고리 — 디스크 전체 접근 권한을 가진 비샌드박스 앱에 악성 의존성이 하나만 섞여도 재앙이다. 의존성을 최소화한 건 게으름이 아니라 위협 모델에 맞춘 결정으로 읽는 게 옳다.

3-2. 세 개의 타깃

// 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")
]
용어
GRDB
Swift용 SQLite 래퍼 라이브러리. Core Data보다 SQL에 가깝고, 마이그레이션·동시성 관리를 잘 해준다. 여기서는 DatabaseQueue(단일 접근)가 아니라 DatabasePool(WAL 모드, 다중 읽기 + 단일 쓰기)을 썼는데, 스캔 결과를 쓰면서 동시에 UI가 읽어야 하기 때문이다.

3-3. 상태 관리는 이주 중이다

코드베이스가 Combine 시대의 ObservableObject에서 Swift의 새 @Observable 매크로로 옮겨가는 중간에 멈춰 있다. 학습자에게는 오히려 좋은 표본이다 — 한 저장소 안에서 옛 방식과 새 방식을 나란히 비교할 수 있다.

// ViewModels/FileHandlerViewModel.swift — 구(Combine) 방식
@MainActor
public final class FileHandlerViewModel: ObservableObject { ... }

// ViewModels/SystemJunkViewModel.swift — 신(Observation 프레임워크) 방식
@MainActor @Observable
final class SystemJunkViewModel { ... }
용어
@Observable vs ObservableObject
둘 다 "데이터가 바뀌면 화면을 다시 그려라"를 SwiftUI에 알리는 장치다. 옛 ObservableObject는 프로퍼티마다 @Published를 붙여야 하고 뷰가 객체 전체 변경에 반응해 불필요한 재렌더가 잦았다. 새 @Observable은 매크로가 자동으로 추적 코드를 심어주고, 실제로 읽은 프로퍼티가 바뀔 때만 뷰를 갱신한다.

4아키텍처 심화 분석

모듈 경계 · 동시성 모델 · 프로세스 간 통신 — 그리고 문서와 코드가 갈라지는 지점

4-1. 시스템 구조도

┌───────────────────────────────────────────────────────────────┐ │ 사용자 (macOS 14+) │ └───────────────┬──────────────────────────┬────────────────────┘ │ 앱 실행 │ 메뉴바 아이콘 클릭 ▼ ▼ ┌──────────────────────────┐ ┌──────────────────────────┐ │ MacClean (본체 GUI) │ │ MacCleanMenu (위젯) │ │ ~11,000 LOC │ │ ~1,180 LOC │ │ │ │ CPU/RAM/디스크/배터리 │ │ App/ 앱 진입점 │ │ host_processor_info │ │ Views/ 9개 화면군 5,980 │ │ vm_statistics64 │ │ ViewModels/ │ │ proc_pidinfo / IOKit │ │ Modules/ 13개 기능 모듈 │ └───────────┬──────────────┘ │ Core/ Scanner/Cleaner │ │ │ Cache/FSMonitor │ │ │ Services/ 권한·로그인항목 │ │ └────────┬─────────────────┘ │ │ │ │ ┌─────────────────────────┘ ▼ ▼ ┌──────────────────────────────────────────────────┐ │ MacCleanKit (공유 프레임워크, 4,285 LOC) │ │ ─ SafetyGuard 경로 안전성 판정 │ │ ─ DuplicateDetection 중복 판정 "순수 로직" │ │ ─ SquarifiedTreemap 트리맵 레이아웃 계산 │ │ ─ AppMatching 앱 잔여물 매칭 10단계 │ │ ─ MalwareSignatures 하드코딩 패턴 19개 │ │ ─ MaintenanceTask/Shell 유지보수 작업 정의·이스케이프 │ │ ─ SharedAppState ◄── 두 프로세스 공유 지점 │ └──────────────────────┬───────────────────────────┘ │ UserDefaults(suiteName: │ "com.macclean.shared") ▼ ~/Library/Preferences/com.macclean.shared.plist ┌──────────────────────────────────────────────────┐ │ 파일시스템 · 시스템 명령 │ │ FileManager.enumerator (모든 스캔의 단일 입구) │ │ GRDB DatabasePool → ~/Library/.../cache.sqlite │ │ osascript "do shell script ... with admin ..." │ │ └─ tmutil / periodic / mdutil / lsregister │ └──────────────────────────────────────────────────┘

4-2. 핵심 설계 패턴 ① — "순수 로직"과 "부작용"의 분리

이 코드베이스에서 가장 일관되게 지켜지는 규칙이다. 판단은 MacCleanKit에, 실제 파일 I/O는 MacClean/Modules에 둔다.

기능순수 판단 (Kit)부작용 실행 (App)
중복 파일DuplicateDetection.swift — 어느 쪽을 원본으로 볼지DuplicatesModule.swift — 실제 해싱·삭제
트리맵SquarifiedTreemap.swift — 사각형 좌표 계산FileTreeScanner.swift — 디스크 순회
앱 삭제AppMatching.swift — 잔여물 매칭 규칙UninstallerModule.swift — 파일 제거
유지보수MaintenanceTask.swift — 위험도·관리자권한 필요 여부MaintenanceModule.swift — osascript 실행
왜 중요한가
테스트가 8,764줄일 수 있는 이유

"이 파일을 지워도 되는가"를 판정하는 코드가 실제로 파일을 지우지 않으면, 그 코드는 임시 디렉토리도 mock도 없이 순수 함수로 테스트할 수 있다. Tests 디렉토리가 소스의 어떤 단일 영역보다 큰 건 우연이 아니라 이 분리의 결과다. 파괴적 동작을 하는 도구를 만들 때 그대로 베껴 쓸 구조다.

4-3. 핵심 설계 패턴 ② — 액터로 감싼 스캐너, TaskGroup으로 병렬화한 해싱

순진하게 생각하면 "파일 순회를 병렬화"할 것 같지만 반대다. 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는 정확히 이 분업을 한다.

용어
Task.yield()
"내가 CPU를 잠깐 놓을 테니 다른 작업 좀 하고 와"라고 스케줄러에 양보하는 호출. 수십만 개 파일을 도는 루프가 한 번도 양보하지 않으면 UI가 얼어붙는다. 200회마다 한 번 양보 — 너무 자주 하면 오버헤드, 너무 드물면 버벅임. 이 숫자 자체가 튜닝 결과다.

4-4. 프로세스 간 통신 — XPC도 App Group도 아닌 "이름 붙인 UserDefaults"

본체 앱과 메뉴바 위젯은 별개 프로세스다. 마지막 악성코드 스캔 시각 같은 상태를 공유해야 하는데, 여기서 정석을 버리고 실용을 택했다. 그리고 그 선택을 코드 주석에 대놓고 적어뒀다.

// 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일 숨김 타임스탬프", "알림 억제 상태" 정도라 문제가 없을 뿐이다. 기술 선택을 볼 때는 "무엇을 골랐나"보다 "무엇을 담기로 했기에 그걸 골라도 됐나"를 봐야 한다.

4-5. README와 코드가 어긋나는 지점 — 특권 헬퍼 이야기

이 문서에서 가장 중요한 대목이다. README의 아키텍처 다이어그램은 이렇게 말한다:

MacCleanHelper — XPC 특권 헬퍼 (root 작업용 LaunchDaemon) 안전 모델: "커널이 강제하는 XPC 특권 게이트 — NSXPCListener.setCodeSigningRequirement"

그런데 저장소에는 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로 전부 하드코딩돼 있다. 사용자 입력이 셸 문자열에 섞여 들어갈 통로가 없다. 특권 경로 설계는 아쉬워도, 그 경로 안의 위생은 지켰다.

5디렉토리 구조 해부

Views가 가장 크고, Tests가 그 다음으로 크다
MacSai/ ├── Package.swift SwiftPM 매니페스트 (실행 2 + 라이브러리 1, GRDB 의존) ├── VERSION "1.16.1" ← 태그된 릴리스 v1.16.0 보다 앞섬 ├── Casks/mac-sai.rb Homebrew cask 정의 ├── Resources/AppIcon.icns ├── scripts/ build-dmg.sh · install.sh · dev-install.sh │ check-version-sync.sh · setup-homebrew-tap.sh ├── docs/ RELEASING.md · TESTING.md · superpowers/{plans,specs} ├── .github/workflows/ ci.yml(PR 빌드·테스트·보안감사) · release.yml(태그→서명·공증) │ verify-signing.yml │ ├── Sources/ │ ├── MacClean/ ── 본체 앱, 약 11,037 LOC │ │ ├── App/ 378 LOC 진입점 · AppDelegate · 최상위 상태 │ │ ├── Core/ 1,279 LOC │ │ │ ├── Scanner/ FileTreeScanner(actor) · TargetedScanner │ │ │ │ · ScanCoordinator │ │ │ ├── Cleaner/ CleaningEngine (SafetyGuard 소비자) │ │ │ ├── Cache/ Database.swift (GRDB DatabasePool) │ │ │ └── FSMonitor/ FSEvents 기반 증분 감시 │ │ ├── Modules/ 2,605 LOC 13개 기능 모듈 — 각각 ScanModule 하나 │ │ │ SystemJunk · Malware · Uninstaller · SpaceLens · Duplicates │ │ │ Shredder · Privacy · Optimization · Maintenance · Updater │ │ │ TrashBins · MailAttachments · LargeOldFiles │ │ ├── Services/ 435 LOC PermissionManager · LaunchAtLoginManager │ │ │ · MenuBarLauncher │ │ ├── ViewModels/ 284 LOC 2개 파일 — 구/신 상태관리 혼재 │ │ ├── Views/ 5,980 LOC ◄── 최대 영역, 9개 하위 폴더 │ │ │ Applications · Cleanup · Files · Performance · Protection │ │ │ Settings · Shared · Sidebar · SmartScan │ │ └── Utilities/ 76 LOC SuperEllipse 도형 등 │ │ │ ├── MacCleanKit/ 4,285 LOC, 50파일 ── 순수 로직 (테스트 가능 영역) │ │ SafetyGuard · DuplicateDetection · SquarifiedTreemap │ │ MalwareSignatures · AppMatching · MaintenanceTask/Shell │ │ UpdateChecker · SharedAppState · Categories/(7파일) │ │ │ └── MacCleanMenu/ 1,182 LOC, 8파일 ── 독립 실행 메뉴바 위젯 │ └── Tests/ 8,764 LOC, 84파일 ◄── 소스 어느 단일 영역보다 큼 ├── MacCleanTests/ 앱 통합·E2E (CleaningEngineTests, │ SmartScanE2ETests, ShredderModuleTests 등 35+) ├── MacCleanKitTests/ Kit 단위 테스트 34파일 (소스 파일당 거의 1:1) └── MacCleanTestSupport/ MockClock · TestFixtures · UniversalBinaryFixture withTempHome / withFakeApp 헬퍼
읽는 법
Views 5,980 vs Modules 2,605
화면 코드가 기능 로직의 2배가 넘는다. 흔한 오해와 달리 유틸리티 앱에서는 정상적인 비율이다 — "무엇을 지울지 정하는 로직"보다 "무엇을 지울지 사용자에게 안전하게 보여주고 확인받는 UI"가 더 많은 코드를 먹는다. 파괴적 도구일수록 UI 예산이 커진다.

6학습 포인트

이 저장소에서만 얻을 수 있는 것들 — 파일 경로와 함께

6-1. 스쿼리파이드 트리맵 — 논문을 230줄 Swift로

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라는 닫힌 수식으로 한 번에 판정한다.

6-2. 중복 탐지 — 4단계 깔때기로 O(n²)를 피한다

모든 파일 쌍을 비교하면 파일 10만 개에 50억 번 비교다. Mac Sai는 점점 비싼 검사로 좁혀간다.

1단계 파일 크기로 그룹핑 비용 0 (스캔 중 이미 알고 있음) └ 500MB 초과 파일은 아예 제외 (maxHashableFileSize) ▼ 크기가 같은 것들만 통과 2단계 앞 4KB만 SHA-256 (병렬) 비용 낮음, 대부분 여기서 탈락 └ 키: "<파일크기>-<부분해시>" ▼ 3단계 전체 파일 SHA-256 (병렬) 비용 높음, 소수만 도달 ▼ 4단계 inode 중복 제거 하드링크는 "같은 파일"이지 중복 아님 └ dedupHardLinks ▼ 진짜 중복 그룹
// 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에서 "안전한 기본값"을 코드로 보장하는 방법이다.

6-3. macOS firmlink 정규화 — 이걸 모르면 안전 검사가 뚫린다

macOS Catalina 이후 시스템 볼륨이 읽기 전용으로 분리되면서 /var/private/var가 같은 위치를 가리키는 기묘한 이중 표기가 생겼다. MacCleanKit/SafetyGuard.swiftcanonicalizeMacOSFirmlinks가 이를 통일한다.

왜 치명적인가
금지 목록 우회

"/private/var는 지우면 안 됨"이라는 금지 목록을 만들었는데, FileManager가 어떤 경로에서는 /var/... 형태로 값을 돌려준다면? 문자열 비교가 실패하고 금지 목록이 그냥 통과된다. /tmp/private/tmp, /etc/private/etc도 마찬가지다. 파일 경로를 다루는 macOS 도구를 만든다면 이 정규화는 선택이 아니다.

6-4. TOCTOU 방어 — 검사와 사용 사이의 틈

용어
TOCTOU (Time-Of-Check to Time-Of-Use)
"확인한 시점"과 "실제로 쓰는 시점" 사이에 대상이 바뀌는 취약점. 예: 앱이 ~/Library/Caches/foo가 안전하다고 확인한 직후, 공격자가 그 경로를 /System을 가리키는 심볼릭 링크로 바꿔치기한다. 앱은 "안전 확인 완료"라고 믿고 시스템을 지운다.

SafetyGuard.validatePath는 삭제 직전에 심볼릭 링크를 다시 해석하고 경로 구성요소 접두사를 비교해 이 공격을 막는다. 안전 검사를 "한 번 하고 결과를 캐싱"하지 않는다는 규율이 핵심이다.

6-5. 실제 버그 수정 커밋이 최고의 교재다

클론 시점의 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)   // 실패를 전파 — 조용히 성공하지 않음
  ...
}

6-6. 나머지 학습 포인트 요약

주제파일배울 것
액터 + AsyncStreamCore/Scanner/FileTreeScanner.swift액터 메서드가 nonisolated로 스트림을 반환하는 패턴, @unchecked Sendable을 정당화 주석과 함께 쓰는 법
GRDB 스키마 버저닝Core/Cache/Database.swiftDatabaseMigratorv1_create_tables 등록, scans/cachedFiles/installedApps 테이블에 명시적 보조 인덱스 부여
위험도를 타입으로MacCleanKit/MaintenanceTask.swiftSeverity{safe, advanced}·requiresAdmin·sideEffects를 데이터로 두어 뷰에 조건문이 흩어지지 않게 함
휴리스틱의 함정MacCleanKit/AppMatching.swift10단계 매칭 중 8단계(회사명)를 기본 비활성화한 이유 — 텐센트 위안바오를 지우면 위챗 파일까지 매칭된 실제 이슈(#98)
구/신 상태관리 비교ViewModels/ 두 파일한 저장소 안의 ObservableObject vs @Observable 대조군
테스트 지원 계층Tests/MacCleanTestSupport/withTempHome·withFakeApp·MockClock — 파일시스템을 건드리는 테스트를 격리하는 헬퍼 설계

7시스템 요구사항 · 설치

맥 전용 — 리눅스/윈도우에서는 코드 읽기만 가능하다
항목요구사항
실행 OSmacOS 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 (서명 인증서 필요)

8직접 해볼 수 있는 실습 과제

난이도별 5개 — 맥이 없어도 1·2번은 가능하다
난이도 ★☆☆ · 맥 불필요

과제 1 — 스쿼리파이드 트리맵을 다른 언어로 이식하기

MacCleanKit/SquarifiedTreemap.swift는 순수 함수라 파일시스템도 UI도 필요 없다. 이걸 Python이나 TypeScript로 옮기고, 임의의 {이름, 크기} 목록을 넣어 SVG로 그려보라.

확인 방법: 크기가 1000·500·500·250·250인 다섯 노드를 정사각형에 배치했을 때, 가장 못생긴 사각형의 가로세로 비를 계산해보라. 같은 데이터를 "그냥 순서대로 왼쪽부터 세로로 자르기"(slice-and-dice) 방식과 비교하면 왜 squarify가 필요한지 눈으로 보인다.

난이도 ★☆☆ · 맥 불필요

과제 2 — README와 코드의 불일치 직접 찾아내기

이 문서가 짚은 두 가지(XPC 헬퍼 부재, 악성코드 서명 19개)를 내 손으로 재현해보라. 남이 정리해준 결론을 읽는 것과, 검증 명령을 스스로 굴려보는 것은 다른 기술이다.

grep -rl "NSXPCListener" Sources/          # 결과 0줄
find . -iname '*.entitlements'              # 결과 없음
cat Sources/MacCleanKit/MalwareSignatures.swift
ls Sources/ | grep -i helper                # MacCleanHelper 없음

확장: 관심 있는 다른 오픈소스에도 같은 감사를 해보라. "아키텍처 다이어그램에 있는 컴포넌트가 실제 코드에 있는가"는 놀랄 만큼 자주 실패하는 검사다.

난이도 ★★☆ · 맥 필요

과제 3 — 중복 탐지 파이프라인을 단계별로 계측하기

DuplicatesModule의 각 단계(크기 그룹핑 → 4KB 해시 → 전체 해시 → inode)에 통과 파일 수와 소요 시간을 찍는 로그를 넣고 실제 폴더에 돌려보라.

확인할 것: 2단계에서 몇 %가 탈락하는가? 만약 4KB 부분 해시를 없애고 곧장 전체 해시로 가면 몇 배 느려지는가? 반대로 부분 해시 크기를 4KB에서 64KB로 키우면 나아지는가 나빠지는가? — 파일 종류(사진 vs 동영상 vs 코드)에 따라 답이 달라지는 게 핵심이다.

난이도 ★★☆ · 맥 필요

과제 4 — SafetyGuard를 뚫어보기 (안전한 환경에서)

Tests/MacCleanTestSupport/withTempHome 헬퍼를 써서 격리된 임시 홈 디렉토리를 만들고, SafetyGuard에 대한 공격 테스트를 직접 작성하라: ① /var vs /private/var 표기 혼용, ② 안전한 경로를 위험한 곳으로 가리키는 심볼릭 링크, ③ 경로에 .. 삽입.

규칙: 절대 실제 홈 디렉토리에서 돌리지 말 것. 이 과제의 절반은 "파괴적 코드를 안전하게 테스트하는 환경을 만드는 법"을 배우는 것이다.

난이도 ★★★ · 맥 필요

과제 5 — 스캐너를 getattrlistbulk로 바꿔 벤치마크

현재 FileTreeScannerFileManager.enumerator를 쓴다. 편하지만 파일 하나마다 여러 번의 시스템 호출이 발생한다. BSD 레벨의 getattrlistbulk(2)는 한 번의 호출로 여러 항목의 속성을 한꺼번에 가져온다.

도전: 10만 개 이상 파일이 있는 디렉토리에서 두 방식의 소요 시간을 비교하라. 그리고 "얼마나 빨라져야 이 복잡도를 감수할 가치가 있는가"를 스스로 판정해보라. 실제 엔지니어링 판단은 "더 빠르다"가 아니라 "이 정도 빠름이 이 정도 복잡도를 정당화하는가"다.

난이도 ★★★ · 심화

과제 6 — 진짜 특권 분리 구현하기

README가 약속했지만 없는 것을 직접 만들어보라: SMAppService.daemon으로 등록되는 XPC 헬퍼를 추가하고, NSXPCListener.setCodeSigningRequirement로 본체 앱의 서명만 접속을 허용하게 한 뒤, 유지보수 명령을 osascript 대신 이 헬퍼로 라우팅한다.

왜 어려운가: 코드 서명, 헬퍼 설치 권한, 프로세스 생명주기, 인터페이스 프로토콜 정의가 전부 얽힌다. 완성하면 macOS 보안 모델을 몸으로 이해하게 된다. 그리고 업스트림에 PR을 낼 가치가 있는 기여다.

9관련 기술 심화 학습 로드맵

4주 — "SwiftUI 튜토리얼"이 아니라 "시스템 도구 만드는 법" 경로
주차주제구체적 행동
1주차Swift 6 동시성actor / @Sendable / TaskGroup / AsyncStream 문법을 익힌 뒤, FileTreeScanner.swiftDuplicatesModule.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류를 먼저 읽고 소스로 넘어가는 순서를 권한다.

10핵심 키워드 사전

이 문서에 나온 용어 총정리
SWIFT
actor
내부 상태에 한 번에 한 작업만 접근하도록 컴파일러가 강제하는 참조 타입. 락(lock)을 직접 걸지 않고도 데이터 경쟁을 막는다. FileTreeScanner가 이걸로 스캔 상태를 보호한다.
SWIFT
Sendable / @unchecked Sendable
Sendable은 "이 타입은 스레드 사이를 안전하게 오갈 수 있다"는 컴파일러 보증. @unchecked Sendable은 "내가 책임질 테니 검사하지 말라"는 수동 탈출구다. FileNode가 이 케이스인데, 실제로는 스캐너 액터 한 곳에서만 쓰기 때문에 안전하다.
SWIFT
TaskGroup
여러 비동기 작업을 동시에 띄우고 결과를 모으는 구조. withTaskGroup 블록을 벗어나면 자식 작업이 반드시 끝나거나 취소된다(구조적 동시성). 중복 탐지의 병렬 해싱이 이걸 쓴다.
SWIFT
AsyncStream
비동기로 값이 하나씩 흘러나오는 시퀀스. for await item in stream으로 소비한다. 수십만 개 파일을 배열에 다 모아 반환하는 대신 발견 즉시 흘려보내 메모리와 체감 속도를 모두 잡는다.
macOS
firmlink
APFS에서 읽기 전용 시스템 볼륨과 쓰기 가능 데이터 볼륨을 이어 붙이는 특수 연결. /var가 실은 /private/var인 식의 이중 표기를 만든다. 경로 기반 안전 검사를 우회당하지 않으려면 반드시 정규화해야 한다.
macOS
inode
파일시스템이 파일 실체에 붙이는 고유 번호. 하드링크 두 개는 경로가 달라도 같은 inode다. 중복 탐지에서 이걸 확인하지 않으면 같은 파일을 "중복"으로 오인해 지우게 된다(실제로는 디스크가 전혀 절약되지 않는다).
macOS
전체 디스크 접근 (Full Disk Access)
TCC(투명성·동의·제어) 시스템이 관리하는 권한 중 가장 넓은 것. 메일·메시지·사파리 데이터 등 보호 영역까지 읽게 해준다. 앱이 스스로 켤 수 없고 사용자가 시스템 설정에서 직접 켜야 한다.
macOS
XPC
macOS의 프로세스 간 통신 방식. 특권이 필요한 작업을 별도 헬퍼 프로세스에 격리하고, setCodeSigningRequirement로 "이 서명을 가진 앱만 나에게 명령 가능"을 커널 수준에서 강제할 수 있다. Mac Sai의 README는 이걸 쓴다고 하지만 실제 코드에는 없다.
macOS
SMAppService
로그인 시 자동 실행·데몬 등록을 다루는 최신 API. Mac Sai는 .mainApp(로그인 시 앱 실행)에만 쓰고, .daemon(특권 데몬)에는 쓰지 않는다.
보안
TOCTOU
Time-Of-Check to Time-Of-Use. 안전을 확인한 시점과 실제 사용 시점 사이에 대상이 바뀌어 검사가 무의미해지는 취약점. 파일 삭제 도구의 대표적 위험 지점.
보안
공급망 공격 (supply-chain attack)
앱 자체가 아니라 앱이 쓰는 라이브러리·빌드 도구에 악성 코드를 심는 공격. 디스크 전체 권한을 가진 비샌드박스 앱은 피해 반경이 극단적으로 커서, 의존성을 최소화하는 게 실질적 방어가 된다.
알고리즘
스쿼리파이드 트리맵 (Squarified Treemap)
계층 데이터를 면적으로 표현하되 각 사각형이 정사각형에 가깝도록 배치하는 알고리즘(Bruls·Huizing·van Wijk, 2000). 길쭉한 사각형은 넓이 비교가 어렵다는 시지각 문제를 푼다.
알고리즘
부분 해시 (partial hash)
파일 전체 대신 앞부분 일부(여기선 4KB)만 해싱해 빠르게 후보를 걸러내는 기법. 다르면 확실히 다르고, 같으면 "아직 모름"이므로 전체 해시로 넘어간다. 필터를 싼 것부터 배치하는 일반 원칙의 사례.
DB
WAL 모드 (Write-Ahead Logging)
SQLite가 변경 내용을 별도 로그 파일에 먼저 쓰는 방식. 쓰기 중에도 읽기가 막히지 않아 "스캔 결과를 저장하면서 UI가 조회"하는 상황에 적합하다. GRDB의 DatabasePool이 이 모드를 쓴다.
배포
Homebrew cask
Homebrew로 GUI 맥 앱을 설치하는 형식. 공식 cask(homebrew/cask 저장소 등재)와 개인 tap은 신뢰 수준이 다르다 — 전자는 리뷰 절차를 거친다.

11참고 링크

원본 소스와 배경 지식

저장소 · 배포

이 문서가 인용한 핵심 파일

배경 지식

비교 대상