클라우드 Mac에서 iOS URL 쿼리 인코딩 계약 검증하기

클라우드 Mac에서 iOS URL 쿼리 인코딩 계약 검증하기

클라우드 Mac에서 iOS URL 쿼리 인코딩 계약 검증하기

검색창에 C++ 入门을 입력했는데 서버에는 C 入门이 도착하거나, 같은 키로 필터 조건 두 개를 보냈는데 하나만 남는 경우가 있습니다. 이런 문제는 대개 네트워크 패킷 손실이 아니라 클라이언트 인코딩과 서버 디코딩 규칙이 서로 맞지 않아 발생합니다. 클라우드 Mac에서 URL 생성 로직을 순수 함수로 분리하고 경계 조건을 XCTest 테스트 케이스로 고정하면, 운영 환경의 요청을 반복해서 캡처하는 것보다 원인을 찾기 쉽습니다. 시뮬레이터를 실행할 필요도 없습니다.

먼저 파라미터 계약을 명확히 정의하기

쿼리 문자열은 딕셔너리가 아닙니다. API에서는 같은 키를 여러 번 사용할 수 있는지, 순서에 의미가 있는지, 파라미터 누락·값 없음·빈 문자열을 동일하게 취급하는지, 서버가 폼 디코딩 규칙을 적용하는지부터 합의해야 합니다. 프런트엔드와 백엔드가 각각 “표준 라이브러리를 사용한다”고 해서 이 규칙까지 일치하는 것은 아닙니다.

예를 들어 flag, flag=, flag가 아예 없는 상태는 서로 다른 세 가지 입력입니다. 클라이언트에서는 URLQueryItem의 값을 nil로 지정하거나, 빈 문자열로 지정하거나, 항목을 추가하지 않는 방식으로 각각 표현할 수 있습니다. 서버 프레임워크가 이 차이를 유지하는지는 실제 파서로 검증해야 합니다.

일반 URL 쿼리에서 리터럴 더하기 기호가 곧 공백을 뜻하는 것은 아닙니다. 더하기 기호를 공백으로 해석하는 것은 폼 인코딩 체계의 규칙이며, 모든 쿼리 API에 그대로 적용할 수는 없습니다.

이러한 계약을 테스트 이름에 드러내세요. “일반 검색 성공” 케이스 하나만 남겨서는 부족합니다. 중국어 검색어는 통과해도 더하기 기호나 중복 필터에서는 여전히 문제가 생길 수 있습니다.

순수 함수로 URL 생성하기

macOS 단위 테스트 타깃을 만들고 다음 Swift 파일을 추가합니다. 예제 주소는 로컬 파싱에만 사용하며, 요청을 보내지 않습니다. 실제 기능에 연결할 때는 고정 주소를 프로젝트의 API 설정으로 바꾸세요. 파라미터 값은 원문 그대로 전달해야 하며, 미리 수동으로 퍼센트 인코딩하면 안 됩니다.

import Foundation
import XCTest

func makeQueryURL(
    items: [URLQueryItem],
    formDecoder: Bool = false
) throws -> URL {
    var parts = URLComponents()
    parts.scheme = "https"
    parts.host = "xcodenode.com"
    parts.path = "/query-fixture"
    parts.queryItems = items

    if formDecoder, let encoded = parts.percentEncodedQuery {
        parts.percentEncodedQuery = encoded.replacingOccurrences(
            of: "+",
            with: "%2B"
        )
    }

    return try XCTUnwrap(parts.url)
}

final class QueryEncodingTests: XCTestCase {
    func testReservedCharactersRoundTrip() throws {
        let value = "C++ 入门 & x=1 # 50% / ?"
        let url = try makeQueryURL(items: [
            URLQueryItem(name: "q", value: value)
        ])
        let parts = try XCTUnwrap(
            URLComponents(url: url, resolvingAgainstBaseURL: false)
        )
        let items = try XCTUnwrap(parts.queryItems)
        XCTAssertEqual(items.count, 1)
        XCTAssertEqual(items.first?.value, value)
        XCTAssertNil(parts.fragment)
    }

