비교 요약으로 돌아가기

Full output

GPT-5.6 Sol · medium 원문 결과물

재고 예약 상태 전이·멱등성 보수 / OpenAI / GPT-5.6 Sol / 실행일 2026-07-30

gpt-5.6-sol-medium 작업 결과

변경한 파일

  • ReservationService.java
  • ReservationServiceContractTest.java

작업 사본은 verification/work/14-inventory-reservation-state/gpt-5.6-sol-medium에 보존했다.

구현 내용

  • null, 빈 문자열과 공백 문자열을 포함한 예약 입력 검증
  • 최초 등장 순서를 유지하는 중복 상품 합산과 long 오버플로 검출
  • 정규화한 상품별 총수량을 이용한 요청 멱등성 비교
  • 모든 상품의 재고를 먼저 확인한 뒤에만 차감하는 다중 상품 예약 원자성
  • 주입된 Clock을 이용한 생성 시각과 정확한 15분 만료 시각 계산
  • 만료 경계 now >= expiresAt 처리와 재고 1회 복구
  • 동일 결제 ID 재시도, 다른 결제 ID 충돌과 전역 결제 ID 고유성 처리
  • 취소·만료 상태의 반복 호출을 no-op으로 처리해 재고 중복 복구 방지
  • 취소·만료 뒤 예약 생성 재시도에서 기존 종결 예약 반환

서비스에서 입력 정규화, 멱등성 판정과 전체 재고 선검증을 순서대로 수행하고, 검증이 끝난 뒤 기존 저장소의 상품별 증감 연산을 적용하는 방식을 선택했다. 별도 저장소 API, 잠금이나 외부 인프라는 추가하지 않았다.

발견한 기존 오류

  • 공백 요청 ID와 상품 ID를 허용했다.
  • 중복 상품 수량을 합산하지 않았고 합산 오버플로도 검출하지 않았다.
  • 다중 상품 예약에서 재고가 부분 차감될 수 있었다.
  • 기존 요청 ID의 payload 차이를 확인하지 않았다.
  • 만료 시각과 정확히 같은 순간을 만료로 판단하지 않았다.
  • 확정 시 만료된 예약의 상태와 재고를 갱신하지 않았다.
  • 확정된 예약의 다른 결제 ID와 예약 간 결제 ID 중복을 허용했다.
  • 종결 상태의 취소 재시도에서 재고가 중복 복구될 수 있었다.

테스트 결과

실행 명령:

./gradlew clean test --no-daemon

결과:

  • 기본 테스트: 4개 통과
  • 추가 계약 테스트: 19개 통과
  • 전체: 23개 통과, 실패 0개
  • BUILD SUCCESSFUL

실제 변경 코드

아래는 보존된 격리 작업 사본을 원본 하네스와 대조해 확인한 변경 파일의 전체 내용입니다.

src/main/java/com/samebrief/reservation/service/ReservationService.java

package com.samebrief.reservation.service;

import com.samebrief.reservation.error.InvalidReservationRequestException;
import com.samebrief.reservation.error.InvalidReservationTransitionException;
import com.samebrief.reservation.error.IdempotencyConflictException;
import com.samebrief.reservation.error.OutOfStockException;
import com.samebrief.reservation.error.PaymentConflictException;
import com.samebrief.reservation.error.ReservationExpiredException;
import com.samebrief.reservation.error.ReservationNotFoundException;
import com.samebrief.reservation.model.Reservation;
import com.samebrief.reservation.model.ReservationLine;
import com.samebrief.reservation.model.ReservationStatus;
import com.samebrief.reservation.service.command.ReserveCommand;
import com.samebrief.reservation.service.command.ReserveLineCommand;
import com.samebrief.reservation.store.IdempotencyRecord;
import com.samebrief.reservation.store.InMemoryIdempotencyStore;
import com.samebrief.reservation.store.InMemoryInventoryStore;
import com.samebrief.reservation.store.InMemoryReservationStore;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;

public final class ReservationService {

    private static final Duration RESERVATION_TTL = Duration.ofMinutes(15);

    private final InMemoryInventoryStore inventoryStore;
    private final InMemoryReservationStore reservationStore;
    private final InMemoryIdempotencyStore idempotencyStore;
    private final Clock clock;

    public ReservationService(
            InMemoryInventoryStore inventoryStore,
            InMemoryReservationStore reservationStore,
            InMemoryIdempotencyStore idempotencyStore,
            Clock clock
    ) {
        this.inventoryStore = Objects.requireNonNull(inventoryStore);
        this.reservationStore = Objects.requireNonNull(reservationStore);
        this.idempotencyStore = Objects.requireNonNull(idempotencyStore);
        this.clock = Objects.requireNonNull(clock);
    }

    public Reservation reserve(ReserveCommand command) {
        Map<String, Long> normalized = normalize(command);

        IdempotencyRecord existing = idempotencyStore.find(command.requestId())
                .orElse(null);
        if (existing != null) {
            if (!existing.normalizedLines().equals(normalized)) {
                throw new IdempotencyConflictException(command.requestId());
            }
            return getReservation(existing.reservationId());
        }

        // Validate the complete request before changing any inventory.
        for (Map.Entry<String, Long> entry : normalized.entrySet()) {
            if (inventoryStore.availableQuantity(entry.getKey()) < entry.getValue()) {
                throw new OutOfStockException(entry.getKey());
            }
        }

        Instant createdAt = clock.instant();
        Instant expiresAt = createdAt.plus(RESERVATION_TTL);
        List<ReservationLine> lines = new ArrayList<>();
        for (Map.Entry<String, Long> entry : normalized.entrySet()) {
            inventoryStore.decrease(entry.getKey(), entry.getValue());
            lines.add(new ReservationLine(entry.getKey(), entry.getValue()));
        }

        Reservation reservation = new Reservation(
                UUID.randomUUID().toString(),
                command.requestId(),
                lines,
                ReservationStatus.ACTIVE,
                createdAt,
                expiresAt,
                null
        );
        reservationStore.save(reservation);
        idempotencyStore.save(new IdempotencyRecord(
                command.requestId(),
                normalized,
                reservation.id()
        ));
        return reservation;
    }

    public Reservation confirm(String reservationId, String paymentId) {
        if (paymentId == null || paymentId.isBlank()) {
            throw new InvalidReservationRequestException("paymentId is required");
        }

        Reservation reservation = getReservation(reservationId);
        if (reservation.status() == ReservationStatus.ACTIVE
                && isDue(reservation, clock.instant())) {
            expire(reservation);
            throw new ReservationExpiredException(reservationId);
        }

        if (reservation.status() == ReservationStatus.CONFIRMED) {
            if (paymentId.equals(reservation.paymentId())) {
                return reservation;
            }
            throw new PaymentConflictException(paymentId);
        }
        if (reservation.status() == ReservationStatus.EXPIRED) {
            throw new ReservationExpiredException(reservationId);
        }
        if (reservation.status() != ReservationStatus.ACTIVE) {
            throw new InvalidReservationTransitionException(reservationId);
        }

        if (reservationStore.findByPaymentId(paymentId).isPresent()) {
            throw new PaymentConflictException(paymentId);
        }

        Reservation confirmed = reservation.transitionTo(
                ReservationStatus.CONFIRMED,
                paymentId
        );
        reservationStore.save(confirmed);
        return confirmed;
    }

    public Reservation cancel(String reservationId) {
        Reservation reservation = getReservation(reservationId);
        if (reservation.status() == ReservationStatus.CONFIRMED) {
            throw new InvalidReservationTransitionException(reservationId);
        }
        if (reservation.status() == ReservationStatus.CANCELLED
                || reservation.status() == ReservationStatus.EXPIRED) {
            return reservation;
        }

        Reservation cancelled = reservation.transitionTo(
                ReservationStatus.CANCELLED,
                reservation.paymentId()
        );
        restoreInventory(reservation);
        reservationStore.save(cancelled);
        return cancelled;
    }

