LODY/기록

센디 퀵 실시간 위치 추적: 끊겨도 안 끊긴 듯한 재연결

박정환박정환

들어가며

쿠팡이츠배달의민족에서 주문하면, 배달 기사를 배정받을 때까지 기다렸다가 지도에서 실시간으로 움직이는 걸 본 적이 있을 것이다. 센디 퀵도 겉보기 흐름은 비슷하다. 배달 기사 대신 차주가 붙고, 음식 대신 을 실어 나른다.

배달의민족 배달 현황 화면. 지도 위 라이더 위치와 배달 단계 스테퍼
배달의민족의 실시간 배달 추적 화면. 센디 퀵의 운송 추적도 겉보기 개념은 같다

화주가 퀵 오더를 넣으면 앱은 순서대로 이렇게 진행한다.

  1. 오더 등록: 짐 보낼 정보를 입력한다.
  2. 배차 대기: 차주 배차되기를 기다린다.
  3. 운송 추적: 배차된 차주 실시간 위치 정보를 지도에서 확인한다.

2번과 3번은 한 흐름처럼 보이지만 서버와 주고받는 데이터가 다르다. 배차 대기는 "아직인지, 배차됐는지"만 알면 되고 값도 자주 바뀌지 않는다. 운송 추적은 배차된 차주 위치가 초 단위로 움직여서 지도 마커가 따라가야 한다. 그래서 2번은 5초 폴링, 3번은 WebSocket 상시 연결로 나눴다. 통신 방식을 어디에 쓸지 정하고, 클라이언트가 기대하는 서버 이벤트(배차 상태, 위치 push, 끊김 종류)를 서버 팀과 맞춘 것도 이 작업에서 iOS 쪽이 주도했다.

그런데 이 상시 연결은 실제 환경에서 생각보다 자주 끊긴다. 이 글의 무게중심은 통신 방식 선택이 아니라, 끊겨도 화주가 끊긴 줄 모르게 만드는 데 있다. 끊김 대부분이 1~2초 안에 저절로 붙는다는 로그에서 출발해, 잠깐 끊김은 화면에 알리지 않고 조용히 재연결하는 2.5초 유예를 넣었다. 실제로 이 유예를 넣은 뒤로는 예기치 않게 끊긴 경우의 대부분이 화주가 배너를 보기 전에 다시 붙었다(뒤에서 수치와 함께 다룬다).

이 글은 배차 대기부터 위치를 받아 지도에 그리는 데까지, 실시간 추적 파이프라인 전반을 순서대로 따라간다.

  1. 배차 대기: WebSocket 없이 5초마다 배차 여부를 물어보고, 남은 대기 시간도 보여주는 화면
  2. 운송 추적: WebSocket으로 위치 push, 운송 단계와 연결 상태 분리
  3. 끊김과 재연결: 터널, 셀룰러 전환, 백그라운드에서 끊겨도 화주가 못 느끼게
  4. 경로 이탈: 차주가 계획 경로를 벗어나면 클라이언트에서 감지해 reroute
  5. 지도 렌더링: 위치가 너무 자주 와도 버벅이지 않게

3번 이후가 이 글의 대부분이다. 폴링, SSE, WebSocket 비교 자체는 실시간 통신 방식 정리에 뒀다.


1. 배차 대기: 상시 연결 없이 5초 폴링

배차 대기 화면은 차주가 배차됐는지만 알면 된다. 위치처럼 초 단위로 바뀌는 값이 아니고, ready, inProgress, accepted 같은 상태 몇 개 사이를 오갈 뿐이다. WebSocket으로 상시 연결을 열고 끊김까지 관리할 일이 아니다. 배차 상태 REST API도 이미 있었고, 5초마다 상태를 조회하는 폴링으로 충분했다.

5초마다 상태를 받아오는 것만으로 화면이 끝나지는 않았다. 화주에게는 얼마나 더 기다리면 배차가 끝나는지도 보여줘야 했다. 그래서 타이머를 두 개 뒀다. 하나는 5초마다 배차 상태 API를 호출하고, 다른 하나는 1초마다 남은 시간을 갱신한다. 둘 다 cancellable effect로 분리해, 상태가 바뀌거나 화면을 벗어나면 바로 취소한다. 남은 시간을 어떻게 계산했는지는 뒤에서 따로 다룬다.

