시스템 설계: 주문 추적 및 배송원 실시간 위치 추적

1. 개요

음식을 주문하거나 배송받을 때, 고객은 주문의 상태와 배송원의 현재 위치를 지도에서 보고 싶어합니다. 이 글에서는 요구사항부터 코드까지 그러한 시스템을 설계합니다. 데이터 모델, API, 처리 흐름을 살펴본 후 세 가지 어려운 점들을 다룹니다: 주문 수락 시 경합, 데이터 일관성, 급격한 트래픽 증가입니다.

이 글을 읽은 후, 여러분은 참고할 수 있는 아키텍처와 구현을 시작할 수 있는 Spring Boot 3.x 코드 예제를 갖게 될 것입니다.

2. 요구사항

2.1. 기능 요구사항

  • 배송원이 앱에서 정기적으로 GPS 위치를 전송합니다.
  • 고객은 거의 실시간으로 주문 상태와 배송원 위치를 볼 수 있으며, 지연 시간은 3초 미만입니다.
  • 배송원이 주문을 수락합니다. 각 주문은 정확히 한 명의 배송원에게만 할당됩니다.
  • 주문 상태는 다음 생명 주기를 따릅니다: PENDING → ASSIGNED → PICKED_UP → DELIVERING → DELIVERED. 특정 상태에서 CANCELLED로 전환될 수 있습니다.
  • 분쟁 해결 및 불만 처리를 위해 상태 변경 이력을 저장합니다.

2.2. 규모 추정

지표 값
피크 시간 온라인 배송원 수 50,000
위치 전송 주기 4초
위치 기록 처리량 ~12,500 msg/s
추적 화면을 보는 고객 연결 수 ~200,000
피크 시간 새 주문 ~300개/초

2.3. 제약 조건

  • 위치는 임시 데이터입니다. 몇 개의 데이터 포인트를 잃어도 허용되지만, 새로운 위치가 이전 위치를 덮어쓰지 않아야 합니다.
  • 주문 상태는 중요 데이터입니다. 손실되거나 순서가 잘못되어서는 안 됩니다.
  • 모바일 네트워크가 불안정하므로, 앱이 재전송하고 배치로 전송합니다.

여기서 핵심 결정을 내립니다: 두 개의 흐름을 분리합니다. 위치 흐름은 높은 처리량이 필요하고 손실을 수용할 수 있습니다. 상태 흐름은 트랜잭션과 내구성이 필요합니다.

3. 전체 아키텍처

App shipper ──HTTP batch──> Location API ──> Kafka [shipper-locations]
                                                   │
                                                   ▼
                                        Location Consumer ──> Redis (최신 위치, GEO)
                                                   │
                                                   └──> Redis Pub/Sub [order:{id}:loc]
                                                               │
App 고객 <──────── SSE ──────── Tracking Gateway <────────────┘

App shipper ──HTTP──> Order Service ──> PostgreSQL (orders, order_events, outbox)
                                              │
                                              └──> Outbox relay ──> Kafka [order-events]

각 컴포넌트는 한 가지 역할을 합니다:

  • Kafka는 위치 쓰기 부하를 흡수하고, shipperId를 key로 사용하므로 배송원당 순서를 보장합니다.
  • Redis는 각 배송원의 최신 위치만 유지합니다. 시스템은 각 GPS 포인트를 관계형 데이터베이스에 기록하지 않습니다.
  • PostgreSQL은 주문 상태의 단일 진실 공급원입니다.
  • SSE는 서버에서 클라이언트로 단방향 데이터를 푸시합니다. SSE는 WebSocket보다 간단하고 이 사용 사례에는 충분합니다.

4. 데이터 모델

4.1. 주문을 위한 PostgreSQL

