클라우드 Mac에서 검증하는 iOS 지오펜스 거리와 히스테리시스

클라우드 Mac에서 검증하는 iOS 지오펜스 거리와 히스테리시스

같은 사용자가 매장 경계에 서 있는데 앱에는 ‘진입, 이탈, 진입’이 연달아 기록된다면, 위치 수집 주기부터 바꾸지 마세요. 거리 계산, 정확도 필터링, 상태 전환을 분리하고 클라우드 Mac에서 고정 샘플을 재생해야 비즈니스 규칙이 경계에서 흔들리는 것인지, 시스템 이벤트가 실제 위치와 맞지 않는 것인지 판단할 수 있습니다.

여기서 검증하는 대상은 앱 자체의 지오펜스 정책이며, 시스템의 영역 모니터링을 대체하려는 것이 아닙니다. 전체 과정에서 오프라인 좌표만 사용하므로 사용자 이동 경로를 수집하지 않으며, 원격 Mac의 실제 위치에도 의존하지 않습니다.

진입과 이탈의 판정 규칙부터 명확히 정의하기

이 예제에서는 중심으로부터 거리가 80미터 이하일 때 진입하고, 120미터 이상일 때 이탈하도록 정합니다. 그 사이 구간에서는 이전 상태를 유지합니다. 위치의 수평 정확도는 0~30미터 범위여야 하며, 이 범위를 벗어나면 해당 샘플로 상태를 갱신하지 않습니다. 이 수치는 테스트용 정책이지, 모든 제품에 적용할 공통 설정값이 아닙니다.

‘신뢰할 수 있는 샘플이 없음’과 ‘확실히 외부에 있음’도 구분해야 합니다. 첫 실행 시에는 알 수 없음 상태로 시작해야 하며, 화면에 값을 표시하려고 이탈 상태를 기본값으로 지정해서는 안 됩니다. 음수 정확도, 유한하지 않은 수, 허용 범위를 벗어난 좌표는 모두 거부해야 합니다.

수평 정확도는 측정 품질을 나타내는 정보이지, 사용자의 실제 위치 편차가 아닙니다. 거리에서 이 값을 그대로 뺀 뒤 사용자가 반드시 지오펜스 안에 있다고 판단하지 마세요.

독립 패키지에 거리 계산 고정하기

먼저 클라우드 Mac의 일반 작업 디렉터리에서 테스트 패키지를 만듭니다.

mkdir GeoPolicy
cd GeoPolicy
swift package init --type library

Sources/GeoPolicy/GeoPolicy.swift를 다음 구현으로 교체합니다. 구면 거리는 미터 단위로 계산하며, 각도는 먼저 라디안으로 변환합니다. 삼각함수는 날짜 변경선을 가로지르는 경도 차이도 올바르게 처리할 수 있으므로, 위도와 경도의 차이를 평면 거리처럼 직접 사용하지 마세요.

import Foundation

struct Point {
    let latitude: Double
    let longitude: Double

    var valid: Bool {
        latitude.isFinite && longitude.isFinite &&
        (-90...90).contains(latitude) &&
        (-180...180).contains(longitude)
    }
}

func meters(_ a: Point, _ b: Point) -> Double? {
    guard a.valid, b.valid else { return nil }
    let radians = Double.pi / 180
    let p1 = a.latitude * radians
    let p2 = b.latitude * radians
    let dp = (b.latitude - a.latitude) * radians
    let dl = (b.longitude - a.longitude) * radians
    let h = pow(sin(dp / 2), 2) +
        cos(p1) * cos(p2) * pow(sin(dl / 2), 2)
    return 6_371_000 * 2 * asin(sqrt(min(1, max(0, h))))
}

이는 구면 근사 계산으로, 이 글의 정책 테스트에는 적합하지만 측량 수준의 알고리즘은 아닙니다. 프로덕션 코드에서 시스템 거리 계산 API를 사용한다면 테스트에서도 같은 API를 호출해야 합니다. 이 보조 구현만 검증하고 실제 앱에서는 다른 공식을 실행하는 식으로 구성하지 마세요.

세 가지 상태와 이중 임계값으로 경계 흔들림 없애기

이어서 정책 함수를 추가합니다. 알 수 없음 상태에서 중간 구간의 샘플을 받으면 상태는 계속 알 수 없음으로 남습니다. 이미 내부 또는 외부 상태라면 중간 구간에서는 기존 값을 유지합니다. 유효하지 않은 측정값은 상태 전환을 일으키지 않아야 하며, 매장 방문 보상 같은 비즈니스 부수 효과도 발생시켜서는 안 됩니다.

enum FenceState: Equatable {
    case unknown, outside, inside
}

func nextState(
    previous: FenceState,
    distance: Double,
    accuracy: Double
) -> FenceState {
    guard distance.isFinite, distance >= 0,
          accuracy.isFinite, (0...30).contains(accuracy)
    else { return previous }

    if distance <= 80 { return .inside }
    if distance >= 120 { return .outside }
    return previous
}

