Slug gwendolyn background networking

백그라운드 파일 다운로드 Swift로 구현하기

사용자들이 앱을 열어둔 상태로 파일 다운로드 완료를 기다리게 하는 것은, 바라보고 있을 때만 물이 끓는 찻주전자를 만드는 것이나 마찬가지입니다. 이 강연에서 Gwendolyn Weston은 iOS의 백그라운드 트랜스퍼 서비스 API를 사용해서 백그라운드에서 파일을 다운로드하는 방법을 자주 실수하는 부분과 베스트 프랙티스와 함께 알려줍니다. 사용자의 시간과 괴로움을 줄이기 위해 이를 쉽게 구현하는 법을 배워보세요. 🍵


소개 (0:00)

안녕하세요, 저는 Gwendolyn Weston이고, PlanGrid라는 회사의 개발자입니다. 오늘 저는 백그라운드 다운로딩과 이를 앱에 어떻게 구현할지에 초점을 맞춰서 백그라운드 전송 서비스에 대해 말씀드리고자 합니다. 첫번째로 NSURLSession에 익숙하지 않은 분을 위해 포그라운드(Foreground)에서 다운로드 하는 법을 말씀드리겠습니다. 두번째로 백그라운드에서 돌 수 있도록 요청을 만드는 법에 대해 말씀드릴 겁니다. 마지막으로는 자주 실수하는 부분을 피하는 방법에 대해서 보여드리겠습니다.

백그라운드 트랜스퍼 서비스 -찻주전자에 비유 (0:14)

백그라운드 트랜스퍼 서비스는 iOS 7에 소개된 API로 예컨대 다운로드나 업로드 등의 네트워킹 요청을 앱이 중지되거나 종료된 이후에도 백그라운드에서 계속할 수 있게 하는 역할을 합니다. 예를 들어 Dropbox가 동기화가 끝날 때까지 백그라운드에서 기기에 파일을 동기화할 수 있도록 합니다.

이 서비스의 유용성을 설명하기 위해 찻주전자에 비유해 보겠습니다. 당신이 전기 찻주전자에 물을 붓고 끓이는 버튼을 누르고 나서 뒤돌아 가는데 제가 멈춰 세워서는 이러는 겁니다. “찻주전자가 물을 끓이도록 하려면 근처에 서있어야 합니다.” 참 이상한 강제 사항이지만 어쨌든 당신은 찻주전자 옆에 서서 폰을 집어 들고 페이스북을 하려고 합니다. 막 친구들의 타임라인을 보려고 하는데 제가 또 막아서서는 “찻주전자가 물을 끓이도록 하려면 지켜보고 있어야 합니다.”라고 하는 겁니다. 정말 이상한 찻주전자죠? 이게 바로 포그라운드(Foreground)에서 다운로드 하는 것과 유사합니다.

사용 예시 (2:02)

제가 일하는 PlanGrid는 “건축 청사진의 GitHub”이라고 묘사할만한 회사입니다. 건축 프로젝트의 버전 컨트롤과 프로젝트 관리를 제공하죠.

저희는 주로 이럴 때 사용합니다. 건설업자는 사이트 상에서 청사진에 표시하고, 이 표시와 주석은 다른 건설업자의 기기에 동기화됩니다. 매번 청사진이 변경될 때마다 새로 인쇄할 필요가 없어 시간과 노력, 금액을 절약할 수 있습니다.

이 서비스를 위해서는 사용자들이 대용량의 고화질 청사진을 우리 저장소에 올려야 합니다. 프로젝트에 누군가 참여할 때마다 매번 몇 시간에 걸쳐 모든 청사진을 동기화해야만 하겠죠. 다운로드가 잘 완료되도록 하려면 사용자가 화면이 잠기지 않게 세팅하고 앱을 내내 켜놓아야 한다고 안내해야 할 겁니다. 또한 언락된 디바이스가 중요한 청사진을 저장한다는 점에서 보안상의 문제도 있습니다. 따라서 “다운로드만 일단 시작하면 우리가 나머지는 알아서 하겠습니다.” 하고 안내할 수 있다면 훨씬 좋겠죠.

