Skip to content

Commit

Permalink
refactor: 데이터베이스 락을 통해 중복 참여 방지 (#673)
Browse files Browse the repository at this point in the history
* test: 동시 참여 시 중복 참여 가능한 상황 테스트

* refactor: 공모 상태 업데이트 로직 이동

* refactor: 사용하지 않는 쿼리 제거

* feat: Offering 테이블 비관적 쓰기 락을 통해 중복 참여 방지

* test: 같은 공모 다른 사용자의 동시 참여 경우 테스트

* refactor: 비관적 쓰기 락 -> 낙관적 락을 통해 중복 참여 방지

* refactor: version 필드 추가로 인한 soft delete 쿼리 수정

* refactor: offeringMember 저장 시 offering의 실 저장 데이터 활용하도록

* refactor: 낙관적 락 -> 비관적 쓰기 락

* test: 동시 실행 코드 ConcurrencyExecutor 클래스로 추출
  • Loading branch information
helenason authored Dec 24, 2024
1 parent 29670c8 commit 0e74f45
Show file tree
Hide file tree
Showing 8 changed files with 261 additions and 29 deletions.
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
@@ -0,0 +1,116 @@
package com.zzang.chongdae.global.helper;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.function.Supplier;

public class ConcurrencyExecutor {

private static ConcurrencyExecutor INSTANCE;

public static ConcurrencyExecutor getInstance() {
if (INSTANCE == null) {
INSTANCE = new ConcurrencyExecutor();
}
return INSTANCE;
}

public void executeWithoutResult(Runnable... tasks) throws InterruptedException {
int executeCount = tasks.length;
ExecutorService executorService = Executors.newFixedThreadPool(executeCount);
CountDownLatch countDownLatch = new CountDownLatch(executeCount);

for (Runnable task : tasks) {
executorService.submit(() -> {
try {
task.run();
} finally {
countDownLatch.countDown();
}
});
}

countDownLatch.await();
executorService.shutdown();
}

public void executeWithoutResult(int repeatCount, Runnable task) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(repeatCount);
CountDownLatch countDownLatch = new CountDownLatch(repeatCount);

for (int i = 0; i < repeatCount; i++) {
executorService.submit(() -> {
try {
task.run();
} finally {
countDownLatch.countDown();
}
});
}

countDownLatch.await();
executorService.shutdown();
}

public final <T> List<T> execute(Supplier<T>... tasks) throws InterruptedException {
int executeCount = tasks.length;
ExecutorService executorService = Executors.newFixedThreadPool(executeCount);
CountDownLatch countDownLatch = new CountDownLatch(executeCount);

List<Future<T>> result = new ArrayList<>();
for (Supplier<T> task : tasks) {
Future<T> future = executorService.submit(() -> {
try {
return task.get();
} finally {
countDownLatch.countDown();
}
});
result.add(future);
}

countDownLatch.await();
executorService.shutdown();

return result.stream()
.map(this::getWithoutException)
.toList();
}

public <T> List<T> execute(int repeatCount, Supplier<T> task) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(repeatCount);
CountDownLatch countDownLatch = new CountDownLatch(repeatCount);

List<Future<T>> result = new ArrayList<>();
for (int i = 0; i < repeatCount; i++) {
Future<T> future = executorService.submit(() -> {
try {
return task.get();
} finally {
countDownLatch.countDown();
}
});
result.add(future);
}

countDownLatch.await();
executorService.shutdown();

return result.stream()
.map(this::getWithoutException)
.toList();
}

private <T> T getWithoutException(Future<T> future) {
try {
return future.get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException("동시 작업 내부 예외 발생: ", e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
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;
import com.epages.restdocs.apispec.ResourceSnippetParameters;
import com.zzang.chongdae.global.helper.ConcurrencyExecutor;
import com.zzang.chongdae.global.integration.IntegrationTest;
import com.zzang.chongdae.member.repository.entity.MemberEntity;
import com.zzang.chongdae.offering.domain.CommentRoomStatus;
Expand Down Expand Up @@ -120,6 +122,56 @@ 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()
);

ConcurrencyExecutor concurrencyExecutor = ConcurrencyExecutor.getInstance();
List<Integer> statusCodes = concurrencyExecutor.execute(5,
() -> RestAssured.given().log().all()
.cookies(cookieProvider.createCookiesWithMember(participant))
.contentType(ContentType.JSON)
.body(request)
.when().post("/participations")
.statusCode());

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

ConcurrencyExecutor concurrencyExecutor = ConcurrencyExecutor.getInstance();
List<Integer> statusCodes = concurrencyExecutor.execute(
() -> RestAssured.given().log().all()
.cookies(cookieProvider.createCookiesWithMember(participant1))
.contentType(ContentType.JSON)
.body(request)
.when().post("/participations")
.statusCode(),
() -> RestAssured.given().log().all()
.cookies(cookieProvider.createCookiesWithMember(participant2))
.contentType(ContentType.JSON)
.body(request)
.when().post("/participations")
.statusCode());

assertThat(statusCodes).containsExactlyInAnyOrder(201, 400);
}
}

@DisplayName("공모 참여 취소")
Expand Down
Loading

0 comments on commit 0e74f45

Please sign in to comment.