Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ public ResponseEntity<BaseResponse<MatchResponse>> createMatch(
.body(BaseResponse.success("매칭 생성 성공", response));
}

@Operation(summary = "매칭 취소 (관리자 전용)", description = "매칭을 취소 상태로 변경합니다. 이미 시작/종료된 매칭은 취소할 수 없습니다.")
@PatchMapping("/matches/{matchId}/cancel")
public ResponseEntity<BaseResponse<Void>> cancelMatch(
@PathVariable Long matchId
) {
adminService.cancelMatch(matchId);
return ResponseEntity.ok(BaseResponse.success("매칭 취소 성공", null));
}

@Operation(summary = "매칭 삭제 (관리자 전용)", description = "매칭을 강제 삭제합니다.")
@DeleteMapping("/matches/{matchId}")
public ResponseEntity<BaseResponse<Void>> deleteMatch(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,7 @@ public interface AdminService {

MatchResponse createMatch(Long adminId, MatchCreateRequest request); // 매칭 생성

void cancelMatch(Long matchId); // 매칭 취소

void deleteMatch(Long matchId); // 매칭 삭제
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ public MatchResponse createMatch(Long adminId, MatchCreateRequest request) {
return matchService.createMatch(adminId, request);
}

@Override
@Transactional
public void cancelMatch(Long matchId) {
matchService.cancelMatch(matchId);
}

@Override
@Transactional
public void deleteMatch(Long matchId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,9 @@ public void leave() {
if (this.status == MatchStatus.FULL) this.status = MatchStatus.OPEN;
}

public void cancel() {
this.status = MatchStatus.CANCELLED;
}


}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.be.sportizebe.domain.match.entity;

public enum MatchStatus {
OPEN, // 참여 가능
FULL, // 정원 마감
CLOSED // 운영상 종료(옵션)
OPEN, // 참여 가능
FULL, // 정원 마감
CLOSED, // 매칭 시작됨 (scheduledAt 이후, 참여 불가) -> "지금 진행중인 매칭" 탭 보여줄 때
COMPLETED, // 매칭 종료 (scheduledAt + durationMinutes 이후) -> "매칭 완료 후 리뷰/평점. 포인트 지금 등 사이드 이펙트 필요 시COM
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

주석 문구가 중간에서 깨져 의미 전달이 어렵습니다.

Line 7의 ...필요 시COM은 오탈자/잔여 문자열로 보이며, 상태 정의 주석은 배포 전 정리하는 것이 좋겠습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/be/sportizebe/domain/match/entity/MatchStatus.java` at line
7, The javadoc/comment for the MatchStatus enum value COMPLETED contains a
broken/garbled fragment ("...필요 시COM"); open the MatchStatus enum and fix the
comment for COMPLETED to a clear, complete sentence (e.g., "매칭 종료 (scheduledAt +
durationMinutes 이후) — 매칭 완료 후 리뷰/평점 처리 및 포인트 지급 등의 사이드 이펙트 처리"), removing the
stray characters and ensuring the meaning is unambiguous.

CANCELLED // Admin 취소
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ public enum MatchErrorCode implements BaseErrorCode {
HttpStatus.BAD_REQUEST,
"MATCH_400_NOT_JOINED",
"해당 매칭에 참가 중이지 않습니다."
),

MATCH_CANNOT_CANCEL(
HttpStatus.BAD_REQUEST,
"MATCH_400_CANNOT_CANCEL",
"이미 취소되었거나 시작/종료된 매칭은 취소할 수 없습니다."
);

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public interface MatchService {

List<MatchNearResponse> getNearMatches(MatchNearRequest request); // 내 주변 매칭 목록 조회

void cancelMatch(Long matchId); // 매칭 취소 (관리자 전용)

void deleteMatch(Long matchId); // 매칭 삭제 (관리자 전용)

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.be.sportizebe.domain.match.entity.MatchParticipant;
import com.be.sportizebe.domain.match.entity.MatchParticipantStatus;
import com.be.sportizebe.domain.match.entity.MatchRoom;
import com.be.sportizebe.domain.match.entity.MatchStatus;
import com.be.sportizebe.domain.match.exception.MatchErrorCode;
import com.be.sportizebe.domain.match.repository.MatchParticipantRepository;
import com.be.sportizebe.domain.match.repository.MatchRoomRepository;
Expand Down Expand Up @@ -126,6 +127,20 @@ public MatchDetailResponse getMatchDetail(Long matchId, Long userId) {
return MatchDetailResponse.of(matchRoom, user);
}

@Override
public void cancelMatch(Long matchId) {
MatchRoom matchRoom = matchRoomRepository.findById(matchId)
.orElseThrow(() -> new CustomException(MatchErrorCode.MATCH_NOT_FOUND));

if (matchRoom.getStatus() == MatchStatus.CANCELLED
|| matchRoom.getStatus() == MatchStatus.CLOSED
|| matchRoom.getStatus() == MatchStatus.COMPLETED) {
throw new CustomException(MatchErrorCode.MATCH_CANNOT_CANCEL);
}

matchRoom.cancel();
}
Comment on lines +130 to +142
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, locate the MatchRoom entity and MatchRoomRepository files
find src/main/java/com/be/sportizebe -name "MatchRoom.java" -o -name "MatchRoomRepository.java"

Repository: Sportize/be

Length of output: 202


🏁 Script executed:

# Check for `@Version`, `@Lock`, and locking-related annotations in the match domain
rg -n "@Version|@Lock|LockModeType|pessimistic|optimistic" src/main/java/com/be/sportizebe/domain/match --type=java

Repository: Sportize/be

Length of output: 37


🏁 Script executed:

# Find the MatchRoomRepository and examine its current methods
fd "MatchRoomRepository" src/main/java --type=java

Repository: Sportize/be

Length of output: 226


🏁 Script executed:

# Check the MatchRoom entity for version control
fd "MatchRoom\.java" src/main/java --type=java | head -5

Repository: Sportize/be

Length of output: 226


🏁 Script executed:

# Look for existing usage of locking patterns in similar cancel/update operations
rg -n "findByIdForUpdate|PESSIMISTIC_WRITE" src/main/java/com/be/sportizebe --type=java

Repository: Sportize/be

Length of output: 37


🏁 Script executed:

# Read the MatchRoom entity to check for `@Version` and other annotations
cat -n src/main/java/com/be/sportizebe/domain/match/entity/MatchRoom.java | head -100

Repository: Sportize/be

Length of output: 2955


🏁 Script executed:

# Read the MatchRoomRepository to check current methods
cat -n src/main/java/com/be/sportizebe/domain/match/repository/MatchRoomRepository.java

Repository: Sportize/be

Length of output: 2113


🏁 Script executed:

# Search for locking annotations/imports across match domain
rg -n "@Version|@Lock|LockModeType" src/main/java/com/be/sportizebe/domain/match

Repository: Sportize/be

Length of output: 37


🏁 Script executed:

# Check for pessimistic/optimistic locking patterns
rg -n "findByIdForUpdate|PESSIMISTIC_WRITE|OPTIMISTIC" src/main/java/com/be/sportizebe/domain

Repository: Sportize/be

Length of output: 37


🏁 Script executed:

# Read the full MatchServiceImpl to understand the context better
cat -n src/main/java/com/be/sportizebe/domain/match/service/MatchServiceImpl.java | sed -n '120,150p'

Repository: Sportize/be

Length of output: 1559


🏁 Script executed:

# Check if cancelMatch method has `@Transactional` or other synchronization annotations
cat -n src/main/java/com/be/sportizebe/domain/match/service/MatchServiceImpl.java | head -50

Repository: Sportize/be

Length of output: 2596


🏁 Script executed:

# Check class-level annotations on MatchServiceImpl
rg -A 10 "class MatchServiceImpl" src/main/java/com/be/sportizebe/domain/match/service/MatchServiceImpl.java

Repository: Sportize/be

Length of output: 445


🏁 Script executed:

# Check if there are any other concurrent cancel/update patterns in the codebase that use locking
rg -B 2 "cancel\(\)" src/main/java/com/be/sportizebe/domain --context=3

Repository: Sportize/be

Length of output: 1349


동시 취소 요청 시 상태 체크와 변경이 원자적이지 않아 중복 성공이 발생할 수 있습니다.

@Transactional만으로는 애플리케이션 계층의 TOCTOU 경합 조건을 방지하지 못합니다. 현재 cancelMatch() 메서드는:

  1. findById()로 조회
  2. 상태 조건 검사 (라인 135-138)
  3. cancel() 호출로 변경

이 경우 동시 요청이 모두 상태 검사를 통과한 후 실행될 수 있으므로, 두 번째 요청도 성공하게 됩니다. MatchRoom 엔티티에 @Version이 없고 MatchRoomRepository에 락 메서드가 없는 상태이므로, 비관적 락(PESSIMISTIC_WRITE)을 추가하여 원자성을 보장해야 합니다.

🔧 제안 수정안
-        MatchRoom matchRoom = matchRoomRepository.findById(matchId)
+        MatchRoom matchRoom = matchRoomRepository.findByIdForUpdate(matchId)
                 .orElseThrow(() -> new CustomException(MatchErrorCode.MATCH_NOT_FOUND));
// MatchRoomRepository.java (추가)
`@Lock`(LockModeType.PESSIMISTIC_WRITE)
`@Query`("select m from MatchRoom m where m.id = :matchId")
Optional<MatchRoom> findByIdForUpdate(`@Param`("matchId") Long matchId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/be/sportizebe/domain/match/service/MatchServiceImpl.java`
around lines 130 - 142, The cancelMatch method can suffer TOCTOU races because
it reads with matchRoomRepository.findById then checks status before cancelling;
add a pessimistic write lock to serialize concurrent cancels by adding a
repository method (e.g. findByIdForUpdate) annotated with
`@Lock`(LockModeType.PESSIMISTIC_WRITE) and use that method inside cancelMatch to
load the MatchRoom (instead of findById), then perform the same status checks
and call matchRoom.cancel(); keep the same exception flows (CustomException with
MatchErrorCode) so behavior is unchanged but the read-modify is now atomic.


@Override
public void deleteMatch(Long matchId) {
if (!matchRoomRepository.existsById(matchId)) {
Expand Down