티스토리 뷰

iOS

[iOS] URLSession image upload

Peppo 2024. 11. 24. 22:42
728x90

 

평소 네트워크 통신을 할때 Alamofire를 사용하면서

직접 URLSession을 사용해 이미지 업로드를 구현해보며 내부 동작을 파악하고 싶어 

해당 블로깅을 작성해봅니다.


 

 

기본적으로 URLSession의 request 헤더 부분에 'Content-Type: application/json'을 사용하게 되는데,

이미지나 대용량 파일 등을 전송하기 위해선 multipart/form-data로 전송하는게 적합 합니다.

 

multipart/form-data

먼저 application/json | multipart/form-data 각각 request body를 보내는 방법 부터가 다른데 아래를 먼저 보고 가봅시다.

// application/json

let body: [String: Any] = [
    "name": name,
    "content": content
]
        
let requestBody: Data? = try? JSONSerialization.data(withJSONObject: body)
request.httpBody = requestBody

 

// multipart/form

private func createUploadBody(params: [String: String], boundary: String, imageData: Data) -> Data {
    var body: Data = Data()
    let lineBreak: String = "\r\n"
        
    for (key, value) in params {
        body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!)
        body.append("Content-Disposition: form-data; name=\"\(key)\"\(lineBreak)\(lineBreak)".data(using: .utf8)!)
        body.append("\(value)\(lineBreak)".data(using: .utf8)!)
    }
        
    // image 파일
    let fileName: String = "test.jpeg"
    let mimeType: String = "image/jpeg"
    body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!)
    body.append("Content-Disposition: form-data; name=\"image\"; filename=\"\(fileName)\"\(lineBreak)".data(using: .utf8)!)
    body.append("Content-Type: \(mimeType)\(lineBreak)\(lineBreak)".data(using: .utf8)!)
    body.append(imageData)
    body.append("\(lineBreak)--\(boundary)--\(lineBreak)".data(using: .utf8)!)
        
    return body
}

 

body를 만드는 방법이 다르고 뭔가 복잡해 보이지만 

multipart/form 방식 통신 규약에 대해 알고가면 조금 정리가 될겁니다.

createUploadBody 메서드로 body를 만들어 서버에 전송하면 아래와 같은 형태가 됩니다

 

https://m.boostcourse.org/web326/lecture/59008

 

POST /test.html HTTP/1.1
Host: example.org
Content-Type: multipart/form-data;boundary="boundary" // 1

// name
--boundary // 2
Content-Disposition: form-data; name="name"

// content
--boundary
Content-Disposition: form-data; name="content" 

// imageFile
// 3
Content-Disposition: form-data; name="image"; filename="test.jpeg" 
Content-Type: image/jpeg

 

1. Content-Type 헤더부분에 boundary 구분자 라고 생각하시면 됩니다.

해당 boundary 값으로 어디가 시작점이고 어디가 다음데이터 혹은 마지막 지점인지 구분할 수 있게 해주는 역할을 합니다.

대부분 boundary 값은 UUID() 값을 통해 고유한 값을 지정합니다 (request는 여러번 보내기 때문에 고유한 값을 지정해 구분이 필요하기 때문)

 

2. 1번에서 설명한것 처럼 boundary 값으로 시작점을 나타냅니다.

 

3. 'name', 'content'와 다르게 이미지 파일 업로드의 경우엔 mimeType, filename을 기입해줍니다.

image파일을 업로드할거니 mimeType은 image/jpeg가 되겠죠

filename은 말 그대로 파일 이름을 넣어주시면 됩니다.

 

참고

body를 만들때 값이 하나라도 맞지 않거나, 띄어쓰기, 줄바꿈이 다를 경우 통신이 되지 않습니다.
꼼꼼히 아래 규약을 잘 지키고 있는지 확인하셔야 합니다.

1. boundary로 구분을 잘해주고 있는지
2. 'lineBreak' ("\r\n")이 잘 되어있는지
3. data를 append 해줬는지
  ex) Content-Disposition: form-data; name=\"\(key)\"\(lineBreak)\(lineBreak)
        // 데이터 추가
