9 분 소요

CaptionMate 앱 개발 중 심각한 문제 두 가지가 발생했다. 첫 번째는 CoreML/MPSGraph 런타임 캐시 충돌로 인한 모델 로딩 실패와 크래시, 두 번째는 모델 추론 과정에서 생성되는 임시 파일들이 맥북의 저장 공간을 과도하게 차지하는 문제였다.

이 글에서는 이러한 문제들을 체계적으로 분석하고 해결한 과정, 그리고 구현한 자동 복구 메커니즘에 대해 공유하고자 한다.

문제 상황

문제 A: 모델 전환 시 이전 모델 캐시 충돌로 인한 MPSGraph 크래시

증상

  • 모델을 바꾸는 과정에서 이전 모델의 캐시가 남아있어 충돌 발생
  • MPSGraph 관련 에러로 앱 크래시 발생
  • MPSGraphExecutable 초기화 실패로 모델 로딩 불가
  • 앱 재시작 후에도 동일한 문제 반복

에러 메시지 예시

⚠️ Model load failed: MPSGraph error occurred during model initialization
⚠️ Model load failed: MPSGraphExecutable creation failed
⚠️ Model load failed: MPSGraph cache conflict detected

문제 발생 시나리오

1. whisper-small 모델 로딩 → 정상 작동
2. whisper-large 모델로 전환 시도
3. 이전 모델(whisper-small)의 MPSGraph 캐시가 남아있음
4. 새로운 모델(whisper-large) 로딩 시 캐시 충돌
5. MPSGraph 에러 발생 → 앱 크래시

문제 B: 임시/캐시 파일 디스크 폭증

증상

  • 앱 작동에는 문제없지만 디스크 용량을 과도하게 차지
  • 모델 추론 과정에서 생성되는 임시 파일들이 정리되지 않고 쌓임
  • 오디오 파일 처리 시 생성되는 임시 파일들 누적
  • 다운로드된 모델 파일들의 중복 캐시 누적

디스크 사용량 분석

~/Library/Caches/com.example.app/
├── com.apple.e5rt.e5bundlecache/     # CoreML 런타임 캐시 (누적)
├── com.apple.CoreML/                  # CoreML 모델 캐시 (누적)
└── huggingface/models/                # WhisperKit 모델 파일 (누적)

/tmp/
├── coreml_*                          # CoreML 임시 파일 (정리 안됨)
├── whisper_*                         # WhisperKit 임시 파일 (정리 안됨)
├── *.wav, *.mp3, *.m4a              # 오디오 처리 임시 파일 (정리 안됨)
└── download_*                        # 다운로드 임시 파일 (정리 안됨)

문제 발생 시나리오

1. 오디오 파일 처리 → 임시 파일 생성
2. 모델 추론 실행 → 추가 임시 파일 생성
3. 앱 종료 시 임시 파일 정리 안됨
4. 반복 사용으로 임시 파일 누적
5. 디스크 용량 부족 (수 GB 단위)

원인 분석

문제 A: 모델 전환 시 캐시 충돌 원인

1. MPSGraph 캐시 메커니즘

CoreML은 모델별로 고유한 MPSGraph 캐시를 생성한다:

// 모델별 MPSGraph 캐시 생성 과정
1. whisper-small 로딩 → MPSGraph A 생성 및 캐시
2. whisper-large 로딩 → MPSGraph B 생성 시도
3. 이전 MPSGraph A 캐시가 남아있어 충돌 발생
4. MPSGraph B 초기화 실패 → 크래시

2. 모델 전환 시 캐시 충돌 발생 이유

  • 이전 모델의 MPSGraph 캐시가 완전히 해제되지 않음
  • 새로운 모델 로딩 시 이전 캐시와 충돌
  • GPU 메모리와 시스템 메모리에서 동시 충돌
  • WhisperKit의 모델 해제 메커니즘이 불완전

3. 캐시 파일 손상

  • 비정상 종료 시 MPSGraph 캐시 파일이 손상된 상태로 남음
  • 손상된 캐시로 인한 새로운 모델 로딩 실패
  • 손상된 캐시가 시스템 레벨에서 충돌 유발

