라이드 셰어링 앱 설계: 최근의 드라이버 찾기와 라이드 매칭

1. 개요

라이드 셰어링 앱에는 두 가지 핵심 질문이 있다. 어느 드라이버가 고객 근처에 있는가? 수십 명의 드라이버가 동시에 “수락”을 누르는 상황에서도 하나의 라이드가 정확히 한 명의 드라이버에게만 할당되도록 하려면?

이 글에서는 시스템 설계 방식으로 문제 전체를 살펴본다. 요구사항, 규모 추정, 데이터 모델, API, 처리 흐름을 다룬다. 그 다음 동시성 문제, 데이터 일관성, 급증하는 부하 같은 어려운 부분들에 집중한다. 이 글을 읽고 나면 Spring Boot 3, Redis, Kafka, PostgreSQL로 “드라이버 검색 및 라이드 매칭” 핵심 부분을 구축할 수 있다.

2. 요구사항

2.1. 기능 요구사항

  • 드라이버는 온라인 중일 때 GPS 위치를 주기적으로 전송한다.
  • 고객은 픽업 지점과 목적지를 입력하여 라이드를 요청한다.
  • 시스템은 사용 가능한 가장 가까운 드라이버를 찾아 순차적으로 오퍼를 보낸다.
  • 드라이버는 수락하거나 거절할 수 있다. 15초 내에 응답이 없으면 시스템은 다음 드라이버에게 오퍼를 보낸다.
  • 고객은 라이드 상태를 추적할 수 있다: REQUESTED → ACCEPTED → IN_PROGRESS → COMPLETED (또는 CANCELLED).

2.2. 규모 및 제약

중간 규모 국가에서 운영되는 시스템이라고 가정하자.

  • 피크 타임에 30만 명의 온라인 드라이버가 있고, 각 드라이버는 4초마다 위치를 전송한다. 따라서 초당 약 75,000번의 위치 업데이트가 발생한다.
  • 초당 약 1,500개의 라이드 요청이 들어온다. 비가 오거나 퇴근 시간대에는 이 수치가 5배까지 증가할 수 있다.
  • 드라이버 검색은 200ms 내에 완료되어야 한다(p99).
  • 한 라이드는 정확히 한 명의 드라이버만 가져야 한다. 한 드라이버는 동시에 최대 한 개의 활성 라이드만 가질 수 있다. 이는 절대로 위반할 수 없는 제약이다.

이 숫자들에서 중요한 통찰을 얻을 수 있다. 드라이버 위치는 매우 자주 기록되고, 오래 유지될 필요가 없으며, 손실을 감수할 수 있는 데이터다. GPS 포인트 하나를 잃어도 괜찮다. 반면 라이드 데이터는 훨씬 적게 기록되지만 절대적인 일관성이 필요하다. 따라서 이 두 종류의 데이터는 다른 저장소에 분리해야 한다.

3. 데이터 모델

드라이버 위치는 Redis에, 라이드는 PostgreSQL에 저장한다.

CREATE TABLE rides (
    id           BIGSERIAL PRIMARY KEY,
    customer_id  VARCHAR(64)  NOT NULL,
    driver_id    VARCHAR(64),
    pickup_lat   DOUBLE PRECISION NOT NULL,
    pickup_lng   DOUBLE PRECISION NOT NULL,
    status       VARCHAR(20)  NOT NULL DEFAULT 'REQUESTED',
    created_at   TIMESTAMP    NOT NULL DEFAULT now(),
    accepted_at  TIMESTAMP
);

-- 각 드라이버는 동시에 최대 한 개의 활성 라이드만 가질 수 있다.
CREATE UNIQUE INDEX ux_rides_driver_active
    ON rides (driver_id)
    WHERE status IN ('ACCEPTED', 'IN_PROGRESS');

끝의 부분 고유 인덱스는 “최후의 안전망”이다. 애플리케이션 계층에 버그가 있어서 동일한 드라이버에게 두 라이드를 할당하려고 한다면, 데이터베이스가 두 번째 쓰기 명령을 거절할 것이다.

Redis 쪽에서는 세 가지 키 타입을 사용한다.

