iOS gzip 응답의 무결성과 압축 해제 후 크기 검사하기

iOS gzip 응답의 무결성과 압축 해제 후 크기 검사하기

iOS 앱이 압축된 JSON을 받을 때 패킷 캡처에는 수십 KB만 보이더라도, 파서에 전달되는 데이터는 훨씬 클 수 있습니다. 앱이 실제로 어떤 데이터를 받아들이는지 확인하려고 운영 API에 장애가 나기를 기다릴 필요는 없습니다. 클라우드 Mac에서 로컬 연결만 받는 테스트 서버를 실행하고, 같은 Mac에서 실행 중인 iOS 시뮬레이터로 요청하면 됩니다. 아래에서는 정상 응답, 검사값이 손상된 응답, 압축 해제 후 크기가 큰 응답을 사용해 URLSession이 반환하는 데이터와 앱이 파싱 전에 적용하는 한계를 확인합니다.

전송 크기와 앱이 사용하는 데이터 크기 구분하기

gzip은 전송 중인 응답 본문을 압축합니다. Content-Length는 압축된 상태로 전송되는 바이트 수를 나타낼 수 있지만, 앱 코드가 받는 Data에는 이미 압축이 해제된 본문이 담겨 있을 수 있습니다. 전송 크기만 제한하면 반복 문자나 매우 비슷한 JSON 조각이 압축 해제 후 많은 메모리를 차지하는 상황을 막을 수 없습니다. 이 글에서는 예제 API의 압축 해제 후 크기 제한을 256 KiB로 설정합니다. 테스트용 임계값일 뿐 모든 iOS API에 적용할 공통값은 아니므로, 실제 응답 크기에 맞춰 별도로 정해야 합니다.

테스트에서는 ‘네트워크 요청 성공’과 ‘본문을 사용할 수 있음’도 구분해야 합니다. 상태 코드가 올바르고 요청이 시간 초과 없이 끝났더라도 콘텐츠 검증을 대신할 수는 없습니다. 특히 압축 스트림이 손상됐을 때 시스템이 반드시 특정 오류를 반환한다고 가정하지 마세요. 확인해야 할 결과는 앱이 손상된 데이터를 완전한 비즈니스 결과로 취급하지 않는다는 점입니다.

테스트 단언은 전송 헤더나 네트워크 패널에 표시된 압축 크기뿐 아니라 앱이 실제로 사용하는 데이터를 대상으로 해야 합니다.

로컬에서 세 가지 gzip 응답 만들기

다음 스크립트를 /tmp/gzip_fixture.py로 저장하고 클라우드 Mac에서 python3 /tmp/gzip_fixture.py를 실행합니다. 서버는 127.0.0.1:8765에서만 연결을 받으며 외부 인터넷에 공개되지 않습니다. /ok는 짧은 본문을 반환하고, /broken은 gzip 끝부분의 검사값을 변경하며, /large는 압축률이 높고 압축 해제 후 크기가 1 MiB인 본문을 전송합니다. 세 경로 모두 실제로 압축된 데이터를 보내고 응답 헤더에는 압축 후 길이를 기록합니다.

import gzip
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

SMALL = b'{"status":"ready"}'
LARGE = b"A" * 1_048_576

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path not in ("/ok", "/broken", "/large"):
            self.send_error(404)
            return

        payload = LARGE if self.path == "/large" else SMALL
        wire = bytearray(gzip.compress(payload))
        if self.path == "/broken":
            wire[-8] ^= 0xFF

        self.send_response(200)
        self.send_header("Content-Type", "application/octet-stream")
        self.send_header("Content-Encoding", "gzip")
        self.send_header("Content-Length", str(len(wire)))
        self.end_headers()
        self.wfile.write(wire)

ThreadingHTTPServer(("127.0.0.1", 8765), Handler).serve_forever()

TCP 연결을 임의로 끊는 대신 검사값을 의도적으로 바꾼 이유는 ‘전송은 완료됐지만 콘텐츠가 손상된 경우’와 연결이 끊긴 경우를 구분해 관찰하기 위해서입니다. 이 스크립트는 고정된 테스트 입력을 제공할 뿐, 실제 API의 압축률이나 처리량을 재현한다고 주장하지 않습니다. 테스트를 실행하기 전에 lsof -nP -iTCP:8765 -sTCP:LISTEN으로 현재 포트를 수신 중인 프로세스가 방금 시작한 서버인지 확인하세요. 포트가 이미 사용 중이라면 이전 서버를 먼저 중지해 테스트가 다른 프로그램에 잘못 연결되지 않도록 합니다.

시뮬레이터에서 실제 응답 본문 확인하기

다음 XCTest 예제를 프로젝트의 기존 테스트 target에 추가합니다. 이 예제는 서버와 같은 Mac에서 실행되는 시뮬레이터에만 적용됩니다. 여기서 127.0.0.1은 해당 Mac의 로컬 주소입니다. 실제 기기를 사용한다면 서버 주소와 접근 제어를 다시 설계해야 합니다. 테스트 프로젝트에서는 이 개발 환경의 로컬 HTTP 요청을 허용해야 하지만, 테스트를 실행하기 위해 정식 빌드의 원격 요청 보안 설정까지 완화해서는 안 됩니다.

import Foundation
import XCTest

