Full output
GPT-5.6 Sol · low 원문 결과물
재고 예약 상태 전이·멱등성 보수 / OpenAI / GPT-5.6 Sol / 실행일 2026-07-30
gpt-5.6-sol-low 작업 결과
변경한 파일
ReservationService.javaInMemoryInventoryStore.javaReservationServiceContractTest.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;
}
}
}