문제 B: 임시 파일 누적 원인

1. CoreML 추론 과정에서의 임시 파일 생성

// WhisperKit 추론 과정에서 생성되는 임시 파일들
1. 오디오 파일 로드 → 임시 WAV 파일 생성
2. Mel Spectrogram 계산 → 중간 결과 임시 저장
3. 모델 추론 → GPU 메모리 → 임시 파일로 스왑
4. 결과 후처리 → 추가 임시 파일 생성
5. 앱 종료 시 → 임시 파일 정리 안됨 (누적)

2. 캐시 정리 메커니즘 부재

  • 앱 종료 시 임시 파일 자동 정리 안 됨
  • 모델 전환 시 이전 모델 캐시 정리 안 됨
  • 디스크 공간 부족 시 자동 정리 메커니즘 없음
  • 반복 사용으로 임시 파일이 수 GB 단위로 누적

해결 과정

시도 1: 수동 캐시 정리 ❌

// 앱 시작 시 캐시 정리
func clearCacheOnStartup() {
    let fileManager = FileManager.default
    let cacheDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
    
    do {
        let contents = try fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil)
        for item in contents {
            try fileManager.removeItem(at: item)
        }
    } catch {
        print("Failed to clear cache: \(error)")
    }
}

문제점:

  • 앱 시작 시에만 정리되어 런타임 중 문제 해결 안 됨
  • 사용자 데이터까지 삭제할 위험
  • 모델 로딩 중 문제 발생 시 대응 불가

시도 2: 에러 발생 시 캐시 정리 ❌

// 모델 로딩 실패 시 캐시 정리
do {
    try await whisperKit.loadModels()
} catch {
    clearCache()
    // 재시도...
}

문제점:

  • 에러 발생 후에야 대응 (사후 처리)
  • 사용자 경험 저하 (로딩 시간 증가)
  • 근본적 해결책이 아님

시도 3: 자동 복구 메커니즘 (최종 해결) ✅

핵심 아이디어: 사전 예방 + 자동 복구 + 지속적 모니터링

1단계: 디스크 공간 사전 확인

// 디스크 공간 확인 및 캐시 정리
func checkDiskSpace() -> (available: Int64, required: Int64, isEnough: Bool) {
    let fileManager = FileManager.default
    guard let cachesDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first else {
        return (available: 0, required: 0, isEnough: false)
    }
    
    do {
        let resourceValues = try cachesDirectory.resourceValues(forKeys: [.volumeAvailableCapacityKey])
        guard let availableSpace = resourceValues.volumeAvailableCapacity else {
            return (available: 0, required: 0, isEnough: false)
        }
        
        let availableSpaceInt64 = Int64(availableSpace)
        let requiredSpace: Int64 = 3_000_000_000 // 3GB
        
        return (
            available: availableSpaceInt64,
            required: requiredSpace,
            isEnough: availableSpaceInt64 > requiredSpace
        )
    } catch {
        print("Failed to check disk space: \(error.localizedDescription)")
        return (available: 0, required: 0, isEnough: false)
    }
}

// 모델 로딩 전 디스크 공간 확인
let diskSpace = checkDiskSpace()
if !diskSpace.isEnough {
    print("⚠️ Insufficient disk space: Available \(diskSpace.available / 1_000_000) MB, Required \(diskSpace.required / 1_000_000) MB")
    clearCoreMLRuntimeCache()
}

2단계: CoreML 캐시 디렉토리 정리

