Delivery Lab 01. 조회할 때마다 라이더를 움직여도 될까? 글 표지
Delivery LabReact NativeTypeScriptTesting

Delivery Lab 01. 조회할 때마다 라이더를 움직여도 될까?

카페 배달을 앱 밖에서 따라가는 개인 프로젝트의 첫 기록. 조회할 때마다 라이더를 움직이려던 아이디어를 서버 시간 기반 모델로 바꾸고, 시간 경계와 취소를 검증한 과정을 담았습니다.

회사에서 React와 React Native로 프론트엔드를 개발하고 있다. 맡는 프로젝트는 카페 앱이 많다. 최근 포트폴리오를 보강할 방법을 고민하면서, 개인 프로젝트 하나를 시작해보고 싶어졌다.

SI 성격의 회사 프로젝트를 정리하다 보니 내가 어떤 문제를 골랐고, 어떤 대안을 고민했는지도 더 보여주고 싶었다. 이번에는 목적을 직접 정하고, 구현하면서 생각이 바뀌는 과정을 글로 남겨보기로 했다.

소재는 익숙한 카페 앱에서 찾았다. 배달 앱을 쓰다 보면 휴대폰을 잠가도 주문 상태와 남은 시간이 보인다. 앱을 다시 열지 않고도 배달이 어디까지 왔는지 알 수 있는 경험을 직접 만들어보고 싶었다.

프로젝트 이름은 Delivery Lab으로 정했다. 이 연재에서는 카페 배달을 기다리는 경험을 만들면서 마주친 질문들을 하나씩 다룬다. 첫 질문은 잠금화면에 도달하기도 전에 나왔다.

테스트용 배달은 무엇을 기준으로 진행되어야 할까?

주문 한 건을 끝까지 따라가 보기

처음 정한 목표는 간단했다.

카페 배달을 기다리는 동안 앱을 반복해서 열지 않아도 주문 상태와 예상 도착 시간을 확인할 수 있게 한다.

우선 고객 앱 하나에서 주문 한 건을 따라가기로 했다. 출발지와 목적지를 정해두고 가상의 라이더가 그 사이를 이동한다. 주문을 생성하면 준비를 시작하고, 시간이 지나면 배달 중이 되고, 마지막에는 완료된다.

이 흐름을 반복해서 재현할 수 있으면 앱 화면과 잠금화면을 연결하는 실험에 쓸 수 있다. 결제나 배차까지 넣으면 기다리는 경험을 확인하기 전에 만들어야 할 것이 너무 많아진다. 라이더 위치도 고정된 테스트 좌표로 시작했다.

실제로 앱을 덜 열게 되는지는 이후에 확인할 가설이다. 지금은 이 경험을 구현하는 데 필요한 데이터와 표시 동작부터 살펴보고 있다.

조회가 끝나면 다음 좌표를 보내면 되지 않을까?

처음 떠올린 목 API는 단순했다. 이동 경로의 좌표를 배열에 넣고, 앱이 조회할 때마다 다음 좌표로 옮긴다. 앱에서는 주기적으로 API를 호출해 받은 위치를 지도에 표시하면 될 것 같았다.

그런데 이 방식대로라면 조회 주기가 라이더의 속도를 결정한다.

한 번 요청할 때마다 같은 거리만큼 움직인다고 가정해보자. 1초마다 조회하는 앱은 5초마다 조회하는 앱보다 라이더를 더 자주 움직인다. 요청을 재시도해도 위치가 한 번 더 바뀐다. 같은 주문을 다른 화면에서도 조회하면 그 요청까지 이동에 영향을 준다.

길 위를 빠르게 뛰어가는 스폰지밥 GIF
라이더: 고객님, 새로고침 좀 천천히 눌러주세요.

앱이 조회를 멈추는 상황은 더 문제였다. 중간에 아무도 조회하지 않으면 라이더도 그 자리에 머문다. 다시 조회하기 시작해야 다음 좌표로 넘어간다.

앱을 자주 열지 않아도 배달을 따라가는 경험을 만들려는데, 배달의 진행 자체가 앱의 요청에 달려 있는 셈이다. 첫 구현에 들어가기 전에 좌표를 옮기는 기준부터 바꾸기로 했다.

주문의 시작 시각을 기준으로 계산하기

주문을 만들 때 시작 시각을 저장하고, 조회 시점의 서버 시각과 비교해 현재 상태를 계산하도록 했다.

첫 시나리오는 준비 15초, 이동 60초였다. 주문 시작 후 15초가 지나면 배달을 시작하고, 75초가 지나면 완료되는 압축된 배달이다. 경로는 고정된 가상 좌표 세 개로 구성했다.

주문을 조회하면 서버는 경과한 시간을 구한다. 준비가 끝났다면 이동 시간 중 어느 정도가 지났는지 계산하고, 그 진행률에 해당하는 좌표를 반환한다. API 응답에는 이 시점의 상태와 위치를 담는다. 코드에서는 이 결과를 snapshot이라고 부른다.

이렇게 정한 규칙은 다음과 같다.

같은 주문 데이터를 같은 서버 시각에 읽으면, 그 전에 몇 번 조회했는지와 관계없이 같은 결과가 나와야 한다.

중간에 조회가 없어도 다음 요청에서는 그동안 흐른 시간을 반영한 상태를 얻는다. 위치를 매초 저장해두는 작업 없이도, 시작 시각과 정해진 시나리오에서 현재 위치를 계산할 수 있다.

