SwiftUI와 libmtp 사이에 취소 가능한 파일 전송 큐 만들기
macOS와 Android 사이의 MTP 파일 전송을 Swift actor로 직렬화하고 C 콜백을 진행률·취소·실패 재시도가 가능한 큐로 연결한 개인 프로토타입 기록입니다.
macOS에서 Android 기기로 파일을 옮기는 개인 앱을 만들면서 처음에는 libmtp의 업로드 함수를 호출하고 완료를 기다리는 정도로 시작했습니다. 단일 파일은 동작했지만 여러 파일을 드래그하면 진행률을 알 수 없었고, 하나가 실패했을 때 전체를 다시 시도해야 했습니다. 전송 중 취소도 할 수 없었고 동시에 들어온 MTP 명령은 USB 연결 상태를 불안정하게 만들 수 있었습니다.
이 글은 SwiftUI 앱과 C 기반 libmtp 사이에 직렬 실행, 진행률, 취소, 실패 재시도를 지원하는 전송 큐를 만든 개인 프로토타입 경험을 정리합니다.
macOS에는 Android 파일 전송 API가 없었다
Android는 USB 파일 전송에 MTP(Media Transfer Protocol)를 사용합니다. macOS에는 이를 직접 다루는 고수준 시스템 API가 없어 libmtp를 Swift Package의 시스템 라이브러리로 연결하고, 필요한 기능을 C 브리지로 감쌌습니다.
앱의 계층은 다음처럼 나뉩니다.
flowchart LR A[SwiftUI 파일 화면] --> B[전송 대기열] B --> C[Swift actor 서비스] C --> D[C 브리지] D --> E[libmtp와 USB 기기] E --> D D --> B B --> A
아래 코드 예시는 설명을 위해 식별자와 운영 설정값을 일반화했습니다.
SwiftUI는 사용자의 선택과 상태 표시를 담당하고, 실제 MTP 연결과 명령은 서비스 계층에 모았습니다.
MTP 작업은 actor로 직렬화했다
하나의 USB MTP 연결에 목록 조회, 업로드, 다운로드, 이름 변경이 동시에 들어가면 연결 객체와 기기 상태가 경쟁할 수 있습니다. 서비스 자체를 Swift actor로 선언해 한 번에 하나의 격리된 작업만 연결 상태를 변경하도록 했습니다.
actor DeviceTransferService: DeviceTransferProtocol {
private var connection: OpaquePointer?
func upload(...) async throws { /* MTP 호출 */ }
func download(...) async throws { /* MTP 호출 */ }
func rename(file: DeviceFile, to name: String) async throws { /* ... */ }
func delete(file: DeviceFile) async throws { /* ... */ }
}
별도 아키텍처 프레임워크를 도입하지 않고 DeviceTransferProtocol 프로토콜과 actor만 사용했습니다. 화면은 프로토콜에 의존하므로 실제 USB 장치 없이 테스트용 구현으로 교체할 여지도 남겼습니다.
C 콜백을 Swift 진행률로 연결했다
libmtp는 전송된 바이트와 전체 바이트를 C 콜백으로 전달합니다. 브리지 함수가 콜백과 context 포인터를 받고, Swift 객체를 불투명 포인터로 전달해 다시 복원했습니다.
private let deviceProgressCallback: DeviceProgressCallback = {
bytesTransferred, totalBytes, context in
guard let context else { return 0 }
let reporter = Unmanaged<TransferProgressObserver>
.fromOpaque(context)
.takeUnretainedValue()
return reporter.receive(
bytesTransferred: bytesTransferred,
totalBytes: totalBytes
) ? 0 : 1
}
콜백 반환값 0은 계속 진행, 1은 취소를 뜻하도록 C 브리지와 맞췄습니다. TransferProgressObserver는 취소 토큰을 확인하고 Swift의 TransferProgress를 화면으로 전달합니다.
진행률 이벤트를 그대로 화면에 보내지 않았다
USB 전송 콜백은 매우 자주 호출될 수 있습니다. 모든 바이트 변화를 SwiftUI 상태로 보내면 메인 스레드 갱신 비용이 커집니다. 진행률 보고 객체에 NSLock과 마지막 보고 시각·바이트를 두고 의미 있는 변화만 전달했습니다.
이 잠금은 MTP 작업 자체를 직렬화하는 actor와 역할이 다릅니다. actor는 USB 명령의 순서를 보호하고, NSLock은 C 콜백이 접근하는 작은 진행률 상태를 보호합니다.
파일별 작업을 독립된 상태로 만들었다
대기열의 각 항목은 업로드 또는 다운로드 방향, 원본, 대상, 상태와 오류를 가집니다. 작업 상태를 분리하면서 다음 기능을 구현할 수 있었습니다.
- 대기 중인 파일의 순차 실행
- 파일별 진행률 표시
- 현재 전송 취소
- 실패한 파일만 다시 시도
- 완료한 항목만 목록에서 정리
- 전체 취소와 실패 항목 일괄 재시도
UI는 Foundation.Progress를 사용해 AppKit 네이티브 진행 표시와 연결했습니다.
struct TransferQueueView: NSViewRepresentable {
let jobs: [TransferJob]
let progressForJob: (UUID) -> Progress?
let onCancel: (UUID) -> Void
let onRetry: (UUID) -> Void
let onCancelAll: () -> Void
let onRetryFailed: () -> Void
}
SwiftUI만으로 파일 테이블을 구성하려다 macOS 네이티브 다중 선택과 드래그 동작을 세밀하게 제어하기 어려운 부분은 NSViewRepresentable로 AppKit을 연결했습니다.
Finder 드래그는 파일을 즉시 준비할 수 없었다
Mac 파일을 Android로 보내는 방향은 로컬 URL을 바로 전달할 수 있습니다. 반대로 Android 파일을 Finder로 끌 때는 드래그가 시작되는 순간 로컬 파일이 아직 없습니다. USB에서 다운로드한 뒤 Finder가 요구한 위치에 파일을 제공해야 합니다.
이 경로에는 NSFilePromiseProvider를 사용했습니다. 파일을 즉시 주는 대신 “요청된 위치에 파일을 준비하겠다”는 약속을 드래그 세션에 전달하고, 실제 쓰기 단계에서 MTP 다운로드를 실행했습니다.
다중 선택에서는 선택된 행 전체로 드래그 세션을 만들었습니다. Mac 파일은 URL 묶음, Android 파일은 파일 promise 묶음으로 처리해 방향별 데이터 표현을 분리했습니다.
같은 패널로 되돌린 드롭은 전송이 아니었다
양쪽 파일 패널이 모두 드래그 소스이자 대상이 되면서, 사용자가 파일을 원래 패널에 다시 놓는 경우가 생겼습니다. 이를 업로드나 다운로드로 처리하면 자기 자신에게 복사하는 잘못된 작업이 만들어집니다.
네이티브 드래그 세션에 출발 패널을 기록하고, 목적지가 같으면 드롭을 거절했습니다. 정렬된 테이블에서는 화면의 행 순서와 원본 데이터 인덱스가 다를 수 있어 드래그, 이름 변경, 선택 로직 모두 같은 정렬 결과를 기준으로 항목을 찾도록 맞췄습니다.
파일 조작은 낙관적으로 반영하고 실패한 항목만 복구했다
Android 폴더 생성, 이름 변경, 삭제는 성공 응답을 기다린 뒤 전체 목록을 다시 읽으면 반응이 느려 보입니다. 화면 상태를 먼저 갱신하고 실제 MTP 작업이 실패한 항목만 원래 상태로 되돌렸습니다.
다만 파일 전송 자체는 성공을 추측할 수 없으므로 완료 전에는 최종 파일로 표시하지 않았습니다. 낙관적 업데이트는 되돌릴 수 있고 결과를 예측할 수 있는 메타데이터 조작에만 적용했습니다.
확인된 결과와 한계
프로토타입에서 구현한 전송 흐름은 다음과 같습니다.
- Mac과 Android 사이의 양방향 드래그 전송을 지원합니다.
- 여러 파일과 폴더를 선택해 일괄 전송할 수 있습니다.
- C 콜백을 Swift 진행률과 취소 토큰에 연결했습니다.
- MTP 작업을 actor에서 직렬 실행합니다.
- 파일별 취소, 실패 재시도, 전체 취소가 가능한 큐를 제공합니다.
- 이름 변경과 삭제는 낙관적으로 반영하고 실패 항목만 복구합니다.
이 작업은 개인 프로토타입으로, 일반 사용자에게 배포한 제품은 아닙니다. 대용량 파일과 다양한 제조사 기기의 안정성은 추가 검증이 필요합니다. MTP 기기는 한 번에 한 애플리케이션만 연결할 수 있고, 기기 잠금이나 USB 모드에 따라 연결이 실패할 수 있습니다.
네이티브 전송 큐는 비동기 작업의 경계를 정하는 일이다
이번 작업에서 얻은 기준은 다음과 같습니다.
- 단일 하드웨어 연결의 명령은 한 실행 주체에서 직렬화합니다.
- C 콜백은 작은 어댑터를 통해 Swift 상태로 변환합니다.
- 진행률 이벤트는 UI가 감당할 빈도로 제한합니다.
- 파일별 상태를 분리해 일부 실패와 재시도를 지원합니다.
- 드래그 데이터는 원본이 즉시 존재하는지에 따라 URL과 file promise를 구분합니다.
전송 큐의 역할은 작업을 줄 세우는 데 그치지 않고, 취소·실패·재시도 때 무엇을 보존할지 정의하는 것이었습니다.