Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading