diff --git a/momogo-api/src/main/java/com/momogo/api/problem/ProblemController.java b/momogo-api/src/main/java/com/momogo/api/problem/ProblemController.java index 71d2538..342020a 100644 --- a/momogo-api/src/main/java/com/momogo/api/problem/ProblemController.java +++ b/momogo-api/src/main/java/com/momogo/api/problem/ProblemController.java @@ -24,6 +24,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -119,9 +120,10 @@ public ResponseEntity deleteProblem( @PreAuthorize("hasRole('ADMIN')") public ResponseEntity> createProblemsByAi( @PathVariable UUID spaceId, + @RequestHeader("Idempotency-Key") UUID idempotencyKey, @RequestBody @Valid ProblemAiCreateRequest request) { - List response = problemService.createProblemsByAi(spaceId, request); + List response = problemService.createProblemsByAi(spaceId, idempotencyKey, request); return ResponseEntity .status(HttpStatus.CREATED) diff --git a/momogo-api/src/main/java/com/momogo/api/room/RoomProblemController.java b/momogo-api/src/main/java/com/momogo/api/room/RoomProblemController.java index 4d86c4a..141ccf3 100644 --- a/momogo-api/src/main/java/com/momogo/api/room/RoomProblemController.java +++ b/momogo-api/src/main/java/com/momogo/api/room/RoomProblemController.java @@ -19,6 +19,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -89,10 +90,11 @@ public ResponseEntity deleteRoomProblem( public ResponseEntity> createRoomProblemsByAi( @AuthenticationPrincipal MoMoGoUserDetails userDetails, @PathVariable UUID roomId, + @RequestHeader("Idempotency-Key") UUID idempotencyKey, @Valid @RequestBody RoomProblemAiCreateRequest request) { List response = roomProblemService. - createRoomProblemsByAi(userDetails.getUserResponse().id(), roomId, request); + createRoomProblemsByAi(userDetails.getUserResponse().id(), roomId, idempotencyKey, request); return ResponseEntity .status(HttpStatus.CREATED) diff --git a/momogo-core/src/main/java/com/momogo/core/domain/problem/exception/ProblemErrorCode.java b/momogo-core/src/main/java/com/momogo/core/domain/problem/exception/ProblemErrorCode.java index 8133564..fb928e7 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/problem/exception/ProblemErrorCode.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/problem/exception/ProblemErrorCode.java @@ -40,7 +40,10 @@ public enum ProblemErrorCode implements ErrorCode { HttpStatus.SERVICE_UNAVAILABLE, "AI 문제 생성에 실패했습니다. 잠시 후 다시 시도해주세요."), COUNTER_NOT_FOUND(3010, "COUNTER_NOT_FOUND", - HttpStatus.INTERNAL_SERVER_ERROR, "문제 통계 정보를 찾을 수 없습니다."); + HttpStatus.INTERNAL_SERVER_ERROR, "문제 통계 정보를 찾을 수 없습니다."), + + DUPLICATE_AI_REQUEST(3011, "DUPLICATE_AI_REQUEST", + HttpStatus.CONFLICT, "이미 처리 중인 요청입니다. 잠시 후 다시 시도해주세요."); private final int numeric; private final String errorKey; diff --git a/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemService.java b/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemService.java index 9b86ade..24568b0 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemService.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemService.java @@ -76,5 +76,5 @@ ProblemCursorResponse getProblems( * @param spaceId 공간 ID * @param request AI 문제 자동 생성 요청 DTO */ - List createProblemsByAi(UUID spaceId, ProblemAiCreateRequest request); + List createProblemsByAi(UUID spaceId, UUID idempotencyKey, ProblemAiCreateRequest request); } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemServiceImpl.java index 033e6c0..c15dd7b 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemServiceImpl.java @@ -24,12 +24,15 @@ import com.momogo.core.domain.user.entity.UserProblemId; import com.momogo.core.domain.user.repository.UserProblemRepository; import com.momogo.core.domain.user.repository.UserRepository; +import java.time.Duration; import java.time.OffsetDateTime; import java.util.List; import java.util.Set; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +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; @@ -42,6 +45,8 @@ public class ProblemServiceImpl implements ProblemService { private final ProblemRepository problemRepository; + private final RedisTemplate redisTemplate; + private final ProblemCategoryRepository categoryRepository; private final ProblemCountersRepository countersRepository; @@ -49,7 +54,7 @@ public class ProblemServiceImpl implements ProblemService { private final SpaceRepository spaceRepository; private final ProblemMapper problemMapper; - + private final ProblemGenerationService problemGenerationService; private final ProblemPersister problemPersister; @@ -58,6 +63,15 @@ public class ProblemServiceImpl implements ProblemService { private final UserRepository userRepository; + // GET한 값이 내 토큰과 같을 때만 DEL - TTL 만료로 락이 다른 요청에 넘어간 경우 잘못 삭제하는 것 방지 + private static final RedisScript UNLOCK_SCRIPT = RedisScript.of(""" + if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) + else + return 0 + end + """, Long.class); + /** * 문제 직접 생성 * @@ -251,7 +265,8 @@ public ProblemSolveResponse solveProblem( String normalizedUserAnswer = request.userAnswer().replaceAll("\\s+", "").toLowerCase(); - String normalizedCorrectAnswer = problem.getCorrectAnswer().replaceAll("\\s+", "").toLowerCase(); + String normalizedCorrectAnswer = problem.getCorrectAnswer().replaceAll("\\s+", + "").toLowerCase(); boolean isSolved = normalizedUserAnswer.contains(normalizedCorrectAnswer); @@ -289,28 +304,47 @@ public ProblemSolveResponse solveProblem( /** * AI 기반 문제 자동 생성 (ADMIN 전용) - * @param spaceId 공간 ID - * @param request AI 문제 자동 생성 요청 DTO + * + * @param spaceId 공간 ID + * @param request AI 문제 자동 생성 요청 DTO */ @Override @Transactional(propagation = Propagation.NOT_SUPPORTED) // 해당 메서드 자체는 트랜잭션 없이 실행 - public List createProblemsByAi(UUID spaceId, ProblemAiCreateRequest request) { - - // 공간 / 카테고리 검증 - if (!spaceRepository.existsById(spaceId)) { - throw new BusinessException(ProblemErrorCode.SPACE_NOT_FOUND); + public List createProblemsByAi(UUID spaceId, UUID idempotencyKey, + ProblemAiCreateRequest request) { + + String lockKey = "idem:problem-ai:" + idempotencyKey; + // 락 값을 요청 식별 토큰으로 - TTL 만료 후 다른 요청이 같은 키로 락을 새로 잡았을 때 구분하기 위함 + String ownerToken = UUID.randomUUID().toString(); + + Boolean acquired = redisTemplate.opsForValue() + .setIfAbsent(lockKey, ownerToken, Duration.ofMinutes(10)); + + if (Boolean.FALSE.equals(acquired)) { + throw new BusinessException(ProblemErrorCode.DUPLICATE_AI_REQUEST); } - if (!categoryRepository.existsById(request.categoryId())) { - throw new BusinessException(ProblemErrorCode.CATEGORY_NOT_FOUND); + try { + + // 공간 / 카테고리 검증 + if (!spaceRepository.existsById(spaceId)) { + throw new BusinessException(ProblemErrorCode.SPACE_NOT_FOUND); + } + + if (!categoryRepository.existsById(request.categoryId())) { + throw new BusinessException(ProblemErrorCode.CATEGORY_NOT_FOUND); + } + + // AI 생성 호출 (트랜잭션 밖 - 커넥션 안 잡고 LLM 응답 대기) + List generated = problemGenerationService.generateProblems( + request.referenceText(), + request.questionCount() + ); + + return problemPersister.saveGeneratedProblems(spaceId, request.categoryId(), generated); + } finally { + // 성공/실패/Error 무관 항상 해제. 무조건 delete 대신 내 토큰일 때만 삭제 (TTL 초과로 락이 넘어갔으면 남의 락을 지우지 않음) + redisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), ownerToken); } - - // AI 생성 호출 (트랜잭션 밖 - 커넥션 안 잡고 LLM 응답 대기) - List generated = problemGenerationService.generateProblems( - request.referenceText(), - request.questionCount() - ); - - return problemPersister.saveGeneratedProblems(spaceId, request.categoryId(), generated); } } 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 c32718c..4be0132 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 @@ -11,7 +11,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, "해당 방에 접근 권한이 없습니다."); + ROOM_ACCESS_DENIED(7003, "ROOM_ACCESS_DENIED", HttpStatus.FORBIDDEN, "해당 방에 접근 권한이 없습니다."), + DUPLICATE_AI_REQUEST(7004, "DUPLICATE_AI_REQUEST", HttpStatus.CONFLICT, "이미 처리 중인 요청입니다. 잠시 후 다시 시도해주세요."); private final int numeric; private final String errorKey; diff --git a/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemService.java b/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemService.java index 05a6a76..e83277f 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemService.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/room/service/RoomProblemService.java @@ -40,5 +40,5 @@ public interface RoomProblemService { * @param roomId 방 ID * @param request AI 문제 생성 DTO */ - List createRoomProblemsByAi(UUID userId, UUID roomId, RoomProblemAiCreateRequest request); + List createRoomProblemsByAi(UUID userId, UUID roomId, UUID idempotencyKey, RoomProblemAiCreateRequest request); } 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 2024947..357f185 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 @@ -21,9 +21,12 @@ import com.momogo.core.domain.user.entity.User; import com.momogo.core.domain.user.entity.enums.UserRole; import com.momogo.core.domain.user.repository.UserRepository; +import java.time.Duration; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; +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; @@ -33,8 +36,19 @@ @Transactional(readOnly = true) public class RoomProblemServiceImpl implements RoomProblemService { + // GET한 값이 내 토큰과 같을 때만 DEL - TTL 만료로 락이 다른 요청에 넘어간 경우 잘못 삭제하는 것 방지 + private static final RedisScript UNLOCK_SCRIPT = RedisScript.of(""" + if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) + else + return 0 + end + """, Long.class); + private final RoomRepository roomRepository; + private final RedisTemplate redisTemplate; + private final RoomProblemRepository roomProblemRepository; private final UserRepository userRepository; @@ -89,9 +103,11 @@ public RoomProblemResponse updateRoomProblem( RoomProblem roomProblem = getRoomProblem(roomId, roomProblemId); - ProblemCategory category = request.categoryId() != null ? getCategory(request.categoryId()) : null; + ProblemCategory category = + request.categoryId() != null ? getCategory(request.categoryId()) : null; - roomProblem.update(category, null, request.name(), request.content(), request.explanation(), request.correctAnswer()); + roomProblem.update(category, null, request.name(), request.content(), request.explanation(), + request.correctAnswer()); return roomProblemMapper.toResponse(roomProblem); } @@ -123,21 +139,40 @@ public void deleteRoomProblem(UUID userId, UUID roomId, UUID roomProblemId) { public List createRoomProblemsByAi( UUID userId, UUID roomId, + UUID idempotencyKey, RoomProblemAiCreateRequest request) { - Room room = roomRepository.findByIdWithSpace(roomId) - .orElseThrow(() -> new BusinessException(RoomErrorCode.ROOM_NOT_FOUND)); + // 같은 idempotencyKey로 들어온 재시도(더블클릭/네트워크 재전송)를 차단 + // 인스턴스가 여러 대라도 Redis가 공용 상태를 갖고 있어 판단 가능 + String lockKey = "idem:room-problem-ai:" + idempotencyKey; + // 락 값을 요청 식별 토큰으로 - TTL 만료 후 다른 요청이 같은 키로 락을 새로 잡았을 때 구분하기 위함 + String ownerToken = UUID.randomUUID().toString(); - validateAdmin(userId, room); + Boolean acquired = redisTemplate.opsForValue() + .setIfAbsent(lockKey, ownerToken, Duration.ofMinutes(10)); - ProblemCategory category = getCategory(request.categoryId()); + if (Boolean.FALSE.equals(acquired)) { + throw new BusinessException(RoomProblemErrorCode.DUPLICATE_AI_REQUEST); + } + + try { + Room room = roomRepository.findByIdWithSpace(roomId) + .orElseThrow(() -> new BusinessException(RoomErrorCode.ROOM_NOT_FOUND)); + + validateAdmin(userId, room); - List generated = problemGenerationService.generateProblems( - request.referenceText(), - request.questionCount() - ); + ProblemCategory category = getCategory(request.categoryId()); - return roomProblemPersister.saveGeneratedProblems(roomId, category, generated); + List generated = problemGenerationService.generateProblems( + request.referenceText(), + request.questionCount() + ); + + return roomProblemPersister.saveGeneratedProblems(roomId, category, generated); + } finally { + // 성공/실패/Error 무관 항상 해제. 무조건 delete 대신 내 토큰일 때만 삭제 (TTL 초과로 락이 넘어갔으면 남의 락을 지우지 않음) + redisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), ownerToken); + } } @@ -170,7 +205,8 @@ private void validateAdmin(UUID userId, Room room) { User user = userRepository.findByIdWithSpace(userId) .orElseThrow(() -> new BusinessException(SpaceErrorCode.SPACE_USER_NOT_FOUND)); - if (user.getRole() != UserRole.ADMIN || user.getSpace() == null || !user.getSpace().getId().equals(room.getSpace().getId())) { + if (user.getRole() != UserRole.ADMIN || user.getSpace() == null + || !user.getSpace().getId().equals(room.getSpace().getId())) { throw new BusinessException(SpaceErrorCode.NOT_SPACE_ADMIN); } } diff --git a/momogo-frontend/src/pages/SpacePage.tsx b/momogo-frontend/src/pages/SpacePage.tsx index a13b753..8dd2acd 100644 --- a/momogo-frontend/src/pages/SpacePage.tsx +++ b/momogo-frontend/src/pages/SpacePage.tsx @@ -418,6 +418,7 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT setLoading(true); await request(`/api/spaces/${space.id}/problems/ai`, { method: 'POST', + headers: { 'Idempotency-Key': crypto.randomUUID() }, body: JSON.stringify({ categoryId: aiCategory, referenceText: aiRefData,