자동차 렌탈 시스템 설계: 차량 일정, 보증금, 취소 처리

1. 개요

자동차 렌탈은 간단해 보입니다: 고객이 차량을 선택하고, 날짜를 정한 후, 보증금을 내고 차량을 받습니다. 가장 어려운 부분은 한 대의 차량이 두 명의 고객에게 같은 시간대로 예약되지 않도록 보장하는 것입니다. 시스템은 또한 결제 게이트웨이가 웹훅을 여러 번 호출할 때를 올바르게 처리해야 하며, 설맞이나 4월 30일 같은 휴가 시즌에 트래픽이 급증할 때를 견뎌내야 합니다.

이 글에서는 전체 설계를 다룹니다: 요구사항, 데이터 모델, API, 예약 보류 흐름, 보증금 처리 및 취소 처리. 각 어려운 부분마다 Java 21, Spring Boot 3.x, PostgreSQL, Redis, Kafka를 사용한 코드 예제를 제공합니다. 이 글을 읽은 후, 당신은 시간 범위 기반 예약 시스템에 충분한 설계 프레임워크를 갖게 될 것입니다. 물론 대상이 차량이 아니라 호텔 객실, 운동장 또는 회의실이어도 적용할 수 있습니다.

2. 요구사항

2.1. 기능 요구사항

  • 고객이 도시와 시간 범위로 차량을 검색합니다. 예: 2026-01-25 08:00 → 2026-01-28 20:00.
  • 고객이 보증금 결제를 위해 15분간 차량을 예약 보류(hold)합니다.
  • 보증금 결제가 성공하면 예약이 CONFIRMED로 변경됩니다. 15분 내에 결제하지 않으면 예약이 EXPIRED로 변경되고 차량이 해제됩니다.
  • 고객이 예약을 취소하면 정책에 따라 보증금이 환불됩니다: 72시간 전 100% 환불, 24시간 전 50% 환불, 그 이후는 환불 없음.
  • 차량 소유자가 취소하면 고객은 전액 환불을 받습니다.
  • 예약 상태가 변경될 때 알림(SMS, 푸시)을 전송합니다.

2.2. 규모 및 제약사항

지표 정상 설맞이 피크
차량 수 20,000 20,000
검색 요청 ~200 req/s ~3,000 req/s
예약 보류 요청 ~10 req/s ~300 req/s
읽기/쓰기 비율 ~20:1 ~10:1, 인기 있는 수백 대 차량에 집중

가장 중요한 제약사항은 절대 중복 예약이 발생하면 안 된다는 것입니다. 시스템은 “방금 누군가가 이 차량을 예약했습니다”라는 오류를 반환할 수 있지만, 같은 시간대에 한 대의 차량에 대한 두 개의 계약이 존재하는 일은 절대 없어야 합니다. 따라서 예약 데이터는 강한 일관성을 가져야 합니다. 검색 페이지는 몇십 초 정도 오래된 데이터를 수용할 수 있습니다.

3. 데이터 모델

PostgreSQL은 tstzrange 타입과 exclusion constraint를 제공하므로 적절한 선택입니다. 이 두 가지 도구를 사용하면, 데이터베이스 자체가 같은 차량에 대한 겹치는 시간 범위를 방지할 수 있습니다.

CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE car (
    id          BIGSERIAL PRIMARY KEY,
    plate       VARCHAR(20)  NOT NULL UNIQUE,
    model       VARCHAR(100) NOT NULL,
    city        VARCHAR(50)  NOT NULL,
    daily_price BIGINT       NOT NULL
);

CREATE TABLE booking (
    id              BIGSERIAL PRIMARY KEY,
    car_id          BIGINT      NOT NULL REFERENCES car(id),
    customer_id     BIGINT      NOT NULL,
    period          TSTZRANGE   NOT NULL,
    status          VARCHAR(20) NOT NULL,
    deposit_amount  BIGINT      NOT NULL,
    hold_expires_at TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT no_overlap EXCLUDE USING gist (
        car_id WITH =,
        period WITH &&
    ) WHERE (status IN ('HOLD', 'CONFIRMED', 'ON_TRIP'))
);