백그라운드 트랜스퍼 서비스 없이 다운로드 하는 것은 물이 끓기를 바라보는 것이나 마찬가지입니다. 반면 백그라운드 트랜스퍼 서비스에서 다운로드하는 것은 근처에 서서 쳐다보지 않아도 어떤 차를 끓일 것인지 얼마나 오래 담가둬야 하는지 알려주기만 하면 끓여주는 찻주전자를 쓰는 것과 같습니다.

포그라운드(Foreground)에서 다운로드하기 (4:41)

샘플 코드를 보시죠.

이런 개발 뉴스를 더 만나보세요

let urlstring = "https://remoteteakettle.com/boiledwater.pdf"
let filepath = "Documents/local_teakettle"
if let url = NSURL(string: urlstring) {
  let task = NSURLSession.sharedSession().downloadTaskWithURL(url, completionHandler: {
    (location:NSURL?, response:NSURLResponse?, error:NSError?) in
    if let loc = location, path = loc.path {
      try! NSFileManager.defaultManager().moveItemAtPath(path, toPath:filepath)
    }
  })
  task.resume()
}

첫 번째 라인은 리모트 파일이 어디에서 제공되는지 나타냅니다. 코드에선 remoteteakettle.com으로부터 boiledwater.pdf를 다운받을 겁니다. 두 번째 라인에서 PDF를 다운받아 어디에 저장할 건지 경로를 지정합니다. 세번째 라인에서는 NSURLSession 프레임워크에서 익숙한 것들이 보입니다. 더 자세히 알아볼까요?

NSURLSession은 요청을 보내고 관리합니다. sharedSession는 싱글턴 세션으로 기본 캐시 정책과 타임아웃 인터벌을 포함한 기본 세팅을 담당합니다. custom behaviors로 커스텀 세션을 만들 수도 있겠지만 이번에는 이걸 사용하도록 하죠. 마지막으로 NSURLSession 다운로드 태스크가 있습니다.

NSURLSessionTask (6:00)

이제 이 코드의 나머지 부분을 보여드리기에 앞서, NSURLSession 다운로드 태스크에 대해 말씀드리겠습니다. 다운로드 태스크는 이 기본 클래스의 서브클래스로 NSURLSessionTask라고 하며 세 종류가 있습니다.

  • NSURLSessionDownloadTask
  • NSURLSessionUploadTask
  • NSURLSessionDataTask (인증 토큰 등 짧은 라이브 요청)

이 세션 태스크를 부르기 위해 convenience initializer를 호출하기 보다 세션 객체를 통해 세션 태스크를 받아오겠습니다. 즉 세션 URL을 피드하는 것이죠.

이게 무슨 뜻일까요? 저는 NSURLSession를 쿠키 몬스터로, URL을 쿠키로 생각하곤 합니다. NSURLSession은 정말 쿠키를 좋아해서 매번 쿠키를 줄 때마다 아주 기뻐하며 애정을 돌려줍니다. 이런 애정 행각이 NSURLSessionTask 인 거죠. 몇 번이나 해볼 수 있고 일대다 관계를 세션과 세션 태스크 사이에 맺은 같은 NSURLSession으로부터 여러 세션 태스크를 돌려받을 수 있습니다. 하나의 세션은 여러 세션 태스크에 엮일 수 있지만 각 세션 태스크는 하나의 세션에만 관계가 있습니다. 또한downloadTaskWithURL(_:completionHandler:) 처럼 세션 객체가 태스크를 만들 수 있는 여러 메서드들이 있습니다.

코드로 돌아가서 completion handler에 대해 말해 보죠. completion handler를 위한 세 가지 매개 변수가 있습니다.

  • Location (다운로드한 파일이 저장될 임시 파일 경로)
  • Response (요청에 대한 상태 코드를 받을 곳)
  • Error (잘못된 경우)

