LODY/기록

운송 컨텍스트 실시간 WebSocket 정합성 분석

박정환박정환

들어가며

네이버페이비상장(증권플러스비상장) iOS 채용공고를 확인했다. 주식 어플리케이션이라 "실시간/스트리밍 데이터 환경에서 끊김/중복/지연 경계 상황 고려 → 화면 상태와 데이터 정합성 안정화" 역량을 중요시 여긴다. 센디의 운송 컨텍스트(OrderContext/QuickContext) 코드를 분석하면서, 이 요구사항과 어떻게 매칭되는지 파악한 전체 기록이다.

OrderContext와 QuickContext가 따로 나뉘어져 있는 이유

백엔드 관점에서는 퀵 운송이나 일반 운송 모두 같은 운송 컨텍스트 (기사, 운송 상태 관제) 로직을 사용한다. 하지만 프론트엔드에서는 다른 문제였다.

결론: 레거시 통합 기간 + 프론트엔드 모듈 분리

1. OrderContext (레거시, 2024.10~)

  • 07408ffcb (2024.10.23) "지금출발 > WebSocket migration"
  • 기존 일반 운송 화면에 WebSocket이 없다가 추가된 것
  • OrderContextWebSocketMessage, OrderContextStatus 등 DTO가 그대로 센디 메인 앱에 있음
  • ViewModel (QuickContextViewModel)에서 457줄 수정 → 레거시에 WebSocket을 끼워 넣은 흔적

2. QuickContext (신규 모듈, 2026.03~)

  • d98d5bad0 (2026.03.20) "QuickFeature SPM 패키지 기본 구조 생성"
  • 428a9df6c (2026.03.20) "퀵 코디네이터 Live client" — 3,267줄 한 번에
  • QuickFeature라는 독립 SPM 패키지로 완전 분리

백엔드는 같은데 프론트엔드가 다른 이유

  1. 진입 경로가 다름 — 퀵은 메인 홈 카테고리에서 바로 진입, 일반은 주문 상세에서
  2. 화면 구성이 다름 — 퀵은 간결한 플로우(탐색→매칭→운송), 일반은 복잡한 폼+결제
  3. 레거시 부채 — 일반 운송은 센디 앱 초기부터 쌓인 코드, 퀵은 새로 뜯어고침
  4. SPM 모듈화 타이밍 — 퀵을 먼저 SPM으로 분리해서 클린하게 시작

왜 퀵만 우선 개선했는가

퀵 기능을 리뉴얼하면서 운송 컨텍스트 로직을 뜯어 고치려 했다. 퀵/일반 운송 모두 고치고 싶었지만, 기능의 영향 범위를 좁히고 QA 범위를 좁히기 위해 퀵만 우선 개선을 진행했다.

결과적 구조

OrderContext (레거시, Sendy 메인 앱 안에 직접)
├── OrderContextServiceProtocol
├── OrderContextApproachingInfo (REST)
├── OrderContextWebSocketMessage (WebSocket DTO)
└── QuickContextViewModel (457줄 수정 → 레거시에 얹음)

QuickContext (신규, QuickFeature SPM 패키지)
├── QuickContextClient (Protocol + Live)
├── QuickContextOrderPayload (도메인 모델)
├── QuickContextSocketEvent (스트리밍 이벤트)
└── QuickContextWebSocketDataSource (연결 관리)

QuickContextWebSocketDataSource가 OrderContextWebSocketMessage를 파싱해서 쓰고 있다. 즉, 하위 데이터 소스는 같은데 상위 레이어만 분리한 것이다.

면접에서 이걸 말하면 좋은 포인트

"백엔드는 동일한 운송 컨텍스트지만, 레거시 코드와 신규 모듈이 공존하는 상황에서 WebSocket 데이터 소스를 공유하면서도, 각각의 도메인 모델과 생명주기를 독립적으로 관리하도록 분리했습니다."

전체 커밋 타임라인

