클라우드 Mac에서 iOS UTF-8 증분 디코딩 검증하기

클라우드 Mac에서 iOS UTF-8 증분 디코딩 검증하기

같은 응답을 로컬에서는 정상적으로 읽는데 클라우드 Mac의 파이프라인에서는 간혹 글자가 깨진다면, 먼저 디코딩 경계를 확인해야 합니다. 네트워크가 전달하는 것은 완성된 문자가 아니라 바이트 청크입니다. ‘中’을 구성하는 3바이트가 세 번에 나뉘어 도착할 수도 있습니다. 청크마다 문자열로 변환하면 매번 오류가 나지 않더라도 올바른 텍스트가 대체 문자로 바뀔 수 있습니다. 여기서는 실제 네트워크에서 청크가 우연히 어떻게 나뉘는지에 의존하지 않고, 독립 실행형 Swift 스크립트로 디코더의 계약을 검증합니다.

먼저 바이트 스트림 계약 정의하기

입력은 UTF-8로 한정하고, 출력에는 디코딩이 완료된 유니코드 스칼라 값만 포함합니다. 빈 청크는 상태를 바꾸지 않으며, 미완성 시퀀스는 다음 청크까지 보관하고 종료 시에도 남아 있으면 실패로 처리합니다. ‘지금 출력할 텍스트가 없다’는 것을 연결이 닫혔다는 뜻으로 해석해서는 안 됩니다.

여기서는 잘못된 입력을 엄격하게 거부합니다. 단독 연속 바이트, 과장 인코딩, 서로게이트 영역 인코딩, 유니코드 상한을 넘는 시퀀스는 모두 허용하지 않습니다. 잘못된 부분을 대체 문자로 바꾸는 방식은 일부 표시 용도에는 적합하지만, 프로토콜 필드나 설정, 영구 저장할 레코드에 아무런 고지 없이 적용해서는 안 됩니다.

네트워크 청크 경계, 문자 경계, 애플리케이션 메시지 경계는 서로 다릅니다. UTF-8 디코딩에 성공했다고 해서 한 줄이나 메시지 하나를 끝까지 받았다는 뜻은 아닙니다.

프로토콜이 다른 인코딩을 명시한다면 입력 단계에서 거부하거나 명시적인 변환 계층으로 넘겨야 합니다. 여러 인코딩을 차례로 시도하며 추측해서는 안 됩니다. 이 글의 구현은 바이트 순서 표식도 자동으로 제거하지 않습니다. 허용 여부는 상위 계층에서 결정합니다.

보관 상태에 상한이 있는 증분 디코더 구현하기

다음 코드를 utf8-contract.swift로 저장합니다. 현재 완성되지 않은 문자만 보관하므로 호출 간에 남는 데이터는 최대 3바이트입니다. 다만 각 호출의 출력량은 여전히 입력 청크 크기에 따라 달라집니다. 오류가 발생하면 해당 인스턴스를 폐기하고 더 이상 데이터를 전달하지 마세요.

import Foundation

enum DecodeFailure: Error {
    case invalidByte
    case truncated
}

struct StrictUTF8 {
    private var pending: [UInt8] = []
    private var expected = 0

    mutating func push(_ bytes: [UInt8]) throws -> String {
        var output: [UInt8] = []

        for byte in bytes {
            if pending.isEmpty {
                switch byte {
                case 0x00...0x7F:
                    output.append(byte)
                    continue
                case 0xC2...0xDF:
                    expected = 2
                case 0xE0...0xEF:
                    expected = 3
                case 0xF0...0xF4:
                    expected = 4
                default:
                    throw DecodeFailure.invalidByte
                }
                pending.append(byte)
                continue
            }

            var allowed: ClosedRange<UInt8> = 0x80...0xBF
            if pending.count == 1 {
                switch pending[0] {
                case 0xE0: allowed = 0xA0...0xBF
                case 0xED: allowed = 0x80...0x9F
                case 0xF0: allowed = 0x90...0xBF
                case 0xF4: allowed = 0x80...0x8F
                default: break
                }
            }

            guard allowed.contains(byte) else {
                throw DecodeFailure.invalidByte
            }
            pending.append(byte)

            if pending.count == expected {
                output.append(contentsOf: pending)
                pending.removeAll(keepingCapacity: true)
                expected = 0
            }
        }

        guard let text = String(bytes: output, encoding: .utf8) else {
            throw DecodeFailure.invalidByte
        }
        return text
    }

    func finish() throws {
        guard pending.isEmpty else {
            throw DecodeFailure.truncated
        }
    }
}

첫 바이트로 길이를 결정하고, 두 번째 바이트에서 허용 범위를 좁힌 뒤, 나머지 바이트가 연속 바이트 범위에 속하는지 확인합니다. 따라서 문자열 변환 단계까지 기다리지 않고도 서로게이트 영역이나 상한을 벗어난 인코딩을 감지할 수 있습니다. 이 구현에는 자동 복구 로직이 없으며, 잘못된 바이트 뒤에 나오는 ASCII를 신뢰할 수 있는 새 메시지로 취급하지도 않습니다.

요청을 반복하는 대신 모든 분할 조합 검사하기

이어서 다음 테스트를 추가합니다. 이 짧은 샘플에서는 인접한 바이트 사이마다 ‘나누기 또는 나누지 않기’의 모든 조합을 열거합니다. 두 청크로 나눈 경우를 한 번만 확인하는 것보다 더 빠짐없이 검사할 수 있습니다. 빈 청크도 삽입해 보관 중인 상태가 지워지지 않는지 확인합니다.

let sample = "A中🙂e\u{301}"
let bytes = Array(sample.utf8)