실제 앱에 연결할 때는 먼저 샘플의 타임스탬프를 확인해야 합니다. 오래된 위치가 더 최신 위치를 덮어써서는 안 됩니다. 시간 검증은 어댑터 계층에 두고, 테스트에서는 현재 시각을 주입하세요. 순수 정책 로직에서 시스템 시계를 직접 읽지 마세요. API 계약상 정확도 0은 유효하지 않음을 나타내는 음수 값에 해당하지 않습니다. 이를 허용할지는 별도의 명확한 비즈니스 근거로 결정해야 합니다.

위치 수신을 기다리지 않고 경계 조건 테스트하기

Tests/GeoPolicyTests/GeoPolicyTests.swift를 다음 테스트로 교체합니다. 상태 경계 테스트에는 거리 값을 직접 사용해 좌표 변환 오차가 등호의 의미를 가리지 않도록 하고, 거리 함수는 별도로 검증합니다.

import XCTest
@testable import GeoPolicy

final class GeoPolicyTests: XCTestCase {
    func testDistanceAcrossDateLine() throws {
        let a = Point(latitude: 0, longitude: 179.999)
        let b = Point(latitude: 0, longitude: -179.999)
        let distance = try XCTUnwrap(meters(a, b))
        XCTAssertEqual(distance, 222.39, accuracy: 0.5)
        XCTAssertEqual(
            distance,
            try XCTUnwrap(meters(b, a)),
            accuracy: 0.001
        )
        XCTAssertEqual(try XCTUnwrap(meters(a, a)), 0)
        XCTAssertNil(meters(
            Point(latitude: 91, longitude: 0), b
        ))
    }

    func testHysteresisAndRejectedFixes() {
        var state = FenceState.outside
        let inputs = [79.0, 85, 110, 119.9, 120, 100, 80]
        let expected: [FenceState] = [
            .inside, .inside, .inside, .inside,
            .outside, .outside, .inside
        ]
        for (distance, result) in zip(inputs, expected) {
            state = nextState(
                previous: state, distance: distance, accuracy: 10
            )
            XCTAssertEqual(state, result)
        }
        for accuracy in [-1.0, 30.01, .nan, .infinity] {
            XCTAssertEqual(nextState(
                previous: .outside, distance: 20, accuracy: accuracy
            ), .outside)
        }
        XCTAssertEqual(nextState(
            previous: .unknown, distance: 100, accuracy: 10
        ), .unknown)
        XCTAssertEqual(nextState(
            previous: .outside, distance: 80, accuracy: 30
        ), .inside)
    }
}

swift test를 실행합니다. 여기서 222.39미터는 예제 좌표와 구면 공식으로 계산한 값이며, 노드 네트워크나 기기에서 실측한 데이터가 아닙니다. 이후에는 음수 거리, 무한대 거리, 극지방 좌표, 대척점 샘플을 추가하세요. 수치 오차는 명시적인 허용 오차로 표현하고, 부동소수점 값을 문자열로 비교하지 마세요.

규칙 테스트를 앱 파이프라인에 연결하기

먼저 순수 로직 테스트를 병합 전 검사에 포함한 다음, iOS 테스트 타깃에서 위치 어댑터 계층을 검증합니다. 오래된 샘플이 버려지는지, 위치 권한이 철회된 뒤 상태를 어떻게 표시하는지, 유효하지 않은 샘플은 거부 사유만 기록하는지 확인하세요. 비즈니스 알림은 유효한 상태 변화가 발생했을 때만 보내야 하며, 같은 내부 상태가 반복되어도 중복 집계해서는 안 됩니다.

샘플 일련번호, 거리, 정확도, 이전 상태, 새 상태, 거부 사유만 남기면 충분합니다. 일상적인 실패 보고서에 실제 좌표 전체를 저장할 필요는 없습니다. 고정 샘플은 정책 버전과 함께 커밋하고, 반경을 변경할 때는 예상 결과도 함께 검토하세요.

마지막으로 실제 기기에서 별도의 인수 테스트를 진행합니다. 시스템 지오펜스 콜백, 백그라운드 깨우기, 실제 위치 오차는 이 오프라인 테스트의 검증 범위에 포함되지 않습니다. 결과에는 ‘규칙 검증 통과’와 ‘기기 동작 검증 통과’를 구분해 기록하고, 시뮬레이터에서 한 번 실행한 것으로 두 검증을 대신하지 마세요.

자주 묻는 질문

진입과 이탈에 같은 거리 기준을 쓰면 안 되나요?

위치 오차로 표본이 경계 양쪽을 오가면 상태가 반복 전환됩니다. 진입 반경은 작게, 이탈 반경은 크게 두고 그 사이에서는 이전 상태를 유지하세요.

이 테스트가 실제 기기 검증을 대신하나요?

아닙니다. 거리와 업무 상태 로직만 검증합니다. 시스템 지오펜스 이벤트, 백그라운드 실행, 권한 변경과 실제 위치 품질은 기기에서 따로 확인해야 합니다.

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

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

클라우드 Mac 구성 선택

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

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