drivers:available            GEO 집합: driverId → (lng, lat), 사용 가능한 드라이버만 포함
driver:heartbeat:{driverId}  문자열, TTL 30초, 드라이버가 여전히 온라인임을 표시
driver:lock:{driverId}       문자열, TTL 15초, 값 = 오퍼가 진행 중인 라이드 ID

4. API

POST /api/drivers/{driverId}/location   { "lat": 10.7769, "lng": 106.7009 }
POST /api/rides                         { "customerId": "c-42", "pickupLat": ..., "pickupLng": ... }
POST /api/rides/{rideId}/accept         { "driverId": "d-7" }
GET  /api/rides/{rideId}

위치 업데이트 엔드포인트는 매우 높은 부하를 받는다. 실제로는 4초마다 HTTP 연결을 열 필요가 없도록 이 부분은 보통 WebSocket이나 gRPC 스트림을 사용한다. 예제를 간단하게 유지하기 위해 이 글에서는 여전히 REST를 사용한다.

5. Redis GEO로 드라이버 위치 저장 및 검색

의존성 추가:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

5.1. 위치 업데이트

@Service
public class DriverLocationService {

    static final String AVAILABLE_KEY = "drivers:available";
    private final StringRedisTemplate redis;

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

    public void updateLocation(String driverId, double lng, double lat) {
        redis.opsForGeo().add(AVAILABLE_KEY, new Point(lng, lat), driverId);
        redis.opsForValue().set("driver:heartbeat:" + driverId, "1", Duration.ofSeconds(30));
    }

    public void markBusy(String driverId) {
        redis.opsForGeo().remove(AVAILABLE_KEY, driverId);
    }
}

GEOADD는 좌표를 정렬된 집합 내의 geohash로 저장한다. 각 명령은 O(log N) 복잡도를 가지므로 하나의 Redis 노드는 초당 수만 개의 명령을 문제없이 처리할 수 있다.

heartbeat 부분이 존재하는 이유는 GEO 세트의 멤버가 개별 TTL을 갖지 않기 때문이다. 드라이버가 신호를 잃거나 앱을 종료해도 이전 좌표는 여전히 세트에 남아있다. 30초 후 만료되는 heartbeat 키는 이러한 “유령” 드라이버를 필터링하는 데 도움이 된다.

주의: Redis GEO는 파라미터를 (경도, 위도) 순서로 받는다. 순서를 뒤바꾸면 흔한 버그가 된다. 순서를 바꾸면 호찌민의 드라이버들이 남극으로 “이동”할 수 있다.

5.2. 가장 가까운 드라이버 찾기

public List<String> findNearby(double lng, double lat, double radiusKm, int limit) {
    var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
            .includeDistance()
            .sortAscending()
            .limit(limit * 2L); // 필터링된 드라이버를 보충하기 위해 여유있게 가져오기

    GeoResults<RedisGeoCommands.GeoLocation<String>> results = redis.opsForGeo().search(
            AVAILABLE_KEY,
            GeoReference.fromCoordinate(lng, lat),
            new Distance(radiusKm, Metrics.KILOMETERS),
            args);

    List<String> candidates = results.getContent().stream()
            .map(r -> r.getContent().getName())
            .toList();

    List<String> alive = redis.opsForValue().multiGet(
            candidates.stream().map(id -> "driver:heartbeat:" + id).toList());

    List<String> online = new ArrayList<>();
    for (int i = 0; i < candidates.size() && online.size() < limit; i++) {
        if (alive.get(i) != null) {
            online.add(candidates.get(i));
        }
    }
    return online;
}

GEOSEARCH는 주어진 반경 내의 드라이버를 거리순으로 반환한다. 그 다음 단일 MGET 명령이 모든 후보의 heartbeat을 확인하므로 한 번의 왕복만 더 필요하다. Quận 1의 고객과 3km 반경에서 결과는 다음과 같을 수 있다.

[d-7 (0.4 km), d-19 (0.9 km), d-3 (1.6 km)]

여기의 거리는 직선 거리다. 실제 서비스에서는 지도 서비스의 실제 ETA를 사용하여 상위 N명 후보를 다시 순위 매겨야 한다. 그 이유는 500m 떨어졌지만 강 건너편에 있는 드라이버가 1km 떨어진 드라이버보다 늦게 도착할 수 있기 때문이다.

6. 라이드 매칭과 동시성 처리