CREATE TABLE payment (
    id              BIGSERIAL PRIMARY KEY,
    booking_id      BIGINT      NOT NULL REFERENCES booking(id),
    provider_txn_id VARCHAR(64) NOT NULL UNIQUE,
    type            VARCHAR(10) NOT NULL,   -- DEPOSIT | REFUND
    amount          BIGINT      NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE outbox (
    id           BIGSERIAL PRIMARY KEY,
    topic        VARCHAR(100) NOT NULL,
    payload      JSONB        NOT NULL,
    created_at   TIMESTAMPTZ  NOT NULL DEFAULT now(),
    published_at TIMESTAMPTZ
);

몇 가지 주목할 점:

  • no_overlap은 같은 car_id를 가진 두 행이 겹치는 period(&&)를 가질 수 없다는 의미입니다. WHERE 조건은 CANCELLED, EXPIRED, COMPLETED 상태인 예약을 제약에서 제외하므로, 이미 취소된 예약은 일정을 차지하지 않습니다.
  • btree_gist extension은 BIGINT에 대한 = 연산을 range에 대한 && 연산과 결합할 때 필요합니다.
  • provider_txn_id UNIQUE는 결제 웹훅 처리 시 멱등성의 기초입니다.
  • outbox 테이블은 Kafka로 이벤트를 전송할 때 메시지 손실을 방지하는 데 사용됩니다 (8절 참조).

참고: 차량은 두 번의 렌탈 사이에 청소 및 검사 시간이 필요합니다. 반납 시간 후 2시간 등 버퍼를 더한 period를 저장해야 합니다. 애플리케이션 코드에서 버퍼를 더하지 마세요. 어느 시점에선 누군가 더하는 것을 잊을 것입니다.

4. API 설계

메서드 엔드포인트 설명
GET /cars?city=HN&from=…&to=… 사용 가능한 차량 검색
POST /bookings 예약 보류, bookingId 및 결제 링크 반환
POST /payments/webhook 결제 게이트웨이의 결과 알림
POST /bookings/{id}/cancel 예약 취소

POST /bookings는 Idempotency-Key 헤더를 받습니다. 네트워크 끊김으로 인해 앱이 재시도할 때, 중복된 요청은 새로운 예약을 만들지 않습니다. 차량이 이미 예약되었을 때 서버는 409 Conflict를 반환하고, 예약 보류가 성공했을 때 다음과 같은 본문을 포함한 201 Created를 반환합니다:

{ "bookingId": 98123, "status": "HOLD", "holdExpiresAt": "2026-01-10T09:15:00+07:00",
  "paymentUrl": "https://pay.example/checkout/98123" }

5. 예약 보류 흐름

5.1. Constraint로 중복 방지

일반적이지만 틀린 방법은 “SELECT로 확인한 후 INSERT”입니다. 두 요청이 동시에 도착하면 둘 다 차량이 비어있다고 보고, 두 요청 모두 성공적으로 INSERT됩니다. 대신, INSERT를 수행하고 exclusion constraint가 어느 요청이 우선인지 결정하도록 하세요.

예시는 JdbcClient를 사용하므로 다음 의존성이 필요합니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
@Service
public class BookingService {

    private final JdbcClient jdbc;

    public BookingService(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    public long hold(long carId, long customerId,
                     OffsetDateTime from, OffsetDateTime to, long deposit) {
        try {
            return jdbc.sql("""
                    INSERT INTO booking(car_id, customer_id, period, status,
                                        deposit_amount, hold_expires_at)
                    VALUES (:carId, :customerId,
                            tstzrange(CAST(:from AS timestamptz), CAST(:to AS timestamptz), '[)'),
                            'HOLD', :deposit, now() + interval '15 minutes')
                    RETURNING id
                    """)
                .param("carId", carId)
                .param("customerId", customerId)
                .param("from", from)
                .param("to", to)
                .param("deposit", deposit)
                .query(Long.class)
                .single();
        } catch (DataIntegrityViolationException e) {
            throw new CarUnavailableException(carId);
        }
    }
}

public class CarUnavailableException extends RuntimeException {
    public CarUnavailableException(long carId) {
        super("Car " + carId + " is already booked in this period");
    }
}

범위 '[)'는 시작점을 포함하지만 끝점을 제외합니다. 따라서 20:00에 끝나는 렌탈과 20:00에 시작하는 렌탈은 겹치는 것으로 계산되지 않습니다. 제약을 위반하면 PostgreSQL은 SQLState 23P01을 반환하고, Spring은 이를 DataIntegrityViolationException으로 변환합니다. 우리는 이를 CarUnavailableException으로 변환하여 컨트롤러가 409로 매핑할 수 있게 합니다.

두 고객이 동시에 차량 42를 같은 시간대로 예약하려고 할 때, 로그는 다음과 같이 보입니다:

customer=1001 -> 201 Created  bookingId=98123 status=HOLD
customer=1002 -> 409 Conflict Car 42 is already booked in this period

5.2. 예약 보류 만료

정기적인 작업이 HOLD 상태를 초과한 예약을 EXPIRED로 변경합니다. EXPIRED 예약은 constraint의 WHERE 조건 밖에 있으므로 차량이 즉시 해제됩니다.

@Component
public class HoldExpiryJob {

    private final JdbcClient jdbc;

    public HoldExpiryJob(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    @Scheduled(fixedDelay = 30_000)
    public void expireHolds() {
        int expired = jdbc.sql("""
                UPDATE booking SET status = 'EXPIRED'
                WHERE status = 'HOLD' AND hold_expires_at < now()
                """).update();
        if (expired > 0) {
            System.out.println("Expired holds: " + expired);
        }
    }
}

설정 클래스에서 @EnableScheduling을 활성화하는 것을 잊지 마세요. 이 작업이 여러 인스턴스에서 병렬로 실행되어도 안전합니다. UPDATE ... WHERE status = 'HOLD' 쿼리는 이미 멱등성이 있기 때문입니다.

6. 보증금: 멱등성 있는 웹훅과 만료와의 경쟁

결제 게이트웨이는 웹훅을 최소 한 번 호출합니다. 이는 두 번 또는 세 번 호출될 수 있다는 뜻입니다. 또한 웹훅은 보류가 이미 만료된 후 도착할 수 있습니다. 예를 들어, 고객이 14:59에 결제했지만 은행의 처리가 늦는 경우입니다. 두 가지 상황 모두 처리해야 합니다.

@Service
public class PaymentService {

    private final JdbcClient jdbc;
    private final OutboxWriter outbox;

    public PaymentService(JdbcClient jdbc, OutboxWriter outbox) {
        this.jdbc = jdbc;
        this.outbox = outbox;
    }

    @Transactional
    public void onDepositPaid(long bookingId, String providerTxnId, long amount) {
        int inserted = jdbc.sql("""
                INSERT INTO payment(booking_id, provider_txn_id, type, amount)
                VALUES (:bookingId, :txn, 'DEPOSIT', :amount)
                ON CONFLICT (provider_txn_id) DO NOTHING
                """)
            .param("bookingId", bookingId).param("txn", providerTxnId).param("amount", amount)
            .update();
        if (inserted == 0) {
            return; // 웹훅 중복, 이미 처리됨
        }

        int confirmed = jdbc.sql("""
                UPDATE booking SET status = 'CONFIRMED', hold_expires_at = NULL
                WHERE id = :id AND status = 'HOLD'
                """).param("id", bookingId).update();

        if (confirmed == 1) {
            outbox.write("booking.confirmed", Map.of("bookingId", bookingId));
        } else {
            outbox.write("refund.requested", Map.of("bookingId", bookingId, "amount", amount));
        }
    }
}

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

  1. payment에 ON CONFLICT DO NOTHING으로 INSERT합니다. inserted == 0이면 이것은 중복된 웹훅이므로 무시합니다.
  2. 조건부 업데이트로 상태를 변경합니다 (WHERE status = 'HOLD'). 이 쿼리는 compare-and-set 역할을 하므로 명시적 잠금이 필요하지 않습니다.
  3. 업데이트가 어떤 행도 수정하지 않으면 보류가 이미 EXPIRED되었고 차량이 다른 누군가에게 할당되었을 수 있습니다. 이 경우 자동으로 환불 요청을 발행하고, 예약을 “부활”시키려고 하지 않습니다.

모든 작업은 하나의 트랜잭션 내에 있습니다. 결제, 예약 상태 및 outbox 이벤트는 함께 기록되거나 아무것도 기록되지 않습니다.

7. 예약 취소 및 환불

환불 정책은 순수 로직이므로 테스트하기 쉽도록 별도의 클래스로 분리합니다:

public final class CancellationPolicy {

    private CancellationPolicy() {}

    public static long refundAmount(long deposit, OffsetDateTime pickupAt,
                                    OffsetDateTime cancelledAt, boolean cancelledByHost) {
        if (cancelledByHost) {
            return deposit;
        }
        long hoursBefore = Duration.between(cancelledAt, pickupAt).toHours();
        if (hoursBefore >= 72) return deposit;
        if (hoursBefore >= 24) return deposit / 2;
        return 0;
    }
}

JUnit 5와 AssertJ를 사용한 테스트:

class CancellationPolicyTest {

    private final OffsetDateTime pickup = OffsetDateTime.parse("2026-01-25T08:00:00+07:00");

    @Test
    void refundsFullWhenCancelledThreeDaysBefore() {
        assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(72), false))
            .isEqualTo(2_000_000);
    }

    @Test
    void refundsHalfWhenCancelledOneDayBefore() {
        assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(30), false))
            .isEqualTo(1_000_000);
    }

    @Test
    void refundsNothingWhenCancelledLastMinute() {
        assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(2), false))
            .isZero();
    }

    @Test
    void refundsFullWhenHostCancels() {
        assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(1), true))
            .isEqualTo(2_000_000);
    }
}