다섯 가지 상태와 타이머 종료

배차 대기에서 다루는 상태는 다섯 가지로 구분해 뒀다. 5초마다 배차 상태 API를 호출해 그중 현재 값을 확인한다.

  • ready: 대기
  • inProgress: 배차 중
  • accepted: 배차 완료
  • failure: 실패
  • canceled: 취소

코드에서는 readyinProgress를 같은 루프로 본다. 이 구간에서는 5초마다 상태를 물어보면서 1초마다 남은 시간을 갱신하고, accepted, failure, canceled로 넘어가면 타이머를 끈다.

ready / inProgress5초 조회 + 1초 갱신accepted차주 배정3. 운송 추적failure배차 실패1회차재시도 UI2회차 이상재실패 UI오더 실패canceled취소ACCEPTED타이머 중단FAILURE1회차2회차+재배차포기CANCELED2. 배차 대기

상태별로 앱이 하는 일은 이렇다.

  • accepted: 타이머를 끄고 배차 완료로 처리한 뒤 운송 추적 화면으로 넘어간다
  • failure: 시도 횟수에 따라 화면을 바꾼다. 1회차면 재배차/포기, 그 이상이면 재실패 화면
  • canceled: 타이머를 끄고 화면을 닫는다

남은 시간, 벽시계 기준 재계산

여기에 판단이 하나 필요했다. 남은 대기 시간을 보여줄 때 흔한 방식은 1초마다 카운터에서 1씩 빼는 것이다. 그런데 이러면 앱이 백그라운드로 내려가거나 타이머 틱이 밀리는 순간마다 값이 실제 시간과 어긋난다.

그래서 카운터를 빼는 대신, 매 tick마다 서버가 내려준 마감 시각 기준으로 남은 시간을 다시 계산했다.

// 남은 시간 = 마감 시각까지 남은 초. 1씩 빼지 않고 매번 마감 시각 기준으로 다시 구한다.
func elapsed(until deadline: Date, total: Double) -> Double {
    let remaining = Calendar.current
        .dateComponents([.second], from: .now, to: deadline).second ?? 0
    return min(total - Double(remaining), total)
}

계산의 기준이 벽시계라, 틱을 몇 번 놓쳐도 다음 tick에서 저절로 맞는다. 백그라운드에 다녀와도 남은 시간 표시가 튀지 않는다. 배차 대기 화면은 상시 연결 없이 타이머 두 개로 충분했다.

문제는 그다음 화면이었다.


2. 운송 추적: WebSocket으로 위치 push

차주가 배차되면 배차된 차주 실시간 위치 정보를 지도에서 확인해야 한다. 여기서는 상황이 정반대다. 위치는 쉬지 않고 바뀌고, 마커가 그걸 따라가야 한다. 5초 폴링으로는 마커가 5초마다 뚝 건너뛴다. 간격을 줄이면 요청만 늘고 여전히 요청 시점의 스냅샷만 얻는다. 위치는 갱신될 때마다 서버가 push하는 방식으로 가기로 했다.

그래서 추적 화면은 WebSocket 상시 연결을 쓴다. 외부 라이브러리 없이 URLSessionWebSocketTask 위에 얹었다. 위치가 바뀔 때마다 서버가 메시지를 push하고, 추적 화면은 열린 연결로 수신한다. 추적 메시지 한 건에 아래 정보를 담아 다룬다.

  • 운송 단계: 상차지 이동, 상차, 하차지 이동, 하차, 완료
  • 남은 도착 시간(초)
  • 차주 현재 위치(lat / lon)
  • 차주 프로필(이름, 차량번호 등)
  • 상·하차 예정 시각

연결 상태와 운송 단계의 분리

여기서 운송 단계연결 상태를 둘로 나눴다. 나중 재연결 설계의 뼈대가 됐다. 운송이 어디까지 갔는지와 실시간 채널이 살아 있는지는 완전히 다른 축이다. 한 enum에 섞으면 이런 조합을 표현하지 못한다.

  • 운송은 진행 중인데 채널만 잠깐 끊김
  • 채널은 살아 있는데 운송은 이미 완료

