본문으로 건너뛰기

iOS 비트 집합 직렬화 검증: 비트 순서, 패딩과 길이 경계

iOS 비트 집합 직렬화 검증: 비트 순서, 패딩과 길이 경계

iOS 비트 집합 직렬화 검증: 비트 순서, 패딩과 길이 경계

팀에서 항목별 읽음 상태를 비트 집합으로 압축해 저장한다고 해 봅시다. 아시아·태평양 지역 구성원이 기록한 데이터를 북미 지역 구성원이 이어서 읽습니다. 로컬에서 저장한 뒤 다시 읽는 테스트는 늘 통과했는데, 클라이언트 버전을 바꾸자 첫 번째와 여덟 번째 표시가 뒤바뀝니다. 이런 문제는 대개 네트워크가 아니라 명확하게 정의하지 않은 바이너리 규약에서 비롯됩니다. 클라우드 Mac에서 이 검증을 정기 작업으로 실행하면 화면이 정상으로 보이는지만 확인하는 대신, 변경할 때마다 동일한 바이트 샘플과 비교할 수 있습니다.

메모리 배치를 그대로 쓰지 말고 전송 형식부터 확정하기

이 글에서는 업무 로직과 무관한 참조 형식을 구현합니다. 처음 네 바이트에는 비트 수를 빅 엔디언 부호 없는 정수로 저장하고, 바로 뒤에 비트 집합 페이로드를 붙입니다. 비트 인덱스는 0부터 시작하며, 각 바이트 안에서는 하위 비트부터 사용합니다. 따라서 인덱스 0은 첫 번째 바이트의 최하위 비트에, 인덱스 8은 두 번째 바이트의 최하위 비트에 해당합니다.

페이로드 바이트 수는 비트 수를 8로 나눈 몫에 나머지가 있으면 1을 더해 계산합니다. 비트 수가 0이면 헤더만 존재합니다. 마지막 바이트에서 사용하지 않는 상위 비트는 반드시 0이어야 하며, 여분의 바이트가 붙어 있어도 거부합니다. 이 형식에는 버전 필드가 없습니다. 기존 프로토콜에 정식으로 도입할 때는 바깥 계층에서 버전을 명시해야 하며, 기존 데이터를 그대로 대체해서는 안 됩니다.

왕복 결과가 같다는 것은 양쪽이 같은 규칙을 따른다는 뜻일 뿐입니다. 인코더와 디코더가 모두 비트 순서를 반대로 구현했다면 테스트는 여전히 통과합니다. 따라서 사람이 직접 계산한 바이트 벡터가 별도로 필요합니다.

구조체의 메모리, 머신 워드, 네이티브 정수 버퍼를 그대로 직렬화하지 마세요. 그렇게 하면 메모리 정렬, 엔디언, 구현 세부 사항이 영속 저장 형식에 섞여 들어갑니다.

입력 상한을 둔 참조 구현 작성하기

클라우드 Mac의 별도 디렉터리에 다음 코드를 BitsetCheck.swift로 저장하고 개발 도구 체인의 Swift로 실행합니다. 여기서는 상한을 백만 비트로 설정합니다. 이는 예제의 방어 정책이지 파일 형식의 이론적 한계가 아닙니다. 입력을 받으면 먼저 상한과 전체 길이를 검사한 다음 결과 배열을 생성합니다. 신뢰할 수 없는 헤더 때문에 대량의 메모리를 할당하지 않도록 하기 위해서입니다.

import Foundation

enum FormatError: Error {
    case invalid
}

let maxBits = 1_000_000

func encode(_ bits: [Bool]) throws -> [UInt8] {
    guard bits.count <= maxBits else {
        throw FormatError.invalid
    }
    let count = UInt32(bits.count)
    var bytes = [
        UInt8(truncatingIfNeeded: count >> 24),
        UInt8(truncatingIfNeeded: count >> 16),
        UInt8(truncatingIfNeeded: count >> 8),
        UInt8(truncatingIfNeeded: count)
    ]
    let size = bits.count / 8 + (bits.count % 8 == 0 ? 0 : 1)
    bytes += Array(repeating: UInt8(0), count: size)
    for index in bits.indices where bits[index] {
        bytes[4 + index / 8] |= UInt8(1) << (index % 8)
    }
    return bytes
}

