From 646a57bd1f2e0eefde50c9528a65cfbaceb17a13 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:49:17 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EB=82=99=EA=B4=80=EC=A0=81=20?= =?UTF-8?q?=EB=9D=BD=20=EC=B6=A9=EB=8F=8C=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=EA=B8=B0=20LockExecutor=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/common/build.gradle.kts | 7 +- .../stream/common/CommonErrorCode.java | 1 + .../kookmin/stream/common/LockExecutor.java | 76 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/LockExecutor.java diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 8b3f4dcc..a5bc9be6 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -2,9 +2,14 @@ plugins { java } -description = "공유 커널 — 순수 Java. web/security/JPA·Spring 런타임 의존 없음(modulith-api는 루트 공통 compileOnly로만 상속). verify 설정에서 shared module로 선언" +description = "공유 커널 — 순수 Java + spring-context·spring-tx만 허용. web/security/JPA 의존 없음(modulith-api는 루트 공통 compileOnly로만 상속). verify 설정에서 shared module로 선언" dependencies { + // LockExecutor가 시도마다 트랜잭션을 열고(spring-tx) 빈으로 등록된다(spring-context) + implementation(platform(libs.springBootDependenciesBom)) + implementation(libs.springContext) + implementation(libs.springTx) + testImplementation(platform(libs.junitBom)) testImplementation(libs.junitJupiter) testRuntimeOnly(libs.junitPlatformLauncher) diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/CommonErrorCode.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/CommonErrorCode.java index 1e5cec6f..5671df39 100644 --- a/core/common/src/main/java/kr/ac/kookmin/stream/common/CommonErrorCode.java +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/CommonErrorCode.java @@ -12,6 +12,7 @@ public enum CommonErrorCode implements ErrorCode { INVALID_INPUT(ErrorStatus.BAD_REQUEST, "유효하지 않은 입력값입니다."), UNAUTHORIZED(ErrorStatus.UNAUTHORIZED, "인증이 필요합니다."), FORBIDDEN(ErrorStatus.FORBIDDEN, "접근 권한이 없습니다."), + OPTIMISTIC_LOCK_CONFLICT(ErrorStatus.CONFLICT, "요청이 몰려 처리하지 못했습니다. 잠시 후 다시 시도해 주세요."), INTERNAL_SERVER_ERROR(ErrorStatus.INTERNAL_SERVER_ERROR, "내부 서버 오류입니다. 다시 시도해 주세요."); private final int status; diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/LockExecutor.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/LockExecutor.java new file mode 100644 index 00000000..1889d8ca --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/LockExecutor.java @@ -0,0 +1,76 @@ +package kr.ac.kookmin.stream.common; + +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Supplier; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * 락 충돌을 다루며 작업을 실행한다. + */ +@Component +public class LockExecutor { + + private static final int MAX_ATTEMPTS = 3; + private static final long MIN_BACKOFF_MILLIS = 30; + private static final long MAX_BACKOFF_MILLIS = 100; + + private final TransactionTemplate transactionTemplate; + + // 공유 TransactionTemplate 빈 대신 전용 템플릿을 만든다. 다른 곳에서 템플릿 설정(타임아웃 등)을 바꿔도 영향받지 않게 한다 + public LockExecutor(PlatformTransactionManager transactionManager) { + this.transactionTemplate = new TransactionTemplate(transactionManager); + } + + /** + * 낙관적 락이 충돌하면 재시도하며 action을 실행한다. + *

+ * 시도마다 새 트랜잭션을 열고 action 전체를 그 안에서 실행한 뒤 커밋한다. 낙관적 락이 충돌하면 + * ({@link OptimisticLockingFailureException}) 롤백하고 잠깐 쉰 뒤, 새 트랜잭션에서 action을 처음부터 다시 실행한다. + * 같은 트랜잭션 안에서 다시 시도하면 1차 캐시와 REPEATABLE READ 스냅샷이 예전 값을 계속 돌려줘 매번 충돌하므로, + * 트랜잭션은 반드시 시도마다 새로 연다. 커밋 시점에 터지는 충돌도 이 안에서 잡힌다. + *

+ */ + public T executeOptimistic(Supplier action) { + if (TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException("executeOptimistic()은 트랜잭션 밖에서 호출해야 합니다."); + } + + for (int attempt = 1; ; attempt++) { + try { + return transactionTemplate.execute(status -> action.get()); + } catch (OptimisticLockingFailureException e) { + if (attempt == MAX_ATTEMPTS) { + throw new BusinessException(CommonErrorCode.OPTIMISTIC_LOCK_CONFLICT); + } + waitBeforeRetry(); + } + } + } + + /** 반환값이 없는 action용. 동작은 {@link #executeOptimistic(Supplier)}와 같다. */ + public void executeOptimistic(Runnable action) { + executeOptimistic(() -> { + action.run(); + return null; + }); + } + + // 함께 충돌한 요청들이 같은 간격으로 다시 부딪히지 않게 대기 시간을 무작위로 둔다 + private void waitBeforeRetry() { + try { + Thread.sleep(ThreadLocalRandom.current().nextLong(MIN_BACKOFF_MILLIS, MAX_BACKOFF_MILLIS + 1)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BusinessException(CommonErrorCode.OPTIMISTIC_LOCK_CONFLICT); + } + } +} From c6c8935343ba22eead2b2c5a26a588fed3715aa3 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:49:17 +0900 Subject: [PATCH 2/7] =?UTF-8?q?test:=20LockExecutor=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/common/LockExecutorTest.java | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 core/common/src/test/java/kr/ac/kookmin/stream/common/LockExecutorTest.java diff --git a/core/common/src/test/java/kr/ac/kookmin/stream/common/LockExecutorTest.java b/core/common/src/test/java/kr/ac/kookmin/stream/common/LockExecutorTest.java new file mode 100644 index 00000000..60335d9b --- /dev/null +++ b/core/common/src/test/java/kr/ac/kookmin/stream/common/LockExecutorTest.java @@ -0,0 +1,166 @@ +package kr.ac.kookmin.stream.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.SimpleTransactionStatus; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * 낙관적 락 충돌만 재시도하고, 시도마다 트랜잭션을 새로 여는지 확인한다. + * 트랜잭션 매니저는 시작·커밋·롤백 횟수만 세는 가짜로 바꿔 DB 없이 검증한다. + */ +class LockExecutorTest { + + private final CountingTransactionManager transactionManager = new CountingTransactionManager(); + private final LockExecutor lockExecutor = new LockExecutor(transactionManager); + private final AtomicInteger calls = new AtomicInteger(); + + @AfterEach + void clearThreadState() { + // 테스트가 바꾼 스레드 상태가 다음 테스트로 새지 않게 되돌린다 + TransactionSynchronizationManager.setActualTransactionActive(false); + Thread.interrupted(); + } + + @Test + @DisplayName("충돌이 없으면 한 번 실행하고 결과를 그대로 돌려준다") + void returnsResultWithoutRetry() { + String result = lockExecutor.executeOptimistic(() -> { + calls.incrementAndGet(); + return "완료"; + }); + + assertEquals("완료", result); + assertEquals(1, calls.get()); + assertEquals(1, transactionManager.begun); + assertEquals(1, transactionManager.committed); + assertEquals(0, transactionManager.rolledBack); + } + + @Test + @DisplayName("반환값이 없는 action도 같은 방식으로 실행한다") + void runsRunnableAction() { + lockExecutor.executeOptimistic(() -> { + calls.incrementAndGet(); + }); + + assertEquals(1, calls.get()); + assertEquals(1, transactionManager.committed); + } + + @Test + @DisplayName("낙관적 락이 충돌하면 롤백하고 새 트랜잭션에서 다시 실행한다") + void retriesInNewTransactionAfterConflict() { + String result = lockExecutor.executeOptimistic(() -> { + if (calls.incrementAndGet() == 1) { + throw new OptimisticLockingFailureException("재고 충돌"); + } + return "완료"; + }); + + assertEquals("완료", result); + assertEquals(2, calls.get()); + assertEquals(2, transactionManager.begun); + assertEquals(1, transactionManager.rolledBack); + assertEquals(1, transactionManager.committed); + } + + @Test + @DisplayName("세 번 모두 충돌하면 OPTIMISTIC_LOCK_CONFLICT로 실패한다") + void failsWithConflictAfterMaxAttempts() { + BusinessException exception = assertThrows(BusinessException.class, () -> + lockExecutor.executeOptimistic(() -> { + calls.incrementAndGet(); + throw new OptimisticLockingFailureException("재고 충돌"); + }) + ); + + assertEquals(CommonErrorCode.OPTIMISTIC_LOCK_CONFLICT, exception.getErrorCode()); + assertEquals(3, calls.get()); + assertEquals(3, transactionManager.rolledBack); + assertEquals(0, transactionManager.committed); + } + + @Test + @DisplayName("낙관적 락 충돌이 아닌 예외는 재시도하지 않고 그대로 던진다") + void doesNotRetryOtherExceptions() { + BusinessException thrown = new BusinessException(CommonErrorCode.INVALID_INPUT); + + BusinessException exception = assertThrows(BusinessException.class, () -> + lockExecutor.executeOptimistic(() -> { + calls.incrementAndGet(); + throw thrown; + }) + ); + + assertSame(thrown, exception); + assertEquals(1, calls.get()); + assertEquals(1, transactionManager.rolledBack); + } + + @Test + @DisplayName("이미 트랜잭션 안이면 재시도가 소용없으므로 실행하지 않고 실패한다") + void rejectsCallInsideTransaction() { + TransactionSynchronizationManager.setActualTransactionActive(true); + + assertThrows(IllegalStateException.class, () -> + lockExecutor.executeOptimistic(() -> { + calls.incrementAndGet(); + }) + ); + + assertEquals(0, calls.get()); + assertEquals(0, transactionManager.begun); + } + + @Test + @DisplayName("재시도를 기다리다 인터럽트되면 인터럽트 상태를 남기고 OPTIMISTIC_LOCK_CONFLICT로 실패한다") + void keepsInterruptFlagWhenInterruptedWhileWaiting() { + BusinessException exception = assertThrows(BusinessException.class, () -> + lockExecutor.executeOptimistic(() -> { + calls.incrementAndGet(); + // 인터럽트 상태에서 대기에 들어가면 sleep이 곧바로 InterruptedException을 던진다 + Thread.currentThread().interrupt(); + throw new OptimisticLockingFailureException("재고 충돌"); + }) + ); + + assertEquals(CommonErrorCode.OPTIMISTIC_LOCK_CONFLICT, exception.getErrorCode()); + assertEquals(1, calls.get()); + assertTrue(Thread.currentThread().isInterrupted()); + } + + private static final class CountingTransactionManager implements PlatformTransactionManager { + + private int begun; + private int committed; + private int rolledBack; + + @Override + public TransactionStatus getTransaction(TransactionDefinition definition) { + begun++; + return new SimpleTransactionStatus(); + } + + @Override + public void commit(TransactionStatus status) { + committed++; + } + + @Override + public void rollback(TransactionStatus status) { + rolledBack++; + } + } +} From 49e1ce3546da9d090abf71cd59e358b9f9e3cd02 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:49:17 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EB=8C=80=EC=97=AC=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EC=9E=AC=EA=B3=A0=20=EC=B0=A8=EA=B0=90=EC=97=90=20?= =?UTF-8?q?=EB=82=99=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/app/welfare/rental/AppRentalApi.java | 5 ++-- .../rental/usecase/RentalApplyUseCase.java | 30 +++++++++++-------- .../domain/rental/service/ItemService.java | 7 ++--- .../impl/RentalHistoryServiceImpl.java | 4 +-- .../stream/db/welfare/ItemJpaEntity.java | 12 ++++++++ 5 files changed, 37 insertions(+), 21 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java index 2f127966..f61e2c8c 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java @@ -53,8 +53,9 @@ ApiResponse getHistories( @Operation(summary = "대여 신청", description = "물품을 대여 신청한다. 대여 시간은 영업시간(10~17시) 안이어야 하고 점심시간(12~13시)은 제외된다. " + "이미 대여 중인 같은 물품이 있으면 거부하되, ignoreDuplicate=true면 건너뛴다. " - + "소모품은 신청 즉시 반납 완료로, 대여품은 대여 중으로 등록된다.") - @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + + "소모품은 신청 즉시 반납 완료로, 대여품은 대여 중으로 등록된다. " + + "같은 물품에 신청이 몰려 재고 차감이 충돌하면 서버가 몇 번 다시 시도하고, 그래도 충돌하면 409를 반환한다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT", "OPTIMISTIC_LOCK_CONFLICT"}) @ApiErrorCode(type = FeeErrorCode.class, codes = {"MEMBER_IS_NOT_PAYER"}) @ApiErrorCode( type = RentalErrorCode.class, diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java index d34219ec..cafb00e1 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java @@ -4,6 +4,7 @@ import java.time.LocalDateTime; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.DateUtil; +import kr.ac.kookmin.stream.common.LockExecutor; import kr.ac.kookmin.stream.welfare.domain.fee.service.PayerService; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalErrorCode; @@ -11,34 +12,37 @@ import kr.ac.kookmin.stream.welfare.domain.rental.service.RentalHistoryService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; /** * 대여 신청. {@code fee}(회비 납부 확인)와 {@code rental}(재고 차감, 이력 생성) 두 도메인을 조합한다. - * 대여 시간(영업시간·점심시간) 검증은 DB에 닿지 않으므로 이 트랜잭션에 들어오기 전, 컨트롤러에서 끝낸다. + * 대여 시간(영업시간·점심시간) 검증은 DB에 닿지 않으므로 트랜잭션을 열기 전, 컨트롤러에서 끝낸다. */ @Component @RequiredArgsConstructor public class RentalApplyUseCase { + private final LockExecutor lockExecutor; private final PayerService payerService; private final ItemService itemService; private final RentalHistoryService rentalHistoryService; - @Transactional + // @Transactional을 걸지 않는다. 트랜잭션은 lockExecutor가 시도마다 새로 연다 — 재고 차감이 충돌하면 + // 같은 트랜잭션에서는 예전 재고만 다시 읽혀 재시도가 소용없어서다. 회비 확인부터 이력 생성까지는 시도마다 한 트랜잭션이다. + // 같은 물품에 동시에 들어온 신청은 재고 충돌로 늦은 쪽이 처음부터 다시 실행되므로, 재고 경합과 함께 + // 같은 회원의 같은 물품 중복 대여 경합도 막힌다(재시도는 먼저 커밋된 이력을 보고 중복으로 거절한다). public void apply(Long memberId, Long itemId, int count, int rentAtHour, int rentAtMinute, boolean ignoreDuplicate) { - // 동시성 보호(락) 없이 진행한다 — 재고 경쟁, 중복 대여 경쟁 둘 다 이론적으로 남아있는 - // 경합이다. 필요해지면 별도로 다시 도입한다(billilge-rental-apply-review-fixes.md 참고). - payerService.validatePayer(memberId); + lockExecutor.executeOptimistic(() -> { + payerService.validatePayer(memberId); - if (!ignoreDuplicate && rentalHistoryService.existsActiveRental(itemId, memberId)) { - throw new BusinessException(RentalErrorCode.RENTAL_ITEM_DUPLICATED); - } + if (!ignoreDuplicate && rentalHistoryService.existsActiveRental(itemId, memberId)) { + throw new BusinessException(RentalErrorCode.RENTAL_ITEM_DUPLICATED); + } - Item item = itemService.decreaseStock(itemId, count); + Item item = itemService.decreaseStock(itemId, count); - LocalDateTime now = LocalDateTime.now(DateUtil.KST); - LocalDateTime rentAt = LocalDate.now(DateUtil.KST).atTime(rentAtHour, rentAtMinute); - rentalHistoryService.create(item, memberId, count, now, rentAt); + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + LocalDateTime rentAt = LocalDate.now(DateUtil.KST).atTime(rentAtHour, rentAtMinute); + rentalHistoryService.create(item, memberId, count, now, rentAt); + }); } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java index 398241c8..8ccf9b7e 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java @@ -19,10 +19,9 @@ public interface ItemService { /** * 재고를 차감한다. *

- * 동시성 보호(락)는 아직 없다 — 동시에 들어온 두 요청이 같은 물품의 재고를 동시에 통과해 - * 재고가 음수로 내려갈 수 있는 경합이 이론적으로 남아있다. 이 프로젝트 규모에서 실제로 - * 문제된 적은 없어 보이는 레거시(billilge/backend)와 같은 수준으로, 일단 보호 없이 간다 - * (`billilge-rental-apply-review-fixes.md` 참고 — 필요해지면 별도로 다시 도입한다). + * 읽은 재고 값과 비교하는 낙관적 락으로 동시 차감을 감지한다. 그 사이 다른 트랜잭션이 재고를 바꿨으면 + * 커밋할 때 {@code OptimisticLockingFailureException}으로 실패한다. 호출부는 {@code LockExecutor}로 감싸 + * 시도마다 새 트랜잭션에서 실행해야 충돌한 요청이 다시 시도된다. */ Item decreaseStock(Long itemId, int amount); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java index b009bcd4..3221db09 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java @@ -77,8 +77,8 @@ public void returnRental(Long memberId, Long historyId) { rentalHistoryRepository.save(history); } - // 단일 조회라 dirty-checking 이득이 없어 트랜잭션을 걸지 않는다. 대여 신청(RentalApplyUseCase)의 - // 트랜잭션 안에서 호출되므로, 재고 락 조회 이후에 고정된 스냅샷을 그대로 공유해서 쓴다. + // 단일 조회라 dirty-checking 이득이 없어 트랜잭션을 걸지 않는다. 대여 신청(RentalApplyUseCase)에서는 + // LockExecutor가 시도마다 연 트랜잭션 안에서 호출되므로, 그 시도의 스냅샷을 그대로 공유해서 쓴다. @Override public boolean existsActiveRental(Long itemId, Long memberId) { return rentalHistoryRepository.existsActiveRental(itemId, memberId); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaEntity.java index 9468f024..8ba383ca 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaEntity.java @@ -17,12 +17,24 @@ import kr.ac.kookmin.stream.welfare.domain.rental.domain.ReturnPolicy; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.hibernate.annotations.DynamicUpdate; +import org.hibernate.annotations.OptimisticLockType; +import org.hibernate.annotations.OptimisticLocking; +// 대여 신청의 재고 차감을 버전 컬럼 없는 낙관적 락으로 보호한다. 바뀐 컬럼만 UPDATE하고(@DynamicUpdate), +// 그 컬럼의 읽은 값을 WHERE에 붙인다(UPDATE items SET count = ? WHERE id = ? AND count = 읽은 값). +// 그 사이 다른 트랜잭션이 count를 바꿨으면 0건이 되어 OptimisticLockingFailureException이 난다. 바뀐 컬럼끼리만 +// 비교하므로 이름 수정과 재고 차감처럼 서로 다른 컬럼을 고친 동시 수정은 충돌 없이 둘 다 반영된다. +// 운영진이 SQL로 count를 직접 바꿔도 같은 방식으로 감지된다. +// ⚠️ 같은 영속성 컨텍스트에서 읽고 고칠 때만 동작한다(읽은 값을 1차 캐시가 기억한다). 수정 화면처럼 읽은 요청과 +// 저장하는 요청이 다르면, 저장 트랜잭션에서 새로 읽은 값과 비교하므로 그 사이의 변경을 덮어쓴다. @Entity @Table( name = "items", indexes = @Index(name = "idx_items_name", columnList = "name") ) +@DynamicUpdate +@OptimisticLocking(type = OptimisticLockType.DIRTY) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ItemJpaEntity extends BaseTimeEntity { From 65f172aee1044fac8e54941919228f2c504cb12d Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:49:17 +0900 Subject: [PATCH 4/7] =?UTF-8?q?chore:=20Testcontainers=20MySQL=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bootstrap/build.gradle.kts | 4 ++++ gradle/libs.versions.toml | 6 ++++++ infrastructure/db/build.gradle.kts | 3 +++ 3 files changed, 13 insertions(+) diff --git a/bootstrap/build.gradle.kts b/bootstrap/build.gradle.kts index d4fbcc8b..4737c0da 100644 --- a/bootstrap/build.gradle.kts +++ b/bootstrap/build.gradle.kts @@ -23,6 +23,10 @@ dependencies { testImplementation(platform(libs.springBootDependenciesBom)) testImplementation(platform(libs.springModulithBom)) testImplementation(libs.springBootStarterTest) + testImplementation(libs.springBootTestcontainers) + testImplementation(libs.testcontainersMysql) + testImplementation(libs.testcontainersJunitJupiter) + testImplementation(libs.springJdbc) // 동시성 테스트의 데이터 준비·검증 쿼리(JdbcTemplate) testImplementation(libs.springModulithStarterTest) testImplementation(libs.springModulithDocs) testImplementation(libs.archunitJunit5) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dfd660e9..ad2f98a8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,11 @@ springBootStarterFlyway = { module = "org.springframework.boot:spring-boot-start springBootStarterTest = { module = "org.springframework.boot:spring-boot-starter-test" } springBootStarterSecurityTest = { module = "org.springframework.boot:spring-boot-starter-security-test" } springBootStarterDataJpaTest = { module = "org.springframework.boot:spring-boot-starter-data-jpa-test" } +springBootTestcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" } + +# Testcontainers — 실제 MySQL로 도는 통합 테스트용 (버전은 springBootDependenciesBom이 관리) +testcontainersMysql = { module = "org.testcontainers:testcontainers-mysql" } +testcontainersJunitJupiter = { module = "org.testcontainers:testcontainers-junit-jupiter" } # Spring Modulith — verify() boundary checks only, bootstrap test scope springModulithApi = { module = "org.springframework.modulith:spring-modulith-api" } @@ -37,6 +42,7 @@ springModulithDocs = { module = "org.springframework.modulith:spring-modulith-do springWeb = { module = "org.springframework:spring-web" } springContext = { module = "org.springframework:spring-context" } springTx = { module = "org.springframework:spring-tx" } +springJdbc = { module = "org.springframework:spring-jdbc" } jakartaServletApi = { module = "jakarta.servlet:jakarta.servlet-api" } jacksonDatabind = { module = "com.fasterxml.jackson.core:jackson-databind" } diff --git a/infrastructure/db/build.gradle.kts b/infrastructure/db/build.gradle.kts index 903dc34b..0718f858 100644 --- a/infrastructure/db/build.gradle.kts +++ b/infrastructure/db/build.gradle.kts @@ -19,5 +19,8 @@ dependencies { testImplementation(platform(libs.springBootDependenciesBom)) testImplementation(libs.springBootStarterDataJpaTest) + testImplementation(libs.springBootTestcontainers) + testImplementation(libs.testcontainersMysql) + testImplementation(libs.testcontainersJunitJupiter) testRuntimeOnly(libs.junitPlatformLauncher) } From 748fe4ccab55947b6ce05493c9774b8d108ceb91 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:49:17 +0900 Subject: [PATCH 5/7] =?UTF-8?q?test:=20=EB=AC=BC=ED=92=88=20=EC=9E=AC?= =?UTF-8?q?=EA=B3=A0=20=EB=82=99=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kookmin/stream/db/DbTestApplication.java | 11 ++ .../kr/ac/kookmin/stream/db/MySqlJpaTest.java | 43 ++++ .../db/welfare/ItemOptimisticLockTest.java | 183 ++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 infrastructure/db/src/test/java/kr/ac/kookmin/stream/db/DbTestApplication.java create mode 100644 infrastructure/db/src/test/java/kr/ac/kookmin/stream/db/MySqlJpaTest.java create mode 100644 infrastructure/db/src/test/java/kr/ac/kookmin/stream/db/welfare/ItemOptimisticLockTest.java diff --git a/infrastructure/db/src/test/java/kr/ac/kookmin/stream/db/DbTestApplication.java b/infrastructure/db/src/test/java/kr/ac/kookmin/stream/db/DbTestApplication.java new file mode 100644 index 00000000..317b470a --- /dev/null +++ b/infrastructure/db/src/test/java/kr/ac/kookmin/stream/db/DbTestApplication.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.db; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * infrastructure:db 테스트(@DataJpaTest 등)가 찾아 쓰는 부트 설정. 이 모듈에는 실행 클래스가 없어서 테스트에만 둔다. + * 엔티티·JPA 레포지토리 스캔이 이 패키지(kr.ac.kookmin.stream.db) 아래로 잡힌다. + */ +@SpringBootApplication +class DbTestApplication { +} diff --git a/infrastructure/db/src/test/java/kr/ac/kookmin/stream/db/MySqlJpaTest.java b/infrastructure/db/src/test/java/kr/ac/kookmin/stream/db/MySqlJpaTest.java new file mode 100644 index 00000000..a63bccc9 --- /dev/null +++ b/infrastructure/db/src/test/java/kr/ac/kookmin/stream/db/MySqlJpaTest.java @@ -0,0 +1,43 @@ +package kr.ac.kookmin.stream.db; + +import java.util.Map; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.mysql.MySQLContainer; + +/** + * 실제 MySQL이 필요한 infrastructure:db 테스트의 부모 클래스. 상속하면 아래가 갖춰진다. + *

    + *
  • MySQL 컨테이너를 이 모듈의 테스트 JVM에서 한 번만 띄우고 모든 테스트 클래스가 함께 쓴다(싱글턴 컨테이너). + * 클래스마다 {@code @Container}로 띄우면 클래스 수만큼 MySQL이 뜨고 내려간다. + * 컨테이너는 JVM이 끝날 때 Testcontainers(Ryuk)가 정리한다.
  • + *
  • Flyway 마이그레이션을 그대로 돌리고, {@code ddl-auto=validate}로 엔티티 매핑을 검증한다.
  • + *
  • Docker가 없으면 실패하지 않고 건너뛴다.
  • + *
+ * 클래스끼리 DB를 공유하므로, 테스트마다 자기 데이터를 새로 만들어 쓰고 다른 테스트가 남긴 데이터에 기대지 않는다. + * 설정(애너테이션)을 바꾸지 않고 상속하면 Spring 테스트 컨텍스트도 캐시되어 다시 뜨지 않는다. + */ +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=validate") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Testcontainers(disabledWithoutDocker = true) +public abstract class MySqlJpaTest { + + // bootstrap의 MySqlIntegrationTest와 같은 설정이다. 바꿀 때 둘 다 바꾼다. + // 버릴 테스트 데이터라 디스크 대신 메모리(tmpfs)에 두고, 장애 복구용 디스크 쓰기를 끈다. + // 트랜잭션 격리·락 동작은 그대로라 낙관적 락·동시성 테스트 결과에는 영향이 없다. + @ServiceConnection + private static final MySQLContainer MYSQL = new MySQLContainer("mysql:8.4") + .withTmpFs(Map.of("/var/lib/mysql", "rw")) + .withCommand( + "--innodb-flush-log-at-trx-commit=0", // 커밋마다 redo log를 디스크에 내리지 않는다 + "--innodb-doublewrite=OFF", // 페이지 이중 쓰기를 끈다 + "--skip-log-bin", // 복제용 바이너리 로그를 끈다 + "--performance-schema=OFF" // 성능 계측을 꺼 기동을 줄인다 + ); + + static { + MYSQL.start(); + } +} diff --git a/infrastructure/db/src/test/java/kr/ac/kookmin/stream/db/welfare/ItemOptimisticLockTest.java b/infrastructure/db/src/test/java/kr/ac/kookmin/stream/db/welfare/ItemOptimisticLockTest.java new file mode 100644 index 00000000..ab75b198 --- /dev/null +++ b/infrastructure/db/src/test/java/kr/ac/kookmin/stream/db/welfare/ItemOptimisticLockTest.java @@ -0,0 +1,183 @@ +package kr.ac.kookmin.stream.db.welfare; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import java.util.concurrent.atomic.AtomicInteger; +import javax.sql.DataSource; +import kr.ac.kookmin.stream.common.LockExecutor; +import kr.ac.kookmin.stream.db.MySqlJpaTest; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemType; +import kr.ac.kookmin.stream.welfare.domain.rental.repository.ItemRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.orm.jpa.EntityManagerHolder; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * 물품 재고의 버전 컬럼 없는 낙관적 락(ItemJpaEntity의 DIRTY)이 실제 MySQL에서 동작하는지, 그리고 LockExecutor가 + * 그 충돌을 잡아 새 트랜잭션에서 다시 시도하는지 확인한다. + *

+ * 동시에 들어온 다른 요청은 스레드 대신 REQUIRES_NEW 트랜잭션으로 흉내 낸다. 바깥 트랜잭션이 재고를 읽은 뒤 + * 별도 트랜잭션(별도 커넥션)이 먼저 커밋하게 해서, 실행 순서를 고정하고 결과가 매번 같게 한다. + * 실제 커밋이 일어나야 하므로 테스트 메서드를 트랜잭션으로 감싸지 않는다(NOT_SUPPORTED). + */ +@Transactional(propagation = Propagation.NOT_SUPPORTED) +@Import({ItemRepositoryImpl.class, LockExecutor.class}) +class ItemOptimisticLockTest extends MySqlJpaTest { + + @Autowired + private ItemRepository itemRepository; + + @Autowired + private LockExecutor lockExecutor; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @Autowired + private DataSource dataSource; + + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jdbcTemplate = new JdbcTemplate(dataSource); + } + + @Test + @DisplayName("다른 트랜잭션이 같은 물품의 재고를 먼저 바꾸고 커밋하면, 늦게 커밋하는 쪽이 충돌로 실패한다") + void laterCommitFailsWhenStockChangedConcurrently() { + Long itemId = createItem(5); + + assertThrows(OptimisticLockingFailureException.class, () -> inTransaction(() -> { + Item item = itemRepository.findById(itemId).orElseThrow(); // 재고 5를 읽는다 + inOtherTransaction(() -> decreaseStock(itemId, 2)); // 다른 요청이 먼저 3으로 커밋한다 + item.decreaseStock(1); + itemRepository.save(item); // 커밋할 때 WHERE count = 5 → 0건 + })); + + assertEquals(3, countOf(itemId)); + } + + @Test + @DisplayName("운영진이 SQL로 재고를 직접 바꿔도 충돌로 감지한다") + void detectsDirectSqlStockChange() { + Long itemId = createItem(5); + + assertThrows(OptimisticLockingFailureException.class, () -> inTransaction(() -> { + Item item = itemRepository.findById(itemId).orElseThrow(); + inOtherTransaction(() -> jdbcTemplate.update("UPDATE items SET count = ? WHERE id = ?", 10, itemId)); + item.decreaseStock(1); + itemRepository.save(item); + })); + + assertEquals(10, countOf(itemId)); + } + + @Test + @DisplayName("서로 다른 컬럼을 고친 동시 수정은 충돌하지 않고 둘 다 반영된다") + void concurrentChangesOnDifferentColumnsBothApply() { + Long itemId = createItem(5); + + inTransaction(() -> { + Item item = itemRepository.findById(itemId).orElseThrow(); + inOtherTransaction(() -> jdbcTemplate.update("UPDATE items SET name = ? WHERE id = ?", "장우산", itemId)); + item.decreaseStock(1); + itemRepository.save(item); // 바뀐 count만 UPDATE한다(@DynamicUpdate) + }); + + assertEquals(4, countOf(itemId)); + assertEquals("장우산", nameOf(itemId)); + } + + @Test + @DisplayName("LockExecutor로 감싸면 충돌한 시도를 새 트랜잭션에서 다시 읽어 성공한다") + void lockExecutorRetriesWithFreshRead() { + Long itemId = createItem(5); + AtomicInteger attempts = new AtomicInteger(); + + lockExecutor.executeOptimistic(() -> decreaseOneWithConcurrentChangeOnFirstAttempt(itemId, attempts)); + + assertEquals(2, attempts.get()); + assertEquals(2, countOf(itemId)); // 5 - 2(끼어든 차감) - 1 + } + + @Test + @DisplayName("OSIV처럼 EntityManager가 스레드에 묶여 있어도, 재시도는 DB에서 새로 읽어 성공한다") + void lockExecutorRetriesWithPreBoundEntityManager() { + Long itemId = createItem(5); + AtomicInteger attempts = new AtomicInteger(); + + // OpenEntityManagerInViewInterceptor가 요청을 시작할 때 하는 일을 그대로 흉내 낸다 + EntityManager entityManager = entityManagerFactory.createEntityManager(); + TransactionSynchronizationManager.bindResource(entityManagerFactory, new EntityManagerHolder(entityManager)); + try { + lockExecutor.executeOptimistic(() -> decreaseOneWithConcurrentChangeOnFirstAttempt(itemId, attempts)); + } finally { + TransactionSynchronizationManager.unbindResource(entityManagerFactory); + entityManager.close(); + } + + assertEquals(2, attempts.get()); + assertEquals(2, countOf(itemId)); + } + + // 재고를 읽은 뒤, 첫 시도에서만 다른 요청이 끼어들어 2개를 먼저 차감한다. 그 위에서 1개를 차감한다 + private void decreaseOneWithConcurrentChangeOnFirstAttempt(Long itemId, AtomicInteger attempts) { + Item item = itemRepository.findById(itemId).orElseThrow(); + if (attempts.incrementAndGet() == 1) { + inOtherTransaction(() -> decreaseStock(itemId, 2)); + } + item.decreaseStock(1); + itemRepository.save(item); + } + + // 대여 신청(ItemServiceImpl.decreaseStock)과 같은 방식(읽기 → 도메인에서 차감 → 저장)으로 재고를 뺀다 + private void decreaseStock(Long itemId, int amount) { + Item item = itemRepository.findById(itemId).orElseThrow(); + item.decreaseStock(amount); + itemRepository.save(item); + } + + private void inTransaction(Runnable work) { + new TransactionTemplate(transactionManager).executeWithoutResult(status -> work.run()); + } + + // 바깥 트랜잭션을 잠시 멈추고 별도 트랜잭션(별도 커넥션)에서 실행해 바로 커밋한다 + private void inOtherTransaction(Runnable work) { + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + template.executeWithoutResult(status -> work.run()); + } + + private Long createItem(int count) { + Item item = Item.of(null, "우산", ItemCategory.DAILY_SUPPLIES, ItemType.CONSUMABLE, count, null, null); + return itemRepository.save(item).getId(); + } + + private int countOf(Long itemId) { + return jdbcTemplate.queryForObject("SELECT count FROM items WHERE id = ?", Integer.class, itemId); + } + + private String nameOf(Long itemId) { + return jdbcTemplate.queryForObject("SELECT name FROM items WHERE id = ?", String.class, itemId); + } +} From 235f501064cc007083c3e7038b18170de61edb9e Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:49:17 +0900 Subject: [PATCH 6/7] =?UTF-8?q?test:=20=EB=8C=80=EC=97=AC=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kookmin/stream/MySqlIntegrationTest.java | 40 +++++ .../stream/RentalApplyConcurrencyTest.java | 169 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 bootstrap/src/test/java/kr/ac/kookmin/stream/MySqlIntegrationTest.java create mode 100644 bootstrap/src/test/java/kr/ac/kookmin/stream/RentalApplyConcurrencyTest.java diff --git a/bootstrap/src/test/java/kr/ac/kookmin/stream/MySqlIntegrationTest.java b/bootstrap/src/test/java/kr/ac/kookmin/stream/MySqlIntegrationTest.java new file mode 100644 index 00000000..9ad15035 --- /dev/null +++ b/bootstrap/src/test/java/kr/ac/kookmin/stream/MySqlIntegrationTest.java @@ -0,0 +1,40 @@ +package kr.ac.kookmin.stream; + +import java.util.Map; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.mysql.MySQLContainer; + +/** + * 애플리케이션 전체를 실제 MySQL로 띄우는 통합 테스트의 부모 클래스. 상속하면 아래가 갖춰진다. + *

    + *
  • MySQL 컨테이너를 이 모듈의 테스트 JVM에서 한 번만 띄우고 모든 테스트 클래스가 함께 쓴다(싱글턴 컨테이너). + * 클래스마다 {@code @Container}로 띄우면 클래스 수만큼 MySQL이 뜨고 내려간다. + * 컨테이너는 JVM이 끝날 때 Testcontainers(Ryuk)가 정리한다.
  • + *
  • Docker가 없으면 실패하지 않고 건너뛴다.
  • + *
+ * 클래스끼리 DB를 공유하므로, 테스트마다 자기 데이터를 새로 만들어 쓰고 다른 테스트가 남긴 데이터에 기대지 않는다. + * 설정(애너테이션·프로퍼티·{@code @MockitoBean})을 바꾸지 않고 상속하면 애플리케이션 컨텍스트도 캐시되어 다시 뜨지 않는다. + */ +@SpringBootTest +@Testcontainers(disabledWithoutDocker = true) +public abstract class MySqlIntegrationTest { + + // infrastructure:db의 MySqlJpaTest와 같은 설정이다. 바꿀 때 둘 다 바꾼다. + // 버릴 테스트 데이터라 디스크 대신 메모리(tmpfs)에 두고, 장애 복구용 디스크 쓰기를 끈다. + // 트랜잭션 격리·락 동작은 그대로라 낙관적 락·동시성 테스트 결과에는 영향이 없다. + @ServiceConnection + private static final MySQLContainer MYSQL = new MySQLContainer("mysql:8.4") + .withTmpFs(Map.of("/var/lib/mysql", "rw")) + .withCommand( + "--innodb-flush-log-at-trx-commit=0", // 커밋마다 redo log를 디스크에 내리지 않는다 + "--innodb-doublewrite=OFF", // 페이지 이중 쓰기를 끈다 + "--skip-log-bin", // 복제용 바이너리 로그를 끈다 + "--performance-schema=OFF" // 성능 계측을 꺼 기동을 줄인다 + ); + + static { + MYSQL.start(); + } +} diff --git a/bootstrap/src/test/java/kr/ac/kookmin/stream/RentalApplyConcurrencyTest.java b/bootstrap/src/test/java/kr/ac/kookmin/stream/RentalApplyConcurrencyTest.java new file mode 100644 index 00000000..1fb15c15 --- /dev/null +++ b/bootstrap/src/test/java/kr/ac/kookmin/stream/RentalApplyConcurrencyTest.java @@ -0,0 +1,169 @@ +package kr.ac.kookmin.stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import javax.sql.DataSource; +import kr.ac.kookmin.stream.api.app.welfare.rental.usecase.RentalApplyUseCase; +import kr.ac.kookmin.stream.common.BusinessException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; + +/** + * 대여 신청이 실제로 동시에 몰릴 때 재고와 대여 이력이 어긋나지 않는지, 애플리케이션 전체를 띄워 확인한다. + *

+ * 몇 건이 성공할지는 스레드 타이밍(재시도 3회를 다 쓰고 409가 나는지 등)에 따라 달라서 성공 건수 자체는 고정하지 않고, + * 타이밍과 관계없이 항상 성립해야 하는 조건만 검사한다. 재고가 음수가 되지 않는지, 차감된 재고와 이력 수가 맞는지, + * 예상하지 못한 예외(500으로 나갈 예외)가 없는지다. + */ +class RentalApplyConcurrencyTest extends MySqlIntegrationTest { + + private static final String SUCCESS = "SUCCESS"; + private static final int RENT_AT_HOUR = 11; + private static final int RENT_AT_MINUTE = 0; + + @Autowired + private RentalApplyUseCase rentalApplyUseCase; + + @Autowired + private DataSource dataSource; + + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jdbcTemplate = new JdbcTemplate(dataSource); + } + + @Test + @DisplayName("재고보다 많은 신청이 동시에 몰려도 재고가 음수가 되지 않고, 차감된 만큼만 대여 이력이 생긴다") + void concurrentApplicationsNeverOversell() throws InterruptedException { + long memberId = registerPayer(1001L); + long itemId = createItem("CONSUMABLE", 5); + + Map outcomes = applyConcurrently(20, () -> + rentalApplyUseCase.apply(memberId, itemId, 1, RENT_AT_HOUR, RENT_AT_MINUTE, true) + ); + + assertOnlyExpectedOutcomes(outcomes, SUCCESS, "ITEM_OUT_OF_STOCK", "OPTIMISTIC_LOCK_CONFLICT"); + assertEquals(20, total(outcomes)); + int successes = outcomes.getOrDefault(SUCCESS, 0); + // 먼저 커밋하는 신청은 항상 성공하므로 적어도 1건은 성공한다 + assertTrue(successes >= 1 && successes <= 5, () -> "성공 건수가 1~5 밖이다: " + outcomes); + assertEquals(5 - successes, countOf(itemId)); + assertEquals(successes, historyCountOf(itemId)); + } + + @Test + @DisplayName("같은 회원이 같은 대여품을 동시에 여러 번 신청해도 대여 중 이력은 하나만 생긴다") + void concurrentDuplicateApplicationsCreateOneRental() throws InterruptedException { + long memberId = registerPayer(1002L); + long itemId = createItem("RENTAL", 10); + + Map outcomes = applyConcurrently(5, () -> + rentalApplyUseCase.apply(memberId, itemId, 1, RENT_AT_HOUR, RENT_AT_MINUTE, false) + ); + + assertOnlyExpectedOutcomes(outcomes, SUCCESS, "RENTAL_ITEM_DUPLICATED", "OPTIMISTIC_LOCK_CONFLICT"); + // 늦게 커밋한 쪽은 재고 충돌로 다시 시도하고, 새 스냅샷에서 먼저 커밋된 이력을 보고 중복으로 거절된다 + assertEquals(1, outcomes.getOrDefault(SUCCESS, 0), () -> "성공이 정확히 1건이 아니다: " + outcomes); + assertEquals(1, activeRentalCountOf(itemId, memberId)); + assertEquals(9, countOf(itemId)); + } + + // 모든 스레드가 출발선에 선 뒤 한꺼번에 신청을 시작하게 하고, 결과를 성공 또는 에러 코드(그 밖의 예외는 클래스 이름)별로 센다 + private Map applyConcurrently(int requests, Runnable apply) throws InterruptedException { + ExecutorService executor = Executors.newFixedThreadPool(requests); + CountDownLatch ready = new CountDownLatch(requests); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(requests); + Map outcomes = new ConcurrentHashMap<>(); + + for (int i = 0; i < requests; i++) { + executor.execute(() -> { + ready.countDown(); + try { + start.await(); + apply.run(); + outcomes.merge(SUCCESS, 1, Integer::sum); + } catch (BusinessException e) { + outcomes.merge(e.getErrorCode().name(), 1, Integer::sum); + } catch (Exception e) { + outcomes.merge(e.getClass().getSimpleName(), 1, Integer::sum); + } finally { + done.countDown(); + } + }); + } + + ready.await(); + start.countDown(); + boolean finished = done.await(1, TimeUnit.MINUTES); + executor.shutdownNow(); + assertTrue(finished, "1분 안에 모든 신청이 끝나지 않았다"); + return outcomes; + } + + private void assertOnlyExpectedOutcomes(Map outcomes, String... expected) { + assertTrue(Set.of(expected).containsAll(outcomes.keySet()), () -> "예상하지 못한 결과가 있다: " + outcomes); + } + + private int total(Map outcomes) { + return outcomes.values().stream().mapToInt(Integer::intValue).sum(); + } + + private long registerPayer(long memberId) { + jdbcTemplate.update( + "INSERT INTO payers (member_id, name, enrollment_year, registered) VALUES (?, ?, ?, ?)", + memberId, "학생" + memberId, "2026", true + ); + return memberId; + } + + private long createItem(String type, int count) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO items (name, category, type, count) VALUES (?, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + ); + statement.setString(1, "물품"); + statement.setString(2, "DAILY_SUPPLIES"); + statement.setString(3, type); + statement.setInt(4, count); + return statement; + }, keyHolder); + return keyHolder.getKey().longValue(); + } + + private int countOf(long itemId) { + return jdbcTemplate.queryForObject("SELECT count FROM items WHERE id = ?", Integer.class, itemId); + } + + private int historyCountOf(long itemId) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM rental_histories WHERE item_id = ?", Integer.class, itemId + ); + } + + private int activeRentalCountOf(long itemId, long memberId) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM rental_histories WHERE item_id = ? AND member_id = ? AND rental_status = 'RENTAL'", + Integer.class, itemId, memberId + ); + } +} From f1e01be3e116943319cf3145bafd811e414cb6b3 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:49:17 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20core:common=EC=9D=98=20spring-tx=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9=EA=B3=BC=20=EB=82=99=EA=B4=80=EC=A0=81=20?= =?UTF-8?q?=EB=9D=BD=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/00-index.md | 4 +++- docs/conventions/architecture.md | 11 ++++++----- docs/conventions/coding-style.md | 22 ++++++++++++++++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/conventions/00-index.md b/docs/conventions/00-index.md index ab16c817..22e6dba4 100644 --- a/docs/conventions/00-index.md +++ b/docs/conventions/00-index.md @@ -25,6 +25,7 @@ Java 21 + Spring Boot 4.1 + Spring Modulith 기반, 단일 학생회 플랫폼 - "새 도메인(예: 사물함) 추가" → `architecture.md`(모듈/경계) → `coding-style.md`(클래스) → `flyway-migration.md`(테이블) - "여러 도메인을 묶는 화면/처리" → `architecture.md` 6-1절 + `coding-style.md` 2-9절 (api의 UseCase) - "행사 유료 신청처럼 원자적 다중 도메인 쓰기" → `architecture.md` 6-1절 (UseCase + `@Transactional`) +- "동시 수정 충돌(낙관적 락)을 재시도" → `architecture.md` 6-1절 (UseCase + `LockExecutor.executeOptimistic()`, `@Transactional` 없음) - "A 도메인 변화에 B가 반응" → `architecture.md` 6-2절 (이벤트 + 아웃박스) - "부서 권한으로 승인 제한" → `config-and-auth.md` 4-4절 (`DepartmentAccessChecker`) - "soft delete 컬럼 인덱스/유니크" → `flyway-migration.md` 3-4절 @@ -45,7 +46,8 @@ api/ └── app-api # 학생 앱 (STUDENT, /v1/app/**) core/ ├── common # ErrorCode/BusinessException/ErrorStatus, PrincipalProvider/Role/Department, -│ # PageResult/CursorSliceResult, OutboxWriter, common.event (shared module) +│ # PageResult/CursorSliceResult, OutboxWriter, LockExecutor, common.event (shared module) +│ # 순수 Java + spring-context·spring-tx만 허용 └── domain/{auth,member,event,welfare,internal} # 모듈 안은 domain/{도메인}/{domain|repository|service|service.impl} # OPEN 모듈. service.impl만 비공개(package-private, ArchUnit이 검사) diff --git a/docs/conventions/architecture.md b/docs/conventions/architecture.md index e1a4b44f..fcd48b89 100644 --- a/docs/conventions/architecture.md +++ b/docs/conventions/architecture.md @@ -35,10 +35,10 @@ root │ ├── admin-api/ # 운영진 콘솔 (ADMIN, /v1/admin/**) │ └── app-api/ # 학생 앱 (STUDENT, /v1/app/**) ├── core/ # (빈 컨테이너 — 코드 없음) -│ ├── common/ # 공유 커널 (순수 Java). verify 설정에서 shared module로 선언 +│ ├── common/ # 공유 커널 (순수 Java + spring-context·spring-tx). verify 설정에서 shared module로 선언 │ │ ├── (common) # ApiResponse 규격, ErrorCode/CommonErrorCode/BusinessException, ErrorStatus, │ │ │ # PageResult/CursorSliceResult, PrincipalProvider·Role·Department, RegexPatterns, -│ │ │ # OutboxWriter(아웃박스 쓰기 포트) +│ │ │ # OutboxWriter(아웃박스 쓰기 포트), LockExecutor(낙관적 락 재시도 실행기) │ │ └── (common.event)# 크로스 도메인 이벤트 타입 (DomainEvent 마커 + 구체 이벤트) │ └── domain/ # (빈 컨테이너 — 코드 없음) │ ├── auth/ # 인증 (로그인·토큰 발급 흐름) @@ -68,7 +68,7 @@ root | --- | --- | --- | --- | | `bootstrap` | 조립·기동 방식 | `@Modulithic` 메인, 최종 빈 조립, 스케줄링 활성화, 실행 설정, `bootJar` | 비즈니스 로직, 도메인 규칙 | | `api:*` | 클라이언트 요구(요청/응답 형태) | Controller, Request/Response DTO, 교차 도메인 `UseCase` | 비즈니스 규칙, 영속화, 보안 정책 구현 | -| `core:common` | 공유 커널 규격 | 응답/에러 규격, 인증 추상(Principal·Role·Department), 페이지/커서 결과, 아웃박스 포트, 크로스 도메인 이벤트 타입 | 특정 도메인 개념(`{Domain}Id` 등), Spring·JPA·web·security | +| `core:common` | 공유 커널 규격 | 응답/에러 규격, 인증 추상(Principal·Role·Department), 페이지/커서 결과, 아웃박스 포트, 크로스 도메인 이벤트 타입, 낙관적 락 재시도 실행기(`LockExecutor`) | 특정 도메인 개념(`{Domain}Id` 등), JPA·web·security, `spring-context`·`spring-tx` 외 Spring | | `core:domain:{도메인}` | 해당 도메인 규칙 | `{Domain}Service`(진입점)·도메인 객체(record)·아웃바운드 포트 인터페이스와 그 구현 로직 | 다른 도메인, web·security·JPA·Modulith core (`@ApplicationModule` 선언용 `spring-modulith-api`는 compileOnly로 허용) | | `gateway:*` | 횡단관심사 정책 | 인증/인가(`auth`), 요청 추적·access log(`logging`) | 도메인 규칙, 영속화 | | `infrastructure:*` | 외부 기술(구현 세부) | `{Domain}Repository`/`{Domain}Client` 구현, JPA Entity·Flyway(`db`), 외부 API 어댑터(`client`), 아웃박스 릴레이(`outbox`) | 비즈니스 규칙, 도메인 진입점 | @@ -209,7 +209,7 @@ Gradle 모듈 분리가 도메인 간 경계를 컴파일 타임에 막고, Spri {basePackage}.core.domain.member // ❌ Gradle 경로를 반영하면 domain이 한 모듈로 뭉쳐 경계가 안 걸림 ``` -- `core:common`은 verify 설정에서 shared module로 선언해, 어디서든 의존해도 위반이 나지 않게 한다(어노테이션 없이 순수 Java 유지). +- `core:common`은 verify 설정에서 shared module로 선언해, 어디서든 의존해도 위반이 나지 않게 한다(Spring은 `spring-context`·`spring-tx`만 허용). - 도메인이 하나일 때부터 **일부러 위반을 만들어 `verify()`가 실패하는지** 확인한다. ### 4-3. 도메인 모듈의 패키지 구조 & 공개 경계 @@ -339,6 +339,7 @@ Data Access {Domain}Repository / {Domain}Client 인터페이스 → core:dom - 서로 다른 도메인의 Service 2개 이상을 조합할 때만 UseCase를 만든다. **단일 도메인 흐름은 Controller가 그 `{Domain}Service`를 직접 참조**한다. - **트랜잭션은 원자성이 필요한 흐름에만 건다.** 유료 행사 신청(행사 정원 차감 + 회비/결제 반영)처럼 전부 성공/전부 실패해야 하는 경우에만 UseCase 메서드에 `@Transactional`을 선언한다(동일 DataSource 기준 한 트랜잭션). 운영진 대시보드 같은 조회 조합에는 트랜잭션을 걸지 않는다. - UseCase 트랜잭션이 도메인 Service를 감싸려면 `{Domain}Service`는 기본 전파(REQUIRED)를 쓴다. REQUIRES_NEW는 원자성을 깨므로 쓰지 않는다. +- **낙관적 락 충돌을 재시도해야 하는 UseCase**(예: 대여 신청의 재고 차감)는 메서드에 `@Transactional`을 걸지 않고, `core:common`의 `LockExecutor.executeOptimistic()` 안에서 Service를 조합한다. 트랜잭션은 `LockExecutor`가 시도마다 새로 연다 — 같은 트랜잭션 안에서 재시도하면 1차 캐시와 REPEATABLE READ 스냅샷이 예전 값을 계속 돌려줘 매번 충돌하기 때문이다. 충돌하면 람다 전체가 처음부터 다시 실행되므로, 롤백되지 않는 부수효과(외부 API 호출 등)는 넣지 않는다. 이미 트랜잭션이 있는 곳에서 호출하면 `IllegalStateException`으로 실패한다. - 롤백 불가한 외부 부수효과(외부 결제 PG 호출 등)가 끼면 트랜잭션으로 원자성을 보장할 수 없다. 그런 UseCase에 한해 보상 로직을 명시한다. ### 6-2. 비동기 반응 — 이벤트 + 직접 구현 아웃박스 @@ -375,7 +376,7 @@ Data Access {Domain}Repository / {Domain}Client 인터페이스 → core:dom | `api:common-api` | `core:domain:{도메인}` + `core:common` + `gateway:auth`(DepartmentAccessChecker) + `gateway:logging` + Spring MVC + validation | | `api:{client}-api` | `api:common-api` + `core:domain:{도메인}` + `core:common` + `gateway:auth` + `gateway:logging` + Spring MVC + validation | | `core:domain:{도메인}` | `core:common` + `spring-context`(DI) + `spring-tx` + `spring-modulith-api`(**compileOnly**, `@ApplicationModule` 선언용) + 순수 Java. **Modulith core·web·security·JPA 없음** | -| `core:common` | 순수 Java / 유틸리티만. Spring·Modulith·web·security·JPA 없음 | +| `core:common` | 순수 Java / 유틸리티 + `spring-context`(빈 등록) + `spring-tx`(`LockExecutor`의 트랜잭션). Modulith·web·security·JPA 없음 | | `gateway:auth` | `core:common` + Spring Security + `jjwt` + `spring-webmvc`(예외 위임) | | `gateway:logging` | `core:common` + `spring-web` + `spring-context` + slf4j 등 + Servlet API | | `infrastructure:db` | `core:domain:{도메인}` + `core:common` + JPA / Flyway / MySQL 드라이버 | diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 893436cd..c42271d5 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -409,6 +409,28 @@ public class EventApplicationUseCase { } ``` +```java +// api:app-api — 낙관적 락 충돌을 재시도하는 원자적 쓰기 (대여 신청) +@Component +@RequiredArgsConstructor +public class RentalApplyUseCase { + + private final LockExecutor lockExecutor; + private final PayerService payerService; + private final ItemService itemService; + private final RentalHistoryService rentalHistoryService; + + // @Transactional을 걸지 않는다. lockExecutor가 시도마다 새 트랜잭션을 열고, 충돌하면 람다 전체를 다시 실행한다 + public void apply(Long memberId, Long itemId, int count, ...) { + lockExecutor.executeOptimistic(() -> { + payerService.validatePayer(memberId); + Item item = itemService.decreaseStock(itemId, count); // 물품 낙관적 락으로 동시 차감 감지 + rentalHistoryService.create(item, memberId, count, ...); + }); + } +} +``` + ### 2-10. 객체 생성 — 정적 팩토리 메서드 객체는 `new`로 직접 만들지 않고 정적 팩토리 메서드로 생성한다. 생성자는 `private`(JPA처럼 프레임워크가 요구하면 `protected`)으로 감춘다.