초기에는 한 값으로 합쳐 썼는데, 운송이 끝난 뒤에도 재연결을 시도하거나 끊김을 완료로 오판하는 사례가 있어 둘로 나눴다. 나눈 뒤로는 완료와 끊김을 가르는 판정이 이 두 축만 보면 되도록 단순해졌다.

상차지 이동상차하차지 이동하차운송 완료연결 중연결됨재연결 중닫힘복구운송 단계연결 상태

운송 단계와 연결 상태는 각각 아래처럼 나눴다.

  • 운송 단계: 상차지 이동, 상차, 하차지 이동, 하차, 운송 완료
  • 연결 상태: 연결 중, 연결됨, 재연결 중, 닫힘 (운송 단계와 무관)

문제는 이 "계속 열어 두는 연결"을 현장에서 온전히 유지하기가 쉽지 않다는 데 있었다.


3. 끊기는 연결: 끊겨도 안 끊긴 듯한 재연결

WebSocket은 오래 유지하는 연결이라 네트워크가 흔들리면 그대로 끊긴다. 현장에서 자주 보이는 경우는 이런 것들이다.

  • 터널·지하 구간 진입
  • 엘리베이터 탑승
  • Wi-Fi와 셀룰러 전환
  • 앱을 잠깐 내렸다 다시 올림

끊기면 위치가 멈추고, 멈춘 지도를 보는 화주는 금방 불안해한다. 끊김 하나가 감지, 재시도, 유예, 완료 판정, 정리 중의 경합까지 여러 갈래로 번졌고, 아래는 그걸 하나씩 푼 기록이다.

끊김 감지 방법

당연해 보이지만 이게 첫 번째 함정이었다. URLSessionWebSocketTask에는 "연결이 끊겼다"를 알려주는 믿을 만한 단일 콜백이 없다.

  • didCloseWith: 서버가 정상 close 프레임을 보낼 때만 호출된다. delegate만 믿으면 부족하다
  • receive() throw: 네트워크 유실 등 실제 끊김은 여기서 잡는다

그래서 종료 감지를 delegate가 아니라 receive() throw로 통일했다.

while !Task.isCancelled {                       // task.state가 아니라 취소 여부로 돈다
    do {
        let message = try await task.receive()  // 끊기면 여기서 throw로 잡힌다
        // ... 메시지 처리
    } catch {
        let kind: CloseEventKind = task.closeCode == .invalid
            ? .clientUnexpected                 // close 프레임 없음 = 네트워크 유실
            : .serverInitiated                  // 서버가 닫음
        continuation.yield(.disconnected)
        break
    }
}

루프 조건을 task.state가 아니라 !Task.isCancelled로 둔 것도 의도적이다. task.state로 걸면 메시지 사이에 서버가 연결을 닫았을 때, 다음 루프 조건 검사에서 task.state가 완료로 바뀌어 catch(.disconnected를 올리는 곳)를 거치지 않고 루프가 조용히 빠져나간다. 그러면 끊김이 안 잡혀 재연결이 안 걸린다.

끊김 종류도 여기서 갈랐다.

  • closeCode == .invalid: close 프레임을 못 받음. 네트워크 유실
  • 실제 close 코드 있음: 서버가 연결을 닫음

이 구분을 Analytics close kind 태깅으로 이어갔다.

receive() throw는 서버 close와 네트워크 에러는 잡지만, close도 에러도 없이 "연결은 열려 있는데 아무것도 안 오는" 상태(half-open)까지 바로 잡지는 못한다. NAT 매핑이 만료되거나 중간 장비가 패킷을 조용히 버리면, 아무 트래픽이 없는 동안 양쪽 다 소켓이 죽은 걸 모른다.

이전 구현은 이 문제를 30초마다 프로토콜 ping을 보내 다뤘다. 죽어 가는 NAT 매핑을 살려 두고, 소켓이 이미 죽었다면 그 전송 시도가 실패하면서 대기 중이던 수신을 깨워 끊김을 빨리 드러내는 keepalive다.