func decode(_ bytes: [UInt8]) throws -> [Bool] {
    guard bytes.count >= 4 else {
        throw FormatError.invalid
    }
    let raw = bytes.prefix(4).reduce(UInt32(0)) {
        ($0 << 8) | UInt32($1)
    }
    guard raw <= UInt32(maxBits) else {
        throw FormatError.invalid
    }
    let count = Int(raw)
    let size = count / 8 + (count % 8 == 0 ? 0 : 1)
    guard bytes.count == 4 + size else {
        throw FormatError.invalid
    }
    let remainder = count % 8
    if remainder != 0 {
        let allowed = UInt8((1 << remainder) - 1)
        guard bytes[bytes.count - 1] & ~allowed == 0 else {
            throw FormatError.invalid
        }
    }
    return (0..<count).map {
        bytes[4 + $0 / 8] & (UInt8(1) << ($0 % 8)) != 0
    }
}

func require(_ condition: Bool) {
    if !condition {
        fatalError("Bitset check failed")
    }
}

func mustReject(_ bytes: [UInt8]) {
    do {
        _ = try decode(bytes)
    } catch {
        return
    }
    fatalError("Invalid input accepted")
}

let expected: [UInt8] = [0, 0, 0, 9, 0x81, 0x01]
var sample = Array(repeating: false, count: 9)
for index in [0, 7, 8] {
    sample[index] = true
}
require(try encode(sample) == expected)
require(try decode(expected) == sample)

for count in [0, 1, 7, 8, 9, 63, 64, 65] {
    let bits = (0..<count).map { $0 % 3 == 0 }
    let bytes = try encode(bits)
    require(try decode(bytes) == bits)
}

mustReject([])
mustReject([0, 0, 0, 9, 0x81])
mustReject(expected + [0])
mustReject([0, 0, 0, 1, 0x80])
mustReject([0xFF, 0xFF, 0xFF, 0xFF])

xcrun swift BitsetCheck.swift를 실행합니다. 성공하면 아무 출력 없이 종료되며, 검증 실패나 처리되지 않은 오류가 발생하면 프로세스가 실패 상태를 반환합니다. 프로젝트로 옮긴 뒤에는 참조 함수를 실제 인코딩·디코딩 진입점으로 교체하고, 벡터와 잘못된 입력을 거부하는 테스트는 유지하세요. 그렇지 않으면 실제 구현이 아니라 예제만 계속 검증하게 됩니다.

고정 벡터로 인코딩과 디코딩을 분리해 검증하기

아홉 개 비트 중 인덱스 0, 7, 8을 켜면 페이로드는 반드시 16진수 81 01이어야 합니다. 첫 번째 바이트는 최하위 비트와 최상위 비트를 함께 확인하고, 두 번째 바이트는 바이트 경계를 넘어 이어지는 비트를 확인합니다. 헤더는 논리적인 비트 수와 페이로드 길이가 다르다는 점도 명확히 보여 줍니다.

모든 비트가 꺼진 경우, 모든 비트가 켜진 경우, 첫 비트만 켜진 경우, 마지막 비트만 켜진 경우의 네 가지 샘플도 추가해야 합니다. 여덟 비트를 모두 켜면 FF, 아홉 비트를 모두 켜면 FF 01이어야 하며 FF FF가 되어서는 안 됩니다. 사용하지 않는 비트는 “어차피 읽지 않으니 아무 값이나 넣어도 되는” 공간이 아닙니다. 표현을 정규화해야 바이트 비교, 해시 계산, 캐시 키가 일관되게 유지됩니다.

고정 벡터는 버전 관리에 포함해야 합니다. 프로토콜을 변경할 때는 예상 바이트부터 검토하고 구현을 수정하세요. 테스트 대상 인코더로 예상 파일을 자동 생성해 덮어쓰면 안 됩니다. 검증받는 대상이 스스로 정답을 만드는 것과 같기 때문입니다.

잘못된 입력을 별도의 테스트 영역으로 다루기

