URLSession을 통한 간단한 업로드 – Swift

URLSession(NSURLSession)을 기반으로 URLSessionDataTask를 이용하면 HTTP를 통해서 서버와 간단한 데이터를 주고받을 수 있다.  Data Task를 이용하는 케이스는 흔히 키-값 쌍의 정보를 URL로 인코딩해서 주소에 포함하고 서버로부터 응답을 받는 식으로 처리하는 것이 일반적이다. 서버로 데이터나 파일을 업로드하는 경우에는 URLSessionUploadTask 클래스를 사용하는데, 이를 이용한 파일 업로드를 어떻게 구현하는지 살펴보자.

사실, 그리 크지 않은 크기의 파일 데이터는 데이터태스크를 이용해도 무방하다.

POST 전송을 통한 파일 업로드

HTTP 규격에는 메소드(METHOD)라는 항목이 포함된다. 가장 많이 쓰이는 메소드는 GET과 POST인데, GET은 서버로부터 페이지를 요청할 때 쓰는 것이고 (뭔가를 페이지로부터 GET할 때), POST는 페이지에 무언가를 넣어줄 때 쓰는 것이다. (그외에도 PUT, DELETE(????)와 같은 METHOD들도 규격 내에 정의되어 있지만, 거의 안 쓰인다.) POST 방식은 흔히 브라우저에서 폼 제출을 할 때 사용되며, 브라우저에서의 파일 업로드에서도 흔히 사용된다.

Part 0 : 파일 업로드를 처리할 수 있는 서버 만들기 (Python + aiohttp)

가장 기본적인 POST 요청을 처리해서 파일을 업로드할 수 있는 웹서버를 만들어보자. 이 글은 사실 파일을 업로드할 수 있는 클라이언트를 Swift로 만드는 것에 관한 것이므로 간단히 코드를 소개만 하겠다.

from aiohttp import web
import re

# 파일 업로드를 처리하기 위한 핸들러
async def upload(request):
  # 헤더로부터 파일 이름을 얻는다.
  filename = 'untitled.dat'
  n = request.headers.get('Content-Disposition')
  if n:
    pat = re.compile(r'filename="(.*?)"')
    m = pat.search(n)
    filename = m.group(1)
  
  # HTTP 요청 BODY를 읽어들여 파일에 쓴다.
  data = await request.read()
  with open(filename, 'wb') as f:
    f.write(data)
  
  # HTTP 응답을 보낸다.
  res = web.Response()
  res.code = 200
  res.text = 'OK'
  return res

# 서버 생성 및 라우터 설정
app = web.Application()
app.router.add_post('/upload', upload)
# 앱 시작
web.run_app(app, host='127.0.0.1', port=3378)

위 파이썬 코드를 실행하고 명령줄에서 curl을 사용해서 (윈도 환경이라면 윈도용 curl을 따로 설치해야 한다) 다음과 같은식으로 파일을 업로드해볼 수 있다.

$ curl -XPOST -H "Content-Disposition: attachment; filename=\"sample.jpg\"" \
  -Tsample.jpg http://localhost:3378/upload
OK

사실 이  Content-Disposition 헤더는 일반적으로 서버의 응답헤더나 멀티파트 요청에 사용되는 것인데, 어차피 통신규약의 일부이므로 이렇게 사용해도 잘못된 것은 아니다.

여기서 파일 업로드 구현은 결국 curl의 -T 옵션을 사용하는 파일 전송과 똑같은 기능을 수행하려는 것이다. (어? 그러면 Process로 curl을 실행시키면 되잖아…)

Part 1 : 바이너리 전송을 위한 Swift 구현

간단히 명령줄에서 바이너리 파일을 업로드할 수 있는 기능을 구현하는 Swift 코드를 작성해보자. 시작하기 전에 몇 가지 참고할 사항은 다음과 같다.

  1. 전송할 파일이름은 인자로 받는다.
  2. URLSessionUploadTask를 사용한다.
  3. 서버의 구현상, 크기가 큰 파일은 전송할 수 없다.