// 이전 구현의 keepalive: 30초마다 ping을 보내 NAT 매핑을 살려 두고 죽은 소켓을 드러낸다
while !Task.isCancelled {
    try? await Task.sleep(nanoseconds: 30_000_000_000)
    task.sendPing { _ in }   // 완료 콜백은 쓰지 않는다
}

지금 채널은 종료 감지를 receive() throw 하나로 통일하면서 이 프로토콜 ping을 뺐다. 프로토콜 ping 없이도 버티는 근거는 위치 스트림 자체가 암묵적 keepalive라는 데 있다. 운송 중에는 서버가 위치를 1~2초 간격으로 내려주고, 그 수신에 대한 클라이언트 TCP ACK가 주기적 아웃바운드 트래픽이 되어 NAT 매핑을 살려 둔다. 그래서 NAT 만료로 생기는 전형적 half-open은 애초에 잘 생기지 않는다. 여기에 화면을 백그라운드에 내렸다 올리면 연결을 새로 맺어(뒤의 재진입 가드가 이 전환을 안전하게 처리한다) 남은 틈도 줄인다. 다만 포그라운드로 열린 채 스트림도 ACK도 멈춰 서버까지 완전히 조용한 순수 half-open은 receive()가 OS 타임아웃 전까지 안 깨는 약점이 남아, 이건 다음 과제로 뒀다. half-open은 인위로 만들기 까다로워서 기기를 잠깐 기내 모드에 넣었다 빼거나 Network Link Conditioner로 응답을 끊어 죽은 소켓을 만들어 재현했다.

재시도가 한꺼번에 몰리는 문제

끊기자마자 쉬지 않고 다시 붙으려 하면 평소엔 괜찮다가도 서버가 잠깐 재기동하는 순간 문제가 된다. 그 순간 끊긴 앱들이 동시에 재접속하면 겨우 살아나려던 서버가 다시 주저앉는다. 동시 접속이 몰리는 시간대에는 이런 재접속이 한꺼번에 쏠릴 여지가 작지 않다.

그래서 재시도 정책을 이렇게 잡았다.

  • backoff: 실패할수록 재시도 간격을 벌린다 (1초, 2초, 4초)
  • jitter: 기기마다 최대 20% 무작위 추가
  • 상한: 3회 시도 후 중단, 화주에게 알림
  • 리셋: 재연결 뒤 첫 위치 메시지를 받으면 시도 횟수 0

모두가 똑같이 1초, 2초, 4초에 맞추면 시도 시점이 오히려 뭉친다. 조금씩 어긋나게 흔들면 재접속이 시간 위에 흩어진다.

private static let backoffDelaysNs: [UInt64] = [1_000_000_000, 2_000_000_000, 4_000_000_000] // 1s/2s/4s
private static let maxReconnectAttempts = 3
 
let jitter = UInt64.random(in: 0...(delay / 5))     // 0…20%
try? await Task.sleep(nanoseconds: delay + jitter)
await openChannel()

무한정 시도하지도 않는다. 간격을 조금씩 어긋나게 흔든 뒤로는, 서버 재기동 직후 재접속이 한 시점에 뭉치던 그래프가 눈에 띄게 평평해졌다.

화주가 몰라도 되는 잠깐 끊김

로그를 보면 끊김의 상당수는 1~2초 안에 저절로 다시 붙는다. 터널을 잠깐 지나거나 네트워크가 순간 흔들린 경우다. 그런데 끊긴 즉시 "연결 끊김"을 띄우면 곧 다시 붙을 상황인데도 화주를 놀라게 하고, 띄웠다 1초 뒤 지우면 배너가 깜빡여 더 거슬린다.

그래서 끊겨도 곧바로 알리지 않는다. 2.5초 유예(grace period) 동안은 조용히 재연결만 시도한다.

  • 2.5초 안에 붙음: 배너 없이 계속 (화주는 끊김을 못 느낌)
  • 2.5초 넘겨도 실패: "연결 끊김" 배너 표시
  • 백그라운드: 화면을 안 보고 있으면 배너 억제
연결 끊김2.5초 유예(조용히 재연결)유예 안에붙었나?배너 없이 계속(화주 못 느낌)연결 끊김 배너아니오

완료와 끊김 구분

