클라우드 Mac에서 검증하는 iOS SemVer 업데이트 판정

클라우드 Mac에서 검증하는 iOS SemVer 업데이트 판정

클라우드 Mac에서 검증하는 iOS SemVer 업데이트 판정

클라우드 Mac에서 자동화 테스트는 모두 통과했는데, 클라이언트는 1.10.0을 1.9.0보다 오래된 버전으로 판단합니다. 빌드 문제가 아니라 업데이트 여부를 판단할 때 문자열 정렬을 사용했기 때문입니다. 파싱 실패를 “업데이트 불필요”로 처리하는 문제도 흔합니다. 서버가 잘못된 형식의 버전을 내려보내면 업데이트 진입점이 아무런 알림 없이 사라집니다.

이 글에서는 버전 판정에 독립적인 테스트 경계를 설정합니다. 엄격하게 파싱하고, 우선순위를 비교한 다음, 마지막으로 업데이트 정책을 적용합니다. 모든 테스트 케이스는 로컬에서 실행되며, 네트워크나 서명용 리소스가 필요하지 않습니다.

비교할 수 있는 버전의 조건부터 정의하기

여기서 다루는 것은 SemVer를 따르는 서비스 버전입니다. 숫자 세 부분으로 구성되며, 사전 출시 식별자와 빌드 메타데이터를 붙일 수 있습니다. 예를 들면 2.4.0-rc.1+build.7입니다. 앱 배포 플랫폼의 버전 필드를 범용으로 검증하는 도구는 아니므로, CFBundleShortVersionString의 배포 규칙을 대신하는 용도로 바로 사용할 수는 없습니다.

핵심 버전의 숫자에는 앞자리 0을 허용하지 않습니다. 사전 출시 식별자가 숫자로만 이루어진 경우에도 같은 규칙을 적용합니다. 사전 출시 식별자와 메타데이터에는 ASCII 영문자, 숫자, 하이픈만 사용할 수 있으며, 점으로 구분한 각 항목은 비어 있으면 안 됩니다. 메타데이터의 숫자에는 앞자리 0을 허용하고, 메타데이터 자체는 우선순위 비교에 사용하지 않습니다.

“더 높은 버전”이라고 해서 “반드시 업데이트해야 하는 버전”인 것은 아닙니다. 최소 지원 버전, 단계적 배포 대상 범위, 테스트 채널, 배포 철회는 정책 계층의 책임이며 비교기에 넣어서는 안 됩니다.

먼저 원칙을 정합니다. 잘못된 입력은 0.0.0으로 대체하지 않고 파싱 실패로 반환합니다. 운영 환경의 입력 지점에서는 비정상 데이터가 불필요하게 리소스를 소모하지 않도록 문자열 길이도 제한해야 합니다. 예를 들어 최대 256바이트로 제한할 수 있습니다.

정수 변환 없이 비교기 구현하기

다음 코드를 테스트 타깃에서 접근할 수 있는 Swift 파일에 넣습니다. 숫자 부분은 길이를 먼저 비교하고, 길이가 같으면 ASCII 순서를 비교합니다. 따라서 매우 긴 숫자도 Int로 변환할 필요가 없습니다. 빌드 메타데이터는 유효성을 검사한 뒤 버리므로, 자동 합성되는 동등성 비교도 “우선순위가 같다”는 의미와 일치합니다.

struct SemVersion: Comparable {
    let core: [String]
    let prerelease: [String]

    private static func numeric(_ value: String) -> Bool {
        !value.isEmpty && value.utf8.allSatisfy { (48...57).contains($0) }
    }

    private static func validNumber(_ value: String) -> Bool {
        numeric(value) && (value == "0" || !value.hasPrefix("0"))
    }

    private static func validIdentifier(_ value: String) -> Bool {
        !value.isEmpty && value.utf8.allSatisfy {
            (48...57).contains($0) || (65...90).contains($0)
                || (97...122).contains($0) || $0 == 45
        }
    }

    private static func fields(_ value: String) -> [String] {
        value.split(separator: ".", omittingEmptySubsequences: false)
            .map(String.init)
    }

