From c276979a587d7ea5a6955a08231448eace534f8d Mon Sep 17 00:00:00 2001 From: Junkov0 Date: Fri, 7 Aug 2026 15:37:21 +0900 Subject: [PATCH 1/2] fix: lock room row before reordering on problem deletion deleteRoomProblem read the room without acquiring a lock, then bulk- updated problemOrder before deleting the target row. Concurrent delete requests on the same room could race and leave problemOrder with gaps or an inconsistent sequence, more likely across multiple instances. Switch to roomRepository.findByIdForUpdate, the same pessimistic-lock pattern already used by createRoomProblemsByAi via RoomProblemPersister#saveGeneratedProblems, so concurrent deletes on the same room are serialized. Co-Authored-By: Claude Sonnet 5 --- .../core/domain/room/service/RoomProblemServiceImpl.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemServiceImpl.java index 357f185..9a0e895 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemServiceImpl.java @@ -119,7 +119,9 @@ public RoomProblemResponse updateRoomProblem( @Transactional public void deleteRoomProblem(UUID userId, UUID roomId, UUID roomProblemId) { - Room room = getRoom(roomId); + // 같은 방에 대한 동시 삭제 요청을 직렬화하기 위해 비관적 락으로 조회 (createRoomProblemsByAi 채번과 동일 패턴) + Room room = roomRepository.findByIdForUpdate(roomId) + .orElseThrow(() -> new BusinessException(RoomErrorCode.ROOM_NOT_FOUND)); validateAdmin(userId, room); From af80e18604066d1229559a967a2aabb1c73e14b3 Mon Sep 17 00:00:00 2001 From: Junkov0 Date: Fri, 7 Aug 2026 15:47:14 +0900 Subject: [PATCH 2/2] fix: map unique-constraint races to BusinessException on category/room-problem creation Both createCategory (name uniqueness) and createRoomProblem (problem order uniqueness) only guarded against duplicates with a pre-check (existsByName / relying on the DB constraint alone). Under concurrent requests the pre-check can pass for both callers, leaving the DB unique constraint as the only real guard, which surfaced as an unmapped DataIntegrityViolationException instead of the intended BusinessException. Wrap the save in saveAndFlush + catch DataIntegrityViolationException, matching the existing isDuplicateEmailViolation pattern in UserServiceImpl: inspect getMostSpecificCause().getMessage() for the constraint name and rethrow as a BusinessException, otherwise propagate. Added RoomProblemErrorCode.DUPLICATE_PROBLEM_ORDER (7005); reused the existing CATEGORY_NAME_DUPLICATED for categories. Co-Authored-By: Claude Sonnet 5 --- .../service/ProblemCategoryServiceImpl.java | 31 ++++++++++++++++--- .../room/exception/RoomProblemErrorCode.java | 3 +- .../room/service/RoomProblemServiceImpl.java | 28 +++++++++++++++-- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemCategoryServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemCategoryServiceImpl.java index 7969140..677c206 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemCategoryServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemCategoryServiceImpl.java @@ -11,9 +11,12 @@ import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -32,14 +35,22 @@ public CategoryResponse createCategory(CategoryCreateRequest request) { String categoryName = request.name(); - // 이름 중복 검사 + // 이름 중복 검사 (동시 요청 시 이 체크를 통과해도 DB 유니크 제약으로 최종 방어됨 - 아래 catch 참고) if (categoryRepository.existsByName(categoryName)) { throw new BusinessException(ProblemErrorCode.CATEGORY_NAME_DUPLICATED); } - ProblemCategory savedCategory = categoryRepository.save(ProblemCategory.create(categoryName)); - - return categoryMapper.toResponse(savedCategory); + try { + ProblemCategory savedCategory = categoryRepository.saveAndFlush( + ProblemCategory.create(categoryName)); + return categoryMapper.toResponse(savedCategory); + } catch (DataIntegrityViolationException e) { + if (isDuplicateNameViolation(e)) { + log.warn("[ProblemCategoryService] 카테고리 이름 중복 제약 조건 위반 발생"); + throw new BusinessException(ProblemErrorCode.CATEGORY_NAME_DUPLICATED); + } + throw e; + } } /** @@ -73,5 +84,15 @@ public CategoryResponse updateCategory(UUID categoryId, CategoryUpdateRequest re return categoryMapper.toResponse(category); } - + /** + * DB 제약 조건 예외(DataIntegrityViolationException)가 카테고리 이름 유니크 제약(UQ_PROBLEM_CATEGORY_NAME) 위반인지 판단합니다. + */ + private boolean isDuplicateNameViolation(DataIntegrityViolationException e) { + Throwable cause = e.getMostSpecificCause(); + String message = cause.getMessage(); + if (message == null) { + return false; + } + return message.toLowerCase().contains("uq_problem_category_name"); + } } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/exception/RoomProblemErrorCode.java b/momogo-core/src/main/java/com/momogo/core/domain/room/exception/RoomProblemErrorCode.java index 4be0132..4b5a45e 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/exception/RoomProblemErrorCode.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/exception/RoomProblemErrorCode.java @@ -12,7 +12,8 @@ public enum RoomProblemErrorCode implements ErrorCode { NOT_FOUND(7001, "NOT_FOUND", HttpStatus.NOT_FOUND, "존재하지 않는 방 문제입니다."), ROOM_NOT_STARTED(7002, "ROOM_NOT_STARTED", HttpStatus.FORBIDDEN, "아직 시작되지 않은 시험입니다."), ROOM_ACCESS_DENIED(7003, "ROOM_ACCESS_DENIED", HttpStatus.FORBIDDEN, "해당 방에 접근 권한이 없습니다."), - DUPLICATE_AI_REQUEST(7004, "DUPLICATE_AI_REQUEST", HttpStatus.CONFLICT, "이미 처리 중인 요청입니다. 잠시 후 다시 시도해주세요."); + DUPLICATE_AI_REQUEST(7004, "DUPLICATE_AI_REQUEST", HttpStatus.CONFLICT, "이미 처리 중인 요청입니다. 잠시 후 다시 시도해주세요."), + DUPLICATE_PROBLEM_ORDER(7005, "DUPLICATE_PROBLEM_ORDER", HttpStatus.CONFLICT, "이미 사용 중인 문제 순번입니다."); private final int numeric; private final String errorKey; diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemServiceImpl.java index 9a0e895..e604789 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemServiceImpl.java @@ -25,12 +25,15 @@ import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -81,9 +84,16 @@ public RoomProblemResponse createRoomProblem( room, category, request.problemOrder(), request.name(), request.content(), request.explanation(), request.correctAnswer()); - RoomProblem saved = roomProblemRepository.save(roomProblem); - - return roomProblemMapper.toResponse(saved); + try { + RoomProblem saved = roomProblemRepository.saveAndFlush(roomProblem); + return roomProblemMapper.toResponse(saved); + } catch (DataIntegrityViolationException e) { + if (isDuplicateOrderViolation(e)) { + log.warn("[RoomProblemService] 방 문제 순번 중복 제약 조건 위반 발생"); + throw new BusinessException(RoomProblemErrorCode.DUPLICATE_PROBLEM_ORDER); + } + throw e; + } } /** @@ -212,4 +222,16 @@ private void validateAdmin(UUID userId, Room room) { throw new BusinessException(SpaceErrorCode.NOT_SPACE_ADMIN); } } + + /** + * DB 제약 조건 예외(DataIntegrityViolationException)가 방 문제 순번 유니크 제약(UQ_ROOM_PROBLEM_ROOM_ORDER) 위반인지 판단합니다. + */ + private boolean isDuplicateOrderViolation(DataIntegrityViolationException e) { + Throwable cause = e.getMostSpecificCause(); + String message = cause.getMessage(); + if (message == null) { + return false; + } + return message.toLowerCase().contains("uq_room_problem_room_order"); + } }