    public int expireDueReservations() {
        Instant now = clock.instant();
        int expiredCount = 0;

        for (Reservation reservation : reservationStore.findAll()) {
            if (reservation.status() != ReservationStatus.ACTIVE
                    || !isDue(reservation, now)) {
                continue;
            }

            expire(reservation);
            expiredCount++;
        }

        return expiredCount;
    }

    private Map<String, Long> normalize(ReserveCommand command) {
        if (command == null) {
            throw new InvalidReservationRequestException("command is required");
        }
        if (command.requestId() == null || command.requestId().isBlank()) {
            throw new InvalidReservationRequestException("requestId is required");
        }
        if (command.lines() == null || command.lines().isEmpty()) {
            throw new InvalidReservationRequestException("lines are required");
        }

        Map<String, Long> normalized = new LinkedHashMap<>();
        for (ReserveLineCommand line : command.lines()) {
            if (line == null
                    || line.productId() == null
                    || line.productId().isBlank()
                    || line.quantity() < 1) {
                throw new InvalidReservationRequestException("invalid reservation line");
            }
            try {
                normalized.merge(line.productId(), line.quantity(), Math::addExact);
            } catch (ArithmeticException exception) {
                throw new InvalidReservationRequestException(
                        "reservation line quantity overflow"
                );
            }
        }
        return normalized;
    }

    private boolean isDue(Reservation reservation, Instant now) {
        return !now.isBefore(reservation.expiresAt());
    }

    private void expire(Reservation reservation) {
        restoreInventory(reservation);
        reservationStore.save(reservation.transitionTo(
                ReservationStatus.EXPIRED,
                null
        ));
    }

    private void restoreInventory(Reservation reservation) {
        for (ReservationLine line : reservation.lines()) {
            inventoryStore.increase(line.productId(), line.quantity());
        }
    }

    private Reservation getReservation(String reservationId) {
        return reservationStore.findById(reservationId)
                .orElseThrow(() -> new ReservationNotFoundException(reservationId));
    }
}

src/test/java/com/samebrief/reservation/ReservationServiceContractTest.java

package com.samebrief.reservation;

import com.samebrief.reservation.error.IdempotencyConflictException;
import com.samebrief.reservation.error.InvalidReservationRequestException;
import com.samebrief.reservation.error.InvalidReservationTransitionException;
import com.samebrief.reservation.error.OutOfStockException;
import com.samebrief.reservation.error.PaymentConflictException;
import com.samebrief.reservation.error.ReservationExpiredException;
import com.samebrief.reservation.model.Reservation;
import com.samebrief.reservation.model.ReservationLine;
import com.samebrief.reservation.model.ReservationStatus;
import com.samebrief.reservation.service.ReservationService;
import com.samebrief.reservation.service.command.ReserveCommand;
import com.samebrief.reservation.service.command.ReserveLineCommand;
import com.samebrief.reservation.store.InMemoryIdempotencyStore;
import com.samebrief.reservation.store.InMemoryInventoryStore;
import com.samebrief.reservation.store.InMemoryReservationStore;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

class ReservationServiceContractTest {

    private static final Instant START = Instant.parse("2026-07-30T00:00:00Z");

    private InMemoryInventoryStore inventory;
    private InMemoryReservationStore reservations;
    private InMemoryIdempotencyStore idempotency;
    private MutableClock clock;
    private ReservationService service;

    @BeforeEach
    void setUp() {
        inventory = new InMemoryInventoryStore();
        reservations = new InMemoryReservationStore();
        idempotency = new InMemoryIdempotencyStore();
        clock = new MutableClock(START);
        service = new ReservationService(inventory, reservations, idempotency, clock);
    }

