iOS 네트워크 상태 표시 검증: NWPathMonitor와 오프라인 판정의 경계

iOS 네트워크 상태 표시 검증: NWPathMonitor와 오프라인 판정의 경계

iOS 네트워크 상태 표시 검증: NWPathMonitor와 오프라인 판정의 경계

사용자가 새로 고침을 누르면 “네트워크를 사용할 수 없습니다”라는 안내가 뜨지만, 잠시 뒤 다시 시도하면 성공하는 경우가 있습니다. 흔한 원인은 요청 처리 계층의 오작동이 아니라, 앱이 아직 갱신되지 않은 네트워크 경로 상태를 요청 허용 조건으로 사용한다는 데 있습니다. 클라우드 Mac에서 iOS 앱을 개발한다면 먼저 결정적인 이벤트 시퀀스로 상태 모델을 검증하고, 시뮬레이터와 실기기에서는 시스템 동작을 확인하면 됩니다. 장애를 재현하려고 원격 연결을 반복해서 끊을 필요는 없습니다.

상태 안내와 요청 결과를 먼저 분리하기

NWPathMonitor는 모니터링 조건에 맞는 네트워크 경로를 관찰합니다. .satisfied는 서버가 정상적으로 응답한다는 보장도, 인증이 유효하다는 보장도 아닙니다. .requiresConnection은 연결을 먼저 수립해야 한다는 뜻이며, 이를 곧바로 지속적인 오프라인 상태로 해석해서는 안 됩니다.

앱 수준에서는 최소한 ‘알 수 없음’, ‘경로 사용 가능’, ‘연결 필요’, ‘경로 사용 불가’의 네 가지 상태를 유지해야 합니다. 앱을 처음 실행한 뒤 아직 콜백을 받지 않았다면 상태를 알 수 없음으로 표시하고, 기본값을 오프라인으로 두지 않습니다. API 요청 시간 초과, 인증 실패, 서버 오류는 각각 요청 결과 모델에서 처리해야 하며, 이 결과로 경로 상태를 덮어쓰면 안 됩니다.

네트워크 상태는 안내에 활용하기에 적합하지만, 모든 요청을 차단하는 전역 스위치로 쓰기에는 적합하지 않습니다. 사용자가 직접 재시도했다면 실제 요청으로 성공 여부를 확인해야 합니다.

이 방식은 네트워크 배너나 자동 재시도 안내가 필요한 앱에 적합합니다. 기능이 단일 요청을 보내는 데 그친다면 별도의 모니터링을 추가하지 않고 요청 결과를 직접 처리하는 편이 더 간단한 경우가 많습니다.

독립적으로 실행할 수 있는 상태 모델 만들기

원격 macOS의 명령줄에서 NetworkStateCheck.swift를 만들고 아래 내용을 전부 입력합니다. 이 모델은 실제 네트워크에 의존하지 않으며, 세대 번호로 서로 다른 모니터 인스턴스를 구분합니다. 예제는 직렬 호출을 전제로 하므로 여러 큐에서 동시에 상태를 변경하는 용도로 그대로 사용하면 안 됩니다.

enum PathState: Equatable {
    case unknown, available, needsConnection, unavailable
}

struct NetworkHint {
    private(set) var generation = 0
    private(set) var state: PathState = .unknown
    private(set) var expensive = false
    private(set) var constrained = false

    mutating func restart() -> Int {
        generation += 1
        state = .unknown
        expensive = false
        constrained = false
        return generation
    }

    mutating func accept(
        generation incoming: Int,
        state next: PathState,
        expensive: Bool = false,
        constrained: Bool = false
    ) {
        guard incoming == generation else { return }
        state = next
        self.expensive = expensive
        self.constrained = constrained
    }
}

var hint = NetworkHint()
precondition(hint.state == .unknown)

let old = hint.restart()
hint.accept(generation: old, state: .available, expensive: true)
precondition(hint.state == .available && hint.expensive)

let current = hint.restart()
hint.accept(generation: old, state: .unavailable)
precondition(hint.state == .unknown)

hint.accept(generation: current, state: .needsConnection)
precondition(hint.state == .needsConnection)

hint.accept(generation: current, state: .available, constrained: true)
precondition(hint.constrained && !hint.expensive)

hint.accept(generation: current, state: .unavailable)
precondition(hint.state == .unavailable)

hint.accept(generation: current, state: .available)
precondition(hint.state == .available && !hint.constrained)

xcrun swift NetworkStateCheck.swift를 실행합니다. 검사를 통과하면 아무것도 출력되지 않으며, 실패하면 0이 아닌 종료 상태로 끝납니다. 이 검사는 앱이 정의한 상태 동작 규약을 검증하는 것이지, 시스템 네트워크 스택의 정확성을 검증하는 것은 아닙니다.

모니터를 연결할 때 실행 순서 고정하기

실서비스용 어댑터에서는 Network를 가져오고 NWPathMonitor()를 생성합니다. 먼저 pathUpdateHandler를 설정한 다음 start(queue:)를 호출합니다. NWPath.Status의 세 가지 상태를 모델의 열거형에 대응시키고, isExpensive와 isConstrained도 함께 전달합니다. 향후 추가되는 알 수 없는 열거형 값은 알 수 없음 상태로 매핑합니다.