두 가지 종류의 동시성 문제를 막아야 한다.

  1. 두 라이드가 동일한 드라이버를 초대한다. 두 고객이 근처에 있고 동시에 차량을 호출하면, 매칭 로직 두 개 모두 d-7을 선택한다.
  2. 두 드라이버가 동일한 라이드를 수락한다. 이는 대기 시간을 줄이기 위해 여러 드라이버에게 동시에 오퍼를 보낼 때 발생한다.

6.1. 오퍼 중에 Redis로 드라이버 락하기

@Service
public class DriverLockService {

    private static final RedisScript<Long> RELEASE = RedisScript.of("""
            if redis.call('GET', KEYS[1]) == ARGV[1] then
                return redis.call('DEL', KEYS[1])
            end
            return 0
            """, Long.class);

    private final StringRedisTemplate redis;

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

    public boolean tryLock(String driverId, long rideId) {
        return Boolean.TRUE.equals(redis.opsForValue()
                .setIfAbsent("driver:lock:" + driverId, String.valueOf(rideId), Duration.ofSeconds(15)));
    }

    public void release(String driverId, long rideId) {
        redis.execute(RELEASE, List.of("driver:lock:" + driverId), String.valueOf(rideId));
    }
}

SET NX EX는 원자적 연산이다. 각 드라이버에 대해 오직 하나의 매칭 로직만 락을 획득할 수 있으며, 락은 드라이버 응답 대기 시간과 동일한 15초 후 자동으로 만료된다. 이 TTL 덕분에 시스템은 드라이버가 응답하지 않을 때 정리 작업 없이 자동으로 “다음 사람으로 넘어간다”.

락을 해제하는 Lua 스크립트는 값이 rideId와 일치할 때만 키를 삭제한다. 만약 단순 DEL을 사용한다면, 락이 만료된 라이드 A가 실수로 라이드 B가 방금 획득한 락을 삭제할 수 있다.

6.2. 조건부 업데이트로 라이드 확정하기

Redis 락은 중복 오퍼를 피하기 위한 최적화 메커니즘일 뿐이다. 소스 오브 트루스는 여전히 데이터베이스다.

@Repository
public class RideRepository {

    private final JdbcTemplate jdbc;

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

    public boolean assignDriver(long rideId, String driverId) {
        int updated = jdbc.update("""
                UPDATE rides
                SET status = 'ACCEPTED', driver_id = ?, accepted_at = CURRENT_TIMESTAMP
                WHERE id = ? AND status = 'REQUESTED'
                """, driverId, rideId);
        return updated == 1;
    }
}

status = 'REQUESTED' 조건은 UPDATE 명령을 compare-and-set 연산으로 변환한다. 두 드라이버가 동시에 수락을 누르면, 데이터베이스는 동일한 행에 대한 두 쓰기 명령을 직렬화한다. 첫 번째 명령은 1을 반환한다. 두 번째 명령은 상태가 이미 ACCEPTED임을 보고 0을 반환한다. 이 방식은 SELECT ... FOR UPDATE나 별도의 버전 열이 필요하지 않다.

라이드 수락을 처리하는 서비스는 단계들을 조합한다.

@Transactional
public AcceptResult accept(long rideId, String driverId) {
    if (!rideRepository.assignDriver(rideId, driverId)) {
        return AcceptResult.ALREADY_TAKEN;
    }
    locationService.markBusy(driverId);
    lockService.release(driverId, rideId);
    return AcceptResult.ACCEPTED;
}

여기의 순서는 의도적이다: 먼저 데이터베이스에 쓴 다음, Redis를 업데이트한다. markBusy 단계가 실패하면, 드라이버는 여전히 사용 가능한 목록에 몇 초간 남아있을 수 있다. 최악의 경우는 드라이버가 추가 오퍼를 받지만, 부분 고유 인덱스가 두 번째 라이드 할당을 거절한다. 반대로 하면 더 위험하다: Redis에서 드라이버를 제거한 후 데이터베이스 쓰기가 실패하면 드라이버가 시스템에서 “사라진다”.

7. Kafka를 사용한 비동기 처리

