Delivery Lab 03. 남은 시간이 0이면 배달 완료일까? 글 표지
Delivery LabReact NativeTanStack QueryTesting

Delivery Lab 03. 남은 시간이 0이면 배달 완료일까?

예상 시간이 지났다고 배달을 완료 처리해도 될까? 서버 시각과 기기 시각, 취소 뒤 늦은 응답, 마지막으로 확인한 정보를 React Native 화면에서 다룬 기록입니다.

지난 글에서는 목적지에 도착한 순간과 커피를 건네준 순간을 나눴다. 현재 시나리오에서는 115초에 도착하고 120초에 전달을 마친다. 서버의 시간표는 그렇게 정해져 있다.

그런데 앱이 마지막 응답을 받은 뒤 연결이 끊기면 어떻게 될까? 서버는 그사이에 배달을 끝낼 수 있지만, 앱이 마지막으로 받은 상태는 여전히 ‘배달 중’이다. 화면의 남은 시간만 계속 줄어든다.

숫자가 0이 됐으니 완료 화면으로 바꿔도 될까?

서버에 정해둔 시간표를 알고 있는 개발자 입장에서는 결과를 예상할 수 있다. 하지만 화면에서 확인한 것은 예상 시간이 지났다는 사실뿐이다. 서버가 지금 어떤 상태라고 답하는지는 아직 모른다.

타이머가 끝났다는 이유로 커피를 전달했다고 말할 수는 없었다.

주변을 둘러보며 당황하는 존 트라볼타 GIF
0초라며. 커피는 어디 있는데.

숫자가 할 일과 상태가 할 일

그래서 화면에서 계산하는 값과 서버에서 받아야 하는 값을 나눴다.

주문 상태와 라이더 위치는 서버 응답을 따른다. 앱의 1초 타이머는 남은 시간과 마지막 확인 이후 얼마나 지났는지를 표시한다. 타이머 안에서 주문을 delivered로 바꾸는 처리는 넣지 않았다.

예상 시간이 지났는데 서버의 완료 응답을 받지 못했다면, 현재 앱은 남은 시간 대신 ‘도착 상태 확인 중’을 보여준다. 주문 상태는 마지막으로 확인한 ‘배달 중’으로 남는다. 실제 응답에서 delivered를 받은 뒤에야 ‘전달 완료’와 ‘맛있게 드세요’로 바뀐다.

테스트에서는 이 두 값을 따로 확인했다. 마지막 상태는 배달 중이고, 응답의 서버 시각을 기준으로 예정 시각까지 5초 남은 데이터를 넣었다. 그 응답을 받은 기기 시각은 2_000, 화면을 계산하는 시각은 9_000밀리초로 두었다. 수신 후 7초가 지났으니 예상 시간은 이미 끝난 셈이다.

expect(describeOrder(snapshot, 2_000, 9_000).etaLabel).toBe(
  '도착 상태 확인 중'
);
expect(describeOrder(snapshot, 2_000, 9_000).statusLabel).toBe('배달 중');

실제 네트워크를 7초 동안 끊어둔 실험은 아니다. 시간을 인자로 받는 표시 함수에 테스트 데이터를 넣고, 예상 시간이 지나도 상태를 바꾸지 않는지 확인한 코드다.

이 구분이 있어야 나중에 예상 전달 시각이 달라져도 숫자와 주문 상태를 따로 갱신할 수 있다. 지금처럼 완료 시각이 정해진 시뮬레이터에서도 앱이 서버의 답을 대신 만들어내지 않도록 했다.

휴대폰 시계에서 바로 빼면 안 될까?

남은 시간은 예정 시각에서 현재 시각을 빼면 구할 수 있다. 다만 예정 시각은 서버에서 왔고, 현재 시각은 휴대폰의 시계다. 두 시계가 어긋나면 배달이 아직 끝나지 않았는데도 숫자가 먼저 0이 될 수 있다.

응답에는 서버가 상태를 계산한 시각인 serverTime이 있다. 앱에서는 응답을 받은 기기 시각인 receivedAt을 함께 저장했다. 다음에 화면을 그릴 때는 수신 이후 기기에서 흐른 시간을 구하고, 그만큼 서버 시각에 더한다.

