From 13a758d8fd20d53edfb1757bc47bf55738515873 Mon Sep 17 00:00:00 2001 From: seokhwan-an Date: Wed, 14 May 2025 23:32:22 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat=20:=20demoday=20=EC=8B=A0=EC=B2=AD=20?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EB=AC=B8=EC=A0=9C=20=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demoday/service/DemoDayServiceTest.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/test/java/dayone/dayone/demoday/service/DemoDayServiceTest.java b/src/test/java/dayone/dayone/demoday/service/DemoDayServiceTest.java index d7a7632..8edf8b9 100644 --- a/src/test/java/dayone/dayone/demoday/service/DemoDayServiceTest.java +++ b/src/test/java/dayone/dayone/demoday/service/DemoDayServiceTest.java @@ -27,6 +27,9 @@ import java.time.LocalTime; import java.util.List; import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -150,5 +153,37 @@ void applyDemoDay() { softly.assertThat(byDemoDayIdAndUserId.isPresent()).isTrue(); }); } + + @DisplayName("티켓의 개수가 1개인 데모데이에 10명의 유저가 동시에 참여 요청을 하더라도 1명한 참여 가능하다.") + @Test + void applyDemoDayWithConcurrent() throws InterruptedException { + // given + final User DemoDayOwner = testUserFactory.createUser("test@test.com", "test", "test", 1); + final List users = testUserFactory.createNUser(10, "test2@test.com", "test2", "test2", 1); + + + final DemoDay demoDayOpen = testDemoDayFactory.createDemoDayOpen("title", "description", DemoDayOwner.getId()); + final Ticket tickets = testTicketFactory.createNTicket(demoDayOpen, 1); + + final ExecutorService executorService = Executors.newFixedThreadPool(users.size()); + final CountDownLatch countDownLatch = new CountDownLatch(users.size()); + + // when + for (final User user : users) { + executorService.submit(() -> { + demoDayService.applyDemoDay(user.getId(), demoDayOpen.getId()); + countDownLatch.countDown(); + }); + } + countDownLatch.await(); + + // then + final List result = demoDayUserRepository.findByDemoDayId(demoDayOpen.getId()); + final Ticket ticket = ticketRepository.findByDemoDayId(demoDayOpen.getId()).get(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).hasSize(tickets.getCapacity()); + softly.assertThat(ticket.getCapacity()).isEqualTo(0); + }); + } } } From 767b00b6d2e770ba24c51602be981aa7441cfcca Mon Sep 17 00:00:00 2001 From: seokhwan-an Date: Thu, 15 May 2025 15:59:00 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor=20:=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=EA=B0=80=20=ED=95=9C=EC=A0=95=EB=90=9C=20dem?= =?UTF-8?q?oday=20=EC=8B=A0=EC=B2=AD=20=EC=8B=9C=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/respository/TicketRepository.java | 8 ++++++++ .../dayone/demoday/service/DemoDayService.java | 2 +- .../demoday/service/DemoDayServiceTest.java | 18 +++++++++++++----- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/main/java/dayone/dayone/demoday/entity/respository/TicketRepository.java b/src/main/java/dayone/dayone/demoday/entity/respository/TicketRepository.java index bb2a141..14b6ec3 100644 --- a/src/main/java/dayone/dayone/demoday/entity/respository/TicketRepository.java +++ b/src/main/java/dayone/dayone/demoday/entity/respository/TicketRepository.java @@ -1,11 +1,19 @@ package dayone.dayone.demoday.entity.respository; import dayone.dayone.demoday.entity.Ticket; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; public interface TicketRepository extends JpaRepository { Optional findByDemoDayId(final Long demoDayId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT T FROM Ticket T WHERE T.demoDay.id = :demoDayId") + Optional findByDemoDayIdForUpdate(final @Param("demoDayId") Long demoDayId); } diff --git a/src/main/java/dayone/dayone/demoday/service/DemoDayService.java b/src/main/java/dayone/dayone/demoday/service/DemoDayService.java index e268eb1..afa57e0 100644 --- a/src/main/java/dayone/dayone/demoday/service/DemoDayService.java +++ b/src/main/java/dayone/dayone/demoday/service/DemoDayService.java @@ -61,7 +61,7 @@ public void applyDemoDay(final Long userId, final Long demoDayId) { .orElseThrow(() -> new UserException(UserErrorCode.NOT_EXIST_USER)); final DemoDay demoDay = demoDayRepository.findById(demoDayId) .orElseThrow(() -> new UserException(DemoDayErrorCode.NOT_EXIST_DEMO_DAY)); - final Ticket ticket = ticketRepository.findByDemoDayId(demoDayId) + final Ticket ticket = ticketRepository.findByDemoDayIdForUpdate(demoDayId) .orElseThrow(() -> new UserException(DemoDayErrorCode.NOT_EXIST_DEMO_DAY)); final List demoDayUsers = demoDayUserRepository.findByDemoDayId(demoDayId); diff --git a/src/test/java/dayone/dayone/demoday/service/DemoDayServiceTest.java b/src/test/java/dayone/dayone/demoday/service/DemoDayServiceTest.java index 8edf8b9..c2dbfb0 100644 --- a/src/test/java/dayone/dayone/demoday/service/DemoDayServiceTest.java +++ b/src/test/java/dayone/dayone/demoday/service/DemoDayServiceTest.java @@ -30,6 +30,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -169,20 +170,27 @@ void applyDemoDayWithConcurrent() throws InterruptedException { final CountDownLatch countDownLatch = new CountDownLatch(users.size()); // when + final AtomicInteger errorCount = new AtomicInteger(0); for (final User user : users) { executorService.submit(() -> { - demoDayService.applyDemoDay(user.getId(), demoDayOpen.getId()); - countDownLatch.countDown(); + try { + demoDayService.applyDemoDay(user.getId(), demoDayOpen.getId()); + } catch (Exception ignored) { + errorCount.incrementAndGet(); + } finally { + countDownLatch.countDown(); + } }); } + countDownLatch.await(); - // then - final List result = demoDayUserRepository.findByDemoDayId(demoDayOpen.getId()); + final List demoDayUsers = demoDayUserRepository.findByDemoDayId(demoDayOpen.getId()); final Ticket ticket = ticketRepository.findByDemoDayId(demoDayOpen.getId()).get(); SoftAssertions.assertSoftly(softly -> { - softly.assertThat(result).hasSize(tickets.getCapacity()); + softly.assertThat(demoDayUsers).hasSize(tickets.getCapacity()); softly.assertThat(ticket.getCapacity()).isEqualTo(0); + softly.assertThat(errorCount.get()).isEqualTo(users.size() - 1); }); } } From ad0fbea2cb3df90cfee0d5571e92d4e92fff5fdc Mon Sep 17 00:00:00 2001 From: seokhwan-an Date: Thu, 15 May 2025 16:00:08 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat=20:=20=ED=95=9C=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=EA=B0=80=20demoday=EC=97=90=20=EC=97=AC=EB=9F=AC=EB=B2=88=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EC=8B=9C=20=EB=B0=9C=EC=83=9D=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demoday/service/DemoDayServiceTest.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/test/java/dayone/dayone/demoday/service/DemoDayServiceTest.java b/src/test/java/dayone/dayone/demoday/service/DemoDayServiceTest.java index c2dbfb0..0ee2084 100644 --- a/src/test/java/dayone/dayone/demoday/service/DemoDayServiceTest.java +++ b/src/test/java/dayone/dayone/demoday/service/DemoDayServiceTest.java @@ -193,5 +193,44 @@ void applyDemoDayWithConcurrent() throws InterruptedException { softly.assertThat(errorCount.get()).isEqualTo(users.size() - 1); }); } + + @DisplayName("같은 유저가 빠르게 2번 데모데이에 신청하더라도 1번만 신청 처리된다.") + @Test + void applyDemoDayWithConcurrentSameUser() throws InterruptedException { + // given + final User DemoDayOwner = testUserFactory.createUser("test@test.com", "test", "test", 1); + final DemoDay demoDayOpen = testDemoDayFactory.createDemoDayOpen("title", "description", DemoDayOwner.getId()); + testTicketFactory.createNTicket(demoDayOpen, 2); + + final User demodayUser = testUserFactory.createUser("test2@test.com", "test2", "test2", 1); + + final int threadCount = 2; + final ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + final CountDownLatch countDownLatch = new CountDownLatch(threadCount); + + // when + final AtomicInteger errorCount = new AtomicInteger(0); + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + demoDayService.applyDemoDay(demodayUser.getId(), demoDayOpen.getId()); + } catch (Exception ignored) { + errorCount.incrementAndGet(); + } finally { + countDownLatch.countDown(); + } + }); + } + countDownLatch.await(); + + // then + final List demoDayUsers = demoDayUserRepository.findByDemoDayId(demoDayOpen.getId()); + final Ticket ticket = ticketRepository.findByDemoDayId(demoDayOpen.getId()).get(); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(demoDayUsers).hasSize(1); + softly.assertThat(ticket.getCapacity()).isEqualTo(1); + softly.assertThat(errorCount.get()).isEqualTo(threadCount - 1); + }); + } } } From 24fe54f53ccded89a93a59f98fdbd0db49071d09 Mon Sep 17 00:00:00 2001 From: seokhwan-an Date: Thu, 15 May 2025 16:00:36 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor=20:=20=ED=95=9C=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=EA=B0=80=20demoday=EC=97=90=20=EC=97=AC=EB=9F=AC?= =?UTF-8?q?=EB=B2=88=20=EC=8B=A0=EC=B2=AD=20=EC=8B=9C=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/respository/DemoDayUserRepository.java | 9 +++++++++ .../dayone/dayone/demoday/service/DemoDayService.java | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/dayone/dayone/demoday/entity/respository/DemoDayUserRepository.java b/src/main/java/dayone/dayone/demoday/entity/respository/DemoDayUserRepository.java index 02a8da9..36e776f 100644 --- a/src/main/java/dayone/dayone/demoday/entity/respository/DemoDayUserRepository.java +++ b/src/main/java/dayone/dayone/demoday/entity/respository/DemoDayUserRepository.java @@ -1,13 +1,22 @@ package dayone.dayone.demoday.entity.respository; import dayone.dayone.demoday.entity.DemoDayUser; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; public interface DemoDayUserRepository extends JpaRepository { + Optional findByDemoDayIdAndUserId(final Long demoDayId, final Long userId); List findByDemoDayId(final Long demoDayId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT D FROM DemoDayUser D WHERE D.demoDayId = :demoDayId") + List findByDemoDayIdForUpdate(final @Param("demoDayId") Long demoDayId); } diff --git a/src/main/java/dayone/dayone/demoday/service/DemoDayService.java b/src/main/java/dayone/dayone/demoday/service/DemoDayService.java index afa57e0..6b3b939 100644 --- a/src/main/java/dayone/dayone/demoday/service/DemoDayService.java +++ b/src/main/java/dayone/dayone/demoday/service/DemoDayService.java @@ -63,7 +63,7 @@ public void applyDemoDay(final Long userId, final Long demoDayId) { .orElseThrow(() -> new UserException(DemoDayErrorCode.NOT_EXIST_DEMO_DAY)); final Ticket ticket = ticketRepository.findByDemoDayIdForUpdate(demoDayId) .orElseThrow(() -> new UserException(DemoDayErrorCode.NOT_EXIST_DEMO_DAY)); - final List demoDayUsers = demoDayUserRepository.findByDemoDayId(demoDayId); + final List demoDayUsers = demoDayUserRepository.findByDemoDayIdForUpdate(demoDayId); final DemoDayUser apply = demoDay.apply(user, ticket, new DemoDayUsers(demoDayUsers)); demoDayUserRepository.save(apply);