음식 배달 시스템 설계: 주문, 음식점, 배달원 및 실시간 상태
1. 개요
음식 배달 앱은 한 주문에 세 개의 이해관계자가 작용합니다: 주문한 고객, 확인하는 음식점, 배송하는 배달원. 이 세 가지 작업은 동시에, 불안정한 모바일 네트워크에서, 점심 시간에 집중됩니다.
이 글은 전체 설계 과정을 다룹니다: 요구사항, 데이터 모델, API, 상태머신, 배달원에게 중복 없이 주문을 분배하는 방법, 데이터베이스와 Kafka 간의 일관성 유지, 고객에게 실시간 상태를 전달하는 방법. 이를 끝내고 나면, 실제로 적용하거나 시스템 설계 면접에 가져갈 구체적인 설계 프레임워크를 갖게 될 것입니다.
2. 요구사항
2.1. 기능 요구사항
- 고객은 메뉴를 보고, 주문하고, 결제하고, 상태를 추적하고, 배달원의 위치를 확인합니다.
- 음식점은 주문을 받고, 수락하거나 거부하고, 음식 준비가 완료되었다고 알립니다.
- 배달원은 온라인 상태를 켜고, 정기적으로 위치를 보내고, 제안된 주문을 수락하거나 거부하고, 픽업 및 배송을 업데이트합니다.
- 고객은 음식점이 조리를 시작하기 전에 주문을 취소할 수 있습니다.
2.2. 규모 및 제약사항
계산의 근거를 갖기 위해 다음 숫자들을 가정합니다:
주문: 하루 100만 주문, 피크 시간(11~13시)이 ~30%를 차지
→ 평균 피크 시간 ~42건/초, 급증 ~200건/초
배달원: 온라인 5만 명 배달원, 4초마다 위치 전송
→ ~초당 1만 2500건의 위치 기록
추적: 고객이 추적 화면을 열면 → 수십만 개의 실시간 연결
이 숫자들로부터 세 가지 주요 제약사항이 나옵니다:
- 주문은 강한 일관성이 필요합니다. 주문을 잃을 수 없으며, 한 주문을 두 배달원에게 할당할 수 없습니다.
- 배달원 위치는 약한 일관성을 허용합니다. 한 위치 지점을 잃는 것은 괜찮지만, 기록은 매우 빨라야 합니다. 위치를 관계형 데이터베이스에 저장하지 않습니다.
- 주문 배치는 멱등성이 있어야 합니다. 모바일 네트워크 때문에 클라이언트가 요청을 다시 보낼 수 있습니다.
3. 전체 아키텍처
고객/음식점/배달원 앱
│ REST + SSE/WebSocket
▼
API Gateway (rate limit, auth)
│
┌────────┼───────────────┬──────────────────┐
▼ ▼ ▼ ▼
Order Restaurant Dispatch Tracking
Service Service Service Service
│ PostgreSQL │ Redis GEO │ Redis (위치, pub/sub)
│ + outbox │ │
└──────────► Kafka (order-events) ◄────────┘
Order Service는 주문의 단일 정보 소스입니다. 다른 서비스들은 Kafka의 이벤트에 반응합니다. 배달원 위치는 Redis에만 존재합니다.
아래의 예제들은 다음 의존성을 사용합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
4. 데이터 모델
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
restaurant_id BIGINT NOT NULL,
shipper_id BIGINT,
status VARCHAR(20) NOT NULL,
total_amount NUMERIC(12,0) NOT NULL,
idempotency_key VARCHAR(64) NOT NULL,
version INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (customer_id, idempotency_key)
);
CREATE TABLE order_items (
order_id BIGINT REFERENCES orders(id),
dish_id BIGINT NOT NULL,
dish_name VARCHAR(200) NOT NULL,
price NUMERIC(12,0) NOT NULL,
quantity INT NOT NULL
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_id BIGINT NOT NULL,
event_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
published BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX idx_orders_restaurant_active ON orders (restaurant_id, status);
CREATE INDEX idx_outbox_unpublished ON outbox (id) WHERE published = false;
몇 가지 주의할 점이 있습니다:
UNIQUE (customer_id, idempotency_key)는 클라이언트가 요청을 다시 보낼 때 중복 주문을 방지합니다.version은 여러 당사자가 하나의 주문을 동시에 업데이트할 때 낙관적 잠금에 사용됩니다.order_items는 주문 시점의dish_name과price를 저장합니다. 음식점이 나중에 가격을 변경해도 이전 청구서는 영향을 받지 않습니다.outbox테이블은 섹션 8의 일관성 부분에서 사용됩니다.
5. 주문 상태머신
주문은 유효한 단계를 따라서만 진행될 수 있습니다. 이 규칙을 전체 서비스에 if 문으로 분산시키지 말고 한 곳에서 코드화해야 합니다:
public enum OrderStatus {
PLACED, CONFIRMED, PREPARING, READY_FOR_PICKUP, PICKED_UP, DELIVERED, CANCELLED;
private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = Map.of(
PLACED, Set.of(CONFIRMED, CANCELLED),
CONFIRMED, Set.of(PREPARING, CANCELLED),
PREPARING, Set.of(READY_FOR_PICKUP),
READY_FOR_PICKUP, Set.of(PICKED_UP),
PICKED_UP, Set.of(DELIVERED),
DELIVERED, Set.of(),
CANCELLED, Set.of()
);
public boolean canTransitionTo(OrderStatus next) {
return TRANSITIONS.get(this).contains(next);
}
}
TRANSITIONS는 각 상태의 유효한 다음 상태를 나열합니다. 고객은 PLACED 또는 CONFIRMED에서만 취소할 수 있습니다. 음식점이 이미 조리 중(PREPARING)일 때는 CANCELLED로 돌아갈 길이 없습니다. 마지막 두 상태는 다음 단계가 없습니다.
이 규칙을 검사하는 테스트:
class OrderStatusTest {
@Test
void customerCanCancelBeforePreparing() {
assertThat(OrderStatus.PLACED.canTransitionTo(OrderStatus.CANCELLED)).isTrue();
assertThat(OrderStatus.CONFIRMED.canTransitionTo(OrderStatus.CANCELLED)).isTrue();
}
@Test
void cannotCancelOnceKitchenStarted() {
assertThat(OrderStatus.PREPARING.canTransitionTo(OrderStatus.CANCELLED)).isFalse();
}
@Test
void deliveredIsTerminal() {
assertThat(OrderStatus.values())
.noneMatch(OrderStatus.DELIVERED::canTransitionTo);
}
}
6. API
POST /api/orders 고객이 주문 배치(Idempotency-Key 헤더)
GET /api/orders/{id} 주문 상세 보기
POST /api/orders/{id}/cancel 고객이 주문 취소
POST /api/restaurant/orders/{id}/confirm 음식점이 확인
POST /api/restaurant/orders/{id}/ready 음식점이 준비 완료 알림
PUT /api/shippers/me/location 배달원이 위치 전송 {lat, lng}
POST /api/shippers/me/offers/{orderId}/accept 배달원이 주문 수락
POST /api/orders/{id}/pickup | /deliver 배달원이 업데이트
GET /api/orders/{id}/stream SSE: 상태 + 배달원 위치
API는 일반적인 PATCH status=... 대신 작업(/confirm, /accept)에 따라 설계되었습니다. 이 방식은 권한 확인이 더 쉽습니다: 주문의 음식점만 /confirm을 호출할 수 있고, 할당된 배달원만 /pickup을 호출할 수 있습니다.
7. 충돌 처리
7.1. 낙관적 잠금으로 상태 업데이트
고객이 취소를 클릭하는 정확한 순간 음식점이 확인을 클릭한다고 가정합니다. 둘 다 PLACED를 읽으면 둘 다 자신이 유효하다고 생각합니다. 조건이 있는 UPDATE 문으로 해결합니다:
@Repository
public class OrderRepository {
private final JdbcClient jdbc;
public OrderRepository(JdbcClient jdbc) {
this.jdbc = jdbc;
}
public boolean transition(long orderId, OrderStatus from, OrderStatus to, int version) {
if (!from.canTransitionTo(to)) {
throw new IllegalStateException(from + " -> " + to + "는 유효하지 않음");
}
int updated = jdbc.sql("""
UPDATE orders SET status = :to, version = version + 1
WHERE id = :id AND status = :from AND version = :version
""")
.param("to", to.name())
.param("id", orderId)
.param("from", from.name())
.param("version", version)
.update();
return updated == 1;
}
}
데이터베이스는 두 UPDATE 문 중 하나만 version 조건과 일치하도록 보장합니다. 승자는 true를 얻습니다. 패자는 false를 얻고 최신 상태와 함께 HTTP 409를 반환하여 앱이 “음식점이 방금 주문을 확인했습니다”를 표시하도록 합니다.
7.2. 배달원에게 주문 분배
이것이 가장 어려운 부분입니다. 음식점 근처의 배달원을 찾고 각 주문이 하나의 배달원만 갖도록 보장해야 합니다.
단계 1: Redis GEO로 위치 저장. 배달원이 위치를 보낼 때마다 GEO sorted set에 기록하고 heartbeat 키를 갱신합니다:
@Service
public class ShipperLocationService {
private final StringRedisTemplate redis;
public ShipperLocationService(StringRedisTemplate redis) {
this.redis = redis;
}
public void update(long shipperId, double lat, double lng) {
String id = String.valueOf(shipperId);
redis.opsForGeo().add("shipper:geo", new Point(lng, lat), id);
redis.opsForValue().set("shipper:alive:" + id, "1", Duration.ofSeconds(30));
}
public List<String> findNearby(double lat, double lng) {
var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance().sortAscending().limit(20);
var results = redis.opsForGeo().search("shipper:geo",
GeoReference.fromCoordinate(lng, lat),
new Distance(3, Metrics.KILOMETERS), args);
return results.getContent().stream()
.map(r -> r.getContent().getName())
.filter(id -> Boolean.TRUE.equals(redis.hasKey("shipper:alive:" + id)))
.toList();
}
}
Point는 경도를 먼저 받고 위도를 나중에 받으며, 이는 매우 흔한 실수입니다. GEO set의 멤버는 자동으로 만료되지 않습니다. 따라서 30초 TTL이 있는 shipper:alive:* 키가 앱을 종료했거나 신호를 잃은 배달원을 제거하는 데 사용됩니다. 정기적인 작업이 shipper:geo에서 죽은 멤버를 ZREM할 수 있습니다.
초당 1만 2500건의 기록으로, 하나의 Redis 인스턴스가 잘 처리합니다. 여러 도시로 확장할 때는 지역별로 샤딩합니다: shipper:geo:hcm, shipper:geo:hn.
단계 2: 각 배달원에게 차례로 초대를 보냅니다. Dispatch Service는 가장 가까운 배달원에게 순차적으로 초대를 보냅니다. 각 초대는 20초의 만료 시간을 가집니다. 배달원이 동시에 두 주문을 받지 않도록 SET NX로 잠금:
public boolean reserveShipperForOffer(String shipperId, long orderId) {
Boolean ok = redis.opsForValue().setIfAbsent(
"offer:shipper:" + shipperId, String.valueOf(orderId), Duration.ofSeconds(20));
return Boolean.TRUE.equals(ok);
}
false일 경우, 배달원이 다른 초대를 보는 중이므로 다음 사람으로 넘어갑니다.
단계 3: 데이터베이스에서 주문 할당을 확정합니다. Redis 잠금은 조정만 도와줍니다. 최종 결정은 데이터베이스에 있어야 합니다:
public boolean assignShipper(long orderId, long shipperId) {
return jdbc.sql("""
UPDATE orders SET shipper_id = :shipperId, version = version + 1
WHERE id = :orderId AND shipper_id IS NULL
AND status IN ('CONFIRMED', 'PREPARING', 'READY_FOR_PICKUP')
""")
.param("shipperId", shipperId)
.param("orderId", orderId)
.update() == 1;
}
shipper_id IS NULL 조건은 두 배달원이 동시에 “수락”을 클릭해도(예: 이전 초대가 만료되고 다시 전송되는 경우) 한 명만 승리하도록 보장합니다.
주의: Redis 잠금을 주문 할당의 정보 소스로 사용하지 마세요. Redis는 failover될 수 있고 잠금을 잃을 수 있습니다. 데이터베이스의 제약은 마지막 보호 계층입니다.
8. 데이터베이스와 Kafka 간의 일관성
주문을 생성한 후 Order Service는 음식점이 알림을 받도록 OrderPlaced 이벤트를 발행해야 합니다. 데이터베이스에 기록한 후 kafkaTemplate.send()를 호출하면 서비스가 두 단계 사이에 죽을 수 있습니다. 결과적으로 주문은 존재하지만 음식점은 절대 알지 못합니다. 이것이 dual write 문제입니다.
해결책은 transactional outbox입니다: 이벤트를 outbox 테이블에 주문과 같은 트랜잭션으로 작성합니다.
@Transactional
public long placeOrder(PlaceOrderCommand cmd) {
long orderId = orderRepository.insert(cmd); // 중복 키일 경우 DuplicateKeyException 발생
jdbc.sql("""
INSERT INTO outbox (aggregate_id, event_type, payload)
VALUES (:id, 'OrderPlaced', CAST(:payload AS JSONB))
""")
.param("id", orderId)
.param("payload", toJson(cmd, orderId))
.update();
return orderId;
}
두 레코드가 함께 커밋되거나 함께 롤백됩니다. 클라이언트가 같은 Idempotency-Key로 다시 보내면 UNIQUE 제약이 DuplicateKeyException을 발생시킵니다. 컨트롤러가 이 오류를 잡고 이전 주문을 찾아서 새로 만드는 대신 반환합니다.
relay가 outbox를 읽고 Kafka로 푸시합니다:
@Scheduled(fixedDelay = 200)
@Transactional
public void relay() {
List<OutboxEvent> events = jdbc.sql("""
SELECT id, aggregate_id, event_type, payload::text AS payload FROM outbox
WHERE published = false ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED
""")
.query(OutboxEvent.class).list();
for (OutboxEvent e : events) {
kafka.send("order-events", String.valueOf(e.aggregateId()), e.payload()).join();
jdbc.sql("UPDATE outbox SET published = true WHERE id = :id")
.param("id", e.id()).update();
}
}
FOR UPDATE SKIP LOCKED은 여러 relay 인스턴스가 중복 처리 없이 실행되도록 허용합니다. 메시지의 키는 orderId이므로 같은 주문의 모든 이벤트는 같은 파티션에 들어가고 순서를 유지합니다.
relay는 at-least-once를 보장합니다. 서비스가 send 후 커밋 전에 죽으면 이벤트가 다시 전송됩니다. 따라서 consumer는 멱등성이 있어야 하며, 예를 들어 처리한 event_id를 저장하거나 섹션 7.1처럼 UPDATE ... WHERE status = ...를 사용합니다.
9. 고객을 위한 실시간 상태
고객은 두 가지 유형의 업데이트를 봐야 합니다: 주문 상태(적음, 중요) 및 배달원 위치(많음, 놓칠 수 있음). Server-Sent Events는 충분히 좋은 선택입니다. 데이터 흐름이 서버에서 클라이언트로만 흐르기 때문입니다:
@RestController
public class OrderStreamController {
private final Map<Long, List<SseEmitter>> emitters = new ConcurrentHashMap<>();
@GetMapping(value = "/api/orders/{id}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@PathVariable long id) {
SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
emitters.computeIfAbsent(id, k -> new CopyOnWriteArrayList<>()).add(emitter);
emitter.onCompletion(() -> emitters.get(id).remove(emitter));
emitter.onTimeout(emitter::complete);
return emitter;
}
public void push(long orderId, String eventName, Object data) {
emitters.getOrDefault(orderId, List.of()).forEach(e -> {
try {
e.send(SseEmitter.event().name(eventName).data(data));
} catch (IOException ex) {
e.completeWithError(ex);
}
});
}
}
각 주문에는 emitter 목록이 있습니다(고객이 여러 기기에서 열 수 있음). push는 이름이 있는 이벤트를 보냅니다, 예: status 또는 location. 클라이언트는 다음을 받습니다:
event:status
data:{"orderId":1024,"status":"PICKED_UP"}
event:location
data:{"lat":10.7769,"lng":106.7009}
여러 인스턴스를 실행할 때 고객의 SSE 연결은 하나의 인스턴스에만 있습니다. 이벤트가 올바른 위치에 도달하도록 하는 두 가지 방법:
- 주문 상태: 각 Tracking 인스턴스는
order-events를 자체group.id로 소비하므로 모든 인스턴스가 모든 이벤트를 받고 자신이 보유한 emitter에만 푸시합니다. - 배달원 위치: 주문이
PICKED_UP상태일 때 Tracking은 Redis 채널track:order:{id}로 위치를 발행합니다. 연결을 가진 인스턴스가 채널을 구독합니다. 고작 1만 2500건/초의 메시지를 Kafka로 보낼 필요가 없습니다. 작은 부분만 보면 됩니다.
부하를 줄이기 위해 위치를 병합할 수 있습니다: 클라이언트는 4~5초마다 한 지점만 필요하고 지도에서 움직임을 자체 보간합니다.
10. 피크 시간 트래픽 급증
점심시간이나 프로모션을 실행할 때 트래픽이 5~10배 증가할 수 있습니다. 시스템을 견고하게 유지하는 데 도움이 되는 몇 가지 기술:
- 메뉴 캐싱. 메뉴는 읽기가 많고 쓰기가 적습니다. Redis에서
restaurant_id로 캐시하고 음식점이 메뉴를 수정할 때 캐시를 무효화합니다. 대부분의 “음식점 보기” 요청은 데이터베이스에 닿지 않습니다. - 게이트웨이에서 사용자 및 음식점별 rate limit. 과도하게 로드된 음식점(상태
PLACED의 N개 주문 이상)에서 주문 수락을 일시적으로 중단할 수도 있습니다. 고객이 40분 기다리도록 두지 말고요. - 읽기를 쓰기와 분리. 주문 API는
orders와outbox만 작성하고 즉시 반환합니다. 알림, 배송료 계산, 할인 제안은 Kafka를 통해 비동기적으로 실행됩니다. - 배치별 dispatch. 수요가 공급을 초과하면 5~10초마다 주문과 배달원을 매칭하는 배치 매칭이 즉시 각 사람을 초대하는 것보다 더 좋은 결과를 줍니다. 시스템은 같은 음식점의 두 주문을 한 배달원에게 묶을 수도 있습니다.
- 제어된 degradation. Redis Tracking이 느릴 때 주문 배치 흐름을 우선으로 유지하고 위치 푸시를 일시적으로 중단합니다. 고객은 지도가 몇 초 동안 고정되는 것을 참을 수 있습니다. 주문을 할 수 없는 것보다요.
11. 흔한 실수
- 배달원 위치를 PostgreSQL에 저장. 초당 1만 2500건의 쓰기, 각각 행을 업데이트하면 bloat과 lock contention을 일으킵니다. 필요한 경우에만 행동 기록을 배치로 저장하면 됩니다(예: 대사 용도로 Kafka로 데이터 웨어하우스로).
- 상태를
SELECT로 확인한 후UPDATE. 두 문 사이에는 항상 race condition을 위한 간격이 있습니다. 조건을WHERE절로 옮겨야 합니다. - outbox 없이
@Transactional내에서 Kafka로 전송. 트랜잭션이 롤백되어도 메시지는 이미 날아갔거나 그 반대입니다. - timeout 없는
SseEmitter사용. 중단된 연결이 쌓여서 리소스를 말립니다. timeout을 설정하고 클라이언트가GET /api/orders/{id}로 자동 재연결하여 최신 상태를 가져오도록 해야 합니다.
주의: Kafka, Redis GEO, outbox를 가진 다중 서비스 아키텍처는 충분히 큰 규모에서만 가치가 있습니다. 하루에 수백 개의 주문으로 하나의 Spring Boot 애플리케이션, 하나의 PostgreSQL, 그리고 5초 polling이 충분하며 운영하기 훨씬 쉽습니다.
12. 결론
이 글에서 요구사항부터 구현까지 음식 배달 시스템을 설계했습니다. 네 가지 핵심이 있습니다:
- 명확한 상태머신으로 주문을 모델링합니다.
- 조건이 있는
UPDATE와 낙관적 잠금으로 충돌을 해결합니다. - Redis GEO와 데이터베이스 제약으로 주문을 분배합니다.
- transactional outbox로 Kafka와의 일관성을 유지합니다.
전반적인 원칙은 일관성 요구사항에 따라 데이터를 분류하는 것입니다. 주문은 강한 일관성이 필요하므로 관계형 데이터베이스에 있습니다. 배달원 위치는 손실을 허용하므로 Redis를 통해 속도를 우선시합니다. 일단 이 두 가지를 분리하면 문제의 대부분의 어려운 지점이 처리하기 쉬워집니다.