From 875ee7a44fd6e0836f5c91a921076dca434ea588 Mon Sep 17 00:00:00 2001 From: ehgur Date: Thu, 17 Jul 2025 15:59:10 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20item=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/locktest/domain/model/Item.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/main/java/com/example/locktest/domain/model/Item.java 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..b3cd4b6 --- /dev/null +++ b/src/main/java/com/example/locktest/domain/model/Item.java @@ -0,0 +1,17 @@ +package com.example.locktest.domain.model; + +public class Item { + private final String name; + private final Long amount; + + public Item(String name, Long amount) { + if (name == null || name.isBlank()) throw new IllegalArgumentException("이름은 필수입니다."); + if (amount <= 0) throw new IllegalArgumentException("재고는 최소 1개 이상이어야 합니다."); + + this.name = name; + this.amount = amount; + } + + public String getName() { return name; } + public Long getAmount() { return amount; } +} From faaa3ec0092067f4a0a0a7ebac46a10e504a8152 Mon Sep 17 00:00:00 2001 From: ehgur062300 Date: Fri, 18 Jul 2025 15:26:10 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20item=20save=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../adapter/in/web/ItemController.java | 24 +++++++++++++ .../out/persistence/JpaItemRepository.java | 25 +++++++++++++ .../persistence/SpringDataItemRepository.java | 7 ++++ .../out/persistence/entity/ItemEntity.java | 36 +++++++++++++++++++ .../command/CreateItemCommand.java | 3 ++ .../port/in/CreateItemUseCase.java | 7 ++++ .../application/port/out/ItemRepository.java | 7 ++++ .../service/CreateItemService.java | 24 +++++++++++++ .../example/locktest/domain/model/Item.java | 18 +++++++--- .../infrastructure/config/BeenConfig.java | 23 ++++++++++++ 11 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/example/locktest/adapter/in/web/ItemController.java create mode 100644 src/main/java/com/example/locktest/adapter/out/persistence/JpaItemRepository.java create mode 100644 src/main/java/com/example/locktest/adapter/out/persistence/SpringDataItemRepository.java create mode 100644 src/main/java/com/example/locktest/adapter/out/persistence/entity/ItemEntity.java create mode 100644 src/main/java/com/example/locktest/application/command/CreateItemCommand.java create mode 100644 src/main/java/com/example/locktest/application/port/in/CreateItemUseCase.java create mode 100644 src/main/java/com/example/locktest/application/port/out/ItemRepository.java create mode 100644 src/main/java/com/example/locktest/application/service/CreateItemService.java create mode 100644 src/main/java/com/example/locktest/infrastructure/config/BeenConfig.java diff --git a/build.gradle b/build.gradle index 8dd652f..4c8b6dc 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.session:spring-session-data-redis' + implementation 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' testImplementation 'org.springframework.boot:spring-boot-starter-test' 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..2232a7e --- /dev/null +++ b/src/main/java/com/example/locktest/adapter/in/web/ItemController.java @@ -0,0 +1,24 @@ +package com.example.locktest.adapter.in.web; + +import com.example.locktest.application.command.CreateItemCommand; +import com.example.locktest.application.port.in.CreateItemUseCase; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/v1/api/items") +public class ItemController { + + private final CreateItemUseCase createItemUseCase; + + public ItemController(CreateItemUseCase createItemUseCase) { + this.createItemUseCase = createItemUseCase; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Long createItem(@RequestBody CreateItemCommand command) { + return createItemUseCase.createItem(command); + } + +} 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..f5561df --- /dev/null +++ b/src/main/java/com/example/locktest/adapter/out/persistence/JpaItemRepository.java @@ -0,0 +1,25 @@ +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; + +public class JpaItemRepository implements ItemRepository { + + private final SpringDataItemRepository springDataItemRepository; + + public JpaItemRepository(SpringDataItemRepository springDataItemRepository) { + this.springDataItemRepository = springDataItemRepository; + } + + @Override + public Long save(Item item) { + ItemEntity entity = ItemEntity.builder() + .id(item.getId()) + .name(item.getName()) + .amount(item.getAmount()) + .creatAt(item.getCreateAt()) + .build(); + return springDataItemRepository.save(entity).getId(); + } +} 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..e0045a1 --- /dev/null +++ b/src/main/java/com/example/locktest/adapter/out/persistence/entity/ItemEntity.java @@ -0,0 +1,36 @@ +package com.example.locktest.adapter.out.persistence.entity; + +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; + + @Column(nullable = false) + private LocalDateTime creatAt; + + @Builder + public ItemEntity(Long id, String name, Long amount, LocalDateTime creatAt) { + this.id = id; + this.name = name; + this.amount = amount; + this.creatAt = 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/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/out/ItemRepository.java b/src/main/java/com/example/locktest/application/port/out/ItemRepository.java new file mode 100644 index 0000000..74184b8 --- /dev/null +++ b/src/main/java/com/example/locktest/application/port/out/ItemRepository.java @@ -0,0 +1,7 @@ +package com.example.locktest.application.port.out; + +import com.example.locktest.domain.model.Item; + +public interface ItemRepository { + Long save(Item item); +} 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/domain/model/Item.java b/src/main/java/com/example/locktest/domain/model/Item.java index b3cd4b6..dc0b7d9 100644 --- a/src/main/java/com/example/locktest/domain/model/Item.java +++ b/src/main/java/com/example/locktest/domain/model/Item.java @@ -1,17 +1,27 @@ package com.example.locktest.domain.model; +import java.time.LocalDateTime; + public class Item { + private final Long id; private final String name; private final Long amount; + private final LocalDateTime createAt; - public Item(String name, Long amount) { - if (name == null || name.isBlank()) throw new IllegalArgumentException("이름은 필수입니다."); - if (amount <= 0) throw new IllegalArgumentException("재고는 최소 1개 이상이어야 합니다."); - + 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 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..c44b2dd --- /dev/null +++ b/src/main/java/com/example/locktest/infrastructure/config/BeenConfig.java @@ -0,0 +1,23 @@ +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.out.ItemRepository; +import com.example.locktest.application.service.CreateItemService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class BeenConfig { + + @Bean + public ItemRepository itemRepository(SpringDataItemRepository springDataItemRepository) { + return new JpaItemRepository(springDataItemRepository); + } + + @Bean + public CreateItemUseCase createItemUseCase(ItemRepository itemRepository) { + return new CreateItemService(itemRepository); + } +} From d53ef46798d83d89efa46aefb529662b1ad2e3a0 Mon Sep 17 00:00:00 2001 From: ehgur062300 Date: Mon, 21 Jul 2025 15:49:46 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20clean=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B3=90=20=EA=B5=AC=EC=A1=B0=20=EC=9E=91=EC=84=B1=20=EB=B0=8F?= =?UTF-8?q?=20lock=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++- .../adapter/in/web/ItemController.java | 28 ++++++++++++--- .../locktest/adapter/out/lock/RedisLock.java | 36 +++++++++++++++++++ .../out/persistence/JpaItemRepository.java | 30 ++++++++++++++-- .../out/persistence/entity/ItemEntity.java | 13 +++++-- .../command/DecreaseItemCommand.java | 4 +++ .../command/UpdateItemCommand.java | 4 +++ .../port/in/DecreaseItemUseCase.java | 7 ++++ .../port/in/UpdateItemUseCase.java | 7 ++++ .../application/port/out/ItemLockPort.java | 6 ++++ .../application/port/out/ItemRepository.java | 4 +++ .../service/DecreaseItemService.java | 31 ++++++++++++++++ .../service/UpdateItemService.java | 23 ++++++++++++ .../example/locktest/domain/model/Item.java | 14 ++++++-- .../infrastructure/config/BeenConfig.java | 15 ++++++++ .../infrastructure/config/RedissonConfig.java | 18 ++++++++++ src/main/resources/application.properties | 1 - src/main/resources/application.yml | 20 +++++++++++ 18 files changed, 252 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/example/locktest/adapter/out/lock/RedisLock.java create mode 100644 src/main/java/com/example/locktest/application/command/DecreaseItemCommand.java create mode 100644 src/main/java/com/example/locktest/application/command/UpdateItemCommand.java create mode 100644 src/main/java/com/example/locktest/application/port/in/DecreaseItemUseCase.java create mode 100644 src/main/java/com/example/locktest/application/port/in/UpdateItemUseCase.java create mode 100644 src/main/java/com/example/locktest/application/port/out/ItemLockPort.java create mode 100644 src/main/java/com/example/locktest/application/service/DecreaseItemService.java create mode 100644 src/main/java/com/example/locktest/application/service/UpdateItemService.java create mode 100644 src/main/java/com/example/locktest/infrastructure/config/RedissonConfig.java delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yml diff --git a/build.gradle b/build.gradle index 4c8b6dc..077787a 100644 --- a/build.gradle +++ b/build.gradle @@ -22,8 +22,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.session:spring-session-data-redis' - implementation 'org.projectlombok:lombok' + 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 index 2232a7e..ca36efe 100644 --- a/src/main/java/com/example/locktest/adapter/in/web/ItemController.java +++ b/src/main/java/com/example/locktest/adapter/in/web/ItemController.java @@ -1,8 +1,13 @@ 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.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @@ -10,15 +15,30 @@ public class ItemController { private final CreateItemUseCase createItemUseCase; + private final UpdateItemUseCase updateItemUseCase; + private final DecreaseItemUseCase decreaseItemUseCase; - public ItemController(CreateItemUseCase createItemUseCase) { + public ItemController(CreateItemUseCase createItemUseCase, + UpdateItemUseCase updateItemUseCase, + DecreaseItemUseCase decreaseItemUseCase) { this.createItemUseCase = createItemUseCase; + this.updateItemUseCase = updateItemUseCase; + this.decreaseItemUseCase = decreaseItemUseCase; } @PostMapping - @ResponseStatus(HttpStatus.CREATED) - public Long createItem(@RequestBody CreateItemCommand command) { - return createItemUseCase.createItem(command); + 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") + public ResponseEntity decreaseItem(@RequestBody DecreaseItemCommand command) { + return ResponseEntity.ok().body(decreaseItemUseCase.decreaseItem(command)); } } diff --git a/src/main/java/com/example/locktest/adapter/out/lock/RedisLock.java b/src/main/java/com/example/locktest/adapter/out/lock/RedisLock.java new file mode 100644 index 0000000..78c823e --- /dev/null +++ b/src/main/java/com/example/locktest/adapter/out/lock/RedisLock.java @@ -0,0 +1,36 @@ +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 org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Component +public class RedisLock implements ItemLockPort { + + private final RedissonClient redissonClient; + + public RedisLock(RedissonClient redissonClient) { + this.redissonClient = redissonClient; + } + + @Override + public void lock(Long itemId) { + RLock lock = redissonClient.getLock("item_lock_" + itemId); + try { + lock.lock(5, TimeUnit.SECONDS); + } 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(); + } + } +} 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 index f5561df..1479859 100644 --- a/src/main/java/com/example/locktest/adapter/out/persistence/JpaItemRepository.java +++ b/src/main/java/com/example/locktest/adapter/out/persistence/JpaItemRepository.java @@ -3,6 +3,10 @@ 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 { @@ -12,14 +16,34 @@ public JpaItemRepository(SpringDataItemRepository springDataItemRepository) { this.springDataItemRepository = springDataItemRepository; } + @Transactional @Override public Long save(Item item) { ItemEntity entity = ItemEntity.builder() - .id(item.getId()) .name(item.getName()) .amount(item.getAmount()) - .creatAt(item.getCreateAt()) + .creatAt(LocalDateTime.now()) .build(); - return springDataItemRepository.save(entity).getId(); + 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); + ItemEntity saved = springDataItemRepository.save(entity); + return saved.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/entity/ItemEntity.java b/src/main/java/com/example/locktest/adapter/out/persistence/entity/ItemEntity.java index e0045a1..18a91d4 100644 --- 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 @@ -1,5 +1,6 @@ package com.example.locktest.adapter.out.persistence.entity; +import com.example.locktest.domain.model.Item; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; @@ -26,11 +27,19 @@ public class ItemEntity { private LocalDateTime creatAt; @Builder - public ItemEntity(Long id, String name, Long amount, LocalDateTime creatAt) { - this.id = id; + 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/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/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 index 74184b8..bc25d47 100644 --- a/src/main/java/com/example/locktest/application/port/out/ItemRepository.java +++ b/src/main/java/com/example/locktest/application/port/out/ItemRepository.java @@ -2,6 +2,10 @@ 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/DecreaseItemService.java b/src/main/java/com/example/locktest/application/service/DecreaseItemService.java new file mode 100644 index 0000000..fffb920 --- /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.decrease(command.quantity()); + return itemRepository.save(item); + } 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 index dc0b7d9..497df17 100644 --- a/src/main/java/com/example/locktest/domain/model/Item.java +++ b/src/main/java/com/example/locktest/domain/model/Item.java @@ -4,8 +4,8 @@ public class Item { private final Long id; - private final String name; - private final Long amount; + private String name; + private Long amount; private final LocalDateTime createAt; public Item(Long id, String name, Long amount, LocalDateTime createAt) { @@ -20,6 +20,16 @@ public void validate() { if (amount <= 0) throw new IllegalArgumentException("재고는 최소 1개 이상이어야 합니다."); } + public void update(String name, Long amount) { + this.name = name; + this.amount = amount; + } + + public void decrease(Long quantity) { + if (amount < quantity) throw new IllegalArgumentException("재고 수량이 부족합니다."); + if (quantity < 1) throw new IllegalArgumentException("요청 값의 크기는 0보다 커야합니다."); + } + public Long getId() { return id; } public String getName() { return name; } public Long getAmount() { return amount; } diff --git a/src/main/java/com/example/locktest/infrastructure/config/BeenConfig.java b/src/main/java/com/example/locktest/infrastructure/config/BeenConfig.java index c44b2dd..939b275 100644 --- a/src/main/java/com/example/locktest/infrastructure/config/BeenConfig.java +++ b/src/main/java/com/example/locktest/infrastructure/config/BeenConfig.java @@ -3,8 +3,13 @@ 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.DecreaseItemUseCase; +import com.example.locktest.application.port.in.UpdateItemUseCase; +import com.example.locktest.application.port.out.ItemLockPort; import com.example.locktest.application.port.out.ItemRepository; import com.example.locktest.application.service.CreateItemService; +import com.example.locktest.application.service.DecreaseItemService; +import com.example.locktest.application.service.UpdateItemService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -20,4 +25,14 @@ public ItemRepository itemRepository(SpringDataItemRepository springDataItemRepo public CreateItemUseCase createItemUseCase(ItemRepository itemRepository) { return new CreateItemService(itemRepository); } + + @Bean + public UpdateItemUseCase updateItemUseCase(ItemRepository itemRepository) { + return new UpdateItemService(itemRepository); + } + + @Bean + public DecreaseItemUseCase decreaseItemUseCase(ItemRepository itemRepository, 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..3e1dbea --- /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://localhost: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..4b94f00 --- /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: localhost + port: 6379 + + redisson: + single-server-config: + address: redis://localhost:6379 \ No newline at end of file From 136894d47f7990263aba684749c2dd8a1166b8cb Mon Sep 17 00:00:00 2001 From: ehgur062300 Date: Tue, 22 Jul 2025 20:23:07 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redisson 분산락 - Optimistic 락 - Synchronize 락 --- .../adapter/in/web/ItemController.java | 29 +++++++-- .../DecreaseWithOptimisticLockAdapter.java | 12 ++++ ...java => DecreaseWithRedisLockAdapter.java} | 12 ++-- .../DecreaseWithSynchronizedLockAdapter.java | 26 ++++++++ .../out/persistence/entity/ItemEntity.java | 3 + .../service/DecreaseItemService.java | 6 +- .../example/locktest/domain/model/Item.java | 3 +- .../infrastructure/config/BeenConfig.java | 9 +-- .../infrastructure/config/LockConfig.java | 54 ++++++++++++++++ .../infrastructure/config/RedissonConfig.java | 2 +- src/main/resources/application.yml | 4 +- .../example/locktest/ItemConcurrencyTest.java | 63 +++++++++++++++++++ 12 files changed, 198 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/example/locktest/adapter/out/lock/DecreaseWithOptimisticLockAdapter.java rename src/main/java/com/example/locktest/adapter/out/lock/{RedisLock.java => DecreaseWithRedisLockAdapter.java} (63%) create mode 100644 src/main/java/com/example/locktest/adapter/out/lock/DecreaseWithSynchronizedLockAdapter.java create mode 100644 src/main/java/com/example/locktest/infrastructure/config/LockConfig.java create mode 100644 src/test/java/com/example/locktest/ItemConcurrencyTest.java 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 index ca36efe..eea6cff 100644 --- a/src/main/java/com/example/locktest/adapter/in/web/ItemController.java +++ b/src/main/java/com/example/locktest/adapter/in/web/ItemController.java @@ -6,6 +6,7 @@ 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.*; @@ -16,14 +17,20 @@ public class ItemController { private final CreateItemUseCase createItemUseCase; private final UpdateItemUseCase updateItemUseCase; - private final DecreaseItemUseCase decreaseItemUseCase; + private final DecreaseItemUseCase syncDecreaseItemUseCase; + private final DecreaseItemUseCase redisDecreaseItemUseCase; + private final DecreaseItemUseCase optimisticDecreaseItemUseCase; public ItemController(CreateItemUseCase createItemUseCase, UpdateItemUseCase updateItemUseCase, - DecreaseItemUseCase decreaseItemUseCase) { + @Qualifier("javaSyncDecreaseItemUseCase") DecreaseItemUseCase sync, + @Qualifier("dbDecreaseItemUseCase") DecreaseItemUseCase optimistic, + @Qualifier("redisDecreaseItemUseCase") DecreaseItemUseCase redis) { this.createItemUseCase = createItemUseCase; this.updateItemUseCase = updateItemUseCase; - this.decreaseItemUseCase = decreaseItemUseCase; + this.syncDecreaseItemUseCase = sync; + this.optimisticDecreaseItemUseCase = optimistic; + this.redisDecreaseItemUseCase = redis; } @PostMapping @@ -36,9 +43,19 @@ public ResponseEntity updateItem(@RequestBody UpdateItemCommand command) { return ResponseEntity.ok().body(updateItemUseCase.updateItem(command)); } - @PostMapping("/decrease") - public ResponseEntity decreaseItem(@RequestBody DecreaseItemCommand command) { - return ResponseEntity.ok().body(decreaseItemUseCase.decreaseItem(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/RedisLock.java b/src/main/java/com/example/locktest/adapter/out/lock/DecreaseWithRedisLockAdapter.java similarity index 63% rename from src/main/java/com/example/locktest/adapter/out/lock/RedisLock.java rename to src/main/java/com/example/locktest/adapter/out/lock/DecreaseWithRedisLockAdapter.java index 78c823e..c5e2d97 100644 --- a/src/main/java/com/example/locktest/adapter/out/lock/RedisLock.java +++ b/src/main/java/com/example/locktest/adapter/out/lock/DecreaseWithRedisLockAdapter.java @@ -3,16 +3,14 @@ import com.example.locktest.application.port.out.ItemLockPort; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; -import org.springframework.stereotype.Component; import java.util.concurrent.TimeUnit; -@Component -public class RedisLock implements ItemLockPort { +public class DecreaseWithRedisLockAdapter implements ItemLockPort { private final RedissonClient redissonClient; - public RedisLock(RedissonClient redissonClient) { + public DecreaseWithRedisLockAdapter(RedissonClient redissonClient) { this.redissonClient = redissonClient; } @@ -20,8 +18,10 @@ public RedisLock(RedissonClient redissonClient) { 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); - } catch(Exception e) { + System.out.println(Thread.currentThread().getName() + " - lock acquired: " + itemId); + } catch (Exception e) { throw new IllegalStateException("lock fail", e); } } @@ -31,6 +31,8 @@ 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/entity/ItemEntity.java b/src/main/java/com/example/locktest/adapter/out/persistence/entity/ItemEntity.java index 18a91d4..2af0344 100644 --- 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 @@ -23,6 +23,9 @@ public class ItemEntity { @Column(nullable = false) private Long amount; + @Version + private Long version; // OptimisticLock 을 위해 사용 + @Column(nullable = false) private LocalDateTime creatAt; diff --git a/src/main/java/com/example/locktest/application/service/DecreaseItemService.java b/src/main/java/com/example/locktest/application/service/DecreaseItemService.java index fffb920..06a12c5 100644 --- a/src/main/java/com/example/locktest/application/service/DecreaseItemService.java +++ b/src/main/java/com/example/locktest/application/service/DecreaseItemService.java @@ -5,6 +5,7 @@ import com.example.locktest.application.port.out.ItemLockPort; import com.example.locktest.application.port.out.ItemRepository; import com.example.locktest.domain.model.Item; +import org.springframework.transaction.annotation.Transactional; public class DecreaseItemService implements DecreaseItemUseCase { @@ -17,13 +18,14 @@ public DecreaseItemService(ItemRepository itemRepository, ItemLockPort itemLockP } @Override + @Transactional public Long decreaseItem(DecreaseItemCommand command) { itemLockPort.lock(command.id()); try { Item item = itemRepository.findById(command.id()) .orElseThrow(() -> new RuntimeException("해당 상품을 찾을 수 없습니다.")); - item.decrease(command.quantity()); - return itemRepository.save(item); + Item updateItem = item.decrease(command.quantity()); + return itemRepository.update(updateItem); } finally { itemLockPort.unlock(command.id()); } diff --git a/src/main/java/com/example/locktest/domain/model/Item.java b/src/main/java/com/example/locktest/domain/model/Item.java index 497df17..fc7565f 100644 --- a/src/main/java/com/example/locktest/domain/model/Item.java +++ b/src/main/java/com/example/locktest/domain/model/Item.java @@ -25,9 +25,10 @@ public void update(String name, Long amount) { this.amount = amount; } - public void decrease(Long quantity) { + 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; } diff --git a/src/main/java/com/example/locktest/infrastructure/config/BeenConfig.java b/src/main/java/com/example/locktest/infrastructure/config/BeenConfig.java index 939b275..bf9c5b2 100644 --- a/src/main/java/com/example/locktest/infrastructure/config/BeenConfig.java +++ b/src/main/java/com/example/locktest/infrastructure/config/BeenConfig.java @@ -3,12 +3,9 @@ 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.DecreaseItemUseCase; import com.example.locktest.application.port.in.UpdateItemUseCase; -import com.example.locktest.application.port.out.ItemLockPort; import com.example.locktest.application.port.out.ItemRepository; import com.example.locktest.application.service.CreateItemService; -import com.example.locktest.application.service.DecreaseItemService; import com.example.locktest.application.service.UpdateItemService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -16,6 +13,7 @@ @Configuration public class BeenConfig { + // CRUD service @Bean public ItemRepository itemRepository(SpringDataItemRepository springDataItemRepository) { return new JpaItemRepository(springDataItemRepository); @@ -30,9 +28,4 @@ public CreateItemUseCase createItemUseCase(ItemRepository itemRepository) { public UpdateItemUseCase updateItemUseCase(ItemRepository itemRepository) { return new UpdateItemService(itemRepository); } - - @Bean - public DecreaseItemUseCase decreaseItemUseCase(ItemRepository itemRepository, ItemLockPort lockPort) { - return new DecreaseItemService(itemRepository, lockPort); - } } 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 index 3e1dbea..7e116b7 100644 --- a/src/main/java/com/example/locktest/infrastructure/config/RedissonConfig.java +++ b/src/main/java/com/example/locktest/infrastructure/config/RedissonConfig.java @@ -12,7 +12,7 @@ public class RedissonConfig { @Bean public RedissonClient redisson() { Config config = new Config(); - config.useSingleServer().setAddress("redis://localhost:6379"); + config.useSingleServer().setAddress("redis://host.docker.internal:6379"); return Redisson.create(config); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4b94f00..a313ab8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,9 +12,9 @@ spring: redis: data: - host: localhost + host: host.docker.internal port: 6379 redisson: single-server-config: - address: redis://localhost:6379 \ No newline at end of file + 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..b39511c --- /dev/null +++ b/src/test/java/com/example/locktest/ItemConcurrencyTest.java @@ -0,0 +1,63 @@ +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", 1000L, LocalDateTime.now()); + itemRepository.save(item); + } + + @Test + void 동시에_100개의_요청을_보내면_정확히_100개_차감되어야_한다() 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 { + decreaseItemUseCase.decreaseItem(new DecreaseItemCommand(itemId, 1L)); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); // 모든 요청 대기 + + Item item = itemRepository.findById(itemId).orElseThrow(); + assertEquals(0L, item.getAmount()); // 100개가 정확히 줄었는지 확인 + } +} From 292ca0faa7b930dfd529f351caf363652c3ea9f0 Mon Sep 17 00:00:00 2001 From: ehgur062300 Date: Mon, 28 Jul 2025 21:21:46 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20redis=20=EB=B6=84=EC=82=B0?= =?UTF-8?q?=EB=9D=BD=20=EB=B0=A9=EC=8B=9D=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 불필요한 transaction 설정 제거 --- .../out/persistence/JpaItemRepository.java | 3 +-- .../service/DecreaseItemService.java | 2 -- .../example/locktest/ItemConcurrencyTest.java | 25 ++++++++++++++----- src/test/resources/application-test.yml | 20 +++++++++++++++ 4 files changed, 40 insertions(+), 10 deletions(-) create mode 100644 src/test/resources/application-test.yml 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 index 1479859..4601f31 100644 --- a/src/main/java/com/example/locktest/adapter/out/persistence/JpaItemRepository.java +++ b/src/main/java/com/example/locktest/adapter/out/persistence/JpaItemRepository.java @@ -35,8 +35,7 @@ public Long update(Item newItem) { () -> new RuntimeException("Item not found") ); entity.update(newItem); - ItemEntity saved = springDataItemRepository.save(entity); - return saved.getId(); + return entity.getId(); } @Transactional(readOnly = true) diff --git a/src/main/java/com/example/locktest/application/service/DecreaseItemService.java b/src/main/java/com/example/locktest/application/service/DecreaseItemService.java index 06a12c5..40fcf71 100644 --- a/src/main/java/com/example/locktest/application/service/DecreaseItemService.java +++ b/src/main/java/com/example/locktest/application/service/DecreaseItemService.java @@ -5,7 +5,6 @@ import com.example.locktest.application.port.out.ItemLockPort; import com.example.locktest.application.port.out.ItemRepository; import com.example.locktest.domain.model.Item; -import org.springframework.transaction.annotation.Transactional; public class DecreaseItemService implements DecreaseItemUseCase { @@ -18,7 +17,6 @@ public DecreaseItemService(ItemRepository itemRepository, ItemLockPort itemLockP } @Override - @Transactional public Long decreaseItem(DecreaseItemCommand command) { itemLockPort.lock(command.id()); try { diff --git a/src/test/java/com/example/locktest/ItemConcurrencyTest.java b/src/test/java/com/example/locktest/ItemConcurrencyTest.java index b39511c..daeb755 100644 --- a/src/test/java/com/example/locktest/ItemConcurrencyTest.java +++ b/src/test/java/com/example/locktest/ItemConcurrencyTest.java @@ -23,8 +23,8 @@ class ItemConcurrencyTest { @Autowired - @Qualifier("redisDecreaseItemUseCase") -// @Qualifier("dbDecreaseItemUseCase") +// @Qualifier("redisDecreaseItemUseCase") + @Qualifier("dbDecreaseItemUseCase") // @Qualifier("javaSyncDecreaseItemUseCase") private DecreaseItemUseCase decreaseItemUseCase; @@ -33,12 +33,12 @@ class ItemConcurrencyTest { @BeforeEach void setUp() { - Item item = new Item(null, "item1", 1000L, LocalDateTime.now()); + Item item = new Item(null, "item1", 100L, LocalDateTime.now()); itemRepository.save(item); } @Test - void 동시에_100개의_요청을_보내면_정확히_100개_차감되어야_한다() throws InterruptedException { + void 동시에_1000개의_요청을_보내면_정확히_1000개_차감되어야_한다() throws InterruptedException { int threadCount = 100; ExecutorService executor = Executors.newFixedThreadPool(32); CountDownLatch latch = new CountDownLatch(threadCount); @@ -48,7 +48,19 @@ void setUp() { for (int i = 0; i < threadCount; i++) { executor.submit(() -> { try { - decreaseItemUseCase.decreaseItem(new DecreaseItemCommand(itemId, 1L)); + // 실패 시 재시도 + 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(); } @@ -58,6 +70,7 @@ void setUp() { latch.await(); // 모든 요청 대기 Item item = itemRepository.findById(itemId).orElseThrow(); - assertEquals(0L, item.getAmount()); // 100개가 정확히 줄었는지 확인 + 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