From 18376e8b338af55d5a7ae1ba48bc942b918f89bb Mon Sep 17 00:00:00 2001 From: Junkov0 Date: Thu, 6 Aug 2026 16:13:43 +0900 Subject: [PATCH 1/4] feat: add Idempotency-Key guard to prevent duplicate AI problem generation Multi-instance migration exposes duplicate AI-generation requests (double-click, network retry landing on a different instance) since there was no way to detect a resubmitted request. Use Redis SETNX with a 10min TTL, scoped per request, to reject retries while the original is still in flight. Co-Authored-By: Claude Sonnet 5 --- .../momogo/api/problem/ProblemController.java | 4 +- .../api/room/RoomProblemController.java | 4 +- .../problem/exception/ProblemErrorCode.java | 5 +- .../problem/service/ProblemService.java | 2 +- .../problem/service/ProblemServiceImpl.java | 60 +++++++++++++------ .../room/exception/RoomProblemErrorCode.java | 3 +- .../room/service/RoomProblemService.java | 2 +- .../room/service/RoomProblemServiceImpl.java | 50 ++++++++++++---- 8 files changed, 93 insertions(+), 37 deletions(-) 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..6320734 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,14 @@ 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.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -42,6 +44,8 @@ public class ProblemServiceImpl implements ProblemService { private final ProblemRepository problemRepository; + private final RedisTemplate redisTemplate; + private final ProblemCategoryRepository categoryRepository; private final ProblemCountersRepository countersRepository; @@ -49,7 +53,7 @@ public class ProblemServiceImpl implements ProblemService { private final SpaceRepository spaceRepository; private final ProblemMapper problemMapper; - + private final ProblemGenerationService problemGenerationService; private final ProblemPersister problemPersister; @@ -251,7 +255,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 +294,45 @@ 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; + + Boolean acquired = redisTemplate.opsForValue() + .setIfAbsent(lockKey, "IN_PROGRESS", 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); + } catch (RuntimeException e) { + redisTemplate.delete(lockKey); + throw e; } - - // 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..9d930ae 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,11 @@ 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.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -35,6 +37,8 @@ public class RoomProblemServiceImpl implements RoomProblemService { private final RoomRepository roomRepository; + private final RedisTemplate redisTemplate; + private final RoomProblemRepository roomProblemRepository; private final UserRepository userRepository; @@ -89,9 +93,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 +129,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; - validateAdmin(userId, room); + Boolean acquired = redisTemplate.opsForValue() + .setIfAbsent(lockKey, "IN_PROGRESS", Duration.ofMinutes(10)); - ProblemCategory category = getCategory(request.categoryId()); + if (Boolean.FALSE.equals(acquired)) { + throw new BusinessException(RoomProblemErrorCode.DUPLICATE_AI_REQUEST); + } - List generated = problemGenerationService.generateProblems( - request.referenceText(), - request.questionCount() - ); + try { + Room room = roomRepository.findByIdWithSpace(roomId) + .orElseThrow(() -> new BusinessException(RoomErrorCode.ROOM_NOT_FOUND)); - return roomProblemPersister.saveGeneratedProblems(roomId, category, generated); + validateAdmin(userId, room); + + ProblemCategory category = getCategory(request.categoryId()); + + List generated = problemGenerationService.generateProblems( + request.referenceText(), + request.questionCount() + ); + + return roomProblemPersister.saveGeneratedProblems(roomId, category, generated); + } catch (RuntimeException e) { + + // 실패 시엔 락을 바로 풀어서 정상 재시도를 막지 않음 (TTL 10분까지 기다리게 하면 안 됨) + redisTemplate.delete(lockKey); + throw e; + } } @@ -170,7 +195,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); } } From e2b76c9ffdfef51fafb2d5b523a0a81d1f4a7f0a Mon Sep 17 00:00:00 2001 From: Junkov0 Date: Thu, 6 Aug 2026 16:18:23 +0900 Subject: [PATCH 2/4] feat: send Idempotency-Key header on AI problem generation request Backend now requires this header (see PR #61); without it the space AI-generation call fails with 400 MissingRequestHeaderException. Co-Authored-By: Claude Sonnet 5 --- momogo-frontend/src/pages/SpacePage.tsx | 1 + 1 file changed, 1 insertion(+) 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, From 499c8bb3775d7a82d13fc8dcb84dbef12a89a5ee Mon Sep 17 00:00:00 2001 From: Junkov0 Date: Fri, 7 Aug 2026 14:52:02 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20Redis=20=EB=9D=BD=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=20=EC=86=8C=EC=9C=A0=EA=B6=8C=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EA=B2=80=EC=A6=9D=EC=9C=BC=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TTL 만료 후 다른 요청이 같은 키로 락을 재획득했을 때, 기존 요청의 실패 처리가 무조건 delete를 호출해 새 락을 지우는 문제가 있었음. 락 값을 요청별 토큰으로 바꾸고 Lua compare-and-delete로 소유자만 해제하도록 수정. Co-Authored-By: Claude Sonnet 5 --- .../problem/service/ProblemServiceImpl.java | 17 +++++++++++++++-- .../room/service/RoomProblemServiceImpl.java | 17 +++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) 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 6320734..2a9865b 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 @@ -32,6 +32,7 @@ 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; @@ -62,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); + /** * 문제 직접 생성 * @@ -304,9 +314,11 @@ public List createProblemsByAi(UUID spaceId, UUID idempotencyKe ProblemAiCreateRequest request) { String lockKey = "idem:problem-ai:" + idempotencyKey; + // 락 값을 요청 식별 토큰으로 - TTL 만료 후 다른 요청이 같은 키로 락을 새로 잡았을 때 구분하기 위함 + String ownerToken = UUID.randomUUID().toString(); Boolean acquired = redisTemplate.opsForValue() - .setIfAbsent(lockKey, "IN_PROGRESS", Duration.ofMinutes(10)); + .setIfAbsent(lockKey, ownerToken, Duration.ofMinutes(10)); if (Boolean.FALSE.equals(acquired)) { throw new BusinessException(ProblemErrorCode.DUPLICATE_AI_REQUEST); @@ -331,7 +343,8 @@ public List createProblemsByAi(UUID spaceId, UUID idempotencyKe return problemPersister.saveGeneratedProblems(spaceId, request.categoryId(), generated); } catch (RuntimeException e) { - redisTemplate.delete(lockKey); + // 무조건 delete 대신 내 토큰일 때만 삭제 (TTL 초과로 락이 넘어갔으면 남의 락을 지우지 않음) + redisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), ownerToken); throw e; } } 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 9d930ae..2c9e217 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 @@ -26,6 +26,7 @@ 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; @@ -35,6 +36,15 @@ @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; @@ -135,9 +145,11 @@ public List createRoomProblemsByAi( // 같은 idempotencyKey로 들어온 재시도(더블클릭/네트워크 재전송)를 차단 // 인스턴스가 여러 대라도 Redis가 공용 상태를 갖고 있어 판단 가능 String lockKey = "idem:room-problem-ai:" + idempotencyKey; + // 락 값을 요청 식별 토큰으로 - TTL 만료 후 다른 요청이 같은 키로 락을 새로 잡았을 때 구분하기 위함 + String ownerToken = UUID.randomUUID().toString(); Boolean acquired = redisTemplate.opsForValue() - .setIfAbsent(lockKey, "IN_PROGRESS", Duration.ofMinutes(10)); + .setIfAbsent(lockKey, ownerToken, Duration.ofMinutes(10)); if (Boolean.FALSE.equals(acquired)) { throw new BusinessException(RoomProblemErrorCode.DUPLICATE_AI_REQUEST); @@ -160,7 +172,8 @@ public List createRoomProblemsByAi( } catch (RuntimeException e) { // 실패 시엔 락을 바로 풀어서 정상 재시도를 막지 않음 (TTL 10분까지 기다리게 하면 안 됨) - redisTemplate.delete(lockKey); + // 무조건 delete 대신 내 토큰일 때만 삭제 (TTL 초과로 락이 넘어갔으면 남의 락을 지우지 않음) + redisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), ownerToken); throw e; } } From f8b4b799182b66a8a36bf100d5ebb18291638222 Mon Sep 17 00:00:00 2001 From: Junkov0 Date: Fri, 7 Aug 2026 15:01:00 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20AI=20=EB=AC=B8=EC=A0=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=9D=BD=20=ED=95=B4=EC=A0=9C=EB=A5=BC=20try-final?= =?UTF-8?q?ly=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit catch(RuntimeException)만 처리해 성공 시 락이 TTL까지 남고, Error 계열(OOM 등) 발생 시 락 누수가 있었음. finally로 바꿔 성공/실패/Error 모든 경로에서 락이 해제되도록 수정. Co-Authored-By: Claude Sonnet 5 --- .../core/domain/problem/service/ProblemServiceImpl.java | 5 ++--- .../core/domain/room/service/RoomProblemServiceImpl.java | 7 ++----- 2 files changed, 4 insertions(+), 8 deletions(-) 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 2a9865b..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 @@ -342,10 +342,9 @@ public List createProblemsByAi(UUID spaceId, UUID idempotencyKe ); return problemPersister.saveGeneratedProblems(spaceId, request.categoryId(), generated); - } catch (RuntimeException e) { - // 무조건 delete 대신 내 토큰일 때만 삭제 (TTL 초과로 락이 넘어갔으면 남의 락을 지우지 않음) + } finally { + // 성공/실패/Error 무관 항상 해제. 무조건 delete 대신 내 토큰일 때만 삭제 (TTL 초과로 락이 넘어갔으면 남의 락을 지우지 않음) redisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), ownerToken); - throw e; } } } 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 2c9e217..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 @@ -169,12 +169,9 @@ public List createRoomProblemsByAi( ); return roomProblemPersister.saveGeneratedProblems(roomId, category, generated); - } catch (RuntimeException e) { - - // 실패 시엔 락을 바로 풀어서 정상 재시도를 막지 않음 (TTL 10분까지 기다리게 하면 안 됨) - // 무조건 delete 대신 내 토큰일 때만 삭제 (TTL 초과로 락이 넘어갔으면 남의 락을 지우지 않음) + } finally { + // 성공/실패/Error 무관 항상 해제. 무조건 delete 대신 내 토큰일 때만 삭제 (TTL 초과로 락이 넘어갔으면 남의 락을 지우지 않음) redisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), ownerToken); - throw e; } }