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