From 2bb1804947b5dcfd237a10036269de5032619292 Mon Sep 17 00:00:00 2001 From: SCY Date: Fri, 13 Dec 2024 21:03:16 +0900 Subject: [PATCH 1/9] =?UTF-8?q?test:=20=EB=8F=99=EC=8B=9C=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20=EC=8B=9C=20=EC=A4=91=EB=B3=B5=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=20=EA=B0=80=EB=8A=A5=ED=95=9C=20=EC=83=81=ED=99=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/OfferingMemberServiceTest.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceTest.java diff --git a/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceTest.java b/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceTest.java new file mode 100644 index 000000000..1d7f26f9e --- /dev/null +++ b/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceTest.java @@ -0,0 +1,53 @@ +package com.zzang.chongdae.offeringmember.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.zzang.chongdae.global.service.ServiceTest; +import com.zzang.chongdae.member.repository.entity.MemberEntity; +import com.zzang.chongdae.offering.repository.entity.OfferingEntity; +import com.zzang.chongdae.offeringmember.service.dto.ParticipantResponse; +import com.zzang.chongdae.offeringmember.service.dto.ParticipationRequest; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class OfferingMemberServiceTest extends ServiceTest { + + @Autowired + private OfferingMemberService offeringMemberService; + + @DisplayName("동시에 참여할 경우 중복 참여가 가능하다.") + @Test + void should_participateInDuplicate() throws InterruptedException { + // given + MemberEntity proposer = memberFixture.createMember("ever"); + OfferingEntity offering = offeringFixture.createOffering(proposer); + offeringMemberFixture.createProposer(proposer, offering); + + // when + ParticipationRequest request = new ParticipationRequest(offering.getId()); + MemberEntity participant = memberFixture.createMember("whoever"); + + ExecutorService executorService = Executors.newFixedThreadPool(2); + + int executeCount = 2; + CountDownLatch countDownLatch = new CountDownLatch(executeCount); + + for (int i = 0; i < executeCount; i++) { + executorService.execute(() -> { + offeringMemberService.participate(request, participant); + countDownLatch.countDown(); + }); + } + + countDownLatch.await(); + executorService.shutdown(); + + // then + ParticipantResponse response = offeringMemberService.getAllParticipant(offering.getId(), proposer); + assertThat(response.participants()).hasSize(executeCount); + } +} From d6a018a4bf24e6c4a428fe46b26baebaf1528c29 Mon Sep 17 00:00:00 2001 From: SCY Date: Fri, 13 Dec 2024 21:03:31 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20=EA=B3=B5=EB=AA=A8=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../offering/repository/entity/OfferingEntity.java | 4 ++++ .../offeringmember/service/OfferingMemberService.java | 11 +++-------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java b/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java index d3d9cf802..2009912ab 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java @@ -120,10 +120,14 @@ public OfferingEntity(MemberEntity member, String title, String description, Str public void participate() { currentCount++; + OfferingStatus offeringStatus = toOfferingJoinedCount().decideOfferingStatus(); + updateOfferingStatus(offeringStatus); } public void leave() { currentCount--; + OfferingStatus offeringStatus = toOfferingJoinedCount().decideOfferingStatus(); + updateOfferingStatus(offeringStatus); } public CommentRoomStatus moveCommentRoomStatus() { diff --git a/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java b/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java index b616b0ef8..bc71d12ba 100644 --- a/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java +++ b/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java @@ -6,7 +6,6 @@ import com.zzang.chongdae.global.exception.MarketException; import com.zzang.chongdae.member.repository.entity.MemberEntity; import com.zzang.chongdae.offering.domain.CommentRoomStatus; -import com.zzang.chongdae.offering.domain.OfferingStatus; import com.zzang.chongdae.offering.exception.OfferingErrorCode; import com.zzang.chongdae.offering.repository.OfferingRepository; import com.zzang.chongdae.offering.repository.entity.OfferingEntity; @@ -44,13 +43,10 @@ public Long participate(ParticipationRequest request, MemberEntity member) { OfferingMemberEntity offeringMember = new OfferingMemberEntity( member, offering, OfferingMemberRole.PARTICIPANT); OfferingMemberEntity saved = offeringMemberRepository.save(offeringMember); - offering.participate(); - OfferingStatus offeringStatus = offering.toOfferingJoinedCount().decideOfferingStatus(); - offering.updateOfferingStatus(offeringStatus); eventPublisher.publishEvent(new ParticipateEvent(this, saved)); - return offeringMember.getId(); + return saved.getId(); } private void validateParticipate(OfferingEntity offering, MemberEntity member) { @@ -78,11 +74,10 @@ public void cancelParticipate(Long offeringId, MemberEntity member) { OfferingMemberEntity offeringMember = offeringMemberRepository.findByOfferingAndMember(offering, member) .orElseThrow(() -> new MarketException(OfferingMemberErrorCode.PARTICIPANT_NOT_FOUND)); validateCancel(offeringMember); - offeringMemberRepository.delete(offeringMember); + offeringMemberRepository.delete(offeringMember); offering.leave(); - OfferingStatus offeringStatus = offering.toOfferingJoinedCount().decideOfferingStatus(); - offering.updateOfferingStatus(offeringStatus); + eventPublisher.publishEvent(new CancelParticipateEvent(this, offeringMember)); } From 112ff0a4fcfb42685b0494d8b189425ba7cbf07e Mon Sep 17 00:00:00 2001 From: SCY Date: Fri, 13 Dec 2024 21:12:32 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=BF=BC=EB=A6=AC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chongdae/offering/repository/OfferingRepository.java | 9 --------- 1 file changed, 9 deletions(-) diff --git a/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java b/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java index c6c3d4063..bc82480a2 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java @@ -1,6 +1,5 @@ package com.zzang.chongdae.offering.repository; -import com.zzang.chongdae.member.repository.entity.MemberEntity; import com.zzang.chongdae.offering.repository.entity.OfferingEntity; import java.time.LocalDateTime; import java.util.List; @@ -18,14 +17,6 @@ public interface OfferingRepository extends JpaRepository """, nativeQuery = true) Optional findByIdWithDeleted(Long offeringId); - @Query(""" - SELECT o - FROM OfferingEntity as o JOIN OfferingMemberEntity as om - ON o.id = om.offering.id - WHERE om.member = :member - """) - List findCommentRoomsByMember(MemberEntity member); - @Query(""" SELECT o FROM OfferingEntity o From 12463de1d19c25bf8595e8c149c6facc3bb4a363 Mon Sep 17 00:00:00 2001 From: SCY Date: Fri, 13 Dec 2024 22:50:36 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20Offering=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EB=B9=84=EA=B4=80=EC=A0=81=20=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=EB=9D=BD=EC=9D=84=20=ED=86=B5=ED=95=B4=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/OfferingRepository.java | 6 +++ .../service/OfferingMemberService.java | 2 +- .../service/OfferingMemberServiceTest.java | 41 ++++++++++++++++++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java b/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java index bc82480a2..1e3748f17 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java @@ -1,11 +1,13 @@ package com.zzang.chongdae.offering.repository; import com.zzang.chongdae.offering.repository.entity.OfferingEntity; +import jakarta.persistence.LockModeType; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; public interface OfferingRepository extends JpaRepository { @@ -127,4 +129,8 @@ List findHighDiscountOfferingsWithMeetingAddressKeyword( AND (o.offeringStatus IN ('AVAILABLE', 'FULL', 'IMMINENT')) """) List findByMeetingDateAndOfferingStatusNotConfirmed(LocalDateTime meetingDate); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT o FROM OfferingEntity o WHERE o.id = :id") + Optional findByIdWithLock(Long id); } diff --git a/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java b/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java index bc71d12ba..2d2a6778d 100644 --- a/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java +++ b/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java @@ -36,7 +36,7 @@ public class OfferingMemberService { @WriterDatabase @Transactional public Long participate(ParticipationRequest request, MemberEntity member) { - OfferingEntity offering = offeringRepository.findById(request.offeringId()) + OfferingEntity offering = offeringRepository.findByIdWithLock(request.offeringId()) .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND)); validateParticipate(offering, member); diff --git a/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceTest.java b/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceTest.java index 1d7f26f9e..46aa471b9 100644 --- a/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceTest.java +++ b/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceTest.java @@ -10,6 +10,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -19,7 +20,8 @@ class OfferingMemberServiceTest extends ServiceTest { @Autowired private OfferingMemberService offeringMemberService; - @DisplayName("동시에 참여할 경우 중복 참여가 가능하다.") + @Disabled + @DisplayName("기존 - 동시에 참여할 경우 중복 참여가 가능하다.") @Test void should_participateInDuplicate() throws InterruptedException { // given @@ -37,7 +39,7 @@ void should_participateInDuplicate() throws InterruptedException { CountDownLatch countDownLatch = new CountDownLatch(executeCount); for (int i = 0; i < executeCount; i++) { - executorService.execute(() -> { + executorService.submit(() -> { offeringMemberService.participate(request, participant); countDownLatch.countDown(); }); @@ -50,4 +52,39 @@ void should_participateInDuplicate() throws InterruptedException { ParticipantResponse response = offeringMemberService.getAllParticipant(offering.getId(), proposer); assertThat(response.participants()).hasSize(executeCount); } + + @DisplayName("개선 - 동시에 참여할 경우 중복 참여가 불가능하다.") + @Test + void should_fail_participateInDuplicate() throws InterruptedException { + // given + MemberEntity proposer = memberFixture.createMember("ever"); + OfferingEntity offering = offeringFixture.createOffering(proposer); + offeringMemberFixture.createProposer(proposer, offering); + + // when + ParticipationRequest request = new ParticipationRequest(offering.getId()); + MemberEntity participant = memberFixture.createMember("whoever"); + + ExecutorService executorService = Executors.newFixedThreadPool(2); + + int executeCount = 100; + CountDownLatch countDownLatch = new CountDownLatch(executeCount); + + for (int i = 0; i < executeCount; i++) { + executorService.submit(() -> { + try { + offeringMemberService.participate(request, participant); + } finally { + countDownLatch.countDown(); + } + }); + } + + countDownLatch.await(); + executorService.shutdown(); + + // then + ParticipantResponse response = offeringMemberService.getAllParticipant(offering.getId(), proposer); + assertThat(response.participants()).hasSize(1); + } } From b10b56a511c051621d165962194bc204148f6b61 Mon Sep 17 00:00:00 2001 From: SCY Date: Fri, 13 Dec 2024 23:18:20 +0900 Subject: [PATCH 5/9] =?UTF-8?q?test:=20=EA=B0=99=EC=9D=80=20=EA=B3=B5?= =?UTF-8?q?=EB=AA=A8=20=EB=8B=A4=EB=A5=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EC=9D=98=20=EB=8F=99=EC=8B=9C=20=EC=B0=B8=EC=97=AC=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=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 --- .../global/domain/OfferingFixture.java | 19 ++++--- ...OfferingMemberServiceConcurrencyTest.java} | 52 +++++++++++++++++-- 2 files changed, 59 insertions(+), 12 deletions(-) rename backend/src/test/java/com/zzang/chongdae/offeringmember/service/{OfferingMemberServiceTest.java => OfferingMemberServiceConcurrencyTest.java} (61%) diff --git a/backend/src/test/java/com/zzang/chongdae/global/domain/OfferingFixture.java b/backend/src/test/java/com/zzang/chongdae/global/domain/OfferingFixture.java index e59872c5b..bebdcb71a 100644 --- a/backend/src/test/java/com/zzang/chongdae/global/domain/OfferingFixture.java +++ b/backend/src/test/java/com/zzang/chongdae/global/domain/OfferingFixture.java @@ -17,6 +17,7 @@ public class OfferingFixture { private OfferingEntity createOffering(MemberEntity member, String title, + Integer totalCount, Double discountRate, OfferingStatus offeringStatus, CommentRoomStatus commentRoomStatus) { @@ -30,10 +31,10 @@ private OfferingEntity createOffering(MemberEntity member, "meetingAddress", "meetingAddressDetail", "meetingAddressDong", - 5, + totalCount, 1, 5000, - 1000, + 10_000, discountRate, offeringStatus, commentRoomStatus @@ -42,23 +43,27 @@ private OfferingEntity createOffering(MemberEntity member, } public OfferingEntity createOffering(MemberEntity member, Double discountRate) { - return createOffering(member, "title", discountRate, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING); + return createOffering(member, "title", 5, discountRate, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING); } public OfferingEntity createOffering(MemberEntity member, CommentRoomStatus commentRoomStatus) { - return createOffering(member, "title", 33.3, OfferingStatus.AVAILABLE, commentRoomStatus); + return createOffering(member, "title", 5, 33.3, OfferingStatus.AVAILABLE, commentRoomStatus); } public OfferingEntity createOffering(MemberEntity member, OfferingStatus offeringStatus) { - return createOffering(member, "title", 33.3, offeringStatus, CommentRoomStatus.GROUPING); + return createOffering(member, "title", 5, 33.3, offeringStatus, CommentRoomStatus.GROUPING); } public OfferingEntity createOffering(MemberEntity member) { - return createOffering(member, "title", 33.3, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING); + return createOffering(member, "title", 5, 33.3, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING); } public OfferingEntity createOffering(MemberEntity member, String title) { - return createOffering(member, title, 33.3, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING); + return createOffering(member, title, 5, 33.3, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING); + } + + public OfferingEntity createOffering(MemberEntity member, Integer totalCount) { + return createOffering(member, "title", totalCount, 33.3, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING); } public void deleteOffering(OfferingEntity offering) { diff --git a/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceTest.java b/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceConcurrencyTest.java similarity index 61% rename from backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceTest.java rename to backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceConcurrencyTest.java index 46aa471b9..0f16ca1cb 100644 --- a/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceTest.java +++ b/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceConcurrencyTest.java @@ -15,7 +15,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -class OfferingMemberServiceTest extends ServiceTest { +class OfferingMemberServiceConcurrencyTest extends ServiceTest { @Autowired private OfferingMemberService offeringMemberService; @@ -53,9 +53,9 @@ void should_participateInDuplicate() throws InterruptedException { assertThat(response.participants()).hasSize(executeCount); } - @DisplayName("개선 - 동시에 참여할 경우 중복 참여가 불가능하다.") + @DisplayName("개선 - 같은 사용자가 같은 공모에 동시에 참여할 경우 중복 참여가 불가능하다.") @Test - void should_fail_participateInDuplicate() throws InterruptedException { + void should_failParticipate_when_givenSameMemberAndSameOffering() throws InterruptedException { // given MemberEntity proposer = memberFixture.createMember("ever"); OfferingEntity offering = offeringFixture.createOffering(proposer); @@ -67,11 +67,11 @@ void should_fail_participateInDuplicate() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(2); - int executeCount = 100; + int executeCount = 5; CountDownLatch countDownLatch = new CountDownLatch(executeCount); for (int i = 0; i < executeCount; i++) { - executorService.submit(() -> { + executorService.execute(() -> { try { offeringMemberService.participate(request, participant); } finally { @@ -87,4 +87,46 @@ void should_fail_participateInDuplicate() throws InterruptedException { ParticipantResponse response = offeringMemberService.getAllParticipant(offering.getId(), proposer); assertThat(response.participants()).hasSize(1); } + + @DisplayName("개선 - 다른 사용자가 같은 공모에 동시에 참여할 경우 중복 참여가 불가능하다.") + @Test + void should_failParticipate_when_givenDifferentMemberAndSameOffering() throws InterruptedException { + // given + MemberEntity proposer = memberFixture.createMember("ever"); + OfferingEntity offering = offeringFixture.createOffering(proposer, 2); + offeringMemberFixture.createProposer(proposer, offering); + + // when + ParticipationRequest request = new ParticipationRequest(offering.getId()); + MemberEntity participant1 = memberFixture.createMember("ever1"); + MemberEntity participant2 = memberFixture.createMember("ever2"); + + ExecutorService executorService = Executors.newFixedThreadPool(2); + + int executeCount = 2; + CountDownLatch countDownLatch = new CountDownLatch(executeCount); + + executorService.execute(() -> { + try { + offeringMemberService.participate(request, participant1); + } finally { + countDownLatch.countDown(); + } + }); + + executorService.execute(() -> { + try { + offeringMemberService.participate(request, participant2); + } finally { + countDownLatch.countDown(); + } + }); + + countDownLatch.await(); + executorService.shutdown(); + + // then + ParticipantResponse response = offeringMemberService.getAllParticipant(offering.getId(), proposer); + assertThat(response.participants()).hasSize(1); + } } From 7d814d080305728504276d99b9eb4cb9d1e0f751 Mon Sep 17 00:00:00 2001 From: SCY Date: Sun, 15 Dec 2024 18:29:02 +0900 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20=EB=B9=84=EA=B4=80=EC=A0=81=20?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=EB=9D=BD=20->=20=EB=82=99=EA=B4=80?= =?UTF-8?q?=EC=A0=81=20=EB=9D=BD=EC=9D=84=20=ED=86=B5=ED=95=B4=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=B0=B8=EC=97=AC=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chongdae/offering/repository/OfferingRepository.java | 6 ------ .../chongdae/offering/repository/entity/OfferingEntity.java | 6 +++++- .../offeringmember/service/OfferingMemberService.java | 2 +- .../service/OfferingMemberServiceConcurrencyTest.java | 2 +- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java b/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java index 1e3748f17..bc82480a2 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java @@ -1,13 +1,11 @@ package com.zzang.chongdae.offering.repository; import com.zzang.chongdae.offering.repository.entity.OfferingEntity; -import jakarta.persistence.LockModeType; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; public interface OfferingRepository extends JpaRepository { @@ -129,8 +127,4 @@ List findHighDiscountOfferingsWithMeetingAddressKeyword( AND (o.offeringStatus IN ('AVAILABLE', 'FULL', 'IMMINENT')) """) List findByMeetingDateAndOfferingStatusNotConfirmed(LocalDateTime meetingDate); - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT o FROM OfferingEntity o WHERE o.id = :id") - Optional findByIdWithLock(Long id); } diff --git a/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java b/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java index 2009912ab..68314d83f 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java @@ -18,6 +18,7 @@ import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.Version; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; @@ -107,6 +108,9 @@ public class OfferingEntity extends BaseTimeEntity { @ColumnDefault("false") private Boolean isDeleted; + @Version + private Long version; + public OfferingEntity(MemberEntity member, String title, String description, String thumbnailUrl, String productUrl, LocalDateTime meetingDate, String meetingAddress, String meetingAddressDetail, String meetingAddressDong, @@ -115,7 +119,7 @@ public OfferingEntity(MemberEntity member, String title, String description, Str OfferingStatus offeringStatus, CommentRoomStatus roomStatus) { this(null, member, title, description, thumbnailUrl, productUrl, meetingDate, meetingAddress, meetingAddressDetail, meetingAddressDong, totalCount, currentCount, totalPrice, - originPrice, discountRate, offeringStatus, roomStatus, false); + originPrice, discountRate, offeringStatus, roomStatus, false, 1L); } public void participate() { diff --git a/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java b/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java index 2d2a6778d..bc71d12ba 100644 --- a/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java +++ b/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java @@ -36,7 +36,7 @@ public class OfferingMemberService { @WriterDatabase @Transactional public Long participate(ParticipationRequest request, MemberEntity member) { - OfferingEntity offering = offeringRepository.findByIdWithLock(request.offeringId()) + OfferingEntity offering = offeringRepository.findById(request.offeringId()) .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND)); validateParticipate(offering, member); diff --git a/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceConcurrencyTest.java b/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceConcurrencyTest.java index 0f16ca1cb..9798ab9db 100644 --- a/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceConcurrencyTest.java +++ b/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceConcurrencyTest.java @@ -65,7 +65,7 @@ void should_failParticipate_when_givenSameMemberAndSameOffering() throws Interru ParticipationRequest request = new ParticipationRequest(offering.getId()); MemberEntity participant = memberFixture.createMember("whoever"); - ExecutorService executorService = Executors.newFixedThreadPool(2); + ExecutorService executorService = Executors.newFixedThreadPool(5); int executeCount = 5; CountDownLatch countDownLatch = new CountDownLatch(executeCount); From 23db7a5e91cf094fbc41da8da29ad6e6b8e01bf0 Mon Sep 17 00:00:00 2001 From: SCY Date: Sun, 15 Dec 2024 18:47:45 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20version=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20soft=20delet?= =?UTF-8?q?e=20=EC=BF=BC=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chongdae/offering/repository/entity/OfferingEntity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java b/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java index 68314d83f..4d9032cd8 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java @@ -36,7 +36,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @EqualsAndHashCode(of = "id", callSuper = false) -@SQLDelete(sql = "UPDATE offering SET is_deleted = true WHERE id = ?") +@SQLDelete(sql = "UPDATE offering SET is_deleted = true, version = version + 1 WHERE id = ? and version = ?") @SQLRestriction("is_deleted = false") @Table(name = "offering") @Entity From d75e57422347f43e47f003b19d0528c49da442f0 Mon Sep 17 00:00:00 2001 From: SCY Date: Sun, 15 Dec 2024 18:48:35 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20offeringMember=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=8B=9C=20offering=EC=9D=98=20=EC=8B=A4=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zzang/chongdae/offering/service/OfferingService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java b/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java index e10d96dc7..9a8fded30 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java @@ -132,13 +132,13 @@ private void validateIsProposer(OfferingEntity offering, MemberEntity member) { public Long saveOffering(OfferingSaveRequest request, MemberEntity member) { OfferingEntity offering = request.toEntity(member); validateMeetingDate(offering.getMeetingDate()); - OfferingEntity savedOffering = offeringRepository.save(offering); + OfferingEntity saved = offeringRepository.save(offering); - OfferingMemberEntity offeringMember = new OfferingMemberEntity(member, offering, OfferingMemberRole.PROPOSER); + OfferingMemberEntity offeringMember = new OfferingMemberEntity(member, saved, OfferingMemberRole.PROPOSER); offeringMemberRepository.save(offeringMember); - eventPublisher.publishEvent(new SaveOfferingEvent(this, savedOffering)); - return savedOffering.getId(); + eventPublisher.publishEvent(new SaveOfferingEvent(this, saved)); + return saved.getId(); } private void validateMeetingDate(LocalDateTime offeringMeetingDateTime) { From 1a4c2c5b680cd617f8013986efa4a0eca7d307c5 Mon Sep 17 00:00:00 2001 From: SCY Date: Sun, 15 Dec 2024 23:51:52 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20=EB=82=99=EA=B4=80=EC=A0=81=20?= =?UTF-8?q?=EB=9D=BD=20->=20=EB=B9=84=EA=B4=80=EC=A0=81=20=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=EB=9D=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/OfferingRepository.java | 6 ++ .../repository/entity/OfferingEntity.java | 8 +- .../service/OfferingMemberService.java | 2 +- .../OfferingMemberIntegrationTest.java | 90 +++++++++++++++++++ .../OfferingMemberServiceConcurrencyTest.java | 51 ++--------- 5 files changed, 106 insertions(+), 51 deletions(-) diff --git a/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java b/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java index bc82480a2..1e3748f17 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java @@ -1,11 +1,13 @@ package com.zzang.chongdae.offering.repository; import com.zzang.chongdae.offering.repository.entity.OfferingEntity; +import jakarta.persistence.LockModeType; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; public interface OfferingRepository extends JpaRepository { @@ -127,4 +129,8 @@ List findHighDiscountOfferingsWithMeetingAddressKeyword( AND (o.offeringStatus IN ('AVAILABLE', 'FULL', 'IMMINENT')) """) List findByMeetingDateAndOfferingStatusNotConfirmed(LocalDateTime meetingDate); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT o FROM OfferingEntity o WHERE o.id = :id") + Optional findByIdWithLock(Long id); } diff --git a/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java b/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java index 4d9032cd8..2009912ab 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java @@ -18,7 +18,6 @@ import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import jakarta.persistence.Version; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; @@ -36,7 +35,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @EqualsAndHashCode(of = "id", callSuper = false) -@SQLDelete(sql = "UPDATE offering SET is_deleted = true, version = version + 1 WHERE id = ? and version = ?") +@SQLDelete(sql = "UPDATE offering SET is_deleted = true WHERE id = ?") @SQLRestriction("is_deleted = false") @Table(name = "offering") @Entity @@ -108,9 +107,6 @@ public class OfferingEntity extends BaseTimeEntity { @ColumnDefault("false") private Boolean isDeleted; - @Version - private Long version; - public OfferingEntity(MemberEntity member, String title, String description, String thumbnailUrl, String productUrl, LocalDateTime meetingDate, String meetingAddress, String meetingAddressDetail, String meetingAddressDong, @@ -119,7 +115,7 @@ public OfferingEntity(MemberEntity member, String title, String description, Str OfferingStatus offeringStatus, CommentRoomStatus roomStatus) { this(null, member, title, description, thumbnailUrl, productUrl, meetingDate, meetingAddress, meetingAddressDetail, meetingAddressDong, totalCount, currentCount, totalPrice, - originPrice, discountRate, offeringStatus, roomStatus, false, 1L); + originPrice, discountRate, offeringStatus, roomStatus, false); } public void participate() { diff --git a/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java b/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java index bc71d12ba..2d2a6778d 100644 --- a/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java +++ b/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java @@ -36,7 +36,7 @@ public class OfferingMemberService { @WriterDatabase @Transactional public Long participate(ParticipationRequest request, MemberEntity member) { - OfferingEntity offering = offeringRepository.findById(request.offeringId()) + OfferingEntity offering = offeringRepository.findByIdWithLock(request.offeringId()) .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND)); validateParticipate(offering, member); diff --git a/backend/src/test/java/com/zzang/chongdae/offeringmember/integration/OfferingMemberIntegrationTest.java b/backend/src/test/java/com/zzang/chongdae/offeringmember/integration/OfferingMemberIntegrationTest.java index cf88123e3..9cc074a17 100644 --- a/backend/src/test/java/com/zzang/chongdae/offeringmember/integration/OfferingMemberIntegrationTest.java +++ b/backend/src/test/java/com/zzang/chongdae/offeringmember/integration/OfferingMemberIntegrationTest.java @@ -4,6 +4,7 @@ import static com.epages.restdocs.apispec.ResourceDocumentation.resource; import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.document; import static com.epages.restdocs.apispec.Schema.schema; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import com.epages.restdocs.apispec.ParameterDescriptorWithType; @@ -15,7 +16,11 @@ import com.zzang.chongdae.offeringmember.service.dto.ParticipationRequest; import io.restassured.RestAssured; import io.restassured.http.ContentType; +import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -120,6 +125,91 @@ void should_throwException_when_emptyValue() { .then().log().all() .statusCode(400); } + + @DisplayName("같은 사용자가 같은 공모에 동시에 참여할 경우 예외가 발생한다.") + @Test + void should_throwException_when_sameMemberAndSameOffering() throws InterruptedException { + ParticipationRequest request = new ParticipationRequest( + offering.getId() + ); + + int executeCount = 5; + ExecutorService executorService = Executors.newFixedThreadPool(executeCount); + CountDownLatch countDownLatch = new CountDownLatch(executeCount); + + List statusCodes = new ArrayList<>(); + for (int i = 0; i < executeCount; i++) { + executorService.submit(() -> { + try { + int statusCode = RestAssured.given().log().all() + .cookies(cookieProvider.createCookiesWithMember(participant)) + .contentType(ContentType.JSON) + .body(request) + .when().post("/participations") + .statusCode(); + statusCodes.add(statusCode); + } finally { + countDownLatch.countDown(); + } + }); + } + + countDownLatch.await(); + executorService.shutdown(); + + assertThat(statusCodes).containsExactlyInAnyOrder(201, 400, 400, 400, 400); + } + + @DisplayName("다른 사용자가 같은 공모에 동시에 참여할 경우 예외가 발생한다.") + @Test + void should_throwException_when_differentMemberAndSameOffering() throws InterruptedException { + MemberEntity proposer = memberFixture.createMember("ever"); + OfferingEntity offering = offeringFixture.createOffering(proposer, 2); + offeringMemberFixture.createProposer(proposer, offering); + + ParticipationRequest request = new ParticipationRequest( + offering.getId() + ); + MemberEntity participant1 = memberFixture.createMember("ever1"); + MemberEntity participant2 = memberFixture.createMember("ever2"); + + int executeCount = 2; + ExecutorService executorService = Executors.newFixedThreadPool(executeCount); + CountDownLatch countDownLatch = new CountDownLatch(executeCount); + + List statusCodes = new ArrayList<>(); + executorService.submit(() -> { + try { + int statusCode = RestAssured.given().log().all() + .cookies(cookieProvider.createCookiesWithMember(participant1)) + .contentType(ContentType.JSON) + .body(request) + .when().post("/participations") + .statusCode(); + statusCodes.add(statusCode); + } finally { + countDownLatch.countDown(); + } + }); + executorService.submit(() -> { + try { + int statusCode = RestAssured.given().log().all() + .cookies(cookieProvider.createCookiesWithMember(participant2)) + .contentType(ContentType.JSON) + .body(request) + .when().post("/participations") + .statusCode(); + statusCodes.add(statusCode); + } finally { + countDownLatch.countDown(); + } + }); + + countDownLatch.await(); + executorService.shutdown(); + + assertThat(statusCodes).containsExactlyInAnyOrder(201, 400); + } } @DisplayName("공모 참여 취소") diff --git a/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceConcurrencyTest.java b/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceConcurrencyTest.java index 9798ab9db..9ca0cd705 100644 --- a/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceConcurrencyTest.java +++ b/backend/src/test/java/com/zzang/chongdae/offeringmember/service/OfferingMemberServiceConcurrencyTest.java @@ -10,7 +10,6 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -20,40 +19,7 @@ class OfferingMemberServiceConcurrencyTest extends ServiceTest { @Autowired private OfferingMemberService offeringMemberService; - @Disabled - @DisplayName("기존 - 동시에 참여할 경우 중복 참여가 가능하다.") - @Test - void should_participateInDuplicate() throws InterruptedException { - // given - MemberEntity proposer = memberFixture.createMember("ever"); - OfferingEntity offering = offeringFixture.createOffering(proposer); - offeringMemberFixture.createProposer(proposer, offering); - - // when - ParticipationRequest request = new ParticipationRequest(offering.getId()); - MemberEntity participant = memberFixture.createMember("whoever"); - - ExecutorService executorService = Executors.newFixedThreadPool(2); - - int executeCount = 2; - CountDownLatch countDownLatch = new CountDownLatch(executeCount); - - for (int i = 0; i < executeCount; i++) { - executorService.submit(() -> { - offeringMemberService.participate(request, participant); - countDownLatch.countDown(); - }); - } - - countDownLatch.await(); - executorService.shutdown(); - - // then - ParticipantResponse response = offeringMemberService.getAllParticipant(offering.getId(), proposer); - assertThat(response.participants()).hasSize(executeCount); - } - - @DisplayName("개선 - 같은 사용자가 같은 공모에 동시에 참여할 경우 중복 참여가 불가능하다.") + @DisplayName("같은 사용자가 같은 공모에 동시에 참여할 경우 중복 참여가 불가능하다.") @Test void should_failParticipate_when_givenSameMemberAndSameOffering() throws InterruptedException { // given @@ -65,13 +31,12 @@ void should_failParticipate_when_givenSameMemberAndSameOffering() throws Interru ParticipationRequest request = new ParticipationRequest(offering.getId()); MemberEntity participant = memberFixture.createMember("whoever"); - ExecutorService executorService = Executors.newFixedThreadPool(5); - int executeCount = 5; + ExecutorService executorService = Executors.newFixedThreadPool(executeCount); CountDownLatch countDownLatch = new CountDownLatch(executeCount); for (int i = 0; i < executeCount; i++) { - executorService.execute(() -> { + executorService.submit(() -> { try { offeringMemberService.participate(request, participant); } finally { @@ -88,7 +53,7 @@ void should_failParticipate_when_givenSameMemberAndSameOffering() throws Interru assertThat(response.participants()).hasSize(1); } - @DisplayName("개선 - 다른 사용자가 같은 공모에 동시에 참여할 경우 중복 참여가 불가능하다.") + @DisplayName("다른 사용자가 같은 공모에 동시에 참여할 경우 중복 참여가 불가능하다.") @Test void should_failParticipate_when_givenDifferentMemberAndSameOffering() throws InterruptedException { // given @@ -101,20 +66,18 @@ void should_failParticipate_when_givenDifferentMemberAndSameOffering() throws In MemberEntity participant1 = memberFixture.createMember("ever1"); MemberEntity participant2 = memberFixture.createMember("ever2"); - ExecutorService executorService = Executors.newFixedThreadPool(2); - int executeCount = 2; + ExecutorService executorService = Executors.newFixedThreadPool(executeCount); CountDownLatch countDownLatch = new CountDownLatch(executeCount); - executorService.execute(() -> { + executorService.submit(() -> { try { offeringMemberService.participate(request, participant1); } finally { countDownLatch.countDown(); } }); - - executorService.execute(() -> { + executorService.submit(() -> { try { offeringMemberService.participate(request, participant2); } finally {