    init?(_ raw: String) {
        guard raw.utf8.count <= 256 else { return nil }
        let parts = raw.split(
            separator: "+", omittingEmptySubsequences: false
        ).map(String.init)
        guard parts.count <= 2 else { return nil }
        if parts.count == 2 {
            guard Self.fields(parts[1]).allSatisfy(Self.validIdentifier)
            else { return nil }
        }

        let version = parts[0].split(
            separator: "-", maxSplits: 1,
            omittingEmptySubsequences: false
        ).map(String.init)
        let core = Self.fields(version[0])
        guard core.count == 3, core.allSatisfy(Self.validNumber)
        else { return nil }

        let pre = version.count == 2 ? Self.fields(version[1]) : []
        guard pre.allSatisfy({
            Self.validIdentifier($0)
                && (!Self.numeric($0) || Self.validNumber($0))
        }) else { return nil }
        self.core = core
        self.prerelease = pre
    }

    private static func numberLess(_ lhs: String, _ rhs: String) -> Bool {
        lhs.count == rhs.count ? lhs < rhs : lhs.count < rhs.count
    }

    static func < (lhs: Self, rhs: Self) -> Bool {
        for (a, b) in zip(lhs.core, rhs.core) where a != b {
            return numberLess(a, b)
        }
        if lhs.prerelease.isEmpty { return false }
        if rhs.prerelease.isEmpty { return true }
        for (a, b) in zip(lhs.prerelease, rhs.prerelease) where a != b {
            let an = numeric(a)
            let bn = numeric(b)
            if an && bn { return numberLess(a, b) }
            if an != bn { return an }
            return a < b
        }
        return lhs.prerelease.count < rhs.prerelease.count
    }
}

이 구현은 공백을 자동으로 제거하지 않으며 v 접두사도 허용하지 않습니다. 엄격한 입력 경계에서는 프로토콜에 맞지 않는 값을 암묵적으로 보정해서는 안 됩니다. 기존 인터페이스에서 이런 표기를 허용한다면 별도의 호환 계층에서 변환하고 변환 규칙을 문서화해야 합니다. 그래야 클라이언트와 서버가 저마다 다른 기준을 추측하는 일을 막을 수 있습니다.

고정 테스트 케이스로 우선순위 보장하기

최소한 네 가지를 검증해야 합니다. 핵심 버전 숫자의 증가, 사전 출시 버전보다 높은 정식 버전의 우선순위, 비숫자 식별자보다 낮은 숫자 식별자의 우선순위, 그리고 앞부분이 같을 때 사전 출시 식별자 목록이 더 긴 버전의 높은 우선순위입니다. 다음 테스트는 단위 테스트 파일에 그대로 넣을 수 있습니다. 구현이 앱 모듈에 있다면 해당 모듈의 테스트용 import를 추가합니다.

import XCTest

final class SemVersionTests: XCTestCase {
    func testPrecedence() throws {
        let ordered = [
            "1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta",
            "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11",
            "1.0.0-rc.1", "1.0.0", "1.9.0", "1.10.0",
            "999999999999999999999999.0.0"
        ]
        for (a, b) in zip(ordered, ordered.dropFirst()) {
            let left = try XCTUnwrap(SemVersion(a))
            let right = try XCTUnwrap(SemVersion(b))
            XCTAssertLessThan(left, right)
            XCTAssertFalse(right < left)
        }
    }

    func testMetadataAndInvalidInput() throws {
        let first = try XCTUnwrap(SemVersion("1.2.3+001"))
        let second = try XCTUnwrap(SemVersion("1.2.3+build.9"))
        XCTAssertEqual(first, second)
        for raw in [
            "", "1.2", "01.2.3", "1.2.3-01", "1.2.3-",
            "1.2.3+", "1.2.3+a..b", "1.2.3+a+b",
            " 1.2.3", "1.2.3\n", "1.2.3"
        ] {
            XCTAssertNil(SemVersion(raw), raw)
        }
    }
}

서로 인접한 버전만 테스트해서는 안 됩니다. 임의의 세 버전에 대한 추이성도 추가로 검증해야 합니다. A가 B보다 작고 B가 C보다 작으면 A는 반드시 C보다 작아야 합니다. 또한 어떤 버전도 자기 자신보다 작지 않은지, 우선순위가 같은 두 입력이 서로보다 작다고 판정되지 않는지도 확인합니다. 이런 속성 검증은 개별 업데이트 사례보다 정렬 로직의 결함을 찾아내기 쉽습니다.

비교 결과를 정책 계층에 연결하기

파싱 실패는 업데이트 불필요가 아니다