f1f5b8146  2023.07.26  WooYoung — 운송 context WIP (최초 도입)
64933c96f  2023.07.27  지도 WIP → main merge
623a1322d  2024.03.06  트럭 요금 계산, 마커 윈도우 추가
aa48c44dc  2024.05.08  "매인스레드로 변경" — RxSwift .observe(on: MainScheduler.instance) 추가
4a2022669  2024.05.17  매칭중/컨텍스트 분리, Usecase 의존성 주입 시작
b17a86a14  2024.06.21  deinit → invalidate() 변경 (onDisappear에서 명시 호출)
2e12fe00f  2024.10.21  refresh 로직 제거 및 신규 WebSocket 연결 확인
07408ffcb  2024.10.23  "지금출발 > WebSocket migration" — 5초 REST polling → WebSocket 전환
22d13bd71  2024.10.22  WebSocket Decoding 로직 추가
2e41a0a05  2024.10.29  WebSocket 재시작 및 close 로직 수정
6b39283fe  2025.01.08  hotfix: 메인쓰레드 크래시 수정 — @MainActor 명시
3197f0dfe  2025.01.25  LAAS-10472 (추가 수정)
d98d5bad0  2026.03.20  QuickFeature SPM 패키지 기본 구조 생성
428a9df6c  2026.03.20  퀵 코디네이터 Live client — 3,267줄 한 번에
23addfdaf  2026.03.22  WebSocket DataSource 분리 — 1,057줄 → 89줄 + 352줄 독립

레거시 OrderContext 문제점

1. 단위 테스트가 구조적으로 불가능: 가장 큰 어려움

무엇보다 우리 운송 컨텍스트의 가장 큰 어려움은 단위 테스트가 어렵다는 것이다.

OrderContextViewModel이 모든 것을 직접 소유하고 있다.

  • OrderService (REST API) → 직접 인스턴스화 (new)
  • URLSessionWebSocketTask → 직접 생성/보관
  • URLSessiondelegateQueue: .main으로 직접 생성
  • Coordinator → init에서 주입받지만 프로토콜 아님
  • NMapsGeometry (NMGLatLng) → 비즈니스 로직에 UI 좌표 타입 결합
  • Configuration/Preferences@propertyWrapper로 ViewModel 내부에서 직접 읽기

결과적으로 ViewModel 하나를 테스트하려면 실제 서버 + WebSocket + 네이버 지도 SDK가 전부 필요하다. OrderContext 관련 테스트 파일은 0개다.

QuickContext에서의 해결:

  • QuickContextClient 프로토콜 → fetchOrderInfo, fetchPaymentStatus, connectApproachingInfo 모두 교체 가능
  • @Dependency(\.quickContextClient) → TCA Dependency로 테스트에서 완전 대체
  • TestClock → 시간 의존 테스트 가능 (실제로 테스트 코드에 사용 중)
  • 테스트 결과: QuickContextFeatureTests.swift에서 5개의 재연결 시나리오를 단위 테스트로 검증

2. 스레드 정합성: 2번 수정됐음에도 여전히 취약

6b39283fe (2025.01.08) hotfix: 메인쓰레드 크래시 수정

1차: 2024.05.08 — RxSwift에 .observe(on: MainScheduler.instance) 추가 2차: 2025.01.08 — Coordinator 호출을 Task { @MainActor in } 래핑

하지만 이 hotfix 이후에도 크래시는 발생하고 있다. 여전히 메인 쓰레드에서 크래시를 발생시킬 수 있는 게 남아있다.

남아있는 위험:

  • receiveWebSocketResponseMessage()while 루프 + async/awaithandleApproachingInfo()setRealtimeData()@MainActor인데 호출 체인에 MainActor 보장이 없음
  • urlSession(_:webSocketTask:didCloseWith:) delegate 콜백 → delegateQueue: .main으로 설정했지만 iOS의 URLSession delegate 보장이 완벽하지 않음
  • self.webSocketTask = nil → 읽기/쓰기가 동시에 발생할 수 있는데 NSLock/actor 없이 raw 접근

