[WWDC23] 탄탄한 재현형 파일 전송 구축하기

kimseawater
daily-monster
Published in
15 min readJun 11, 2024

안녕하세요 오랜만에 화요일에 돌아온 kimseawater입니다.

이제 벌써.. WWDC24가 코앞인데요…….. (쓰다보니 벌써 시작되었네요..)

WWDC23중에 못들은 내용을 호다닥 듣고있습니다.. 1년이 참 빨리 가네요.. 오늘은 최근에 들었던 WWDC23 중에서 ‘탄탄한 재현형 파일 전송 구축하기 (Build robust and resumable file transfers)’를 가져와봤습니다.

이 세션에서는 파일 전송을 정지했다가, 재개하는 방법, 또 앱이 백그라운드에서 정지된 경우에도 URLSession을 이용해 파일을 전송하는 사례 등에 대해 소개하고 있습니다.

평소 대용량 파일 같은걸 전송할때를 생각해봅시다. 네트워크가 잠깐 중단되어 전송하던 것이 전부 날라갈 수도 있고, 더 오래걸릴 수도, 혹은 이 과정에서 뭔가 잘못될 수도 있습니다.

이 세션에서는 사용자가 앱 사용을 중단하거나, 와이파이가 끊기거나 등등, 네트워크 문제를 겪었을때의 해결방법과 탄탄한 네트워크를 제공할 수 있는 방법에 대해 알려줍니다.

Pause and Resume

resumable http 프로토콜은 사용자가 연결이 중단되었을때도 진행 상태를 유지할 수 있도록 해줍니다. 그래서 대용량 데이터를 전송하다가 만약 의도치 않은 네트워크 문제가 발생하거나, 앱이 종료되는 등 사용자와 연결이 끊어졌을 때 발생할 수 있는 문제들을 해결할 수 있습니다.

파일을 다운받다가, 이슈가 생겨서 다운로드가 멈췄다고 해봅시다. 그치만, 와이파이가 다시 연결되는 등 이슈가 해결되면 다시 처음부터 받는게 아니라, 중단된 지점부터 다시 다운받을 수 있습니다.

이는 어떻게 작동할까요?

Resumable downloads in HTTP

  • 먼저 클라이언트가 서버에 GET 요청을 보냅니다.
  • 그럼 서버에서 Accept-Ranges 헤더를 통해, 이 자원의 특정 bytes에 대해 서버가 range 요청을 지원함을 알립니다.
  • 서버 응답의 ETag는 서버 콘텐츠가 변경되었는지를 알립니다.
  • 다운로드하다가 중단되면, 클라이언트에서는 뒤에 아직 다운받지 않은 남은 부분만 필요하게 됩니다.. 이를 위해 서버에 Range 정보를 보냅니다.
  • 그리고 이미 받은 클라이언트의 자원이 변하지 않았는지를 확인하기 위해 If-Range 부분에 ETag를 보냅니다.. 이 ETag는 이전 서버 응답에서 받은 정보입니다.
  • 서버에서 받은 ETag가 같으면, 이미 다운받은 데이터는 변하지 않은 거니까 서버에 남은 데이터만 보내라고 말합니다.
  • ETag가 같으면, 서버에서 206 Partial Content 응답이 오고, Content-Range에 응답에 포함된 바이트 범위를 담습니다.

지금까지 URLSession에서 download를 멈추고 재개하는 기능은 제공했었는데, upload할때 멈추고 재개하는 기능이 생겼습니다.

먼저 이미 있던 다운로드부터 봅시다.

URLSession에서 download pause 및 resume

사용자가 다운로드를 직접 멈추고 재개할 수 있는 UI를 만들고 있다고 생각해봅시다.

let downloadTask = session.downloadTask(with: request)
downloadTask.resume()

다운로드 task를 만들고 resume을 호출해 시작합니다.

let downloadTask = session.downloadTask(with: request)
downloadTask.resume()

guard let resumeData = await downloadTask.cancelByProducingResumeData() else {
// Download cannot be resumed
return
}

사용자가 정지버튼을 누를때 cancelByProducingResumeData()를 호출할 수 있습니다. 여기에는 부분 다운로드에 대한 정보 (ex. ETag나 현재 용량, 디스크 위치 등)나 메타데이터들이 있습니다. 그치만 이 resumeData가 부분 다운로드 데이터 자체는 아니고, 그 데이터에 대한 정보만 포함하고 있습니다!