const elapsed = Math.max(0, now - receivedAt);
const estimatedServerNow = snapshot.serverTime + elapsed;
const seconds = Math.max(
  0,
  Math.ceil((snapshot.estimatedArrivalAt - estimatedServerNow) / 1000)
);

예를 들어 응답의 서버 시각을 기준으로 35초가 남아 있었다면, 기기에서 5초가 흐른 뒤에는 30초로 표시한다. 기기 시각 자체가 서버보다 몇 분 앞서 있는지는 이 뺄셈에 직접 들어가지 않는다.

여기서 구한 값은 어디까지나 추정 시각이다. 서버가 응답을 만든 뒤 앱이 받아서 파싱하기까지 걸린 시간은 별도로 보정하지 않는다. 기기에서는 Date.now()를 쓰므로 실행 중 사용자가 시계를 바꾸는 상황까지 해결했다고 볼 수도 없다. 이번에는 고정된 서버·기기 시각 차이가 남은 시간 계산에 그대로 섞이지 않는 범위를 확인했다.

나중에 온 응답이 더 새로운 응답은 아니다

시간 표시와 별개로, 서버 응답을 받은 순서에도 기준이 필요했다. 먼저 보낸 조회가 늦어지고 그사이에 취소가 성공하는 경우를 생각해봤다.

  1. 앱이 배달 상태를 조회한다.
  2. 사용자가 주문을 취소하고, 서버의 취소 성공 응답을 받는다.
  3. 첫 번째 조회의 ‘배달 중’ 응답이 뒤늦게 도착한다.

마지막에 도착한 응답을 그대로 반영하면 취소했던 주문이 다시 배달 중으로 보일 수 있다. 화면이 새 응답을 받았다는 사실과, 그 응답이 더 최근 상태를 담고 있다는 사실을 구분해야 했다.

취소 응답 뒤에 이전 조회 응답이 도착해도 취소 상태를 유지하는 가상 순서도
응답은 늦게 왔지만, 그 안의 상태까지 최신인 것은 아니다.

주문별 캐시에는 서버 데이터인 snapshot과 그 데이터를 받은 시각인 receivedAt을 함께 둔다. 새 데이터를 받아들이는 기준은 acceptSnapshot 함수에 모았다.

  • 다른 주문 ID의 응답은 받지 않는다.
  • sequence가 더 작으면 이전 상태로 보고 버린다. 같은 sequence라면 serverTime이 더 커야 받는다.
  • 이미 전달 완료나 취소를 확인했다면 다른 상태로 바꾸지 않는다. 이때는 새 응답의 sequence가 더 커도 거부한다.

조회 결과를 캐시에 넣을 때와 취소 성공 응답을 반영할 때 모두 이 함수를 거친다. UI가 각각의 응답 순서를 따로 판정하지 않도록 한 것이다.

취소 자체도 버튼을 누르자마자 성공한 것으로 바꾸지는 않는다. 서버의 취소 성공 응답을 받은 뒤 주문 ID가 맞는지 확인하고, 해당 주문의 진행 중 조회를 취소한다. 이어 같은 주문의 캐시에 취소 결과를 반영한다. 관련 없는 주문의 캐시까지 모두 비우지는 않는다.

진행 중 요청을 취소하는 처리와 데이터를 받아들일지 결정하는 규칙을 함께 둔 이유도 여기에 있다. 조회를 멈추는 일만으로 모든 캐시 반영 경로의 순서를 설명할 수는 없기 때문이다.

데이터는 버렸는데 ‘방금 확인’은 남는다면

오래된 응답을 버릴 때는 수신 시각도 같이 봐야 했다.

배달 위치와 상태는 그대로 두면서 receivedAt만 지금으로 바꾸면, 화면에는 다시 ‘방금 확인’이 뜬다. 정작 사용자가 보고 있는 내용은 갱신되지 않았는데 말이다. 예상 시간 계산의 기준도 이 시각에 묶여 있어서, 데이터와 수신 시각을 따로 교체하면 숫자까지 영향을 받는다.

그래서 거부하는 응답에는 새 객체를 만들지 않고 기존 값을 그대로 반환한다. 아래는 현재 테스트의 일부다.

const accepted = { snapshot: deliveringFixture, receivedAt: 100 };
expect(
  acceptSnapshot(accepted, { ...deliveringFixture, sequence: 1 }, 900)
).toBe(accepted);