이 모델은 실제 배달의 지연이나 교통 상황을 예측하지 않는다. 도착 시각이 정해진 시뮬레이터다. 화면이 잠기거나 요청이 늦어지는 상황을 같은 조건에서 반복해보는 데 필요한 만큼의 모델을 먼저 만든 것이다.

45초를 기다리지 않고 45초 뒤를 확인하기

계산 함수는 현재 시각을 인자로 받도록 만들었다.

getOrderSnapshot(order, serverTime);

HTTP 요청을 처리하는 쪽에서 시각을 전달하면, 이 함수는 주문 데이터와 전달받은 시각으로 결과를 계산한다. 테스트에서는 원하는 시각을 직접 넣을 수 있다.

그럴 시간이 어딨냐고 말하는 스위트 브라운의 반응 GIF
테스트 한 번에 45초씩 기다릴 수는 없으니까.

첫 시나리오에서 주문 시작 후 45초는 준비가 끝난 지 30초가 되는 시점이다. 이동에 걸리는 시간을 60초로 정했으니, 이동 진행률은 30 / 60 = 0.5여야 한다.

주문 시작 후 경과 시간초기 시나리오의 기대 상태이동 진행률
15초 직전준비 중0
15초배달 중0
45초배달 중0.5
75초배달 완료1

테스트에는 계산 함수로 기대값을 다시 구하는 대신, 이 시간표에서 직접 계산한 값을 넣었다. 함수가 잘못 계산했는데 테스트도 같은 계산을 따라가면 문제를 놓칠 수 있기 때문이다.

아래는 초기 테스트에서 핵심 부분을 발췌한 코드다. 시각의 단위는 밀리초이고, 주문의 시작 시각을 1_000_000으로 두었다.

const order = createOrder('00000000-0000-4000-8000-000000000001', 1_000_000);
 
getOrderSnapshot(order, 1_001_000);
getOrderSnapshot(order, 1_002_000);
const snapshot = getOrderSnapshot(order, 1_045_000);
 
expect(snapshot.status).toBe('delivering');
expect(snapshot.progress).toBe(0.5);
expect(snapshot.position.latitude).toBeCloseTo(37.501);
expect(snapshot.position.longitude).toBeCloseTo(127.036);
expect(snapshot.estimatedArrivalAt).toBe(1_075_000);
expect(getOrderSnapshot(order, 1_045_000)).toEqual(snapshot);

1초와 2초 시점에 조회를 끼워 넣어도 45초 시점에는 배달 중이고, 이동 진행률은 0.5였다. 위치는 정해둔 경로의 중간 좌표와 일치했다. 같은 45초 시각으로 다시 조회한 결과도 같았다. 준비가 끝나는 경계와 배달이 끝나는 경계는 별도 테스트로 확인했다.

여기서 확인한 것은 명시한 시각을 넣었을 때의 계산 결과다. 실제로 1초 간격, 5초 간격으로 HTTP 요청을 보내며 비교한 실험은 아직 하지 않았다. 그 비교는 이후 재실험에서 보완하려고 한다.

취소한 주문까지 계속 흘러가면 안 된다

시간을 기준으로 계산하더라도 주문이 취소되면 진행을 멈춰야 한다. 취소는 별도로 기록해야 하는 사용자 행동이다.

그래서 생성·조회·취소의 역할을 나눴다. createOrder는 시작 시각과 경로를 가진 주문을 만든다. getOrderSnapshot은 원본 주문을 바꾸지 않고 주어진 시점의 결과를 계산한다. cancelOrder는 취소 시각을 기록한 새 주문 데이터를 반환한다.

취소된 주문을 조회할 때는 취소 시각을 기준으로 위치와 진행률을 계산한다. 이후 시간이 흘러도 배달 위치가 계속 움직이지 않는다. 반복 취소는 같은 취소 결과를 유지하고, 이미 완료된 주문에 대한 취소는 거절하도록 했다.

일반 진행은 시작 시각에서 계산하고, 취소처럼 진행을 바꾸는 사건은 주문 데이터에 남기는 구조다. 이 구분 덕분에 조회 함수 안에서 다음 좌표를 저장하거나 주문 상태를 덮어쓸 필요가 없어졌다.

다음에는 매장과 라이더의 시간을 나눠보기

목 API를 설계하면서 정한 첫 기준은 앱이 얼마나 자주 물어보든 배달은 같은 시간표를 따라야 한다는 것이었다. 이 기준이 있어야 앱의 조회를 멈췄다가 다시 시작하는 상황도 의미 있게 확인할 수 있다.

물론 서버에서 현재 상태를 계산할 수 있다는 것만으로 잠금화면에 새 정보가 도착하지는 않는다. 계산한 결과를 앱과 OS 표시에 전달하는 과정은 별도로 다뤄야 한다.

그 전에 배달 시나리오에도 더 표현하고 싶은 장면이 생겼다. 매장은 아직 조리 중인데 라이더가 먼저 도착해서 기다리고 있다면, 이 주문은 어떤 상태여야 할까? 라이더가 목적지에 도착한 순간과 주문자에게 음식을 건네준 순간은 같은 완료로 봐도 될까?

다음 글에서는 매장의 조리 과정과 라이더의 이동을 함께 표현하면서 상태 모델이 어떻게 달라졌는지 정리한다.


이 글은 초기 75초 시나리오를 기준으로 작성했다. 현재 코드는 매장·라이더 상태를 확장한 120초 시나리오로 바뀌었으며, 그 과정은 다음 회차에서 다룬다. 프로젝트는 로컬 앱과 iOS 시뮬레이터를 중심으로 개발 중이고, 실제 iPhone의 APNs 원격 갱신은 아직 검증하지 않았다.