앞서 나눈 운송 단계연결 상태 두 축으로 완료와 끊김을 가른다. 운송이 정상적으로 끝나 서버가 연결을 닫은 것과 네트워크 문제로 끊긴 것을 구분해야 한다. 앞은 재연결하면 안 되고 뒤는 해야 한다. 그런데 서버 close 코드만으로는 이 둘을 가를 수 없었다.

그래서 연결이 닫히면 곧바로 재연결하지 않고, 운송 상태를 한 번 다시 조회해서 판정한다. 조회 결과가 운송 완료면 재연결하지 않는다. 그게 아니면 진짜 끊김으로 보고 재연결에 들어간다. 이 재조회를 넣기 전에는 운송이 끝났는데도 끊김으로 오해해 재연결을 거는 경우가 있었다. 재조회를 판정에 넣은 뒤로는 완료된 세션에서 헛도는 재연결 로그가 더 잡히지 않는다.

이건 서버가 완료와 오류를 구분해 닫지 않아 생긴 우회다. 근본적으로는 close 코드로 둘을 바로 가르는 게 맞아서, 클라이언트가 기대하는 close 코드 규칙을 정리해 서버 팀에 제안했고 표준화는 논의를 이어 가고 있다(다음 과제에서 다룬다).

private func handleDisconnect() async {
    let result = await repository.fetchTrackingSnapshot(orderId: orderId)
    guard !isSuspendedByUser, !Task.isCancelled else { return }
    if case .success(let info) = result, info.phase == .deliveryComplete {
        setConnectionState(.closed(.completed))   // 운송 완료 → 재연결 안 함
    } else {
        scheduleReconnect()                       // 진짜 끊김 → 재연결
    }
}

연결 상태 흐름은 이렇게 설계했다.

연결 중연결됨닫힘 →상태 재조회재연결 중(1초→2초→4초, ×3)닫힘(운송 완료)닫힘(재연결 실패)첫 위치 메시지연결 닫힘운송 완료그 외첫 메시지 (횟수 0)재시도3회 초과

정리하다 엉키는 문제 (actor 재진입)

가장 미묘한 경계는 마지막에 드러났다. 화면을 백그라운드로 내렸다 곧바로 다시 여는 짧은 순간, 이전 연결을 정리하는 작업과 새 연결을 여는 작업이 겹치면 정리됐어야 할 옛 연결이 유령처럼 남는다. 그러면 위치가 두 번 그려지거나 끊김 처리가 꼬인다.

연결을 actor로 감싸 한 곳에서만 소유하게 한 것까지는 자연스러웠다. 그런데 actor로 감쌌다고 안전한 게 아니었다. actor는 한 번에 한 작업만 실행하지만, await를 만날 때마다 actor를 잠깐 놓는다(재진입, reentrancy).

소켓을 여는 openChannel()은 화면 갱신을 위해 중간에 메인 액터로 잠깐 넘어갔다 온다. 바로 그 await 사이에 suspend()가 끼어들어 "이제 그만"이 켜질 수 있다. 열기 전에만 확인하면 이 창을 놓친다.

그래서 메인 액터로 넘어가는 hop 전과 후, 두 번 확인한다.

private func openChannel() async {
    guard !isSuspendedByUser else { return }              // (1) 열기 전
    let stream = await MainActor.run {                    // ← 여기서 actor가 잠깐 풀린다
        channel.events(orderId: orderId)                  //    (소켓이 열린다)
    }
    guard !isSuspendedByUser else {                       // (2) 열고 난 뒤 다시
        await MainActor.run { channel.disconnect(reason: .sessionEnded) }
        return                                            //    끼어든 suspend가 있었으면 방금 연 소켓을 즉시 닫는다
    }
    // ... 정상 경로: receive 루프 시작
}

hop을 아예 없애고 actor 안에서 소켓을 직접 열면 이 창이 사라지지만, 지도 SDK가 메인 스레드를 요구해 hop 자체는 피할 수 없었다. 그래서 hop을 없애는 대신 hop 전후를 막는 쪽을 택했다.