모니터를 생성할 때 현재 세대 번호를 캡처합니다. 콜백이 상태 모델에 반영되기 직전에 세대 번호를 다시 비교합니다. 모니터링을 다시 시작할 때는 먼저 세대 번호를 증가시켜 이전 이벤트를 무효화하고, 기존 인스턴스를 취소한 뒤 새 인스턴스를 생성합니다. 이미 취소한 인스턴스에 시작 메서드를 다시 호출해서는 안 됩니다.

모니터의 생명주기와 모델 업데이트는 동일한 직렬 실행 영역에서 관리해야 합니다. 메인 실행 영역을 사용한다면 콜백이 그 영역으로 전환된 뒤에 세대 번호를 검사합니다. 그렇지 않으면 대기 중이던 이전 이벤트가 새 상태를 오염시킬 수 있습니다. UI에 상태를 전달할 때도 이 순서를 유지해, 여러 비동기 전달 작업이 역순으로 상태를 덮어쓰지 않도록 해야 합니다.

기기에 네트워크 연결이 있는지 판단할 때 requiredInterfaceType: .wifi만 사용하지 마세요. 이 설정은 특정 인터페이스 조건에 해당하는 경로만 관찰하므로, 사용 가능한 다른 경로를 놓칠 수 있습니다. 기능 자체가 해당 인터페이스로 제한되어 있을 때만 이 초기화 방식을 사용합니다.

이벤트 매트릭스로 경계 조건 보완하기

스크립트를 프로젝트의 단위 테스트 타깃으로 옮기고, 각 테스트 케이스마다 독립적인 모델을 생성합니다. 최소한 다음 항목을 추가로 확인합니다.

  • 시작 후 콜백이 오지 않는 경우: 상태는 알 수 없음으로 유지되고, 요청은 계속 보낼 수 있어야 합니다.
  • 경로는 사용 가능하지만 API 요청이 실패한 경우: 요청 오류만 갱신하고, 경로가 끊겼다고 표시하지 않아야 합니다.
  • 사용할 수 없던 경로가 복구된 경우: 배너가 사라지며, 동일한 업무 동작을 중복 실행하지 않아야 합니다.
  • 취소 후 이전 이벤트가 도착한 경우: 상태와 비용 안내 모두 바뀌지 않아야 합니다.
  • 같은 이벤트가 반복해서 도착한 경우: 안내는 일관되게 유지되고, 재시도 작업이 누적되지 않아야 합니다.
  • 고비용 또는 제한 상태가 바뀐 경우: 업무 로직에서 허용하는 범위 안에서 다운로드 정책만 조정해야 합니다.

isExpensive는 특정 인터페이스 유형 하나를 뜻하지 않으며, isConstrained 역시 네트워크가 완전히 끊겼다는 뜻이 아닙니다. 대용량 파일 작업에는 URLSessionConfiguration의 고비용 및 제한 네트워크 정책을 함께 활용할 수 있습니다. 안내용 모델만을 근거로 사용자 작업을 아무런 알림 없이 폐기해서는 안 됩니다.

자동화 검증과 기기 검증의 범위 구분하기

시뮬레이터는 배너, 버튼, 상태 모델 연결을 확인하는 데 적합하지만, 네트워크 동작은 호스트의 영향을 받습니다. 시뮬레이터에서 안내가 바뀌었다고 해서 셀룰러 전환까지 검증했다고 판단하면 안 됩니다. 특히 테스트를 위해 클라우드 Mac의 네트워크 인터페이스를 꺼서 원격 데스크톱 연결이 끊기는 상황은 피해야 합니다.

실기기에서는 무선 네트워크 전환, 제한된 네트워크, 백그라운드에서 복귀하는 상황을 추가로 검사합니다. 각 상황에서 경로 이벤트의 순서, UI 변화, 요청 결과를 기록합니다. 테스트 서버가 연결을 거부하는 상황과 네트워크 경로를 사용할 수 없는 상황은 서로 다른 시나리오이므로 검증 자료도 별도로 남겨야 합니다.

마지막으로 상태 모델, 이벤트 테스트 케이스, 기기 점검 기록을 제출합니다. 합격 기준은 배너가 네트워크 상태를 언제나 즉시 맞히는 것이 아닙니다. 안내가 요청을 잘못 차단하지 않고, 이전 모니터가 새 세션을 덮어쓰지 않으며, 실패 후 사용자가 수행한 작업이 어떻게 처리되는지 명확해야 합니다.

자주 묻는 질문

satisfied이면 API에 반드시 접속할 수 있나요?

아닙니다. 모니터 조건에 맞는 경로가 있다는 뜻이며 서버, 인증, 응답 내용은 검증하지 않습니다. 실제 요청 결과로 성공 여부를 판단해야 합니다.

취소한 모니터의 이전 콜백은 어떻게 차단하나요?

같은 직렬 실행 영역에서 세대 번호를 먼저 올린 뒤 이전 모니터를 취소합니다. 콜백 생성 당시의 세대가 현재 세대와 일치할 때만 반영하고, 다시 시작할 때는 새 모니터를 만듭니다.

직접 작업해 보며 원격 Mac을 검증하세요.

Mac mini M4 전체를 단독으로 임대해 macOS 그래픽 인터페이스와 명령줄을 사용해 보세요. 연결과 작업 흐름을 먼저 검증하기에 적합합니다. 장시간 오프라인 작업이나 전문 하드웨어 연결이 필요하다면 직접 구매하는 방법도 비교해 보세요.

전용 Mac mini 임대

하루 먼저 임대하고, 구매 여부를 결정하세요.

연결감과 실제 작업을 함께 확인한 뒤, 자신에게 맞는 사용 기간을 선택하세요.