QuickContext에서의 해결:

  • TCA Reducer → 모든 상태 변이는 Reducer 내부에서 동기적으로 발생 (스레드 안전 보장)
  • WebSocket → AsyncThrowingStream.run { send in } → TCA Action 전달 → Reducer가 메인 스레드에서 처리
  • webSocketTask 같은 raw URLSession 참조를 ViewModel/Feature가 직접 보관하지 않음

3. 재연결 로직 부재

레거시 connectWebSocket()은 한 번만 호출된다.

  • 정상 종료(normalClosure) → onWebSocketClosedNormally()에서 disappear/background가 아니면 알림만 띄움
  • 에러 종료 → receiveWebSocketResponseMessage()에서 webSocketTask?.cancel() → 그게 끝. 재연결 없음.
  • 2024.10.29에 resume() 추가됐지만, appearwebSocketTask == nil 확인만 → 에러로 끊긴 후에는 resume해도 재연결 안 됨

사용자 경험: 지하철에서 화면 보다가 네트워크 끊기면 끝. 실시간 위치 영원히 안 옴.

QuickContext에서의 해결:

while !Task.isCancelled {
    for try await event in stream {
        errorRetryIndex = 0         // 정상 수신 시 카운터 리셋
    }
    // 정상 종료 → 1초 후 재연결
    try await clock.sleep(for: .seconds(1))
} catch {
    // 에러 → [1s, 2s, 5s] backoff → 이후 5s 간격 무한 재시도
}

4. 화면 생명주기와 WebSocket 생명주기 불일치

레거시 문제:
  - deinit()에서 invalidate() → Swift에서 deinit 타이밍은 보장 안 됨
  - 2024.06.21에 deinit → invalidate()로 변경했지만 onDisappear에서만 호출
  - 백그라운드 진입 → 아무 처리 없음
  - foreground 복귀 → 아무 처리 없음

QuickContext:
  - onDisappear → .cancel(id: CancelID.webSocket) → Effect 즉시 정리
  - onSceneActive → cancelInFlight: true로 기존 소켓 취소 + 새 연결
  - TCA의 Effect lifecycle이 SwiftUI의 view lifecycle과 정확히 정합

5. URLSession을 ViewModel이 직접 보관 (자원 누수)

// 레거시: 호출할 때마다 새 URLSession 생성!
var webSocketURLSession: URLSession? {
    return .init(configuration: .default, delegate: self, delegateQueue: .main)
}
// webSocketURLSession은 retain되지만 invalidate() 호출 없음

QuickContext:

  • QuickContextWebSocketDataSourceConnection 내부에 URLSession 보관
  • subscribers == 0이면 session.invalidateAndCancel() 명시 호출
  • 싱글턴이지만 orderId별로 독립 Connection → 필요할 때만 생성

6. 중복 연결 방지 없음

레거시 QuickContextViewModelwebSocketTask를 단일 변수로 보관한다.

  • 같은 주문을 여러 화면에서 열면 각각 독립적인 WebSocket 연결 생성
  • 동일 이벤트를 여러 구독자가 중복 수신
  • 메모리 누수 (구독자 해제 시에도 URLSession이 살아있을 수 있음)

QuickContext에서의 해결:

  • QuickContextWebSocketDataSource.shared 싱글턴
  • orderId당 단일 Connection → 멀티 구독자 fan-out
  • activeSubscribers == 0이면 session.invalidateAndCancel() 자동 정리

7. 에러 분류 없음

레거시는 WebSocket close/error를 모두 동일하게 처리한다.

  • 정상 종료(normalClosure)도 에러로 표시 가능
  • 사용자에게 불필요한 "실시간 연결이 종료되었습니다" 노출

QuickContext에서의 해결:

  • shouldIgnoreWebSocketClose: normalClosure/goingAway는 무시
  • shouldIgnoreWebSocketError: cancelled(의도적 해제)는 무시
  • userVisibleWebSocketError: 실제 네트워크 장애만 사용자에게 노출