for mask in 0..<(1 << (bytes.count - 1)) {
    var decoder = StrictUTF8()
    var chunk: [UInt8] = []
    var result = ""

    for index in bytes.indices {
        chunk.append(bytes[index])
        let cut = index == bytes.count - 1
            || (mask & (1 << index)) != 0
        if cut {
            result += try decoder.push(chunk)
            result += try decoder.push([])
            chunk.removeAll(keepingCapacity: true)
        }
    }

    try decoder.finish()
    precondition(Array(result.utf8) == bytes)
}

여기서는 UTF-8 바이트를 비교합니다. 정규 등가성을 고려하는 문자열 비교가 내용 변화를 가리는 일을 막기 위해서입니다. 결합 악센트는 완성된 스칼라 값이 반드시 사용자가 보는 완성된 문자 하나를 뜻하지는 않는다는 점도 보여 줍니다. 디코더는 알파벳을 먼저 출력하고 결합 부호를 나중에 출력할 수 있습니다. UI에서 자소 클러스터 단위로 처리해야 한다면 더 상위 계층에서 다뤄야 합니다.

전체 조합 수는 샘플 길이에 따라 지수적으로 증가하므로, 이 방식은 짧고 고정된 테스트 벡터에만 사용합니다. 긴 텍스트는 고정된 난수 시드로 분할을 생성하고, 실패했을 때의 청크 길이 배열을 저장합니다.

거부 경로도 단언문으로 검증하기

올바른 텍스트가 통과한다는 사실만으로는 절반만 검증한 셈입니다. 다음 벡터를 추가해 단독 연속 바이트, 과장 인코딩, 서로게이트 영역, 상한 초과, 잘못된 연속 바이트, 끝부분 잘림을 각각 확인합니다. 모든 입력은 1바이트씩 전달해 청크 분할 방식으로 오류 검사를 우회할 수 없는지 검증합니다.

let invalid: [[UInt8]] = [
    [0x80],
    [0xC0, 0xAF],
    [0xED, 0xA0, 0x80],
    [0xF4, 0x90, 0x80, 0x80],
    [0xE4, 0x41],
    [0xF0, 0x9F, 0x99]
]

for input in invalid {
    var decoder = StrictUTF8()
    var rejected = false

    do {
        for byte in input {
            _ = try decoder.push([byte])
        }
        try decoder.finish()
    } catch {
        rejected = true
    }

    precondition(rejected)
}

유효 범위의 경계값도 추가해야 합니다. 예를 들어 가장 큰 유효 스칼라 값에 해당하는 4바이트 시퀀스가 있습니다. 문자열에 대체 문자가 있는지만을 유일한 판단 기준으로 삼아서는 안 됩니다. 입력 자체에 그 문자가 올바르게 포함되어 있을 수 있기 때문입니다. 엄격한 디코딩은 원본 바이트를 기준으로 판단해야 합니다.

파이프라인에 통합하고 오류 경계 유지하기

선택한 Xcode 명령줄 도구 환경에서 실행합니다.

xcrun swift utf8-contract.swift

정상 종료하면 통과입니다. 오류가 발생하거나 단언문이 실패하면 반드시 후속 작업을 중단해야 합니다. 정식으로 통합할 때는 디코더를 공유 소스 코드에 넣고 테스트 벡터를 테스트 타깃으로 옮깁니다. 독립 실행형 스크립트는 알고리즘을 먼저 검증하는 데 적합하지만, 실제 iOS 타깃의 컴파일과 실행을 대신할 수는 없습니다.

연결마다 별도의 인스턴스를 두고 수신 순서대로 직렬 호출합니다. 재연결할 때는 새 인스턴스를 생성해야 하며, 이전 연결에서 남은 문자의 일부를 새 응답 앞에 이어 붙여서는 안 됩니다. 취소할 때도 상태를 폐기하고 ‘정상 종료’ 경로는 호출하지 않습니다.

마지막으로 출력을 소비하는 쪽도 확인합니다. 청크별 출력은 임시 버퍼에만 넣고, 애플리케이션 메시지 하나가 완성되고 검증까지 통과한 뒤에 영구 저장하거나 동작을 실행해야 합니다. 앞부분을 이미 데이터베이스에 저장한 뒤 뒷부분에서 잘못된 인코딩을 발견하더라도, 엄격한 디코딩만으로는 부수 효과를 자동으로 되돌릴 수 없습니다. 오류 기록에는 바이트 오프셋, 청크 길이, 오류 종류만 남기면 충분하며, 기본적으로 응답 전체를 업로드하는 것은 피해야 합니다. 이렇게 하면 단순히 ‘글자가 깨지지 않는다’는 것뿐 아니라, 청크 분할이 달라져도 결과가 같고 오류를 정상 전달로 처리하지 않는다는 것까지 검증할 수 있습니다.

자주 묻는 질문

수신한 Data 블록을 각각 문자열로 바꿔도 되나요?

안전하지 않습니다. UTF-8 문자 중간에서 블록이 나뉠 수 있습니다. 엄격한 변환은 실패하고 관대한 변환은 대체 문자를 넣을 수 있으므로 미완성 바이트를 보관해야 합니다.

스트림 종료 때 남은 바이트는 무시해도 되나요?

완전한 UTF-8 텍스트가 필요하다면 무시하면 안 됩니다. 잘린 문자로 판단해 종료 검증을 실패시켜야 합니다. 복구 동작은 별도 정책으로 명시해야 합니다.

팀 전용 빌드를 실행하는 Mac

Apple Silicon M4 전용 물리 Mac mini를 선택하고 팀의 툴체인과 runner 설정을 그대로 유지하세요. 일·주·월·분기 단위로 이용할 수 있으며, 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.

빌드 노드 대여하기

빌드 요구 사항에 맞는 구성 선택

먼저 메모리와 저장 공간을 확인한 다음, 요금제와 노드를 선택하세요.