더 낮은 sequence의 응답이 도착했지만, 반환값은 기존 accepted다. 내용뿐 아니라 receivedAt: 100도 유지된다. ‘방금 확인’이라는 문구가 마지막으로 도착한 아무 응답을 가리키지 않게 한 것이다.

취소한 데이터에 더 큰 sequence의 배달 중 응답을 넣어도 기존 취소 상태가 유지되는지 별도로 확인했다. 화면 테스트에서도 조회 응답을 보류한 채 취소를 먼저 성공시키고, 나중에 조회를 끝내는 순서를 재현했다.

갱신에 실패했다고 화면까지 비울 필요는 없었다

처음부터 주문 정보를 한 번도 받지 못한 경우와, 잘 보여주던 주문의 다음 조회만 실패한 경우는 다르게 처리했다.

최초 조회가 실패하면 오류 안내와 다시 시도할 동작이 필요하다. 반면 갱신만 실패했다면 마지막으로 확인한 위치와 상태는 남아 있다. 이 경우에는 기존 화면을 유지하면서 ‘마지막 정보를 표시하고 있어요’라는 안내와 재시도 버튼을 보여준다. 마지막 확인 시각도 계속 표시한다.

여기에는 서로 다른 두 종류의 오래됨이 있다. TanStack Query의 staleTime은 1초로 두었고, 화면에서 정보가 오래됐다고 안내하는 기준은 서버 응답의 staleAt이다. 현재 서버는 응답 시각에서 10초 뒤를 보낸다. 캐시를 언제부터 오래된 것으로 취급할지 정하는 설정과, 사용자에게 갱신 대기를 알리는 기준을 같은 값으로 쓰지는 않았다.

화면 테스트에서는 정상 응답을 보여준 뒤 다음 조회를 실패시켰다. 기존 상태가 유지되고 안내가 나타나는지, 재시도에 성공하면 새 상태로 바뀌는지 확인했다.

다만 ‘주문이 없다’는 응답은 일반적인 연결 실패와 구분했다. 글을 정리하면서 확인해 보니 로컬 서버가 꺼져 있어서 다시 켰다. 지금 서버는 주문을 메모리에 보관하므로 재시작하면 이전 주문이 사라진다. 앱에 저장된 주문 ID만 남아 있을 수 있다.

이 경우에는 같은 ID를 계속 재시도하는 대신, 없는 주문이라는 안내와 새 테스트를 준비하는 동작으로 연결했다. 네트워크 실패는 다시 확인할 이유가 있고, 서버에서 사라진 테스트 주문은 새로 시작할 이유가 있다.

다음에는 이 정보를 잠금화면으로

화면에는 주문 상태, 남은 시간, 마지막 확인 시각이 함께 보인다. 처음에는 같은 배달을 설명하는 값들이라고 생각하기 쉽지만, 각각 근거가 다르다. 주문 상태는 서버에서 확인한 사실이고, 남은 시간은 그때 받은 예정 시각으로 계산한 값이다. 마지막 확인 시각은 그 정보가 언제부터 새 응답을 기다리고 있는지 보여준다.

글을 정리하면서 표시 함수, 주문 query hook, 주문 화면의 기존 테스트를 다시 실행했다. 3개 묶음의 44개 테스트가 통과했다. 화면 테스트에서는 TanStack Query의 비동기 알림과 관련된 act 경고가 한 번 나왔고, 이번 글을 준비하면서 그 경고를 수정하지는 않았다.

이번 글의 연결 실패와 늦은 응답은 외부 경계를 제어한 테스트에서 확인한 내용이다. 실제 앱의 연결을 끊고 0초가 되는 순간부터 재연결까지 촬영하는 실험은 아직 남아 있다. 재시작한 로컬 서버에서는 HTTP 응답과 주문 생성·조회·취소가 정상인 것까지 확인했다.

이제 같은 정보를 앱 밖에서도 보여주고 싶었다. React Native 화면에서 확인한 상태를 잠금화면에 전달하면 될 것 같았는데, 빌드는 성공해도 내용이 나타나지 않았다.

앱과 위젯 확장 사이에서 무엇을 확인했는지는 다음 편에서 이어간다.