multipart/form-data 타입의 HTTP 메시지 구성 방법

파일 업로드를 구현할 때, 클라이언트가 웹브라우저라면 폼을 통해서 파일을 등록해서 전송하게 됩니다. 이때 웹브라우저가 보내는 HTTP 메시지는 Content-Type 속성이 multipart/form-data로 지정되며, 정해진 형식에 따라 메시지를 인코딩하여 전송합니다. 이를 처리하기 위한 서버는 멀티파트 메시지에 대해서 각 파트별로 분리하여 개별 파일의 정보를 얻게 됩니다.

만약 서버사이드가 이러한 방식으로 동작할 때, 웹브라우저처럼 파일을 멀티파트 메시지로 만들어서 업로드하는 것을 별도의 앱에서 구현하려면 어떻게해야 할까요? 그 방법을 알기 위해서 멀티파트 http 메시지가 어떻게 생겼는지를 살펴보면 거기에 해답이 있을 것 같습니다. 먼저 http 메시지의 구조를 보겠습니다.

multipart/form-data 타입의 HTTP 메시지 구성 방법 더보기

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()
  }
}

참고자료

네이버 검색에서 로또 당첨번호 파싱하기 – 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)
}

Form submit via POST on Swift (NSURLSession)

POST 폼전송

NSURLSession을 통해서 POST로 폼 내용을 전송하는 예제를 작성해보겠다. 사실 이 예제는 꽤 예전에 Objective-C를 이용해서 작성해 본 바 있는 내용이다.

먼저 POST 폼 전송에 대해서 잠깐 살펴보면, 폼의 각 필드명과 필드값이 GET 방식과 비슷한 형태로 연결되고 URL인코딩을 거쳐 바이너리스트림 데이터로 생성된다. 이걸 submit 하게 되면 브라우저는 웹서버의 해당 주소에 HTTP 요청을 보내게 되는데, Form submit via POST on Swift (NSURLSession) 더보기