비교 요약으로 돌아가기

Full output

GPT-5.6 Sol · low 원문 결과물

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

gpt-5.6-sol-low 작업 결과

변경한 파일

  • ReservationService.java
  • InMemoryInventoryStore.java
  • ReservationServiceContractTest.java

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

구현 내용

  • 공백 요청 ID와 상품 ID를 포함한 예약 입력 검증
  • 중복 상품 합산과 long 범위 초과 검출
  • 정규화된 상품별 총수량을 이용한 요청 멱등성 판정
  • 저장소의 decreaseAll을 통한 다중 상품 재고 사전 확인과 일괄 차감
  • 저장소의 increaseAll을 통한 취소·만료 재고 복구 사전 계산과 일괄 반영
  • 만료 시각을 포함하는 now >= expiresAt 경계 처리
  • 만료된 ACTIVE 예약의 상태 전이와 재고 복구 후 확정 예외 처리
  • 동일 결제 재시도, 다른 결제 충돌과 결제 ID 전역 고유성 처리
  • 취소·만료 종결 상태의 반복 호출에서 재고 중복 복구 방지

원자성에 필요한 다중 상품 연산을 인메모리 재고 저장소에 모아, 전체 검증 또는 계산이 끝난 뒤 수량을 반영하는 방식을 선택했다. 실제 병렬 처리, 잠금이나 외부 인프라는 추가하지 않았다.

발견한 기존 오류

  • 중복 상품 수량이 합산되지 않고 마지막 행으로 덮어써졌다.
  • 중복 상품 합산 오버플로를 검출하지 않았다.
  • 다중 상품 예약 실패 전에 일부 재고가 차감될 수 있었다.
  • 같은 요청 ID의 다른 상품 구성을 멱등성 충돌로 처리하지 않았다.
  • 만료 시각과 정확히 같은 순간을 만료에서 제외했다.
  • 확정 요청 시 만료 상태 전이와 재고 복구를 수행하지 않았다.
  • 결제 확정 재시도와 결제 ID 전역 고유성 규칙이 누락됐다.
  • 취소 또는 만료된 예약을 다시 취소하면 재고가 중복 복구될 수 있었다.

테스트 결과

실행 명령:

./gradlew clean test --no-daemon

결과:

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

실제 변경 코드

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

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

package com.samebrief.reservation.service;

