Wireframe

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. 서버의 구현상, 크기가 큰 파일은 전송할 수 없다.

aiohttp의 기본적인 코드에서는 2MB보다 큰 요청을 읽어들일 수 없도록 되어 있다. 큰 파일을 처리하고 싶다면 데이터를 부분적으로 로드하여 버퍼에 쌓아서 처리하는식으로 서버를 만들어야 한다.

인자로 받게되는 내용은 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.swift 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()
  }
}

참고자료

Exit mobile version