연결의 생명주기도 한 소유자로 모았다.

  • 종료 신호 단일화: receive() throw 하나만 사용. delegate close와 중복 처리 방지
  • 연결 전용 Task: receive 루프는 연결마다 분리. onTermination이 해당 Task·소켓만 정리
  • suspend(): 진행 중 재연결 Task와 늦게 오는 초기 API 응답까지 취소해 openChannel 재진입 차단

이런 재진입 창은 눈으로 잡기 어려워서, 백그라운드-포그라운드 전환을 아주 빠르게 반복하고 openChannel의 hop 지점에 suspend()를 끼워 재현했다. 가드를 넣은 뒤 같은 시나리오를 반복해도 좀비 연결이 남지 않았고, 백그라운드 전환이 잦은 기기에서 위치가 두 번 그려지거나 배너가 잘못 뜨던 증상도 함께 정리됐다.

측정: 끊김 종류별 태깅

이 개선이 실제로 먹혔는지는 연결 열림과 닫힘을 Firebase Analytics로 남겨 확인했다. "닫힘"을 세 종류로 태깅했다.

  • client_initiated: 앱이 의도적으로 닫음 (백그라운드, 화면 이탈)
  • server_initiated: 서버가 close 프레임 전송 (운송 완료 등)
  • client_unexpected: 예기치 않게 끊김 (네트워크 유실, 타임아웃)

여기에 session_duration_ms, close_code, close 사유를 함께 실었다. 같은 계측을 유예 도입 전부터 켜 둬서 전후를 같은 기준으로 볼 수 있었고, 재연결과 유예 효과는 이 중 client_unexpected 이벤트만 모아서 봤다.

정확한 대시보드 수치는 회사 데이터라 아래는 추정이되 규모 감만 밝힌다. 유예 도입 후 약 6주간 예기치 않은 끊김은 운송 세션 기준 수천 건 단위로 쌓였고, 그 표본에서 다음을 봤다.

  • 예기치 않게 끊긴 경우의 약 78%가 유예 시간(2.5초) 안에 다시 붙어 배너 없이 넘어갔다
  • 최종 재연결 성공률은 약 94% (유예 내 복구는 이 안에 포함된다)
  • 끊김 배너 노출이 운송 추적 세션당 평균 2회 남짓에서 1회 미만으로 줄었다. 이전 값도 같은 태깅으로 실측했다

배너가 뜬다는 건 화주가 멈춘 지도를 보고 불안해하는 순간이다. 이 빈도를 줄이는 게 이 작업의 실제 목표였고, 지표도 거기에 맞춰 봤다. 추적 화면은 퀵 오더가 배차될 때마다 반드시 열리는 핵심 경로라, 이 개선은 특정 실험군이 아니라 배차된 모든 운송 세션에 걸린다.


4. 경로 이탈: 클라이언트에서 실시간 감지

위치를 잘 수신하는 것과 별개로, 차주가 계획된 경로를 벗어났는지를 실시간으로 알아야 지도에 새 경로를 다시 그려 줄 수 있다. 서버에 매번 판정을 맡기기엔 너무 잦고, 위치가 들어올 때마다 전체 경로와 대조하면 무겁다. 그래서 이탈 판정을 클라이언트에 두고 아래처럼 가볍게 굴렸다.

핵심은 cross-track distance, 즉 차주 위치에서 계획 경로까지의 수직 거리다. 위경도를 기준점 중심의 평면으로 투영(equirectangular)해 미터로 계산한다. 도시 규모에서는 삼각함수를 매 점마다 돌리지 않아도 충분히 정확하다.

// 계획 경로의 한 구간(a→b)에 대한 차주 위치 q의 수직 거리
let t   = min(max((aq · ab) / (ab · ab), 0), 1)  // 구간 [0,1]로 클램프한 진행도
let proj = a + t * ab                             // 구간 위 최근접점
let xtd  = distance(q, proj)                       // 수직 거리(m)
if xtd > 50 { /* 이탈 → reroute */ }               // 임계값 50m (기본 140m를 운영에서 조정)

기본 임계값 140m는 고속도로 기준이라 도심 운송에서는 옆 골목만 들어가도 이탈을 놓쳤다. 실주행 로그를 보며 50m로 낮췄다.

무겁지 않게 만든 장치가 두 개다.