8. REST + WebSocket 두 소스의 정합성

화면 진입 시 REST API로 초기 데이터 로드 (OrderDetail + ApproachingInfo), 이후 WebSocket으로 실시간 업데이트.

두 데이터 소스의 타이밍 차이로 "초기값은 REST, 업데이트는 WebSocket"인데 중간에 끊기면 화면이 구시간 데이터를 보여준다.

QuickContext에서의 해결:

  • initialSocketEvent를 payload에 포함 → 첫 WebSocket 이벤트 전까지 REST 데이터로 안전하게 초기 렌더링
  • RetryPolicy(delays: [1, 2]) → 타임아웃/연결 끊김 시 자동 재시도
  • bufferingPolicy: .bufferingNewest(5) → 빠른 이벤트 유실 방지

9. 모듈화 부재

레거시에서는 WebSocket 연결 로직이 ViewModel(QuickContextViewModel)에 457줄이 있었다. UI 로직 + 네트워크 로직 + 연결 관리가 하나의 클래스에 혼재되고, URLSessionWebSocketTask를 ViewModel이 직접 보관한다. 테스트 불가 (WebSocket을 mock할 수 없음).

QuickContext에서의 해결:

  • QuickFeature SPM 패키지로 분리
  • QuickContextClient 프로토콜 → Live/Mock 분리
  • WebSocketDataSource를 독립 클래스로 추출
  • QuickContextClient+Live: 1,057줄 → DataSource 분리 후 89줄로 축소

WebSocket 헬스체크 문제

서버 구조

운송 컨텍스트 웹소켓 통신 헬스 체크 관련해서는 ping pong을 서버입장에서는 클라이언트에게 ping을 보내기만 하고 있다.

클라이언트가 직접 끊는게 아니면 안끊는다.

서버 ──── ping ────→ 클라이언트 (iOS가 자동 pong 회신)
         ...서버가 타임아웃되면 서버가 연결 끊음

iOS SDK가 프레임 레벨에서 pong을 자동 회신하므로, 서버가 보내는 ping에 대해서는 클라이언트 코드에서 아무것도 안 해도 된다.

현재 QuickContext의 sendPing은 불필요

let pingTask = Task { [weak connection] in
    while !Task.isCancelled {
        try? await Task.sleep(nanoseconds: 30_000_000_000)  // 30초마다
        guard !Task.isCancelled, let task = connection?.task else { return }
        task.sendPing { _ in }  // 클라이언트 → 서버로 ping 전송
    }
}

QuickContext쪽에서는 ping을 보내고 있을텐데 이건 나의 실수야.

서버가 "클라이언트가 직접 끊으면 끊는다"이고, 서버 쪽에서 ping을 보내서 서버 스스로 연결 상태를 관리하고 있다면, 클라이언트가 sendPing을 할 필요는 없다.

sendPing은 NAT/방화벽 idle timeout 방지 용도로는 유효하지만, pong 체크를 안 하고 있어서 서버 죽음 감지에는 전혀 도움이 안 된다.

서버 장애 시 문제 (HALF-OPEN)

1. 서버 프로세스 장애 / 재배포 / 네트워크 장비 문제로 서버가 죽음
2. 서버가 더 이상 ping을 안 보냄
3. 클라이언트는 ping이 안 와도 아무 일 안 일어남
   - URLSession delegate의 didClose가 안 호출됨
   - receive()도 에러 안 남 → 그냥 무한 대기
4. 클라이언트의 sendPing도 여전히 30초마다 전송
   - 하지만 서버가 죽었으니 pong도 안 옴
   - sendPing 콜백을 { _ in }으로 무시하고 있어서 클라이언트는 이것도 모름
5. 결과: 화면에는 "실시간 운송 현황"이라고 뜨는데
   기사 위치는 영원히 업데이트 안 됨. 사용자는 아무것도 모름.