드라이버를 찾고, 오퍼를 보내고, 15초를 기다리는 것은 오래 걸리는 프로세스다. 그 동안 고객의 HTTP 요청을 유지해서는 안 된다. POST /api/rides API는 라이드를 저장하고 즉시 202 Accepted를 반환한다. 매칭 부분은 별도의 consumer에서 실행된다.

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
public record RideRequested(long rideId, String cityId, double lng, double lat, int attempt) {}

@Component
public class MatchingConsumer {

    private final DriverLocationService locations;
    private final DriverLockService locks;
    private final OfferSender offerSender;
    private final KafkaTemplate<String, RideRequested> kafka;

    // 생성자 생략

    @KafkaListener(topics = "ride-requested", groupId = "matching")
    public void onRideRequested(RideRequested event) {
        for (String driverId : locations.findNearby(event.lng(), event.lat(), 3, 10)) {
            if (locks.tryLock(driverId, event.rideId())) {
                offerSender.send(driverId, event.rideId()); // 푸시 알림 / WebSocket
                return;
            }
        }
        if (event.attempt() < 5) {
            // 아직 사용 가능한 드라이버가 없음: 나중에 재시도하도록 다시 큐에 입력
            kafka.send("ride-requested-retry", event.cityId(),
                    new RideRequested(event.rideId(), event.cityId(), event.lng(), event.lat(), event.attempt() + 1));
        }
    }
}

Consumer는 후보들을 가까운 순서대로 순회한다. 첫 번째 사용 가능한 드라이버를 락하고 그 드라이버에게 오퍼를 보낸다. 사용 가능한 드라이버가 없으면, 이벤트는 재시도 topic으로 푸시되며 재시도 횟수에 제한이 있다.

메시지의 키는 cityId다. 덕분에 한 도시의 모든 라이드는 동일한 파티션 그룹으로 들어가고, consumer의 로컬 드라이버 캐시(있다면) 히트율이 높아진다.

한 가지 작은 문제가 있다: PostgreSQL에 라이드를 저장한 다음 Kafka로 전송하는 것은 두 개의 분리된 작업이다. 애플리케이션이 중간에 죽으면, 라이드는 REQUESTED 상태로 영원히 “막힐” 수 있다. 표준적인 해결책은 트랜잭셔널 아웃박스다. 이벤트를 rides 테이블과 동일한 트랜잭션 내의 outbox 테이블에 기록한 다음, 별도의 프로세스(또는 Debezium)가 아웃박스 테이블을 읽고 Kafka로 푸시하도록 한다.

주의: Kafka는 at-least-once를 보장하므로 consumer가 이벤트를 두 번 받을 수 있다. 매칭 로직은 멱등성이어야 한다. 오퍼를 보내기 전에 라이드가 여전히 REQUESTED 상태인지 확인하라. 섹션 6.2의 조건부 업데이트는 이미 중복에 안전하다.

8. 급증하는 부하 처리

오후 5시에 폭우가 내리면, 라이드 요청과 온라인 드라이버 모두 급증한다. 4가지 주요 처리 방법이 있다.

  • 지역별로 위치 데이터를 샤딩한다. 전체 국가에 하나의 drivers:available 키를 사용하는 대신, 도시별로 또는 geohash 셀별로 나누자. 예를 들어 drivers:available:hcm처럼 하면 된다. 각 키는 Redis Cluster의 한 샤드에 위치하므로 쓰기 부하가 균등하게 분산된다. 고객이 두 셀 경계 근처에 있을 때는 인접한 셀도 쿼리한다.
  • 위치 업데이트 빈도를 줄인다. 정차 중이거나 고객을 태운 드라이버는 4초마다 위치를 보낼 필요가 없다. 50m 이상 이동했거나 15초가 경과했을 때만 보내면 된다. 이 하나의 최적화만으로도 쓰기량의 40~60%를 줄일 수 있다.
  • 붕괴 대신 백프레셔를 사용한다. Kafka가 버퍼 역할을 한다. 라이드 요청이 5배 증가하면, 매칭 지연이 수 초 증가하지만 라이드가 손실되지는 않는다. consumer lag에 따라 consumer 수를 스케일하며, 최대값은 파티션 수다.
  • 동적 가격(서지 프라이싱). 이것은 기술 계층이 아닌 제품 계층의 해결책이다. 높은 가격은 수요를 줄이고 더 많은 드라이버를 온라인으로 끌어와 시스템의 균형을 다시 맞춘다.

