Skip to content

Commit 14cf50e

Browse files
committed
✨feat: 모임 참석 동시성 보장[#203]
그룹 참석 작업 중 경쟁 조건을 방지하기 위해 비관적 잠금을 구현합니다. 이를 통해 특히 동시 접속이 많은 시나리오에서 그룹 용량 및 상태가 정확하게 업데이트됩니다. 또한, 변경 사항은 서비스 수준 유효성 검사를 추가하고 사용자 승인 전에 용량 검사를 수행하여 그룹 참여 승인의 안정성을 향상시키고, 그룹이 최대 참가자 수를 초과하지 않도록 합니다. 테스트 HTTP 파일에서 이미지 리소스 경로를 업데이트합니다.
1 parent d684236 commit 14cf50e

4 files changed

Lines changed: 55 additions & 48 deletions

File tree

src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2AttendanceService.java

Lines changed: 36 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ public AttendanceGroupV2Response attend(Long userId, Long groupId, String messag
5555
throw new GroupException(GroupErrorCode.USER_ID_NULL);
5656
}
5757

58-
// 모임 체크
59-
GroupV2 group = groupV2Repository.findById(groupId)
58+
// 모임 체크: for update로 가져오기
59+
GroupV2 group = groupV2Repository.findByIdForUpdate(groupId)
6060
.orElseThrow(
6161
() -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND_BY_ID, groupId));
6262

@@ -102,6 +102,13 @@ public AttendanceGroupV2Response attend(Long userId, Long groupId, String messag
102102

103103
// 즉시 참여인 경우
104104
if (group.getJoinPolicy() == GroupV2JoinPolicy.FREE) {
105+
// 정원 체크(ATTEND만 카운트)
106+
long attendCount = groupUserV2Repository.countByGroupIdAndStatus(groupId,
107+
GroupUserV2Status.ATTEND);
108+
if (attendCount >= group.getMaxParticipants()) {
109+
throw new GroupException(GroupErrorCode.GROUP_IS_FULL, groupId);
110+
}
111+
105112
if (groupUserV2 != null) {
106113
// LEFT, KICKED, REJECTED, CANCELLED -> 재참여
107114
groupUserV2.reAttend(); // 내부에서 BANNED만 막고, ATTEND로 변경
@@ -111,15 +118,10 @@ public AttendanceGroupV2Response attend(Long userId, Long groupId, String messag
111118
groupUserV2Repository.save(groupUserV2);
112119
}
113120

114-
// 정원 체크(ATTEND만 카운트)
115-
long attendCount = groupUserV2Repository.countByGroupIdAndStatus(groupId,
116-
GroupUserV2Status.ATTEND);
117-
if (attendCount > group.getMaxParticipants()) {
118-
throw new GroupException(GroupErrorCode.GROUP_IS_FULL, groupId);
119-
}
120-
121121
// FULL 자동 전환
122-
if (attendCount == group.getMaxParticipants()
122+
long newCount = groupUserV2Repository.countByGroupIdAndStatus(groupId, GroupUserV2Status.ATTEND);
123+
124+
if (newCount == group.getMaxParticipants()
123125
&& group.getStatus() == GroupV2Status.RECRUITING) {
124126
group.changeStatus(GroupV2Status.FULL);
125127
}
@@ -130,7 +132,7 @@ public AttendanceGroupV2Response attend(Long userId, Long groupId, String messag
130132
eventPublisher.publishEvent(
131133
new GroupJoinedEvent(groupId, group.getHost().getId(), userId));
132134

133-
return AttendanceGroupV2Response.of(group, attendCount, membership);
135+
return AttendanceGroupV2Response.of(group, newCount, membership);
134136
}
135137

136138
if (joinPolicy == GroupV2JoinPolicy.APPROVAL_REQUIRED) {
@@ -152,16 +154,6 @@ public AttendanceGroupV2Response attend(Long userId, Long groupId, String messag
152154
long attendCount = groupUserV2Repository.countByGroupIdAndStatus(
153155
groupId,
154156
GroupUserV2Status.ATTEND);
155-
if (attendCount > group.getMaxParticipants()) {
156-
// 방금 reAttend로 늘었는데 초과하면 롤백시키기 위해 예외
157-
throw new GroupException(GroupErrorCode.GROUP_IS_FULL, groupId);
158-
}
159-
160-
// FULL 자동 전환
161-
if (attendCount == group.getMaxParticipants()
162-
&& group.getStatus() == GroupV2Status.RECRUITING) {
163-
group.changeStatus(GroupV2Status.FULL);
164-
}
165157

166158
// 내 멤버십 + 최신 카운트 + 모임 상태 응답
167159
MyMembership membership = MyMembership.from(groupUserV2);
@@ -180,7 +172,7 @@ public AttendanceGroupV2Response left(Long userId, Long groupId) {
180172
throw new GroupException(GroupErrorCode.USER_ID_NULL);
181173
}
182174

183-
GroupV2 group = groupV2Repository.findById(groupId)
175+
GroupV2 group = groupV2Repository.findByIdForUpdate(groupId)
184176
.orElseThrow(
185177
() -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND_BY_ID,
186178
groupId));
@@ -241,7 +233,7 @@ public GroupUserV2StatusResponse approve(Long approverUserId, Long groupId,
241233
throw new GroupException(GroupErrorCode.CANNOT_APPROVE_SELF, groupId, approverUserId);
242234
}
243235

244-
GroupV2 group = groupV2Repository.findById(groupId)
236+
GroupV2 group = groupV2Repository.findByIdForUpdate(groupId)
245237
.orElseThrow(
246238
() -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND_BY_ID, groupId));
247239

@@ -285,26 +277,31 @@ public GroupUserV2StatusResponse approve(Long approverUserId, Long groupId,
285277
.orElseThrow(() -> new GroupException(GroupErrorCode.GROUP_USER_NOT_FOUND,
286278
targetUserId));
287279

288-
// PENDING만 승인 가능 (도메인에서 검증)
289-
target.approveJoin();
290-
291-
eventPublisher.publishEvent(
292-
new GroupJoinApprovedEvent(groupId, approverUserId, targetUserId));
280+
// 서비스 레벨 방어
281+
if (target.getStatus() != GroupUserV2Status.PENDING) {
282+
throw new GroupException(GroupErrorCode.GROUP_USER_STATUS_NOT_ALLOWED_TO_APPROVE,
283+
groupId, targetUserId, target.getStatus().name());
284+
}
293285

294-
long attendCount = groupUserV2Repository.countByGroupIdAndStatus(groupId,
286+
// 사전 정원 체크: 지금 ATTEND가 max-1 이하여야 승인 가능
287+
long beforeCount = groupUserV2Repository.countByGroupIdAndStatus(groupId,
295288
GroupUserV2Status.ATTEND);
296-
297-
if (attendCount > group.getMaxParticipants()) {
289+
if (beforeCount >= group.getMaxParticipants()) {
298290
throw new GroupException(GroupErrorCode.GROUP_IS_FULL, groupId);
299291
}
300292

301-
// FULL 자동 전환
302-
if (attendCount == group.getMaxParticipants()
303-
&& group.getStatus() == GroupV2Status.RECRUITING) {
293+
target.approveJoin(); // PENDING -> ATTEND
294+
295+
long newCount = beforeCount + 1;
296+
297+
if (newCount == group.getMaxParticipants() && group.getStatus() != GroupV2Status.FULL) {
304298
group.changeStatus(GroupV2Status.FULL);
305299
}
306300

307-
return GroupUserV2StatusResponse.of(group, attendCount, targetUserId, target);
301+
eventPublisher.publishEvent(
302+
new GroupJoinApprovedEvent(groupId, approverUserId, targetUserId));
303+
304+
return GroupUserV2StatusResponse.of(group, newCount, targetUserId, target);
308305
}
309306

310307
@Transactional
@@ -318,7 +315,7 @@ public GroupUserV2StatusResponse reject(Long approverUserId, Long groupId,
318315
throw new GroupException(GroupErrorCode.CANNOT_REJECT_SELF, groupId, approverUserId);
319316
}
320317

321-
GroupV2 group = groupV2Repository.findById(groupId)
318+
GroupV2 group = groupV2Repository.findByIdForUpdate(groupId)
322319
.orElseThrow(
323320
() -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND_BY_ID, groupId));
324321

@@ -381,7 +378,7 @@ public GroupUserV2StatusResponse kick(Long kickerUserId, Long groupId, Long targ
381378
throw new GroupException(GroupErrorCode.GROUP_CANNOT_KICK_SELF, groupId, kickerUserId);
382379
}
383380

384-
GroupV2 group = groupV2Repository.findById(groupId)
381+
GroupV2 group = groupV2Repository.findByIdForUpdate(groupId)
385382
.orElseThrow(
386383
() -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND_BY_ID, groupId));
387384

@@ -436,7 +433,7 @@ public GroupUserV2StatusResponse ban(Long bannerUserId, Long groupId, Long targe
436433
throw new GroupException(GroupErrorCode.GROUP_CANNOT_BAN_SELF, groupId, bannerUserId);
437434
}
438435

439-
GroupV2 group = groupV2Repository.findById(groupId)
436+
GroupV2 group = groupV2Repository.findByIdForUpdate(groupId)
440437
.orElseThrow(
441438
() -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND_BY_ID, groupId));
442439

@@ -547,7 +544,7 @@ public GroupUserV2StatusResponse unban(Long requesterUserId, Long groupId, Long
547544
throw new GroupException(GroupErrorCode.USER_ID_NULL);
548545
}
549546

550-
GroupV2 group = groupV2Repository.findById(groupId)
547+
GroupV2 group = groupV2Repository.findByIdForUpdate(groupId)
551548
.orElseThrow(
552549
() -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND_BY_ID, groupId));
553550

src/main/java/team/wego/wegobackend/group/v2/domain/repository/GroupV2Repository.java

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package team.wego.wegobackend.group.v2.domain.repository;
22

33

4+
import jakarta.persistence.LockModeType;
45
import java.time.LocalDateTime;
56
import java.util.List;
67
import java.util.Optional;
78
import org.springframework.data.domain.Pageable;
89
import org.springframework.data.jpa.repository.JpaRepository;
10+
import org.springframework.data.jpa.repository.Lock;
911
import org.springframework.data.jpa.repository.Modifying;
1012
import org.springframework.data.jpa.repository.Query;
1113
import org.springframework.data.repository.query.Param;
@@ -14,6 +16,14 @@
1416

1517
public interface GroupV2Repository extends JpaRepository<GroupV2, Long> {
1618

19+
@Lock(LockModeType.PESSIMISTIC_WRITE)
20+
@Query("""
21+
select g
22+
from GroupV2 g
23+
where g.id = :groupId
24+
""")
25+
Optional<GroupV2> findByIdForUpdate(@Param("groupId") Long groupId);
26+
1727
@Query("""
1828
select distinct g
1929
from GroupV2 g
@@ -38,13 +48,13 @@ int bulkFinishByStartTime(
3848
);
3949

4050
@Query("""
41-
select g.id
42-
from GroupV2 g
43-
where g.deletedAt is null
44-
and g.status = 'FINISHED'
45-
and g.startTime <= :threshold
46-
order by g.id asc
47-
""")
51+
select g.id
52+
from GroupV2 g
53+
where g.deletedAt is null
54+
and g.status = 'FINISHED'
55+
and g.startTime <= :threshold
56+
order by g.id asc
57+
""")
4858
List<Long> findFinishedExpiredGroupIdsByStartTime(
4959
@Param("threshold") LocalDateTime threshold,
5060
Pageable pageable

src/test/http/group/v2/v2-group-create.http

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,10 @@ Content-Type: multipart/form-data; boundary=boundary
6767
Authorization: Bearer {{accessToken}}
6868

6969
--boundary
70-
Content-Disposition: form-data; name="images"; filename="img1.png"
70+
Content-Disposition: form-data; name="images"; filename="2023-02-26_012341.png"
7171
Content-Type: image/png
7272

73-
< ../../image/resources/img1.png
73+
< ../../image/resources/2023-02-26_012341.png
7474
--boundary
7575
Content-Disposition: form-data; name="images"; filename="img4.jpg"
7676
Content-Type: image/jpeg
3.1 MB
Loading

0 commit comments

Comments
 (0)