diff --git a/.claude/agents/flyway-migration-generator.md b/.claude/agents/flyway-migration-generator.md
new file mode 100644
index 00000000..27f297f1
--- /dev/null
+++ b/.claude/agents/flyway-migration-generator.md
@@ -0,0 +1,62 @@
+# Flyway Migration Generator Agent
+
+## Description
+Automatically generates Flyway migration files when database schema changes are detected in JPA entities or when manual schema modifications are needed for the EduKit application.
+
+## Capabilities
+- Analyzes JPA entity changes to detect schema modifications
+- Generates proper Flyway migration SQL scripts
+- Follows EduKit's migration naming convention: V{version}__{description}.sql
+- Creates migrations for:
+ - New tables and columns
+ - Index additions/modifications
+ - Foreign key constraints
+ - Data type changes
+ - Table/column renames
+- Validates migration syntax for MySQL
+- Ensures timezone considerations (Asia/Seoul)
+
+## Usage Instructions
+This agent should be used when:
+- JPA entities are modified (new fields, annotations, etc.)
+- Database schema needs manual changes
+- New tables or relationships are required
+- Index optimization is needed
+- Data migration scripts are required
+
+## EduKit-Specific Context
+- Database: MySQL on AWS RDS
+- Timezone: Asia/Seoul
+- Migration location: `edukit-api/src/main/resources/db/migration/`
+- Naming convention: `V{version}__{description}.sql`
+- JPA validation mode (no auto-DDL)
+- Entities are in edukit-core module
+- Multi-module Spring Boot application structure
+
+## Migration File Template
+```sql
+-- Migration: V{version}__{description}.sql
+-- Description: {detailed description}
+-- Author: Generated by Claude Code
+-- Date: {current date}
+
+-- Add your migration SQL here
+-- Example:
+-- CREATE TABLE example_table (
+-- id BIGINT AUTO_INCREMENT PRIMARY KEY,
+-- name VARCHAR(255) NOT NULL,
+-- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+-- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+-- );
+
+-- Manual Rollback Guide (Flyway Community Edition):
+-- To rollback this migration, manually execute:
+-- DROP TABLE example_table;
+```
+
+## Expected Output
+- Properly versioned Flyway migration files
+- MySQL-compatible SQL syntax
+- Appropriate indexes and constraints
+- Timezone-aware timestamp handling
+- Manual rollback guidance (Flyway Community Edition)
\ No newline at end of file
diff --git a/.claude/agents/swagger-documenter.md b/.claude/agents/swagger-documenter.md
new file mode 100644
index 00000000..49211d07
--- /dev/null
+++ b/.claude/agents/swagger-documenter.md
@@ -0,0 +1,33 @@
+# Swagger API Documenter Agent
+
+## Description
+Automatically generates and updates Swagger/OpenAPI documentation when new controllers or API endpoints are added to the EduKit Spring Boot application.
+
+## Capabilities
+- Analyzes Spring Boot controllers to extract API information
+- Generates proper Swagger annotations (@Operation, @ApiResponse, @Schema, etc.)
+- Updates existing controllers with missing documentation
+- Validates API documentation completeness
+- Generates OpenAPI 3.0 specification files
+- Ensures consistency with EduKit's API versioning pattern (/v1/, /v2/)
+
+## Usage Instructions
+This agent should be used when:
+- New REST controllers are created
+- New endpoints are added to existing controllers
+- API documentation is missing or incomplete
+- Swagger annotations need to be updated
+
+## EduKit-Specific Context
+- Multi-module Spring Boot application (edukit-api, edukit-core, edukit-external, edukit-common)
+- Controllers are in edukit-api module
+- API versioning follows /v1/, /v2/ pattern
+- Uses Spring Boot 3.5.3 with Java 21
+- JWT authentication with Spring Security
+- Request/response DTOs follow facade pattern
+
+## Expected Output
+- Complete Swagger annotations on controllers and methods
+- Proper @Schema annotations on DTOs
+- API documentation following OpenAPI 3.0 standards
+- Updated application.yml with Swagger configuration if needed
\ No newline at end of file
diff --git a/.claude/agents/test-generator.md b/.claude/agents/test-generator.md
new file mode 100644
index 00000000..d0501d16
--- /dev/null
+++ b/.claude/agents/test-generator.md
@@ -0,0 +1,185 @@
+# Comprehensive Test Generator Agent
+
+## Description
+Generates comprehensive test suites including unit tests, integration tests, and edge case scenarios with special focus on concurrency, deadlock prevention, and exception handling for the EduKit Spring Boot application.
+
+## Capabilities
+### ๐งช Test Types Generated
+- **Unit Tests**: Service layer, Repository layer, Utility classes
+- **Integration Tests**: Controller tests, Database integration, External API tests
+- **Concurrency Tests**: Thread safety, Race conditions, Deadlock scenarios
+- **Exception Tests**: Business exceptions, Validation errors, System failures
+- **Security Tests**: Authentication, Authorization, Input validation
+- **Performance Tests**: Load testing, Memory usage, Query performance
+
+### ๐ฏ Test Scenarios Covered
+- **Happy Path**: Normal flow scenarios
+- **Edge Cases**: Boundary conditions, Null values, Empty collections
+- **Error Handling**: Exception propagation, Error responses, Rollback scenarios
+- **Concurrency Issues**:
+ - Simultaneous user operations
+ - Database transaction conflicts
+ - Redis cache race conditions
+ - JWT token concurrent access
+- **Security Vulnerabilities**:
+ - SQL injection attempts
+ - XSS prevention
+ - CSRF protection
+ - Rate limiting
+
+## EduKit-Specific Test Patterns
+
+### ๐๏ธ Architecture Testing
+```java
+// Service Layer Tests
+@ExtendWith(MockitoExtension.class)
+class UserServiceTest {
+ @Mock private UserRepository userRepository;
+ @Mock private RedisTemplate redisTemplate;
+
+ // Concurrency test example
+ @Test
+ @Timeout(value = 10, unit = TimeUnit.SECONDS)
+ void shouldHandleConcurrentUserCreation() throws InterruptedException {
+ // Multi-thread user creation test
+ }
+}
+
+// Controller Integration Tests
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("test") // REQUIRED: Always use test profile
+class UserControllerIntegrationTest {
+ // JWT authentication tests
+ // CORS tests
+ // API versioning tests
+}
+```
+
+### ๐๏ธ Database Testing
+```java
+// JPA Repository Tests
+@DataJpaTest
+@ActiveProfiles("test") // REQUIRED: Always use test profile
+class UserRepositoryTest {
+ // Transaction isolation tests
+ // Deadlock prevention tests
+ // Timezone handling tests (Asia/Seoul)
+}
+
+// Flyway Migration Tests
+@Sql(scripts = "/db/test-data.sql")
+@Transactional
+class MigrationTest {
+ // Schema validation tests
+ // Data migration integrity tests
+}
+```
+
+### โก Concurrency & Performance Testing
+```java
+// Redis Cache Concurrency
+@Test
+void shouldHandleRedisConcurrentAccess() {
+ // Multiple threads accessing same cache key
+ // Cache invalidation race conditions
+ // Distributed lock testing
+}
+
+// Database Connection Pool
+@Test
+void shouldHandleConnectionPoolExhaustion() {
+ // Connection leak detection
+ // Pool saturation scenarios
+ // Transaction timeout handling
+}
+
+// JWT Token Concurrency
+@Test
+void shouldHandleSimultaneousTokenOperations() {
+ // Concurrent token validation
+ // Token refresh race conditions
+ // Session management conflicts
+}
+```
+
+### ๐ก๏ธ Security Testing
+```java
+// Authentication Tests
+@WithMockUser(roles = "USER")
+@Test
+void shouldPreventUnauthorizedAccess() {
+ // Role-based access control
+ // JWT token validation
+ // Session hijacking prevention
+}
+
+// Input Validation Tests
+@ParameterizedTest
+@ValueSource(strings = {"", "'; DROP TABLE users; --"})
+void shouldSanitizeUserInput(String maliciousInput) {
+ // XSS prevention
+ // SQL injection prevention
+ // Input length validation
+}
+```
+
+## Test Configuration Templates
+
+### ๐ง Test Properties
+**Use existing `edukit-api/src/test/resources/application-test.yml`**
+- MySQL on port 3307 (container-based)
+- Redis on port 6370 (container-based)
+- Mock AWS services
+- Test JWT configuration
+
+### ๐ฆ Test Dependencies
+```gradle
+testImplementation 'org.springframework.boot:spring-boot-starter-test'
+testImplementation 'org.springframework.security:spring-security-test'
+testImplementation 'org.testcontainers:mysql'
+testImplementation 'org.testcontainers:junit-jupiter'
+testImplementation 'com.github.tomakehurst:wiremock-jre8'
+testImplementation 'org.awaitility:awaitility'
+```
+
+## Usage Instructions
+This agent should be used when:
+- New services or controllers are created
+- Critical business logic is implemented
+- External integrations are added
+- Performance-critical code is written
+- Security-sensitive operations are implemented
+- Database operations involve complex transactions
+
+## Test Generation Strategy
+1. **Analyze Code Structure**: Identify testable components
+2. **Detect Dependencies**: Mock external services, databases
+3. **Identify Risk Areas**: Concurrency, security, performance
+4. **Generate Test Matrix**: Cover all scenarios systematically
+5. **Create Test Data**: Realistic test datasets
+6. **Validate Coverage**: Ensure high code coverage
+
+## โ ๏ธ MANDATORY TEST REQUIREMENTS
+- **ALL test classes MUST use `@ActiveProfiles("test")`**
+- **NO test should run without test profile**
+- **Always verify test uses application-test.yml configuration**
+
+## Expected Output
+- Complete test classes with proper annotations
+- Mock configurations for external dependencies
+- Parameterized tests for edge cases
+- Concurrency tests using ExecutorService
+- Performance benchmarks using JMH
+- Security tests with penetration scenarios
+- Test data builders and fixtures
+- Test documentation explaining test scenarios
+
+## EduKit-Specific Context
+- Multi-module Spring Boot application structure
+- MySQL database with Asia/Seoul timezone
+- Redis caching layer
+- JWT authentication with 2-week expiration
+- AWS services integration (S3, SES, SQS)
+- OpenAI API integration
+- Blue-green deployment considerations
+- Korean localization requirements
\ No newline at end of file
diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md
new file mode 100644
index 00000000..38a30249
--- /dev/null
+++ b/.claude/commands/commit.md
@@ -0,0 +1,74 @@
+# Auto Commit Command
+
+## Slash Command
+`/commit`
+
+## Description
+Automatically analyzes staged changes and generates appropriate commit messages following EduKit's commit conventions and Jira ticket format.
+
+## Functionality
+- Analyzes all staged files and changes
+- Detects the type of changes (feature, fix, refactor, docs, etc.)
+- Generates commit messages in EduKit format: `[prefix] descriptive message`
+- Follows conventional commit patterns
+- Includes Korean descriptions when appropriate for Korean team members
+- **SAFETY**: Only commits already staged files (no auto-staging by default)
+
+## Commit Message Patterns
+- `[feat] ์๋ก์ด ๊ธฐ๋ฅ: {feature description}` - for new features
+- `[fix] ๋ฒ๊ทธ ์์ : {bug description}` - for bug fixes
+- `[refac] ๋ฆฌํฉํ ๋ง: {refactor description}` - for code refactoring
+- `[docs] ๋ฌธ์ ์
๋ฐ์ดํธ: {docs description}` - for documentation
+- `[test] ํ
์คํธ ์ถ๊ฐ: {test description}` - for tests
+- `[infra] ์ค์ ๋ณ๊ฒฝ: {config description}` - for configuration changes
+- `[dependency] ์์กด์ฑ ์
๋ฐ์ดํธ: {dependency description}` - for dependency updates
+
+## EduKit-Specific Context
+- Project uses Jira tickets with EDMT prefix
+- Multi-module Spring Boot application
+- Team uses both Korean and English in commit messages
+- Follows clean architecture principles
+- Common file patterns:
+ - Controllers: `edukit-api/src/main/java/.../controller/`
+ - Services: `edukit-core/src/main/java/.../service/`
+ - Entities: `edukit-core/src/main/java/.../domain/`
+ - Migrations: `edukit-api/src/main/resources/db/migration/`
+ - Configuration: `application-*.yml`
+
+## Usage Examples
+```bash
+# User types:
+/commit
+
+# Agent analyzes changes and generates:
+[EDMT-123] ์๋ก์ด ๊ธฐ๋ฅ: ์ฌ์ฉ์ ์ธ์ฆ API ์๋ํฌ์ธํธ ์ถ๊ฐ
+
+๐ค Generated with Claude Code
+Co-Authored-By: Claude
+```
+
+## Expected Behavior
+1. **Check for staged changes** - abort if nothing staged
+2. Analyze current git status and staged changes only
+3. Determine the primary type of changes
+4. Extract or prompt for Jira ticket number if not found
+5. Generate descriptive commit message
+6. Execute git commit with generated message
+7. Include Claude Code attribution
+
+## Safety Options
+- Default: Only commit staged files
+- `--stage`: Interactive staging with `git add -p`
+- `--stage-all`: Stage all modified files (use with caution)
+
+## Usage Examples
+```bash
+# Safe default - only staged files
+/commit
+
+# Interactive staging first
+/commit --stage
+
+# Stage all (dangerous - requires confirmation)
+/commit --stage-all
+```
diff --git a/.claude/commands/pr.md b/.claude/commands/pr.md
new file mode 100644
index 00000000..cb5685cb
--- /dev/null
+++ b/.claude/commands/pr.md
@@ -0,0 +1,106 @@
+# Pull Request Automation Command
+
+## Slash Command
+`/pr`
+
+## Description
+Automatically creates pull requests with comprehensive summaries, test plans, and proper formatting for the EduKit project using GitHub CLI.
+
+## Functionality
+- Analyzes all commits in the current branch since diverging from develop
+- Generates detailed PR title and description
+- Creates comprehensive test plan based on changes
+- Includes Jira ticket references
+- Follows EduKit's PR template format
+- **SAFETY**: Requires user confirmation before pushing to remote
+- Uses Korean descriptions when appropriate
+
+## PR Template Format
+```markdown
+## ๐ฃ Jira Ticket
+
+[EDMT-]
+
+
+## ๐ฉโ๐ป ์์
๋ด์ฉ
+
+
+
+## ๐ ๋ฆฌ๋ทฐ ์์ฒญ & ๋
ผ์ํ๊ณ ์ถ์ ๋ด์ฉ
+
+
+
+## ๐ธ ์คํฌ๋ฆฐ ์ท (์ ํ)
+
+## Test Plan
+### โ
Manual Testing Checklist
+- [ ] {Test case 1}
+- [ ] {Test case 2}
+- [ ] {Test case 3}
+
+### ๐งช Automated Tests
+- [ ] Unit tests pass: `./gradlew test`
+- [ ] Integration tests pass
+- [ ] Build succeeds: `./gradlew build`
+
+### ๐ Code Quality
+- [ ] Code review completed
+- [ ] No new warnings or errors
+- [ ] Security considerations reviewed
+
+## Deployment Notes
+- [ ] Database migrations included (if applicable)
+- [ ] Environment variables updated (if needed)
+- [ ] AWS resources configured (if required)
+
+๐ค Generated with Claude Code
+```
+
+## EduKit-Specific Context
+- Base branch: `develop`
+- Jira project: EDMT
+- Multi-module Spring Boot application
+- Uses blue-green deployment
+- AWS infrastructure (S3, SES, SQS, RDS, ElastiCache)
+- Korean team communication preferred
+- Common change patterns:
+ - API additions in edukit-api
+ - Business logic in edukit-core
+ - External integrations in edukit-external
+ - Database migrations in Flyway
+
+## Usage Examples
+```bash
+# User types:
+/pr
+
+# Agent analyzes branch and creates PR with title:
+[EDMT-123] ์ฌ์ฉ์ ์ธ์ฆ API ๊ฐ์ ๋ฐ JWT ํ ํฐ ๊ฐฑ์ ๋ก์ง ์ถ๊ฐ
+```
+
+## Expected Behavior
+1. Check git status and current branch
+2. Compare with develop branch to get all commits
+3. Analyze changed files and commit messages
+4. Extract Jira ticket numbers
+5. Generate comprehensive PR title and description
+6. **Check if branch needs pushing** - ask user for confirmation
+7. Create PR using GitHub CLI (only if remote branch exists)
+8. Return PR URL for easy access
+
+## Safety Options
+- Default: No automatic pushing - user confirmation required
+- `--push`: Auto-push without confirmation (use with caution)
+- `--no-push`: Create PR draft only (local analysis)
+
+## Usage Examples
+```bash
+# Safe default - asks before pushing
+/pr
+
+# Auto-push (use carefully)
+/pr --push
+
+# Analysis only, no remote operations
+/pr --no-push
+```
diff --git a/.github/workflows/api-dev-cd.yml b/.github/workflows/api-dev-cd.yml
index 163fdea6..274827b5 100644
--- a/.github/workflows/api-dev-cd.yml
+++ b/.github/workflows/api-dev-cd.yml
@@ -153,14 +153,23 @@ jobs:
- name: Switch nginx upstream and reload
run: |
+ # 1๏ธโฃ nginx ์ปจํ
์ด๋ ์ฒดํฌ & ์คํ
+ if ! docker ps --format '{{.Names}}' | grep -q '^nginx$'; then
+ echo "nginx not running. starting with docker-compose..."
+ docker-compose -f docker-compose.dev.yml up -d nginx
+ fi
+
+ # 2๏ธโฃ ์ ๊ท target ๊ฒฐ์
if [ "${{ steps.current.outputs.CURRENT }}" = "blue" ]; then
NEW_TARGET="app-green:8080"
else
NEW_TARGET="app-blue:8080"
fi
+ # 3๏ธโฃ nginx ์ค์ ๋ณ๊ฒฝ & reload
docker exec nginx bash -c \
"echo 'set \$service_url $NEW_TARGET;' > /etc/nginx/conf.d/service-url.inc && nginx -t && nginx -s reload"
+
- name: Stop and remove old container
run: |
diff --git a/.github/workflows/api-prod-cd.yml b/.github/workflows/api-prod-cd.yml
index 04bc3768..a1c326f7 100644
--- a/.github/workflows/api-prod-cd.yml
+++ b/.github/workflows/api-prod-cd.yml
@@ -153,12 +153,20 @@ jobs:
- name: Switch nginx upstream and reload
run: |
+ # 1๏ธโฃ nginx ์ปจํ
์ด๋ ์ฒดํฌ & ์คํ
+ if ! docker ps --format '{{.Names}}' | grep -q '^nginx$'; then
+ echo "nginx not running. starting with docker-compose..."
+ docker-compose -f docker-compose.dev.yml up -d nginx
+ fi
+
+ # 2๏ธโฃ ์ ๊ท target ๊ฒฐ์
if [ "${{ steps.current.outputs.CURRENT }}" = "blue" ]; then
NEW_TARGET="app-green:8080"
else
NEW_TARGET="app-blue:8080"
fi
+ # 3๏ธโฃ nginx ์ค์ ๋ณ๊ฒฝ & reload
docker exec nginx bash -c \
"echo 'set \$service_url $NEW_TARGET;' > /etc/nginx/conf.d/service-url.inc && nginx -t && nginx -s reload"
diff --git a/build.gradle b/build.gradle
index 839bac2b..54401e6f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -43,7 +43,7 @@ subprojects {
annotationProcessor 'org.projectlombok:lombok'
// spring boot
- implementation 'org.springframework.boot:spring-boot-starter'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework:spring-webflux'
// spring boot configuration processor
diff --git a/edukit-api/build.gradle b/edukit-api/build.gradle
index 9c167ad7..6c4bc248 100644
--- a/edukit-api/build.gradle
+++ b/edukit-api/build.gradle
@@ -10,8 +10,7 @@ dependencies {
implementation project(':edukit-common')
runtimeOnly project(':edukit-external')
- // Spring Boot Web
- implementation 'org.springframework.boot:spring-boot-starter-web'
+ // validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Spring Boot Security
@@ -29,6 +28,9 @@ dependencies {
// Prometheus Metrics
implementation 'io.micrometer:micrometer-registry-prometheus'
+ // AOP Support for @Aspect
+ implementation 'org.springframework.boot:spring-boot-starter-aop'
+
// Swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'
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 3111e71d..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
@@ -4,6 +4,7 @@
import com.edukit.common.annotation.MemberId;
import com.edukit.core.studentrecord.exception.StudentRecordErrorCode;
import com.edukit.core.studentrecord.exception.StudentRecordException;
+import com.edukit.core.studentrecord.service.StudentRecordService;
import com.edukit.studentrecord.controller.request.StudentRecordPromptRequest;
import com.edukit.studentrecord.facade.StudentRecordAIFacade;
import com.edukit.studentrecord.facade.response.StudentRecordTaskResponse;
@@ -25,14 +26,16 @@
public class StudentRecordAIController implements StudentRecordAIApi {
private final StudentRecordAIFacade studentRecordAIFacade;
+ private final StudentRecordService studentRecordService;
@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) {
+
StudentRecordTaskResponse response = studentRecordAIFacade.createTaskId(memberId, recordId,
- 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 0a88be53..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
@@ -1,5 +1,6 @@
package com.edukit.studentrecord.facade;
+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;
@@ -21,25 +22,26 @@
public class StudentRecordAIFacade {
private final MemberService memberService;
- private final StudentRecordService studentRecordService;
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 int byteCount,
final String userPrompt) {
Member member = memberService.getMemberById(memberId);
StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId);
- String requestPrompt = AIPromptGenerator.createStreamingPrompt(studentRecord.getStudentRecordType(), byteCount,
- userPrompt);
+ String requestPrompt = AIPromptGenerator.createStreamingPrompt(studentRecord.getStudentRecordType(), byteCount, userPrompt);
StudentRecordAITask task = aiTaskService.createAITask(member, userPrompt);
eventPublisher.publishEvent(AITaskCreateEvent.of(task, userPrompt, requestPrompt, byteCount));
return StudentRecordTaskResponse.of(String.valueOf(task.getId()));
}
+
public SseEmitter createChannel(final long memberId, final String taskId) {
aiTaskService.validateUserTask(memberId, taskId);
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 2d03705f..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
@@ -1,5 +1,6 @@
package com.edukit.studentrecord.facade;
+import com.edukit.common.annotation.StudentRecordMetrics;
import com.edukit.core.student.db.entity.Student;
import com.edukit.core.student.service.ExcelService;
import com.edukit.core.studentrecord.db.entity.StudentRecord;
@@ -41,6 +42,7 @@ public StudentRecordsGetResponse getStudentRecords(final long memberId, final St
}
@Transactional
+ @StudentRecordMetrics
public void updateStudentRecord(final long memberId, final long recordId, final String description) {
StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId);
studentRecordService.updateStudentRecord(studentRecord, description);
diff --git a/edukit-common/src/main/java/com/edukit/common/annotation/AIGenerationMetrics.java b/edukit-common/src/main/java/com/edukit/common/annotation/AIGenerationMetrics.java
new file mode 100644
index 00000000..0e797321
--- /dev/null
+++ b/edukit-common/src/main/java/com/edukit/common/annotation/AIGenerationMetrics.java
@@ -0,0 +1,11 @@
+package com.edukit.common.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface AIGenerationMetrics {
+}
\ No newline at end of file
diff --git a/edukit-common/src/main/java/com/edukit/common/annotation/StudentRecordMetrics.java b/edukit-common/src/main/java/com/edukit/common/annotation/StudentRecordMetrics.java
new file mode 100644
index 00000000..f2a17fd7
--- /dev/null
+++ b/edukit-common/src/main/java/com/edukit/common/annotation/StudentRecordMetrics.java
@@ -0,0 +1,11 @@
+package com.edukit.common.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface StudentRecordMetrics {
+}
\ No newline at end of file
diff --git a/edukit-core/build.gradle b/edukit-core/build.gradle
index edb31ae1..feae52c4 100644
--- a/edukit-core/build.gradle
+++ b/edukit-core/build.gradle
@@ -13,9 +13,6 @@ dependencies {
// Jackson
implementation 'com.fasterxml.jackson.core:jackson-databind'
- // Spring Web for SSE support
- implementation 'org.springframework.boot:spring-boot-starter-web'
-
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
@@ -25,6 +22,9 @@ dependencies {
implementation 'org.apache.poi:poi:5.4.0'
implementation 'org.apache.poi:poi-ooxml:5.4.0'
+ // Micrometer for metrics
+ implementation 'io.micrometer:micrometer-core'
+
// QueryDSL
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
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
new file mode 100644
index 00000000..729e2958
--- /dev/null
+++ b/edukit-core/src/main/java/com/edukit/core/studentrecord/aop/StudentRecordMetricsAspect.java
@@ -0,0 +1,83 @@
+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.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;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.AfterReturning;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Aspect
+@Component
+@RequiredArgsConstructor
+public class StudentRecordMetricsAspect {
+
+ private final StudentRecordMetricsCounter metricsService;
+ private final RecordGenerationTracker recordGenerationTracker;
+ private final StudentRecordService studentRecordService;
+
+ @AfterReturning(pointcut = "@annotation(com.edukit.common.annotation.StudentRecordMetrics)")
+ public void collectCompletionMetrics(final JoinPoint joinPoint) {
+ Object[] args = joinPoint.getArgs();
+
+ if (args.length == 3) {
+ long memberId = (Long) args[0];
+ long recordId = (Long) args[1];
+ String description = (String) args[2];
+
+ StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId);
+ StudentRecordType recordType = studentRecord.getStudentRecordType();
+
+ try {
+ metricsService.recordCompletion(recordType, description);
+ } catch (Exception e) {
+ // ๋ฉํธ๋ฆญ ์์ง ์คํจ๋ ๋ก๊ทธ๋ง ๋จ๊ธฐ๊ณ ๋น์ฆ๋์ค ๋ก์ง์ ๊ณ์ ์งํ
+ log.warn("Error collecting completion metrics for recordType: {}", recordType, e);
+ }
+ }
+ }
+
+ @Around("@annotation(com.edukit.common.annotation.AIGenerationMetrics)")
+ public Object collectAIGenerationMetrics(final ProceedingJoinPoint joinPoint) throws Throwable {
+ Object[] args = joinPoint.getArgs();
+
+ if (args.length == 4) {
+ long memberId = (Long) args[0];
+ long recordId = (Long) args[1];
+
+ StudentRecord studentRecord = studentRecordService.getRecordDetail(memberId, recordId);
+ StudentRecordType recordType = studentRecord.getStudentRecordType();
+
+ try {
+ boolean isFirstGeneration = recordGenerationTracker.isFirstGeneration(recordId);
+
+ // ์ ์ฒด AI ์์ฑ ์์ฒญ ์นด์ดํธ
+ metricsService.recordAIGenerationRequest(recordType);
+
+ // ์ฒซ ์์ฑ vs ์ฌ์์ฑ ๊ตฌ๋ถ ๋ฉํธ๋ฆญ
+ if (isFirstGeneration) {
+ metricsService.recordFirstGeneration(recordType);
+ log.debug("First generation request for recordId: {}", recordId);
+ } else {
+ metricsService.recordRegeneration(recordType);
+ log.debug("Regeneration request for recordId: {}", recordId);
+ }
+
+ } catch (Exception e) {
+ // ๋ฉํธ๋ฆญ ์์ง ์คํจ๋ ๋ก๊ทธ๋ง ๋จ๊ธฐ๊ณ ๋น์ฆ๋์ค ๋ก์ง์ ๊ณ์ ์งํ
+ log.warn("Error collecting AI generation metrics for recordId: {}", recordId, e);
+ }
+ }
+
+ // ๋น์ฆ๋์ค ๋ก์ง์ ๋ฐ๋์ 1ํ๋ง ์คํ, ์์ธ๋ ๊ทธ๋๋ก ์ ํ
+ return joinPoint.proceed();
+ }
+}
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/StudentRecordMetricsCounter.java b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsCounter.java
new file mode 100644
index 00000000..1ff99e06
--- /dev/null
+++ b/edukit-core/src/main/java/com/edukit/core/studentrecord/service/StudentRecordMetricsCounter.java
@@ -0,0 +1,54 @@
+package com.edukit.core.studentrecord.service;
+
+import com.edukit.core.studentrecord.db.enums.StudentRecordType;
+import io.micrometer.core.instrument.MeterRegistry;
+import java.nio.charset.StandardCharsets;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class StudentRecordMetricsCounter {
+
+ private final MeterRegistry meterRegistry;
+
+ private static final String COMPLETION_METRIC = "student_record_completion_total";
+ private static final String AI_GENERATION_REQUEST_METRIC = "student_record_ai_generation_requests_total";
+ private static final String AI_FIRST_GENERATION_METRIC = "student_record_ai_first_generation_total";
+ private static final String AI_REGENERATION_METRIC = "student_record_ai_regeneration_total";
+
+ public void recordCompletion(final StudentRecordType type, final String description) {
+ if (isCompleted(type, description)) {
+ meterRegistry.counter(COMPLETION_METRIC,
+ "type", type.name(), "action", "completion")
+ .increment();
+ }
+ }
+
+ public void recordAIGenerationRequest(final StudentRecordType type) {
+ meterRegistry.counter(AI_GENERATION_REQUEST_METRIC,
+ "type", type.name(), "action", "ai_generation")
+ .increment();
+ }
+
+ public void recordFirstGeneration(final StudentRecordType type) {
+ meterRegistry.counter(AI_FIRST_GENERATION_METRIC,
+ "type", type.name(), "action", "first_generation")
+ .increment();
+ }
+
+ public void recordRegeneration(final StudentRecordType type) {
+ meterRegistry.counter(AI_REGENERATION_METRIC,
+ "type", type.name(), "action", "regeneration")
+ .increment();
+ }
+
+ private boolean isCompleted(final StudentRecordType type, final String description) {
+ if (description == null || description.trim().isEmpty()) {
+ return false;
+ }
+
+ int minBytes = (type == StudentRecordType.SUBJECT) ? 1000 : 750;
+ return description.getBytes(StandardCharsets.UTF_8).length >= minBytes;
+ }
+}
diff --git a/promtail-config.yml b/promtail-config.yml
index 9ea73838..209c8cc2 100644
--- a/promtail-config.yml
+++ b/promtail-config.yml
@@ -26,8 +26,6 @@ scrape_configs:
expressions:
timestamp: timestamp
level: level
- thread: thread
- logger: logger
message: message
traceId: traceId
userId: userId
@@ -39,11 +37,12 @@ scrape_configs:
expression: '.*(health|actuator|metrics|prometheus|favicon\.ico).*'
source: requestUrl
- # ๋์ ๋ผ๋ฒจ ์ค์ (๊ณ ์นด๋๋๋ฆฌํฐ ๋ผ๋ฒจ ์ต์ํ)
+ # ๋์ ๋ผ๋ฒจ ์ค์
- labels:
+ timestamp:
level:
- logger:
traceId:
+ requestUrl:
# ํ์์คํฌํ ํ์ฑ
- timestamp:
@@ -68,8 +67,6 @@ scrape_configs:
expressions:
timestamp: timestamp
level: level
- thread: thread
- logger: logger
message: message
traceId: traceId
userId: userId
@@ -83,9 +80,10 @@ scrape_configs:
# ๋์ ๋ผ๋ฒจ ์ค์ (๊ณ ์นด๋๋๋ฆฌํฐ ๋ผ๋ฒจ ์ต์ํ)
- labels:
+ timestamp:
level:
- logger:
traceId:
+ requestUrl:
# ํ์์คํฌํ ํ์ฑ
- timestamp: