클라우드 Mac에서 iOS 부동소수점 경계값 테스트하기

클라우드 Mac에서 iOS 부동소수점 경계값 테스트하기

클라우드 Mac에서 iOS 부동소수점 경계값 테스트하기

진행률 표시줄이 간헐적으로 갱신되지 않거나 차트가 갑자기 비어 보인다면, 원인은 UI 스레드가 아니라 앞선 처리 단계에서 유입된 NaN일 수 있습니다. 센서 측정값을 측정 범위로 나누거나 완료량을 전체량으로 나누는 계산에서도 유효하지 않은 결과가 나올 수 있습니다. ArmShell 클라우드 Mac에서 이 계산을 먼저 독립적인 Swift 패키지로 분리하고, 경계값 테스트로 잘못된 값을 걸러낸 뒤 iOS 프로젝트에 연결할 수 있습니다.

허용 오차를 조정하기 전에 수치 계약부터 정의하기

이 글에서는 음이 아닌 측정값을 0부터 1 사이로 정규화하는 한 가지 명확한 요구 사항만 다룹니다. 입력은 유한해야 하고, 측정 범위는 0보다 커야 하며, 결과는 유한하면서 허용 범위를 벗어나지 않아야 합니다. 유효하지 않은 데이터는 자동으로 범위 안에 맞추지 않고 오류로 반환합니다. 측정값 0은 유효하며, 음의 영은 항상 양의 영으로 반환합니다.

놓치기 쉬운 조건이 하나 있습니다. value < 0 || value > 1로는 NaN을 걸러낼 수 없습니다. NaN에 대한 대소 비교는 모두 false를 반환하므로, 프로그램이 이를 유효한 결과로 취급한 채 계속 실행될 수 있습니다.

유한성, 업무상 허용 범위, 근사 동등성은 서로 다른 세 단계의 검사입니다. 테스트의 허용 오차를 늘려도 유효하지 않은 입력은 해결되지 않습니다.

오류 처리 방침도 미리 정해야 합니다. UI에 “데이터를 사용할 수 없음”이라고 표시할 수는 있지만, 실패를 아무런 표시 없이 0으로 바꾸면 안 됩니다. 그러면 사용자가 실제 측정값 0과 읽기 실패를 구분할 수 없기 때문입니다.

시뮬레이터 없이 실행할 테스트 패키지 만들기

먼저 명령줄 도구를 사용할 수 있는지 확인한 다음 임시 디렉터리에 패키지를 만듭니다. 다음 작업은 앱 프로젝트를 수정하지 않으며 그래픽 세션을 시작할 필요도 없습니다.

xcrun swift --version
root="$(mktemp -d)"
cd "$root"
swift package init --type library --name NumericBoundary

Sources/NumericBoundary/NumericBoundary.swift의 내용을 다음 코드로 교체합니다.

public enum LevelError: Error, Equatable {
    case invalidInput
    case invalidResult
}

public func normalizedLevel(
    _ raw: Double,
    scale: Double
) throws -> Double {
    guard raw.isFinite, scale.isFinite,
          raw >= 0, scale > 0 else {
        throw LevelError.invalidInput
    }

    let ratio = raw / scale
    guard ratio.isFinite, ratio >= 0, ratio <= 1 else {
        throw LevelError.invalidResult
    }

    return ratio == 0 ? 0.0 : ratio
}

입력만 검증해서는 충분하지 않습니다. 유한한 두 수를 나누어도 무한대가 나올 수 있습니다. 예를 들어 매우 큰 분자를 아주 작은 양의 분모로 나누는 경우가 그렇습니다. 유한한 결과라도 측정 범위를 벗어나면 실패해야 합니다. 계산 계층의 검증을 UI 컴포넌트에 맡겨서는 안 됩니다.

이 구현은 일반적인 부동소수점 반올림을 허용합니다. 요구 사항에 최소 유효 측정값까지 포함된다면 별도의 임계값을 정해야 합니다. 매우 작은 양수를 나누는 과정에서는 언더플로로 결과가 0이 될 수 있으며, 이 예제는 이를 허용합니다. “유한함”을 “정밀도 손실이 전혀 없음”과 혼동하지 않는 것입니다.

반례로 오류 경로 검증하기

기본 테스트 파일의 내용을 다음 코드로 교체합니다. 각 사례는 오류 종류까지 확인하므로, 함수가 다른 이유로 오류를 던졌을 때 테스트가 잘못 통과하는 일을 방지합니다.

import XCTest
@testable import NumericBoundary

final class NumericBoundaryTests: XCTestCase {
    func testInvalidInputs() {
        let cases: [(Double, Double)] = [
            (.nan, 1),
            (.infinity, 1),
            (-.infinity, 1),
            (1, .nan),
            (1, .infinity),
            (1, -.infinity),
            (-1, 1),
            (1, 0),
            (1, -0.0),
            (1, -1)
        ]

        for (raw, scale) in cases {
            XCTAssertThrowsError(
                try normalizedLevel(raw, scale: scale)
            ) { error in
                XCTAssertEqual(error as? LevelError, .invalidInput)
            }
        }
    }