인자로 받게되는 내용은 CommandLine.arguments 로  액세스할 수 있다. 여느 언어들과 마찬가지로 0번 요소는 프로그램 자신의 이름이며 추가되는 인자는 그 다음부터 들어오게 된다. 받아들인 인자는 파일 이름일수도 있고 경로일 수도 있는데, 이것을 URL 타입의 객체로 변환해야 한다.

import Foundation

// 인자로부터 받은 파일이름을 URL로 변환한다.
let fileurl: URL = {
  guard CommandLine.arguments.count > 1, 
      case let filepath = CommandLine.arguments[1]
  else {
    fatalError("needs filename.")
  }
  return URL(fileURLWithPath: filepath)
}()

서버쪽에서는 헤더의 정보를 통해서 파일의 이름을 파악하며, 파일의 데이터가 HTTP 요청의 payload에 해당하게 된다. 헤더와 페이로드에 각각 데이터를 넣어서 POST 전송을 하기 위해서 요청 객체를 만들어보자. URLRequest는 클래스가 아닌 Struct 값이므로 변경 가능한 값으로 생성해야 한다.

let serverURL = URL(string:"http://localhost:3378/upload")!
let request: URLRequest = {
   // 헤더에 포함시키기 위한 파일 이름은 파일의 URL로부터 구할 수 있다.
   let filename = fileURL.lastPathComponent
   var req = URLRequest(url: serverURL)
   req.httpMethod = "POST"
   req.addValue("attachment; filename=\"\(filename)\";",
         forHTTPHeaderField: "Content-Disposition")
   return req
}()

다음은 업로드 작업 객체를 생성한다. 작업 객체는 세션으로부터 만들어지며, 요청객체와 업로드할 데이터, 그리고 처리 완료시 핸들러를 필요로 한다. 처리 완료 핸들러는 따로 지정하지 않을 수 있고, 별도의 델리게이트를 지정하여 처리를 위임할 수 있는데, 델리게이트를 지정하는 경우 업로드 작업은 데이터를 전송하는 현황을 체크하는 것이 가능하다.

if let data = try? Data(contentsOf: fileURL) {
  let session = URLSession.shared
  let task = session.uploadTask(with: request, from: data) { data, res, error in 
    
    /// 1: 실제로 비동기로 동작하는 업로드 동작 때문에, 완료핸들러의 처리 후에는
    //     런루프를 멈춰서 프로그램이 종료할 수 있게 해주어야 한다. 
    defer {
      CFRunLoopStop(CFRunLoopGetMain())
    }

    // 에러 체크
    guard error == nil else {
      print("Error: \(error!.localizedDescription)")
      return
    }

    // 서버로부터 응답이 제대로 내려왔는지 체크
    // 응답값은 URLResponse? 인데, 
    // 이를 HTTPURLResponse로 캐스팅해서 statusCode를 확인한다.
    if let res= res as? HTTPURLResponse,
        res.statusCode != 200 {
       print("Server failed")
    }

    if let data = data, let message = String(data:data, encoding:.utf8)
    {
       pritn(message)
    }
  }

  // 업로드 시작
  task.resume()
  // 위 함수는 non-blocking이기 때문에
  // 런루프를 돌려서 대기해야 한다. 
  CFRunLoopRun()
} else {
  // 파일 읽기에 실패
  fatalError("Failed to read file")
}

위 스크립트를 실행해보자.

$ swift upload.py sample.jpg
OK

서버로부터 정상적인 응답이 떨어졌다면, 서버가 돌아가고 있던 폴더를 확인해보면 파일이 만들어져 있는 것을 확인할 수 있다.

 

Part 2 : 폼 전송을 사용한 업로드

서버 사이드를 직접 만들었다면 문제가 없는데, 그렇지 않은 상황도 있을 수 있으니 문제이다. 예를 들어 파일을 업로드하려는 서버가 실제로는 웹브라우저에서 form을 사용해서 업로드를 수행하는 프론트엔드와 짝이되는 경우를 생각해볼 수 있다. 이 경우에는 브라우저가 파일을 업로드하는 방식을 그대로 흉내내어야 한다. 문제는 브라우저는 위와 같은 간단한 방식으로 파일을 업로드 하지 않고 multipart/form-data라는 형식을 사용해서 요청의 BODY 부분을 특별한 규칙에 맞게 구성한다는 것이다. 기본적으로 웹에서의 폼은 단일 파일 하나만을 서버로 전송하는 것이 아니라 그외 다른 input, textarea 등의 폼 필드 요소의 값을 하나로 묶어서 서버로 전송하기 때문이다.

