-
Notifications
You must be signed in to change notification settings - Fork 6
[VISION] 분석 일일 사용량 DB 관리 및 인증 UX 전면 개선 #71
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| package io.github.petty.vision.entity; | ||
|
|
||
| import jakarta.persistence.*; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
| import lombok.Setter; | ||
| import org.hibernate.annotations.CreationTimestamp; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.time.LocalDateTime; | ||
| import java.util.UUID; | ||
|
|
||
| @Entity | ||
| @Table(name = "vision_usage", | ||
| uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "usage_date"})) | ||
| @Getter | ||
| @Setter | ||
| @NoArgsConstructor | ||
| public class VisionUsage { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.UUID) | ||
| private UUID id; | ||
|
|
||
| @Column(name = "user_id", nullable = false) | ||
| private UUID userId; | ||
|
|
||
| @Column(name = "usage_date", nullable = false) | ||
| private LocalDate usageDate; | ||
|
|
||
| @Column(name = "usage_count", nullable = false) | ||
| private Integer usageCount = 0; | ||
|
|
||
| @Column(name = "daily_limit", nullable = false) | ||
| private Integer dailyLimit = 3; // 기본값 3회 | ||
|
|
||
| @CreationTimestamp | ||
| @Column(name = "created_at") | ||
| private LocalDateTime createdAt; | ||
|
|
||
| @Column(name = "updated_at") | ||
| private LocalDateTime updatedAt; | ||
|
|
||
| public VisionUsage(UUID userId, LocalDate usageDate, Integer dailyLimit) { | ||
| this.userId = userId; | ||
| this.usageDate = usageDate; | ||
| this.dailyLimit = dailyLimit; | ||
| this.usageCount = 0; | ||
| } | ||
|
|
||
| // 사용량 증가 | ||
| public void incrementUsage() { | ||
| this.usageCount++; | ||
| this.updatedAt = LocalDateTime.now(); | ||
| } | ||
|
|
||
| // 사용 가능 여부 확인 | ||
| public boolean canUse() { | ||
| return this.usageCount < this.dailyLimit; | ||
| } | ||
|
|
||
| // 남은 사용량 | ||
| public int getRemainingUsage() { | ||
| return Math.max(0, this.dailyLimit - this.usageCount); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,11 +32,16 @@ public class ImageValidator { | |
| ); | ||
| private static final long MIN_FILE_SIZE = 10 * 1024; // 10KB | ||
| private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB | ||
| private static final int MIN_WIDTH = 200; | ||
| private static final int MIN_HEIGHT = 200; | ||
| private static final float MIN_ANIMAL_CONFIDENCE = 70.0f; | ||
|
|
||
| // 🔥 해상도 제한 대폭 완화 | ||
| private static final int MIN_WIDTH = 50; // 200 → 50으로 완화 | ||
| private static final int MIN_HEIGHT = 50; // 200 → 50으로 완화 | ||
|
|
||
| // 🔥 동물 감지 요구사항 완화 | ||
| private static final float MIN_ANIMAL_CONFIDENCE = 50.0f; // 70 → 50으로 완화 | ||
| private static final Set<String> ANIMAL_LABELS = new HashSet<>( | ||
| Arrays.asList("Animal", "Pet", "Dog", "Cat", "Mammal", "Canine", "Feline") | ||
| Arrays.asList("Animal", "Pet", "Dog", "Cat", "Mammal", "Canine", "Feline", | ||
| "Bird", "Fish", "Reptile", "Amphibian", "Insect") | ||
| ); | ||
|
|
||
| public ValidationResult validate(MultipartFile file) { | ||
|
|
@@ -68,39 +73,66 @@ public ValidationResult validate(MultipartFile file) { | |
| if (image == null) { | ||
| return ValidationResult.invalid("유효한 이미지 파일이 아닙니다."); | ||
| } | ||
| if (image.getWidth() < MIN_WIDTH || image.getHeight() < MIN_HEIGHT) { | ||
| return ValidationResult.invalid("이미지 해상도가 너무 낮습니다. 최소 200×200 이상이어야 합니다."); | ||
|
|
||
| int width = image.getWidth(); | ||
| int height = image.getHeight(); | ||
|
|
||
| log.info("📏 이미지 해상도 확인: {}×{} (최소 요구: {}×{})", | ||
| width, height, MIN_WIDTH, MIN_HEIGHT); | ||
|
|
||
| // 🔥 아주 관대한 해상도 체크 | ||
| if (width < MIN_WIDTH || height < MIN_HEIGHT) { | ||
| log.warn("⚠️ 해상도 낮음: {}×{}, 하지만 분석 진행", width, height); | ||
| // return ValidationResult.invalid() 대신 경고만 하고 통과 | ||
| } | ||
| return validateAnimalContent(bytes); | ||
|
|
||
| // 🔥 동물 감지도 선택적으로 (실패해도 무조건 통과) | ||
| return validateAnimalContentOptional(bytes); | ||
|
|
||
|
Comment on lines
+89
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 검증 로직이 실질적으로 무효화됨 모든 경로에서
최소한의 검증 기준은 유지하면서 UX를 개선하는 균형점을 찾는 것이 좋겠습니다. Also applies to: 126-126, 131-131 🤖 Prompt for AI Agents |
||
| } catch (IOException e) { | ||
| log.error("이미지 처리 중 오류 발생", e); | ||
| return ValidationResult.invalid("이미지 파일을 처리하는 중 오류가 발생했습니다."); | ||
| } | ||
| } | ||
|
|
||
| private ValidationResult validateAnimalContent(byte[] bytes) { | ||
| /** | ||
| * 🔥 동물 콘텐츠 검증 (선택적 - 무조건 통과) | ||
| */ | ||
| private ValidationResult validateAnimalContentOptional(byte[] bytes) { | ||
| try { | ||
| DetectLabelsRequest request = DetectLabelsRequest.builder() | ||
| .image(Image.builder().bytes(SdkBytes.fromByteArray(bytes)).build()) | ||
| .maxLabels(10) | ||
| .minConfidence(50.0f) | ||
| .maxLabels(20) // 10 → 20으로 증가 | ||
| .minConfidence(30.0f) // 50 → 30으로 낮춤 | ||
| .build(); | ||
| DetectLabelsResponse response = rekognitionClient.detectLabels(request); | ||
|
|
||
| boolean animalDetected = false; | ||
| for (Label label : response.labels()) { | ||
| log.debug("🔍 감지된 라벨: {} (신뢰도: {}%)", label.name(), label.confidence()); | ||
|
|
||
| if (ANIMAL_LABELS.contains(label.name()) && label.confidence() >= MIN_ANIMAL_CONFIDENCE) { | ||
| log.info("동물 감지됨: {}, 신뢰도: {}", label.name(), label.confidence()); | ||
| return ValidationResult.valid(); | ||
| log.info("✅ 동물 감지됨: {}, 신뢰도: {}%", label.name(), label.confidence()); | ||
| animalDetected = true; | ||
| break; | ||
| } | ||
| } | ||
| return ValidationResult.invalid("반려동물이 감지되지 않았습니다."); | ||
|
|
||
| if (!animalDetected) { | ||
| log.warn("⚠️ 동물이 명확히 감지되지 않았지만 분석을 진행합니다."); | ||
| } | ||
|
|
||
| // 🔥 동물이 감지되든 안 되든 무조건 통과 | ||
| return ValidationResult.valid(); | ||
|
|
||
| } catch (Exception e) { | ||
| log.error("Rekognition 오류", e); | ||
| return ValidationResult.invalid("이미지 분석 중 오류가 발생했습니다."); | ||
| log.warn("⚠️ Rekognition 검증 실패, 기본 검증만 수행: {}", e.getMessage()); | ||
| // 🔥 Rekognition 실패해도 무조건 통과 | ||
| return ValidationResult.valid(); | ||
| } | ||
| } | ||
|
|
||
| // Magic Number Signatures | ||
| // Magic Number Signatures (변경 없음) | ||
| private static final byte[] JPG_SIG = new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}; | ||
| private static final byte[] PNG_SIG = new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; | ||
| private static final byte[] BMP_SIG = new byte[]{0x42, 0x4D}; | ||
|
|
@@ -136,4 +168,4 @@ public static ValidationResult invalid(String message) { | |
| return new ValidationResult(false, message); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| package io.github.petty.vision.repository; | ||
|
|
||
| import io.github.petty.vision.entity.VisionUsage; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
| import org.springframework.data.jpa.repository.Modifying; | ||
| import org.springframework.data.jpa.repository.Query; | ||
| import org.springframework.data.repository.query.Param; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.Optional; | ||
| import java.util.UUID; | ||
|
|
||
| @Repository | ||
| public interface VisionUsageRepository extends JpaRepository<VisionUsage, UUID> { | ||
|
|
||
| /** | ||
| * 특정 사용자의 특정 날짜 사용량 조회 | ||
| */ | ||
| Optional<VisionUsage> findByUserIdAndUsageDate(UUID userId, LocalDate usageDate); | ||
|
|
||
| /** | ||
| * 오늘 사용량 조회 (편의 메서드) | ||
| */ | ||
| @Query("SELECT v FROM VisionUsage v WHERE v.userId = :userId AND v.usageDate = CURRENT_DATE") | ||
| Optional<VisionUsage> findTodayUsage(@Param("userId") UUID userId); | ||
|
Comment on lines
+25
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chain
시간대 일관성을 확인하는 스크립트를 실행해보세요: 🏁 Script executed: #!/bin/bash
# Description: 애플리케이션과 DB 시간대 설정 확인
# application.yml에서 시간대 설정 확인
rg -A 3 -B 3 "timezone|time-zone" --type yaml
# Java 코드에서 LocalDate.now() 사용 패턴 확인
rg "LocalDate\.now\(\)" --type javaLength of output: 577 타임존 일관성 확보 필요 현재 JPQL 수정 제안:
점검 대상 파일:
이와 같이 처리하여 날짜 조회 시 생성되는 타임존 차이를 방지하세요. 🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * 특정 사용자의 총 사용량 조회 | ||
| */ | ||
| @Query("SELECT COALESCE(SUM(v.usageCount), 0) FROM VisionUsage v WHERE v.userId = :userId") | ||
| Long getTotalUsageByUser(@Param("userId") UUID userId); | ||
|
|
||
| /** | ||
| * 오래된 사용량 기록 삭제 (30일 이전) | ||
| */ | ||
| @Modifying | ||
| @Query("DELETE FROM VisionUsage v WHERE v.usageDate < :cutoffDate") | ||
| void deleteOldUsageRecords(@Param("cutoffDate") LocalDate cutoffDate); | ||
|
Comment on lines
+37
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chain
서비스에서 이 메서드 호출 시 트랜잭션 처리를 확인해보세요: 🏁 Script executed: #!/bin/bash
# Description: deleteOldUsageRecords 메서드 사용처에서 트랜잭션 처리 확인
# 해당 메서드를 호출하는 코드 검색
rg -A 5 -B 5 "deleteOldUsageRecords" --type java
# @Transactional 어노테이션과 함께 사용되는지 확인
ast-grep --pattern 'class $_ {
$$$
@Transactional
$$$
deleteOldUsageRecords($_)
$$$
}'Length of output: 2453 서비스 스케줄러 메서드에 트랜잭션 처리 추가 필요
• 파일:
예시 변경(diff): @Scheduled(cron = "0 0 2 * * *")
+ @Transactional
public void cleanupOldRecords() {
LocalDate cutoffDate = LocalDate.now().minusDays(30);
visionUsageRepository.deleteOldUsageRecords(cutoffDate);
log.info("Vision 사용량 기록 정리 완료: {} 이전 데이터 삭제", cutoffDate);
}🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * 특정 날짜 이후 사용량 기록 조회 | ||
| */ | ||
| @Query("SELECT v FROM VisionUsage v WHERE v.userId = :userId AND v.usageDate >= :fromDate ORDER BY v.usageDate DESC") | ||
| java.util.List<VisionUsage> findUsageHistory(@Param("userId") UUID userId, @Param("fromDate") LocalDate fromDate); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| package io.github.petty.vision.service; | ||
|
|
||
| import io.github.petty.vision.entity.VisionUsage; | ||
| import io.github.petty.vision.repository.VisionUsageRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.scheduling.annotation.Scheduled; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
| import java.util.UUID; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| @Slf4j | ||
| @Transactional(transactionManager = "supabaseTransactionManager") | ||
| public class VisionUsageService { | ||
|
|
||
| private final VisionUsageRepository visionUsageRepository; | ||
|
|
||
| @Value("${vision.daily-limit:3}") | ||
| private int defaultDailyLimit; | ||
|
|
||
| /** | ||
| * 오늘 사용량 조회 (없으면 새로 생성) | ||
| */ | ||
| public VisionUsage getTodayUsage(UUID userId) { | ||
| LocalDate today = LocalDate.now(); | ||
| return visionUsageRepository.findByUserIdAndUsageDate(userId, today) | ||
| .orElseGet(() -> createNewUsageRecord(userId, today)); | ||
| } | ||
|
|
||
| /** | ||
| * 사용 가능 여부 확인 | ||
| */ | ||
| public boolean canUseToday(UUID userId) { | ||
| VisionUsage usage = getTodayUsage(userId); | ||
| return usage.canUse(); | ||
| } | ||
|
|
||
| /** | ||
| * 사용량 증가 (분석 완료 시 호출) | ||
| */ | ||
| public VisionUsage incrementUsage(UUID userId) { | ||
| VisionUsage usage = getTodayUsage(userId); | ||
|
|
||
| if (!usage.canUse()) { | ||
| throw new IllegalStateException( | ||
| String.format("일일 사용 한도(%d회)를 초과했습니다. 현재 사용량: %d회", | ||
| usage.getDailyLimit(), usage.getUsageCount()) | ||
| ); | ||
| } | ||
|
|
||
| usage.incrementUsage(); | ||
| VisionUsage saved = visionUsageRepository.save(usage); | ||
|
|
||
| log.info("Vision 사용량 증가: 사용자={}, 날짜={}, 사용량={}/{}", | ||
| userId, usage.getUsageDate(), saved.getUsageCount(), saved.getDailyLimit()); | ||
|
|
||
| return saved; | ||
| } | ||
|
Comment on lines
+47
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 동시성 환경에서의 race condition 방지가 필요합니다. 여러 요청이 동시에 들어올 때 같은 사용자가 한도를 초과하여 사용할 수 있는 race condition이 발생할 수 있습니다. 비관적 락이나 낙관적 락 적용을 고려해보세요. 비관적 락을 사용한 개선안: public VisionUsage incrementUsage(UUID userId) {
- VisionUsage usage = getTodayUsage(userId);
+ // 비관적 락으로 동시성 제어
+ LocalDate today = LocalDate.now();
+ VisionUsage usage = visionUsageRepository.findByUserIdAndUsageDate(userId, today)
+ .orElseGet(() -> createNewUsageRecord(userId, today));
+
+ // 락을 걸고 다시 조회하여 최신 상태 확인
+ usage = visionUsageRepository.findById(usage.getId())
+ .orElseThrow(() -> new IllegalStateException("사용량 기록을 찾을 수 없습니다."));
if (!usage.canUse()) {
throw new IllegalStateException(
String.format("일일 사용 한도(%d회)를 초과했습니다. 현재 사용량: %d회",
usage.getDailyLimit(), usage.getUsageCount())
);
}또는 리포지토리에
🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * 남은 사용량 조회 | ||
| */ | ||
| public int getRemainingUsage(UUID userId) { | ||
| VisionUsage usage = getTodayUsage(userId); | ||
| return usage.getRemainingUsage(); | ||
| } | ||
|
|
||
| /** | ||
| * 사용량 기록 생성 | ||
| */ | ||
| private VisionUsage createNewUsageRecord(UUID userId, LocalDate date) { | ||
| VisionUsage newUsage = new VisionUsage(userId, date, defaultDailyLimit); | ||
| VisionUsage saved = visionUsageRepository.save(newUsage); | ||
|
|
||
| log.debug("새로운 Vision 사용량 기록 생성: 사용자={}, 날짜={}, 한도={}", | ||
| userId, date, defaultDailyLimit); | ||
|
|
||
| return saved; | ||
| } | ||
|
|
||
| /** | ||
| * 사용자 사용량 히스토리 조회 (최근 7일) | ||
| */ | ||
| public List<VisionUsage> getRecentUsageHistory(UUID userId) { | ||
| LocalDate fromDate = LocalDate.now().minusDays(7); | ||
| return visionUsageRepository.findUsageHistory(userId, fromDate); | ||
| } | ||
|
|
||
| /** | ||
| * 총 사용량 조회 | ||
| */ | ||
| public Long getTotalUsage(UUID userId) { | ||
| return visionUsageRepository.getTotalUsageByUser(userId); | ||
| } | ||
|
|
||
| /** | ||
| * 오래된 기록 정리 (매일 새벽 2시) | ||
| */ | ||
| @Scheduled(cron = "0 0 2 * * *") | ||
| public void cleanupOldRecords() { | ||
| LocalDate cutoffDate = LocalDate.now().minusDays(30); | ||
| visionUsageRepository.deleteOldUsageRecords(cutoffDate); | ||
| log.info("Vision 사용량 기록 정리 완료: {} 이전 데이터 삭제", cutoffDate); | ||
| } | ||
|
Comment on lines
+105
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chain스케줄러 설정의 시간대 일관성을 확인하세요. cron 표현식 시간대 설정을 확인해보세요: 만약 특정 시간대를 명시해야 한다면: -@Scheduled(cron = "0 0 2 * * *")
+@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Seoul")🏁 Script executed: #!/bin/bash
# Description: 스케줄러 시간대 설정 확인
# application.yml에서 시간대 관련 설정 확인
rg -A 5 -B 5 "spring\.task\.scheduling|timezone|time-zone" --type yaml
# @Scheduled 어노테이션 사용 패턴 확인
rg -A 3 -B 1 "@Scheduled.*cron" --type javaLength of output: 1182 스케줄러 타임존 설정 일관성 보장 필요 현재
• 대상 파일:
🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * 관리자용: 사용자 일일 한도 조정 | ||
| */ | ||
| public VisionUsage updateDailyLimit(UUID userId, int newLimit) { | ||
| if (newLimit < 0) { | ||
| throw new IllegalArgumentException("일일 한도는 0 이상이어야 합니다."); | ||
| } | ||
|
|
||
| VisionUsage usage = getTodayUsage(userId); | ||
| usage.setDailyLimit(newLimit); | ||
|
|
||
| log.info("Vision 일일 한도 변경: 사용자={}, 기존한도={}, 새한도={}", | ||
| userId, usage.getDailyLimit(), newLimit); | ||
|
|
||
| return visionUsageRepository.save(usage); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,7 @@ spring: | |
| import: "optional:classpath:application-secret.yml" | ||
|
|
||
| profiles: | ||
| active: prod | ||
| active: dev | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 프로덕션 프로파일이 dev로 변경됨 활성 프로파일이
프로덕션 배포 시에는 반드시 🤖 Prompt for AI Agents |
||
|
|
||
| logging: | ||
| level: | ||
|
|
@@ -16,4 +16,10 @@ logging: | |
| com.google.cloud: ERROR | ||
|
|
||
| server: | ||
| forward-headers-strategy: FRAMEWORK | ||
| forward-headers-strategy: FRAMEWORK | ||
|
|
||
| vision: | ||
| daily-limit: 3 # 기본 일일 한도 | ||
| cleanup: | ||
| enabled: true | ||
| retention-days: 30 # 30일 이전 데이터 삭제 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
updatedAt필드의 자동 업데이트 개선을 고려해보세요.현재
updatedAt필드가 수동으로 관리되고 있습니다. 더 일관성 있는 타임스탬프 관리를 위해@UpdateTimestamp어노테이션 사용을 권장합니다.그리고
incrementUsage()메서드에서 수동 설정 제거:public void incrementUsage() { this.usageCount++; - this.updatedAt = LocalDateTime.now(); }🤖 Prompt for AI Agents