diff --git a/build.gradle b/build.gradle index 8dd652f..077787a 100644 --- a/build.gradle +++ b/build.gradle @@ -22,7 +22,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.session:spring-session-data-redis' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.redisson:redisson-spring-boot-starter:3.50.0' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/example/locktest/adapter/in/web/ItemController.java b/src/main/java/com/example/locktest/adapter/in/web/ItemController.java new file mode 100644 index 0000000..eea6cff --- /dev/null +++ b/src/main/java/com/example/locktest/adapter/in/web/ItemController.java @@ -0,0 +1,61 @@ +package com.example.locktest.adapter.in.web; + +import com.example.locktest.application.command.CreateItemCommand; +import com.example.locktest.application.command.DecreaseItemCommand; +import com.example.locktest.application.command.UpdateItemCommand; +import com.example.locktest.application.port.in.CreateItemUseCase; +import com.example.locktest.application.port.in.DecreaseItemUseCase; +import com.example.locktest.application.port.in.UpdateItemUseCase; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/v1/api/items") +public class ItemController { + + private final CreateItemUseCase createItemUseCase; + private final UpdateItemUseCase updateItemUseCase; + private final DecreaseItemUseCase syncDecreaseItemUseCase; + private final DecreaseItemUseCase redisDecreaseItemUseCase; + private final DecreaseItemUseCase optimisticDecreaseItemUseCase; + + public ItemController(CreateItemUseCase createItemUseCase, + UpdateItemUseCase updateItemUseCase, + @Qualifier("javaSyncDecreaseItemUseCase") DecreaseItemUseCase sync, + @Qualifier("dbDecreaseItemUseCase") DecreaseItemUseCase optimistic, + @Qualifier("redisDecreaseItemUseCase") DecreaseItemUseCase redis) { + this.createItemUseCase = createItemUseCase; + this.updateItemUseCase = updateItemUseCase; + this.syncDecreaseItemUseCase = sync; + this.optimisticDecreaseItemUseCase = optimistic; + this.redisDecreaseItemUseCase = redis; + } + + @PostMapping + public ResponseEntity createItem(@RequestBody CreateItemCommand command) { + return ResponseEntity.status(HttpStatus.CREATED).body(createItemUseCase.createItem(command)); + } + + @PutMapping + public ResponseEntity updateItem(@RequestBody UpdateItemCommand command) { + return ResponseEntity.ok().body(updateItemUseCase.updateItem(command)); + } + + @PostMapping("/decrease/sync") + public ResponseEntity decreaseItemWithSync(@RequestBody DecreaseItemCommand command) { + return ResponseEntity.ok(syncDecreaseItemUseCase.decreaseItem(command)); + } + + @PostMapping("/decrease/optimistic") + public ResponseEntity decreaseItemWithOptimistic(@RequestBody DecreaseItemCommand command) { + return ResponseEntity.ok(optimisticDecreaseItemUseCase.decreaseItem(command)); + } + + @PostMapping("/decrease/redis") + public ResponseEntity decreaseItemWithRedis(@RequestBody DecreaseItemCommand command) { + return ResponseEntity.ok(redisDecreaseItemUseCase.decreaseItem(command)); + } + +} diff --git a/src/main/java/com/example/locktest/adapter/out/lock/DecreaseWithOptimisticLockAdapter.java b/src/main/java/com/example/locktest/adapter/out/lock/DecreaseWithOptimisticLockAdapter.java new file mode 100644 index 0000000..51fd9f4 --- /dev/null +++ b/src/main/java/com/example/locktest/adapter/out/lock/DecreaseWithOptimisticLockAdapter.java @@ -0,0 +1,12 @@ +package com.example.locktest.adapter.out.lock; + +import com.example.locktest.application.port.out.ItemLockPort; + +public class DecreaseWithOptimisticLockAdapter implements ItemLockPort { + + @Override + public void lock(Long itemId) { } + + @Override + public void unlock(Long itemId) { } +} diff --git a/src/main/java/com/example/locktest/adapter/out/lock/DecreaseWithRedisLockAdapter.java b/src/main/java/com/example/locktest/adapter/out/lock/DecreaseWithRedisLockAdapter.java new file mode 100644 index 0000000..c5e2d97 --- /dev/null +++ b/src/main/java/com/example/locktest/adapter/out/lock/DecreaseWithRedisLockAdapter.java @@ -0,0 +1,38 @@ +package com.example.locktest.adapter.out.lock; + +import com.example.locktest.application.port.out.ItemLockPort; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; + +import java.util.concurrent.TimeUnit; + +public class DecreaseWithRedisLockAdapter implements ItemLockPort { + + private final RedissonClient redissonClient; + + public DecreaseWithRedisLockAdapter(RedissonClient redissonClient) { + this.redissonClient = redissonClient; + } + + @Override + public void lock(Long itemId) { + RLock lock = redissonClient.getLock("item_lock_" + itemId); + try { + System.out.println(Thread.currentThread().getName() + " - try to lock: " + itemId); + lock.lock(5, TimeUnit.SECONDS); + System.out.println(Thread.currentThread().getName() + " - lock acquired: " + itemId); + } catch (Exception e) { + throw new IllegalStateException("lock fail", e); + } + } + + @Override + public void unlock(Long itemId) { + RLock lock = redissonClient.getLock("item_lock_" + itemId); + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + System.out.println(Thread.currentThread().getName() + " - unlock: " + itemId); + } + } + +} diff --git a/src/main/java/com/example/locktest/adapter/out/lock/DecreaseWithSynchronizedLockAdapter.java b/src/main/java/com/example/locktest/adapter/out/lock/DecreaseWithSynchronizedLockAdapter.java new file mode 100644 index 0000000..296f8d2 --- /dev/null +++ b/src/main/java/com/example/locktest/adapter/out/lock/DecreaseWithSynchronizedLockAdapter.java @@ -0,0 +1,26 @@ +package com.example.locktest.adapter.out.lock; + +import com.example.locktest.application.port.out.ItemLockPort; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class DecreaseWithSynchronizedLockAdapter implements ItemLockPort { + + // 아이템별로 락 오브젝트를 관리하는 맵 + private final Map locks = new ConcurrentHashMap<>(); + + @Override + public void lock(Long itemId) { + Object lock = locks.computeIfAbsent(itemId, k -> new Object()); + synchronized (lock) { + // synchronized로 락 획득 → 이후 작업은 UseCase에서 수행됨 + } + } + + @Override + public void unlock(Long itemId) { + // synchronized는 자동 해제되므로 별도 unlock 필요 없음 + // 하지만 인터페이스 맞추기 위해 dummy 구현 제공 + } +} diff --git a/src/main/java/com/example/locktest/adapter/out/persistence/JpaItemRepository.java b/src/main/java/com/example/locktest/adapter/out/persistence/JpaItemRepository.java new file mode 100644 index 0000000..4601f31 --- /dev/null +++ b/src/main/java/com/example/locktest/adapter/out/persistence/JpaItemRepository.java @@ -0,0 +1,48 @@ +package com.example.locktest.adapter.out.persistence; + +import com.example.locktest.adapter.out.persistence.entity.ItemEntity; +import com.example.locktest.application.port.out.ItemRepository; +import com.example.locktest.domain.model.Item; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +public class JpaItemRepository implements ItemRepository { + + private final SpringDataItemRepository springDataItemRepository; + + public JpaItemRepository(SpringDataItemRepository springDataItemRepository) { + this.springDataItemRepository = springDataItemRepository; + } + + @Transactional + @Override + public Long save(Item item) { + ItemEntity entity = ItemEntity.builder() + .name(item.getName()) + .amount(item.getAmount()) + .creatAt(LocalDateTime.now()) + .build(); + ItemEntity saved = springDataItemRepository.save(entity); + return saved.getId(); + } + + @Transactional + @Override + public Long update(Item newItem) { + ItemEntity entity = springDataItemRepository.findById(newItem.getId()).orElseThrow( + () -> new RuntimeException("Item not found") + ); + entity.update(newItem); + return entity.getId(); + } + + @Transactional(readOnly = true) + @Override + public Optional findById(Long id) { + return springDataItemRepository.findById(id) + .map(ItemEntity::toDomain); + + } +} diff --git a/src/main/java/com/example/locktest/adapter/out/persistence/SpringDataItemRepository.java b/src/main/java/com/example/locktest/adapter/out/persistence/SpringDataItemRepository.java new file mode 100644 index 0000000..84b7a7b --- /dev/null +++ b/src/main/java/com/example/locktest/adapter/out/persistence/SpringDataItemRepository.java @@ -0,0 +1,7 @@ +package com.example.locktest.adapter.out.persistence; + +import com.example.locktest.adapter.out.persistence.entity.ItemEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SpringDataItemRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/locktest/adapter/out/persistence/entity/ItemEntity.java b/src/main/java/com/example/locktest/adapter/out/persistence/entity/ItemEntity.java new file mode 100644 index 0000000..2af0344 --- /dev/null +++ b/src/main/java/com/example/locktest/adapter/out/persistence/entity/ItemEntity.java @@ -0,0 +1,48 @@ +package com.example.locktest.adapter.out.persistence.entity; + +import com.example.locktest.domain.model.Item; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Table(name = "items") +@NoArgsConstructor +@Entity +public class ItemEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private Long amount; + + @Version + private Long version; // OptimisticLock 을 위해 사용 + + @Column(nullable = false) + private LocalDateTime creatAt; + + @Builder + public ItemEntity(String name, Long amount, LocalDateTime creatAt) { + this.name = name; + this.amount = amount; + this.creatAt = creatAt; + } + + public void update(Item item) { + this.name = item.getName(); + this.amount = item.getAmount(); + } + + public Item toDomain() { + return new Item(id, name, amount, creatAt); + } +} + diff --git a/src/main/java/com/example/locktest/application/command/CreateItemCommand.java b/src/main/java/com/example/locktest/application/command/CreateItemCommand.java new file mode 100644 index 0000000..aaad15f --- /dev/null +++ b/src/main/java/com/example/locktest/application/command/CreateItemCommand.java @@ -0,0 +1,3 @@ +package com.example.locktest.application.command; + +public record CreateItemCommand(String name, Long amount) {} diff --git a/src/main/java/com/example/locktest/application/command/DecreaseItemCommand.java b/src/main/java/com/example/locktest/application/command/DecreaseItemCommand.java new file mode 100644 index 0000000..44da80a --- /dev/null +++ b/src/main/java/com/example/locktest/application/command/DecreaseItemCommand.java @@ -0,0 +1,4 @@ +package com.example.locktest.application.command; + +public record DecreaseItemCommand(Long id, Long quantity) { +} diff --git a/src/main/java/com/example/locktest/application/command/UpdateItemCommand.java b/src/main/java/com/example/locktest/application/command/UpdateItemCommand.java new file mode 100644 index 0000000..4ee0d68 --- /dev/null +++ b/src/main/java/com/example/locktest/application/command/UpdateItemCommand.java @@ -0,0 +1,4 @@ +package com.example.locktest.application.command; + +public record UpdateItemCommand(Long id, String name, Long amount) { +} diff --git a/src/main/java/com/example/locktest/application/port/in/CreateItemUseCase.java b/src/main/java/com/example/locktest/application/port/in/CreateItemUseCase.java new file mode 100644 index 0000000..0713263 --- /dev/null +++ b/src/main/java/com/example/locktest/application/port/in/CreateItemUseCase.java @@ -0,0 +1,7 @@ +package com.example.locktest.application.port.in; + +import com.example.locktest.application.command.CreateItemCommand; + +public interface CreateItemUseCase { + Long createItem(CreateItemCommand command); +} diff --git a/src/main/java/com/example/locktest/application/port/in/DecreaseItemUseCase.java b/src/main/java/com/example/locktest/application/port/in/DecreaseItemUseCase.java new file mode 100644 index 0000000..f31caef --- /dev/null +++ b/src/main/java/com/example/locktest/application/port/in/DecreaseItemUseCase.java @@ -0,0 +1,7 @@ +package com.example.locktest.application.port.in; + +import com.example.locktest.application.command.DecreaseItemCommand; + +public interface DecreaseItemUseCase { + Long decreaseItem(DecreaseItemCommand command); +} diff --git a/src/main/java/com/example/locktest/application/port/in/UpdateItemUseCase.java b/src/main/java/com/example/locktest/application/port/in/UpdateItemUseCase.java new file mode 100644 index 0000000..e8847f0 --- /dev/null +++ b/src/main/java/com/example/locktest/application/port/in/UpdateItemUseCase.java @@ -0,0 +1,7 @@ +package com.example.locktest.application.port.in; + +import com.example.locktest.application.command.UpdateItemCommand; + +public interface UpdateItemUseCase { + Long updateItem(UpdateItemCommand command); +} diff --git a/src/main/java/com/example/locktest/application/port/out/ItemLockPort.java b/src/main/java/com/example/locktest/application/port/out/ItemLockPort.java new file mode 100644 index 0000000..4448248 --- /dev/null +++ b/src/main/java/com/example/locktest/application/port/out/ItemLockPort.java @@ -0,0 +1,6 @@ +package com.example.locktest.application.port.out; + +public interface ItemLockPort { + void lock(Long itemId); + void unlock(Long itemId); +} diff --git a/src/main/java/com/example/locktest/application/port/out/ItemRepository.java b/src/main/java/com/example/locktest/application/port/out/ItemRepository.java new file mode 100644 index 0000000..bc25d47 --- /dev/null +++ b/src/main/java/com/example/locktest/application/port/out/ItemRepository.java @@ -0,0 +1,11 @@ +package com.example.locktest.application.port.out; + +import com.example.locktest.domain.model.Item; + +import java.util.Optional; + +public interface ItemRepository { + Long save(Item item); + Long update(Item newItem); + Optional findById(Long id); +} diff --git a/src/main/java/com/example/locktest/application/service/CreateItemService.java b/src/main/java/com/example/locktest/application/service/CreateItemService.java new file mode 100644 index 0000000..0247086 --- /dev/null +++ b/src/main/java/com/example/locktest/application/service/CreateItemService.java @@ -0,0 +1,24 @@ +package com.example.locktest.application.service; + +import com.example.locktest.application.command.CreateItemCommand; +import com.example.locktest.application.port.in.CreateItemUseCase; +import com.example.locktest.application.port.out.ItemRepository; +import com.example.locktest.domain.model.Item; + +import java.time.LocalDateTime; + +public class CreateItemService implements CreateItemUseCase { + + private final ItemRepository itemRepository; + + public CreateItemService(ItemRepository itemRepository) { + this.itemRepository = itemRepository; + } + + @Override + public Long createItem(CreateItemCommand command) { + Item item = new Item(null, command.name(), command.amount(), LocalDateTime.now()); + item.validate(); + return itemRepository.save(item); + } +} diff --git a/src/main/java/com/example/locktest/application/service/DecreaseItemService.java b/src/main/java/com/example/locktest/application/service/DecreaseItemService.java new file mode 100644 index 0000000..40fcf71 --- /dev/null +++ b/src/main/java/com/example/locktest/application/service/DecreaseItemService.java @@ -0,0 +1,31 @@ +package com.example.locktest.application.service; + +import com.example.locktest.application.command.DecreaseItemCommand; +import com.example.locktest.application.port.in.DecreaseItemUseCase; +import com.example.locktest.application.port.out.ItemLockPort; +import com.example.locktest.application.port.out.ItemRepository; +import com.example.locktest.domain.model.Item; + +public class DecreaseItemService implements DecreaseItemUseCase { + + private final ItemRepository itemRepository; + private final ItemLockPort itemLockPort; + + public DecreaseItemService(ItemRepository itemRepository, ItemLockPort itemLockPort) { + this.itemRepository = itemRepository; + this.itemLockPort = itemLockPort; + } + + @Override + public Long decreaseItem(DecreaseItemCommand command) { + itemLockPort.lock(command.id()); + try { + Item item = itemRepository.findById(command.id()) + .orElseThrow(() -> new RuntimeException("해당 상품을 찾을 수 없습니다.")); + Item updateItem = item.decrease(command.quantity()); + return itemRepository.update(updateItem); + } finally { + itemLockPort.unlock(command.id()); + } + } +} diff --git a/src/main/java/com/example/locktest/application/service/UpdateItemService.java b/src/main/java/com/example/locktest/application/service/UpdateItemService.java new file mode 100644 index 0000000..18f5e68 --- /dev/null +++ b/src/main/java/com/example/locktest/application/service/UpdateItemService.java @@ -0,0 +1,23 @@ +package com.example.locktest.application.service; + +import com.example.locktest.application.command.UpdateItemCommand; +import com.example.locktest.application.port.in.UpdateItemUseCase; +import com.example.locktest.application.port.out.ItemRepository; +import com.example.locktest.domain.model.Item; + +public class UpdateItemService implements UpdateItemUseCase { + + private final ItemRepository itemRepository; + + public UpdateItemService(ItemRepository itemRepository) { + this.itemRepository = itemRepository; + } + + @Override + public Long updateItem(UpdateItemCommand command) { + Item item = itemRepository.findById(command.id()) + .orElseThrow(() -> new RuntimeException("Item not found")); + item.update(command.name(), command.amount()); + return itemRepository.update(item); + } +} diff --git a/src/main/java/com/example/locktest/domain/model/Item.java b/src/main/java/com/example/locktest/domain/model/Item.java new file mode 100644 index 0000000..fc7565f --- /dev/null +++ b/src/main/java/com/example/locktest/domain/model/Item.java @@ -0,0 +1,38 @@ +package com.example.locktest.domain.model; + +import java.time.LocalDateTime; + +public class Item { + private final Long id; + private String name; + private Long amount; + private final LocalDateTime createAt; + + public Item(Long id, String name, Long amount, LocalDateTime createAt) { + this.id = id; + this.name = name; + this.amount = amount; + this.createAt = createAt; + } + + public void validate() { + if (name == null || name.isBlank()) throw new IllegalArgumentException("이름은 필수입니다."); + if (amount <= 0) throw new IllegalArgumentException("재고는 최소 1개 이상이어야 합니다."); + } + + public void update(String name, Long amount) { + this.name = name; + this.amount = amount; + } + + public Item decrease(Long quantity) { + if (amount < quantity) throw new IllegalArgumentException("재고 수량이 부족합니다."); + if (quantity < 1) throw new IllegalArgumentException("요청 값의 크기는 0보다 커야합니다."); + return new Item(this.id, this.name, this.amount - quantity, this.createAt); + } + + public Long getId() { return id; } + public String getName() { return name; } + public Long getAmount() { return amount; } + public LocalDateTime getCreateAt() { return createAt; } +} diff --git a/src/main/java/com/example/locktest/infrastructure/config/BeenConfig.java b/src/main/java/com/example/locktest/infrastructure/config/BeenConfig.java new file mode 100644 index 0000000..bf9c5b2 --- /dev/null +++ b/src/main/java/com/example/locktest/infrastructure/config/BeenConfig.java @@ -0,0 +1,31 @@ +package com.example.locktest.infrastructure.config; + +import com.example.locktest.adapter.out.persistence.JpaItemRepository; +import com.example.locktest.adapter.out.persistence.SpringDataItemRepository; +import com.example.locktest.application.port.in.CreateItemUseCase; +import com.example.locktest.application.port.in.UpdateItemUseCase; +import com.example.locktest.application.port.out.ItemRepository; +import com.example.locktest.application.service.CreateItemService; +import com.example.locktest.application.service.UpdateItemService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class BeenConfig { + + // CRUD service + @Bean + public ItemRepository itemRepository(SpringDataItemRepository springDataItemRepository) { + return new JpaItemRepository(springDataItemRepository); + } + + @Bean + public CreateItemUseCase createItemUseCase(ItemRepository itemRepository) { + return new CreateItemService(itemRepository); + } + + @Bean + public UpdateItemUseCase updateItemUseCase(ItemRepository itemRepository) { + return new UpdateItemService(itemRepository); + } +} diff --git a/src/main/java/com/example/locktest/infrastructure/config/LockConfig.java b/src/main/java/com/example/locktest/infrastructure/config/LockConfig.java new file mode 100644 index 0000000..7147ef3 --- /dev/null +++ b/src/main/java/com/example/locktest/infrastructure/config/LockConfig.java @@ -0,0 +1,54 @@ +package com.example.locktest.infrastructure.config; + +import com.example.locktest.adapter.out.lock.DecreaseWithOptimisticLockAdapter; +import com.example.locktest.adapter.out.lock.DecreaseWithRedisLockAdapter; +import com.example.locktest.adapter.out.lock.DecreaseWithSynchronizedLockAdapter; +import com.example.locktest.application.port.in.DecreaseItemUseCase; +import com.example.locktest.application.port.out.ItemLockPort; +import com.example.locktest.application.port.out.ItemRepository; +import com.example.locktest.application.service.DecreaseItemService; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class LockConfig { + + // redis + @Bean("redisItemLockPort") + public ItemLockPort redisItemLockPort(RedissonClient redissonClient) { + return new DecreaseWithRedisLockAdapter(redissonClient); + } + + @Bean("redisDecreaseItemUseCase") + public DecreaseItemUseCase redisDecreaseItemUseCase(ItemRepository itemRepository, + @Qualifier("redisItemLockPort") ItemLockPort lockPort + ) { + return new DecreaseItemService(itemRepository, lockPort); + } + + // db optimistic + @Bean("databaseItemLockPort") + public ItemLockPort databaseItemLockPort() { + return new DecreaseWithOptimisticLockAdapter(); + } + + @Bean("dbDecreaseItemUseCase") + public DecreaseItemUseCase dbDecreaseItemUseCase(ItemRepository itemRepository, + @Qualifier("databaseItemLockPort") ItemLockPort lockPort) { + return new DecreaseItemService(itemRepository, lockPort); + } + + // sync + @Bean("javaSyncItemLockPort") + public ItemLockPort javaSyncItemLockPort() { + return new DecreaseWithSynchronizedLockAdapter(); + } + + @Bean("javaSyncDecreaseItemUseCase") + public DecreaseItemUseCase syncDecreaseItemUseCase(ItemRepository itemRepository, + @Qualifier("javaSyncItemLockPort") ItemLockPort lockPort) { + return new DecreaseItemService(itemRepository, lockPort); + } +} diff --git a/src/main/java/com/example/locktest/infrastructure/config/RedissonConfig.java b/src/main/java/com/example/locktest/infrastructure/config/RedissonConfig.java new file mode 100644 index 0000000..7e116b7 --- /dev/null +++ b/src/main/java/com/example/locktest/infrastructure/config/RedissonConfig.java @@ -0,0 +1,18 @@ +package com.example.locktest.infrastructure.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfig { + + @Bean + public RedissonClient redisson() { + Config config = new Config(); + config.useSingleServer().setAddress("redis://host.docker.internal:6379"); + return Redisson.create(config); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index aa18513..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=lockTest diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..a313ab8 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,20 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + username: root + password: 1234 + url: jdbc:mysql://localhost:3306/lockTest?serverTimezone=UTC&useSSL=false&allowPublicKeyRetrieval=true + + jpa: + show_sql: true + hibernate: + ddl-auto: create + + redis: + data: + host: host.docker.internal + port: 6379 + + redisson: + single-server-config: + address: redis://host.docker.internal:6379 \ No newline at end of file diff --git a/src/test/java/com/example/locktest/ItemConcurrencyTest.java b/src/test/java/com/example/locktest/ItemConcurrencyTest.java new file mode 100644 index 0000000..daeb755 --- /dev/null +++ b/src/test/java/com/example/locktest/ItemConcurrencyTest.java @@ -0,0 +1,76 @@ +package com.example.locktest; + +import com.example.locktest.application.command.DecreaseItemCommand; +import com.example.locktest.application.port.in.DecreaseItemUseCase; +import com.example.locktest.application.port.out.ItemRepository; +import com.example.locktest.domain.model.Item; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +@ActiveProfiles("test") +class ItemConcurrencyTest { + + @Autowired +// @Qualifier("redisDecreaseItemUseCase") + @Qualifier("dbDecreaseItemUseCase") +// @Qualifier("javaSyncDecreaseItemUseCase") + private DecreaseItemUseCase decreaseItemUseCase; + + @Autowired + private ItemRepository itemRepository; + + @BeforeEach + void setUp() { + Item item = new Item(null, "item1", 100L, LocalDateTime.now()); + itemRepository.save(item); + } + + @Test + void 동시에_1000개의_요청을_보내면_정확히_1000개_차감되어야_한다() throws InterruptedException { + int threadCount = 100; + ExecutorService executor = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(threadCount); + + Long itemId = 1L; + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + // 실패 시 재시도 + for (int retry = 0; retry < 10; retry++) { + try { + decreaseItemUseCase.decreaseItem(new DecreaseItemCommand(itemId, 1L)); + break; // 성공하면 break + } catch (Exception e) { + // 낙관적 락 충돌 or LockTimeout → 재시도 + System.out.println(Thread.currentThread().getName() + " - retrying: " + e.getMessage()); + Thread.sleep(10); // 너무 빠르게 재시도하지 않게 딜레이 + } + } + } catch (Exception e) { + System.err.println("Thread Error: " + e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); // 모든 요청 대기 + + Item item = itemRepository.findById(itemId).orElseThrow(); + assertEquals(0L, item.getAmount()); // 재고가 정확히 0이어야 성공 + } + +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..a313ab8 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,20 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + username: root + password: 1234 + url: jdbc:mysql://localhost:3306/lockTest?serverTimezone=UTC&useSSL=false&allowPublicKeyRetrieval=true + + jpa: + show_sql: true + hibernate: + ddl-auto: create + + redis: + data: + host: host.docker.internal + port: 6379 + + redisson: + single-server-config: + address: redis://host.docker.internal:6379 \ No newline at end of file