시나리오서버 동작클라이언트 감지
정상 종료close 보냄✅ didClose → 재연결
서버 장애 (close 못 보냄)아무것도 못 함❌ 감지 불가 — 영원 대기
네트워크 중간 단절close 못 도착❌ 감지 불가 — 영원 대기

해결을 위해서는 클라이언트 쪽에 "마지막 메시지 수신으로부터 N초 경과 시 서버가 죽은 것으로 간주"하는 로직이 필요하다. 현재의 sendPing은 지우고, 대신 이런 타임아웃을 두는 게 맞다.

JD 매칭 분석

완벽히 매칭되는 부분

"실시간/스트리밍 데이터에서 끊김/중복/지연 경계 상황 → 화면 상태와 데이터 정합성 안정화"

QuickContextWebSocketDataSource 코드에 그대로 답이 있다:

  • 멀티 구독자 관리: 동일 orderId에 여러 화면(VM)이 구독할 수 있는 구조 → Connectionsubscribers: [UUID: Continuation] 딕셔너리
  • 끊김 방지: readMessages 루프 + 30초 ping keep-alive + 재시도 로직
  • 중복 방지: NSLock 기반 thread-safe connection 관리 → 중복 WebSocket 연결 방지
  • 정리(cleanup): 마지막 구독자가 떠나면 session.invalidateAndCancel()로 자원 정리
  • 에러 분류: shouldIgnoreWebSocketError / shouldIgnoreWebSocketClose → 정상 종료 vs 비정상 종료 구분
  • 버퍼링: AsyncThrowingStream(bufferingPolicy: .bufferingNewest(5)) → 빠른 이벤트는 최신 5개 유지, 지연 시 누락 방지

우대사항: "복잡한 상태 정합성, 장애·경계 케이스 해결 경험"

  • OrderContextStatus enum (MOVING_INTO_DEPARTURE → LOADING → MOVING_INTO_ARRIVAL → UNLOADING) → 상태머신
  • REST API 초기 데이터 + WebSocket 실시간 업데이트 → 두 소스의 정합성을 QuickContextOrderPayload.initialSocketEvent로 해결
  • RetryPolicy(delays: [1, 2]) → 타임아웃/연결 끊김 시 지수 백오프 재시도
  • QuickFeature SPM 패키지로 모듈화 (테스트 격리)

증권 앱과의 구조적 동일성

증권 앱에서 실시간 데이터가 왜 특히 중요한가

주식 호가/체결 → WebSocket 스트리밍
  - 1초 단위로 데이터가 쏟아짐
  - 끊김 → 구시간 호가 → 잘못된 매수/매도 결정 → 금전적 손실
  - 중복 → 동일 체결이 두 번 반영 → 잔고 오차
  - 지연 → 시장가보다 느린 가격으로 체결 → 슬리피지

센디의 운송 컨텍스트와 주식 앱의 호가 스트리밍이 구조적으로 거의 같다.

센디와 주식 앱의 매핑

센디 (운송)주식 앱공통 문제
기사 실시간 위치호가/체결 데이터끊김 감지
REST 초기 데이터 + WS 실시간시가 REST + 체결 WS두 소스 정합성
운송 상태 전이 (loading→moving)주문 상태 전이 (접수→체결→결제)상태머신 일관성
화면 뒤로 갔다 복귀앱 백그라운드 후 복귀stale 연결 감지
기사 정보 업데이트잔고/보유 종목 변경멀티 구독자 동기화

증권 앱의 복잡한 상태 정합성 케이스

증권 앱의 "복잡한 상태 정합성과 장애·경계 케이스"는 더 다양한 케이스가 있다.

거래 생명주기 정합성: 주문 접수 → 매도/매수 호가 대기 → 부분 체결 → 전부 체결 (또는 미체결 취소) → 정산 완료 → 잔고 반영

  • 부분 체결: 100주 주문 → 30주 체결 → 나머지 70주 대기 중 → 잔고는 체결된 30주만 반영?
  • 정정/취소: 대기 중인 주문을 정정하면 WS로 "취소 완료" vs REST로 "이미 체결됨"이 동시 도착