func clearCoreMLRuntimeCache() {
    let fileManager = FileManager.default
    
    // 1. 앱의 캐시 디렉토리 찾기
    guard let cachesDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first else {
        print("Cache directory not found.")
        return
    }
    
    // 2. 앱의 번들 ID 가져오기
    let bundleID = Bundle.main.bundleIdentifier ?? "com.example.app"
    
    // 3. CoreML 캐시 디렉토리 찾기
    let possibleCacheDirs = [
        cachesDirectory.appendingPathComponent(bundleID)
            .appendingPathComponent("com.apple.e5rt.e5bundlecache"),
        cachesDirectory.appendingPathComponent(bundleID)
            .appendingPathComponent("com.apple.CoreML"),
        cachesDirectory.appendingPathComponent("com.apple.CoreML"),
        cachesDirectory.appendingPathComponent("CoreML"),
    ]
    
    // 4. 모든 가능한 캐시 디렉토리 정리
    var clearedAny = false
    for cacheDir in possibleCacheDirs {
        if fileManager.fileExists(atPath: cacheDir.path) {
            do {
                let contents = try fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil)
                for item in contents {
                    do {
                        try fileManager.removeItem(at: item)
                        print("Cache item deleted: \(item.lastPathComponent)")
                        clearedAny = true
                    } catch {
                        print("Failed to delete cache item: \(item.lastPathComponent) - \(error.localizedDescription)")
                    }
                }
                print("CoreML cache directory cleaned: \(cacheDir.path)")
            } catch {
                print("Failed to access CoreML cache directory: \(error.localizedDescription)")
            }
        }
    }
}

3단계: 임시 파일 필터링 및 정리

// 임시 디렉토리 정리
let tempDirectory = URL(fileURLWithPath: NSTemporaryDirectory())
do {
    let tempContents = try fileManager.contentsOfDirectory(
        at: tempDirectory,
        includingPropertiesForKeys: [.fileSizeKey, .creationDateKey]
    )
    
    // 앱 관련 임시 파일 필터링
    let appTempFiles = tempContents.filter { file in
        let fileName = file.lastPathComponent.lowercased()
        return fileName.contains("coreml") ||
            fileName.contains("whisper") ||
            fileName.contains(".bundle") ||
            fileName.contains("model") ||
            fileName.contains("mps") ||
            fileName.contains("mlmodel") ||
            fileName.contains(".wav") ||
            fileName.contains(".mp3") ||
            fileName.contains(".m4a") ||
            fileName.contains(".aac") ||
            fileName.contains(".flac") ||
            fileName.hasPrefix("tmp") ||
            fileName.contains("download")
    }
    
    var totalClearedSize: Int64 = 0
    for tempFile in appTempFiles {
        do {
            let resourceValues = try tempFile.resourceValues(forKeys: [.fileSizeKey])
            let fileSize = resourceValues.fileSize ?? 0
            
            try fileManager.removeItem(at: tempFile)
            totalClearedSize += Int64(fileSize)
            print("Temporary file deleted: \(tempFile.lastPathComponent) (\(ByteCountFormatter.string(fromByteCount: Int64(fileSize), countStyle: .file)))")
            clearedAny = true
        } catch {
            print("Failed to delete temporary file: \(tempFile.lastPathComponent) - \(error.localizedDescription)")
        }
    }
    
    if totalClearedSize > 0 {
        print("Total temporary files cleaned: \(ByteCountFormatter.string(fromByteCount: totalClearedSize, countStyle: .file))")
    }
} catch {
    print("Failed to search temporary directory: \(error.localizedDescription)")
}

4단계: MPSGraph 에러 자동 복구

// 모델 로드 시도
do {
    try await whisperKit.loadModels()
} catch {
    print("⚠️ Model load failed: \(error.localizedDescription)")
    
    // MPSGraph 관련 에러인 경우 캐시 정리 후 한 번만 재시도
    let errorString = error.localizedDescription
    if errorString.contains("MPSGraph") || 
       errorString.contains("MPSGraphExecutable") || 
       errorString.contains("No space left on device") {
        
        print("MPSGraph error or disk space shortage detected, retrying after cache cleanup...")
        clearCoreMLRuntimeCache()
        
        // 한 번 더 시도
        do {
            try await Task.sleep(nanoseconds: 500_000_000) // 500ms 대기
            try await whisperKit.loadModels()
        } catch {
            await MainActor.run {
                modelManagementState.modelState = .unloaded
                modelManagementState.hasModelLoadError = true
                modelManagementState.modelLoadError = "Model load failed (after retry): \(error.localizedDescription)"
            }
            return
        }
    } else {
        // 다른 에러는 즉시 실패 처리
        await MainActor.run {
            modelManagementState.modelState = .unloaded
            modelManagementState.hasModelLoadError = true
            modelManagementState.modelLoadError = error.localizedDescription
        }
    }
}