만약 resumeData가 nil이면, 하나 이상의 다운로드 resume 조건이 충족되지 않았다는 뜻입니다. nil이 아니라서 위 코드에서 resumeData에 할당된다면, 나중에 사용하기 위해 이를 가지고 있어야 합니다.

let downloadTask = session.downloadTask(with: request)
downloadTask.resume()

guard let resumeData = await downloadTask.cancelByProducingResumeData() else {
// Download cannot be resumed
return
}

let newDownloadTask = session.downloadTask(withResumeData: resumeData)
newDownloadTask.resume()

그리고 resume할때, 위에서 저장한 resumeData를 downloadTask에 전달하면 됩니다.

위에 나온 예시는 직접 다운로드를 정지할 경우에 좋습니다. 그치만 URLSession에서는 예상치 못한 연결 중단을 복구할 수도 있습니다.

do {
let (url, response) = try await session.download(for: request)
} catch let error as URLError {
guard let resumeData = error.downloadTaskResumeData else {
return
}
}

downloadTask가 네트워크 문제로 실패하면, URLError에서 resumeData를 가지고 올 수 있습니다. 다운로드가 재개될 수 있는 경우 오류에서 userInfo 딕셔너리에 데이터가 저장되어 있습니다.

URLSession의 download resume 조건

  • 다운로드는 기본적으로 데이터를 가져오고, 반복에 안전해야 하기 때문에 HTTPS, HTTP GET만 지원합니다.
  • 서버가 byte-range요청을 지원해야 합니다. 그리고 이를 Accept-Ranges헤더를 통해 알려야 합니다.
  • 서버에서 응답할때 ETag나 Last-Modified 필드를 제공해야 합니다. (ETag가 더 선호됨)
  • 임시 다운로드 파일이 시스템에 의해 지워지면 안됩니다.

이 요건이 만족되면, 직접 다운로드를 정지하고 재개할 수 있게 됩니다.

Upload

업로드는 보통 다운로드보다 훨씬 느립니다. 그래서 iOS17 부터는 새로운 resume upload task가 도입되었고, 서버에서 이 프로토콜의 규격을 만족하기만 한다면 자동으로 resume할 수 있게 됩니다.

let uploadTask = session.uploadTask(with: request, fromFile: fileURL)
uploadTask.resume()

먼저 download task와 비슷하게, upload task를 만들고 resume해서 업로드를 시작합니다.

let uploadTask = session.uploadTask(with: request, fromFile: fileURL)
uploadTask.resume()

guard let resumeData = await uploadTask.cancelByProducingResumeData() else {
return
}

그리고 pause할때 download와 비슷하게 cancelByProducingResumeData()를 지원합니다. 이는 서버가 resume upload를 지원하는 경우, 추후 resume을 위해 resumeData를 저장할 수 있게 해줍니다.

let uploadTask = session.uploadTask(with: request, fromFile: fileURL)
uploadTask.resume()

guard let resumeData = await uploadTask.cancelByProducingResumeData() else {
return
}

let newUploadTask = session.uploadTask(withResumeData: resumeData)
newUploadTask.resume()

마지막으로, 정지된 업로드를 resume하기 위해서 uploadTask에 위에서 저장했던 resumeData를 전달하고, resume()을 해주면 재개됩니다.

다운로드랑 똑같네요….

do {
let (data, response) = try await session.upload(for: request, fromFile: fileURL)
} catch let error as URLError {
guard let resumeData = error.uploadTaskResumeData else {
return
}
}

네트워크 중단이 발생해도 다운로드 처럼 중간에 resumeData를 가져올 수 있습니다. 똑같이 URLError의 uploadTaskResumeData에서 가져오면 됩니다.

Resumeable upload protocol

그치만 위에 나온 저 내용을 쓸 수 있으려면…. 서버에서 최신 업로드 프로토콜을 지원해줘야 합니다. 만약 저걸 지원해주지 않으면 그냥 일반적인 업로드로 계속됩니다.

먼저 클라이언트가 upload endpoint에 요청을 하나 보냅니다. 위에서 Upload-Incomplete는 클라이언트가 업로드 resume을 지원한다는 것을 의미합니다. 그리고 ?0는 boolean값으로, false를 나타냅니다. 그래서 위에서는 업로드 데이터 전체가 request body에 포함되어 있음을 나타냅니다.

서버에서 upload resume을 지원하면, 클라이언트의 헤더를 감지하고 104 Upload Resumption Supported 응답을 사용해서 지원 내용을 줍니다. 104 응답에는 resume URL과 location field가 포함됩니다.

resume URL은 upload 식별에 사용되어서, 연결이 중단되면 어디서 resume해야 하는지를 알려줍니다. 서버에서는 받은 업로드 데이터를 이 url과 연결시킵니다.

업로드가 끝나면 서버에서 201 created를 보내고 끝납니다.

그치만 만약 업로드를 하다가 중단되었다고 해봅시다.

서버는 resume url에 업로드 내용을 일부 저장합니다. 클라이언트는 서버가 가진 실제 데이터양을 알아야 하는데요,, 그래서 resume url로 HEAD 요청을 보내 서버가 실제로 수신한 바이트 수(upload offset)를 요청합니다.

그럼 서버는 클라이언트에 upload-offset을 보냅니다.

그런다음, 클라이언트에서 해당 오프셋을 받고, 남은 데이터를 전송합니다. 이를 위해서 resume url에 PATCH 요청을 보냅니다. 그리고 body에는 서버에서 받은 upload-offset에서 시작된 데이터를 포함시켜 보냅니다.

모든 데이터를 보내고 나면 서버에서 201 Created 응답을 보내줍니다.

이 세션에서는 SwiftNIO를 이용해서 upload resume기능을 추가하는 내용도 나오는데요,, 이부분은 따로 정리는 안하겠습니다..! 대신

요 링크에 가면 더 자세히 나와있습니다!!

protocol URLSessionTaskDelegate: URLSessionDelegate {
optional func urlSession(
_ session: URLSession,
task: URLSessionTask,
didReceiveInformationalResponse response: HTTPURLResponse
)
}

Resume Upload 프로토콜에서는 104 상태코드를 사용하는데, URLSession에서 이를 쉽게 처리할 수 있는 새로운 API가 나왔습니다. 이는 104 코드를 자동으로 처리하고, 새로운 delegate메서드도 제공합니다. 이를 통해 102 Processing이나 103 Early Hints같은 즉각적인 응답들도 처리할 수 있습니다.

Background URLSession

resumable 프로토콜은 네트워크 중단을 완화하고, 대역폭을 절약할 수 있는 최고의 방법입니다. 또한, Background URLSession도 대용량 파일 전송에 유리합니다.

만약 스키를 타면서 찍은 4K영상을 업로드하고 싶다고 해봅시다. 업로드 하는 동안 연결이 중단되더라도, 가능하면 중단된 부분으로 부터 resume할 수 있도록 하고 싶을 것입니다.

background session의 경우에는 중간에 연결이 끊어져도 항상 다시 연결되도록 기다리고, 연결된 시점에 resume할 수 있도록 해줍니다.

또한, background task의 스케줄링은 앱의 프로세스 바깥에서, 시스템에서 실행됩니다. 그래서 앱 자체가 종료되거나 중단되어도 계속 실행될 수 있습니다. 그래서 오랜 시간이 걸리는 경우에는 background session을 사용하는 것이 좋습니다.

효율적으로 task가 스케줄링 되려면, 급하지 않은 task들은 나중에 스케줄링 되도록 해야할 것입니다.

// background session 설정

let configuration = URLSessionConfiguration.background(withIdentifier: "com.example.app")
configuration.isDiscretionary = true
configuration.allowsConstrainedNetworkAccess = false
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)

// background task 설정
let backgroundTask = session.uploadTask(with: url, fromFile: fileURL)
backgroundTask.earliestBeginDate = .now.addingTimeInterval(60 * 60)
backgroundTask.countOfBytesClientExpectsToSend = 500 * 1024
backgroundTask.countOfBytesClientExpectsToReceive = 200
  • 바로 실행될 필요가 없는 태스크일 경우, isDiscretionary를 true로 설정하면 됩니다. 이렇게 하면 시스템이 알아서 여러 요인들을 고려해 태스크를 스케줄링 합니다.
  • 과한 대역폭 사용 방지를 위해 allowConstrainedNetworkAccess 프로퍼티를 false로 지정해줄 수 있습니다.
  • 사용자가 시스템 자원을 덜 사용하는 경우라면, background Task 일정을 나중에 시작할 수 있게도 해줄 수 있습니다.
  • 시스템 스케줄링을 추가 지원하기 위해서 countOfBytesClientExpectsToSend와 Receive 프로퍼티도 설정해줄 수 있습니다.

Background session은 결국 즉시 발생하지 않아도 되는 대용량 파일을 전송하거나, 앱이 중단되어도 계속되어야 하는 파일 전송에 딱 좋은 도구 입니다.

--

--