CREATE TABLE orders (
    id          BIGSERIAL PRIMARY KEY,
    customer_id BIGINT      NOT NULL,
    shipper_id  BIGINT,
    status      VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    version     INT         NOT NULL DEFAULT 0,
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE order_events (
    id         BIGSERIAL PRIMARY KEY,
    order_id   BIGINT      NOT NULL REFERENCES orders(id),
    from_status VARCHAR(20),
    to_status  VARCHAR(20) NOT NULL,
    actor_id   BIGINT      NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE outbox (
    id           BIGSERIAL PRIMARY KEY,
    aggregate_id BIGINT      NOT NULL,
    payload      JSONB       NOT NULL,
    published    BOOLEAN     NOT NULL DEFAULT false,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_outbox_unpublished ON outbox(id) WHERE published = false;

version 열은 낙관적 잠금에 사용됩니다. order_events 테이블은 추가만 가능한 이력입니다. outbox 테이블은 이벤트가 손실되지 않고 Kafka로 발행되도록 보장합니다. 섹션 7에서 자세히 설명합니다.

4.2. 위치를 위한 Redis

shipper:loc:{shipperId}         HASH  {lat, lng, ts}       TTL 120s
shippers:geo:{city}             GEO   member = shipperId
shipper:active-order:{shipperId} STRING orderId

TTL이 120초이므로, 배송원이 연결을 끊으면 시스템에서 자동으로 사라집니다. 별도의 정리 작업이 필요하지 않습니다. GEO key를 도시별로 분리하면, 가까운 배송원을 찾을 때 GEOSEARCH 명령이 전국을 스캔하지 않아도 됩니다.

5. API

POST /api/v1/shippers/{shipperId}/locations      본문: [ {lat,lng,ts}, ... ]  -> 202
POST /api/v1/orders/{orderId}/claim              -> 200 | 409
POST /api/v1/orders/{orderId}/transitions        본문: {to: "PICKED_UP"}  -> 200 | 409
GET  /api/v1/orders/{orderId}                    -> 상태 + 현재 위치
GET  /api/v1/orders/{orderId}/track              -> text/event-stream

위치 API는 데이터를 Kafka에 푸시하고 즉시 응답하므로 202 Accepted를 반환합니다. 이 API는 배치로 데이터를 받아서, 앱이 연결이 없을 때 쌓인 포인트를 한 번에 전송할 수 있습니다.

6. 위치 처리 흐름

필요한 의존성은 다음과 같습니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</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>

Kafka용 JSON serializer를 설정합니다:

spring:
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      acks: 1
    consumer:
      group-id: location-consumer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: "com.nonla.tracking"

acks: 1 값은 의도적인 선택입니다. broker leader가 죽을 때 몇 개의 GPS 포인트를 잃는 것은 허용되지만, 그 대신 acks: all보다 쓰기 지연 시간이 낮습니다.

6.1. 위치 수신

public record LocationUpdate(long shipperId, double lat, double lng, long ts) {}

@RestController
@RequestMapping("/api/v1/shippers")
public class LocationController {

    private final KafkaTemplate<String, LocationUpdate> kafka;

    public LocationController(KafkaTemplate<String, LocationUpdate> kafka) {
        this.kafka = kafka;
    }

    @PostMapping("/{shipperId}/locations")
    public ResponseEntity<Void> push(@PathVariable long shipperId,
                                     @RequestBody List<LocationUpdate> batch) {
        batch.stream()
             .filter(u -> u.shipperId() == shipperId)
             .forEach(u -> kafka.send("shipper-locations", String.valueOf(shipperId), u));
        return ResponseEntity.accepted().build();
    }
}

두 가지 주의할 점:

  • key는 shipperId입니다. 같은 배송원의 모든 포인트는 같은 partition으로 가므로, consumer는 전송 순서대로 처리합니다.
  • filter 조건은 경로의 shipperId와 다른 shipperId를 가진 포인트를 제거합니다. 실제로는 JWT에서 shipperId를 가져와야 본문의 값을 신뢰하지 않습니다.

6.2. 최신 위치만 기록, 오래된 포인트는 버림

partition의 순서만으로는 부족합니다. 앱이 이전 배치를 네트워크 복구 후 다시 전송할 수 있고, 그 배치가 더 최신인 포인트 후에 도착할 수 있습니다. 따라서 consumer는 ts를 비교하고 더 최신인 포인트일 때만 기록해야 합니다. 비교와 쓰기는 원자적이어야 하므로 Lua 스크립트를 사용합니다:

@Component
public class LocationConsumer {

    private static final RedisScript<Long> UPSERT_IF_NEWER = RedisScript.of("""
        local cur = redis.call('HGET', KEYS[1], 'ts')
        if cur and tonumber(cur) >= tonumber(ARGV[3]) then return 0 end
        redis.call('HSET', KEYS[1], 'lat', ARGV[1], 'lng', ARGV[2], 'ts', ARGV[3])
        redis.call('EXPIRE', KEYS[1], 120)
        return 1
        """, Long.class);

    private final StringRedisTemplate redis;

    public LocationConsumer(StringRedisTemplate redis) {
        this.redis = redis;
    }

    @KafkaListener(topics = "shipper-locations", concurrency = "6")
    public void onLocation(LocationUpdate u) {
        Long updated = redis.execute(UPSERT_IF_NEWER,
                List.of("shipper:loc:" + u.shipperId()),
                String.valueOf(u.lat()), String.valueOf(u.lng()), String.valueOf(u.ts()));

        if (updated == null || updated == 0) return; // 오래된 포인트, 건너뛰기

        redis.opsForGeo().add("shippers:geo:hcm", new Point(u.lng(), u.lat()),
                String.valueOf(u.shipperId()));

        String orderId = redis.opsForValue().get("shipper:active-order:" + u.shipperId());
        if (orderId != null) {
            redis.convertAndSend("order:" + orderId + ":loc",
                    "{\"lat\":%s,\"lng\":%s,\"ts\":%d}".formatted(u.lat(), u.lng(), u.ts()));
        }
    }
}

처리 흐름은 다음과 같습니다:

  • 스크립트는 포인트가 저장된 포인트보다 오래되거나 같으면 0을 반환합니다. 이 경우 consumer는 멈추고, GEO를 업데이트하거나 고객에게 푸시하지 않습니다.
  • GEOADD 명령은 경도 먼저, 위도 나중 순서로 인자를 받습니다. 이것은 아주 흔한 실수입니다.
  • 시스템은 배송원이 활성 주문을 가졌을 때만 발행합니다. 한가한 배송원은 트래픽을 만들지 않습니다.

주의: 위의 스크립트는 한 개의 key만 건드리므로 Redis Cluster에서 실행될 수 있습니다. GEOADD까지 스크립트에 포함하고 싶다면, 여러 key를 사용하므로 hash tag를 사용해야 합니다. 예: {hcm}:loc:42와 {hcm}:geo.

6.3. SSE로 고객에게 푸시

@RestController
public class TrackingController implements MessageListener {

    private final Map<String, Set<SseEmitter>> subscribers = new ConcurrentHashMap<>();

    @GetMapping(value = "/api/v1/orders/{orderId}/track",
                produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter track(@PathVariable String orderId) {
        SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
        Set<SseEmitter> set = subscribers.computeIfAbsent(orderId, k -> ConcurrentHashMap.newKeySet());
        set.add(emitter);
        Runnable cleanup = () -> set.remove(emitter);
        emitter.onCompletion(cleanup);
        emitter.onTimeout(cleanup);
        emitter.onError(e -> cleanup.run());
        return emitter;
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String channel = new String(message.getChannel());   // order:123:loc
        String orderId = channel.split(":")[1];
        String body = new String(message.getBody());
        subscribers.getOrDefault(orderId, Set.of()).forEach(e -> {
            try {
                e.send(SseEmitter.event().name("location").data(body));
            } catch (IOException ex) {
                e.completeWithError(ex);
            }
        });
    }
}

listener를 order:*:loc 패턴으로 등록합니다:

@Bean
RedisMessageListenerContainer container(RedisConnectionFactory cf, TrackingController listener) {
    var c = new RedisMessageListenerContainer();
    c.setConnectionFactory(cf);
    c.addMessageListener(listener, new PatternTopic("order:*:loc"));
    return c;
}

클라이언트는 다음과 같은 이벤트를 받습니다:

event:location
data:{"lat":10.7769,"lng":106.7009,"ts":1760342400000}

Gateway는 stateless이므로 원하는 만큼 인스턴스를 수평 확장할 수 있습니다. 고객의 SSE 연결을 유지하는 인스턴스가 Pub/Sub을 통해 메시지를 받아 아래로 푸시합니다. 200,000개의 연결로, virtual threads를 켜거나 (spring.threads.virtual.enabled=true) WebFlux로 전환해야 각 연결이 platform thread 하나를 차지하지 않습니다.

7. 어려운 점과 해결책

7.1. 경합: 두 배송원이 같은 주문을 수락

새로운 주문이 근처의 여러 배송원에게 표시됩니다. 둘 다 “수락” 버튼을 동시에 누를 수 있습니다. 잘못된 방법은 상태를 읽어서 PENDING인지 확인한 후 쓰는 것입니다. 읽기와 쓰기 사이에 시간 간격이 있기 때문입니다. 올바른 방법은 조건이 있는 UPDATE를 사용하는 것입니다:

@Service
public class OrderClaimService {

    private final JdbcTemplate jdbc;

    public OrderClaimService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    public boolean claim(long orderId, long shipperId) {
        int rows = jdbc.update("""
            UPDATE orders
               SET shipper_id = ?, status = 'ASSIGNED', version = version + 1, updated_at = now()
             WHERE id = ? AND status = 'PENDING'
            """, shipperId, orderId);
        if (rows == 0) return false; // 다른 사람이 이미 수락함

        jdbc.update("INSERT INTO order_events(order_id, from_status, to_status, actor_id) VALUES (?, 'PENDING', 'ASSIGNED', ?)",
                orderId, shipperId);
        jdbc.update("INSERT INTO outbox(aggregate_id, payload) VALUES (?, ?::jsonb)",
                orderId, "{\"orderId\":%d,\"status\":\"ASSIGNED\",\"shipperId\":%d}".formatted(orderId, shipperId));
        return true;
    }
}

PostgreSQL은 UPDATE 실행 중 행을 잠금합니다. 두 번째 배송원은 대기했다가, 조건 status = 'PENDING'을 다시 평가해서 더 이상 일치하지 않음을 보고 rows == 0을 받습니다. Controller는 409 Conflict를 반환하고, 앱은 “주문이 이미 누군가에게 할당됨”이라는 메시지를 표시합니다. Distributed lock이 필요 없습니다.

7.2. 일관성: 상태 머신과 outbox

모든 상태 전환이 유효해야 합니다. 상태 전환 규칙을 enum에 인코딩합니다:

public enum OrderStatus {
    PENDING, ASSIGNED, PICKED_UP, DELIVERING, DELIVERED, CANCELLED;

    public boolean canTransitionTo(OrderStatus next) {
        return switch (this) {
            case PENDING    -> next == ASSIGNED || next == CANCELLED;
            case ASSIGNED   -> next == PICKED_UP || next == CANCELLED;
            case PICKED_UP  -> next == DELIVERING;
            case DELIVERING -> next == DELIVERED;
            case DELIVERED, CANCELLED -> false;
        };
    }
}

핵심 동작에 대한 테스트:

class OrderStatusTest {

    @Test
    void shouldAllowHappyPath() {
        assertThat(OrderStatus.PENDING.canTransitionTo(OrderStatus.ASSIGNED)).isTrue();
        assertThat(OrderStatus.DELIVERING.canTransitionTo(OrderStatus.DELIVERED)).isTrue();
    }

    @Test
    void shouldRejectCancelAfterPickup() {
        assertThat(OrderStatus.PICKED_UP.canTransitionTo(OrderStatus.CANCELLED)).isFalse();
    }

    @Test
    void terminalStatesAreFinal() {
        assertThat(OrderStatus.values())
            .noneMatch(OrderStatus.DELIVERED::canTransitionTo)
            .noneMatch(OrderStatus.CANCELLED::canTransitionTo);
    }
}

상태를 전환할 때, WHERE id = ? AND version = ? 조건을 사용합니다. 업데이트된 행이 없으면 다른 사람이 먼저 주문을 변경했다는 뜻입니다. 이 경우 409를 반환하고 클라이언트가 새로고침하도록 합니다.

Outbox 패턴은 “DB 쓰기는 성공했지만 Kafka 전송이 실패”라는 문제를 해결합니다. 이벤트는 orders 변경과 같은 트랜잭션 내에 outbox 테이블에 기록됩니다. 정기적인 relay가 발행되지 않은 행을 읽어서 Kafka로 푸시합니다:

@Scheduled(fixedDelay = 500)
@Transactional
public void relay() {
    var rows = jdbc.queryForList(
        "SELECT id, aggregate_id, payload::text AS payload FROM outbox WHERE published = false ORDER BY id LIMIT 200 FOR UPDATE SKIP LOCKED");
    for (var r : rows) {
        kafka.send("order-events", r.get("aggregate_id").toString(), (String) r.get("payload")).join();
        jdbc.update("UPDATE outbox SET published = true WHERE id = ?", r.get("id"));
    }
}

FOR UPDATE SKIP LOCKED 절을 사용하면 여러 relay가 동시에 실행되고 같은 행을 중복으로 처리하지 않습니다. Relay는 최소 한 번 전송을 보장합니다. Relay가 전송 후 but 발행 표시 전에 죽으면 이벤트가 다시 전송됩니다. 따라서 order-events consumer는 idempotent해야 합니다. 예를 들어, 처리한 version보다 작거나 같은 version의 이벤트는 건너뜁니다.

order-events의 consumer는 또한 shipper:active-order:{shipperId} key를 설정하거나 삭제합니다. 이렇게 해서 위치 흐름은 어느 주문에 푸시해야 하는지 알 수 있습니다.

7.3. 트래픽 급증

점심 시간, 대비, 또는 세일 때는 수 분 내에 트래픽이 3–5배 증가할 수 있습니다. 처리할 수 있는 방법:

  • Kafka가 버퍼 역할을 합니다. Consumer가 느리면 lag가 증가하지만, API는 빠르게 응답합니다. partition 수를 미리 (예: 48) 늘려두세요. 실행 중에 partition을 늘리면 key별 순서가 섞입니다.
  • 너무 오래된 포인트를 버립니다. Lag가 클 때, 30초 이상 된 GPS 포인트는 더 이상 표시할 가치가 없습니다. Consumer는 if (System.currentTimeMillis() - u.ts() > 30_000) return;으로 바로 건너뛸 수 있습니다.
  • 적응형 전송 주기. Server가 헤더를 제안합니다 (예: X-Next-Interval: 8). 시스템이 과부하일 때 앱은 전송 주기를 늘립니다. 가만히 있는 배송원은 더 드물게 보냅니다.
  • Client에서 통합. 앱이 2–3개 포인트를 한 요청에 모으면 HTTP 요청 수는 크게 줄지만, Kafka 메시지 수는 같습니다.
  • 배송원당 rate limit. 최대 초당 1 요청 제한으로 버그 앱이나 가짜 클라이언트 스팸을 막습니다.
  • 리소스 분리. 위치 흐름은 별도의 Redis 클러스터와 인스턴스 그룹에서 실행합니다. 이 흐름이 다운되어도 주문 수락과 상태 업데이트는 계속됩니다.

8. 흔한 실수와 피해야 할 것

  • 매 GPS 포인트마다 PostgreSQL에 기록. 초당 12,500 insert와 인덱스는 빠르게 database를 막습니다. 대결 로그를 위해 저장해야 한다면, 다른 consumer가 더 낮은 빈도로 TimescaleDB나 object storage 같은 시스템에 배치로 기록하게 합니다.
  • 주문 수락에 distributed lock (Redisson) 사용. 이 방법은 조건이 있는 UPDATE보다 복잡하고 더 실수하기 쉽습니다.
  • 요청의 도착 순서를 믿습니다. 모바일 네트워크는 순서를 보장하지 않습니다. 항상 ts를 비교하고, 상태는 version을 비교해야 합니다.
  • 중요 데이터에 Redis Pub/Sub 사용. Pub/Sub은 fire-and-forget입니다. Subscriber가 연결을 끊으면 메시지가 손실됩니다. 위치에는 괜찮지만, 상태 이벤트에는 맞지 않습니다. 상태 이벤트는 Kafka를 거쳐야 합니다. Client가 SSE를 다시 연결하면 GET /orders/{id}를 호출해서 최신 스냅샷을 가져옵니다.

주의: 배송원이 수백 명에 불과하다면 이 아키텍처는 과도합니다. 위치를 Redis에만 기록하고, 클라이언트는 5초마다 polling하는 단일 service로 충분합니다. 실제 수치가 강제하기 전까지는 Kafka와 gateway SSE를 추가하지 마세요.

9. 결론

이 글은 핵심 원칙에 따라 시스템을 설계했습니다: 임시 데이터를 중요 데이터와 분리합니다.

  • 위치는 Kafka를 거쳐 Redis로 갑니다. 시스템은 손실을 수용하지만, Lua 스크립트의 timestamp 비교로 오래된 포인트가 새 포인트를 덮어쓰지 못하도록 합니다.
  • 주문 상태는 PostgreSQL에 있습니다. 경합은 조건이 있는 UPDATE로, 일관성은 상태 머신과 outbox 패턴으로 유지됩니다.
  • 트래픽 급증은 Kafka로 흡수하고, 오래된 포인트를 버리고, 전송 주기를 적응시키고, 리소스를 분리함으로써 처리합니다.

이 틀에서, ETA 계산, GEOSEARCH를 이용한 위치별 주문 통합, 또는 대결 목적의 경로 저장 같은 기능을 추가할 수 있습니다.

system-designspring-bootkafkaredisrealtime