본문으로 건너뛰기

클라우드 Mac에서 검증하는 iOS CRC-32 스트리밍 체크섬

클라우드 Mac에서 검증하는 iOS CRC-32 스트리밍 체크섬

클라우드 Mac에서 검증하는 iOS CRC-32 스트리밍 체크섬

같은 파일을 한 번에 처리하면 체크섬 검증을 통과하는데 청크로 나누어 읽으면 실패한다면, 대개 클라우드 Mac의 네트워크 문제가 아니라 청크를 읽을 때마다 CRC 상태를 초기화하는 것이 원인입니다. 여러 지역에 분산된 팀이 파일 처리 로직을 지속적인 테스트에 포함할 때는 입력 파일뿐 아니라 알고리즘 변형, 청크 분할 방식, 결과 표현 형식도 고정해야 합니다. 아래에서는 클라우드 Mac에서 바로 실행할 수 있는 인수 테스트 예제를 Swift로 작성한 뒤, 같은 검증을 iOS 프로젝트에 적용합니다.

먼저 CRC-32 계산 규약 정하기

이 글에서는 CRC-32/ISO-HDLC를 사용합니다. 정방향 표현의 다항식은 0x04C11DB7이고, 반사 방식 구현에서는 0xEDB88320을 사용합니다. 초깃값과 최종 XOR 값은 모두 0xFFFFFFFF이며, 입력과 출력에는 반사 규약을 적용합니다. 인터페이스에 단순히 “CRC32”라고만 명시하면 양쪽에서 서로 다른 변형을 선택할 수 있습니다.

표준 문자열 123456789의 결과는 CBF43926, 빈 입력의 결과는 00000000이어야 합니다. 여기서는 문자열을 줄바꿈 없이 UTF-8 바이트로 변환합니다. 터미널에서 샘플을 만들 때는 일부 출력 명령이 줄바꿈을 자동으로 덧붙인다는 점에 주의해야 합니다.

CRC-32는 우발적인 데이터 손상을 감지하는 용도이며, 변조 방지를 보장하지 않습니다. 파일의 출처를 확인하려면 신뢰할 수 있는 디지털 서명을 검증해야 합니다. 파일 옆에 적힌 CRC 값만으로 보안상 신뢰성을 판단해서는 안 됩니다.

전송 규약에는 결과를 8자리 16진수 텍스트로 보낼지, 4바이트 정수로 보낼지도 명시해야 합니다. 정수로 보낸다면 바이트 순서도 별도로 정해야 합니다. 알고리즘의 “반사”는 네트워크 전송에 리틀 엔디언을 사용한다는 뜻이 아닙니다.

내부 상태를 유지하는 구현 작성하기

다음 코드를 crc-check.swift로 저장합니다. 매개변수를 쉽게 검토할 수 있도록 비트 단위로 처리하는 예제입니다. 정확성을 확인하는 기준 구현으로 적합하지만, 높은 처리량을 위한 구현은 아닙니다. 실제 서비스에서 조회 테이블 방식으로 최적화하더라도 뒤에 나오는 테스트는 그대로 재사용해야 합니다.

import Foundation

struct CRC32 {
    private var register: UInt32 = 0xFFFFFFFF

    mutating func update<S: Sequence>(_ bytes: S)
    where S.Element == UInt8 {
        for byte in bytes {
            register ^= UInt32(byte)
            for _ in 0..<8 {
                let lowBit = register & 1
                register >>= 1
                if lowBit != 0 {
                    register ^= 0xEDB88320
                }
            }
        }
    }

    var value: UInt32 {
        register ^ 0xFFFFFFFF
    }
}

func checksum(_ bytes: [UInt8]) -> UInt32 {
    var crc = CRC32()
    crc.update(bytes)
    return crc.value
}

func require(_ condition: Bool, _ name: String) {
    if !condition {
        fatalError(name)
    }
}

let vector = Array("123456789".utf8)
require(checksum(vector) == 0xCBF43926, "reference-vector")
require(checksum([]) == 0, "empty-input")

for split in 0...vector.count {
    var crc = CRC32()
    crc.update(vector[..<split])
    let interim = crc.value
    require(interim == checksum(Array(vector[..<split])),
            "intermediate-value")
    crc.update([])
    crc.update(vector[split...])
    require(crc.value == 0xCBF43926, "split-invariance")
}

value는 내부 레지스터 값을 읽기 전용으로 보여 주는 속성일 뿐입니다. 중간 결과를 읽어도 이후 계산에는 영향을 주지 않습니다. 이 속성 안에서 레지스터를 수정하거나, 최종 XOR을 적용한 결과를 다음 청크의 초기 상태로 사용해서는 안 됩니다. 잘못 사용하기 쉬운 초기화 인터페이스를 제공하는 것보다 파일마다 새 인스턴스를 만드는 편이 더 명확합니다.

고정 벡터에서 청크 분할 테스트로 확장하기

9바이트 벡터만으로 확인할 수 있는 동작은 제한적입니다. 이어서 가능한 모든 바이트 값을 포함하는 결정적 입력을 추가합니다. 이렇게 하면 최상위 비트가 설정된 바이트, 값이 0인 바이트, 정해진 크기를 채우지 못한 마지막 청크까지 테스트할 수 있습니다.

let payload = (0..<4097).map {
    UInt8(truncatingIfNeeded: $0)
}
let expected = checksum(payload)

for size in [1, 7, 64, 1024, 4096, 8192] {
    var crc = CRC32()
    var offset = 0
    while offset < payload.count {
        let end = min(offset + size, payload.count)
        crc.update(payload[offset..<end])
        offset = end
    }
    require(crc.value == expected, "chunk-size-\(size)")
}

var first = CRC32()
first.update(vector)
var second = CRC32()
second.update(payload)
require(first.value == 0xCBF43926, "independent-state")
require(second.value == expected, "second-stream")

이 테스트가 확인하는 것은 “동일한 구현이 청크 분할 방식과 관계없이 같은 결과를 내는가”입니다. 이것만으로 알고리즘의 정확성을 입증할 수는 없습니다. 따라서 표준 벡터 테스트도 반드시 유지해야 합니다. 실제 프로토콜에 연동할 때는 독립적인 구현으로 생성하고 결과를 대조한 바이너리 샘플도 추가해야 합니다.

서로 다른 두 입력의 CRC가 반드시 다르다고 단언해서는 안 됩니다. 체크섬의 값 범위는 유한하므로 충돌은 존재합니다. 임의로 만든 파일에서 충돌이 발생하지 않았다는 사실만으로 구현의 신뢰성을 입증할 수는 없습니다.

파일 읽기로 오류 경로 검증하기

같은 파일에 다음 읽기 함수를 이어 붙입니다. FileHandle로 청크를 순서대로 읽고, 매번 현재 데이터만 동일한 인스턴스에 전달하므로 파일 전체를 메모리에 올리지 않아도 됩니다.

func checksumFile(_ url: URL) throws -> UInt32 {
    let handle = try FileHandle(forReadingFrom: url)
    defer { try? handle.close() }

    var crc = CRC32()
    while true {
        let data = try handle.read(upToCount: 64 * 1024) ?? Data()
        if data.isEmpty {
            break
        }
        crc.update(data)
    }
    return crc.value
}

if CommandLine.arguments.count == 2 {
    let url = URL(fileURLWithPath: CommandLine.arguments[1])
    let result = try checksumFile(url)
    Swift.print(String(format: "%08X", result))
}

xcrun swift crc-check.swift를 실행하면 앞서 작성한 단언 검사가 실행됩니다. 읽을 수 있는 파일 경로를 인수로 추가하면 해당 파일의 체크섬도 출력합니다. 읽기 오류는 빈 파일로 잘못 처리되지 않고 호출 측으로 전파됩니다. 파일 열기 실패, 읽기 실패, 결과 불일치는 애플리케이션 로직에서 각각 구분해 기록해야 합니다.

읽을 파일은 쓰기가 끝난 안정적인 복사본이어야 합니다. 다운로드하면서 같은 경로의 파일을 계속 수정하는 동시에 별도로 열어 체크섬을 계산하면, 서로 다른 버전의 데이터가 섞여 읽힐 수 있습니다. 계산이 끝났다고 해서 이후에 쓰기가 발생하지 않았음을 보장할 수도 없습니다. 인수 테스트에서는 먼저 입력을 더 이상 변경되지 않도록 고정한 뒤 길이와 CRC를 비교해야 합니다.

프로젝트 연동 시 상태의 소유 관계 확인하기

구현을 단위 테스트에서 접근할 수 있는 모듈에 넣고, 표준 벡터, 청크 분할 불변성, 인스턴스 격리, 파일 오류 전파라는 네 가지 검사를 유지합니다. 파일 테스트에는 별도의 임시 디렉터리를 사용합니다. 빈 파일과 바이너리 파일을 각각 만들고, 테스트가 끝나면 정리합니다.

iOS UI는 진행 상황과 결과만 표시하고, 파일 읽기가 메인 스레드를 점유하지 않도록 합니다. 전송 작업마다 전용 CRC 인스턴스를 하나씩 사용해야 합니다. 같은 작업의 청크는 반드시 파일 순서대로 처리해야 하며, 동시에 실행되는 콜백이 레지스터를 직접 수정하게 해서는 안 됩니다.

작업이 도중에 취소되면 해당 인스턴스는 폐기합니다. 중단된 전송을 재개할 때 최종 체크섬만으로는 계산을 바로 이어 갈 수 없습니다. 이미 저장된 파일 앞부분을 다시 읽거나, 버전 제약을 갖춘 내부 상태 체크포인트를 설계해야 합니다. 후자의 경우 오프셋과 파일 식별 정보도 반드시 확인해야 하며, 정수 하나만 저장해서는 안 됩니다.

마지막으로 툴체인 버전, 샘플 길이, 청크 분할 방식, 실패한 단언 검사 이름을 테스트 산출물에 남깁니다. 그러면 다음에 노드를 바꾸거나 알고리즘을 최적화할 때 매개변수 변경, 청크 누락, 상태 혼용을 구분할 수 있습니다. 원인도 모른 채 파일을 반복 전송하며 운에 맡길 필요가 없습니다.

자주 묻는 질문

CRC-32로 파일의 악의적인 변조를 막을 수 있나요?

아니요. CRC-32는 우발적인 손상을 찾는 용도이며 공격자는 파일과 체크섬을 함께 바꿀 수 있습니다. 변조 방지에는 신뢰할 수 있는 디지털 서명이나 프로토콜에 맞는 키 기반 인증이 필요합니다.

각 청크의 CRC를 XOR하면 전체 파일의 CRC가 되나요?

아닙니다. 순서대로 읽을 때는 하나의 내부 상태를 계속 업데이트하고 마지막에 최종값을 구해야 합니다. 독립적인 청크 결과를 합치려면 청크 길이를 반영하는 별도 알고리즘이 필요합니다.

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

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

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

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