By 37Design |

iOS Share Extension이 2초 만에 종료되는 문제: OOM Kill 해결법

iPhone에서 WordPress로 직접 사진을 업로드하는 앱 SnapPress를 개발하면서, 사용자가 SnapPress를 먼저 열지 않고도 사진 앱에서 바로 공유할 수 있도록 Share Extension을 추가했습니다.

Extension은 작동했습니다. 대부분의 경우에는요. 때로는 열렸다가 로딩 스피너를 약 2초 동안 보여준 후 흔적도 없이 사라졌습니다. Xcode에서 크래시 로그 없음. 스택 트레이스 없음. 오류 메시지 없음. 그냥 사라졌습니다.

Share Extension vs Action Extension

Action Extension(com.apple.ui-services)은 공유 시트의 앱 아이콘 행에 나타납니다. 콘텐츠를 그 자리에서 변환하거나 처리하도록 설계되었습니다.

Share Extension(com.apple.share-services)은 공유 시트의 활동 목록에 나타납니다. 콘텐츠를 어딘가로 전송하도록 설계되었습니다. 자체 UI, 자체 프로세스, 자체 메모리 제한을 가진 완전히 독립적인 미니 앱으로 실행됩니다.

크래시: "Invalidation Requested"

첫 번째 단서는 Mac의 Console.app에서 나왔습니다. iPhone을 연결하고 Extension 프로세스 이름으로 필터링하니 sharingd에서 다음을 발견했습니다:

MetricEvent 'com.apple.sharing.sharesheetCompleted' : {
  "success" : false,
  "totalShareTimeMs" : 2700,
  "activityType" : "app.snappress.SnapPress.ShareExtension",
}

그리고 Photos에서:

View service session ended with error:
_UIViewServiceHostSessionErrorDomain Code=4
UserInfo={Message=Invalidation requested}

Invalidation requested는 Extension 프로세스가 예기치 않게 종료되었음을 의미합니다. totalShareTimeMs: 2700은 정확히 2.7초 동안 살아있었음을 나타냅니다. 이것은 OOM kill의 특징입니다. iOS는 메모리 제한을 초과한 프로세스를 크래시 로그나 신호 없이 조용히 종료합니다.

근본 원인: UIImage(data:)는 메모리에 전체 해상도로 디코딩한다

Share Extension은 NSItemProvider에서 이미지를 로드하고 처리한 후 WordPress에 업로드합니다. 처리 단계에서 이 메서드를 호출했습니다:

static func process(_ imageData: Data, quality: ImageQuality) -> Data? {
    guard let image = UIImage(data: imageData) else { return nil }
    let resized = resize(image, maxDimension: quality.maxDimension)
    return resized.jpegData(compressionQuality: quality.compressionQuality)
}

문제는 UIImage(data: imageData)입니다. 이것은 아무 처리도 하기 전에 압축된 이미지 파일을 전체 해상도 픽셀 버퍼로 디코딩합니다. 12메가픽셀 iPhone 사진(4032 × 3024 픽셀)은 다음과 같이 확장됩니다:

4032 × 3024 × 4바이트 (RGBA) = ~47 MB

리사이즈된 복사본, JPEG 출력 버퍼, SwiftData 컨테이너, 썸네일 미리보기를 합치면 단일 업로드에서 60~80 MB에 달할 수 있습니다. iOS는 Share Extension에 약 120~150 MB를 제공합니다. 큰 HEIC 사진 몇 장이면 그 한도를 쉽게 초과합니다.

해결책: CGImageSource로 목표 크기에 직접 디코딩하기

CGImageSource는 디코딩 중에 다운샘플링을 수행하여 메모리에 전체 해상도 버퍼를 만들지 않고 목표 크기의 이미지를 생성할 수 있습니다.

static func process(_ imageData: Data, quality: ImageQuality) -> Data? {
    let maxDim = Int(quality.maxDimension)
    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDim,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceShouldCacheImmediately: false,
    ]
    guard
        let source = CGImageSourceCreateWithData(imageData as CFData, nil),
        let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
    else { return nil }

    return UIImage(cgImage: cgImage).jpegData(compressionQuality: quality.compressionQuality)
}

이미지당 메모리 사용량이 ~60 MB에서 ~6 MB로 줄었습니다. 10배 감소입니다. 그 이후로 Share Extension은 한 번도 크래시되지 않았습니다.

요약

iOS Share Extension이 크래시 로그 없이 2~3초 후에 종료된다면, 거의 확실히 OOM kill입니다. UIImage(data:)CGImageSourceCreateThumbnailAtIndex로 교체하고 kCGImageSourceThumbnailMaxPixelSize를 목표 크기로 설정하세요.

SnapPress는 App Store에서 이용 가능합니다.