차주 위치를 실시간으로 공유하는 운송 추적

안녕하세요. 센디 플랫폼 스쿼드에서 iOS 앱을 개발하는 박정환(Lody)입니다.

쿠팡이츠나 배달의민족에서 주문하면, 배달 기사를 배정받을 때까지 기다렸다가 지도에서 실시간으로 움직이는 걸 본 적이 있으실 겁니다. 우버나 카카오T 같은 라이드 헤일링 앱도 마찬가지로, 기사의 현재 위치와 지금 어느 단계인지를 지도 위에서 실시간으로 공유합니다. 센디 퀵도 겉보기 흐름은 비슷합니다. 배달 기사 대신 차주가 붙고, 음식 대신 짐을 실어 나릅니다. 화주가 보는 핵심 경험은 결국 차주의 실시간 위치와 운송 상태를 지도에서 함께 보는 것입니다.

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

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

  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

화면을 만들기 전에, 서버와 어떻게 주고받을지부터 정해야 했습니다. 실시간 통신에서 흔히 저울질하는 선택지는 폴링, SSE, WebSocket 셋입니다. 각각의 장단점은 이렇습니다.

  • 폴링(REST 반복 조회): 기존 REST API를 그대로 재사용할 수 있고, 연결을 상시 열어 두지 않아 관리할 상태가 없습니다. 대신 조회 주기만큼 지연이 생기고, 주기를 줄이면 요청만 늘면서 여전히 요청 시점의 스냅샷만 얻습니다.
  • SSE(서버→클라 단방향 스트림): 서버가 값을 push하고 HTTP 위에서 동작해 비교적 단순하며, 재연결이 규격에 들어 있습니다. 대신 단방향이라 클라이언트가 서버로 보내는 길은 없고, 모바일 네트워크·중간 프록시에서 오래 열린 연결이 끊기는 처리는 결국 직접 다뤄야 합니다.
  • WebSocket(양방향 상시 연결): 서버가 위치를 즉시 push하고, 양방향이라 이후 클라이언트 이벤트까지 같은 채널로 확장할 수 있으며 지연이 낮습니다. 대신 연결 수명 관리와 끊김·재연결을 직접 설계해야 하고, half-open 같은 미묘한 상태도 떠안습니다.

용도로 나눠 골랐습니다. 배차 대기는 상태가 드물게 바뀌어 5초 폴링으로 충분했습니다. 운송 추적은 위치가 초 단위로 움직여 서버가 지속적으로 push해야 했습니다. 여기서는 SSE도 후보였지만, 어차피 모바일에서 끊김과 재연결을 직접 다뤄야 하는 건 SSE든 WebSocket이든 같았고, 이후 클라이언트에서 서버로 올려 보낼 이벤트의 여지까지 고려해 WebSocket을 택했습니다. 그래서 이 글의 무게중심은 그 WebSocket 연결을 끊겨도 안 끊긴 듯 유지하는 데 있습니다.


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

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

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

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

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

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

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

배차 대기 상태머신. ready/inProgress에서 accepted·failure·canceled로 전이하고 실패 시 재배차·포기로 갈리는 흐름

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

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

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

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

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

강남역 출발지 주변에서 배차를 기다리는 Sendy iOS 화면
Sendy iOS의 배차 대기 화면입니다. 시뮬레이터에서 캡처했고, 화면 문구와 위치는 로컬 캡처용 미리보기 값입니다.

지도에 보이는 원은 출발지 주변에서 탐색 범위를 넓혀 가는 애니메이션입니다. 이 애니메이션은 화면 안에서 반복되며, 5초마다 서버에 배차 상태를 묻는 폴링 주기와는 별개입니다.

출발지 주변에 원형 탐색 범위가 퍼지는 배차 대기 지도 애니메이션
시뮬레이터에서 캡처한 Sendy iOS 지도 애니메이션입니다. 위치는 로컬 미리보기 값이며, 원의 움직임은 서버 응답을 나타내지 않습니다.
// 남은 시간 = 마감 시각까지 남은 초. 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하는 방식으로 가기로 했습니다.

차량 위치와 상차지·하차지를 지도에서 확인하는 Sendy iOS 실시간 운송 현황 화면
시뮬레이터에서 캡처한 Sendy iOS 화면이며, 주문·차주·위치는 로컬 미리보기 값입니다. 이름과 차량 번호는 흐리게 처리했고 위치는 한 지점만 표시합니다.

그래서 추적 화면은 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()                       // 진짜 끊김 → 재연결
    }
}

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

WebSocket 연결 상태머신. 연결됨에서 닫힘 감지 후 재연결(1·2·4초 ×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 진행 중

위치가 올 때마다 cross-track 거리로 경로 이탈을 판정해 50m 초과면 새 경로를 요청하는 흐름

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

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


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

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

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

고빈도 위치 신호에서 최신 1건만 받고 직전 값과 다를 때만 지도를 갱신하는 렌더 스로틀

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


마치며

선택한 구조

처음 목표는 화주가 차주의 위치와 운송 상태를 실시간으로 확인하게 하는 것이었습니다. 작업하면서 가장 많은 시간을 쓴 부분은 위치를 그리는 일보다 끊기는 연결을 끊긴 줄 모르게 유지하는 일이었습니다.

  • 통신 방식: 용도로 나눠 배차 대기는 5초 폴링, 운송 추적은 WebSocket. SSE는 끊김·재연결을 어차피 직접 다뤄야 하고 이후 양방향 확장 여지까지 고려해 접었습니다
  • 상태 분리: 운송 단계와 연결 상태를 두 축으로 나눠 완료와 끊김을 단순하게 판정
  • 끊김 감지: delegate가 아니라 receive() throw 하나로 종료를 통일
  • 재연결: backoff와 jitter로 재접속 쏠림을 흩고, 2.5초 유예로 짧은 끊김을 화주에게 숨김
  • 재진입 가드: actor hop 전후 두 번 확인으로 좀비 연결 차단
  • 경로 이탈: cross-track distance와 슬라이딩 윈도우로 클라이언트에서 실시간 감지
  • 지도 렌더: 최신 1건·변경 시에만 반영해 고빈도 스트림의 버벅임 제거

남은 비용과 판단 기준

각 선택이 가장 간단한 구현은 아니었습니다. WebSocket을 고른 순간 연결 수명, 끊김 감지, 재연결, 재진입까지 전부 직접 떠안아야 했습니다. 지금도 짧은 끊김은 거의 드러나지 않고 이탈도 실시간으로 잡지만, 남은 숙제가 있습니다.

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

이 작업에서 계속 지켰던 기준은 하나였습니다. 화주가 멈춘 지도를 보고 불안해하는 순간을 줄이는 것이었습니다. 통신 방식도, 유예도, 재진입 가드도 전부 그 한 지점으로 모였고, 지표도 거기에 맞춰 봤습니다. 기술 선택은 결국 어떤 사용자 경험을 지킬지부터 정하고 그다음에 고르는 것이라는 걸 다시 확인한 작업이었습니다.

이전 글센디 iOS 앱에 React Native 얹기다음 글후기 탐색을 주문 시작으로 잇기 위한 운송후기 탭 실험