Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ ApiResponse<RentalHistoryListResponse> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,45 @@
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;
import kr.ac.kookmin.stream.welfare.domain.rental.service.ItemService;
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);
});
}
}
4 changes: 4 additions & 0 deletions bootstrap/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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로 띄우는 통합 테스트의 부모 클래스. 상속하면 아래가 갖춰진다.
* <ul>
* <li>MySQL 컨테이너를 이 모듈의 테스트 JVM에서 한 번만 띄우고 모든 테스트 클래스가 함께 쓴다(싱글턴 컨테이너).
* 클래스마다 {@code @Container}로 띄우면 클래스 수만큼 MySQL이 뜨고 내려간다.
* 컨테이너는 JVM이 끝날 때 Testcontainers(Ryuk)가 정리한다.</li>
* <li>Docker가 없으면 실패하지 않고 건너뛴다.</li>
* </ul>
* 클래스끼리 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();
}
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* 대여 신청이 실제로 동시에 몰릴 때 재고와 대여 이력이 어긋나지 않는지, 애플리케이션 전체를 띄워 확인한다.
* <p>
* 몇 건이 성공할지는 스레드 타이밍(재시도 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<String, Integer> 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<String, Integer> 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<String, Integer> 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<String, Integer> 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<String, Integer> outcomes, String... expected) {
assertTrue(Set.of(expected).containsAll(outcomes.keySet()), () -> "예상하지 못한 결과가 있다: " + outcomes);
}

private int total(Map<String, Integer> 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
);
}
}
7 changes: 6 additions & 1 deletion core/common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading