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 357f185..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; + } } /** @@ -119,7 +129,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); @@ -210,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"); + } }