에러나 예외와 관련한 아무 처리도 하지 않은 것을 볼 수 있을 텐데요. 잠시 이를 무시하고 에러와 예외 없는 조화로운 세계에 있다고 가정할 생각입니다. 따라서 위에서 초기화한 경로로 임시 파일 경로에서 파일을 옮기기만 할 겁니다.

잘 작동하긴 하지만 사용자가 다른 앱으로 전환하면 어떨까요? 쳐다보지 않으니 물이 끓지 않겠죠.

백그라운드 다운로드 — NSURLSessionConfiguration (9:23)

이 작업을 백그라운드에서 하려면 NSURLSessionConfiguration을 사용해서 이들 태스크가 백그라운드에 맞게 작동하도록 시스템에 알려야 합니다. 캐시 정책과 타임아웃 인터벌 등의 프로퍼티를 설정할 수 있도록 커스텀 세션을 초기화할 수 있다고 앞서 말했는데 조금 다릅니다. 사실 세션에서 이들을 세팅할 수 없습니다. configuration 객체에서 이 프로퍼티를 설정하고 세션을 이 configuration으로 초기화하는 것입니다. 이 것이 첫 번째 단계입니다.

background configuration이라 불리는 커스텀 세션을 만들 텐데요. 이는 configuration 객체의 세팅입니다. 예제를 보시죠.

let config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier("i am the batman")
let session = NSURLSession(configuration: config, delegate: self, delegateQueue: nil)

첫째 라인에서 backgroundSessionConfigurationWithIdentifier(_:) 메서드로 세션 configuration을 만들었습니다. 이 identifier는 좋아하는 색이라든가 백경의 첫 소절, 혹은 “나는 배트맨이다.” 라는 문장 등 무엇이든 바꿔 넣을 수 있습니다. 둘째 라인에서 이 configuration으로 NSURLSession을 초기화합니다. self를 델리게이션으로 설정해서 세션을 받는 어느 델리게이션 메서드라도 받을 수 있습니다. 마지막으로 델리게이션 큐가 있습니다.

델리게이션 큐는 델리게이션 메서드가 불리도록 하는데 어떤 큐라도 될 수 있습니다. nil을 넘기면 기본 큐를 제공합니다. 샘플 코드에 이 코드를 넣기 전에 이들 identifier는 유일해야 한다는 점을 강조하고 싶네요. 이유를 설명하려면 백그라운드 요청의 생명 주기를 파악해야 합니다.

백그라운드 요청의 생명 주기 (11:02)

Dropbox 앱에서 기기로 파일을 동기화하는 작업을 포그라운드(Foreground)에서 시작한 후 앱이 죽은 상황을 가정해 볼까요. 한 세션에서의 모든 요청은 결국 완료되는데 그 이유는 이들 요청이 백그라운드 작업에 걸맞기 때문입니다. 시스템이 앱에 핑을 보내고 이 세션의 모든 요청이 완료됐다고 알려줍니다. 익히 아는 바대로 UIAppDelegate 메서드를 부릅니다.

한 가지 다른 점이라면 옵션이 있는 로딩 호출을 끝낸 후 application:handleEventsForBackgroundURLSession라는 새 메서드를 부른다는 점입니다. 이 메서드의 결과로 시스템이 해당 identifier를 돌려줍니다. 예를 들어 “나는 배트맨이다.” 라는 문장을 넣어 초기화했다면 “‘나는 배트맨이다.’라는 세션이 완료됐어, 이걸로 뭐할래?” 하고 묻고, 모든 요청의 에러나 성공 코드를 받길 원하는 경우 이 메서드에서 세션을 다시 생성하면 됩니다. 코드에서는 해당 identifier로 별도 백그라운드 configuration을 다시 만들어서 별도 세션을 이 configuration으로 생성합니다.

func application(application: UIApplication, handleEventsForBackgroundURLSession identifier: String,
  completionHandler:() -> Void) {
    let config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier(identifier)
    let session = NSURLSession(configuration: config, delegate: self, delegateQueue: NSOperationQueue.mainQueue())
    session.getTasksWithCompletionHandler { (dataTasks, uploadTasks, downloadTasks) -> Void in
      // yay! you have your tasks!
    }
  }

이런 데이지 체인을 만들면 시스템이 “세션이 되살아났으니 그 세션에 관련된 태스크 모두를 돌려줄게.” 라면서 세션 태스크를 돌려줍니다.

시스템은 세션 identifier로만 어떤 태스크가 재생성돼야 하는지 파악하는데, 같은 identifier로 두 개의 세션이 있다면 어떨까요? 어떤 태스크를 재생성하려 하는지 시스템이 어떻게 파악할 수 있을까요? 공식 문서에서는 다중 세션이 같은 identifier를 공유하는 것이 undefined라고 하니 일단 그렇게 하지는 말아 보죠. 백그라운드 configuration을 만들던 코드를 넣어보겠습니다. 두 줄이 새로 추가되었습니다.

let urlstring = "https://remoteteakettle.com/boiledwater.pdf"
let filepath = "Documents/local_teakettle"
if let url = NSURL(string: urlstring) {

  let config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier((NSUUID().UUIDString))
  let session = NSURLSession(configuration: config, delegate: self, delegateQueue: nil)

  let task = NSURLSession.sharedSession().downloadTaskWithURL(url, completionHandler: {
    (location:NSURL?, response:NSURLResponse?, error:NSError?) in
    if let loc = location, path = loc.path {
      try! NSFileManager.defaultManager().moveItemAtPath(path, toPath:filepath)
    }
  })
  task.resume()
}

빠지기 쉬운 함정 #1: Completion Handling 빠뜨리기 (13:03)

아쉽게도 이 코드가 백그라운드에 적합한 요청을 만들지는 않습니다. 백그라운드 태스크를 위해 completion handler로 태스크를 생성하려고 하면 콘솔에서는 지원되지 않는다는 경고를 뿜어 댑니다. 대신 델리게이트 메서드를 사용해야 합니다. 앱이 백업을 시작한 후 handleEventsForBackgroundURLSession() 메서드로 세션을 재생성합니다. 특히 URLSession(_:downloadTask:didFinishDownloadingToURL:)에 주목해 보죠. 아래 3개를 돌려줍니다.

  • 세션 객체
  • 완료된 태스크
  • 파일이 임시로 다운로드될 장소

이제 두 번째 단계로 들어섰습니다. completion handler에서 델리게이트 메서드로 코드를 옮겨와 보죠. 코드는 다음과 같습니다.

let urlstring = "https://remoteteakettle.com/boiledwater.pdf"
let filepath = "Documents/local_teakettle"
if let url = NSURL(string: urlstring) {

  let config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier((NSUUID().UUIDString))
  let session = NSURLSession(configuration: config, delegate: self, delegateQueue: nil)

  let task = session.downloadTaskWithURL(url)
  task.resume()
}

func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didFinishDownloadingToURL location:
  NSURL) {
    if let path = location.path {
      try! NSFileManager.defaultManager().moveItemAtPath(path, toPath:filepath)
    }
  }

completion handler로 다운로드 태스크를 초기화하는 대신 이 델리게이션 메서드로 이를 옮겨왔습니다. 아쉽게도 filepath가 델리게이션 메서드 스콥 바깥에 있어서 작동하지는 않습니다.

빠지기 쉬운 함정 #2: 예비 요청 정보 빠뜨리기 (14:49)

다른 메서드에서의 마지막 파일 경로 등 요청에 대한 정보를 가져와야 하므로 이는 영속적이어야 합니다. 이를 위해 taskDescription를 사용할 수 있지만 UI 요소로 사용자가 읽을 수 있는 스트링을 위한 것이라고 공식 문서에서 명시하고 있습니다. 단순한 파일 경로 외에도 model UUID나 파일 이름 등 요청에 대한 더 많은 정보를 저장하려면 taskDescription 사용은 좋지 않습니다. 우리 쿠키 몬스터가 미리 정의된 클래스만 돌려주므로 NSURLSessionDownloadTask의 서브 클래스를 만드는 것도 작동하지 않습니다.

해결법: 영속적인 요청 정보 (16:43)

시스템이 지원해주지 않는다면 직접 해야겠죠. 데이터를 지속하도록 스스로 구현해야 합니다.

public class HalfBoiledWater: NSObject {
  public let sessionId: String
  public let taskId: Int
  public let filepath: String

  init(sessionId:String, taskId:Int, filepath:String) {
    self.sessionId = sessionId
    self.taskId = taskId
    self.filepath = filepath
  }

  func save() {
    // 영속적인 데이터를 여기 저장합니다!
  }
}

public func fetchModel(sessionId:String, taskId:Int) -> HalfBoiledWater {
  // 영속적인 데이터 저장소로부터 가져옵니다!
}

데이터를 지속시키는 방법은 여러 가지가 있습니다. FMDB이나 SQLite, Core Data 등 제각기 선호하는 방법이 있겠죠. 저는 save()fetchModel(_:taskId:)를 권하고 싶습니다. 이 시리얼라이저블 모델에서 파일 경로를 갖고 있으므로 데이터베이스에 넣거나 뺄 수 있습니다. 이 모델을 저장하기 위한 기본 키로는 sessionIdtaskId를 사용할 겁니다. 아래 보시면 이 내용을 코드에 적용했습니다.

let urlstring = "https://remoteteakettle.com/boiledwater.pdf"
let filepath = "Documents/local_teakettle"
if let url = NSURL(string: urlstring) {

  let config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier((NSUUID().UUIDString))
  let session = NSURLSession(configuration: config, delegate: self, delegateQueue: nil)

  let task = session.downloadTaskWithURL(url)
  if let sessionId = session.configuration.identifier {
    let persistedModel = HalfBoiledWater(sessionId:sessionId, taskId:task.taskIdentifier, filepath:filepath)
    persistedModel.save()
  }
  task.resume()
}

func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didFinishDownloadingToURL location:
  NSURL) {
  if let sessionId = session.configuration.identifier {
    let persistedModel = fetchModel(sessionId, taskId:downloadTask.taskIdentifier)
    if let path = location.path {
      try! NSFileManager.defaultManager().moveItemAtPath(path, toPath:persistedModel.filepath)
    }
}

다운로드 태스크 생성 직후 모든 부가 정보를 다루는 이 모델을 생성해서 영속적으로 사용할 수 있습니다. 델리게이트 메서드에서는 파일 경로를 되돌려받고 싶을 때 세션 ID와 task identifier를 통해 이 모델을 가져오면 됩니다.

맺음말 (18:35)

백그라운드 다운로딩의 기능 구현을 위한 핵심 사항은 거의 짚어 봤습니다. 복습해보자면 첫 번째로 백그라운드 configuration을 만들어서 이번 세션의 모든 태스크는 백그라운드에서 돌아갈 것을 시스템에 알려줬습니다. 두 번째로는 completion handler에서 코드를 델리게이트 메서드로 옮겼습니다. 세 번째로 요청을 필요할 때 추가 정보를 사용할 수 있는 방향으로 영속적인 요청을 만들었습니다.

이건 단지 시작일 뿐이고 이 API로 할 수 있는 것이 아주 많습니다. 프로그레스를 보여주거나 중지시킬 수 있고, 에러가 생겼을 때 다운로드가 멈춘 지점에서부터 재시작할 수도 있습니다. 각 과정에서도 빠지기 쉬운 함정들이 있을 겁니다.

이 강연이 백그라운드 다운로딩 기능을 앱에 추가해서 여러분의 사용자저가 멈춰서서 물 끓이기를 바라보지 않도록 하는데 도움이 되었으면 좋겠습니다. 감사합니다.

컨텐츠에 대하여

이 컨텐츠는 저자의 허가 하에 이곳에서 공유합니다.

Gwendolyn Weston

Gwendolyn Weston is a developer at PlanGrid where she works on version control for construction blueprints. Outside of that, she likes to rock climb and obsess over the color purple.

4 design patterns for a RESTless mobile integration »

close