iOS WebSocket 하트비트와 연결 세대 격리 테스트

iOS WebSocket 하트비트와 연결 세대 격리 테스트

iOS WebSocket 하트비트와 연결 세대 격리 테스트

채팅 화면의 네트워크 연결은 이미 복구됐는데, 뒤늦게 도착한 종료 콜백 하나가 화면을 다시 “연결 중” 상태로 돌려놓을 수 있습니다. 이런 문제는 대개 재연결 시도 횟수가 부족해서가 아니라, 이전 연결이 여전히 새 세션의 상태를 바꿀 수 있어서 발생합니다. GNUMac 클라우드 Mac에서 iOS WebSocket 클라이언트를 검증할 때는 먼저 하트비트 기한과 연결 식별 로직을 네트워크 코드에서 분리한 뒤 실제 전송 테스트를 진행하세요. 네트워크를 반복해서 끊는 것보다 경계 조건을 재현하기 쉽습니다.

먼저 연결 식별 규칙과 시간 기준 정하기

전송 작업을 생성할 때마다 증가하는 세대 번호를 부여합니다. 연결 성공, Pong, 수신 실패, 종료 콜백에는 실행 시점의 “현재 번호”가 아니라 생성 시점에 캡처한 번호를 전달해야 합니다. 그렇지 않으면 이전 콜백을 새 연결의 이벤트로 잘못 처리할 수 있습니다.

시간은 시스템 날짜가 아닌 단조 시계를 기준으로 합니다. 프로덕션에서는 ContinuousClock으로 경과 시간을 계산하고, 테스트에서는 정수 밀리초 값을 직접 전달할 수 있습니다. 이 글에서는 다음과 같이 정책을 명확히 정합니다. 연결이 열린 뒤 한 주기를 기다렸다가 Ping을 보내고, Pong을 기다리는 동안에는 두 번째 Ping을 보내지 않습니다. 마감 시각까지 응답이 없으면 연결을 닫습니다.

이전 작업을 취소하는 것만으로는 격리가 보장되지 않습니다. 이미 큐에 들어간 콜백은 여전히 실행될 수 있으므로, 이벤트를 받는 쪽에서 연결 번호와 현재 단계를 확인해야 합니다.

이 모델은 단일 연결의 하트비트와 식별만 다룹니다. 재연결 백오프, 메시지 재전송, 애플리케이션 수준의 확인 응답은 포함하지 않습니다. 서로 다른 프로토콜을 한데 섞어 검증하기 어려운 대상으로 만들지 않기 위해서입니다.

네트워크에 의존하지 않는 상태 머신 작성하기

아래 내용을 heartbeat.swift로 저장합니다. tick은 실행해야 할 동작을 반환하고, 바깥쪽 어댑터가 실제 Ping 전송이나 전송 작업 취소를 담당합니다. 예제의 짧은 간격은 테스트용이며 프로덕션 권장값이 아닙니다.

struct Heartbeat {
    enum Phase {
        case idle, connecting, open
    }

    enum Action: Equatable {
        case ping(Int)
        case close(Int)
    }

    private(set) var generation = 0
    private(set) var phase = Phase.idle
    private var nextPing = 0
    private var deadline: Int?
    let interval: Int
    let timeout: Int

    mutating func connect() -> Int {
        generation += 1
        phase = .connecting
        deadline = nil
        return generation
    }

    mutating func opened(_ id: Int, now: Int) {
        guard id == generation, phase == .connecting else {
            return
        }
        phase = .open
        nextPing = now + interval
    }

    mutating func pong(_ id: Int, now: Int) {
        guard id == generation, phase == .open,
              let limit = deadline, now < limit else {
            return
        }
        deadline = nil
        nextPing = now + interval
    }

    mutating func tick(now: Int) -> Action? {
        guard phase == .open else { return nil }
        if let limit = deadline {
            guard now >= limit else { return nil }
            phase = .idle
            deadline = nil
            return .close(generation)
        }
        guard now >= nextPing else { return nil }
        deadline = now + timeout
        return .ping(generation)
    }
}

여기서는 마감 시각에 정확히 도착한 Pong도 늦은 응답으로 처리합니다. 같은 시각에 타이머와 응답 중 어느 쪽을 먼저 처리하더라도 타임아웃 판정이 달라지지 않도록 하기 위해서입니다. 실제 종료 동작은 다음 tick에서 발생하므로, 스케줄러는 계속해서 검사를 실행해야 합니다.

명시적인 시각으로 재연결 경계 테스트하기

이어서 다음 검증문을 추가하고, 기본 비최적화 모드로 xcrun swift heartbeat.swift를 실행합니다. 검증에 실패하면 프로세스는 0이 아닌 종료 코드로 끝나며, 모두 통과하면 아무 출력도 없습니다. 이 검사를 비활성화하는 빌드 설정은 사용하지 마세요.

var h = Heartbeat(interval: 100, timeout: 1000)
let first = h.connect()
h.opened(first, now: 0)

precondition(h.tick(now: 99) == nil)
precondition(h.tick(now: 100) == .ping(first))
precondition(h.tick(now: 1099) == nil)
h.pong(first, now: 1100)
precondition(h.tick(now: 1100) == .close(first))

let second = h.connect()
h.opened(first, now: 1200)
precondition(h.phase == .connecting)
h.opened(second, now: 2000)
precondition(h.tick(now: 2100) == .ping(second))

h.pong(first, now: 2150)
precondition(h.tick(now: 3100) == .close(second))

let third = h.connect()
h.opened(third, now: 4000)
precondition(h.tick(now: 4100) == .ping(third))
h.pong(third, now: 4200)
precondition(h.tick(now: 4299) == nil)
precondition(h.tick(now: 4300) == .ping(third))

두 번째 테스트 구간에서는 새 연결이 응답을 기다리는 동안 이전 연결의 Pong이 도착하도록 의도했습니다. 세대 번호 검사를 제거하면 이 Pong이 새 연결의 수명을 잘못 늘리게 됩니다. 연결 열림 이벤트의 중복 수신, 유휴 상태에서의 Pong 수신, 타임아웃 이후의 반복 검사도 테스트에 추가해, 장애 한 번에 종료 동작이 한 번만 생성되는지 확인해야 합니다.

전송 계층을 연결할 때 직렬 처리 경계 지키기

실제 클라이언트에서는 하나의 actor가 상태 머신을 소유하도록 구성할 수 있습니다. 타이머, 연결 콜백, 취소 요청은 모두 같은 격리 영역으로 보내고, 여러 큐가 상태를 직접 수정하지 못하게 해야 합니다. 동작에 포함된 번호도 확인해야 합니다. 동작을 실행하기 전에 새 연결이 이미 만들어졌다면, 이전 종료 동작이 현재 작업을 취소해서는 안 됩니다.

URLSessionWebSocketTask.sendPing을 사용할 때는 완료 콜백에 오류가 없는 경우에만 모델에 Pong 이벤트를 전달합니다. 오류가 있으면 세대 번호를 검사하는 장애 처리 경로로 넘깁니다. 애플리케이션 메시지 수신은 Pong 수신과 다르므로, 이를 전송 계층의 하트비트 대신으로 간주해서는 안 됩니다.

모델의 “응답 대기 중인 Ping은 하나뿐”이라는 전제를 유지하려면, 어댑터는 각 Ping에 대해 완료 이벤트를 한 번만 전달해야 합니다. 콜백이 중복 호출될 수 있는 래퍼로 교체한다면 Ping 요청 번호도 추가해야 합니다. 연결 세대 번호는 서로 다른 연결을 격리할 뿐, 같은 연결 안에서 주고받는 여러 하트비트를 구분하지는 못합니다.

마지막으로 실제 연결 검증 마무리하기

오프라인 검증을 통과한 뒤에는 제어 가능한 테스트 상대를 사용해 프로토콜 동작을 검증합니다. 최소한 정상 응답, 지연 응답, 즉시 종료, 연결 수립 실패의 네 가지 결과를 기록하고, 수신 루프가 이후 메시지도 계속 읽을 수 있는지 확인하세요. 로그에는 세대 번호, 이벤트 유형, 단조 시간, 상태 변화만 남기고 토큰이나 애플리케이션 메시지 본문은 기록하지 않습니다.

앱이 백그라운드로 들어가면 타이머 콜백이 계속 실행된다는 보장이 없습니다. 하트비트 모델을 백그라운드 연결 유지 기능으로 오해해서는 안 됩니다. 포그라운드로 돌아올 때는 먼저 연결 상태를 다시 평가한 뒤 기존 연결을 재사용할지 새로 만들지 결정합니다. 시뮬레이터로 이벤트 라우팅을 검증할 수는 있지만, 백그라운드 복귀와 네트워크 전환은 실제 기기에서도 테스트해야 합니다.

검증 완료의 기준은 “결국 연결됐다”가 아닙니다. 이전 이벤트가 새 세션을 수정할 수 없고, 타임아웃 경계가 명확하며, 모든 종료에 대응하는 이유가 있어야 합니다. 이 검증문들을 파이프라인에 추가하면, 이후 하트비트 간격을 조정할 때 사람이 다시 기다리며 레이스 컨디션을 찾아낼 필요가 없습니다.

자주 묻는 질문

하트비트 테스트에 실제 네트워크 단절이 필요한가요?

단위 테스트에는 필요하지 않습니다. 단조 시간을 주입해 마감 직전과 마감 시점을 검사하세요. 실제 Ping/Pong 교환, 취소, 네트워크 전환은 별도의 통합 테스트로 확인합니다.

이전 연결을 취소한 뒤에도 세대 번호를 확인해야 하나요?

취소해도 이미 실행 대기열에 들어간 콜백은 남을 수 있습니다. 연결을 만들 때마다 세대 번호를 올리고, 콜백을 반영하기 전에 번호와 현재 상태를 함께 검사해야 합니다.

워크플로에 맞는 전용 Mac mini를 구성하세요

프로젝트에 맞춰 모델, 노드, 대여 기간을 선택하세요. 주문 전에 메모리, 저장 공간, 연결 요구 사항을 확인하세요. 실제 이용 가능 여부는 콘솔에 실시간으로 표시됩니다.

먼저 워크플로를 정하고, 바로 시작하세요.

전용 물리 Mac mini를 이용하세요. 모델, 노드, 대여 기간을 선택한 다음 프로젝트에 필요한 저장 공간을 구성하면 됩니다.