따라서 요청 헤더에 Content-Type 정보를 정의하고 그에 맞게 데이터를 구성해주어야 한다. 멀티파트 폼데이터의 실제 구성에 대해서 간략히 설명하면 다음과 같다.

  1. 바운더리를 구분하기 위한 문자열을 임의로 정의한다.
  2. 각 폼필드 요소의 값은 --바운더리 모양의 라인 하나로 구분된다.
  3. 이후 해당 필드 요소 데이터에 대한 헤더를 정의한다. (Content-Disposition 헤더는 주로 여기서 사용된다.)
  4. HTTP헤더와 마찬가지로 각 파트의 헤더와 본체는 빈줄 1개로 구분된다.
  5. 해당 데이터값을 쓴다.
  6. 다시 줄을 바꾸고 두 번째 바운더리를 시작한다. (2~5의 과정)
  7. 모든 요소의 기입이 끝났으면 줄을 바꾸고 --바운더리--의 모양으로 데이터를 기록하고 끝낸다.

여기서 중요한 것은 HTTP 요청에서 줄 바꿈은 플랫폼에 상관없이 \r\n 으로 고정되어 있다는 것과, 전송되는 BODY는 항상 “이진 데이터”이기 때문에 문자열을 그대로 쓸 수 없다는 것이다. 즉 헤더 정보는 문자열로 작성한 후 UTF8로 인코딩하여 Data 타입으로 변환해주어야 한다.

예를 들어 바운더리 문자열을 XXXXX 라고 했다면 HTTP POST의 본체는 다음과 같이 구성될 것이다. 폼 필드에서 필드 이름을 “file”이라고 정했다고 가정하자.

--XXXXX
Content-Disposition: form-data; name="file"; filename="image.jpg"
Content-Type: image/jpeg

.....{파일의 바이너리 데이터}
--XXXXX--

자, 그렇다면 파일의 내용을 읽어서 멀티파트 데이터를 생성하는 함수를 하나 작성해봐야겠다.

func buildBody(with fileURL: URL, fieldName: String) -> Data? {
  // 파일을 읽을 수 없다면 nil을 리턴
  guard let filedata = try? Data(contentsOf: fileURL),
  else { return nil }

  // 바운더리 값을 정하고, 
  // 각 파트의 헤더가 될 라인들을 배열로 만든다.
  // 이 배열을 \r\n 으로 조인하여 한 덩어리로 만들어서 
  // 데이터로 인코딩한다.
  let boundary = "XXXXX"
  let headerLines = ["--\(boundary)",
    "Content-Disposition: form-data; name=\"file\"; filename=\"\(fileURL.lastPathComponent)\"",
    "Content-Type: \(mimetype)",
    "\r\n"]
  var data = headerLines.joined(separator:"\r\n").data(using:.utf8)!

  // 그 다음에 파일 데이터를 붙이고
  data.append(contentsOf: filedata)
  // 마지막으로 데이터의 끝임을 알리는 바운더리를 한 번 더 사용한다. 
  // 이는 '새로운 개행'이 필요하므로 앞에 \r\n이 있어야 함에 유의 한다. 
  data.append(contentsOf: "\r\n--\(boundary)--".data(using:.utf8)!)
  return data
}

그렇다면 이후의 과정은 거의 동일하다. 단, Content-Type 헤더 값이 mulitpart/form-data 여야 하고, boundary 값을 정해주어야 한다는 점만 다르다.  다음 함수는 위 buildBody를 사용해서 주어진 이미지 파일을 서버로 업로드하는 기능을 수행한다.