    func testDuplicatesAndEmptyValues() throws {
        let url = try makeQueryURL(items: [
            URLQueryItem(name: "tag", value: "swift"),
            URLQueryItem(name: "tag", value: "ios"),
            URLQueryItem(name: "flag", value: nil),
            URLQueryItem(name: "empty", value: "")
        ])
        let parts = try XCTUnwrap(
            URLComponents(url: url, resolvingAgainstBaseURL: false)
        )
        let items = try XCTUnwrap(parts.queryItems)
        XCTAssertEqual(items.map(\.name), [
            "tag", "tag", "flag", "empty"
        ])
        XCTAssertEqual(items.map(\.value), [
            "swift", "ios", nil, ""
        ])
    }

    func testFormDecoderReceivesLiteralPlus() throws {
        let value = "C++ 50%"
        let url = try makeQueryURL(
            items: [URLQueryItem(name: "q", value: value)],
            formDecoder: true
        )
        let parts = try XCTUnwrap(
            URLComponents(url: url, resolvingAgainstBaseURL: false)
        )
        let query = try XCTUnwrap(parts.percentEncodedQuery)
        XCTAssertFalse(query.contains("+"))
        let pair = try XCTUnwrap(
            query.split(separator: "=", maxSplits: 1).last
        )
        let decoded = String(pair)
            .replacingOccurrences(of: "+", with: " ")
            .removingPercentEncoding
        XCTAssertEqual(decoded, value)
    }
}

첫 번째 테스트는 예약 문자 때문에 추가 파라미터나 프래그먼트가 생기지 않았는지 확인합니다. 두 번째는 배열의 중복 키와 순서가 유지되는지 확인합니다. 세 번째는 폼 디코딩을 한 번 수행하는 과정을 모사합니다. 완전한 서버 파서가 아니라 단일 값에 포함된 더하기 기호의 경계 조건만 검증하므로, API 통합 테스트를 대신할 수는 없습니다.

네트워크 테스트 범위보다 실패 케이스 늘리기

같은 테스트 픽스처에 중국어, 결합 문자, 이모지, 빈 값, 앞뒤 공백, 리터럴 문자열 %2B를 추가합니다. 특히 마지막 항목이 중요합니다. 사용자가 입력한 퍼센트 기호는 인코딩해야 하며, 이미 인코딩된 더하기 기호로 취급해서는 안 됩니다.

%252B가 보이면 먼저 입력이 원래의 더하기 기호였는지, 리터럴 %2B였는지 확인하세요. 전자는 대개 이중 인코딩을 의미하지만, 후자는 올바른 결과일 수 있습니다. %25가 보인다는 이유만으로 오류라고 판단하면 수정 과정에서 새로운 버그가 생길 수 있습니다.

폭넓은 문자 집합 하나로 완성된 URL 전체를 인코딩하지 마세요. 파라미터 이름, 파라미터 값, 경로는 서로 다른 문맥에 속합니다. &, =, #를 직접 이어 붙이면 구조가 달라질 수 있습니다. URL 생성 함수가 구조를 처리하도록 하고, 파싱한 뒤 항목 수와 이름, 값을 각각 확인하세요.

매우 긴 검색어에는 별도의 입력 길이 제한을 둡니다. 인코딩된 바이트 수와 문자 수는 다르므로, 중국어 한 글자가 한 바이트라고 가정하지 말고 API 계약에 따라 제한 정책을 테스트해야 합니다.

서명된 요청은 바이트 단위까지 검증하기

의미가 같은 URL이라도 바이트가 같지는 않을 수 있습니다. 파라미터 순서, 공백을 표현하는 방식, 퍼센트 이스케이프의 대소문자는 모두 서명에 영향을 줄 수 있습니다. 일반 쿼리 테스트에서는 인코딩과 디코딩을 거친 값이 같은지 확인하면 되지만, 서명 테스트에서는 프로토콜이 정한 정규화 문자열까지 검증해야 합니다.