    @Test
    void normalizesDuplicateProductsInFirstAppearanceOrder() {
        inventory.put("A", 10);
        inventory.put("B", 5);

        Reservation reservation = reserve(
                "request-1",
                line("A", 2),
                line("B", 1),
                line("A", 3)
        );

        assertEquals(
                List.of(new ReservationLine("A", 5), new ReservationLine("B", 1)),
                reservation.lines()
        );
        assertEquals(5, inventory.availableQuantity("A"));
        assertEquals(4, inventory.availableQuantity("B"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void insufficientMultiProductRequestIsAtomic() {
        inventory.put("A", 10);
        inventory.put("B", 1);
        Map<String, ?> before = inventory.snapshot();

        assertThrows(
                OutOfStockException.class,
                () -> reserve("request-1", line("A", 4), line("B", 2))
        );

        assertEquals(before, inventory.snapshot());
        assertEquals(0, reservations.count());
        assertEquals(0, idempotency.count());
    }

    @Test
    void unknownProductDoesNotCausePartialDeduction() {
        inventory.put("A", 10);

        assertThrows(
                OutOfStockException.class,
                () -> reserve("request-1", line("A", 4), line("missing", 1))
        );

        assertEquals(10, inventory.availableQuantity("A"));
        assertEquals(0, inventory.availableQuantity("missing"));
        assertEquals(0, reservations.count());
        assertEquals(0, idempotency.count());
    }

    @Test
    void invalidRequestsAndDuplicateOverflowDoNotMutateState() {
        inventory.put("A", 10);

        assertThrows(
                InvalidReservationRequestException.class,
                () -> reserve("   ", line("A", 1))
        );
        assertThrows(
                InvalidReservationRequestException.class,
                () -> reserve("request-2", line(" \t", 1))
        );
        assertThrows(
                InvalidReservationRequestException.class,
                () -> reserve("request-3", line("A", 0))
        );
        assertThrows(
                InvalidReservationRequestException.class,
                () -> reserve(
                        "request-4",
                        line("A", Long.MAX_VALUE),
                        line("A", 1)
                )
        );

        assertEquals(10, inventory.availableQuantity("A"));
        assertEquals(0, reservations.count());
        assertEquals(0, idempotency.count());
    }

    @Test
    void equivalentIdempotentRetryReturnsExistingReservation() {
        inventory.put("A", 10);
        Reservation first = reserve("request-1", line("A", 2), line("A", 3));

        Reservation retried = reserve("request-1", line("A", 5));

        assertEquals(first.id(), retried.id());
        assertEquals(ReservationStatus.ACTIVE, retried.status());
        assertEquals(5, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void changedIdempotentRequestConflictsBeforeStockEvaluation() {
        inventory.put("A", 10);
        Reservation first = reserve("request-1", line("A", 5));

        assertThrows(
                IdempotencyConflictException.class,
                () -> reserve("request-1", line("A", 100))
        );

        assertEquals(first, reservations.findById(first.id()).orElseThrow());
        assertEquals(5, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void failedRequestDoesNotClaimIdempotencyKey() {
        inventory.put("A", 1);
        assertThrows(
                OutOfStockException.class,
                () -> reserve("request-1", line("A", 2))
        );

        inventory.put("A", 2);
        Reservation reservation = reserve("request-1", line("A", 2));

        assertEquals(ReservationStatus.ACTIVE, reservation.status());
        assertEquals(0, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void reservationUsesInjectedClockAndExactFifteenMinuteTtl() {
        inventory.put("A", 1);

        Reservation reservation = reserve("request-1", line("A", 1));

        assertEquals(START, reservation.createdAt());
        assertEquals(START.plusSeconds(15 * 60), reservation.expiresAt());
    }

    @Test
    void confirmationOneNanosecondBeforeExpiryPreventsExpiration() {
        inventory.put("A", 10);
        Reservation reservation = reserve("request-1", line("A", 3));
        clock.set(reservation.expiresAt().minusNanos(1));

        Reservation confirmed = service.confirm(reservation.id(), "payment-1");
        clock.set(reservation.expiresAt());

        assertEquals(ReservationStatus.CONFIRMED, confirmed.status());
        assertEquals("payment-1", confirmed.paymentId());
        assertEquals(0, service.expireDueReservations());
        assertEquals(ReservationStatus.CONFIRMED, current(confirmed).status());
        assertEquals(7, inventory.availableQuantity("A"));
    }

    @Test
    void confirmationAtExpiryExpiresAndRestoresExactlyOnce() {
        inventory.put("A", 10);
        Reservation reservation = reserve("request-1", line("A", 3));
        clock.set(reservation.expiresAt());

        assertThrows(
                ReservationExpiredException.class,
                () -> service.confirm(reservation.id(), "payment-1")
        );
        Reservation expired = current(reservation);
        assertEquals(ReservationStatus.EXPIRED, expired.status());
        assertNull(expired.paymentId());
        assertEquals(10, inventory.availableQuantity("A"));

        assertThrows(
                ReservationExpiredException.class,
                () -> service.confirm(reservation.id(), "payment-2")
        );
        assertEquals(10, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void bulkExpiryOnlyProcessesDueActiveReservationsOnce() {
        inventory.put("A", 30);
        Reservation due = reserve("request-1", line("A", 2));
        clock.set(START.plusSeconds(5 * 60));
        Reservation future = reserve("request-2", line("A", 2));
        Reservation confirmed = reserve("request-3", line("A", 2));
        service.confirm(confirmed.id(), "payment-1");
        clock.set(due.expiresAt());

        assertEquals(1, service.expireDueReservations());
        assertEquals(ReservationStatus.EXPIRED, current(due).status());
        assertEquals(ReservationStatus.ACTIVE, current(future).status());
        assertEquals(ReservationStatus.CONFIRMED, current(confirmed).status());
        assertEquals(26, inventory.availableQuantity("A"));

        assertEquals(0, service.expireDueReservations());
        assertEquals(26, inventory.availableQuantity("A"));
        assertEquals(3, reservations.count());
        assertEquals(3, idempotency.count());
    }

    @Test
    void cancellingTwiceReturnsCancelledReservationAndRestoresOnce() {
        inventory.put("A", 10);
        Reservation reservation = reserve("request-1", line("A", 3));

        Reservation first = service.cancel(reservation.id());
        Reservation second = service.cancel(reservation.id());

        assertEquals(first, second);
        assertEquals(ReservationStatus.CANCELLED, second.status());
        assertEquals(10, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void cancellingExpiredReservationIsANoOp() {
        inventory.put("A", 10);
        Reservation reservation = reserve("request-1", line("A", 3));
        clock.set(reservation.expiresAt());
        assertEquals(1, service.expireDueReservations());

        Reservation cancelled = service.cancel(reservation.id());

        assertEquals(ReservationStatus.EXPIRED, cancelled.status());
        assertEquals(current(reservation), cancelled);
        assertEquals(10, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void samePaymentRetryReturnsConfirmedReservation() {
        inventory.put("A", 10);
        Reservation reservation = reserve("request-1", line("A", 3));

        Reservation first = service.confirm(reservation.id(), "payment-1");
        Reservation second = service.confirm(reservation.id(), "payment-1");

        assertEquals(first, second);
        assertEquals(ReservationStatus.CONFIRMED, second.status());
        assertEquals("payment-1", second.paymentId());
        assertEquals(7, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
    }

    @Test
    void differentPaymentOnConfirmedReservationConflicts() {
        inventory.put("A", 10);
        Reservation reservation = reserve("request-1", line("A", 3));
        service.confirm(reservation.id(), "payment-1");

        assertThrows(
                PaymentConflictException.class,
                () -> service.confirm(reservation.id(), "payment-2")
        );

        Reservation confirmed = current(reservation);
        assertEquals(ReservationStatus.CONFIRMED, confirmed.status());
        assertEquals("payment-1", confirmed.paymentId());
        assertEquals(7, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
    }

    @Test
    void paymentCannotConfirmTwoReservations() {
        inventory.put("A", 10);
        Reservation first = reserve("request-1", line("A", 2));
        Reservation second = reserve("request-2", line("A", 3));
        service.confirm(first.id(), "payment-1");

        assertThrows(
                PaymentConflictException.class,
                () -> service.confirm(second.id(), "payment-1")
        );

        assertEquals(ReservationStatus.CONFIRMED, current(first).status());
        assertEquals("payment-1", current(first).paymentId());
        assertEquals(ReservationStatus.ACTIVE, current(second).status());
        assertNull(current(second).paymentId());
        assertEquals(5, inventory.availableQuantity("A"));
        assertEquals(2, reservations.count());
        assertEquals(2, idempotency.count());
    }

    @Test
    void confirmedReservationCannotBeCancelled() {
        inventory.put("A", 10);
        Reservation reservation = reserve("request-1", line("A", 3));
        service.confirm(reservation.id(), "payment-1");

        assertThrows(
                InvalidReservationTransitionException.class,
                () -> service.cancel(reservation.id())
        );

        Reservation confirmed = current(reservation);
        assertEquals(ReservationStatus.CONFIRMED, confirmed.status());
        assertEquals("payment-1", confirmed.paymentId());
        assertEquals(7, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void retriesAfterCancellationAndExpiryReturnCurrentTerminalReservation() {
        inventory.put("A", 10);
        Reservation cancelled = reserve("request-1", line("A", 2));
        cancelled = service.cancel(cancelled.id());
        long afterCancellation = inventory.availableQuantity("A");

        Reservation cancelledRetry = reserve("request-1", line("A", 2));
        assertEquals(cancelled.id(), cancelledRetry.id());
        assertEquals(ReservationStatus.CANCELLED, cancelledRetry.status());
        assertEquals(afterCancellation, inventory.availableQuantity("A"));

        Reservation expired = reserve("request-2", line("A", 3));
        clock.set(expired.expiresAt());
        assertEquals(1, service.expireDueReservations());
        long afterExpiry = inventory.availableQuantity("A");

        Reservation expiredRetry = reserve("request-2", line("A", 3));
        assertEquals(expired.id(), expiredRetry.id());
        assertEquals(ReservationStatus.EXPIRED, expiredRetry.status());
        assertEquals(afterExpiry, inventory.availableQuantity("A"));
        assertEquals(2, reservations.count());
        assertEquals(2, idempotency.count());
    }

    @Test
    void blankPaymentIsRejectedBeforeReservationLookup() {
        assertThrows(
                InvalidReservationRequestException.class,
                () -> service.confirm("missing-reservation", " \t")
        );
        assertEquals(0, reservations.count());
        assertEquals(0, idempotency.count());
    }

    private Reservation reserve(String requestId, ReserveLineCommand... lines) {
        return service.reserve(new ReserveCommand(requestId, List.of(lines)));
    }

    private ReserveLineCommand line(String productId, long quantity) {
        return new ReserveLineCommand(productId, quantity);
    }

    private Reservation current(Reservation reservation) {
        return reservations.findById(reservation.id()).orElseThrow();
    }

    private static final class MutableClock extends Clock {

        private Instant instant;

        private MutableClock(Instant instant) {
            this.instant = instant;
        }

        private void set(Instant instant) {
            this.instant = instant;
        }

        @Override
        public ZoneId getZone() {
            return ZoneOffset.UTC;
        }

        @Override
        public Clock withZone(ZoneId zone) {
            return this;
        }

        @Override
        public Instant instant() {
            return instant;
        }
    }
}