func uploadImageFile(at filepath:String, completionHandler:@escaping(Data?, URLResponse?) -> Void)
{
  // 경로를 준비하고
  let fileURL = URL(fileURLWithPath: (filepath as NSString).expandingTildeInPath))
  let url = URL(string: "http://localhost:7070/fileupload")!
  
  // 경로로부터 요청을 생성한다. 이 때 Content-Type 헤더 필드를 변경한다.
  var request = URLRequest(url: url)
  request.httpMethod = "POST"
  request.setValue("multipart/form-data; boundary=\"XXXXX\"",
       forHTTPHeaderField: "Content-Type")
  
  // 파일URL로부터 multipart 데이터를 생성하고 업로드한다.
  if let data = buildBody(with: fileURL) {
    let task = URLSession.shared.uploadTask(with: request, from: data){ data, res, _ in
      completionHandler(data, res)
    }
    task.resume()
  }
}

참고자료

 

네이버 검색에서 로또 당첨번호 파싱하기 – Swift + CommandLine

네이버 검색으로부터 로또 당첨 번호를 파싱하는 방법에 대해서 살펴보자. 기본적으로 이 작업을 수행하기 위해서는 두 가지 기술을 사용할 것이다.

  1. NSURLSession :  웹페이지 데이터를 받아와야 하기 때문에 네트워킹 API 를 사용해야 한다.
  2. NSRegularExpression : 받아온 데이터는 HTML 페이지의 소스 데이터이며, 여기서 로또 당첨번호의 내용을 추출하기 위해 간단한 정규식을 사용할 것이다.

아, 그리고 참고로 여기서 사용된 Swift 버전은 4.0이다.

준비 과정

네이버는 로또 당첨번호 검색에 대해서 일반적인 웹문서 결과가 아닌 별도로 디자인된 영역으로 당첨번호를 예쁘게 표시해주고 있다. 실제로 네이버에서 “로또 당첨번호”라는 키워드로 검색해보면 다음과 같은 화면을 볼 수 있다.

여기서 로또 회차 부분을 클릭하면 다른 회차의 당첨번호도 알 수 있는데, 다른 회차들을 선택해보자. 이 때, 브라우저의 주소창을 보면 일정한 패턴으로 구성되는 것을 알 수 있다.

https://search.naver.com/search.naver?sm=tab_drt&where=nexearch&query=792회로또

즉 위의 주소에서 회차에 해당하는 번호값만 바뀌는 것을 알 수 있다. 그러면 당첨 번호는 어떻게 알 수 있을까? 색색의 예쁜 숫자 공을 선택해서 브라우저의 인스펙터에서 조사해보면 “ball번호“의 패턴으로 클래스가 적용되어 있는 것을 확인할 수 있다.

따라서 ball(\d+) 라는 정규식 패턴으로 당첨번호에서 숫자부분만 손쉽게 추출할 수 있다.

정규식으로 당첨번호를 추출하여 출력하기

URLSession을 사용해서 간단한 데이터 받아오기라는 글에서 URLSession을 사용하는 간단한 방법을 소개한 적이 있는데, (Data) -> Void 타입의 완료 핸들러를 이용해서 웹주소로부터 HTTP 통신을 통해 받아온 데이터를 처리할 수 있다고 하였다. 그렇다면 이 부분을 먼저 작성해보자. 네이버는 웹페이지에 UTF8 인코딩을 사용하고 있으므로 받아온 데이터를 UTF8로 디코딩하여 문자열을 얻고, 여기에서 위 패턴을 적용하여 번호들을 추출할 수 있다.

func parseBallNumbers(_ data: Data) {
  guard let html = String(data:data, encoding:.utf8) else { return }
  let regex = try! NSRegularExpression(pattern: "ball(\\d+)", options:[])
  else { return }

  let matches = regex.matches(in: html, options:[], range: NSMakeRange(0, html.count))
  let results = matches.map{ (html as NSString).substring(with: $0.range(at:1)) }
  // 6개의 당첨번호와 1개의 보너스번호
  let nums = results[..<6].joined(separator: ", ")
  let bonus = results.last!
  print("\(nums) - 보너스: \(bonus)")
}

데이터를 요청하기

