From e79b827b46385d4b195ac55adec9ec1cfede33b8 Mon Sep 17 00:00:00 2001 From: Ji-minhyeok Date: Thu, 23 Apr 2026 17:04:49 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20Row=20Lock=20=EA=B2=BD=ED=95=A9?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=A0=88?= =?UTF-8?q?=EC=8A=A8=20=EC=B0=B8=EA=B0=80=EC=9E=90=20=EC=88=98=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20=EB=94=94?= =?UTF-8?q?=EC=BB=A4=ED=94=8C=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lesson/issue/LessonApplyService.java | 22 +++++-------------- .../LessonStockReconciliationScheduler.java | 18 ++++++++++++--- .../teacher/repository/LessonRepository.java | 15 +++++++++++++ 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonApplyService.java b/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonApplyService.java index c56ed6d1..e5725c33 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonApplyService.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonApplyService.java @@ -85,20 +85,8 @@ public int getBatchSize() { Map countsByLesson = messages.stream() .collect(Collectors.groupingBy(ApplyMessage::lessonId, Collectors.counting())); - for (Map.Entry entry : countsByLesson.entrySet()) { - Long lessonId = entry.getKey(); - int amount = entry.getValue().intValue(); - - int affectedRows = lessonRepository.incrementParticipantCountBatch(lessonId, amount, - LessonStatus.RECRUITMENT_COMPLETED); - - if (affectedRows == 0) { - log.error("Batch update failed for lesson [{}]: Capacity exceeded", lessonId); - Metrics.counter("lesson.apply.rejected", "lessonId", String.valueOf(lessonId)).increment(amount); - throw new BusinessException(ErrorCode.LESSON_NOT_AVAILABLE); - } - - // Dirty Set 등록 + for (Long lessonId : countsByLesson.keySet()) { + // Dirty Set 등록 (스케줄러 보정 요청) coreRedisTemplate.opsForSet().add(LessonApplyStreamConstant.DIRTY_SET_KEY, String.valueOf(lessonId)); } @@ -147,10 +135,12 @@ public boolean apply(Long lessonId, Long userId, String requestId, Long produceT return false; } - // 실제 DB 저장 및 카운트 증가 (원자적 연산 적용) + // 실제 DB 저장 LessonParticipant participant = LessonParticipant.builder().lesson(lesson).user(user).build(); lessonParticipantRepository.save(participant); - lessonRepository.incrementParticipantCount(lessonId); + + // Dirty Set 등록 (보정 스케줄러 처리 요청) + coreRedisTemplate.opsForSet().add(LessonApplyStreamConstant.DIRTY_SET_KEY, String.valueOf(lessonId)); // 처리 성공 결과 저장 및 지연 시간 측정 long latency = System.currentTimeMillis() - produceTime; diff --git a/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java b/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java index 80060d95..890c51f3 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java @@ -2,12 +2,14 @@ import java.util.Optional; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonParticipantRepository; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Profile; import org.springframework.data.redis.connection.stream.StreamInfo; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; @@ -24,6 +26,7 @@ public class LessonStockReconciliationScheduler { private final LessonRepository lessonRepository; + private final LessonParticipantRepository lessonParticipantRepository; private final LessonApplyProducer lessonApplyProducer; @Qualifier("coreRedisTemplate") @@ -32,6 +35,7 @@ public class LessonStockReconciliationScheduler { @Qualifier("mqRedisTemplate") private final StringRedisTemplate mqRedisTemplate; + @Transactional @Scheduled(fixedRate = 30000) @SchedulerLock(name = "LessonStockReconciliation", lockAtMostFor = "25s", lockAtLeastFor = "20s") public void reconcileStock() { @@ -41,7 +45,7 @@ public void reconcileStock() { return; } - log.info("Starting Smart Stock Reconciliation (DB -> Redis)..."); + log.info("Starting Smart Stock Reconciliation (Actual DB Count -> Lesson Row -> Redis)..."); String dirtySetKey = LessonApplyStreamConstant.DIRTY_SET_KEY; @@ -57,7 +61,14 @@ public void reconcileStock() { try { Long lessonId = Long.valueOf(lessonIdStr); - // DB 카운트 조회 + // 실제 참여자 수 계산 (DB) + long actualParticipantCount = lessonParticipantRepository.countByLessonId(lessonId); + + // Lesson 테이블 카운트 보정 및 상태 변경 + lessonRepository.updateParticipantCount(lessonId, (int)actualParticipantCount, + com.threestar.trainus.domain.lesson.teacher.entity.LessonStatus.RECRUITMENT_COMPLETED); + + // 최신 레슨 정보 조회하여 Redis 동기화 Optional lessonOpt = lessonRepository.findById(lessonId); if (lessonOpt.isPresent()) { Lesson lesson = lessonOpt.get(); @@ -74,7 +85,8 @@ public void reconcileStock() { coreRedisTemplate.opsForSet().remove(dirtySetKey, lessonIdStr); processedCount++; - log.debug("Reconciled lesson [{}] stock to [{}]", lessonId, currentStock); + log.debug("Reconciled lesson [{}] to actual count [{}] and stock [{}]", + lessonId, actualParticipantCount, currentStock); } } catch (Exception e) { log.error("Failed to reconcile lesson [{}]: {}", lessonIdStr, e.getMessage()); diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonRepository.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonRepository.java index b4b4aaa0..4258b6ee 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonRepository.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonRepository.java @@ -80,6 +80,21 @@ default void decrementParticipantCount(Long lessonId) { updateDecrementInternal(lessonId, LessonStatus.RECRUITING); } + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE Lesson l + SET l.participantCount = :count, + l.status = CASE WHEN :count >= l.maxParticipants + THEN :completedStatus + ELSE l.status END + WHERE l.id = :lessonId + """) + int updateParticipantCount( + @Param("lessonId") Long lessonId, + @Param("count") int count, + @Param("completedStatus") LessonStatus completedStatus + ); + // 중복 레슨 검증(같은 강사가 같은 이름과 시작시간으로 레슨 생성했는지 체크) @Query(""" SELECT COUNT(l) > 0 FROM Lesson l From 3d823b2df9c8a60a6e01d3067091bc4abf480503 Mon Sep 17 00:00:00 2001 From: Ji-minhyeok Date: Sat, 25 Apr 2026 00:17:00 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20Redis=20Busy=20Counter=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=EC=9D=84=20=ED=86=B5=ED=95=9C=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EA=B2=B0?= =?UTF-8?q?=ED=95=A8=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lesson/issue/LessonApplyConsumer.java | 34 +++++++++++++++---- .../LessonStockReconciliationScheduler.java | 11 ++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonApplyConsumer.java b/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonApplyConsumer.java index 1844aeeb..56dc2bf0 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonApplyConsumer.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonApplyConsumer.java @@ -27,6 +27,9 @@ public class LessonApplyConsumer implements StreamListener> buffer = new ConcurrentLinkedQueue<>(); @@ -35,14 +38,17 @@ public class LessonApplyConsumer implements StreamListener message) { + // 메세지를 받으면 레슨 ID 추출 및 Busy 카운터 증가 + String lessonIdStr = message.getValue().get("lessonId"); + if (lessonIdStr != null) { + String key = "lesson:busy:" + lessonIdStr; + coreRedisTemplate.opsForValue().increment(key); + coreRedisTemplate.expire(key, java.time.Duration.ofSeconds(60)); + } + buffer.add(message); // 설정값(BATCH_SIZE)이 되면 프로세스 시작 @@ -51,7 +57,7 @@ public void onMessage(MapRecord message) { } } - // 오래 방치된 데이터만 처리 + // 오래 방치된 데이터 처리를 위한 스케줄링 @Scheduled(fixedRate = 2000) public void scheduledProcess() { if (buffer.isEmpty()) { @@ -124,6 +130,20 @@ public Object execute(org.springframework.data.redis.core.RedisOperations operat mqRedisTemplate.opsForStream().delete(STREAM_KEY, record.getId()); } } + } finally { + // 작업 완료 후 각 레슨별로 이번 배치에서 처리한 개수만큼 Busy 카운터 감소 + Map countsPerLesson = messages.stream() + .collect(Collectors.groupingBy(ApplyMessage::lessonId, Collectors.counting())); + + countsPerLesson.forEach((lessonId, count) -> { + coreRedisTemplate.opsForValue().decrement("lesson:busy:" + lessonId, count); + }); } } + + public void clearBuffer() { + buffer.clear(); + lastProcessedTime = System.currentTimeMillis(); + log.info("Consumer buffer cleared."); + } } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java b/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java index 890c51f3..a47058f4 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java @@ -61,6 +61,17 @@ public void reconcileStock() { try { Long lessonId = Long.valueOf(lessonIdStr); + // 해당 레슨이 현재 배치 처리 중인지 확인 + String busyKey = "lesson:busy:" + lessonId; + String busyCountStr = coreRedisTemplate.opsForValue().get(busyKey); + int busyCount = (busyCountStr == null) ? 0 : Integer.parseInt(busyCountStr); + + if (busyCount > 0) { + log.info("Lesson [{}] is still being processed by batch consumers (Busy count: {}). Skipping reconciliation for now.", + lessonId, busyCount); + continue; + } + // 실제 참여자 수 계산 (DB) long actualParticipantCount = lessonParticipantRepository.countByLessonId(lessonId); From a1083b0c39aa2c71b790add86e383c82e2498801 Mon Sep 17 00:00:00 2001 From: Ji-minhyeok Date: Sat, 25 Apr 2026 15:18:42 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=EB=B0=B0=EC=B9=98=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B5=9C=EB=8C=80=20=ED=97=88=EC=9A=A9=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=A0=95=EC=9D=98=20=EB=B0=8F=20Busy=20Counter=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=9C=A0=EC=A7=80=20=EC=A3=BC=EA=B8=B0=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lesson/issue/LessonApplyConsumer.java | 10 ++++- .../LessonStockReconciliationScheduler.java | 37 ++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonApplyConsumer.java b/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonApplyConsumer.java index 56dc2bf0..e6e4be31 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonApplyConsumer.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonApplyConsumer.java @@ -45,8 +45,10 @@ public void onMessage(MapRecord message) { String lessonIdStr = message.getValue().get("lessonId"); if (lessonIdStr != null) { String key = "lesson:busy:" + lessonIdStr; + String lastActiveKey = "lesson:busy:last_active:" + lessonIdStr; coreRedisTemplate.opsForValue().increment(key); - coreRedisTemplate.expire(key, java.time.Duration.ofSeconds(60)); + coreRedisTemplate.expire(key, java.time.Duration.ofMinutes(10)); + coreRedisTemplate.opsForValue().set(lastActiveKey, String.valueOf(System.currentTimeMillis()), java.time.Duration.ofMinutes(10)); } buffer.add(message); @@ -132,11 +134,15 @@ public Object execute(org.springframework.data.redis.core.RedisOperations operat } } finally { // 작업 완료 후 각 레슨별로 이번 배치에서 처리한 개수만큼 Busy 카운터 감소 + // 작업 완료 후 각 레슨별로 마지막 처리 시점 기록 Map countsPerLesson = messages.stream() .collect(Collectors.groupingBy(ApplyMessage::lessonId, Collectors.counting())); countsPerLesson.forEach((lessonId, count) -> { - coreRedisTemplate.opsForValue().decrement("lesson:busy:" + lessonId, count); + String busyKey = "lesson:busy:" + lessonId; + String lastActiveKey = "lesson:busy:last_active:" + lessonId; + coreRedisTemplate.opsForValue().decrement(busyKey, count); + coreRedisTemplate.opsForValue().set(lastActiveKey, String.valueOf(System.currentTimeMillis()), java.time.Duration.ofMinutes(10)); }); } } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java b/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java index 27a132ca..29138c5e 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java @@ -63,13 +63,40 @@ public void reconcileStock() { // 해당 레슨이 현재 배치 처리 중인지 확인 String busyKey = "lesson:busy:" + lessonId; + String lastActiveKey = "lesson:busy:last_active:" + lessonId; String busyCountStr = coreRedisTemplate.opsForValue().get(busyKey); - int busyCount = (busyCountStr == null) ? 0 : Integer.parseInt(busyCountStr); - if (busyCount > 0) { - log.info("Lesson [{}] is still being processed by batch consumers (Busy count: {}). Skipping reconciliation for now.", - lessonId, busyCount); - continue; + // 보정 로직 미실행 조건 체크 + if (busyCountStr != null) { + // 마지막 활동 시간 로드 + String lastActiveStr = coreRedisTemplate.opsForValue().get(lastActiveKey); + long now = System.currentTimeMillis(); + int busyCount = Integer.parseInt(busyCountStr); + + // 일정 시간 무응답시 교착상태 방지 + boolean isStale = (lastActiveStr != null && (now - Long.parseLong(lastActiveStr) > 600000)); // 10분 이상 무응답 + boolean isRecent = (lastActiveStr != null && (now - Long.parseLong(lastActiveStr) < 5000)); // 5초 이내 활동 + boolean isNegative = busyCount < 0; + + // busy 카운터 존재 & 응답 10분 미만 + if (busyCount > 0 && !isStale) { + log.info("Lesson [{}] is still being processed (Busy count: {}). Skipping reconciliation.", + lessonId, busyCountStr); + continue; + } + + // busy 카운터 = 0 & 5초 내 활동이 있었음 (DB트랜잭션 잠시 대기) + if (busyCount == 0 && isRecent) { + log.info("Lesson [{}] recently active. Waiting for safety margin.", lessonId); + continue; + } + + // 응답 10분 초과 OR 음수 카운터 발생 (교착상태 방지 및 회복) + if (isStale || isNegative) { + log.warn("Detected stale or invalid busy state for lesson [{}] (Count: {}, Stale: {}). Forcing reset.", + lessonId, busyCountStr, isStale); + coreRedisTemplate.opsForValue().set(busyKey, "0", java.time.Duration.ofMinutes(10)); + } } // 실제 참여자 수 계산 (DB) From 629066da194b13ef9a1baeeeec40a429e09c4e3b Mon Sep 17 00:00:00 2001 From: Ji-minhyeok Date: Sat, 25 Apr 2026 16:32:00 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20=EB=B3=B4=EC=A0=95=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=EC=8B=9C=EA=B0=84=EA=B3=BC=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC=20=EC=A3=BC=EA=B8=B0=EB=A5=BC=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=ED=95=98=EC=97=AC=20=EC=A0=95=ED=95=A9?= =?UTF-8?q?=EC=84=B1=20=EB=B3=B4=EC=A0=95=20=EC=8B=A0=EB=A2=B0=EC=84=B1=20?= =?UTF-8?q?=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lesson/issue/LessonStockReconciliationScheduler.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java b/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java index 29138c5e..5bc5808b 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/issue/LessonStockReconciliationScheduler.java @@ -75,7 +75,7 @@ public void reconcileStock() { // 일정 시간 무응답시 교착상태 방지 boolean isStale = (lastActiveStr != null && (now - Long.parseLong(lastActiveStr) > 600000)); // 10분 이상 무응답 - boolean isRecent = (lastActiveStr != null && (now - Long.parseLong(lastActiveStr) < 5000)); // 5초 이내 활동 + boolean isRecent = (lastActiveStr != null && (now - Long.parseLong(lastActiveStr) < 30000)); // 30초 이내 활동 boolean isNegative = busyCount < 0; // busy 카운터 존재 & 응답 10분 미만 @@ -85,7 +85,7 @@ public void reconcileStock() { continue; } - // busy 카운터 = 0 & 5초 내 활동이 있었음 (DB트랜잭션 잠시 대기) + // busy 카운터 = 0 & 30초 내 활동이 있었음 (DB트랜잭션 잠시 대기) if (busyCount == 0 && isRecent) { log.info("Lesson [{}] recently active. Waiting for safety margin.", lessonId); continue;