서비스 레이어에서 취소도 조건부 업데이트와 RETURNING을 사용하여 같은 쿼리에서 요금 계산 데이터를 가져옵니다:

UPDATE booking SET status = 'CANCELLED'
WHERE id = :id AND status = 'CONFIRMED'
RETURNING lower(period) AS pickup_at, deposit_amount;

쿼리가 행을 반환하지 않으면 예약이 이미 취소되었거나 고객이 이미 차량을 인수했습니다 (ON_TRIP). 이 경우 API는 409를 반환합니다. 행이 반환되면 refundAmount를 계산하고 같은 트랜잭션 내에서 outbox에 refund.requested 이벤트를 작성합니다.

참고: 항상 서버 시간(Clock 주입)을 cancelledAt으로 사용하세요. 클라이언트에서 시간을 가져오지 마세요. 24시간 경계 주변 몇 분 차이는 수백만 원 차이입니다.

8. 트래픽 급증 및 신뢰할 수 있는 이벤트 전송

8.1. 읽기와 쓰기 분리

고가 시즌 동안 대부분의 트래픽은 검색 작업에서 발생합니다. 검색 결과는 avail:{city}:{date} 키로 Redis에 30-60초의 TTL로 캐시할 수 있습니다. 캐시된 데이터는 오래될 수 있으며, 그것은 수용 가능합니다. PostgreSQL의 constraint가 최후의 진실이기 때문입니다. 최악의 경우, 고객은 차량이 “사용 가능”하다고 보지만 예약을 보류하려고 할 때 409를 받습니다.

