클라우드 Mac에서 iOS multipart 요청 인코딩 검증하기
같은 이미지가 디버깅 도구에서는 정상적으로 업로드되는데 iOS 클라이언트에서는 “파일 필드 누락” 오류가 난다면, 타임아웃부터 바꾸지 마세요. 업로드 요청 본문을 클라우드 Mac으로 가져와 고정 입력으로 원시 바이트를 검사하면 원인을 더 빨리 구분할 수 있습니다. 필드 이름이 맞지 않는지, 줄바꿈이 잘못됐는지, 문자열 변환 과정에서 파일 내용이 손상됐는지 확인하는 것입니다. 개발용 Mac에는 사용 가능한 Swift 도구 체인만 있으면 되며, 실제 운영 업로드 서비스에 연결할 필요는 없습니다.
먼저 요청 본문의 바이트 규약 정의하기
multipart/form-data는 단순히 텍스트에 파일을 끼워 넣는 형식이 아닙니다. 각 파트는 경계 구분 줄, 필드 헤더, 빈 줄, 내용으로 구성됩니다. 프로토콜의 줄바꿈은 CRLF이며, 바이트로는 0D 0A입니다. 파일 내용 뒤에도 CRLF를 붙여야 하고, 마지막 경계 구분자 끝에는 하이픈 두 개가 추가로 필요합니다.
먼저 API 담당자에게 세 가지를 확인하세요. 텍스트 필드 이름, 파일 필드 이름, 같은 이름의 필드를 여러 번 보내도 되는지입니다. 아래 예제에서는 텍스트 필드를 note, 파일 필드를 file로 정합니다. 중복 키를 없애는 딕셔너리를 쓰지 않고 배열 순서대로 인코딩합니다.
검증 대상은 실제로 전송할 최종 Data입니다. 로그에서 정상으로 보이는 문자열이 아닙니다. 스크린샷만으로는 바이너리 내용이 변하지 않았다는 사실을 증명할 수 없습니다.
테스트에서는 비교하기 쉽도록 boundary를 고정합니다. 운영 요청에서는 무작위 값을 생성하고 내용과 충돌하지 않는지 확인하세요. 요청 헤더의 boundary 매개변수에는 구분 줄 앞에 붙이는 하이픈 두 개를 넣지 않습니다.
지원 범위를 명확히 한 인코더 작성하기
아래 코드를 multipart-check.swift로 저장하세요. 이 코드는 안전한 ASCII 필드 이름과 파일명만 지원하며, 파일 파트의 미디어 타입은 application/octet-stream으로 고정합니다. 검증을 위해 의도적으로 범위를 제한한 구현이므로, 서버마다 다른 국제화 파일명 처리 방식까지 모두 지원하지는 않습니다.
import Foundation
struct Part {
let name: String
let filename: String?
let bytes: Data
}
enum EncodingError: Error {
case invalidHeader
case invalidBoundary
case boundaryCollision
}
func safeHeader(_ value: String) -> Bool {
!value.isEmpty && value.utf8.allSatisfy {
$0 >= 32 && $0 <= 126 && $0 != 34 && $0 != 92
}
}
func encode(_ parts: [Part], boundary: String) throws -> Data {
guard (1...70).contains(boundary.utf8.count),
boundary.utf8.allSatisfy({
(48...57).contains($0) ||
(65...90).contains($0) ||
(97...122).contains($0) || $0 == 45
}) else {
throw EncodingError.invalidBoundary
}
var body = Data()
func append(_ text: String) {
body.append(Data(text.utf8))
}
for part in parts {
guard safeHeader(part.name),
part.filename.map({ safeHeader($0) }) ?? true else {
throw EncodingError.invalidHeader
}
guard part.bytes.range(of: Data(boundary.utf8)) == nil else {
throw EncodingError.boundaryCollision
}
append("--\(boundary)\r\n")
var header = "Content-Disposition: form-data; name=\"\(part.name)\""
if let filename = part.filename {
header += "; filename=\"\(filename)\""
}
append(header + "\r\n")
append(part.filename == nil
? "Content-Type: text/plain; charset=utf-8\r\n"
: "Content-Type: application/octet-stream\r\n")
append("\r\n")
body.append(part.bytes)
append("\r\n")
}
append("--\(boundary)--\r\n")
return body
}
func expectFailure(_ action: () throws -> Data) {
do {
_ = try action()
} catch {
return
}
fatalError("Expected encoding failure")
}
let boundary = "fixture-boundary"
let binary = Data([0x00, 0xFF, 0x0D, 0x0A, 0x80])
let parts = [
Part(name: "note", filename: nil, bytes: Data("验收".utf8)),
Part(name: "file", filename: "sample.bin", bytes: binary)
]
let body = try encode(parts, boundary: boundary)
precondition(body.starts(with: Data("--\(boundary)\r\n".utf8)))
precondition(body.suffix(Data("--\(boundary)--\r\n".utf8).count)
== Data("--\(boundary)--\r\n".utf8))
let filePrefix = Data(
("Content-Disposition: form-data; name=\"file\"; filename=\"sample.bin\"\r\n"
+ "Content-Type: application/octet-stream\r\n\r\n").utf8
)
guard let headerRange = body.range(of: filePrefix) else {
fatalError("Missing file header")
}
let start = headerRange.upperBound
precondition(body.subdata(in: start..<(start + binary.count)) == binary)
precondition(body.subdata(in: (start + binary.count)..<body.count)
== Data("\r\n--\(boundary)--\r\n".utf8))
expectFailure {
try encode([
Part(name: "file", filename: "bad\r\nInjected: yes", bytes: binary)
], boundary: boundary)
}
expectFailure {
try encode([
Part(name: "note", filename: nil, bytes: Data(boundary.utf8))
], boundary: boundary)
}
코드는 파일을 UTF-8로 디코딩하지 않고 Data 그대로 추가합니다. 헤더 값에서는 제어 문자, 큰따옴표, 백슬래시를 거부합니다. 사용자가 제공한 파일명이 따옴표 범위를 벗어나거나 새 필드 헤더를 주입하지 못하게 하기 위해서입니다. 여기서 사용하는 충돌 검사는 프로토콜 요구 사항보다 보수적입니다. 내용에 boundary 값 자체가 포함되면 거부하며, 운영 환경에서는 boundary를 새로 생성해 다시 시도할 수 있습니다.
클라우드 Mac에서 실행하고 테스트 샘플 보강하기
먼저 xcrun swift multipart-check.swift를 실행하세요. 정상적으로 통과하면 출력이 없고 종료 코드는 0입니다. 단언 검사 실패나 인코딩 오류가 발생하면 0이 아닌 종료 코드가 반환됩니다. 이 검증 스크립트는 단언 검사를 비활성화하는 최적화 모드로 실행하지 마세요.
현재 바이너리 샘플에는 의도적으로 널 바이트, 유효하지 않은 UTF-8, CRLF가 들어 있습니다. 단언 검사는 파일 내용을 찾는 데 그치지 않고, 파일 뒤의 CRLF 바로 다음에 종료 구분 줄이 오는지도 확인합니다. 따라서 “바이트는 있지만 위치가 잘못된” 요청 본문이 검사를 통과하는 일을 막습니다.
실패 및 경계 입력 네 가지 추가하기
빈 파일, 빈 텍스트, 이름이 같은 텍스트 필드 두 개, 이름에 큰따옴표가 들어간 파트를 추가하세요. 내용이 비어 있어도 헤더 뒤의 빈 줄과 내용 뒤의 CRLF는 유지해야 합니다. 중복 필드는 개수와 순서를 모두 보존해야 합니다. 중국어 파일명 샘플도 추가하고, 이름을 임의로 바꾸는 것이 아니라 거부하는 것이 기대 결과임을 명확히 하세요.
서비스에서 중국어 파일명을 반드시 받아야 한다면, 먼저 서버가 지원하는 인코딩 규칙을 확인한 뒤 구현을 확장하세요. 파일명을 퍼센트 인코딩하는 방법을 범용 해결책으로 간주해서는 안 됩니다. 헤더 주입 검사 없이 모든 Unicode 문자를 허용하는 것도 피해야 합니다.
URLSession 연동 시 같은 데이터 유지하기
실제 연동에서는 boundary와 인코딩된 Data를 함께 요청 생성 계층에 전달하세요. Content-Type은 multipart/form-data; boundary= 뒤에 실제 값을 붙여 설정하고, 업로드 작업에도 같은 Data를 전달합니다. 두 계층이 각각 별도의 boundary를 생성하게 해서는 안 됩니다.
작은 샘플은 메모리에서 인코딩해도 됩니다. 큰 파일은 임시 요청 본문 파일에 나누어 기록한 뒤 파일 기반 업로드를 사용하세요. 그러면 원본 파일과 전체 요청 본문을 동시에 메모리에 보관하지 않아도 됩니다. 파일 기반 인코딩으로 바꾼 뒤에는 최종 파일을 대상으로 같은 바이트 검증을 다시 실행해야 합니다.
서비스에서 Content-Length를 설정한다면 최종 요청 본문의 바이트 수를 사용해야 합니다. 문자 수나 원본 파일 크기를 사용해서는 안 됩니다. 수동 설정이 필요한지는 네트워크 계층에서 일관되게 결정하고, 인코더가 부정확할 수 있는 별도의 길이 값을 만들지 않도록 하세요.
프로토콜 검증과 API 검증 분리하기
로컬 바이트 검사를 통과했다는 것은 이 인코딩 규약을 만족한다는 뜻일 뿐입니다. 다음으로 테스트 API에서 요청을 파싱하게 하여 필드 값, 파일 길이, 파일 해시를 확인하세요. 이어서 중복 필드, 빈 파일, 파일명에 대한 서버의 실제 동작을 점검합니다.
실패가 발생하면 boundary, 바이트 길이, 합성 테스트 샘플만 보관하면 됩니다. 실제 사용자 업로드 내용은 보관하지 마세요. 진단 순서는 구분자 확인, 필드 헤더 확인, 파일 바이트 비교 순으로 고정합니다. 파일 크기 제한을 초과했거나 API가 파일 타입을 거부했다면 서버 측 요구 사항을 다시 확인하세요. 줄바꿈을 바꿔 서비스 규칙 위반을 감추려 해서는 안 됩니다.
마지막으로 스크립트를 커밋 검사에 포함하고, 테스트 보고서에 도구 체인 버전과 종료 코드를 기록하세요. 이렇게 하면 업로드 모듈의 구현을 바꾸더라도 우연히 한 번 성공한 네트워크 요청이 아니라 동일한 바이트 규약을 기준으로 검증할 수 있습니다.
자주 묻는 질문
실제 요청에도 고정 boundary를 사용해야 하나요?
아니요. 고정 경계는 반복 가능한 테스트에만 사용합니다. 실제 요청에서는 무작위 경계를 생성하고 각 파트 내용과 충돌하지 않는지 확인한 뒤 헤더와 본문에 같은 값을 사용하세요.
요청 본문을 문자열로 변환해서 검사해도 되나요?
바이너리 검증에는 적합하지 않습니다. 파일 바이트가 유효한 UTF-8이 아닐 수 있으므로 Data를 직접 비교하세요. 문자열 보기는 헤더와 구분자를 살펴보는 보조 수단입니다.
프로젝트를 위한 전용 Mac을 확보하세요
가상 머신이 아닌 Mac mini 물리 노드입니다. 일·주·월·분기 단위로 대여하고, 용도와 사양을 선택한 뒤 콘솔에서 실시간 제공 상태를 확인하세요.