final class GzipResponseTests: XCTestCase {
    private let base = URL(string: "http://127.0.0.1:8765")!

    private func fetch(_ path: String) async throws -> Data {
        let url = base.appendingPathComponent(path)
        let (data, response) = try await URLSession.shared.data(from: url)
        XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
        return data
    }

    func testValidBody() async throws {
        let data = try await fetch("ok")
        XCTAssertEqual(data, Data(#"{"status":"ready"}"#.utf8))
    }

    func testLargeDecodedBodyExceedsAppLimit() async throws {
        let data = try await fetch("large")
        XCTAssertGreaterThan(data.count, 256 * 1024)
    }

    func testDamagedBodyIsNotAcceptedAsComplete() async {
        do {
            let data = try await fetch("broken")
            XCTAssertNotEqual(
                data,
                Data(#"{"status":"ready"}"#.utf8)
            )
        } catch {
            return
        }
    }
}

정상 응답 테스트를 먼저 실행한 다음 큰 본문과 손상된 본문 테스트를 실행하세요. 정상 테스트에서 예상한 텍스트 대신 gzip 원본 바이트를 받는다면 응답 헤더, 테스트 환경, 요청 구현부터 확인해야 합니다. 단언을 ‘데이터가 있기만 하면 통과’로 바꾸지 마세요. 손상된 본문 테스트는 두 가지 관찰 결과를 허용합니다. 호출에서 오류가 발생하거나, 반환된 데이터가 완전한 예상 본문과 달라야 합니다. 이 테스트는 시스템이 검사값 오류를 반드시 특정 단계에서 거부한다는 뜻은 아니며, 비즈니스 로직에서 JSON 필드를 검증하는 작업을 대신하지도 않습니다.

파싱 전에 크기 제한 적용하기

앱 코드에서는 디코더에 데이터를 넘기기 전에 Data.count를 확인합니다. 아래 코드는 전체 응답을 받은 후 적용하는 크기 제한입니다. 지나치게 큰 본문을 계속 파싱하지 않게 해 주지만, 다운로드와 압축 해제 과정에서 이미 발생한 메모리 사용량까지 막지는 못합니다. API가 매우 큰 콘텐츠를 반환할 수 있다면 분할 수신, 디스크 임시 저장 또는 서버 측 제한도 아키텍처에 반영해야 합니다. 이 한 줄의 검사에만 의존해서는 안 됩니다.

enum ResponseError: Error {
    case tooLarge
}

func checkedBody(_ data: Data) throws -> Data {
    guard data.count <= 256 * 1024 else {
        throw ResponseError.tooLarge
    }
    return data
}

큰 본문 테스트를 확장해 checkedBody(data)가 tooLarge를 던지는지 단언하고, 정상 본문은 통과하는지도 확인하세요. 그러면 테스트 목표가 ‘차이를 관찰한다’에서 ‘앱이 실제로 제한을 적용한다’로 나아갑니다. API가 JSON을 반환한다면 크기 검사에 통과한 후 JSON을 디코딩하고 필수 필드를 확인하세요. ‘파싱할 수 있다’는 사실을 ‘예상한 API에서 온 콘텐츠다’라는 뜻으로 받아들여서는 안 됩니다.

마무리할 때 재현 가능한 확인 항목 남기기

이 테스트를 제출하기 전에 다섯 가지를 확인하세요. 서버가 로컬 연결만 받는지, 테스트가 같은 Mac의 시뮬레이터를 명시적으로 사용하는지, 정상 응답을 완전한 예상 본문과 비교하는지, 손상된 응답을 완전한 콘텐츠로 받아들이지 않는지, 크기 제한이 URLSession이 앱에 전달하는 Data를 대상으로 하는지 확인합니다. 마지막으로 임시 서버를 중지하고 8765 포트가 더 이상 연결을 받지 않는지 확인하세요. 테스트 target의 로컬 HTTP 예외가 정식 빌드에 포함되지 않았는지도 점검합니다.

이 테스트용 서버는 의도적으로 단순하게 유지했습니다. 이후 빈 본문, 오류 상태 코드 또는 다른 인코딩을 다루려면 별도의 경로와 단언을 추가하세요. 여러 장애 조건을 매번 달라지는 하나의 응답에 몰아넣지 않는 편이 좋습니다. 고정된 입력과 명확한 제한을 두고 앱이 실제로 사용하는 바이트를 확인하는 것이 패킷 캡처에 보이는 압축 크기만으로 안전성을 판단하는 것보다 훨씬 믿을 만합니다.

자주 묻는 질문

Content-Length만으로 응답 크기를 제한할 수 있나요?

아니요. 그 값은 전송된 압축 데이터의 길이입니다. 앱의 제한은 압축 해제 뒤 반환된 Data의 크기에 적용해야 합니다.

gzip 체크섬이 손상되면 URLSession이 반드시 오류를 던져야 하나요?

특정 실패 형태에 의존하지 마세요. 오류가 발생하거나 본문 검증이 실패하는 등, 손상된 응답을 완전한 데이터로 받아들이지 않으면 됩니다.

직접 테스트해 보시겠어요?

Mac mini를 단독 임대해 빌드를 바로 실행하세요

두 가지 M4 물리 머신 중 하나를 선택하고, 필요에 맞는 임대 기간과 리전을 고르세요. 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.

Mac mini 지금 임대하기