4. 파일의 경우) 파일이름이 있는지, mimeType을 지정했는지
5. 데이터의 끝을 나타내는 경계값을 추가하여 데이터의 마지막 부분을 잘 구분 하고 있는지

 

 

아래는 사용 예시 입니다.

// UIImage를 Data로 전환
// self.selectedImage: UIImage
// guard let imageData = self.selectedImage?.jpegData(compressionQuality: 0.8) else { return }

func addTodo(
        name: String,
        content: String,
        imageData: Data,
        completion: @escaping (Result<SomeModel, Error>) -> Void
    ) {
        var urlComponents = URLComponents()
        urlComponents.scheme = "http"
        urlComponents.host = "localhost"
        urlComponents.port = 8080
        urlComponents.path = "/testPath"
        
        guard let url = urlComponents.url
        else {
            completion(.failure(NetworkError.invalidURL))
            return
        }
        
        // request
        var request: URLRequest = .init(url: url)
        request.httpMethod = "POST"
        // header
        let identifier: String = UUID().uuidString
        let boundary: String = "Boundary-\(identifier)"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
        
        let body: Data = createUploadBody(
            params: [
                "name": name,
                "content": content
            ],
            boundary: boundary,
            imageData: imageData
        )
        request.httpBody = body
        
        let task = URLSession.shared.uploadTask(
            with: request,
            from: body,
            completionHandler: { data, response, error in
                if let error = error {
                    completion(.failure(error))
                    return
                }
                if let data = data {
                    do {
                        let decodedData: SomeModel = try JSONDecoder().decode(SomeModel.self, from: data)
                        print(#function, "success: \(decodedData)")
                        completion(.success(decodedData))
                    }
                    catch {
                        completion(.failure(error))
                    }
                }
            })
        
        task.resume()
    }
    
    
    private func createUploadBody(params: [String: String], boundary: String, imageData: Data) -> Data {
        var body: Data = Data()
        let lineBreak: String = "\r\n"
        
        for (key, value) in params {
            body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!)
            body.append("Content-Disposition: form-data; name=\"\(key)\"\(lineBreak)\(lineBreak)".data(using: .utf8)!)
            body.append("\(value)\(lineBreak)".data(using: .utf8)!)
        }
        
        // image 파일
        let fileName: String = "test.jpeg"
        let mimeType: String = "jpeg"
        body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!)
        body.append("Content-Disposition: form-data; name=\"imageFile\"; filename=\"\(fileName)\"\(lineBreak)".data(using: .utf8)!)
        body.append("Content-Type: image/\(mimeType)\(lineBreak)\(lineBreak)".data(using: .utf8)!)
        body.append(imageData)
        body.append("\(lineBreak)--\(boundary)--\(lineBreak)".data(using: .utf8)!)
        
        return body
    }

 

 

Alamofire의 경우도 uploadTask를 사용하는걸 볼 수 있습니다.

public class UploadRequest: DataRequest {

    }

    // ...

    override func task(for request: URLRequest, using session: URLSession) -> URLSessionTask {
        // ...
        switch uploadable {
        case let .data(data): return session.uploadTask(with: request, from: data)
        case let .file(url, _): return session.uploadTask(with: request, fromFile: url)
        case .stream: return session.uploadTask(withStreamedRequest: request)
        }

    // ...
}

 

 


지난 블로깅에서 Kingfisher를 분해해보며 

그동안 당연하게 사용하고있던 라이브러리들의 내부구조가 어떻게 되어있는지 파악하면서 

동작원리를 알아가는데 배워가는게 많은것 같습니다.

 

이번 Alamofire를 통해 업로드 하는기능을 URLSession으로 직접 구현해보면서 

다시한번 Alamofire 내부구조도 확인할 수 있었고, 

막 가져다 쓰기보단 짬내서라도 하나씩 파악하면서 공부하는 습관을 길러야겠다는 생각이 들었습니다.

 

참고

1. https://developer.mozilla.org/ko/docs/Web/HTTP/Methods/POST

2.https://developer.apple.com/documentation/foundation/url_loading_system/uploading_data_to_a_website#2923850

3. https://sandclock-itblog.tistory.com/244

728x90