잔고/포지션 정합성:

  • 주문 체결 → 잔고 감소 → 보유 종목 수량 변경 → 실시간 P&L 재계산 → 이 세 가지가 원자적으로 갱신
  • 당일 매매 수익률: 현재가는 WS로 실시간, 매입단가는 REST → 동시 갱신 시 중간 상태 노출
  • 예수금: 주문 시 예수금 차감 → 체결 완료 → 실제 차감 → "주문 대기 중" 예수금과 "실제 차감" 구분

호가/체결 데이터 정합성:

  • 체결 1건 → 매도 잔량 감소 + 매수 잔량 감소 → 하나의 이벤트로 오는가, 두 개로 오는가?
  • 중간에 다른 체결이 끼어들면 호가창이 일시적으로 불일치
  • 체결가 vs 현재가 갱신 타이밍이 다름 → "현재가 50,900"인데 매수 시 "50,850으로 체결"

시장 상태 전이: 장전 → 장중 → 동시호가 → 장마감 → 각 상태에서 WS 메시지 포맷이 다를 수 있음

멀티 디바이스/세션 정합성: 같은 계정, 여러 기기에서 WS 연결 → 한쪽에서 상태 변경 시 다른 쪽 즉시 반영

센디 경험과 증권 앱의 차이

센디에서 경험증권 앱에서 더 복잡해지는 이유
운송 상태 5단계주문 생명주기는 부분 체결, 정정, 취소 등 더 많은 분기
단일 주문 구독포트폴리오 내 N개 종목이 동시에 갱신
REST+WS 초기 정합성잔고+호가+체결+포지션 4개 소스가 동시에 변이
기사 위치 1종체결가, 호가, 잔량, 현재가, 수익률 등 다양한 데이터 타입

포트폴리오 스토리 구성 제안

"물류 앱에서 기사 실시간 위치를 WebSocket으로 수신하는 화면에서, REST 초기 데이터와 스트리밍 데이터의 정합성, 멀티 구독자 중복 제거, 스레드 안전성 문제를 해결했습니다. 이 경험은 주식 앱의 실시간 호가/체결 스트리밍에서 끊김/중복/지연을 다루는 것과 구조적으로 동일합니다."

면접에서는 "물류 앱에서는 단일 주문의 단일 스트림이었지만, 증권 앱에서는 포트폴리오 내 여러 종목이 동시에 상태 변이하므로, 상태 갱신의 원자성과 순서 보장이 더 중요하다"라고 말하면 이해도를 보여줄 수 있다.

참고 코드

핵심 파일들

# 레거시 (OrderContext)
Sendy/Data/DTO/Order/OrderContextStatus.swift
Sendy/Data/DTO/Order/OrderContextWebSocketMessage.swift
Sendy/Data/DTO/Order/OrderContextApproachingInfo.swift
Sendy/Data/Service/Order/OrderContextServiceProtocol.swift
Sendy/Presentation/Features/Order/V2/OrderList/OrderContext/OrderContextViewModel.swift
Sendy/Presentation/Coordinator/Order/OrderContextCoordinator.swift

# 신규 (QuickContext)
packages/QuickFeature/Sources/QuickFeature/QuickContextClient.swift
packages/QuickFeature/Sources/QuickFeature/Models/QuickContextModels.swift
packages/QuickFeature/Sources/QuickFeature/Models/QuickContextFeatureModels.swift
packages/QuickFeature/Sources/QuickFeature/Pages/Context/QuickContextFeature.swift
Sendy/Presentation/Coordinator/Order/Quick/PostOrder/QuickContextClient+Live.swift
Sendy/Presentation/Coordinator/Order/Quick/PostOrder/QuickContextWebSocketDataSource.swift
Sendy/Presentation/Coordinator/Order/Quick/PostOrder/QuickContextQuickFeatureMapper.swift

# 테스트
packages/QuickFeature/Tests/QuickFeatureTests/QuickContextFeatureTests.swift