데이터를 받아와서 당첨번호를 추출하고 출력하는 함수를 작성했으니, 이제 데이터를 받아올 함수를 작성할 차례이다. 단, 나는 여기서 명령줄에서 실행되는 버전을 상정하고 있는데 URLSession의 모든 동작은 철저하게 비동기로 동작한다. 따라서 데이터 작업 객체의 resume()은 비동기로 네트워크 통신을 개시하면서 즉시 리턴하기 때문에 응답을 기다릴 필요가 있다. 이 경우, 메인 스레드에서 런루프를 실행하여 네트워크 통신을 기다리고, 완료 핸들러 내에서 런루프를 중지하고 끝내는 방식으로 처리한다.

func processURL(_ url: URL, handler: @escaping (Data) -> Void) {
  let task = URLSession.shared.dataTask(with: url){ data, _, _ in
    defer { CFRunLoopStop(CFRunLoopGetMain()) }
    guard let data = data else { return }
    handler(data)
  }
  task.resume()
  CFRunLoopRun()
}

조립하기

이제 우리는 네트워크 통신으로 웹 페이지를 받아오고, 데이터를 파싱하는 함수를 모두 갖게 되었다. 이를 조립하여 원하는 동작을 이루어보자.

func main() {
  if let s = readLine(), let i = Int(s) {
    let address = "https://search.naver.com/search.naver?sm=tab_drt&where=nexearch&query=\(i)회로또"
    let encodedAddress = address.appendingPercentEncoding(withAllowedCharacters:.urlQueryAllowed)!
    let url = URL(string:encodedAddress)!
    processURL(url, handler: parseBallNumbers)
}

URLSession을 사용해서 간단한 데이터를 받아오기 – Swift

HTTP통신을 통해서 서버로부터 이미지나 텍스트와 같은 작은 파일을 가져오거나, API 통신을 하는 방법을 살펴보자. NSURLSession은 NSURLConnection과는 달리 ‘간단한’ 작업을 위해서는 정말 간단한 코드로만 작업을 수행할 수 있게 해준다. 대신에 NSURLSession은 완전한 비동기 통신만을 지원하는데, 해당 API는 가급적 델리게이트의 작성량을 최대한 줄일 수 있도록 디자인되어 있으므로 이러한 작업의 경우, 코드도 매우 단순해진다.

기본적인 원리에 대해서 살짝 언급하자면 다음과 같다.

  • 통신을 위해서는 NSURLSession 객체가 필요하다. 세션 객체가 있다면 이로부터 NSURLSessionDataTask 객체를 생성할 수 있다.
  • 모든 작업은 비동기로 이루어지므로, 델리게이트가 필요할 것이나 데이터 작업을 생성할 때 완료 핸들러를 제공하는 것으로 대체할 수 있다.
  • 작업 객체를 생성했다면, resume() 메소드를 호출해서 통신을 개시할 수 있다.
  • 이 때 네트워크로 전송되는 데이터는 NSData 클래스로 감싸지게 된다.

세션 객체를 만드는 것도 사실 간단한데, 기본적인 API 통신의 경우에는 싱글톤인 공유 세션을 사용할 수 있다. 공유 세션은 URLSession의 shared 프로퍼티(Objective-C 의 경우, [NSURLSession sharedSession])를 통해서 접근할 수 있다. 만약 HTML 페이지의 소스를 가져와서 파싱하는 함수가 있다면 실제 코드의 개괄은 다음과 같이 이루어질 수 있다.

let processHTML:(String) -> Void = { ... }
let url = ...
let session = URLSession.shared
let task = session.dataTask(with: url){ data, response, error in 
   // 위 세 파라미터는 모드 옵셔널 타입이다. 응답 데이터를 통해서 HTML 소스문자열을 만든다.
   print("HTTP Response received.")

   guard let data = data, 
   let html = String(data:data, encoding:.utf8)
   else { return }
  
   processHTML(html)
}
task.resume()

델리게이트를 사용해야 할 때

작업 객체를 생성할 때 완료 핸들러를 전달하지 않으면, 응답이 도착했을 때에 시스템이 별도로 생성한 델리게이트가 이를 처리한다. 만약 커스텀 델리게이트를 지정하고 싶다면 세션을 생성할 때 델리게이트를 지정해야 한다. 세션의 델리게이트는 세션의 라이프 사이클 내에서 발생하는 변경에 따라 이를 처리하는 메소드를 제공하면서 동시에 세션 내에서 생성된 작업 객체의 라이프 사이클에 따른 이벤트도 함께 처리한다.

따라서 매 요청에 대한 응답을 델리게이트로 하여금 처리하게 하고 싶다면 델리게이트는 적어도 두 개의 이벤트를 처리할 수 있어야 하는데, 이는 다음과 같다.

  1. 서버로부터 응답 데이터 조각을 받았을 때, 이 데이터를 처리
  2. 서버로부터 모든 응답을 받고 통신이 종료되었을 때 이를 처리.

델리게이트를 사용하면 일정량의 데이터를 수신할 때마다 1이 호출되기 때문에 통신의 전체 진행과정을 표시할 수 있다는 장점이 있다. 단, 시스템의 기본 세션 델리게이트가 해주던 중요한 일 하나를 직접 수행해야 한다. 바로 수신 받은 데이터를 어딘가에 보관하는 일이다. 응답데이터를 누적하여 수집하는 임무는 델리게이트의 일이며, 세션이나 작업 객체는 이에 대한 책임을 지지 않는다.

또 모든 데이터 수신을 완료하고 통신이 종료되었을 때의 처리, 즉 완료 핸들러가 해야하는 일에 대해서도 델리게이트가 직접 처리하게 된다. 위의 두 메소드는 무려 각각 별도의 프로토콜에서 선언되어 있는데, 각각은 다음과 같다.

  1. optional func urlSession(_:dataTask:didReceive:) :: URLSessionDataDelegate
  2. optional func urlSession(_:task:didCompleteWithError:) :: URLSessionTaskDelegate

서버로부터 텍스트를 요청해서 출력하면서, 그 중간에 진행률을 출력하는 동작을 보이기 위한 델리게이트는 이상의 요건을 만족하면서 다음과 같이 작성될 수 있다.

class MYDelegate: NSObject
{
  var data = Data()
  // 수신 받은 데이터 조각을 저장할 공간을 마련한다.
}



extension MYDelegate: URLSessionDataDelegate
{
  // URLSessionDataDelegate는 데이터를 한 번 받아올 때마다 필요한 처리를 한다. 
  func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {

  // 받은 데이터를 누적시키고
  self.data.append(data)

  // 얼마나 받아왔는지를 표시하자.
  let totalBytes = dataTask.countOfExpectedToReceive
  let receivedBytes = dataTask.countOfBytesReceived
  print("\(receivedBytes)/\(totalBytes)")
  }
}



extension MYDelegate: URLSessionTaskDelegate
{
  // URLSessionTaskDelegate는 태스크의 완료시에 취할 행동이다.
  // 받아온 데이터를 문자열로 변환하여 출력한다.
  func urlSession(_ session: URLSession, task: URLSessionTask,
                  didCompleteWithError error: Error?) {
    if let string = String(data:recievedData, encoding:.utf8) {
      print(string)
    }
}

이런 식으로 델리게이트를 작성했다면, 세션 객체를 만들 때에는 공유 세션이 아닌 디폴트 세션을 만들고 델리게이트를 지정해준다. 참고로, delegate 속성은 읽기 전용이기 때문에 다음의 이니셜라이저를 통해서 생성했을 때에만 지정해 줄 수 있다.

let session = URLSession(configuration: .default,
                         delegate:MYDelegate()
                         delegateQueue: .current)
let task = session.dataTask(with: url) // 완료 핸들러 없음
task.resume()
...

통상 델리게이트를 사용하는 경우에, 델리게이트 프로퍼티는 약한 참조 혹은 소유하지 않는 참조를 사용하는데, 세션 객체의 경우 델리게이트에 대해서 강한 참조를 가진다. 따라서 메모리 누수를 방지하기 위해서 세션의 사용이 끝났을 때, 세션을 제거하여 델리게이트가 앱 종료시까지 메모리에 유지되지 않도록 주의한다.