본문 바로가기
네이버 커넥트재단 부스트캠프/기술적도전

[WeTri][multipart/form-data] 회원가입 프로필 이미지 처리

by Joahnee 2023. 12. 12.

해당 게시글은 네이버부스트캠프에서 그룹 프로젝트를 진행하며 기록하는 내용입니다.

틀린 내용이 있을 수 있습니다. 
저희 프로젝트는 WeTri입니다.

많이 구경하러 와주세요!

https://github.com/boostcampwm2023/iOS08-WeTri

 

GitHub - boostcampwm2023/iOS08-WeTri: 우리가 함께 만드는, 트라이애슬론 🏃🏻 | 🏊‍♂️ | 🚴

우리가 함께 만드는, 트라이애슬론 🏃🏻 | 🏊‍♂️ | 🚴. Contribute to boostcampwm2023/iOS08-WeTri development by creating an account on GitHub.

github.com

 

오늘 게시물은 WeTri에서 회원가입을 구현하면서 프로필 이미지에 관련하여 고민과 겪었던 문제상황과 해결과정을 담았습니다.

 

form-data 관련된 양질의 정보가 많아서 이론 설명은 하지않겠습니다 :)

 

🚀 도입부

저희 앱의 회원가입 Feature에는 User의 프로필 이미지를 받아야하는 부분이 있습니다. 

 

앱의 기획상 여러장의 이미지를 보내고, 네이버 그린아이를 통해서 심의를 규정하는 기능이 있어서 이미지 전송을 Multipart 형식으로 전송 하기로 결정했습니다.

 

회원가입 프로필 이미지 같은 경우에는 이미지 전송을 한 장만 진행하게 되지만,

 

네이버 그린아이를 사용해야했기 때문에, 이미지 API와 회원가입 API가 서비스로직이 무거워진다는 이유로 분리되어있고 서버로 전송하는 모든 이미지를 Multipart로 보내달라고 요청하셔서 구현하게 되었습니다.

 

기존에 저는 이진 Data를 post 요청하는 작업을 진행해봤지만, JSON형태가 아닌 다른 형식으로 데이터를 보내는 작업은 처음이라서 어떤 트러블 슈팅을 하게될 지 걱정반 설렘반으로 시작했습니다 🤩

 

📚 Multipart 흐름도

 

 

🧐 이미지 처럼 큰 데이터는 어떻게 보내야 더 안전하게 보낼 수 있을까?

이미지는 일반적인 data(for:delegate:) 함수를 사용 시, 크기가 크기 때문에 전송과정에서 백그라운드로의 전환, wifi 연결 불안정 등으로 인해 데이터가 유실될 위험성이 다분했습니다.

 

그래서, 팀에서 자체제작한 네트워크 라이브러리인 Trinet에 upload() 함수를 추가하여 진행하였습니다.

/// Trinet.TNProvidable.swift
 public func uploadRequest(_ service: T, successStatusCodeRange range: Range<Int> = 200 ..< 300) async throws -> Data {
    guard let multipart = service.multipart else { throw TNError.unknownError }
    let (data, response) = try await session.upload(for: service.requestFormData(), from: multipart.makeBody())
    try checkStatusCode(response, successStatusCodeRange: range)
    return data
  }

 

🤯  Status Code와의 전쟁

 

😓 500:

제가 설정한 Header 값과 서버의 설정값이 맞지 않았습니다.

 

Header를 수정하여 해결했습니다.

 

😅 400:

form-data 파트를 진행하면서,

 

이게 제일 해결하기 힘들었던 경험이 있네요..

 

서버로그에는 201로 나타나고 제가 받은 HTTPURLResponse의 Status Code 상에는 400이 나타났습니다 😂

 

form-data 형식이 잘못되었다는게 해당 error의 이유였습니다..

 

form-data 형식을 맞춰가고 더 이상 form-data 형식의 문제가 아니라는 것을 반정도 확신했습니다.

 

원인이 두가지 씩이나 있었습니다.

 

1️⃣ 첫번째

 

처음에는 URLSession의 data()함수로 구현된 네트워크 라이브러리 함수로 테스트해봤는데요.

 

계속해서 form-data형식이 잘못되길래, Data를 구성하는 URLRequest를 찾아봤습니다.

