Skip to content

Commit 0e74f45

Browse files
authored
refactor: 데이터베이스 락을 통해 중복 참여 방지 (#673)
* test: 동시 참여 시 중복 참여 가능한 상황 테스트 * refactor: 공모 상태 업데이트 로직 이동 * refactor: 사용하지 않는 쿼리 제거 * feat: Offering 테이블 비관적 쓰기 락을 통해 중복 참여 방지 * test: 같은 공모 다른 사용자의 동시 참여 경우 테스트 * refactor: 비관적 쓰기 락 -> 낙관적 락을 통해 중복 참여 방지 * refactor: version 필드 추가로 인한 soft delete 쿼리 수정 * refactor: offeringMember 저장 시 offering의 실 저장 데이터 활용하도록 * refactor: 낙관적 락 -> 비관적 쓰기 락 * test: 동시 실행 코드 ConcurrencyExecutor 클래스로 추출
1 parent 29670c8 commit 0e74f45

File tree

8 files changed

+261
-29
lines changed

8 files changed

+261
-29
lines changed

backend/src/main/java/com/zzang/chongdae/offering/repository/OfferingRepository.java

+6-9
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
package com.zzang.chongdae.offering.repository;
22

3-
import com.zzang.chongdae.member.repository.entity.MemberEntity;
43
import com.zzang.chongdae.offering.repository.entity.OfferingEntity;
4+
import jakarta.persistence.LockModeType;
55
import java.time.LocalDateTime;
66
import java.util.List;
77
import java.util.Optional;
88
import org.springframework.data.domain.Pageable;
99
import org.springframework.data.jpa.repository.JpaRepository;
10+
import org.springframework.data.jpa.repository.Lock;
1011
import org.springframework.data.jpa.repository.Query;
1112

1213
public interface OfferingRepository extends JpaRepository<OfferingEntity, Long> {
@@ -18,14 +19,6 @@ public interface OfferingRepository extends JpaRepository<OfferingEntity, Long>
1819
""", nativeQuery = true)
1920
Optional<OfferingEntity> findByIdWithDeleted(Long offeringId);
2021

21-
@Query("""
22-
SELECT o
23-
FROM OfferingEntity as o JOIN OfferingMemberEntity as om
24-
ON o.id = om.offering.id
25-
WHERE om.member = :member
26-
""")
27-
List<OfferingEntity> findCommentRoomsByMember(MemberEntity member);
28-
2922
@Query("""
3023
SELECT o
3124
FROM OfferingEntity o
@@ -136,4 +129,8 @@ List<OfferingEntity> findHighDiscountOfferingsWithMeetingAddressKeyword(
136129
AND (o.offeringStatus IN ('AVAILABLE', 'FULL', 'IMMINENT'))
137130
""")
138131
List<OfferingEntity> findByMeetingDateAndOfferingStatusNotConfirmed(LocalDateTime meetingDate);
132+
133+
@Lock(LockModeType.PESSIMISTIC_WRITE)
134+
@Query("SELECT o FROM OfferingEntity o WHERE o.id = :id")
135+
Optional<OfferingEntity> findByIdWithLock(Long id);
139136
}

backend/src/main/java/com/zzang/chongdae/offering/repository/entity/OfferingEntity.java

+4
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,14 @@ public OfferingEntity(MemberEntity member, String title, String description, Str
120120

121121
public void participate() {
122122
currentCount++;
123+
OfferingStatus offeringStatus = toOfferingJoinedCount().decideOfferingStatus();
124+
updateOfferingStatus(offeringStatus);
123125
}
124126

125127
public void leave() {
126128
currentCount--;
129+
OfferingStatus offeringStatus = toOfferingJoinedCount().decideOfferingStatus();
130+
updateOfferingStatus(offeringStatus);
127131
}
128132

129133
public CommentRoomStatus moveCommentRoomStatus() {

backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java

+4-4
Original file line numberDiff line numberDiff line change
@@ -132,13 +132,13 @@ private void validateIsProposer(OfferingEntity offering, MemberEntity member) {
132132
public Long saveOffering(OfferingSaveRequest request, MemberEntity member) {
133133
OfferingEntity offering = request.toEntity(member);
134134
validateMeetingDate(offering.getMeetingDate());
135-
OfferingEntity savedOffering = offeringRepository.save(offering);
135+
OfferingEntity saved = offeringRepository.save(offering);
136136

137-
OfferingMemberEntity offeringMember = new OfferingMemberEntity(member, offering, OfferingMemberRole.PROPOSER);
137+
OfferingMemberEntity offeringMember = new OfferingMemberEntity(member, saved, OfferingMemberRole.PROPOSER);
138138
offeringMemberRepository.save(offeringMember);
139139

140-
eventPublisher.publishEvent(new SaveOfferingEvent(this, savedOffering));
141-
return savedOffering.getId();
140+
eventPublisher.publishEvent(new SaveOfferingEvent(this, saved));
141+
return saved.getId();
142142
}
143143

144144
private void validateMeetingDate(LocalDateTime offeringMeetingDateTime) {

backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java

+4-9
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import com.zzang.chongdae.global.exception.MarketException;
77
import com.zzang.chongdae.member.repository.entity.MemberEntity;
88
import com.zzang.chongdae.offering.domain.CommentRoomStatus;
9-
import com.zzang.chongdae.offering.domain.OfferingStatus;
109
import com.zzang.chongdae.offering.exception.OfferingErrorCode;
1110
import com.zzang.chongdae.offering.repository.OfferingRepository;
1211
import com.zzang.chongdae.offering.repository.entity.OfferingEntity;
@@ -37,20 +36,17 @@ public class OfferingMemberService {
3736
@WriterDatabase
3837
@Transactional
3938
public Long participate(ParticipationRequest request, MemberEntity member) {
40-
OfferingEntity offering = offeringRepository.findById(request.offeringId())
39+
OfferingEntity offering = offeringRepository.findByIdWithLock(request.offeringId())
4140
.orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND));
4241
validateParticipate(offering, member);
4342

4443
OfferingMemberEntity offeringMember = new OfferingMemberEntity(
4544
member, offering, OfferingMemberRole.PARTICIPANT);
4645
OfferingMemberEntity saved = offeringMemberRepository.save(offeringMember);
47-
4846
offering.participate();
49-
OfferingStatus offeringStatus = offering.toOfferingJoinedCount().decideOfferingStatus();
50-
offering.updateOfferingStatus(offeringStatus);
5147

5248
eventPublisher.publishEvent(new ParticipateEvent(this, saved));
53-
return offeringMember.getId();
49+
return saved.getId();
5450
}
5551

5652
private void validateParticipate(OfferingEntity offering, MemberEntity member) {
@@ -78,11 +74,10 @@ public void cancelParticipate(Long offeringId, MemberEntity member) {
7874
OfferingMemberEntity offeringMember = offeringMemberRepository.findByOfferingAndMember(offering, member)
7975
.orElseThrow(() -> new MarketException(OfferingMemberErrorCode.PARTICIPANT_NOT_FOUND));
8076
validateCancel(offeringMember);
81-
offeringMemberRepository.delete(offeringMember);
8277

78+
offeringMemberRepository.delete(offeringMember);
8379
offering.leave();
84-
OfferingStatus offeringStatus = offering.toOfferingJoinedCount().decideOfferingStatus();
85-
offering.updateOfferingStatus(offeringStatus);
80+
8681
eventPublisher.publishEvent(new CancelParticipateEvent(this, offeringMember));
8782
}
8883

backend/src/test/java/com/zzang/chongdae/global/domain/OfferingFixture.java

+12-7
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public class OfferingFixture {
1717

1818
private OfferingEntity createOffering(MemberEntity member,
1919
String title,
20+
Integer totalCount,
2021
Double discountRate,
2122
OfferingStatus offeringStatus,
2223
CommentRoomStatus commentRoomStatus) {
@@ -30,10 +31,10 @@ private OfferingEntity createOffering(MemberEntity member,
3031
"meetingAddress",
3132
"meetingAddressDetail",
3233
"meetingAddressDong",
33-
5,
34+
totalCount,
3435
1,
3536
5000,
36-
1000,
37+
10_000,
3738
discountRate,
3839
offeringStatus,
3940
commentRoomStatus
@@ -42,23 +43,27 @@ private OfferingEntity createOffering(MemberEntity member,
4243
}
4344

4445
public OfferingEntity createOffering(MemberEntity member, Double discountRate) {
45-
return createOffering(member, "title", discountRate, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING);
46+
return createOffering(member, "title", 5, discountRate, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING);
4647
}
4748

4849
public OfferingEntity createOffering(MemberEntity member, CommentRoomStatus commentRoomStatus) {
49-
return createOffering(member, "title", 33.3, OfferingStatus.AVAILABLE, commentRoomStatus);
50+
return createOffering(member, "title", 5, 33.3, OfferingStatus.AVAILABLE, commentRoomStatus);
5051
}
5152

5253
public OfferingEntity createOffering(MemberEntity member, OfferingStatus offeringStatus) {
53-
return createOffering(member, "title", 33.3, offeringStatus, CommentRoomStatus.GROUPING);
54+
return createOffering(member, "title", 5, 33.3, offeringStatus, CommentRoomStatus.GROUPING);
5455
}
5556

5657
public OfferingEntity createOffering(MemberEntity member) {
57-
return createOffering(member, "title", 33.3, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING);
58+
return createOffering(member, "title", 5, 33.3, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING);
5859
}
5960

6061
public OfferingEntity createOffering(MemberEntity member, String title) {
61-
return createOffering(member, title, 33.3, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING);
62+
return createOffering(member, title, 5, 33.3, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING);
63+
}
64+
65+
public OfferingEntity createOffering(MemberEntity member, Integer totalCount) {
66+
return createOffering(member, "title", totalCount, 33.3, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING);
6267
}
6368

6469
public void deleteOffering(OfferingEntity offering) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package com.zzang.chongdae.global.helper;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
import java.util.concurrent.CountDownLatch;
6+
import java.util.concurrent.ExecutionException;
7+
import java.util.concurrent.ExecutorService;
8+
import java.util.concurrent.Executors;
9+
import java.util.concurrent.Future;
10+
import java.util.function.Supplier;
11+
12+
public class ConcurrencyExecutor {
13+
14+
private static ConcurrencyExecutor INSTANCE;
15+
16+
public static ConcurrencyExecutor getInstance() {
17+
if (INSTANCE == null) {
18+
INSTANCE = new ConcurrencyExecutor();
19+
}
20+
return INSTANCE;
21+
}
22+
23+
public void executeWithoutResult(Runnable... tasks) throws InterruptedException {
24+
int executeCount = tasks.length;
25+
ExecutorService executorService = Executors.newFixedThreadPool(executeCount);
26+
CountDownLatch countDownLatch = new CountDownLatch(executeCount);
27+
28+
for (Runnable task : tasks) {
29+
executorService.submit(() -> {
30+
try {
31+
task.run();
32+
} finally {
33+
countDownLatch.countDown();
34+
}
35+
});
36+
}
37+
38+
countDownLatch.await();
39+
executorService.shutdown();
40+
}
41+
42+
public void executeWithoutResult(int repeatCount, Runnable task) throws InterruptedException {
43+
ExecutorService executorService = Executors.newFixedThreadPool(repeatCount);
44+
CountDownLatch countDownLatch = new CountDownLatch(repeatCount);
45+
46+
for (int i = 0; i < repeatCount; i++) {
47+
executorService.submit(() -> {
48+
try {
49+
task.run();
50+
} finally {
51+
countDownLatch.countDown();
52+
}
53+
});
54+
}
55+
56+
countDownLatch.await();
57+
executorService.shutdown();
58+
}
59+
60+
public final <T> List<T> execute(Supplier<T>... tasks) throws InterruptedException {
61+
int executeCount = tasks.length;
62+
ExecutorService executorService = Executors.newFixedThreadPool(executeCount);
63+
CountDownLatch countDownLatch = new CountDownLatch(executeCount);
64+
65+
List<Future<T>> result = new ArrayList<>();
66+
for (Supplier<T> task : tasks) {
67+
Future<T> future = executorService.submit(() -> {
68+
try {
69+
return task.get();
70+
} finally {
71+
countDownLatch.countDown();
72+
}
73+
});
74+
result.add(future);
75+
}
76+
77+
countDownLatch.await();
78+
executorService.shutdown();
79+
80+
return result.stream()
81+
.map(this::getWithoutException)
82+
.toList();
83+
}
84+
85+
public <T> List<T> execute(int repeatCount, Supplier<T> task) throws InterruptedException {
86+
ExecutorService executorService = Executors.newFixedThreadPool(repeatCount);
87+
CountDownLatch countDownLatch = new CountDownLatch(repeatCount);
88+
89+
List<Future<T>> result = new ArrayList<>();
90+
for (int i = 0; i < repeatCount; i++) {
91+
Future<T> future = executorService.submit(() -> {
92+
try {
93+
return task.get();
94+
} finally {
95+
countDownLatch.countDown();
96+
}
97+
});
98+
result.add(future);
99+
}
100+
101+
countDownLatch.await();
102+
executorService.shutdown();
103+
104+
return result.stream()
105+
.map(this::getWithoutException)
106+
.toList();
107+
}
108+
109+
private <T> T getWithoutException(Future<T> future) {
110+
try {
111+
return future.get();
112+
} catch (InterruptedException | ExecutionException e) {
113+
throw new RuntimeException("동시 작업 내부 예외 발생: ", e);
114+
}
115+
}
116+
}

backend/src/test/java/com/zzang/chongdae/offeringmember/integration/OfferingMemberIntegrationTest.java

+52
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
import static com.epages.restdocs.apispec.ResourceDocumentation.resource;
55
import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.document;
66
import static com.epages.restdocs.apispec.Schema.schema;
7+
import static org.assertj.core.api.Assertions.assertThat;
78
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
89

910
import com.epages.restdocs.apispec.ParameterDescriptorWithType;
1011
import com.epages.restdocs.apispec.ResourceSnippetParameters;
12+
import com.zzang.chongdae.global.helper.ConcurrencyExecutor;
1113
import com.zzang.chongdae.global.integration.IntegrationTest;
1214
import com.zzang.chongdae.member.repository.entity.MemberEntity;
1315
import com.zzang.chongdae.offering.domain.CommentRoomStatus;
@@ -120,6 +122,56 @@ void should_throwException_when_emptyValue() {
120122
.then().log().all()
121123
.statusCode(400);
122124
}
125+
126+
@DisplayName("같은 사용자가 같은 공모에 동시에 참여할 경우 첫 요청 이후의 요청은 예외가 발생한다.")
127+
@Test
128+
void should_throwException_when_sameMemberAndSameOffering() throws InterruptedException {
129+
ParticipationRequest request = new ParticipationRequest(
130+
offering.getId()
131+
);
132+
133+
ConcurrencyExecutor concurrencyExecutor = ConcurrencyExecutor.getInstance();
134+
List<Integer> statusCodes = concurrencyExecutor.execute(5,
135+
() -> RestAssured.given().log().all()
136+
.cookies(cookieProvider.createCookiesWithMember(participant))
137+
.contentType(ContentType.JSON)
138+
.body(request)
139+
.when().post("/participations")
140+
.statusCode());
141+
142+
assertThat(statusCodes).containsExactlyInAnyOrder(201, 400, 400, 400, 400);
143+
}
144+
145+
@DisplayName("다른 사용자가 같은 공모에 동시에 참여할 경우 예외가 발생한다.")
146+
@Test
147+
void should_throwException_when_differentMemberAndSameOffering() throws InterruptedException {
148+
MemberEntity proposer = memberFixture.createMember("ever");
149+
OfferingEntity offering = offeringFixture.createOffering(proposer, 2);
150+
offeringMemberFixture.createProposer(proposer, offering);
151+
152+
ParticipationRequest request = new ParticipationRequest(
153+
offering.getId()
154+
);
155+
MemberEntity participant1 = memberFixture.createMember("ever1");
156+
MemberEntity participant2 = memberFixture.createMember("ever2");
157+
158+
ConcurrencyExecutor concurrencyExecutor = ConcurrencyExecutor.getInstance();
159+
List<Integer> statusCodes = concurrencyExecutor.execute(
160+
() -> RestAssured.given().log().all()
161+
.cookies(cookieProvider.createCookiesWithMember(participant1))
162+
.contentType(ContentType.JSON)
163+
.body(request)
164+
.when().post("/participations")
165+
.statusCode(),
166+
() -> RestAssured.given().log().all()
167+
.cookies(cookieProvider.createCookiesWithMember(participant2))
168+
.contentType(ContentType.JSON)
169+
.body(request)
170+
.when().post("/participations")
171+
.statusCode());
172+
173+
assertThat(statusCodes).containsExactlyInAnyOrder(201, 400);
174+
}
123175
}
124176

125177
@DisplayName("공모 참여 취소")

0 commit comments

Comments
 (0)