    func testInvalidResults() {
        let cases: [(Double, Double)] = [
            (2, 1),
            (.greatestFiniteMagnitude, .leastNormalMagnitude)
        ]

        for (raw, scale) in cases {
            XCTAssertThrowsError(
                try normalizedLevel(raw, scale: scale)
            ) { error in
                XCTAssertEqual(error as? LevelError, .invalidResult)
            }
        }
    }

    func testValidBoundaries() throws {
        XCTAssertEqual(try normalizedLevel(0, scale: 1), 0)
        XCTAssertEqual(try normalizedLevel(1, scale: 1), 1)
        XCTAssertEqual(
            try normalizedLevel(1, scale: 10),
            0.1,
            accuracy: 1e-12
        )

        let zero = try normalizedLevel(-0.0, scale: 1)
        XCTAssertEqual(zero, 0)
        XCTAssertEqual(zero.sign, .plus)
    }
}

일반적인 동등 비교에서는 음의 영과 양의 영이 같다고 판단합니다. 따라서 정규화 방침이 실제로 적용되었음을 입증하려면 sign도 확인해야 합니다. 비정상 사례를 검증할 때 “결과가 NaN과 같지 않다”는 단언도 사용하지 마세요. NaN은 자기 자신과도 같지 않으므로, 이런 테스트로는 아무것도 입증할 수 없습니다.

허용 오차는 실제 값의 규모에 맞추기

이 예제의 출력 범위는 0부터 1로 고정되어 있으므로 절대 오차로 비교하는 방식이 직관적입니다. 1e-12는 이 작은 계산을 테스트하기 위해 선택한 값이지, 모든 센서·그래픽·통계 작업에 적용할 수 있는 보편적인 정밀도 기준이 아닙니다.

계산값의 크기 차이가 크다면 먼저 두 값이 모두 유한한지 확인한 뒤, 요구 사항에 맞게 절대 오차와 상대 오차를 조합합니다.

abs(actual - expected)
    <= max(absoluteTolerance,
           relativeTolerance * max(abs(actual), abs(expected)))

두 허용 오차 자체도 유한하고 음이 아니어야 합니다. 값의 규모가 극단적이면 두 값의 차이나 우변의 곱셈에서 오버플로가 발생할 수 있으므로, 이 수식을 그대로 범용 안전 라이브러리 구현으로 간주해서는 안 됩니다.

0 근처에서는 주로 절대 오차가, 큰 수에서는 주로 상대 오차가 비교를 좌우합니다. 테스트가 통과할 때까지 허용 오차를 계속 늘리는 대신, 장치의 분해능이나 알고리즘의 오차 예산처럼 임계값의 근거를 테스트 코드에 명시해야 합니다. 비교 전에 값을 소수 형식의 문자열로 변환하는 것도 피해야 합니다. 실제 차이가 가려질 수 있기 때문입니다.

파이프라인에 연결하고 테스트 관문이 작동하는지 확인하기

패키지 디렉터리에서 다음 명령을 실행합니다.

swift test
swift test -c release

디버그 구성과 릴리스 구성에서 모두 실행해야 합니다. 프로덕션 코드의 검증에는 최적화 빌드에서 제거될 수 있는 assert가 아니라 guard와 오류 던지기를 사용합니다. 파이프라인은 명령의 종료 코드를 보존하고, 테스트가 실패하면 이후 배포 단계를 중단해야 합니다.

이어서 의도적으로 코드를 망가뜨려 검증합니다. 입력 검사에서 raw.isFinite를 잠시 제거하고, 오류 종류가 달라져 NaN 사례가 실패하는지 확인한 다음 코드를 복원합니다. 이렇게 해야 테스트가 단순히 함수를 실행하는 데 그치지 않고 입력 검증을 실제로 확인한다는 사실을 입증할 수 있습니다.

앱에 연결할 때는 테스트 패키지에 복사본을 따로 유지하지 말고, 앱의 업무 로직이 동일한 구현을 호출하도록 해야 합니다. 도구 체인 버전, 커밋 식별자, 실패 사례를 기록하면 충분하며 실제 사용자 측정값을 업로드할 필요는 없습니다. 최종 통과 기준은 유효하지 않은 입력이 명확하게 실패하고, 유효한 경계값이 일관되게 통과하며, 허용 오차에 근거가 있고, 릴리스 구성에서도 같은 계약이 적용되는 것입니다.

자주 묻는 질문

영보다 작거나 일보다 큰지 확인하면 NaN도 걸러지나요?

아닙니다. NaN에서는 두 비교가 모두 false입니다. 먼저 isFinite를 확인하고 허용 범위를 검사해야 합니다.

입력이 모두 유한하면 결과 검사는 생략해도 되나요?

안 됩니다. 유한한 값끼리 나누어도 무한대가 될 수 있습니다. 결과의 isFinite와 업무상 허용 범위를 다시 확인해야 합니다.

다음 빌드를 위한 클라우드 Mac

작업 부하에 맞춰 모델과 이용 기간을 선택하세요. 가상 머신이 아닌 Apple Silicon 전용 물리 노드입니다.

클라우드 Mac 대여하기

읽기에서 첫 빌드까지

작업 부하를 준비한 뒤 적합한 전용 물리 노드를 선택하세요.