해결 원리

변경 전: 수동 관리 (Manual Management)

사용자 앱 사용
    ↓
모델 로딩 시도
    ↓
MPSGraph 에러 발생
    ↓
앱 크래시 또는 수동 재시작
    ↓
임시 파일 누적
    ↓
디스크 공간 부족

변경 후: 자동 복구 시스템 (Auto-Recovery System)

모델 로딩 시도
    ↓
디스크 공간 사전 확인
    ↓
부족 시 자동 캐시 정리
    ↓
모델 로딩 시도
    ↓
MPSGraph 에러 감지
    ↓
자동 캐시 정리 + 재시도
    ↓
성공 또는 명확한 에러 메시지

해결 로직 및 메커니즘

문제: 캐시 관리의 복잡성

CoreML 캐시 관리는 단순한 파일 삭제가 아니라 여러 요소를 고려해야 한다:

  1. 캐시 위치의 다양성: Apple이 버전별로 캐시 위치를 변경
  2. 안전성: 시스템 파일과 앱 파일 구분
  3. 효율성: 필요한 파일만 정확히 타겟팅
  4. 사용자 경험: 정리 과정이 사용자에게 미치는 영향 최소화

해결 메커니즘: 이중 캐시 관리 시스템

문제 A 해결: 모델 전환 시 MPSGraph 캐시 정리 메커니즘

// 모델 로딩 전 이전 모델 캐시 정리
func loadModel(_ model: String, redownload: Bool = false) {
    // 1. 기존 WhisperKit 인스턴스 해제
    if let kit = whisperKit {
        await kit.unloadModels()
        whisperKit = nil
    }
    
    // 2. MPSGraph 에러 감지 및 자동 복구
    do {
        try await whisperKit.loadModels()
    } catch {
        if errorString.contains("MPSGraph") {
            clearCoreMLRuntimeCache()  // MPSGraph 캐시 정리
            try await whisperKit.loadModels()  // 재시도
        }
    }
}

문제 B 해결: 임시 파일 정리 메커니즘

// 앱별 임시 파일 필터링 및 정리
func clearCoreMLRuntimeCache() {
    // 1. CoreML 캐시 디렉토리 정리
    let possibleCacheDirs = [
        cachesDirectory.appendingPathComponent("com.apple.e5rt.e5bundlecache"),
        cachesDirectory.appendingPathComponent("com.apple.CoreML"),
        // ...
    ]
    
    // 2. 임시 파일 필터링 및 정리
    let appTempFiles = tempContents.filter { file in
        let fileName = file.lastPathComponent.lowercased()
        return fileName.contains("coreml") ||
            fileName.contains("whisper") ||
            fileName.contains("model") ||
            // ... 앱 관련 파일만 필터링
    }
}

각 메커니즘의 특징 비교

메커니즘 목적 적용 시점 대상
MPSGraph 캐시 정리 모델 전환 시 충돌 방지 모델 로딩 전/에러 발생 시 MPSGraph 런타임 캐시
임시 파일 정리 디스크 용량 절약 디스크 부족 시/주기적 임시/캐시 파일

현재 프로젝트의 선택

현재 CaptionMate에서는 이중 캐시 관리 시스템을 구현했다:

  1. 문제 A 해결: 모델 전환 시 MPSGraph 캐시 정리 메커니즘
  2. 문제 B 해결: 임시 파일 정리 메커니즘
  3. 통합 관리: 두 메커니즘을 하나의 clearCoreMLRuntimeCache() 함수로 통합

왜 이 방식을 선택했나?

  1. 문제별 맞춤 해결: 각 문제의 특성에 맞는 정확한 해결책
  2. 코드 재사용성: 하나의 함수로 두 문제 모두 해결
  3. 효율성: 필요한 시점에만 정리하여 성능 영향 최소화
  4. 유지보수성: 명확한 책임 분리로 코드 관리 용이

교훈 및 베스트 프랙티스