프로토콜에서 정렬을 요구한다면 먼저 중복 키를 어떻게 정렬할지 명확히 정한 뒤 최종 문자열에 서명합니다. 순서 유지가 필요하다면 항목 배열을 그대로 보존해야 하며, 딕셔너리를 거쳐 다시 구성하면 안 됩니다. 서명한 뒤에는 더하기 기호 처리 모드를 바꾸지 마세요. 전송 내용과 서명 입력이 서로 달라질 수 있습니다.

고정 테스트 데이터에는 같은 이름의 키, 빈 값, 비 ASCII 텍스트를 포함하고, 최종 인코딩 문자열을 어설션으로 검증합니다. 여기서 “고정”은 프로젝트에서 합의한 형식을 뜻합니다. 특정 시점의 시스템 출력을 무조건 영구적인 기준으로 삼는다는 의미는 아닙니다. 서버에도 같은 테스트 데이터를 보관해 양쪽의 서명 입력이 일치하는지 확인해야 합니다.

클라우드 노드에서 실행하고 검증 자료 보관하기

테스트 타깃은 Foundation만 사용하면 되며, 앱 서명을 설정할 필요는 없습니다. 테스트 타깃의 공유 Scheme으로 실행합니다. 아래 이름은 프로젝트의 실제 이름으로 바꾸고, 결과 디렉터리는 아직 존재하지 않는 경로를 지정해야 합니다.

set -euo pipefail
mkdir -p artifacts
result="artifacts/query-$(uuidgen).xcresult"
xcodebuild test \
  -project QueryContract.xcodeproj \
  -scheme QueryContract \
  -destination 'platform=macOS' \
  -resultBundlePath "$result"

실행 전에 Xcode 버전, 커밋 식별자, URL 생성 함수의 모드를 기록합니다. 같은 테스트 묶음에서는 툴체인을 동일하게 유지하세요. 툴체인을 업그레이드한 뒤에는 성공한 테스트 수만 볼 것이 아니라 서명 대상 바이트를 다시 확인해야 합니다.

최종 검수에서는 네 가지를 확인합니다. 원본 값이 미리 인코딩되지 않았는지, 딕셔너리 때문에 중복 키가 사라지지 않았는지, 폼 디코딩 옵션이 API 계약에 따라 결정되는지, 실제 서버 파서가 같은 테스트 데이터 전체를 통과하는지입니다. 실패 보고서에는 입력 유형과 인코딩 결과만 보관하면 충분합니다. 실제 검색어나 토큰이 포함된다면 합성 테스트 데이터로 대체하세요.

최종적으로 순수 함수 테스트는 클라이언트의 구조를 지키고, 서버 계약 테스트는 디코딩 경계를 지키도록 구성합니다. 두 계층이 모두 통과해야 파라미터가 올바르게 전달되었다고 판단할 수 있습니다. 클라이언트에서 두 번 파싱한 결과가 우연히 같았다는 사실만으로는 충분하지 않습니다.

자주 묻는 질문

URLComponents는 더하기 기호를 항상 %2B로 바꾸나요?

항상 그렇지는 않습니다. URL 쿼리에서는 더하기 기호를 그대로 둘 수 있지만 폼 디코더는 이를 공백으로 해석할 수 있습니다. 폼 디코딩이 계약에 포함되면 인코딩 후 percentEncodedQuery에 남은 더하기 기호를 %2B로 바꾸고 서버 디코더도 테스트해야 합니다.

서명 전에 쿼리 항목을 딕셔너리로 바꿔도 되나요?

중복 키를 금지하고 순서를 무시하며 정규화 규칙을 명시한 프로토콜에서만 가능합니다. 그렇지 않으면 URLQueryItem 배열을 유지하고 서명에 사용하는 최종 인코딩 바이트를 별도로 검증해야 합니다.

개발 작업에 적합한 전용 클라우드 Mac 선택하기

Apple Silicon 기반 물리 노드로, 가상 머신이 아닙니다. 일·주·월·분기 단위로 대여할 수 있으며, 모델과 요금은 공개되어 있습니다. 실제 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.

클라우드 Mac 대여하기 모델 및 대여 기간 보기