public extension TNEndPoint {
    func request() throws -> URLRequest {
    guard let targetURL = URL(string: baseURL)?.appending(path: path).appending(query: query)
    else {
      throw TNError.invalidURL
    }
    var request = URLRequest(url: targetURL)
    request.httpMethod = method.rawValue
    request.allHTTPHeaderFields = headers.dictionary
    request.httpBody = body?.data
    return request
}

 

request.httpBody = body?.data 부분이 혹시 보이시나요?

 

private extension Encodable {
    var data: Data? {
    return try? JSONEncoder().encode(self)
    }
}

 

form-data는 그 자체로 데이터 전송 형식인데,

 

이걸 JSON으로 바꾸는 로직이 내부에 존재해서 서버에서는 잘못된 form-data형식이라는 에러가 계속해서 발생한 것입니다 😅

 

이 문제는 처음부터 upload()함수를 사용했다면 request의 httpBody에 아무 데이터도 넣지 않게되면서 겪지 않았을 문제이지만,,

 

만들어 놓은 라이브러리에 너무 의존하게 되면 이런 불상사가 생긴다는걸 다시 한번 깨달았고, 내부를 확실히 파악하고 있어야한다는 경각심도 생긴것 같습니다!

 

2️⃣ 두번째

 

upload()를 도입하고나서 또 잘못된 형식이라는 에러가 발생했는데요.

 

처음으로 돌아가 URL Path부터 모든 부분을 디버깅 해봤습니다.

 

form-data에서는 무결성과 데이터 구분을 위해 고유한 boundary값을 사용하는데, Header에 들어가는 boundary와 실제 data에 담기는 boundary의 UUID값이 다른걸 확인했습니다. 😓

 

해당 문제상황이 나타났던, 이유는 EndPoint에서 Computed Property로 Multipart의 boundary를 쓸 때마다 새로운 인스턴스를 생성해서 UUID가 계속 달라진 것이었습니다..

 

요런식으루요...

/// ImageFormRespoitory.swift
enum ImageFormEndPoint: TNEndPoint {
    var multipart: MultipartFormData? {
    switch self {
    case let .image(imageData, _):
      return MultipartFormData(mimeType: "image/png", imageDataList: [imageData])
    }
  }
}

 

그래서  Multipart를 stored property로 사용할 수 있게 struct로 변경하여 해결하였습니다.

struct ImageFormEndPoint: TNEndPoint {
  let headers: TNHeaders
  var multipart: MultipartFormData?
  init(imageDataList: [Data]) {
    let uuid = UUID()

    headers = [
      .contentType("multipart/form-data; boundary=\(uuid.uuidString)"),
    ]

    multipart = MultipartFormData(
      uuid: uuid,
      mimeType: "image/png",
      imageDataList: imageDataList
    )
  }

  let path: String = "/api/v1/images"
  let method: TNMethod = .post
  let query: Encodable? = nil
  let body: Encodable? = nil
}

 

🧐 413:

file size too large라는 에러는 서버에서 정해놓은 규격의 크기보다 파일의 사이즈가 클 경우 발생하는 Status Code 인데요!

 

해당 주제에 관련한 내용은 다음 게시물에서 이어서 설명드리도록 하겠습니다!

https://sapjilkingios.tistory.com/entry/WeTri%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%B5%9C%EC%A0%81%ED%99%94

 

[WeTri][메모리 최적화] 이미지 크기 최적화 및 메모리 스파이크 해결과정

 

sapjilkingios.tistory.com

 

🤗 멀티파트 코드 작성

public struct MultipartFormData {
  private let boundary: String
  private let imageDataList: [Data]

  public let multipartItems: [MultipartItem]

  public init(uuid: UUID, multipartItems: [MultipartItem]) {
    boundary = uuid.uuidString
    self.multipartItems = multipartItems
    imageDataList = []
  }

  public func makeBody() -> Data {
    let lineBreak = "\r\n"
    let boundaryPrefix = "--\(boundary)\(lineBreak)"

    var body = Data()

    for item in multipartItems {
      let imageFieldName = "images"
      let filename = "image\(UUID().uuidString)\(item.fileExtension.rawValue)"

      body.append(boundaryPrefix)
      body.append(#"Content-Disposition: form-data; name="\#(imageFieldName)"; filename="\#(filename)"\#(lineBreak)"#)
      body.append("Content-Type: \(item.mimeType.rawValue)\(lineBreak)\(lineBreak)")
      body.append(item.data)
      body.append("\(lineBreak)")
    }

    // insert final boundary
    body.append("--\(boundary)--\(lineBreak)")
    return body
  }
}

private extension Data {
  mutating func append(_ string: String) {
    guard let data = string.data(using: .utf8) else {
      return
    }
    append(data)
  }
}

 

 

 

댓글