잘린 데이터 샘플에는 헤더가 네 바이트보다 짧은 경우와 헤더는 완전하지만 페이로드가 한 바이트 부족한 경우를 모두 포함해야 합니다. 여분의 바이트도 거부해야 합니다. 그렇지 않으면 상위 단계에서 데이터를 잘못 조립해도 오류가 드러나지 않습니다. 0이 아닌 패딩 비트를 검사할 때는 비트 길이를 1로 두고 페이로드에 80을 사용하면 됩니다. 이 샘플은 비트 순서 혼동과 끝부분 검증 누락을 함께 찾아낼 수 있습니다.

길이 상한은 서비스에서 허용하는 항목 수를 기준으로 정하고, 애플리케이션 계층에서 데이터를 받을 때 전체 바이트 수도 제한해야 합니다. 예제 디코더에 전달되는 배열은 이미 생성된 상태이므로, 네트워크 계층이 지나치게 큰 응답을 미리 읽어 들이는 것까지 막을 수는 없습니다. 스트리밍 방식으로 수신할 때도 누적 바이트 수에 대한 별도의 한도가 필요합니다.

헤더의 길이만 보고 먼저 메모리를 할당한 뒤 실제 페이로드를 검사하지 마세요. 외부에서 들어오는 임의의 정수에 “길이에 7을 더한 뒤 8로 나누기” 공식을 적용해서도 안 됩니다. 이 구현은 먼저 길이를 제한한 뒤 나눗셈과 나머지로 계산합니다. 검사 순서 역시 테스트 계약의 일부입니다.

회귀 테스트에 연결할 때 비교 가능한 근거 남기기

스크립트를 저장소의 테스트 디렉터리에 두고, 같은 커밋에 포함된 실제 업무 구현을 검증하도록 실행합니다. 커밋 식별자, xcrun swift --version 출력, 실패한 벡터 이름을 기록하세요. 사용자의 실제 읽음 상태나 업무 페이로드 전체는 기록하지 않습니다.

잘린 데이터, 잘못된 패딩, 길이 상한 초과처럼 오류 유형마다 일관된 이름을 부여하는 것이 좋습니다. 모든 실패를 하나의 네트워크 오류로 뭉뚱그리지 마세요. 위 예제에서 단일 오류를 사용한 것은 참조 코드를 간결하게 만들기 위해서일 뿐입니다. 운영 환경의 구현에서는 원인을 진단할 수 있도록 오류 분류를 유지해야 합니다.

마지막으로 의도적으로 결함을 넣어 테스트가 이를 잡는지 확인합니다. 바이트 안의 비트 순서를 일부러 뒤집고 고정 벡터 검증이 실패하는지 확인하세요. 끝부분 검사를 제거한 뒤에는 테스트가 잘못된 패딩을 잡아내는지 확인합니다. 이런 결함이 실제로 작업을 실패시켜야 클라우드 회귀 테스트가 형식의 경계를 지킨다고 할 수 있습니다. 그렇지 않으면 성공하는 왕복 테스트를 한 번 더 반복하는 데 그칩니다.

자주 묻는 질문

인코딩 후 디코딩하는 테스트만으로 충분하지 않은 이유는 무엇인가요?

인코더와 디코더가 같은 잘못된 비트 순서를 사용하면 테스트가 통과합니다. 직접 계산한 고정 바이트 벡터로 실제 형식을 별도로 확인해야 합니다.

마지막 바이트에서 사용하지 않는 비트는 어떻게 처리하나요?

이 형식에서는 사용하지 않는 상위 비트를 모두 0으로 지정합니다. 디코더는 0이 아닌 패딩을 거부하여 같은 집합의 표현이 하나로 고정되도록 합니다.

프로젝트 일정에 맞춰 Mac mini를 대여하세요

독립 물리 노드를 선택하고 일간·주간·월간·분기별로 이용할 수 있습니다. 성수기에는 추가로 대여하고, 기간이 끝난 뒤 갱신하지 않으면 서비스가 중지됩니다. 실제 이용 가능 여부는 콘솔에 실시간으로 표시되는 상태를 확인해 주세요.

다음 빌드에 필요한 머신을 준비하세요

사양과 이용 기간을 확인한 뒤 프로젝트에 맞는 독립 물리 노드를 선택하세요.