8.2. 차량 점유 방지

설맞이 전에, 일부 고객(또는 봇)이 많은 차량을 예약한 후 결제하지 않습니다. Redis를 사용하여 각 고객이 15분마다 최대 3개의 보류를 할 수 있도록 제한할 수 있습니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Component
public class HoldRateLimiter {

    private final StringRedisTemplate redis;

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

    public boolean tryAcquire(long customerId) {
        String key = "hold-limit:" + customerId;
        Long count = redis.opsForValue().increment(key);
        if (count != null && count == 1) {
            redis.expire(key, Duration.ofMinutes(15));
        }
        return count != null && count <= 3;
    }
}

increment는 원자적 작업이므로 여러 인스턴스가 동시에 호출해도 정확하게 계산됩니다. 15분 창 내에서 4번째 시도는 false를 받으며, API는 429 Too Many Requests를 반환합니다.

8.3. Outbox를 Kafka로

트랜잭션 내에서 직접 Kafka로 전송하는 것은 매우 위험합니다. DB 커밋이 성공하지만 Kafka가 실패하면 이벤트가 손실됩니다. 반대 순서로 하면, 변경 사항이 저장되지 않았는데도 이벤트를 전송합니다. transactional outbox 패턴이 이 문제를 해결합니다: 이벤트를 같은 트랜잭션 내 outbox 테이블에 기록한 후, relay가 테이블을 읽고 Kafka로 푸시합니다.

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
@Component
public class OutboxRelay {

