diff --git a/config/src/main/resources/config-repo/product-service.yml b/config/src/main/resources/config-repo/product-service.yml index fec5d41..29c457a 100644 --- a/config/src/main/resources/config-repo/product-service.yml +++ b/config/src/main/resources/config-repo/product-service.yml @@ -9,6 +9,10 @@ spring: username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: ${SPRING_DATA_REDIS_HOST:localhost} + port: ${SPRING_DATA_REDIS_PORT:6379} jpa: hibernate: @@ -25,4 +29,4 @@ management: endpoint: "http://localhost:9411/api/v2/spans" tracing: sampling: - probability: 1.0 \ No newline at end of file + probability: 1.0 diff --git a/docker-compose.yml b/docker-compose.yml index e0f9992..bc3c752 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: networks: - hubeleven-network healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8761/actuator/health"] + test: [ "CMD", "curl", "-f", "http://localhost:8761/actuator/health" ] interval: 10s timeout: 5s retries: 5 @@ -103,8 +103,8 @@ services: - SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD} - SPRING_JPA_DATABASE_PLATFORM=org.hibernate.dialect.MySQL8Dialect - SPRING_JPA_HIBERNATE_DDL_AUTO=update - - SPRING_REDIS_HOST=${REDIS_HOST} - - SPRING_REDIS_PORT=${REDIS_PORT} + - SPRING_DATA_REDIS_HOST=${REDIS_HOST} + - SPRING_DATA_REDIS_PORT=${REDIS_PORT} - MANAGEMENT_ZIPKIN_TRACING_ENDPOINT=${ZIPKIN_ENDPOINT} depends_on: - eurekaServer diff --git a/product/build.gradle b/product/build.gradle index 716931d..6fbed9c 100644 --- a/product/build.gradle +++ b/product/build.gradle @@ -36,6 +36,9 @@ dependencies { implementation 'org.springframework.cloud:spring-cloud-starter-config' implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + // Redis / Redisson + implementation 'org.redisson:redisson:3.27.2' + // External Libraries implementation 'com.github.ElevenHub:HubEleven-common:v0.0.1' diff --git a/product/src/main/java/com/hubEleven/stock/application/port/StockLockManager.java b/product/src/main/java/com/hubEleven/stock/application/port/StockLockManager.java new file mode 100644 index 0000000..efb4381 --- /dev/null +++ b/product/src/main/java/com/hubEleven/stock/application/port/StockLockManager.java @@ -0,0 +1,8 @@ +package com.hubEleven.stock.application.port; + +import java.util.function.Supplier; + +public interface StockLockManager { + + T executeWithLock(String lockKey, Supplier supplier); +} diff --git a/product/src/main/java/com/hubEleven/stock/application/service/StockDecreaseProcessor.java b/product/src/main/java/com/hubEleven/stock/application/service/StockDecreaseProcessor.java new file mode 100644 index 0000000..cd616c8 --- /dev/null +++ b/product/src/main/java/com/hubEleven/stock/application/service/StockDecreaseProcessor.java @@ -0,0 +1,40 @@ +package com.hubEleven.stock.application.service; + +import static com.hubEleven.product.domain.exception.ProductErrorCode.PRODUCT_NOT_FOUND; +import static com.hubEleven.stock.domain.exception.StockErrorCode.STOCK_NOT_FOUND; + +import com.commonLib.common.exception.GlobalException; +import com.hubEleven.product.domain.model.Product; +import com.hubEleven.product.domain.repository.ProductRepository; +import com.hubEleven.stock.application.dto.StockResult; +import com.hubEleven.stock.domain.model.Stock; +import com.hubEleven.stock.domain.repository.StockRepository; +import com.hubEleven.stock.presentation.dto.request.StockRequests; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class StockDecreaseProcessor { + + private final StockRepository stockRepository; + private final ProductRepository productRepository; + + @Transactional + public StockResult decrease(StockRequests.Decrease request) { + Product product = + productRepository + .findByIdNotDeleted(request.productId()) + .orElseThrow(() -> new GlobalException(PRODUCT_NOT_FOUND)); + + Stock stock = + stockRepository + .findByProductId(request.productId()) + .orElseThrow(() -> new GlobalException(STOCK_NOT_FOUND)); + + stock.decreaseQuantity(request.quantity()); + + return StockResult.from(stock, product.getName()); + } +} diff --git a/product/src/main/java/com/hubEleven/stock/application/service/StockServiceImpl.java b/product/src/main/java/com/hubEleven/stock/application/service/StockServiceImpl.java index 9421201..d6dbc73 100644 --- a/product/src/main/java/com/hubEleven/stock/application/service/StockServiceImpl.java +++ b/product/src/main/java/com/hubEleven/stock/application/service/StockServiceImpl.java @@ -7,6 +7,7 @@ import com.hubEleven.product.domain.model.Product; import com.hubEleven.product.domain.repository.ProductRepository; import com.hubEleven.stock.application.dto.StockResult; +import com.hubEleven.stock.application.port.StockLockManager; import com.hubEleven.stock.domain.model.Stock; import com.hubEleven.stock.domain.repository.StockRepository; import com.hubEleven.stock.presentation.dto.request.StockRequests; @@ -21,6 +22,8 @@ public class StockServiceImpl implements StockService { private final StockRepository stockRepository; private final ProductRepository productRepository; + private final StockLockManager stockLockManager; + private final StockDecreaseProcessor stockDecreaseProcessor; private Product getProductOrThrow(UUID productId) { return productRepository @@ -60,16 +63,9 @@ public StockResult getStockByProductId(UUID productId) { } @Override - @Transactional public StockResult decreaseStock(StockRequests.Decrease request) { - - Product product = getProductOrThrow(request.productId()); - - Stock stock = getStockOrThrow(request.productId()); - - stock.decreaseQuantity(request.quantity()); - - return StockResult.from(stock, product.getName()); + return stockLockManager.executeWithLock( + "stock:decrease:" + request.productId(), () -> stockDecreaseProcessor.decrease(request)); } @Override diff --git a/product/src/main/java/com/hubEleven/stock/domain/exception/StockErrorCode.java b/product/src/main/java/com/hubEleven/stock/domain/exception/StockErrorCode.java index 69a3b3b..8a6a732 100644 --- a/product/src/main/java/com/hubEleven/stock/domain/exception/StockErrorCode.java +++ b/product/src/main/java/com/hubEleven/stock/domain/exception/StockErrorCode.java @@ -12,6 +12,7 @@ public enum StockErrorCode implements ErrorCode { DECREASE_QUANTITY_INVALID(HttpStatus.BAD_REQUEST, "재고 감소 수량은 1 이상이어야 합니다."), RESTORE_QUANTITY_INVALID(HttpStatus.BAD_REQUEST, "재고 복원 수량은 1 이상이어야 합니다."), INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "재고가 부족합니다."), + STOCK_LOCK_TIMEOUT(HttpStatus.CONFLICT, "재고 감소 요청이 많아 잠시 후 다시 시도해 주세요."), INITIAL_QUANTITY_INVALID(HttpStatus.BAD_REQUEST, "초기 재고 수량은 0 이상이어야 합니다."); private final HttpStatus httpStatus; diff --git a/product/src/main/java/com/hubEleven/stock/infrastructure/configuration/RedissonConfig.java b/product/src/main/java/com/hubEleven/stock/infrastructure/configuration/RedissonConfig.java new file mode 100644 index 0000000..0e3ba4a --- /dev/null +++ b/product/src/main/java/com/hubEleven/stock/infrastructure/configuration/RedissonConfig.java @@ -0,0 +1,21 @@ +package com.hubEleven.stock.infrastructure.configuration; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfig { + + @Bean(destroyMethod = "shutdown") + public RedissonClient redissonClient( + @Value("${spring.data.redis.host:localhost}") String host, + @Value("${spring.data.redis.port:6379}") int port) { + Config config = new Config(); + config.useSingleServer().setAddress("redis://" + host + ":" + port); + return Redisson.create(config); + } +} diff --git a/product/src/main/java/com/hubEleven/stock/infrastructure/lock/RedissonStockLockManager.java b/product/src/main/java/com/hubEleven/stock/infrastructure/lock/RedissonStockLockManager.java new file mode 100644 index 0000000..59f7e7b --- /dev/null +++ b/product/src/main/java/com/hubEleven/stock/infrastructure/lock/RedissonStockLockManager.java @@ -0,0 +1,50 @@ +package com.hubEleven.stock.infrastructure.lock; + +import static com.hubEleven.stock.domain.exception.StockErrorCode.STOCK_LOCK_TIMEOUT; + +import com.commonLib.common.exception.GlobalException; +import com.hubEleven.stock.application.port.StockLockManager; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedissonStockLockManager implements StockLockManager { + + private static final int MAX_RETRY_COUNT = 3; + private static final long WAIT_TIME_SECONDS = 3L; + private static final long LEASE_TIME_SECONDS = 5L; + private static final long RETRY_BACKOFF_MILLIS = 100L; + + private final RedissonClient redissonClient; + + @Override + public T executeWithLock(String lockKey, Supplier supplier) { + RLock lock = redissonClient.getLock(lockKey); + boolean locked = false; + + try { + for (int retryCount = 0; retryCount < MAX_RETRY_COUNT; retryCount++) { + locked = lock.tryLock(WAIT_TIME_SECONDS, LEASE_TIME_SECONDS, TimeUnit.SECONDS); + if (locked) { + return supplier.get(); + } + + Thread.sleep(RETRY_BACKOFF_MILLIS); + } + + throw new GlobalException(STOCK_LOCK_TIMEOUT); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new GlobalException(STOCK_LOCK_TIMEOUT); + } finally { + if (locked && lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } +} diff --git a/product/src/main/resources/application.yml b/product/src/main/resources/application.yml index 727f30d..79273fe 100644 --- a/product/src/main/resources/application.yml +++ b/product/src/main/resources/application.yml @@ -1,8 +1,10 @@ spring: application: name: product-service - config: - import: "configserver:" + data: + redis: + host: ${SPRING_DATA_REDIS_HOST:localhost} + port: ${SPRING_DATA_REDIS_PORT:6379} cloud: config: discovery: @@ -16,4 +18,26 @@ eureka: service-url: defaultZone: http://localhost:8761/eureka/ instance: - instance-id: ${spring.cloud.client.hostname}:${spring.application.instance_id:${random.value}} \ No newline at end of file + instance-id: ${spring.cloud.client.hostname}:${spring.application.instance_id:${random.value}} + +--- +# Product Profile +spring: + config: + activate: + on-profile: prod + import: "configserver:" + +--- +# Test Profile +spring: + config: + activate: + on-profile: test + cloud: + config: + enabled: false + +eureka: + client: + enabled: false diff --git a/product/src/test/java/com/hubEleven/product/ProductApplicationTests.java b/product/src/test/java/com/hubEleven/product/ProductApplicationTests.java index 35bcfd5..7bec26d 100644 --- a/product/src/test/java/com/hubEleven/product/ProductApplicationTests.java +++ b/product/src/test/java/com/hubEleven/product/ProductApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class ProductApplicationTests { @Test diff --git a/product/src/test/java/com/hubEleven/product/stock/application/fixtures/ProductFixture.java b/product/src/test/java/com/hubEleven/product/stock/application/fixtures/ProductFixture.java new file mode 100644 index 0000000..54f1556 --- /dev/null +++ b/product/src/test/java/com/hubEleven/product/stock/application/fixtures/ProductFixture.java @@ -0,0 +1,21 @@ +package com.hubEleven.product.stock.application.fixtures; + +import com.hubEleven.product.domain.model.Product; +import java.util.UUID; + +public class ProductFixture { + + // ===== ID ===== + + public static final UUID COMPANY_ID = UUID.randomUUID(); + + public static final UUID HUB_ID = UUID.randomUUID(); + + // ===== Factory Methods ===== + + public static Product createDefault() { + return Product.create("Default Product", COMPANY_ID, HUB_ID); + } + + private ProductFixture() {} +} diff --git a/product/src/test/java/com/hubEleven/product/stock/application/fixtures/StockFixture.java b/product/src/test/java/com/hubEleven/product/stock/application/fixtures/StockFixture.java new file mode 100644 index 0000000..7f21d8c --- /dev/null +++ b/product/src/test/java/com/hubEleven/product/stock/application/fixtures/StockFixture.java @@ -0,0 +1,21 @@ +package com.hubEleven.product.stock.application.fixtures; + +import com.hubEleven.product.domain.model.Product; +import com.hubEleven.stock.domain.model.Stock; +import com.hubEleven.stock.presentation.dto.request.StockRequests; + +public class StockFixture { + + // ===== Factory Methods ===== + + public static Stock createFromProductWithQuantity(Product product, int quantity) { + return Stock.create( + product.getProductId(), product.getCompanyId(), product.getHubId(), quantity); + } + + public static StockRequests.Decrease decreaseRequest(Product product, int quantity) { + return new StockRequests.Decrease(product.getProductId(), quantity); + } + + private StockFixture() {} +} diff --git a/product/src/test/java/com/hubEleven/product/stock/application/service/StockConcurrencyTest.java b/product/src/test/java/com/hubEleven/product/stock/application/service/StockConcurrencyTest.java new file mode 100644 index 0000000..073d137 --- /dev/null +++ b/product/src/test/java/com/hubEleven/product/stock/application/service/StockConcurrencyTest.java @@ -0,0 +1,100 @@ +package com.hubEleven.product.stock.application.service; + +import static org.junit.jupiter.api.Assertions.*; + +import com.hubEleven.product.domain.model.Product; +import com.hubEleven.product.infrastructure.repository.JpaProductRepository; +import com.hubEleven.product.stock.application.fixtures.ProductFixture; +import com.hubEleven.product.stock.application.fixtures.StockFixture; +import com.hubEleven.stock.application.service.StockServiceImpl; +import com.hubEleven.stock.domain.model.Stock; +import com.hubEleven.stock.infrastructure.repository.JpaStockRepository; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; +import org.junit.jupiter.api.AfterEach; +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.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest +public class StockConcurrencyTest { + + @Autowired private StockServiceImpl stockServiceImpl; + + @Autowired private JpaStockRepository jpaStockRepository; + + @Autowired private JpaProductRepository jpaProductRepository; + + private Product product; + + @BeforeEach + void setUp() { + product = ProductFixture.createDefault(); + jpaProductRepository.saveAndFlush(product); + + Stock stock = StockFixture.createFromProductWithQuantity(product, 100); + jpaStockRepository.saveAndFlush(stock); + } + + @AfterEach + void tearDown() { + jpaStockRepository.deleteAll(); + jpaProductRepository.deleteAll(); + } + + @Test + @DisplayName("재고 감소 - 100개 동시 요청 시 정확히 차감") + void decreaseStock_when100ConcurrentRequests_thenSuccess() throws Exception { + + // given + int threadCount = 100; + + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + Queue exceptions = new ConcurrentLinkedQueue<>(); + + // when + for (int i = 0; i < threadCount; i++) { + executorService.submit( + () -> { + try { + readyLatch.countDown(); + startLatch.await(); + stockServiceImpl.decreaseStock(StockFixture.decreaseRequest(product, 1)); + } catch (Throwable e) { + exceptions.add(e); + } finally { + doneLatch.countDown(); + } + }); + } + + readyLatch.await(); + startLatch.countDown(); + doneLatch.await(); + executorService.shutdown(); + + // then + Stock updatedStock = jpaStockRepository.findByProductId(product.getProductId()).orElseThrow(); + + assertTrue(exceptions.isEmpty(), exceptionMessages(exceptions)); + assertEquals(0, updatedStock.getQuantity()); + } + + private String exceptionMessages(Queue exceptions) { + return exceptions.stream() + .map(throwable -> throwable.getClass().getName() + ": " + throwable.getMessage()) + .collect(Collectors.joining(System.lineSeparator())); + } +} diff --git a/product/src/test/java/com/hubEleven/product/stock/application/service/StockServiceImplTest.java b/product/src/test/java/com/hubEleven/product/stock/application/service/StockServiceImplTest.java new file mode 100644 index 0000000..0f13dd7 --- /dev/null +++ b/product/src/test/java/com/hubEleven/product/stock/application/service/StockServiceImplTest.java @@ -0,0 +1,69 @@ +package com.hubEleven.product.stock.application.service; + +import static org.assertj.core.api.Assertions.*; + +import com.hubEleven.product.domain.model.Product; +import com.hubEleven.product.infrastructure.repository.JpaProductRepository; +import com.hubEleven.product.stock.application.fixtures.ProductFixture; +import com.hubEleven.product.stock.application.fixtures.StockFixture; +import com.hubEleven.stock.application.dto.StockResult; +import com.hubEleven.stock.application.service.StockServiceImpl; +import com.hubEleven.stock.domain.model.Stock; +import com.hubEleven.stock.infrastructure.repository.JpaStockRepository; +import com.hubEleven.stock.presentation.dto.request.StockRequests; +import org.junit.jupiter.api.AfterEach; +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.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest +class StockServiceImplTest { + + @Autowired private StockServiceImpl stockServiceImpl; + + @Autowired private JpaStockRepository jpaStockRepository; + + @Autowired private JpaProductRepository jpaProductRepository; + + private Product product; + + // 테스트 전 상품 재고 입력 + @BeforeEach + public void setUp() { + + product = ProductFixture.createDefault(); + jpaProductRepository.saveAndFlush(product); + + Stock stock = StockFixture.createFromProductWithQuantity(product, 100); + jpaStockRepository.saveAndFlush(stock); + } + + @AfterEach + public void after() { + jpaStockRepository.deleteAll(); + jpaProductRepository.deleteAll(); + } + + @Test + @DisplayName("재고 감소 - 단일 요청 성공") + void decreaseStock_success() { + + // given + int decreaseAmount = 10; + + StockRequests.Decrease request = StockFixture.decreaseRequest(product, decreaseAmount); + + // when + StockResult result = stockServiceImpl.decreaseStock(request); + + // then - 반환값 검증 + assertThat(result.productId()).isEqualTo(product.getProductId()); + assertThat(result.companyId()).isEqualTo(product.getCompanyId()); + assertThat(result.hubId()).isEqualTo(product.getHubId()); + assertThat(result.quantity()).isEqualTo(90); + } +} diff --git a/product/src/test/resources/application-test.yml b/product/src/test/resources/application-test.yml new file mode 100644 index 0000000..7c5f145 --- /dev/null +++ b/product/src/test/resources/application-test.yml @@ -0,0 +1,26 @@ +spring: + config: + import: "optional:file:.env[.properties],optional:file:../.env[.properties]" + cloud: + config: + enabled: false + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3306/hubeleven_test?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: true + show-sql: true + data: + redis: + host: localhost + port: 6379 + +eureka: + client: + enabled: false