본문 바로가기
C.W.K.
Stream
Lesson 05 of 06 · published

WebSocket: 연결은 다시 잇고, 명령은 절대 다시 안 보내

~17 min · engine-at-home, websockets, reconnect, heartbeat, urlsession

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"죽은 소켓은 조용한 소켓이랑 똑같이 생겼어."

요청과 응답으론 모자랄 때

화면이 열려 있는 동안 내내 프레임이 양쪽으로 흘러야 하는 클라이언트가 있어. 살아 있는 세션에 붙는 터미널 앱의 폰 클라이언트, 코딩 엔진의 원격 게이트웨이, Pippa 백엔드로 이어지는 브리지들이 그래. 프로토콜은 URLSessionWebSocketTask가 맡아. send(.string(…))은 텍스트 프레임을, send(.data(…))는 바이너리 프레임을 보내. 파이썬 websockets 서버에 대고 보내보니 딱 그대로 도착했어. 이 차이는 받는 쪽에 중요해. 가족 파이썬 브리지는 텍스트 프레임에서 JSON을 읽고, 터미널 게이트웨이는 바이너리를 기대하거든. 그래서 공유 연결은 따로 지정하지 않으면 텍스트로 보내.

틀리기 쉬운 것 세 가지

공유 연결 타입은 재연결을 저마다 따로 구현했던 앱 세 개의 코드를 모아 만들었는데, 완성도가 셋 다 달랐어. 조사해보니 차이를 가른 건 이거였어.

  1. 세대. 연결을 내릴 때마다 새 토큰을 만들고, 비동기 콜백은 상태를 건드리기 전에 매번 그 토큰과 비교해. 이게 없으면 재연결 두 번 전에 취소한 소켓의 수신 콜백이 지난 프레임을 배달하거나, 실패를 보고해서 그 뒤에 들어온 새 소켓을 내려버릴 수 있어.
  2. 연결을 원하는 것, 앱이 활성인 것, 실제로 연결된 것은 다 달라. "사용자가 연결을 원했다", "앱이 앞에 있다", "소켓이 열려 있다"는 각각 다른 불리언 세 개야. 앱이 백그라운드로 가면 사용자가 원했다는 사실은 그대로 두고 소켓만 내려. 연결이 실패하면 앞의 두 값이 참일 때만 다시 시도하고.
  3. 죽은 소켓이 조용한 소켓처럼 보여. 레슨을 준비하며 세션 도중에 테스트 서버를 SIGSTOP으로 얼려봤어. 5초 뒤에도 클라이언트 작업은 .running이었어. 3초 마감을 건 핑은 침묵이라고 보고했고. 세 앱 중 하나는 하트비트가 아예 없어서, tailnet 링크가 끊겨도 뭔가 보내려고 하기 전까진 계속 "돌고 있음"으로 표시됐어.

연결은 다시 시도해도, 명령은 절대 재전송하지 않아

소켓이 끊기면 연결은 알아서 다시 이어. 처음엔 1초를 기다리고, 그 뒤로는 두 배씩 늘리다가 30초에서 멈추고, 기본값으로는 끝없이 시도해. 집에 있는 Mac이 그냥 자고 있는 거라면 기다려주는 게 맞으니까. 절대 하지 않는 건 전송 중이던 프레임을 다시 보내는 거야. 터미널로 보낸 명령은 끊기기 전에 이미 실행됐을 수도 있고, 다시 보내면 두 번 돌 수 있어. 그래서 잃어버린 요청을 다시 보낼지 버릴지는 앱이 정해. 같은 타입에서 나온 작은 규칙이 둘 더 있어. 목적지는 connect(to:)를 명시적으로 불러야만 확정돼. 그래서 설정에서 호스트 칸을 고쳐도, 살아 있는 연결이 다음에 재연결할 곳은 바뀌지 않아. 그리고 연결의 이벤트는 연결하기 전에 구독해. 연결 단계는 상태라서 새 구독자한테도 다시 전해주지만, 프레임은 그렇지 않아. 늦게 구독하면 처음 몇 개를 놓칠 수 있어.

Code

마감이 있는 핑, 그리고 재연결 지연·swift
import Foundation

enum PingOutcome: Equatable, Sendable {
    case alive
    case silent
    case failed(String)
}

/// Resumes exactly once: whichever of the pong and the deadline arrives first wins.
final class OnceGate: @unchecked Sendable {
    private let lock = NSLock()
    private var done = false
    func claim() -> Bool { lock.withLock { if done { return false }; done = true; return true } }
}

/// A dead socket looks exactly like a quiet one. Only a ping with a deadline tells them apart.
/// Nothing here waits on the pong handler being called, so a handler that never fires cannot hang it.
func ping(_ task: URLSessionWebSocketTask, within deadline: Duration) async -> PingOutcome {
    let gate = OnceGate()
    return await withCheckedContinuation { continuation in
        task.sendPing { error in
            guard gate.claim() else { return }
            continuation.resume(returning: error.map { .failed($0.localizedDescription) } ?? .alive)
        }
        Task {
            try? await Task.sleep(for: deadline)
            guard gate.claim() else { return }
            continuation.resume(returning: .silent)
            task.cancel(with: .goingAway, reason: nil)      // silence is the answer: tear down and reconnect
        }
    }
}

/// 1 s, 2 s, 4 s … capped at 30 s, with the exponent capped so the shift cannot overflow.
func reconnectDelay(afterFailures failures: Int) -> Duration {
    guard failures > 0 else { return .zero }
    let seconds = min(30, 1 << min(failures - 1, 5))
    return .seconds(seconds)
}
로컬 파이썬 websockets 서버에 대고 잰 결과·text
reply: string("echo text: {\"hello\":1}")      # .string arrived as a text frame
reply: string("echo binary: 3")               # .data arrived as a binary frame
ping while healthy: alive
state after 5 s of server silence: running    # the server process was frozen with SIGSTOP
ping while server frozen: silent
delays: [0.0, 1.0, 2.0, 4.0, 8.0, 16.0, 30.0, 30.0, 30.0] seconds

External links

Exercise

파이썬 websockets 패키지로 로컬 에코 서버를 띄우고, Swift 실행 파일로 연결해서 측정을 재현해. 텍스트 프레임 하나와 바이너리 프레임 하나를 보내고, 멀쩡할 때 핑을 날려봐. 이어서 서버에 kill -STOP을 걸고 작업 상태와 핑 결과를 적어. 그러고 나서 클라이언트에 세대 카운터를 넣어. 연결을 내릴 때마다 값을 올리고, 수신 루프에선 그 값을 붙잡아둬. 옛 세대의 콜백이 아무것도 바꾸지 않는다는 걸 테스트로 증명해.
Hint
kill -CONT로 얼린 서버를 풀면 재연결이 성공하는 걸 볼 수 있어. 세대 테스트엔 네트워크가 필요 없어. 수신 콜백을 세대와 프레임을 받는 함수로 만들고, 지난 세대 값으로 불러봐.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.