Introduction & Requirements
저녁 시간에 고객이 음식점에서 식사를 주문했습니다. 음식점은 조리를 시작하고, 근처에는 여러 배달원이 있습니다. 서버는 누구에게 이 주문을 제안해야 할까요.
가장 가까운 배달원을 선택하면 될 것 같습니다. 그런데 음식이 준비되려면 아직 15분이 남았고, 그 배달원은 2분 뒤 음식점에 도착할 수 있습니다. 바로 배차하면 배달원이 매장에서 오래 기다립니다. 반대로 음식이 준비될 때까지 아무것도 하지 않으면, 그때는 가까운 배달원이 다른 주문을 수행하고 있을 수 있습니다.
다른 주문까지 고려하면 선택이 달라집니다. 같은 음식점에서 비슷한 시각에 준비되고 배송지도 가까운 주문이 있다면, 한 배달원이 함께 처리하는 편이 유리할 수 있습니다. 하지만 방향이 다른 주문을 묶으면 한 고객의 배송이 늦어집니다.
여기에 사람의 응답이 들어갑니다. 시스템이 배달원을 선택했다고 해서 배차가 확정되는 것은 아닙니다. DoorDash의 배달원인 Dasher는 제안을 수락하거나 거절할 수 있습니다. 공식 도움말은 앱에서 달리 안내하지 않는 한 제안 수락 시간을 50초로 설명합니다. 따라서 계산 결과, 발송된 제안, 수락된 배차를 서로 다른 상태로 다뤄야 합니다. DoorDash 제안 수락 안내
가장 단순한 구현은 주문 테이블, 배달원 위치 테이블, 가까운 배달원을 찾는 함수로 시작할 수 있습니다. 주문마다 주변 배달원을 검색하고, 한 명에게 제안하고, 거절하면 다시 검색합니다.
규모가 커지면 문제가 두 방향으로 나타납니다. 위치 갱신과 후보 계산이 늘어나 계산 시간이 길어집니다. 동시에 여러 서버가 같은 배달원과 주문을 선택하면서 서로 충돌하는 제안을 만들 수 있습니다. 계산을 빠르게 하는 것과 배차를 정확하게 확정하는 것은 별도로 해결해야 합니다.
이 시스템에서 가장 다루기 어려운 상태는 조금 전 계산에서는 적합했지만, 확정하는 순간에는 달라진 주문과 배달원의 조합입니다.
공개된 DoorDash의 설명과 이 글의 설계 가정
DoorDash는 2021년 DeepRed 소개에서 배차 후보 생성, 음식 준비 시각과 이동 시간 및 수락 가능성 예측, 최적화를 통한 제안 선택을 설명했습니다. 묶음 배달과 배차 시점을 늦추는 선택도 함께 다룹니다. 이는 당시 공개된 구조이며, 현재의 모든 구성요소가 그대로라는 뜻은 아닙니다. DoorDash DeepRed 소개
더 최근인 2026년 5월에는 공급 인센티브와 묶음 배달 수준을 함께 결정하는 최적화를 공개했습니다. 2026년 6월 DoorDash 소속 연구자들의 논문은 기존 배차 최적화기를 유지하면서 매장 상태에 따라 목적함수의 가중치를 조정하는 강화학습 방식을 설명합니다. 이 자료들만으로 전체 배차 플랫폼이 하나의 새 구조로 교체되었다고 단정할 수는 없습니다. 공급과 배차의 공동 최적화, 배차 목적함수 조정에 관한 논문
이하의 API, PostgreSQL 스키마, Stream 구성, 샤드 경계, 처리 주기, 성능 목표는 공개된 제품 동작을 만족시키기 위한 설계 가정입니다. 이 글에 제시한 조합이 DoorDash의 실제 내부 구현이라는 의미는 아닙니다. 공개 자료는 2026년 9월 14일 기준으로 확인했습니다.
Functional Requirements
고객은 주문의 배달원 탐색, 배차 확정, 픽업, 배송 완료 상태와 예상 도착 시간을 확인할 수 있습니다.
음식점은 주문 수락, 조리 지연, 준비 완료 상태를 전달할 수 있습니다.
배달원은 활동 상태와 위치를 갱신하고, 유효한 배차 제안을 수락하거나 거절할 수 있습니다.
시스템은 주문과 배달원의 현재 상태, 이동 시간, 음식 준비 시각을 고려해 제안을 생성합니다.
시스템은 경로와 품질 조건을 만족하는 주문을 묶거나 기존 경로에 추가할 수 있습니다.
시스템은 거절, 응답 만료, 취소, 배달원 이탈에 따라 제안을 철회하거나 다시 탐색합니다.
시스템은 같은 주문의 중복 배차와 서로 충돌하는 경로 변경을 차단합니다.
운영자는 지연 원인, 배차 결정 이력, 상태 불일치를 확인하고 필요한 복구를 수행할 수 있습니다.
Non-Functional Requirements
다음 수치는 모두 설계를 위한 가정이며 DoorDash의 공개 SLA가 아닙니다.
지역별 배차 계산은 p95 500ms, p99 1초 이내를 목표로 합니다. 제안할 준비가 된 주문이 다음 계산 기회를 얻기까지의 시간은 p95 5초 이내로 둡니다.
수락 API의 서버 처리 시간은 p99 300ms 이내를 목표로 합니다. 배달원의 응답 시간과 모바일 네트워크 지연은 별도로 측정합니다.
유효한 상태 변경 요청을 처리하는 API의 월간 가용성은 99.95%를 목표로 합니다. 정확한 배차 상태를 확인할 수 없다면 확정 성공을 반환하지 않습니다.
정상적으로 위치를 전송하는 배달원에 대해 조회 위치의 나이는 p95 10초 이내를 목표로 합니다. 연결이 끊긴 기기는 별도 집계하고 오래된 위치를 현재 위치처럼 사용하지 않습니다.
수락 성공 응답을 반환한 상태는 가용 영역 장애에도 보존하도록 동기 복제를 사용합니다. 원격 지역의 비동기 복제본에는 같은 수준의 데이터 보존을 가정하지 않습니다.
주문 예약, 제안 수락, 취소, 경로 변경은 강한 정합성을 요구합니다. 지도 표시와 운영 집계는 제한된 지연을 허용합니다.
고객 주소와 배달원의 정밀 위치는 업무 수행에 필요한 주체에게만 제공합니다. 원본 위치의 보관 기간, 접근 기록, 삭제 경로를 분리합니다.
배차 품질은 평균 배송 시간뿐 아니라 장시간 미배차, 음식점 대기, 고객 지연, 배달원과 지역별 기회 분포를 함께 평가합니다.
시스템 비용은 주문 수뿐 아니라 위치 이벤트 수, 후보 경로 수, 모델 추론량, 외부 경로 조회량에 비례해 관리합니다.
Out of Scope
결제 승인, 환불 계산, 배달원 정산의 내부 구현
음식점 검색, 메뉴 추천, 장바구니와 재고 관리
도로 지도 제작과 내비게이션 엔진의 내부 알고리즘
인센티브 금액을 결정하는 공급 정책의 전체 구현
장보기, 다중 매장 구매, 드론과 로봇을 포함한 이종 배송
사고 대응과 고객 지원 조직의 전체 운영 절차
여기서는 음식점 주문의 실시간 배달에 집중합니다. 주문마다 픽업과 배송 지점이 하나씩 있다고 가정하고, 설명을 위해 한 경로의 미완료 주문을 최대 두 건으로 제한합니다. 이는 실제 DoorDash의 주문 묶음 한도가 아닙니다.
Traffic & Capacity Assumptions
사용자와 주문
일간 활성 고객을 1,000만 명, 이 중 하루에 한 번 주문하는 비율을 30%로 가정하겠습니다. 하루 주문은 300만 건입니다. 검색과 탐색 요청을 제외한 주문 생성은 평균 초당 약 35건이고, 피크를 평균의 6배로 보면 초당 약 208건입니다.
주문 생성 QPS만 보면 단일 DB로도 시작할 여지가 있습니다. 다만 주문 한 건이 여러 차례의 상태 변경과 재탐색을 만들기 때문에, 이 수치를 전체 배차 시스템의 부하로 사용하면 안 됩니다.
배차 제안과 상태 이벤트
주문 한 건이 평균 세 번의 제안을 거친다고 가정하면 하루 제안 시도는 약 900만 건입니다. 평균 초당 약 104건, 피크에는 약 625건입니다. 주문당 상태 이벤트를 평균 20건으로 잡으면 하루 6,000만 이벤트가 발생합니다.
제안 시도 수는 실제 전송되는 제안 기준이며, 발송하지 않고 계산만 한 후보 수와 다릅니다. 상태 이벤트의 평균 처리량은 약 694건/초이지만, 음식점의 대량 갱신이나 장애 복구 시 재생 부하까지 고려해야 합니다.
실시간 위치
하루 동안의 평균 동시 활동 배달원을 10만 명, 피크를 30만 명으로 가정하겠습니다. 각 기기가 평균 5초마다 위치를 보내면 평균 초당 2만 건, 피크 초당 6만 건의 위치 이벤트를 받습니다. 하루 약 17억 2,800만 건입니다.
위치 갱신량은 주문 생성량보다 훨씬 많습니다. 이를 배차 확정용 RDBMS에 계속 쓰면 WAL, 인덱스 갱신, 복제에 쓰는 자원을 함께 증가시킵니다. 위치 수집 경로를 거래 상태 변경 경로와 분리할 이유가 여기에서 나옵니다.
후보 계산
피크 시점에 재계산 대상 주문이 3만 건이고, 주문당 배달원 후보를 200명씩 확인한다고 가정하겠습니다. 5초마다 전부 재평가하면 한 주기에 600만 조합, 초당 약 120만 조합을 검사합니다. 가벼운 필터로 주문당 20개를 남겨도 정밀 평가 대상은 초당 약 12만 조합입니다.
이는 시스템 전체의 처리량이며, 지역별 배차 작업으로 분산해야 합니다. 묶음 경로까지 생성하면 후보는 더 늘어납니다. 따라서 모든 조합에 원격 경로 API와 비용이 큰 모델을 적용하는 구조는 피합니다.
저장 공간과 전송량
위치 이벤트를 압축 전 평균 100바이트로 가정하면 하루 데이터 본문은 약 173GB입니다. 세 벌로 복제하면 약 518GB이며, 프로토콜 헤더와 인덱스 및 저장 형식의 오버헤드는 별도입니다. 원본 본문만 7일 보관해도 약 1.2TB가 필요합니다.
반면 최신 위치만 배달원당 200바이트로 유지하면 피크 30만 명의 순수 데이터는 약 60MB입니다. 실제 메모리는 자료구조와 복제 때문에 더 크지만, 최신 상태와 전체 이력을 나누면 온라인 저장량을 크게 줄일 수 있습니다.
상태 이벤트를 평균 500바이트로 잡으면 하루 약 30GB입니다. 대용량 경로 후보와 디버깅 자료는 이 이벤트에 모두 넣지 않고 별도 Object Storage에 저장합니다.
Core Entities & Data Modeling
먼저 어떤 데이터가 즉시 정확해야 하는지 구분하겠습니다.
지도에 표시된 배달원 위치가 몇 초 늦는 것은 제한적으로 허용할 수 있습니다. 하지만 이미 취소된 주문을 수락하거나, 두 제안이 같은 배달원의 경로를 동시에 덮어쓰는 것은 허용할 수 없습니다.
따라서 배차 권한을 바꾸는 데이터와 배차 판단에 참고하는 데이터를 분리합니다.
원본 상태를 조회에 맞게 미리 합쳐 둔 데이터를 Read Model이라고 부르겠습니다. 주문 원본과는 별도로 갱신되므로 마지막 반영 시각과 버전이 필요합니다.
배차 소유권과 확정 상태처럼 조건 검사와 변경을 같은 트랜잭션으로 처리해야 하는 데이터는 RDBMS에 둡니다. 배달 작업, 예약, 제안, 확정 경로가 여기에 포함됩니다.
주문, 제안, 픽업, 위치 이벤트처럼 비동기 전달과 재생, 소비자별 독립 처리가 필요한 데이터는 Event Stream으로 보냅니다.
위치 이력, 학습 데이터, 모델 파일, 결정 상세처럼 대용량이고 배치 처리가 적합한 데이터는 Object Storage에 보관합니다.
주변 배달원 검색과 도로 연결 관계는 공간 인덱스와 도로 Graph에 둡니다. 매장 대기, 지역 공급, 최근 이동 속도는 Online Feature Store에 시간 정보와 함께 저장합니다. 운영 분석, 실험, 정산 대조용 집계는 Warehouse로 분리합니다.
이 설계에서는 Vector Index가 기본 요구사항이 아닙니다. 물리적 접근 가능성과 주문 수행 조건을 만족하는 후보를 찾는 일이 의미적으로 비슷한 콘텐츠를 찾는 일보다 중요하기 때문입니다.
주문 상태와 배차 상태를 구분하기
결제 완료 여부는 Commerce 시스템이 소유합니다. 배차 시스템은 그 주문의 배송 수행 상태를 별도로 소유합니다.
여기서 ‘소유한다’는 말은 해당 상태를 확정하는 쓰기 권한이 그 시스템에 있다는 뜻입니다. 고객이 취소를 요청하면 Commerce DB에서 취소 플래그만 바꾸고 끝내지 않습니다. 배차 시스템에도 취소 명령이 전달되어 진행 중인 제안과 경로를 함께 조정해야 합니다.
배차 작업은 다음 상태를 가질 수 있습니다.
상태의미waiting탐색 대상이거나 계획된 배차 시점까지 대기offered유효한 제안을 위해 주문이 예약됨assigned배달원이 수락했고 경로에 반영됨picked_up음식 인수가 확인됨delivered배송 완료가 확인됨cancelled허용된 취소 절차가 완료됨
수락 후 이탈로 다시 배차하는 경우에는 기존 배차를 이력으로 남기고 버전을 올려 waiting으로 전환합니다. 이미 음식을 인수했다면 같은 방식으로 되돌리지 않고 별도의 회수·인계 절차로 보냅니다.
영속 상태의 스키마
다음은 하나의 배차 소유 영역을 같은 PostgreSQL 샤드에 배치하는 예시입니다. 배차 소유 영역은 동일한 주문과 배달원 상태를 트랜잭션으로 변경할 수 있는 논리적 범위이며, 세부 지리 격자와는 다릅니다.
CREATE TABLE delivery_tasks (
id UUID PRIMARY KEY,
dispatch_domain_id TEXT NOT NULL,
order_id TEXT NOT NULL,
merchant_id BIGINT NOT NULL,
request_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'waiting'
CHECK (status IN (
'waiting', 'offered', 'assigned',
'picked_up', 'delivered', 'cancelled'
)),
reserved_offer_id UUID,
assigned_dasher_id BIGINT,
merchant_ready_at TIMESTAMPTZ,
version BIGINT NOT NULL DEFAULT 1 CHECK (version > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
UNIQUE (dispatch_domain_id, order_id),
UNIQUE (dispatch_domain_id, request_key),
CHECK (
(status = 'offered' AND reserved_offer_id IS NOT NULL)
OR
(status <> 'offered' AND reserved_offer_id IS NULL)
)
);
CREATE TABLE dasher_dispatch_state (
dasher_id BIGINT PRIMARY KEY,
dispatch_domain_id TEXT NOT NULL,
availability TEXT NOT NULL
CHECK (availability IN ('online', 'paused', 'offline')),
ownership_epoch BIGINT NOT NULL CHECK (ownership_epoch > 0),
route_version BIGINT NOT NULL DEFAULT 0,
route_stops JSONB NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(route_stops) = 'array'),
reserved_offer_id UUID,
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE TABLE offers (
id UUID PRIMARY KEY,
dispatch_domain_id TEXT NOT NULL,
dasher_id BIGINT NOT NULL
REFERENCES dasher_dispatch_state(dasher_id),
idempotency_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN (
'pending', 'accepted', 'declined', 'expired', 'revoked'
)),
base_route_version BIGINT NOT NULL,
ownership_epoch BIGINT NOT NULL,
proposed_route JSONB NOT NULL
CHECK (jsonb_typeof(proposed_route) = 'array'),
expires_at TIMESTAMPTZ NOT NULL,
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
UNIQUE (dispatch_domain_id, idempotency_key)
);
CREATE UNIQUE INDEX one_pending_offer_per_dasher
ON offers(dasher_id)
WHERE status = 'pending';
CREATE TABLE offer_tasks (
offer_id UUID NOT NULL REFERENCES offers(id),
task_id UUID NOT NULL REFERENCES delivery_tasks(id),
reserved_task_version BIGINT NOT NULL,
PRIMARY KEY (offer_id, task_id)
);
CREATE TABLE mutation_results (
actor_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
operation TEXT NOT NULL,
request_hash TEXT NOT NULL,
response JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (actor_id, idempotency_key)
);
CREATE TABLE outbox_events (
event_id UUID PRIMARY KEY,
aggregate_id UUID NOT NULL,
event_type TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
aggregate_version BIGINT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
published_at TIMESTAMPTZ,
UNIQUE (aggregate_id, event_type, idempotency_key)
);
CREATE INDEX waiting_tasks_by_domain
ON delivery_tasks(dispatch_domain_id, created_at)
WHERE status = 'waiting';
CREATE INDEX pending_offers_by_expiry
ON offers(expires_at)
WHERE status = 'pending';request_key와 idempotency_key는 재시도를 같은 작업으로 식별합니다. request_hash는 같은 키로 다른 주문이나 다른 경로를 보내는 실수를 차단합니다. 해시는 서버가 정규화한 요청으로 계산하고, 키에는 인증된 호출자 또는 서비스 범위를 포함합니다.
version은 오래된 화면이나 계산 결과가 최신 상태를 덮어쓰는 것을 막습니다. created_at은 최초 발생 시점을 보존하고 updated_at은 마지막 변경 시점을 나타냅니다. 상태를 boolean으로 표현하지 않는 이유는 예약, 수락, 픽업, 취소가 서로 다른 전이 조건을 갖기 때문입니다.
배달원당 pending 제안은 하나로 제한하지만, 그 제안에 주문 여러 건이 들어갈 수 있습니다. 이미 수행 중인 경로에 주문을 추가하는 경우에도 proposed_route에는 기존 미완료 작업을 보존한 전체 경로 변경안을 넣습니다.
route_stops를 JSON으로 저장한 것은 짧고 제한된 경로를 한 번에 버전 관리하기 위한 선택입니다. 서버는 픽업 선행 조건, 중복 지점, 기존 주문 누락, 최대 적재량을 검증해야 합니다. 경로가 길어지고 지점별 조회가 늘어나면 route와 route_stop 테이블로 분리합니다.
위 DDL만으로 업무 규칙 전체가 보장되지는 않습니다. 같은 소유 영역인지, 모든 주문이 예약 가능한지, 제안이 만료되지 않았는지는 뒤에서 설명할 트랜잭션 절차로 검사합니다.
RDBMS에서 제외할 데이터
원본 GPS 갱신
배달원 위치는 수초마다 변하지만 대부분의 배차 계산에는 최신 위치만 필요합니다. 원본은 위치 Stream으로 받고 최신 상태 저장소와 공간 인덱스를 갱신합니다. 장기 분석에 필요한 범위만 Object Storage로 보냅니다.
배차 확정 시 사용한 위치의 시각과 식별자는 결정 이력에 남길 수 있습니다. 이것이 모든 GPS 이벤트를 배차 DB에 저장해야 한다는 뜻은 아닙니다.
생성했다가 버리는 경로 후보
한 주문을 위해 수백 개의 조합을 검토하더라도 실행하는 것은 일부입니다. 후보 전체를 OLTP에 저장하면 실제 배차보다 계산 중간 결과를 관리하는 비용이 커질 수 있습니다.
온라인에서는 메모리에서 계산하고, 재현에 필요한 후보 요약과 점수 및 모델 버전은 별도의 결정 로그에 남깁니다. 전체 후보 덤프는 샘플링하거나 문제 상황에 한정합니다.
모델 입력 벡터와 짧은 시간의 집계
최근 매장 대기 시간이나 지역 공급량은 Stream 처리로 갱신하고 Online Feature Store에서 조회합니다. 값뿐 아니라 집계 구간, 마지막 반영 시각, schema version을 함께 저장합니다.
큰 파일과 분석 원본
배송 증빙 이미지, 모델 파일, 지도 파일, 원본 분석 이벤트는 Object Storage에 둡니다. RDBMS에는 객체 식별자, 접근 권한, 처리 상태처럼 트랜잭션에 필요한 정보만 저장합니다.
API Signatures
배송 작업 생성
결제와 음식점 접수 조건을 만족한 Commerce 시스템이 내부 API를 호출합니다. 고객 앱이 임의로 배송 작업을 만드는 API는 아닙니다.
POST /internal/v1/delivery-tasks
Authorization: Bearer <service-token>
Idempotency-Key: commerce-ord_842-delivery
Content-Type: application/json
{
"order_id": "ord_842",
"merchant_id": 901,
"dispatch_domain_id": "sj-03"
}{
"task_id": "62d88720-01f0-4d44-9b2c-55f9c25019e7",
"status": "waiting",
"version": 1
}서버는 작업과 Outbox 기록이 커밋될 때까지 기다립니다. 후보 탐색이나 배달원의 수락까지 기다리지는 않습니다. 같은 키와 같은 요청의 재시도에는 기존 작업을 반환하고, 같은 키로 다른 요청을 보내면 409를 반환합니다.
위치 갱신
POST /v1/dashers/me/location
Authorization: Bearer <dasher-token>
Content-Type: application/json
{
"session_id": "location-session-73",
"session_epoch": 8,
"sequence": 1842,
"recorded_at": "2026-09-14T19:00:05Z",
"latitude": 37.3382,
"longitude": -121.8863,
"accuracy_meters": 12
}{
"accepted_sequence": 1842,
"status": "queued"
}202 응답은 수집 경로가 이벤트를 받아들였다는 뜻이며, 공간 인덱스 반영까지 끝났다는 뜻은 아닙니다. 동일 세션의 중복은 sequence로 제거합니다. 새 세션의 epoch는 서버가 발급하고, 이전 epoch의 늦은 이벤트는 최신 위치를 덮어쓰지 못하게 합니다.
클라이언트 시간만 신뢰하면 잘못된 기기 시계가 위치를 미래로 보낼 수 있습니다. received_at을 서버에서 추가하고, recorded_at의 허용 오차와 위치 정확도를 함께 검사합니다.
제안 조회와 수락
GET /v1/dashers/me/offers/current
Authorization: Bearer <dasher-token>{
"offer_id": "78c7fcaf-a76a-4f34-8600-5c02ba7372a5",
"status": "pending",
"offer_version": 1,
"base_route_version": 12,
"expires_at": "2026-09-14T19:01:00Z",
"task_ids": ["62d88720-01f0-4d44-9b2c-55f9c25019e7"],
"additional_distance_meters": 2300,
"estimated_duration_seconds": 1100
}POST /v1/offers/78c7fcaf-a76a-4f34-8600-5c02ba7372a5/accept
Authorization: Bearer <dasher-token>
Idempotency-Key: accept-attempt-9841
Content-Type: application/json
{
"expected_offer_version": 1,
"expected_route_version": 12
}{
"offer_id": "78c7fcaf-a76a-4f34-8600-5c02ba7372a5",
"status": "accepted",
"route_version": 13
}이 API는 제안, 주문 예약, 배달원 경로를 같은 트랜잭션으로 확정한 뒤 200을 반환합니다. 푸시 알림과 고객 화면 갱신은 비동기입니다.
응답이 유실되면 앱은 같은 Idempotency-Key로 재시도합니다. 이미 성공한 요청이라면 저장한 성공 결과를 반환합니다. 제안이 만료되었거나 경로가 변경되었다면 409와 현재 상태를 반환하고, 앱은 최신 경로를 다시 조회합니다.
거절은 같은 구조의 decline API로 처리합니다. 거절의 주체는 인증된 배달원이며, 요청 본문의 dasher_id를 신뢰하지 않습니다.
취소와 진행 상태 변경
POST /v1/delivery-tasks/62d88720-01f0-4d44-9b2c-55f9c25019e7/cancel
Authorization: Bearer <customer-token>
Idempotency-Key: cancel-ord_842-1
Content-Type: application/json
{
"expected_version": 7,
"reason": "customer_request"
}공개 API의 인증과 주문 소유권 검사를 통과하면 취소 조정 서비스가 배차 소유 영역으로 명령을 전달합니다. 이 예시는 수수료와 환불 정책의 상세 처리를 생략합니다.
배차 상태까지 취소가 확정되면 cancelled를 반환합니다. 여러 서비스의 처리가 남았다면 202와 cancellation_pending을 반환합니다. 이 상태를 최종 취소 완료로 표시하지 않습니다.
음식점의 준비 완료와 배달원의 픽업·배송 완료 API도 Idempotency-Key와 expected_version을 받습니다. 픽업은 해당 주문을 수행 중인 배달원만 기록할 수 있고, 이미 취소된 주문이나 이전 배차의 자격으로는 변경할 수 없습니다.
배송 상태 동기화
GET /v1/delivery-tasks/62d88720-01f0-4d44-9b2c-55f9c25019e7/events
Authorization: Bearer <customer-token>
Accept: text/event-stream
Last-Event-ID: task-62d88720-v7연결이 끊겼다가 돌아오면 마지막 수신 이벤트 이후를 재전송합니다. 이벤트 보관 범위를 벗어났다면 최신 snapshot과 version을 받아 이어갑니다. 지도 좌표는 유실 가능한 최신값 스트림으로 전달하고, 배차·취소 같은 업무 상태 이벤트와 별도로 취급합니다.
High-Level Design
주문 상태와 제안 전달 경로
Assignment Service는 상태 변경의 최종 권한을 가집니다. 배차 계산기가 좋은 후보를 찾더라도 이 서비스를 통과해야 실제 제안과 경로가 바뀝니다.
Read Model과 알림은 커밋된 이벤트를 받아 갱신됩니다. 알림 실패가 수락 트랜잭션을 롤백시키지 않으며, 알림이 성공했다고 배차 상태가 자동으로 확정되는 것도 아닙니다.
위치 수집과 배차 계산 경로
주변 후보를 찾는 과정은 조금 늦게 갱신된 조회 상태를 사용할 수 있습니다. 하지만 마지막 예약 트랜잭션에서는 주문과 배달원 경로가 여전히 유효한지 다시 확인합니다.
배차 계산기는 DB 잠금을 잡고 모델 응답을 기다리지 않습니다. 계산은 잠금 밖에서 수행하고, 짧은 확정 단계에서 버전을 비교합니다. 충돌하면 그 제안만 다시 계산하도록 되돌립니다.
지역 전체의 계산 결과가 반드시 한 트랜잭션으로 확정될 필요는 없습니다. 한 배달원에게 보내는 제안과 그 안의 주문 묶음이 원자적 변경 단위입니다. 일부 제안이 충돌해도 이미 유효하게 확정된 다른 제안까지 취소하지 않습니다.
배차 제안과 확정의 정합성
계산 결과는 예약이 아니다
계산기 A와 계산기 B가 동시에 같은 주문을 읽을 수 있습니다. 둘 다 그 주문이 waiting이라고 판단하는 것 자체를 막을 필요는 없습니다.
실제 문제가 되는 순간은 둘 다 유효한 제안을 생성하는 경우입니다. 이를 막기 위해 제안 생성 트랜잭션은 배달원 상태와 관련 주문을 잠그고, 상태와 버전을 검사한 뒤 예약합니다.
모든 경로 변경은 배달원 행, 관련 작업 행의 ID 정렬 순서, 제안 행 순으로 잠급니다. 취소와 만료 처리도 같은 순서를 따릅니다. 잠글 대상을 찾기 위해 미리 읽은 정보가 잠금 후 달라졌다면 트랜잭션을 다시 시작합니다.
주문 묶음의 일부만 예약되었다면 전체를 롤백합니다. 새 제안의 주문뿐 아니라 기존 경로에서 영향을 받는 미완료 작업도 검사해야 합니다.
제안 생성
예약 단계는 다음 조건을 확인합니다.
배달원이 해당 소유 영역에 속하고 현재 제안을 받을 수 있습니다.
다른 pending 제안이 없으며 ownership_epoch가 일치합니다.
계산의 base_route_version과 현재 route_version이 같습니다.
추가할 주문이 모두 waiting이고 각 version이 계산에서 사용한 값과 같습니다.
기존 수락 작업이 새 경로에서 사라지지 않고, 용량과 수행 조건을 만족합니다.
조건을 만족하면 offers와 offer_tasks를 생성하고, 작업을 offered로 바꾸며 version을 올립니다. 이때 올린 버전을 reserved_task_version에 저장합니다. 배달원과 주문의 reserved_offer_id도 함께 기록합니다.
제안의 유효 기간은 영속 상태입니다. Redis TTL은 만료 작업을 깨우는 용도로 사용할 수 있지만, TTL이 끝났다는 사실만으로 주문 예약을 해제하지 않습니다.
수락과 만료가 동시에 발생한다면
수락 요청은 다음 순서로 처리합니다.
BEGIN
배달원 행 잠금
같은 키의 성공 결과가 있으면 요청 내용 비교 후 기존 결과 반환
관련 작업 행을 ID 순으로 잠금
제안 행 잠금
인증된 배달원과 제안 수신자 일치 확인
소유권 epoch와 현재 경로 version 확인
모든 주문의 예약 ID와 예약 version 확인
현재 업무 상태와 경로 제약 확인
DB 현재 시각으로 pending 제안의 유효 기간 검사
유효한 경우에만 accepted로 조건부 변경
주문들을 assigned로 변경하고 예약 해제
배달원 경로 변경과 route_version 증가
배달원의 제안 예약 해제
성공 응답과 Outbox 이벤트 기록
COMMIT검사 실패나 예상과 다른 변경 행 수는 전체 롤백으로 연결합니다. 예외를 잡아서 일부 변경만 커밋하면 안 됩니다.
앞의 잠금과 검사를 수행한 같은 트랜잭션에서, 제안 전이는 다음과 같이 조건부로 실행할 수 있습니다. 달러 기호의 매개변수는 DB 드라이버가 바인딩합니다.
UPDATE offers
SET status = 'accepted',
version = version + 1,
updated_at = clock_timestamp()
WHERE id = $1
AND dasher_id = $2
AND status = 'pending'
AND version = $3
AND base_route_version = $4
AND ownership_epoch = $5
AND expires_at > clock_timestamp()
RETURNING id, version;반환 행이 정확히 하나여야 나머지 주문과 경로 변경을 진행합니다. 이 UPDATE만 따로 커밋하면 주문 예약과 경로까지 원자적으로 바뀌는 보장은 얻을 수 없습니다.
만료 판단에는 애플리케이션 서버의 시계 대신 DB 시각을 사용합니다. 특히 PostgreSQL의 CURRENT_TIMESTAMP는 트랜잭션 시작 시각을 나타내므로, 잠금 대기 후의 실제 시각을 확인하려면 clock_timestamp()를 사용합니다. 수락 유효성은 잠금을 획득한 뒤 조건부 전이를 실행하는 시점을 기준으로 정의합니다. PostgreSQL 날짜와 시간 함수
만료 작업과 수락 요청 중 같은 잠금을 먼저 획득해 유효한 전이를 수행한 작업이 상태를 결정합니다. 먼저 수락되었다면 만료 작업은 아무것도 해제하지 않습니다. 먼저 만료되었다면 늦은 수락은 성공할 수 없습니다.
클라이언트가 만료 전에 버튼을 눌렀더라도 요청이 서버에 늦게 도착할 수 있습니다. 이 경우 앱은 수락을 확정 표시하지 않고 최신 제안을 조회합니다.
취소와 수락의 충돌
취소와 수락도 같은 주문과 경로 상태를 잠급니다. 취소가 먼저 확정되면 해당 제안은 revoked가 되고 수락은 실패합니다. 수락이 먼저 확정되면 취소는 assigned 상태에 적용되는 취소 절차를 따릅니다.
이미 picked_up이라면 자동으로 waiting으로 돌리지 않습니다. 음식의 실제 위치와 소유 상태를 확인해야 하므로, 고객에게 처리 중 상태를 보여주고 인계 또는 지원 절차로 연결합니다.
이는 DB의 상태 변경을 되돌리는 것과 현실에서 이미 수행한 행동을 되돌리는 일이 다르기 때문입니다.
Transactional Outbox와 재시도
작업 생성과 이벤트 발행을 별도로 처리하면 DB 커밋 직후 서버가 종료되었을 때 배차 계산기가 새 주문을 모를 수 있습니다. 같은 DB 트랜잭션 안에 작업과 발행할 이벤트를 함께 기록하면 이 간격을 줄일 수 있습니다.
아래 함수는 앞의 테이블을 사용합니다. 중복 요청에서는 새로 만든 UUID를 사용하지 않고 실제 저장된 작업을 다시 조회합니다.
CREATE OR REPLACE FUNCTION create_delivery_task(
p_domain TEXT,
p_order_id TEXT,
p_merchant_id BIGINT,
p_request_key TEXT,
p_request_hash TEXT
)
RETURNS UUID
LANGUAGE plpgsql
AS $$
DECLARE
task_row delivery_tasks%ROWTYPE;
BEGIN
INSERT INTO delivery_tasks (
id, dispatch_domain_id, order_id, merchant_id,
request_key, request_hash
)
VALUES (
gen_random_uuid(), p_domain, p_order_id, p_merchant_id,
p_request_key, p_request_hash
)
ON CONFLICT (dispatch_domain_id, request_key) DO NOTHING;
SELECT *
INTO STRICT task_row
FROM delivery_tasks
WHERE dispatch_domain_id = p_domain
AND request_key = p_request_key
FOR UPDATE;
IF task_row.request_hash <> p_request_hash
OR task_row.order_id <> p_order_id
OR task_row.merchant_id <> p_merchant_id THEN
RAISE EXCEPTION 'idempotency key payload mismatch';
END IF;
INSERT INTO outbox_events (
event_id, aggregate_id, event_type,
idempotency_key, aggregate_version, payload
)
VALUES (
gen_random_uuid(), task_row.id, 'DeliveryTaskCreated',
p_request_key, 1,
jsonb_build_object(
'task_id', task_row.id,
'order_id', task_row.order_id,
'merchant_id', task_row.merchant_id,
'dispatch_domain_id', task_row.dispatch_domain_id
)
)
ON CONFLICT (
aggregate_id, event_type, idempotency_key
) DO NOTHING;
RETURN task_row.id;
END;
$$;함수 호출과 커밋이 성공한 뒤에만 API 성공 응답을 반환합니다. 이 예시는 PostgreSQL의 READ COMMITTED를 기준으로 합니다. 충돌한 INSERT 다음의 SELECT는 새로운 문장 snapshot에서 실제 저장된 행을 읽습니다. 더 높은 격리 수준에서 serialization failure가 발생하면 같은 키로 전체 트랜잭션을 재시도합니다. PostgreSQL 트랜잭션 격리
동일 주문을 다른 request_key로 생성하면 order_id의 고유 제약이 막습니다. 서버는 이를 중복 작업 생성 오류로 처리하고 기존 주문의 상태를 조회하게 합니다. 주문의 소유 영역은 서버가 정하며 재시도 중 변경하지 않습니다.
Relay는 발행 대상 행을 짧게 선점한 뒤 Stream으로 전달합니다. publish 성공 직후 published_at 기록 전에 종료될 수 있으므로 같은 이벤트가 다시 전달될 수 있습니다.
따라서 보장하는 것은 at-least-once delivery와 멱등적인 소비자 처리입니다. 소비자는 event_id를 중복 제거하고, 업무 상태 변경과 소비 완료 기록을 자신의 저장소에서 원자적으로 처리합니다. 단순히 메모리에서 ‘이미 봤다’고 표시하는 것으로는 재시작을 견딜 수 없습니다.
Notification Worker는 발송 직전에 제안이 아직 pending이고 유효한지 확인합니다. 알림에는 제안 ID와 버전을 넣고, 앱은 원본 조회로 현재 상태를 확인합니다. 지연된 푸시 자체를 수락 가능한 제안의 증거로 사용하지 않습니다.
같은 aggregate의 여러 이벤트가 Relay 병렬 처리로 역전되지 않도록 발행 경로를 정렬하거나, 소비자가 aggregate_version의 간격을 감지해 재조회합니다. 최종 snapshot을 적용하는 소비자는 낮은 버전을 무시할 수 있지만, 모든 전이를 수행해야 하는 소비자는 누락된 이벤트를 건너뛰면 안 됩니다.
Outbox를 정리한 뒤 오래된 요청을 재실행하는 문제도 있습니다. 멱등성 키와 소비자 중복 제거 기록의 보관 기간을 재시도·재생 가능 기간에 맞추고, 오래된 요청은 tombstone 또는 요청 만료 정책으로 차단합니다. 버전 1 생성 이벤트가 재생되더라도 이미 진행한 작업을 waiting으로 되돌리지 않습니다.
Problem Framing
배차 모델의 질문은 ‘이 배달원이 좋은가’처럼 하나로 정의하기 어렵습니다. 주문과 배달원을 연결했을 때 발생할 여러 결과를 예측하고, 그 결과로 실행 가능한 경로 조합을 선택해야 합니다.
예측 대상출력 예시배차 결정에 미치는 영향음식 준비준비 완료 시각의 분포너무 이른 도착과 음식 방치 시간을 비교이동매장까지와 배송지까지의 소요 시간이동 가능한 후보와 경로 평가현장 처리주차, 매장 인수, 건물 진입 시간지도상의 주행 시간에 없는 지연 반영제안 수락P(accept given offer and context)거절 후 재탐색에 필요한 시간 고려배송 지연P(delivered after promised time)평균이 비슷한 경로의 지연 위험 비교
이 예측값이 제품 목표 자체는 아닙니다. 수락률만 높이면 일부 주문이 계속 제외될 수 있습니다. 이동 거리만 줄이면 준비가 늦은 음식점에 배달원이 오래 대기할 수 있습니다.
여기서는 약속한 배송 품질을 유지하면서 고객 대기, 배달원 대기와 이동, 장시간 미배차를 줄이는 것을 목표로 합니다. 개별 예측은 그 결정을 지원하는 입력입니다.
가장 가까운 배달원을 고르면 생기는 문제
음식이 모두 준비되어 있고, 각 배달원이 한 주문만 수행한다고 단순화하겠습니다. 아래 숫자는 설명을 위한 이동 시간 가정입니다.
배달원주문 A의 매장까지주문 B의 매장까지D12분3분D23분15분
주문 A부터 처리하며 가장 가까운 D1을 선택하면, B는 D2를 기다려야 합니다. 매장까지 이동 시간의 합은 17분입니다.
A를 D2에게, B를 D1에게 연결하면 합은 6분입니다. 주문별로 독립적으로 가장 좋은 후보를 고르는 것과, 같은 배달원을 공유하는 주문들을 함께 고려하는 것은 결과가 다릅니다.
작은 서비스에서는 순차적인 탐욕 선택으로 시작할 수 있습니다. 하지만 인접 주문이 같은 배달원을 두고 자주 경쟁한다면 지역 단위의 공동 선택이 필요합니다.



