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 스냅샷이 예전 값을 계속 돌려줘 매번 충돌하므로, + * 트랜잭션은 반드시 시도마다 새로 연다. 커밋 시점에 터지는 충돌도 이 안에서 잡힌다. + *
- * 동시성 보호(락)는 아직 없다 — 동시에 들어온 두 요청이 같은 물품의 재고를 동시에 통과해 - * 재고가 음수로 내려갈 수 있는 경합이 이론적으로 남아있다. 이 프로젝트 규모에서 실제로 - * 문제된 적은 없어 보이는 레거시(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 테스트의 부모 클래스. 상속하면 아래가 갖춰진다. + *
+ * 동시에 들어온 다른 요청은 스레드 대신 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로 띄우는 통합 테스트의 부모 클래스. 상속하면 아래가 갖춰진다. + *
+ * 몇 건이 성공할지는 스레드 타이밍(재시도 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