또한 customerId별로 라이드 요청 API에 레이트 제한을 해야 한다. 앱에서 “차량 찾는 중”이 너무 오래 표시되면, 고객은 반복해서 재요청하는 경향이 있어 자동으로 부하를 2배로 만든다.

9. 수락할 때의 동시성 테스트

테스트해야 할 가장 중요한 동작은: 여러 드라이버가 동일한 라이드를 수락하면 정확히 한 사람만 이긴다는 것이다. 아래 테스트는 @JdbcTest를 통해 H2 인메모리를 사용한다. src/test/resources/schema.sql 파일에는 섹션 3의 rides 테이블이 포함되어 있으며, H2가 지원하지 않으므로 부분 인덱스는 생략한다.

@JdbcTest
@Import(RideRepository.class)
class RideRepositoryTest {

    @Autowired
    RideRepository rideRepository;

    @Autowired
    JdbcTemplate jdbc;

    @Test
    void givenConcurrentAccepts_whenAssignDriver_thenOnlyOneDriverWins() throws Exception {
        jdbc.update("INSERT INTO rides (id, customer_id, pickup_lat, pickup_lng, status) "
                + "VALUES (1, 'c-42', 10.77, 106.70, 'REQUESTED')");

        List<String> drivers = List.of("d-1", "d-2", "d-3", "d-4", "d-5");
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Boolean>> results = drivers.stream()
                    .map(d -> executor.submit(() -> rideRepository.assignDriver(1L, d)))
                    .toList();

            long winners = 0;
            for (Future<Boolean> f : results) {
                if (f.get()) winners++;
            }
            assertThat(winners).isEqualTo(1);
        }

        String status = jdbc.queryForObject("SELECT status FROM rides WHERE id = 1", String.class);
        assertThat(status).isEqualTo("ACCEPTED");
    }
}

5개의 virtual thread가 동시에 한 라이드에 대해 assignDriver를 호출한다. 실행 순서와 상관없이, status = 'REQUESTED' 조건과 일치하는 UPDATE 명령은 정확히 하나다.

테스트가 @JdbcTest의 기본 @Transactional을 제외했음을 알 수 있다. 그 이유는 새 스레드가 테스트 메서드와 동일한 트랜잭션에서 실행되지 않고, 자동 커밋 구성에 따라 데이터가 이미 커밋되었으므로 INSERT 명령이 여전히 보인다. winners = 0 결과가 나오면 테스트 클래스에 @Transactional(propagation = Propagation.NOT_SUPPORTED)를 추가하라.

GEO 로직의 경우, mock을 사용하는 대신 Testcontainers를 사용하여 실제 Redis를 실행해야 한다. 거리 계산과 정렬 순서는 mock이 검증할 수 없는 것들이다.

10. 결론

이 글은 라이드 셰어링 앱의 핵심 부분을 개괄했다. 핵심은 데이터 성질에 따라 분리하는 것이다.

  • 드라이버 위치는 자주 기록되고 손실을 감수할 수 있으므로 Redis GEO에 저장하고, 연결이 끊긴 드라이버를 필터링하기 위해 heartbeat을 함께 사용한다.
  • 라이드는 일관성이 필요하므로 PostgreSQL에 저장한다. 조건부 업데이트는 compare-and-set 역할을 하고, 부분 고유 인덱스는 최후의 안전망이다.
  • TTL이 있는 Redis 락은 중복 오퍼를 방지하고 타임아웃을 자동으로 처리한다. 그러나 락은 최적화 메커니즘일 뿐 소스 오브 트루스는 아니다.
  • Kafka는 매칭을 고객 요청으로부터 분리하고, 급증한 부하를 흡수하며, 재시도를 가능하게 한다. Outbox 패턴은 이벤트 손실을 방지한다.

향후 확장할 때 고려할 수 있는 방향은: 직선 거리 대신 ETA로 후보 순위 매기기, 글로벌 최적화를 위해 매 몇 초마다 일괄 매칭(batch matching) 수행, 카풀링 같은 것들이다. 이 모든 것이 여기서 제시한 기반 위에 구축될 수 있다.

system-designrediskafkaspring-bootcase-study