diff --git a/edukit-api/src/main/java/com/edukit/EdukitApiApplication.java b/edukit-api/src/main/java/com/edukit/EdukitApiApplication.java index 4f549343..cc3df321 100644 --- a/edukit-api/src/main/java/com/edukit/EdukitApiApplication.java +++ b/edukit-api/src/main/java/com/edukit/EdukitApiApplication.java @@ -4,7 +4,9 @@ import java.util.TimeZone; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +@EnableAspectJAutoProxy @SpringBootApplication public class EdukitApiApplication { diff --git a/edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java b/edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java index 99abe84b..43dc787f 100644 --- a/edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java +++ b/edukit-api/src/main/java/com/edukit/studentrecord/controller/StudentRecordAIController.java @@ -2,7 +2,6 @@ import com.edukit.common.EdukitResponse; import com.edukit.common.annotation.MemberId; -import com.edukit.core.studentrecord.db.entity.StudentRecord; import com.edukit.core.studentrecord.exception.StudentRecordErrorCode; import com.edukit.core.studentrecord.exception.StudentRecordException; import com.edukit.core.studentrecord.service.StudentRecordService; @@ -31,15 +30,12 @@ public class StudentRecordAIController implements StudentRecordAIApi { @PostMapping("/ai-generate/{recordId}") public ResponseEntity> aiGenerateStudentRecord( - @MemberId final long memberId, - @PathVariable final long recordId, + @MemberId final long memberId, @PathVariable final long recordId, @RequestBody @Valid final StudentRecordPromptRequest request) { - // 컨트롤러에서 직접 타입 조회 (1회 DB 조회) - StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId); - // 타입과 함께 호출 (AOP에서 DB 조회 없음) StudentRecordTaskResponse response = studentRecordAIFacade.createTaskId(memberId, recordId, - studentRecord.getStudentRecordType(), request.byteCount(), request.prompt()); + request.byteCount(), request.prompt() + ); return ResponseEntity.ok(EdukitResponse.success(response)); } diff --git a/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java b/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java index 12a2ff60..f1e7ae80 100644 --- a/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java +++ b/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordAIFacade.java @@ -3,10 +3,11 @@ import com.edukit.common.annotation.AIGenerationMetrics; import com.edukit.core.member.db.entity.Member; import com.edukit.core.member.service.MemberService; +import com.edukit.core.studentrecord.db.entity.StudentRecord; import com.edukit.core.studentrecord.db.entity.StudentRecordAITask; -import com.edukit.core.studentrecord.db.enums.StudentRecordType; import com.edukit.core.studentrecord.service.AITaskService; import com.edukit.core.studentrecord.service.SSEChannelManager; +import com.edukit.core.studentrecord.service.StudentRecordService; import com.edukit.core.studentrecord.util.AIPromptGenerator; import com.edukit.studentrecord.event.AITaskCreateEvent; import com.edukit.studentrecord.facade.response.StudentRecordTaskResponse; @@ -22,17 +23,18 @@ public class StudentRecordAIFacade { private final MemberService memberService; private final AITaskService aiTaskService; + private final StudentRecordService studentRecordService; private final SSEChannelManager sseChannelManager; private final ApplicationEventPublisher eventPublisher; @Transactional @AIGenerationMetrics - public StudentRecordTaskResponse createTaskId(final long memberId, final long recordId, - final StudentRecordType recordType, final int byteCount, + public StudentRecordTaskResponse createTaskId(final long memberId, final long recordId, final int byteCount, final String userPrompt) { Member member = memberService.getMemberById(memberId); + StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId); - String requestPrompt = AIPromptGenerator.createStreamingPrompt(recordType, byteCount, userPrompt); + String requestPrompt = AIPromptGenerator.createStreamingPrompt(studentRecord.getStudentRecordType(), byteCount, userPrompt); StudentRecordAITask task = aiTaskService.createAITask(member, userPrompt); eventPublisher.publishEvent(AITaskCreateEvent.of(task, userPrompt, requestPrompt, byteCount)); diff --git a/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordFacade.java b/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordFacade.java index f344d2ff..0a75480c 100644 --- a/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordFacade.java +++ b/edukit-api/src/main/java/com/edukit/studentrecord/facade/StudentRecordFacade.java @@ -48,7 +48,6 @@ public void updateStudentRecord(final long memberId, final long recordId, final studentRecordService.updateStudentRecord(studentRecord, description); } - @Transactional(readOnly = true) public StudentRecordDetailResponse getStudentRecord(final long memberId, final long recordId) { StudentRecord recordDetail = studentRecordService.getRecordDetail(memberId, recordId); diff --git a/edukit-core/src/main/java/com/edukit/core/studentrecord/aop/StudentRecordMetricsAspect.java b/edukit-core/src/main/java/com/edukit/core/studentrecord/aop/StudentRecordMetricsAspect.java index b469ca4f..729e2958 100644 --- a/edukit-core/src/main/java/com/edukit/core/studentrecord/aop/StudentRecordMetricsAspect.java +++ b/edukit-core/src/main/java/com/edukit/core/studentrecord/aop/StudentRecordMetricsAspect.java @@ -1,8 +1,10 @@ package com.edukit.core.studentrecord.aop; +import com.edukit.core.studentrecord.db.entity.StudentRecord; import com.edukit.core.studentrecord.db.enums.StudentRecordType; -import com.edukit.core.studentrecord.service.GenerationTrackingService; -import com.edukit.core.studentrecord.service.StudentRecordMetricsService; +import com.edukit.core.studentrecord.service.RecordGenerationTracker; +import com.edukit.core.studentrecord.service.StudentRecordMetricsCounter; +import com.edukit.core.studentrecord.service.StudentRecordService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; @@ -11,8 +13,6 @@ import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; @Slf4j @Aspect @@ -20,54 +20,27 @@ @RequiredArgsConstructor public class StudentRecordMetricsAspect { - private final StudentRecordMetricsService metricsService; - private final GenerationTrackingService generationTrackingService; + private final StudentRecordMetricsCounter metricsService; + private final RecordGenerationTracker recordGenerationTracker; + private final StudentRecordService studentRecordService; - @AfterReturning("@annotation(com.edukit.common.annotation.StudentRecordMetrics)") + @AfterReturning(pointcut = "@annotation(com.edukit.common.annotation.StudentRecordMetrics)") public void collectCompletionMetrics(final JoinPoint joinPoint) { Object[] args = joinPoint.getArgs(); - if (args.length >= 4) { + if (args.length == 3) { + long memberId = (Long) args[0]; long recordId = (Long) args[1]; - StudentRecordType recordType = (StudentRecordType) args[2]; - String description = (String) args[3]; + String description = (String) args[2]; - try { - // 트랜잭션 커밋 후에만 메트릭 수집 및 정리 작업 수행 - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - try { - metricsService.recordCompletion(recordType, description); - - // 커밋 성공 후 생성 추적 정보 정리 (메모리 절약) - generationTrackingService.clearRecord(recordId); - - log.debug("Completion metrics recorded after transaction commit for recordId: {}", recordId); - - } catch (Exception e) { - log.warn("Error collecting completion metrics after commit for recordId: {}", recordId, e); - } - } - - @Override - public void afterCompletion(int status) { - if (status == STATUS_ROLLED_BACK) { - // 롤백 시에도 생성 추적 정보 정리 (메모리 누수 방지) - generationTrackingService.clearRecord(recordId); - log.debug("Transaction rolled back - cleared generation tracking for recordId: {}", recordId); - } - } - }); - } else { - metricsService.recordCompletion(recordType, description); - generationTrackingService.clearRecord(recordId); - log.warn("No transaction synchronization - recording metrics immediately for recordId: {}", recordId); - } + StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId); + StudentRecordType recordType = studentRecord.getStudentRecordType(); + try { + metricsService.recordCompletion(recordType, description); } catch (Exception e) { - log.warn("Error setting up completion metrics collection", e); + // 메트릭 수집 실패는 로그만 남기고 비즈니스 로직은 계속 진행 + log.warn("Error collecting completion metrics for recordType: {}", recordType, e); } } } @@ -76,12 +49,15 @@ public void afterCompletion(int status) { public Object collectAIGenerationMetrics(final ProceedingJoinPoint joinPoint) throws Throwable { Object[] args = joinPoint.getArgs(); - if (args.length >= 3) { + if (args.length == 4) { + long memberId = (Long) args[0]; long recordId = (Long) args[1]; - StudentRecordType recordType = (StudentRecordType) args[2]; + + StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId); + StudentRecordType recordType = studentRecord.getStudentRecordType(); try { - boolean isFirstGeneration = generationTrackingService.isFirstGeneration(recordId); + boolean isFirstGeneration = recordGenerationTracker.isFirstGeneration(recordId); // 전체 AI 생성 요청 카운트 metricsService.recordAIGenerationRequest(recordType); diff --git a/edukit-core/src/main/java/com/edukit/core/studentrecord/service/GenerationTrackingService.java b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/GenerationTrackingService.java deleted file mode 100644 index f80095f1..00000000 --- a/edukit-core/src/main/java/com/edukit/core/studentrecord/service/GenerationTrackingService.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.edukit.core.studentrecord.service; - -import java.time.LocalDateTime; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -public class GenerationTrackingService { - - private final ConcurrentHashMap generationCounts = new ConcurrentHashMap<>(); - - public boolean isFirstGeneration(long recordId) { - GenerationInfo info = generationCounts.compute(recordId, (key, existing) -> { - if (existing == null) { - return new GenerationInfo(1, LocalDateTime.now()); - } else { - existing.incrementCount(); - return existing; - } - }); - - boolean isFirst = info.getCount() == 1; - log.debug("RecordId: {}, Generation count: {}, Is first: {}", recordId, info.getCount(), isFirst); - - return isFirst; - } - - public void clearRecord(long recordId) { - generationCounts.remove(recordId); - log.debug("Cleared generation tracking for recordId: {}", recordId); - } - - private static class GenerationInfo { - private final AtomicInteger count; - @Getter - private final LocalDateTime firstGenerationTime; - - public GenerationInfo(int initialCount, LocalDateTime firstGenerationTime) { - this.count = new AtomicInteger(initialCount); - this.firstGenerationTime = firstGenerationTime; - } - - public void incrementCount() { - count.incrementAndGet(); - } - - public int getCount() { - return count.get(); - } - - } -} diff --git a/edukit-core/src/main/java/com/edukit/core/studentrecord/service/RecordGenerationTracker.java b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/RecordGenerationTracker.java new file mode 100644 index 00000000..c41686ca --- /dev/null +++ b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/RecordGenerationTracker.java @@ -0,0 +1,65 @@ +package com.edukit.core.studentrecord.service; + +import java.util.Collections; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RecordGenerationTracker { + + @Value("${record.generation.ttl}") + private long ttlSeconds; + + private static final String KEY_PREFIX = "sr:gen:"; + private static final String LUA_SCRIPT = + "local ttl = tonumber(ARGV[1]) or 0 " + + "local c = redis.call('INCR', KEYS[1]) " + + "if c == 1 and ttl > 0 then " + + " redis.call('EXPIRE', KEYS[1], ttl) " + + "end " + + "return c"; + + private static final DefaultRedisScript INCR_EXPIRE_SCRIPT; + + + static { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptText(LUA_SCRIPT); + script.setResultType(Long.class); + INCR_EXPIRE_SCRIPT = script; + } + + private final StringRedisTemplate redisTemplate; + + public boolean isFirstGeneration(long recordId) { + String key = getCountKey(recordId); + Long newCount = redisTemplate.execute( + INCR_EXPIRE_SCRIPT, + Collections.singletonList(key), + String.valueOf(ttlSeconds) + ); + + if (newCount == null) { + log.warn("Redis script returned null for key: {}", key); + return false; + } + + boolean isFirst = newCount == 1L; + if (isFirst) { + log.debug("RecordId: {}, Generation count set to 1 (first)", recordId); + } else { + log.debug("RecordId: {}, Generation count: {} (regeneration)", recordId, newCount); + } + return isFirst; + } + + private String getCountKey(long recordId) { + return KEY_PREFIX + recordId + ":count"; + } +} diff --git a/edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsService.java b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsCounter.java similarity index 98% rename from edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsService.java rename to edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsCounter.java index 9fb80b89..1ff99e06 100644 --- a/edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsService.java +++ b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsCounter.java @@ -8,7 +8,7 @@ @Component @RequiredArgsConstructor -public class StudentRecordMetricsService { +public class StudentRecordMetricsCounter { private final MeterRegistry meterRegistry;