Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: 데이터베이스 락을 통해 중복 참여 방지 #667

Closed
wants to merge 9 commits into from
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
package com.zzang.chongdae.offering.repository;

import com.zzang.chongdae.member.repository.entity.MemberEntity;
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<OfferingEntity, Long> {
Expand All @@ -18,14 +19,6 @@ public interface OfferingRepository extends JpaRepository<OfferingEntity, Long>
""", nativeQuery = true)
Optional<OfferingEntity> 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<OfferingEntity> findCommentRoomsByMember(MemberEntity member);

@Query("""
SELECT o
FROM OfferingEntity o
Expand Down Expand Up @@ -136,4 +129,8 @@ List<OfferingEntity> findHighDiscountOfferingsWithMeetingAddressKeyword(
AND (o.offeringStatus IN ('AVAILABLE', 'FULL', 'IMMINENT'))
""")
List<OfferingEntity> findByMeetingDateAndOfferingStatusNotConfirmed(LocalDateTime meetingDate);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT o FROM OfferingEntity o WHERE o.id = :id")
Optional<OfferingEntity> findByIdWithLock(Long id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -37,20 +36,17 @@ 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);

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) {
Expand Down Expand Up @@ -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));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public class OfferingFixture {

private OfferingEntity createOffering(MemberEntity member,
String title,
Integer totalCount,
Double discountRate,
OfferingStatus offeringStatus,
CommentRoomStatus commentRoomStatus) {
Expand All @@ -30,10 +31,10 @@ private OfferingEntity createOffering(MemberEntity member,
"meetingAddress",
"meetingAddressDetail",
"meetingAddressDong",
5,
totalCount,
1,
5000,
1000,
10_000,
discountRate,
offeringStatus,
commentRoomStatus
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<Integer> 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<Integer> 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("공모 참여 취소")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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 OfferingMemberServiceConcurrencyTest extends ServiceTest {

@Autowired
private OfferingMemberService offeringMemberService;

@DisplayName("같은 사용자가 같은 공모에 동시에 참여할 경우 중복 참여가 불가능하다.")
@Test
void should_failParticipate_when_givenSameMemberAndSameOffering() 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");

int executeCount = 5;
ExecutorService executorService = Executors.newFixedThreadPool(executeCount);
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);
}

@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");

int executeCount = 2;
ExecutorService executorService = Executors.newFixedThreadPool(executeCount);
CountDownLatch countDownLatch = new CountDownLatch(executeCount);

executorService.submit(() -> {
try {
offeringMemberService.participate(request, participant1);
} finally {
countDownLatch.countDown();
}
});
executorService.submit(() -> {
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);
}
}