비즈니스 로직의 인터페이스는 비교 가능, 현재 버전이 잘못됨, 원격 버전이 잘못됨이라는 세 가지 결과를 명시적으로 반환하는 편이 좋습니다. 원격 데이터에 오류가 있으면 현재 앱의 기능을 유지하면서 검색 가능한 오류 이벤트를 남겨야 합니다. 이를 “이미 최신 버전입니다”로 바꾸어 처리해서는 안 됩니다. 강제 업데이트 정책에서 최소 버전이 유효하지 않다면, 즉석에서 판단하지 말고 미리 정의한 장애 처리 분기로 보내야 합니다.

테스트 채널도 별도로 판단해야 합니다. 정식 채널에서는 2.0.0-beta.1이 1.9.0보다 높다는 이유만으로 사전 출시 버전 설치를 안내해서는 안 됩니다. 먼저 허용된 채널을 걸러내고, 그다음 버전을 비교한 뒤, 마지막으로 단계적 배포와 최소 지원 버전 규칙을 적용합니다.

동일한 버전을 다시 패키징할 때 메타데이터를 추적 용도로 사용할 수는 있지만, 이를 이용해 SemVer 순서를 바꿀 수는 없습니다. 이런 빌드 산출물도 업데이트해야 한다면 별도의 릴리스 일련번호를 저장하고, 그 단조 증가성을 테스트하며, 채널 간 비교를 허용할지도 명확하게 정해야 합니다.

클라우드 노드에 재검토 가능한 검증 결과 남기기

기존 공유 Scheme의 단위 테스트 실행 경로에 이 테스트들을 추가하고, 프로젝트에서 고정해 둔 시뮬레이터 실행 대상을 그대로 사용합니다. 소스 코드 커밋, 툴체인 버전, 테스트 결과, 실패한 입력을 기록합니다. 운영 중인 업데이트 인터페이스에 연결할 필요는 없습니다. 이렇게 하면 같은 커밋에 대해 로컬과 클라우드에서 동일한 계약을 검증할 수 있습니다.

병합 전에 확인할 사항은 다음과 같습니다. 유효한 테스트 입력은 모두 파싱되어야 합니다. 잘못된 입력은 어떤 것도 보정 후 허용되어서는 안 됩니다. 메타데이터는 우선순위에 영향을 주지 않아야 합니다. 매우 긴 숫자에서도 오버플로가 발생하지 않아야 합니다. 파싱 오류를 “업데이트 불필요”로 표시해서는 안 되며, 정식 채널에서 사전 출시 버전을 잘못 선택해서도 안 됩니다.

마지막으로 구현을 일부러 잘못 바꿔 테스트 자체를 검증합니다. 숫자 비교를 사전식 비교로 바꾸고, 메타데이터를 정렬에 반영하고, 앞자리 0을 허용해 봅니다. 각 변경은 반드시 해당 테스트를 실패하게 만들어야 합니다. 이런 오류를 잡아내야 검증 게이트가 업데이트 진입점을 실제로 지킨다고 할 수 있습니다. 테스트 화면에 초록색 항목 하나를 더하는 것만으로는 충분하지 않습니다.

자주 묻는 질문

문자열 정렬로 업데이트 여부를 결정해도 되나요?

안 됩니다. 문자열 정렬은 1.10.0과 1.9.0의 순서를 잘못 판단할 수 있고 사전 출시 규칙도 반영하지 못합니다. 파싱 후 구성 요소별로 비교해야 합니다.

빌드 메타데이터가 달라지면 새 버전인가요?

SemVer 우선순위에서는 더하기 기호 뒤의 메타데이터를 무시합니다. 같은 버전의 재빌드를 구분하려면 별도의 배포 번호와 업데이트 정책이 필요합니다.

빌드 전용 클라우드 Mac을 사용해 보세요

물리 머신 한 대를 단독으로 사용하며, 가상 머신이 아닙니다. 빌드 규모에 맞춰 기종과 이용 기간을 선택하고, 아카이브·코드 서명·캐시를 나만의 노드에 보관하세요.

클라우드 Mac 구성 선택

다음 아카이브는 전용 클라우드 Mac에서 빌드하세요

빌드 규모에 맞는 머신을 고르고, 팀 위치에 맞는 노드를 선택하세요. 구성을 확인한 뒤 툴체인 이전을 시작하면 됩니다.