슬라이딩 윈도우. 전체 경로가 아니라 현재 구간 주변 [idx-2 … idx+5]만 검사한다. 진행도 t가 0.95를 넘으면 다음 구간으로 넘어간다.

  • 뒤로 2구간: GPS가 잠깐 튀어 직전 구간에 가까워 보일 여유
  • 앞으로 5구간: 신호가 끊겨 몇 구간을 건너뛴 경우 따라잡을 여유

O(전체)가 아니라 O(윈도우)라 위치가 아무리 자주 들어와도 부담이 없고, 멀리 있는 뒷 구간에 잘못 붙는 것도 막는다.

불필요한 판정 건너뛰기. 아래면 cross-track 계산 자체를 하지 않는다.

  • 대기 상태
  • 운송 구간이 아님
  • reroute 직후 10초 (cooldown)
  • reroute 진행 중
위치 도착대기·비운송·cooldown·reroute 중?윈도우 내 구간의cross-track 거리50m초과?새 경로 요청 → 다시 그림정상 주행건너뜀판정이탈경로 위

이탈로 판정하면 새 경로를 요청하고, 응답을 받으면 지도를 갈아 끼운 뒤 현재 구간을 0으로 리셋하고 10초 cooldown을 건다. 방금 받은 경로가 곧바로 다시 이탈로 잡히지 않게 하기 위해서다. 화면을 다시 열어 차주가 이미 경로 중간에 있을 때는, 첫 위치가 엉뚱한 이탈로 잡히지 않게 시작 시 한 번 전체 스캔으로 가장 가까운 구간을 잡아 둔다.

판정을 클라이언트에 둔 덕에 이탈은 서버 왕복을 기다리지 않고 그 자리에서 잡히고, cooldown과 윈도우가 GPS의 잠깐 튐을 걸러 준다. 멀쩡히 주행 중인데 경로를 헛되이 다시 그리는 불필요한 reroute는 관찰되지 않았다.


5. 너무 자주 오는 위치: 지도 렌더 최적화

연결이 살아 있는 동안엔 반대 문제가 있다. 서버 push가 촘촘할수록 좋지만, 수신한 값을 매번 그대로 지도에 반영하면 화면이 버벅인다. 밀린 값을 버리고 최신 하나만, 그것도 달라졌을 때만 그리는 방식은 지도 마커든 빠르게 갱신되는 수치든 고빈도 스트림을 UI에 반영할 때 똑같이 쓰인다. 대단한 장치를 새로 만들진 않았고, 아래처럼 좁은 최적화로 불필요한 그리기를 줄였다.

  • 최신 1건만 유지: 밀린 위치는 버리고 최신 하나만 처리
  • 변경 시에만 갱신: 직전 좌표·프로필과 같으면 지도 SDK 호출 생략
  • 정적 준비는 1회: 아이콘 세팅 등은 처음 한 번만
  • 마커 방향: 일정 거리 이상 움직였을 때만 갱신 (GPS 미세 흔들림 무시)
  • 남은 시간: 분이 바뀔 때만 갱신
위치 신호(자주 도착)최신 1건만밀린 값 버림직전 값과같으면 건너뜀지도 갱신(좌표만)아무것도 안 함달라짐같음

같은 좌표가 연달아 들어오는 신호(전체의 20% 안팎)에서는 지도를 아예 건드리지 않아, 확대나 이동 중 버벅임이 눈에 띄게 줄었다. 다만 마커는 여전히 수신한 좌표에서 다음 좌표로 딱딱 끊어 이동한다. 두 점 사이를 부드럽게 잇지는 않는다.


다음 과제

지금도 짧은 끊김은 거의 드러나지 않고 이탈도 실시간으로 잡지만, 남은 숙제가 있다.

  • 마커 보간: 지점에서 지점으로 툭 건너뛰는 대신, 두 좌표 사이를 미끄러지듯 움직이게
  • 순수 half-open 감지: 포그라운드로 열려 있는 채 서버까지 조용한 경우, receive()가 OS 타임아웃 전까지 안 깨는 구간을 앱 레벨 유휴 타이머로 좁히기
  • close 코드 협의: 완료와 끊김을 상태 재조회 없이 서버 close 코드로 바로 판정 (제안 후 서버 팀과 진행 중)