    private final JdbcClient jdbc;
    private final KafkaTemplate<String, String> kafka;

    public OutboxRelay(JdbcClient jdbc, KafkaTemplate<String, String> kafka) {
        this.jdbc = jdbc;
        this.kafka = kafka;
    }

    @Scheduled(fixedDelay = 1_000)
    @Transactional
    public void publish() {
        var rows = jdbc.sql("""
                SELECT id, topic, payload::text AS payload FROM outbox
                WHERE published_at IS NULL ORDER BY id LIMIT 100
                FOR UPDATE SKIP LOCKED
                """).query().listOfRows();

        for (var row : rows) {
            kafka.send((String) row.get("topic"), (String) row.get("payload")).join();
            jdbc.sql("UPDATE outbox SET published_at = now() WHERE id = :id")
                .param("id", row.get("id")).update();
        }
    }
}

FOR UPDATE SKIP LOCKED를 통해 여러 relay 인스턴스가 동시에 실행되어도 같은 행을 획득하지 않습니다. Relay는 여전히 이벤트를 중복으로 전송할 수 있습니다. 예를 들어, send 후에 크래시되지만 UPDATE 전에 크래시되는 경우입니다. 따라서 consumer(알림 서비스, 환불 서비스)는 bookingId에 따라 멱등성이 있어야 합니다.

9. 자주 하는 실수와 적용해서는 안 될 때

  • SELECT로 확인한 후 INSERT. 이 방법은 항상 경쟁 조건이 있습니다. constraint가 자기 일을 하도록 두세요.
  • Redis로 lock을 대신하기. Lock은 GC 일시 중지 등의 이유로 중간에 만료될 수 있습니다. Redis는 보조 보호 레이어일 수만 있고, 진실의 소스가 되어서는 안 됩니다.
  • 시간대 없이 시간 저장. TIMESTAMPTZ와 OffsetDateTime을 사용하세요. 특히 나중에 여러 나라로 확장할 때는 더욱 그렇습니다.
  • 취소 요청에서 동기적으로 환불. 결제 게이트웨이가 느리면 취소 API도 느려집니다. outbox를 통해 환불을 별도로 처리하세요.
  • 여러 데이터베이스에 걸쳐 일정을 shard할 때 constraint를 사용하지 마세요. Constraint는 하나의 데이터베이스 내에서만 유효합니다. Shard가 필요하면 car_id로 shard하여 한 대의 차량에 대한 모든 예약이 같은 곳에 있도록 하세요.

10. 결론

자동차 렌탈 문제는 세 가지 주요 포인트를 중심으로 합니다. 첫째, tstzrange와 exclusion constraint를 사용하여 데이터베이스가 중복 예약을 차단하도록 합니다. 둘째, 조건부 업데이트와 unique 키를 결합하여 중복된 웹훅 및 보류 만료와의 경쟁을 처리합니다. 셋째, transactional outbox 및 Kafka를 사용하여 알림 및 환불 같은 부차 작업을 메인 흐름에서 분리합니다. Redis는 높은 부하를 처리합니다: 검색 캐시 및 예약 보류 제한. PostgreSQL이 진실의 최후 소스입니다.

이 설계 프레임워크는 시간 범위 기반 예약 시스템에 적용할 수 있습니다. 단지 car를 호텔 객실, 운동장 또는 의료 상담으로 바꾸면 됩니다.

system-designspring-bootpostgresqlkafkaredis