import com.samebrief.reservation.error.IdempotencyConflictException;
import com.samebrief.reservation.error.InvalidReservationRequestException;
import com.samebrief.reservation.error.InvalidReservationTransitionException;
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.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());
        }

        inventoryStore.decreaseAll(normalized);
        List<ReservationLine> lines = normalized.entrySet().stream()
                .map(entry -> new ReservationLine(entry.getKey(), entry.getValue()))
                .toList();

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

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

        Reservation reservation = getReservation(reservationId);
        if (reservation.status() == ReservationStatus.ACTIVE
                && !clock.instant().isBefore(reservation.expiresAt())) {
            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;
        }

        inventoryStore.increaseAll(reservation.lines());

        Reservation cancelled = reservation.transitionTo(
                ReservationStatus.CANCELLED,
                reservation.paymentId()
        );
        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
                    || now.isBefore(reservation.expiresAt())) {
                continue;
            }

            expire(reservation);
            expiredCount++;
        }

        return expiredCount;
    }

    private Map<String, Long> normalize(ReserveCommand command) {
        if (command == null) {
            throw new InvalidReservationRequestException("command is required");
        }
        if (isBlank(command.requestId())) {
            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 || isBlank(line.productId()) || 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 void expire(Reservation reservation) {
        inventoryStore.increaseAll(reservation.lines());
        reservationStore.save(reservation.transitionTo(
                ReservationStatus.EXPIRED,
                null
        ));
    }

    private boolean isBlank(String value) {
        return value == null || value.isBlank();
    }

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

src/main/java/com/samebrief/reservation/store/InMemoryInventoryStore.java

package com.samebrief.reservation.store;

import com.samebrief.reservation.error.OutOfStockException;
import com.samebrief.reservation.model.InventoryItem;
import com.samebrief.reservation.model.ReservationLine;

import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;

public final class InMemoryInventoryStore {

    private final Map<String, InventoryItem> items = new LinkedHashMap<>();

    public void put(String productId, long availableQuantity) {
        if (availableQuantity < 0) {
            throw new IllegalArgumentException("availableQuantity must not be negative");
        }
        items.put(productId, new InventoryItem(productId, availableQuantity));
    }

    public Optional<InventoryItem> find(String productId) {
        return Optional.ofNullable(items.get(productId));
    }

    public long availableQuantity(String productId) {
        return find(productId)
                .map(InventoryItem::availableQuantity)
                .orElse(0L);
    }

    public void decrease(String productId, long quantity) {
        InventoryItem item = items.get(productId);
        if (item == null || item.availableQuantity() < quantity) {
            throw new OutOfStockException(productId);
        }
        items.put(productId, new InventoryItem(
                productId,
                item.availableQuantity() - quantity
        ));
    }

    public void increase(String productId, long quantity) {
        InventoryItem item = items.get(productId);
        if (item == null) {
            throw new IllegalStateException("Unknown inventory item: " + productId);
        }
        items.put(productId, new InventoryItem(
                productId,
                Math.addExact(item.availableQuantity(), quantity)
        ));
    }

    public void decreaseAll(Map<String, Long> quantities) {
        for (Map.Entry<String, Long> entry : quantities.entrySet()) {
            InventoryItem item = items.get(entry.getKey());
            if (item == null || item.availableQuantity() < entry.getValue()) {
                throw new OutOfStockException(entry.getKey());
            }
        }

        quantities.forEach((productId, quantity) -> {
            InventoryItem item = items.get(productId);
            items.put(productId, new InventoryItem(
                    productId,
                    item.availableQuantity() - quantity
            ));
        });
    }

    public void increaseAll(List<ReservationLine> lines) {
        Map<String, Long> updatedQuantities = new LinkedHashMap<>();
        for (ReservationLine line : lines) {
            InventoryItem item = items.get(line.productId());
            if (item == null) {
                throw new IllegalStateException("Unknown inventory item: " + line.productId());
            }
            updatedQuantities.put(
                    line.productId(),
                    Math.addExact(item.availableQuantity(), line.quantity())
            );
        }

        updatedQuantities.forEach((productId, quantity) ->
                items.put(productId, new InventoryItem(productId, quantity)));
    }

    public Map<String, InventoryItem> snapshot() {
        return Map.copyOf(items);
    }
}

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 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 normalizesDuplicatesInFirstAppearanceOrder() {
        inventory.put("A", 10);
        inventory.put("B", 5);

        Reservation result = reserve("r1", line("A", 2), line("B", 1), line("A", 3));

        assertEquals(
                List.of(new ReservationLine("A", 5), new ReservationLine("B", 1)),
                result.lines()
        );
        assertState(5, 4, 1, 1);
    }

    @Test
    void stockFailureIsAtomicAndDoesNotClaimRequestId() {
        inventory.put("A", 10);
        inventory.put("B", 1);

        assertThrows(OutOfStockException.class,
                () -> reserve("r1", line("A", 3), line("B", 2)));
        assertState(10, 1, 0, 0);

        inventory.put("B", 2);
        Reservation retry = reserve("r1", line("A", 3), line("B", 2));
        assertEquals(ReservationStatus.ACTIVE, retry.status());
        assertState(7, 0, 1, 1);
    }

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

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

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

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

        assertInvalid(new ReserveCommand(" ", List.of(line("A", 1))));
        assertInvalid(new ReserveCommand("r1", List.of(line(" ", 1))));
        assertInvalid(new ReserveCommand("r1", List.of(line("A", 0))));
        assertInvalid(new ReserveCommand(
                "r1",
                List.of(line("A", Long.MAX_VALUE), line("A", 1))
        ));

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

    @Test
    void requestIdempotencyUsesTotalsRegardlessOfOrderOrSplitting() {
        inventory.put("A", 10);
        inventory.put("B", 10);
        Reservation first = reserve("r1", line("A", 2), line("B", 1), line("A", 3));

        Reservation retry = reserve("r1", line("B", 1), line("A", 5));

        assertEquals(first.id(), retry.id());
        assertEquals(5, inventory.availableQuantity("A"));
        assertEquals(9, inventory.availableQuantity("B"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    @Test
    void conflictingIdempotentRequestIsRejectedBeforeStockCheck() {
        inventory.put("A", 10);
        Reservation first = reserve("r1", line("A", 3));
        inventory.put("A", 0);

        assertThrows(IdempotencyConflictException.class,
                () -> reserve("r1", line("A", 4)));

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

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

        Reservation result = reserve("r1", line("A", 1));

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

    @Test
    void confirmationOneNanosecondBeforeExpirySurvivesExpirationSweep() {
        inventory.put("A", 10);
        Reservation active = reserve("r1", line("A", 3));
        clock.set(active.expiresAt().minusNanos(1));

        Reservation confirmed = service.confirm(active.id(), "p1");
        clock.set(active.expiresAt().plusSeconds(1));

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

    @Test
    void confirmationAtExpiryExpiresAndRestoresExactlyOnce() {
        inventory.put("A", 10);
        Reservation active = reserve("r1", line("A", 3));
        clock.set(active.expiresAt());

        assertThrows(ReservationExpiredException.class,
                () -> service.confirm(active.id(), "p1"));
        Reservation expired = reservations.findById(active.id()).orElseThrow();
        assertEquals(ReservationStatus.EXPIRED, expired.status());
        assertNull(expired.paymentId());
        assertEquals(10, inventory.availableQuantity("A"));

        assertThrows(ReservationExpiredException.class,
                () -> service.confirm(active.id(), "p1"));
        assertEquals(10, inventory.availableQuantity("A"));
    }

    @Test
    void expirationSweepOnlyExpiresDueActiveReservationsOnce() {
        inventory.put("A", 20);
        Reservation due = reserve("r1", line("A", 2));
        clock.set(START.plusSeconds(1));
        Reservation later = reserve("r2", line("A", 3));
        service.confirm(later.id(), "p2");
        clock.set(due.expiresAt());

        assertEquals(1, service.expireDueReservations());
        assertEquals(0, service.expireDueReservations());
        assertEquals(ReservationStatus.EXPIRED,
                reservations.findById(due.id()).orElseThrow().status());
        assertEquals(ReservationStatus.CONFIRMED,
                reservations.findById(later.id()).orElseThrow().status());
        assertEquals(17, inventory.availableQuantity("A"));
    }

    @Test
    void cancellationAndExpiredCancellationRestoreAtMostOnce() {
        inventory.put("A", 10);
        Reservation active = reserve("r1", line("A", 3));

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

        assertEquals(first, second);
        assertEquals(ReservationStatus.CANCELLED, second.status());
        assertEquals(10, inventory.availableQuantity("A"));

        inventory.put("B", 4);
        Reservation expiring = reserve("r2", line("B", 2));
        clock.set(expiring.expiresAt());
        assertEquals(1, service.expireDueReservations());
        Reservation expired = service.cancel(expiring.id());
        assertEquals(ReservationStatus.EXPIRED, expired.status());
        assertEquals(4, inventory.availableQuantity("B"));
    }

    @Test
    void paymentRetriesConflictsAndGlobalUniquenessAreEnforced() {
        inventory.put("A", 10);
        Reservation first = reserve("r1", line("A", 2));
        Reservation second = reserve("r2", line("A", 3));

        Reservation confirmed = service.confirm(first.id(), "p1");
        Reservation retry = service.confirm(first.id(), "p1");
        assertEquals(confirmed, retry);

        assertThrows(PaymentConflictException.class,
                () -> service.confirm(first.id(), "different"));
        assertEquals("p1", reservations.findById(first.id()).orElseThrow().paymentId());

        assertThrows(PaymentConflictException.class,
                () -> service.confirm(second.id(), "p1"));
        assertEquals(ReservationStatus.ACTIVE,
                reservations.findById(second.id()).orElseThrow().status());
        assertEquals(5, inventory.availableQuantity("A"));
    }

    @Test
    void invalidPaymentAndConfirmedCancellationDoNotMutateState() {
        inventory.put("A", 10);
        Reservation active = reserve("r1", line("A", 3));

        assertThrows(InvalidReservationRequestException.class,
                () -> service.confirm(active.id(), " "));
        assertEquals(ReservationStatus.ACTIVE,
                reservations.findById(active.id()).orElseThrow().status());

        service.confirm(active.id(), "p1");
        assertThrows(InvalidReservationTransitionException.class,
                () -> service.cancel(active.id()));
        Reservation unchanged = reservations.findById(active.id()).orElseThrow();
        assertEquals(ReservationStatus.CONFIRMED, unchanged.status());
        assertEquals("p1", unchanged.paymentId());
        assertEquals(7, inventory.availableQuantity("A"));
    }

    @Test
    void creationRetryReturnsCurrentTerminalReservationWithoutStockChange() {
        inventory.put("A", 10);
        Reservation cancelled = service.cancel(reserve("r1", line("A", 3)).id());

        Reservation retry = reserve("r1", line("A", 3));

        assertEquals(cancelled.id(), retry.id());
        assertEquals(ReservationStatus.CANCELLED, retry.status());
        assertEquals(10, inventory.availableQuantity("A"));
        assertEquals(1, reservations.count());
        assertEquals(1, idempotency.count());
    }

    private void assertInvalid(ReserveCommand command) {
        assertThrows(InvalidReservationRequestException.class, () -> service.reserve(command));
    }

    private void assertState(long a, long b, int reservationCount, int idempotencyCount) {
        assertEquals(a, inventory.availableQuantity("A"));
        assertEquals(b, inventory.availableQuantity("B"));
        assertEquals(reservationCount, reservations.count());
        assertEquals(idempotencyCount, idempotency.count());
    }

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

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

    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;
        }
    }
}