클라우드 Mac에서 iOS 테스트를 할 때 인터페이스가 최종적으로 무엇을 반환했는지만 확인하면 중간에 발생한 HTTP 리디렉션을 놓치기 쉽습니다. 예를 들어 다운로드 요청이 먼저 302를 받았는데 대상 주소의 포트가 바뀌었다고 가정해 보겠습니다. 클라이언트가 리디렉션을 계속 따라가면 한 서비스에만 보내려던 테스트 요청이 다른 서비스에 도달합니다. 아래에서는 로컬 포트 두 개로 이 상황을 재현하고, XCTest로 ‘동일 출처 리디렉션 허용’과 ‘다른 출처 리디렉션 거부’를 각각 검증합니다. 외부 사이트가 필요 없고 실패도 쉽게 재현할 수 있습니다.
단언문보다 먼저 경계 정하기
여기서 ‘출처’는 프로토콜, 호스트, 포트의 조합을 뜻합니다. http://127.0.0.1:8765에서 같은 포트의 /target으로 이동하면 동일 출처입니다. 반면 http://127.0.0.1:8766/target으로 이동하면 호스트 이름이 그대로여도 다른 출처입니다. 이 예제의 정책은 동일 출처 리디렉션만 따르는 것입니다. 시연용 정책일 뿐, 모든 서비스가 다른 출처로의 이동을 막아야 한다는 뜻은 아닙니다. 다운로드 서비스가 실제로 다른 도메인을 사용한다면 허용할 대상을 먼저 명시하고 실제 요구사항에 맞춰 규칙을 작성하세요.
리디렉션 테스트에서는 이동 도중 내린 결정을 확인해야 합니다. 마지막에
200을 받았다고만 단언해서는 요청이 예상 밖의 주소를 거치지 않았음을 증명할 수 없습니다.
테스트용 서버는 테스트 저장소에 두고 가짜 토큰을 사용하세요. 프로덕션 자격 증명을 스크립트나 테스트 로그에 기록해서는 안 됩니다. 실제 API가 HTTPS를 사용한다면 TLS도 별도로 검증해야 합니다. 로컬 HTTP 테스트용 서버는 리디렉션 결정만 검사하며, 운영 환경의 연결 보안 테스트를 대신할 수 없습니다.
로컬에서만 수신하는 포트 두 개 열기
다음 코드를 redirect_fixture.py로 저장한 뒤 클라우드 Mac 터미널에서 python3 redirect_fixture.py를 실행하세요. 첫 번째 포트는 두 종류의 리디렉션을 제공하고, 두 번째 포트는 다른 출처의 대상 요청을 받는 데만 사용합니다. 서버는 127.0.0.1에 바인딩되므로 테스트 포트가 외부에 공개되지 않습니다.
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
port = self.server.server_port
if port == 8765 and self.path in ("/same", "/start"):
target = (
"/target" if self.path == "/same"
else "http://127.0.0.1:8766/target"
)
self.send_response(302)
self.send_header("Location", target)
self.end_headers()
return
if self.path == "/target":
body = b"ok"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
self.send_error(404)
servers = [
HTTPServer(("127.0.0.1", port), Handler)
for port in (8765, 8766)
]
threads = [
Thread(target=server.serve_forever, daemon=True)
for server in servers
]
for thread in threads:
thread.start()
threads[0].join()
먼저 다른 터미널에서 테스트용 서버를 확인하세요. curl -i http://127.0.0.1:8765/start는 두 번째 포트의 주소가 포함된 302를 반환해야 하고, curl -i http://127.0.0.1:8765/same은 /target을 가리키는 302를 반환해야 합니다. 여기서는 -L을 추가하지 마세요. 추가하면 명령줄 도구가 리디렉션을 대신 따라가서 최초 응답을 볼 수 없습니다. 테스트가 끝나면 서버를 실행한 터미널에서 Ctrl-C를 눌러 중지하세요.
URLSession 델리게이트에서 이동 여부 결정하기
판단 로직은 URLSessionTaskDelegate의 리디렉션 콜백에 넣습니다. 두 호스트 이름만 비교하는 것이 아니라 현재 응답의 주소와 다음에 요청할 주소를 비교해야 합니다. 아래 클래스는 iOS 테스트 target에 넣을 수 있습니다. 작업 상태를 저장하지 않으므로 두 테스트에서 각각 인스턴스를 만들 수 있습니다.
import Foundation
final class SameOriginGate: NSObject, URLSessionTaskDelegate,
@unchecked Sendable {
func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
guard let from = response.url,
let to = request.url,
let source = URLComponents(url: from, resolvingAgainstBaseURL: false),
let destination = URLComponents(url: to, resolvingAgainstBaseURL: false),
source.scheme == destination.scheme,
source.host == destination.host,
source.port == destination.port else {
completionHandler(nil)
return
}
completionHandler(request)
}
}
포트를 명시적으로 비교하면 이 테스트용 서버의 두 주소를 구분하기에는 충분합니다. 프로덕션 코드에서는 기본 포트를 정규화하는 규칙도 정해야 합니다. 예를 들어 포트를 적지 않은 HTTPS 주소와 443을 명시한 주소를 동일하게 볼지 결정해야 합니다. 예제의 단순화된 비교가 어느새 제품 정책이 되지 않도록 하세요. 델리게이트가 새 요청을 허용할 때는 어떤 요청 헤더를 계속 전송해도 되는지도 확인해야 합니다.
결과를 구분하는 XCTest 두 개 작성하기
테스트 target에 다음 테스트를 추가하세요. data(for:delegate:)는 이번 요청의 리디렉션 결정을 지정한 델리게이트에 맡깁니다. 각 테스트는 세션 상태를 재사용하지 않도록 임시 세션을 사용합니다. 먼저 테스트가 실행될 바로 그 클라우드 Mac에서 테스트용 서버를 시작한 뒤 테스트를 실행하세요.
import XCTest
final class RedirectBoundaryTests: XCTestCase {
func testSameOriginRedirectSucceeds() async throws {
let session = URLSession(configuration: .ephemeral)
defer { session.invalidateAndCancel() }
let url = URL(string: "http://127.0.0.1:8765/same")!
let (data, response) = try await session.data(
for: URLRequest(url: url),
delegate: SameOriginGate()
)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
XCTAssertEqual(String(data: data, encoding: .utf8), "ok")
XCTAssertEqual(response.url?.path, "/target")
}
func testDifferentPortRedirectStops() async throws {
let session = URLSession(configuration: .ephemeral)
defer { session.invalidateAndCancel() }
let url = URL(string: "http://127.0.0.1:8765/start")!
var request = URLRequest(url: url)
request.setValue("fixture-only", forHTTPHeaderField: "X-Test-Token")
let (_, response) = try await session.data(
for: request,
delegate: SameOriginGate()
)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 302)
XCTAssertEqual(response.url?.port, 8765)
}
}
리디렉션을 거부할 때 완료 콜백에 nil을 전달하면 URLSession은 두 번째 포트에 요청하지 않고 원래의 302 응답을 테스트에 돌려줍니다. 따라서 두 번째 테스트가 기대하는 값은 네트워크 오류가 아니라 302입니다. 예제의 X-Test-Token은 요청에 민감한 헤더가 포함될 수 있음을 보여줄 뿐입니다. 대상 서비스가 그 헤더를 실제로 받지 않았는지 검증하려면 테스트용 서버에서 두 번째 포트가 받은 요청 수를 기록하고 그 값이 0이라고 단언해야 합니다.
실패하면 단계별로 점검하기
두 테스트 모두 연결 실패를 보고한다면 테스트용 서버 프로세스가 여전히 실행 중인지, 테스트가 서버를 시작한 Mac에서 실제로 실행되는지 먼저 확인하세요. 127.0.0.1은 언제나 요청을 보내는 기기 자신을 가리킵니다. 같은 테스트를 iOS 시뮬레이터 내부에서 직접 실행하면 시뮬레이터 자신의 로컬 주소로 접속하므로 Mac의 테스트용 서버에 연결되지 않을 수 있습니다. 시뮬레이터에서 실행해야 하는 프로젝트라면 시뮬레이터가 접근할 수 있는 테스트 호스트 주소를 설정하되, 서버는 신뢰할 수 있는 테스트 네트워크에만 공개하세요.
동일 출처 테스트가 302를 반환한다면 델리게이트를 data(for:delegate:)에 전달했는지, 요청과 응답의 프로토콜·호스트·포트가 예상대로 설정됐는지 확인하세요. 다른 출처 테스트가 예상과 달리 200을 받는다면 콜백에서 completionHandler(request)를 호출했는지부터 확인하세요. 다른 네트워크 래퍼가 테스트에 사용된 세션을 바꾸지 않았는지도 살펴봐야 합니다.
마지막으로 승인 기준을 파이프라인에 넣으세요. 테스트용 서버가 시작되고, 두 테스트가 각각 200과 302를 받으며, 테스트 종료 후 서버 프로세스가 끝나고, 로그에 실제 토큰이 없어야 합니다. 나중에 리디렉션 규칙을 완화해야 한다면 해당 허용 사례를 먼저 테스트에 추가한 다음 델리게이트를 수정하세요. 그러면 정책 변경이 실행 가능한 기록으로 남습니다.
자주 묻는 질문
호스트가 같아도 포트가 바뀌면 다른 출처인가요?
그렇습니다. 출처는 프로토콜, 호스트, 포트로 결정되므로 8765에서 8766으로 이동하면 출처가 달라집니다.
이동을 거부했는데 테스트에서 302를 받는 이유는 무엇인가요?
델리게이트가 completionHandler에 nil을 전달하면 URLSession은 목적지를 요청하지 않고 원래의 리디렉션 응답을 반환합니다.
직접 테스트해 보시겠어요?
Mac mini를 단독 임대해 빌드를 바로 실행하세요
두 가지 M4 물리 머신 중 하나를 선택하고, 필요에 맞는 임대 기간과 리전을 고르세요. 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.