1. CoreML 캐시 관리 원칙

// ✅ 좋은 예: 다중 캐시 디렉토리 대응
let possibleCacheDirs = [
    cachesDirectory.appendingPathComponent(bundleID)
        .appendingPathComponent("com.apple.e5rt.e5bundlecache"),
    cachesDirectory.appendingPathComponent(bundleID)
        .appendingPathComponent("com.apple.CoreML"),
    cachesDirectory.appendingPathComponent("com.apple.CoreML"),
    cachesDirectory.appendingPathComponent("CoreML"),
]

// ❌ 나쁜 예: 단일 경로만 고려
let cacheDir = cachesDirectory.appendingPathComponent("CoreML")

2. 안전한 파일 삭제

// ✅ 좋은 예: 앱별 파일 필터링
let appTempFiles = tempContents.filter { file in
    let fileName = file.lastPathComponent.lowercased()
    return fileName.contains("coreml") ||
        fileName.contains("whisper") ||
        fileName.contains("model") ||
        // ... 앱 관련 파일만 필터링
}

// ❌ 나쁜 예: 전체 임시 디렉토리 삭제
try fileManager.removeItem(at: tempDirectory)

3. 에러 복구 메커니즘

// ✅ 좋은 예: 특정 에러만 재시도
if errorString.contains("MPSGraph") || 
   errorString.contains("MPSGraphExecutable") {
    clearCoreMLRuntimeCache()
    try await whisperKit.loadModels()
}

// ❌ 나쁜 예: 모든 에러에 재시도
catch {
    clearCache()
    retry()
}

4. 디스크 공간 모니터링

// ✅ 좋은 예: 모델별 공간 요구사항 계산
let estimatedSize: Int64
if model.contains("large") {
    estimatedSize = 3_600_000_000 // 3GB + 20%
} else if model.contains("medium") {
    estimatedSize = 1_800_000_000 // 1.5GB + 20%
} else {
    estimatedSize = 600_000_000 // 500MB + 20%
}

// ❌ 나쁜 예: 고정된 공간 요구사항
let requiredSpace: Int64 = 1_000_000_000 // 1GB

5. 로깅 및 모니터링

// ✅ 좋은 예: 상세한 로깅
print("Cache item deleted: \(item.lastPathComponent)")
print("Total temporary files cleaned: \(ByteCountFormatter.string(fromByteCount: totalClearedSize, countStyle: .file))")
print("CoreML cache directory cleaned: \(cacheDir.path)")

// ❌ 나쁜 예: 로깅 없음
try fileManager.removeItem(at: item)

성능 개선 결과

변경 전

  • MPSGraph 에러 발생 시 앱 크래시 또는 수동 재시작 필요
  • 임시 파일 누적으로 디스크 공간 부족
  • 모델 로딩 실패율: 높음
  • 사용자 경험: 불안정

변경 후

  • MPSGraph 에러 자동 감지 및 복구
  • 디스크 공간 사전 확인으로 예방적 관리
  • 임시 파일 자동 정리로 디스크 공간 관리

결론

CoreML/MPSGraph 캐시 관리는 단순한 파일 삭제가 아니라 시스템의 안정성과 사용자 경험을 보장하는 핵심 인프라다.

이번 문제 해결 과정을 통해 배운 것들:

  1. 예방적 관리의 중요성: 문제 발생 전에 미리 대응하는 것이 가장 효과적
  2. 자동 복구 메커니즘: 사용자 개입 없이 시스템이 스스로 문제를 해결
  3. 안전한 파일 관리: 시스템 파일과 앱 파일을 구분하여 안전하게 관리
  4. 지속적 모니터링: 일회성 해결이 아닌 지속적인 관리 시스템 구축

이러한 원칙들은 CoreML뿐만 아니라 다른 머신러닝 프레임워크나 대용량 데이터를 다루는 앱에서도 적용할 수 있는 범용적인 해결책이다. 작은 캐시 문제 하나가 전체 앱의 안정성을 좌우할 수 있다는 것을 배웠다.

참고 자료


프로젝트: CaptionMate

댓글남기기