Conversation
- ClubRepository 주입으로 Club을 주입
…h-MOA-539 [feature] 연관어 검색 구현
- System 프롬프트 - 프롬프트 지시사항 구체화
- PreferWebpImageDisplayUrlResolver: 저장 URL을 WebP 존재 시 WebP URL로 해석 - ClubImageUrlPersistenceService: 해석된 URL을 비동기로 DB에 반영해 이후 조회 시 S3 HEAD 생략 - ClubProfileService, ClubSearchService에서 해석·반영 연동 - AsyncConfig에 imageUrlAsync Executor 추가 - 단위 테스트 추가 Co-authored-by: Cursor <cursoragent@cursor.com>
- PreferWebpImageDisplayUrlResolverTest: ArgumentMatchers.any()를 any(HeadObjectRequest.class)로 타입 안전하게 변경 - ClubProfileServiceTest: ClubProfileService 의존성 변경에 맞춰 mock 추가 (ClubSearchRepository, RecruitmentStateCalculator, ImageDisplayUrlResolver, ClubImageUrlPersistenceService) Co-authored-by: Cursor <cursoragent@cursor.com>
…A-611 [feature] 프로메테우스 셋업
…-MOA-601 [refactor] AI 프롬프트 튜닝
- ClubApplicant 엔티티에 aiSummary 필드 추가 - updateAiSummary() 메서드 추가 - ApplicantIdMessageConsumer에서 AI 요약 결과를 memo 대신 aiSummary 필드에 저장하도록 변경 - 기존 memo 필드는 사용자 메모용으로 유지
…-593 feat: 이미지 로딩 시에 webp가 존재하면 로딩 아니면 원본 로딩 기능 추가
…l-info-MOA-619 [feature] 개인정보 활용 동의 API 추가
…MOA-585 [feat] 홍보게시판 API 구현
…mmary-MOA-617 [refactor] 메모와 AI요약 분리
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning
|
| Cohort / File(s) | Summary |
|---|---|
Build & Configuration backend/build.gradle, backend/src/main/java/moadong/MoadongApplication.java, backend/src/main/java/moadong/global/config/AsyncConfig.java |
Maven 저장소 및 Prometheus 메트릭 의존성 추가, @EnableAsync 애노테이션 추가, 이미지 URL 비동기 처리용 새로운 스레드 풀 실행자 추가. |
프로모션 아티클 기능 backend/src/main/java/moadong/club/entity/PromotionArticle.java, backend/src/main/java/moadong/club/payload/dto/PromotionArticleDto.java, backend/src/main/java/moadong/club/payload/request/PromotionArticleCreateRequest.java, backend/src/main/java/moadong/club/payload/response/PromotionArticleResponse.java, backend/src/main/java/moadong/club/repository/PromotionArticleRepository.java, backend/src/main/java/moadong/club/service/PromotionArticleService.java, backend/src/main/java/moadong/club/controller/PromotionArticleController.java |
프로모션 아티클 MongoDB 엔티티, DTO, 요청/응답 페이로드, 저장소, 서비스, 컨트롤러 추가. GET/POST 엔드포인트로 프로모션 아티클 조회 및 생성 지원. |
단어 사전 관리 기능 backend/src/main/java/moadong/club/entity/WordDictionary.java, backend/src/main/java/moadong/club/payload/request/WordDictionaryCreateRequest.java, backend/src/main/java/moadong/club/payload/request/WordDictionaryBulkCreateRequest.java, backend/src/main/java/moadong/club/payload/request/WordDictionaryUpdateRequest.java, backend/src/main/java/moadong/club/payload/response/WordDictionaryListResponse.java, backend/src/main/java/moadong/club/payload/response/WordDictionaryResponse.java, backend/src/main/java/moadong/club/repository/WordDictionaryRepository.java, backend/src/main/java/moadong/club/service/WordDictionaryAdminService.java, backend/src/main/java/moadong/club/controller/WordDictionaryAdminController.java, backend/src/main/java/moadong/club/util/CsvParser.java |
단어 사전 MongoDB 엔티티, 검증된 요청/응답 페이로드, 저장소, 관리 서비스, 어드민 컨트롤러, CSV 파서 추가. 단일/대량 생성, CSV 업로드, 업데이트, 삭제, 캐시 새로고침 지원. |
키워드 확장 & 검색 통합 backend/src/main/java/moadong/club/service/WordDictionaryService.java, backend/src/main/java/moadong/club/repository/ClubSearchRepository.java, backend/src/main/java/moadong/club/service/ClubSearchService.java |
단어 사전 기반 키워드 확장 서비스 추가, ClubSearchRepository가 검색 시 확장된 키워드 사용, ClubSearchService가 URL 해석 및 지속성 처리 추가. |
이미지 URL 디스플레이 해석 backend/src/main/java/moadong/media/resolver/ImageDisplayUrlResolver.java, backend/src/main/java/moadong/media/resolver/NoOpImageDisplayUrlResolver.java, backend/src/main/java/moadong/media/resolver/PreferWebpImageDisplayUrlResolver.java, backend/src/main/java/moadong/club/service/ClubImageUrlPersistenceService.java, backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java, backend/src/main/java/moadong/club/service/ClubProfileService.java |
이미지 URL 해석 인터페이스, No-Op 구현, WebP 선호 S3 기반 구현 추가. ClubDetailedResult에 해석기 지원 오버로드 메서드 추가. 클럽 프로필 서비스가 URL 해석 및 비동기 지속성 통합. |
클럽 검색 결과 & DTO backend/src/main/java/moadong/club/payload/dto/ClubSearchResult.java, backend/src/main/java/moadong/club/controller/ClubProfileController.java |
ClubSearchResult에 Club 객체로부터 생성하는 팩토리 메서드 추가. ClubProfileController 임포트를 와일드카드로 정리. |
클럽 지원자 & AI 요약 backend/src/main/java/moadong/club/entity/ClubApplicant.java, backend/src/main/java/moadong/club/summary/ApplicantIdMessageConsumer.java |
ClubApplicant에 aiSummary 필드 및 updateAiSummary 메서드 추가. ApplicantIdMessageConsumer가 구조화된 시스템 프롬프트 사용, AI 생성 요약 지속성 추가. |
Gemma AI 서비스 backend/src/main/java/moadong/gemma/dto/AIRequest.java, backend/src/main/java/moadong/gemma/service/GemmaService.java |
AIRequest에 system 필드 및 AIOptions 중첩 레코드 추가. GemmaService.getSummarizeContent 메서드 시그니처가 (String prompt) → (String system, String prompt)로 변경, 구조화된 요청 빌드 추가. |
사용자 개인 정보 동의 backend/src/main/java/moadong/user/entity/User.java, backend/src/main/java/moadong/user/controller/UserController.java, backend/src/main/java/moadong/user/service/UserCommandService.java, backend/src/main/java/moadong/user/payload/response/LoginResponse.java |
User에 allowedPersonalInformation 필드 및 allowPersonalInformation 메서드 추가. UserController에 개인 정보 동의 엔드포인트 추가. UserCommandService가 동의 처리 메서드 및 LoginResponse에 동의 상태 포함하도록 업데이트. |
테스트 backend/src/test/java/moadong/club/service/ClubApplyAdminServiceTest.java, backend/src/test/java/moadong/club/service/ClubImageUrlPersistenceServiceTest.java, backend/src/test/java/moadong/club/service/ClubProfileServiceTest.java, backend/src/test/java/moadong/club/service/ClubSearchServiceTest.java, backend/src/test/java/moadong/media/resolver/PreferWebpImageDisplayUrlResolverTest.java, backend/src/test/java/moadong/unit/club/ClubProfileServiceTest.java |
클럽 라이프사이클 관리 테스트 개선, 이미지 URL 지속성 및 해석기 테스트 추가, 검색 서비스 테스트 업데이트. |
Sequence Diagram(s)
sequenceDiagram
participant Client
participant PromotionController as PromotionArticle<br/>Controller
participant PromotionService as PromotionArticle<br/>Service
participant ClubRepo as ClubRepository
participant PromotionRepo as PromotionArticle<br/>Repository
Client->>PromotionController: POST /api/promotion<br/>(PromotionArticleCreateRequest)
PromotionController->>PromotionService: createPromotionArticle(request)
PromotionService->>ClubRepo: findById(clubId)
alt Club Found
ClubRepo-->>PromotionService: Club
PromotionService->>PromotionService: Build PromotionArticle<br/>(request + clubName)
PromotionService->>PromotionRepo: save(promotionArticle)
PromotionRepo-->>PromotionService: saved
PromotionService-->>PromotionController: ResponseEntity.ok
else Club Not Found
ClubRepo-->>PromotionService: empty
PromotionService-->>PromotionController: RestApiException<br/>(CLUB_NOT_FOUND)
end
PromotionController-->>Client: 200/400
sequenceDiagram
participant Client
participant WordDictController as WordDictionary<br/>AdminController
participant WordDictAdminService as WordDictionary<br/>AdminService
participant CsvParser
participant WordDictService as WordDictionary<br/>Service
participant WordDictRepo as WordDictionary<br/>Repository
Client->>WordDictController: POST /api/admin/word-dictionary/csv<br/>(MultipartFile)
WordDictController->>CsvParser: parseWordDictionaryCsv(file)
CsvParser-->>WordDictController: List<WordDictionaryCreateRequest>
WordDictController->>WordDictAdminService: createWordDictionariesFromCsv(file)
WordDictAdminService->>WordDictAdminService: Parse CSV to<br/>List<WordDictionaryCreateRequest>
loop For Each Request
WordDictAdminService->>WordDictRepo: findByStandardWord(word)
alt Duplicate Check
WordDictRepo-->>WordDictAdminService: exists
WordDictAdminService->>WordDictAdminService: Skip (duplicate)
else New Entry
WordDictAdminService->>WordDictRepo: save(wordDictionary)
end
end
WordDictAdminService->>WordDictService: refreshDictionary()
WordDictService->>WordDictRepo: findAll()
WordDictRepo-->>WordDictService: List<WordDictionary>
WordDictService->>WordDictService: Build in-memory cache
WordDictAdminService-->>WordDictController: ResponseEntity.ok
WordDictController-->>Client: 200
sequenceDiagram
participant Client
participant SearchController as ClubSearch<br/>Controller
participant SearchService as ClubSearch<br/>Service
participant SearchRepo as ClubSearch<br/>Repository
participant WordDictService as WordDictionary<br/>Service
participant ImageResolver as ImageDisplay<br/>UrlResolver
participant PersistenceService as ClubImageUrl<br/>PersistenceService
Client->>SearchController: GET /api/clubs?keyword=...
SearchController->>SearchService: searchClubsByKeyword(keyword)
SearchService->>SearchRepo: findByKeyword(keyword)
SearchRepo->>WordDictService: expandKeywords(keyword)
WordDictService-->>SearchRepo: List<String> (확장키워드)
SearchRepo->>SearchRepo: Build OR-regex filter
SearchRepo-->>SearchService: List<ClubSearchResult>
SearchService->>SearchService: Sort by category priority
loop For Each Club Result
SearchService->>ImageResolver: resolveDisplayUrl(logo)
alt URL Changed
ImageResolver-->>SearchService: resolved URL
SearchService->>PersistenceService: schedulePersistResolvedUrls(...)
PersistenceService->>PersistenceService: Async: Update Club
else URL Unchanged
ImageResolver-->>SearchService: same URL
end
end
SearchService-->>SearchController: ClubSearchResponse
SearchController-->>Client: 200 (clubs with resolved URLs)
sequenceDiagram
participant Client
participant ClubProfileController as ClubProfile<br/>Controller
participant ClubProfileService as ClubProfile<br/>Service
participant ClubDetailedResult
participant ImageResolver as ImageDisplay<br/>UrlResolver
participant PersistenceService as ClubImageUrl<br/>PersistenceService
Client->>ClubProfileController: GET /api/clubs/{clubId}
ClubProfileController->>ClubProfileService: getClubProfile(clubId)
ClubProfileService->>ClubDetailedResult: of(club, imageResolver)
alt With Resolver
ClubDetailedResult->>ImageResolver: resolveDisplayUrl(logo)
ImageResolver-->>ClubDetailedResult: resolved logo
ClubDetailedResult->>ImageResolver: resolveDisplayUrl(cover)
ImageResolver-->>ClubDetailedResult: resolved cover
ClubDetailedResult->>ClubDetailedResult: Resolve feed images
ClubDetailedResult-->>ClubProfileService: ClubDetailedResult<br/>(with URLs)
end
ClubProfileService->>PersistenceService: schedulePersistResolvedUrls(...)
PersistenceService->>PersistenceService: Async: Update Club<br/>if changed
ClubProfileService-->>ClubProfileController: ClubDetailedResult
ClubProfileController-->>Client: 200 (club profile)
Estimated code review effort
🎯 4 (Complex) | ⏱️ ~60 minutes
Possibly related PRs
- [feature] 개인정보 활용 동의 API 추가 #1160: 동일한 개인 정보 동의 기능 추가 (User.allowedPersonalInformation, LoginResponse, UserCommandService 메서드)
- feat: 이미지 로딩 시에 webp가 존재하면 로딩 아니면 원본 로딩 기능 추가 #1134: ImageDisplayUrlResolver 구현, ClubImageUrlPersistenceService, ClubDetailedResult.of 오버로드 및 ClubProfileService/ClubSearchService 통합과 동일
- [feature] 연관어 검색 구현 #1093: WordDictionary 엔티티/저장소, CsvParser, WordDictionaryService/Admin/Controller 및 ClubSearchRepository 키워드 확장 통합과 동일
Suggested labels
✨ Feature, 📬 API
Suggested reviewers
- Zepelown
- seongwon030
🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
| Check name | Status | Explanation | Resolution |
|---|---|---|---|
| Docstring Coverage | Docstring coverage is 36.51% which is insufficient. The required threshold is 80.00%. | Write docstrings for the functions missing them to satisfy the coverage threshold. | |
| Title check | ❓ Inconclusive | 제목 '[BE] Release'는 매우 일반적이고 모호하여 실제 변경 사항의 핵심을 전달하지 못합니다. | 제목을 더 구체적으로 작성하세요. 예: '[BE] v1.1.4 Release - 프로모션 게시판, WebP 이미지 처리, 단어 사전 기반 검색' 같이 주요 기능들을 명시하면 좋습니다. |
✅ Passed checks (1 passed)
| Check name | Status | Explanation |
|---|---|---|
| Description Check | ✅ Passed | Check skipped - CodeRabbit’s high-level summary is enabled. |
✏️ Tip: You can configure your own custom pre-merge checks in the settings.
✨ Finishing touches
- 📝 Generate docstrings
🧪 Generate unit tests (beta)
- Create PR with unit tests
- Post copyable unit tests in a comment
- Commit unit tests in branch
develop/be
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands and usage tips.
There was a problem hiding this comment.
Actionable comments posted: 14
🤖 Fix all issues with AI agents
In `@backend/build.gradle`:
- Around line 24-29: Remove the unnecessary snapshot/milestone Maven
repositories declared as the three maven blocks (the ones with url
'https://repo.spring.io/milestone', 'https://repo.spring.io/snapshot', and the
block named 'Central Portal Snapshots' pointing to
'https://central.sonatype.com/repository/maven-snapshots/') from the Gradle
repositories block so only standard release repositories remain; update the
repositories section in build.gradle by deleting those three maven {...} entries
to speed dependency resolution and avoid pulling unstable artifacts.
In
`@backend/src/main/java/moadong/club/controller/PromotionArticleController.java`:
- Around line 35-43: The createPromotionArticle endpoint in
PromotionArticleController currently only checks isAuthenticated() but does not
verify the caller is an admin of the target club; update the authorization to
validate club-level permission by either adding a PreAuthorize expression that
calls a bean method (e.g. `@PreAuthorize`("@clubAuthService.isClubAdmin(principal,
`#request.clubId`)") ) or by adding an explicit check in
promotionArticleService.createPromotionArticle that looks up the current user
(from SecurityContext) and verifies they are a manager/admin of the clubId
contained in PromotionArticleCreateRequest, throwing AccessDeniedException if
not authorized; reference createPromotionArticle, PromotionArticleCreateRequest,
and promotionArticleService.createPromotionArticle when making the change.
In `@backend/src/main/java/moadong/club/repository/ClubSearchRepository.java`:
- Line 64: The Aggregation.unwind call is using a non-existent field
"club_tags", so change the unwind field to "recruitmentInformation.tags" to
match the match stage and projection; update the Aggregation.unwind("club_tags",
true) invocation to Aggregation.unwind("recruitmentInformation.tags", true) so
the unwind operates on the actual document field referenced elsewhere in the
pipeline (search for Aggregation.unwind and the match/projection that reference
"recruitmentInformation.tags").
In `@backend/src/main/java/moadong/club/service/PromotionArticleService.java`:
- Around line 32-50: In createPromotionArticle (PromotionArticleService) add an
authorization check after loading Club from clubRepository: obtain the current
authenticated user id from SecurityContextHolder (via
Authentication.getPrincipal() or a UserDetails service), compare it to the
club's owner/admin field(s) (e.g., club.getOwnerId() or club.getAdminIds()), and
if the user is not an authorized manager throw a RestApiException with an
appropriate error code (e.g., ErrorCode.FORBIDDEN); perform this check before
building/saving the PromotionArticle so only club managers can create promotion
articles.
In `@backend/src/main/java/moadong/club/service/WordDictionaryAdminService.java`:
- Around line 142-143: Replace the incorrect ErrorCode used when a
WordDictionary is missing: in WordDictionaryAdminService update all occurrences
where wordDictionaryRepository.findById(...).orElseThrow(() -> new
RestApiException(ErrorCode.CLUB_NOT_FOUND)) (and similar orElseThrow usages at
the other listed locations) to throw new
RestApiException(ErrorCode.WORD_DICTIONARY_NOT_FOUND) instead; ensure every
findById/orElseThrow in that service uses ErrorCode.WORD_DICTIONARY_NOT_FOUND
and keep the RestApiException and repository usage unchanged.
- Around line 41-43: 현재 WordDictionaryAdminService의 중복 표준단어 검사에서
RestApiException에 ErrorCode.USER_INVALID_FORMAT를 던지고 있는데 의미가 맞지 않습니다;
wordDictionaryRepository.findByStandardWord(...) 결과가 존재할 때 던지는 예외의 ErrorCode를
의미에 맞는 값으로 교체하거나 새 enum 값을 추가하세요 (예: ErrorCode.DUPLICATE_STANDARD_WORD), 해당
enum을 ErrorCode 선언부에 추가하고 WordDictionaryAdminService에서 RestApiException 생성 시 새
코드로 바꾸며 관련 테스트나 에러-응답 매핑이 있다면 함께 갱신하세요.
- Around line 63-92: Both createWordDictionariesBulk and
createWordDictionariesFromCsv duplicate the same duplicate-check/save/refresh
logic and use a per-item findByStandardWord causing N+1 queries; extract the
shared logic into a private helper (e.g., private List<WordDictionaryResponse>
createAndSaveWordDictionaries(Collection<WordDictionaryCreateRequest> requests))
that: calls a new repository method findByStandardWordIn(Collection<String>
standardWords) to load existing standardWords once, filters the incoming
requests in-memory against that set, maps remaining requests to WordDictionary
entities, calls saveAll once, invokes wordDictionaryService.refreshDictionary()
once, and returns responses via toResponse; add findByStandardWordIn to
WordDictionaryRepository and update both createWordDictionariesBulk and
createWordDictionariesFromCsv to delegate to the new helper.
In `@backend/src/main/java/moadong/club/service/WordDictionaryService.java`:
- Around line 86-92: The CSV parsing in WordDictionaryService (around the block
that currently uses line.split(",")) fails on quoted fields; replace the naive
split with a proper CSV parser (e.g., reuse your existing CsvParser instance or
integrate a library like OpenCSV/Apache Commons CSV) so it correctly handles
quoted fields and embedded commas; update the code that reads columns (the
String[] columns logic and subsequent checks) to use the parser's output and
keep the same warning/continue behavior when a record has fewer than the
expected fields.
- Around line 239-258: The expandKeywords method returns a direct reference to
the internal mutable List from the field dictionary, breaking thread-safety;
modify expandKeywords to return an immutable copy (e.g., List.copyOf(expanded)
or Collections.unmodifiableList(new ArrayList<>(expanded))) instead of the raw
expanded list, and also ensure any code that builds/populates the dictionary
(the place that assigns to the dictionary field) wraps stored lists as immutable
(e.g., Collections.unmodifiableList(...)) so callers and other threads cannot
mutate internal state; reference expandKeywords and the dictionary field when
making these changes.
In `@backend/src/main/java/moadong/club/summary/ApplicantIdMessageConsumer.java`:
- Line 83: The current check in ApplicantIdMessageConsumer (where
summarizeContent.response() is inspected) only guards against null but not empty
or whitespace-only strings; update the conditional around
summarizeContent.response() to also treat empty/blank responses as missing
(e.g., check response == null || response.trim().isEmpty() or use
StringUtils.isBlank(response)) and return early in that case so no meaningless
summary is persisted.
- Line 80: The code in ApplicantIdMessageConsumer calls
log.info(prompt.toString()) which writes decrypted applicant PII (name, student
id, phone, answers) at INFO level; remove this INFO-level logging and either
omit logging entirely or log only non-PII metadata (e.g., messageId, length) or
a redacted summary, and if you must keep a log use a secure, lower-verbosity
level (debug) with explicit redaction; update the call site that references
prompt (prompt.toString()) to instead produce a redacted string or metadata, and
ensure ApplicantIdMessageConsumer uses that redacted output for any logs to
avoid writing raw PII.
In `@backend/src/main/java/moadong/gemma/service/GemmaService.java`:
- Around line 50-64: The call in GemmaService that does
restTemplate.postForObject(gemmaServerUrl, request, AIResponse.class) lacks
network timeouts; update the code to use a RestTemplate instance configured with
connect and read timeouts (or inject a dedicated timeout-configured
RestTemplate) before invoking postForObject, ensuring you set sensible
connectTimeout and readTimeout (or set the client's request factory/HttpClient
timeout settings) so GemmaService's call to postForObject(gemmaServerUrl, ...)
cannot block indefinitely.
- Around line 60-64: The catch in GemmaService.getSummarizeContent currently
logs and returns null (causing NPE in ApplicantIdMessageConsumer when calling
summarizeContent.response()); change the behavior to not return null—either
throw a descriptive unchecked exception (e.g., new IllegalStateException("Failed
to get summarize content from "+ gemmaServerUrl, e)) from the catch block or
return a non-null fallback SummarizeContent object; update the method signature
if needed and ensure ApplicantIdMessageConsumer handles the exception or
fallback (reference: GemmaService.getSummarizeContent and
ApplicantIdMessageConsumer.summarizeContent.response()) so callers never receive
null.
In
`@backend/src/main/java/moadong/media/resolver/PreferWebpImageDisplayUrlResolver.java`:
- Around line 122-131: The toWebpKey method incorrectly uses lastIndexOf('.')
across the whole key which can match dots in earlier path segments (e.g.,
"v1.0/image" -> "v1.webp"); change the logic to find the last slash (path
separator) first and only treat a dot as the extension separator if its index is
after that slash (i.e., compute lastSlash = key.lastIndexOf('/') and then only
use lastDot when lastDot > lastSlash); keep the existing behaviors for
null/empty keys and for keys with no extension by appending ".webp" when no
valid dot-after-slash is found.
🧹 Nitpick comments (28)
backend/src/main/java/moadong/club/entity/ClubApplicant.java (1)
48-50:updateAiSummary메서드도 기존 패턴과 일관됩니다.
updateMemo와 동일한 패턴을 따르고 있습니다. 다만,null입력 시 기존 빈 문자열 기본값이null로 덮어씌워질 수 있습니다.updateMemo도 동일한 상황이므로 현재로서는 문제가 되지 않지만, 향후 방어적null처리를 일괄 적용하는 것도 고려해볼 수 있습니다.backend/src/test/java/moadong/club/service/ClubApplyAdminServiceTest.java (1)
47-76: setUp에서 User는 생성하지만 tearDown에서 삭제하지 않음
Club과ClubApplicationForm은tearDown에서 삭제하지만,User는 삭제하지 않습니다. 테스트 간 데이터 격리를 위해User도 정리하거나, 아니면setUp의 존재 여부 체크에 의존하는 것이 의도적인지 확인이 필요합니다.또한, if-else 블록들을
Optional메서드로 간결하게 정리할 수 있습니다.♻️ setUp 간소화 제안
`@BeforeEach` void setUp() { - if (userRepository.findUserByUserId(UserFixture.collectUserId).isEmpty()) { - userRepository.save(UserFixture.createUser(passwordEncoder)); - } - User user = userRepository.findUserByUserId(UserFixture.collectUserId).get(); + User user = userRepository.findUserByUserId(UserFixture.collectUserId) + .orElseGet(() -> userRepository.save(UserFixture.createUser(passwordEncoder))); userDetails = new CustomUserDetails(user); - // Club이 없으면 생성 - if (clubRepository.findClubByUserId(user.getId()).isEmpty()) { - Club club = new Club(user.getId()); - clubRepository.save(club); - this.clubId = club.getId(); - } else { - Club club = clubRepository.findClubByUserId(user.getId()).get(); - this.clubId = club.getId(); - } + Club club = clubRepository.findClubByUserId(user.getId()) + .orElseGet(() -> clubRepository.save(new Club(user.getId()))); + this.clubId = club.getId(); - // ClubApplicationForm이 없으면 생성 - if (clubApplicationFormsRepository.findByClubId(clubId).isEmpty()) { - ClubApplicationForm clubApplicationForm = ClubApplicationForm.builder() + ClubApplicationForm form = clubApplicationFormsRepository.findByClubId(clubId) + .stream().findFirst() + .orElseGet(() -> clubApplicationFormsRepository.save(ClubApplicationForm.builder() .clubId(clubId) - .build(); - clubApplicationFormsRepository.save(clubApplicationForm); - this.clubApplicationFormId = clubApplicationForm.getId(); - } else { - ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubId(clubId).get(0); - this.clubApplicationFormId = clubApplicationForm.getId(); - } + .build())); + this.clubApplicationFormId = form.getId(); }backend/src/main/java/moadong/user/entity/User.java (1)
138-141: 개인정보 동의 철회(revoke) 메서드가 없습니다.
allowPersonalInformation()은 동의를true로만 설정하며, 다시false로 되돌리는 방법이 없습니다. GDPR/개인정보보호법 등에서는 사용자가 동의를 철회할 수 있어야 합니다. 향후 필요할 수 있으므로revokePersonalInformation()메서드 추가를 고려해 주세요.제안
public void allowPersonalInformation() { this.allowedPersonalInformation = true; } + + public void revokePersonalInformation() { + this.allowedPersonalInformation = false; + }backend/src/main/java/moadong/user/service/UserCommandService.java (1)
7-7:org.springframework.transaction.annotation.Transactional로 변경하세요.프로젝트는
MongoTransactionManager를 사용하고 있으므로,jakarta.transaction.Transactional(JTA 전용)보다 Spring의@Transactional이 적합합니다. Spring의@Transactional은MongoDB 트랜잭션 관리와 잘 통합되며,readOnly,propagation,rollbackFor등의 추가 속성도 지원합니다. 또한 코드베이스의 대부분의 서비스가 이미org.springframework.transaction.annotation.Transactional을 사용하고 있으므로 일관성도 개선됩니다.-import jakarta.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional;backend/src/main/java/moadong/global/config/AsyncConfig.java (1)
27-37: 두 executor의 설정이 완전히 동일합니다 — 헬퍼 메서드 추출을 고려해 보세요.
fcmAsyncExecutor()와imageUrlAsyncExecutor()의 풀 설정(corePoolSize, maxPoolSize, queueCapacity, rejectionPolicy)이 동일합니다. 향후 설정 변경 시 하나만 수정하고 다른 하나를 누락할 수 있으므로, 공통 설정을 추출하는 것이 좋습니다.♻️ 헬퍼 메서드 추출 제안
+ private ThreadPoolTaskExecutor createExecutor(String threadNamePrefix) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix(threadNamePrefix); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } + `@Bean`(name = "fcmAsync") public TaskExecutor fcmAsyncExecutor() { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(4); - executor.setMaxPoolSize(8); - executor.setQueueCapacity(100); - executor.setThreadNamePrefix("moadong-async-"); - executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); - executor.initialize(); - return executor; + return createExecutor("moadong-async-"); } `@Bean`(name = "imageUrlAsync") public TaskExecutor imageUrlAsyncExecutor() { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(4); - executor.setMaxPoolSize(8); - executor.setQueueCapacity(100); - executor.setThreadNamePrefix("moadong-image-url-async-"); - executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); - executor.initialize(); - return executor; + return createExecutor("moadong-image-url-async-"); }backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java (1)
57-70:ClubSearchResult.of()와 달리clubRecruitmentInformation에 대한 null 체크가 없습니다.
ClubSearchResult.of(Club)에서는info != null방어 코드가 있지만, 이 메서드에서는clubRecruitmentInformation이 null일 경우 Line 46의hasRecruitmentPeriod()호출 시점에서 NPE가 발생합니다. 이 부분은 기존 코드이므로 이 PR의 범위는 아니지만, 일관성을 위해 향후 개선을 고려해 보세요.Resolver 통합 로직 자체는 null-safe하게 잘 구현되어 있습니다.
backend/src/main/java/moadong/media/resolver/PreferWebpImageDisplayUrlResolver.java (1)
98-106:String.replace는 모든 일치를 치환합니다.
toAlternatePathKey에서String.replace를 사용하면 경로 내 동일 세그먼트가 여러 번 나타날 경우 모두 대문자로 치환됩니다. 현재 S3 키 구조상 문제가 되지 않을 수 있으나,replaceFirst를 사용하면 의도를 더 명확히 할 수 있습니다.backend/src/test/java/moadong/media/resolver/PreferWebpImageDisplayUrlResolverTest.java (1)
35-53: 테스트 커버리지가 부족합니다.현재
.webp로 끝나는 URL에 대한 바이패스 케이스만 검증되고 있습니다. 다음 시나리오에 대한 테스트 추가를 권장합니다:
- S3 HEAD 성공 시 WebP URL 반환
- S3 HEAD 실패(NoSuchKeyException) 시 원본 URL 폴백
- 대문자 경로(alternate key) 시도 및 성공/실패
null/빈 문자열 입력 처리- S3Exception 발생 시 안전한 폴백
backend/src/main/java/moadong/club/payload/request/WordDictionaryUpdateRequest.java (1)
7-11: 리스트 내 개별 요소에 대한 유효성 검증이 없습니다.
@NotEmpty는 리스트 자체가 비어있지 않은지만 검증합니다. 리스트 내null이나 빈 문자열이 포함될 수 있습니다. 개별 요소 검증이 필요하면@NotBlank를 요소 레벨에 추가할 수 있습니다:💡 제안
public record WordDictionaryUpdateRequest( `@NotEmpty`(message = "입력단어 목록은 비어있을 수 없습니다") - List<String> inputWords + List<@NotBlank(message = "입력단어는 빈 문자열일 수 없습니다") String> inputWords ) { }backend/src/test/java/moadong/club/service/ClubProfileServiceTest.java (2)
53-60:save()반환값을 사용하는 것이 더 안전합니다.Spring Data MongoDB는 일반적으로 원본 객체에도 ID를 설정하지만,
save()의 반환값을 사용하는 것이 관례적으로 더 안전합니다.♻️ 제안
if (clubRepository.findClubByUserId(user.getId()).isEmpty()) { Club club = new Club(user.getId()); - clubRepository.save(club); - this.clubId = club.getId(); + Club saved = clubRepository.save(club); + this.clubId = saved.getId(); } else { Club club = clubRepository.findClubByUserId(user.getId()).get(); this.clubId = club.getId(); }
123-125:notFoundCount단언을conflictCount단언보다 먼저 수행하면 실패 원인 파악이 더 쉽습니다.
conflictCount단언이 먼저 실패하면, 실제 원인이CLUB_NOT_FOUND인 경우에도 충돌 횟수 불일치 메시지만 보게 됩니다.notFoundCount검증을 먼저 배치하면 근본 원인을 바로 확인할 수 있습니다.♻️ 단언 순서 변경 제안
+ assertEquals(0, notFoundCount.get(), "CLUB_NOT_FOUND 예외는 발생하지 않아야 합니다."); assertEquals(1, successCount.get(), "성공한 요청은 1개여야 합니다."); assertEquals(numberOfThreads - 1, conflictCount.get(), "실패(충돌)한 요청은 " + (numberOfThreads - 1) + "개여야 합니다."); - assertEquals(0, notFoundCount.get(), "CLUB_NOT_FOUND 예외는 발생하지 않아야 합니다.");backend/src/test/java/moadong/club/service/ClubImageUrlPersistenceServiceTest.java (1)
58-58:Mockito.never()를 정적 임포트로 통일하면 가독성이 향상됩니다.Line 4에서
verify와when은 정적 임포트를 사용하고 있지만,never()는org.mockito.Mockito.never()로 완전한 경로를 사용하고 있어 일관성이 떨어집니다.♻️ 정적 임포트 추가 제안
import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never;그런 다음
org.mockito.Mockito.never()를never()로 변경합니다.Also applies to: 67-67
backend/src/main/java/moadong/club/payload/request/WordDictionaryCreateRequest.java (1)
12-13:inputWords리스트 내 개별 요소에 대한 유효성 검증이 없습니다.
@NotEmpty는 리스트가 비어있지 않은지만 검증합니다. 빈 문자열("")이나 공백 문자열(" ")이 요소로 포함되는 것을 방지하려면 요소 수준의 제약 조건을 추가하세요.♻️ 요소별 검증 추가 제안
`@NotEmpty`(message = "입력단어 목록은 비어있을 수 없습니다") - List<String> inputWords + List<@NotBlank(message = "입력단어는 빈 값일 수 없습니다") String> inputWordsbackend/src/main/java/moadong/club/entity/PromotionArticle.java (2)
39-40:createdAt의@Builder.Default사용 시 주의점.
Instant.now()는 객체 생성 시점에 평가됩니다. Builder 패턴으로 생성할 때는 정상 동작하지만, MongoDB에서createdAt필드가 없는 기존 문서를 역직렬화할 경우NoArgsConstructor의 필드 초기화로 인해 현재 시각이 설정됩니다. 의도된 동작인지 확인해 주세요. 서버 타임스탬프가 필요하면 Spring Data의@CreatedDate와@EnableMongoAuditing사용을 고려하세요.
23-23: 프로모션 조회 시clubId기반 쿼리가 예상된다면, MongoDB 인덱스 추가를 고려하세요.
clubId로 필터링하거나createdAt으로 정렬하는 쿼리가 빈번할 경우,@Indexed어노테이션이나 별도 인덱스 설정이 성능에 도움이 됩니다.backend/src/main/java/moadong/club/repository/ClubSearchRepository.java (1)
27-28: Repository가 Service 계층에 의존하고 있습니다.
ClubSearchRepository가WordDictionaryService를 직접 의존하는 것은 일반적인 레이어 아키텍처(Repository → Service 방향이 아닌 Service → Repository 방향이 정상)를 위반합니다. 이로 인해 순환 의존 위험이 생기고 테스트 시 불필요한 mocking이 필요해집니다.키워드 확장 로직은 Service 계층(
ClubSearchService)에서 수행한 후 확장된 키워드 목록을 Repository 메서드의 파라미터로 전달하는 방식이 더 적절합니다.♻️ 제안된 구조 변경
Repository 메서드 시그니처를 변경하여 확장된 키워드를 받도록 합니다:
public class ClubSearchRepository { private final MongoTemplate mongoTemplate; - private final WordDictionaryService wordDictionaryService; - public List<ClubSearchResult> searchClubsByKeyword(String keyword, String recruitmentStatus, + public List<ClubSearchResult> searchClubsByKeyword(List<String> expandedKeywords, String recruitmentStatus, String division, String category) {그리고
ClubSearchService에서 키워드 확장 후 Repository를 호출합니다.backend/src/test/java/moadong/club/service/ClubSearchServiceTest.java (1)
65-111: 주석 처리된 테스트 코드를 삭제하거나 복원해야 합니다.두 개의 테스트가 주석 처리되어 있습니다: 빈 리스트 반환 테스트와 정렬 검증 테스트. 새로운 의존성(
imageDisplayUrlResolver등)이 추가되면서 비활성화된 것으로 보입니다.이 테스트들이 여전히 유효하다면 새 mock 설정에 맞게 업데이트하여 활성화하고, 더 이상 필요하지 않다면 삭제하는 것이 좋습니다. 주석 처리된 테스트 코드는 유지보수 부담을 증가시킵니다.
backend/src/main/java/moadong/gemma/service/GemmaService.java (1)
32-48: AI 모델명과 옵션 값들이 하드코딩되어 있습니다.
"gemma3:4b",4096,0.0,150,0.9,1.1등의 값들이 코드에 직접 작성되어 있어, 변경 시 재배포가 필요합니다.gemma.server.host/port처럼 application properties로 외부화하는 것이 운영 유연성에 도움이 됩니다.backend/src/main/java/moadong/club/util/CsvParser.java (2)
54-59: 단순split(",")방식은 CSV 표준을 완전히 처리하지 못합니다.필드 값에 쉼표가 포함된 경우(예:
"프로그래밍,코딩"같은 인용 필드) 파싱이 잘못됩니다. 현재 단어 사전 데이터에서는 문제가 없을 수 있지만, CSV 파일 업로드를 외부(관리자)에게 제공하는 API이므로 예기치 않은 입력에 대한 방어가 필요합니다.Apache Commons CSV나 OpenCSV 같은 라이브러리를 사용하면 인용 필드, 이스케이프 등을 올바르게 처리할 수 있습니다.
84-87: 일반RuntimeException대신 도메인 예외를 사용하는 것이 좋습니다.프로젝트에서 이미
RestApiException/ErrorCode패턴을 사용하고 있으므로, CSV 파싱 실패에 대한ErrorCode를 추가하고RestApiException을 throw하면 일관된 에러 응답을 반환할 수 있습니다.backend/src/main/java/moadong/club/payload/request/PromotionArticleCreateRequest.java (2)
14-15:eventStartDate가eventEndDate이전인지 검증이 누락되어 있습니다.현재
@NotNull만 적용되어 있어 종료일이 시작일보다 앞선 데이터가 저장될 수 있습니다. 커스텀 validation 또는 서비스 레이어에서 검증을 추가하는 것을 권장합니다.
17-17: 이미지 목록의 최대 크기 제한을 고려해주세요.
@NotEmpty만 적용되어 있어 이미지 개수에 상한이 없습니다.@Size(max = N)을 추가하여 과도한 입력을 방지하는 것이 좋습니다.backend/src/main/java/moadong/club/controller/PromotionArticleController.java (1)
28-33: GET 엔드포인트에 페이지네이션이 없습니다.홍보 게시글이 증가하면 전체 목록을 한 번에 반환하는 것은 성능 문제를 야기할 수 있습니다.
Pageable파라미터 추가를 고려해주세요.backend/src/main/java/moadong/club/service/PromotionArticleService.java (1)
38-47:eventStartDate가eventEndDate이전인지 서비스 레이어에서도 검증을 고려해주세요.request DTO에 날짜 순서 검증이 없으므로, 최소한 이 레이어에서 방어적으로 확인하는 것이 좋습니다.
🛡️ 검증 로직 예시
+ if (request.eventEndDate().isBefore(request.eventStartDate())) { + throw new RestApiException(ErrorCode.INVALID_DATE_RANGE); + } + PromotionArticle article = PromotionArticle.builder()backend/src/main/java/moadong/club/service/WordDictionaryService.java (2)
107-144: dictionary 구축 로직이loadFromCsvAndSaveToMongo와loadDictionaryFromMongo에 중복되어 있습니다.표준단어/입력단어로부터 확장 리스트를 만들고, 정규화 키로 매핑하고, 중복을 제거하는 로직이 거의 동일합니다. 공통 private 메서드로 추출하면 유지보수성이 향상됩니다.
Also applies to: 176-211
50-166:CsvParser가 이미 존재하는데 수동 CSV 파싱을 하고 있습니다.
WordDictionaryAdminService에서 사용하는CsvParser.parseWordDictionaryCsv()가 이미 CSV →WordDictionaryCreateRequest변환을 처리합니다. 여기서도CsvParser를 재사용하면 파싱 로직 중복과 불일치 위험을 줄일 수 있습니다.backend/src/main/java/moadong/club/service/WordDictionaryAdminService.java (2)
145-150: 업데이트 시 새 엔티티를 빌더로 재생성하는 방식기존 엔티티를 조회한 후 빌더로 새 객체를 만들고 있습니다. 이 방식은
id와standardWord를 수동으로 복사해야 하므로 필드 추가 시 누락 위험이 있습니다.WordDictionary엔티티에updateInputWords(List<String> inputWords)같은 업데이트 메서드를 추가하는 것이 더 안전합니다.
38-57: 모든 변경 작업 후refreshDictionary()호출의 성능 영향모든 CUD 작업 후
refreshDictionary()를 호출하여 MongoDB에서 전체 사전을 다시 로드하고 있습니다. 사전 크기가 커지면 매 변경마다 전체 재로드가 부담이 될 수 있습니다. 현재 규모에서 문제가 없다면 당장은 괜찮지만, 향후 증분 업데이트(incremental update) 방식을 고려해 보세요.Also applies to: 62-92, 97-135, 140-159, 164-175
| maven { url 'https://repo.spring.io/milestone' } | ||
| maven { url 'https://repo.spring.io/snapshot' } | ||
| maven { | ||
| name = 'Central Portal Snapshots' | ||
| url = 'https://central.sonatype.com/repository/maven-snapshots/' | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check for snapshot/milestone/RC versions in gradle files
rg -n -i '(SNAPSHOT|milestone|\.M[0-9]|\.RC[0-9])' --type=gradle
# Check for Spring AI dependencies
rg -n 'spring-ai|vector|embedding' --type=gradle
# Get the full build.gradle file to see all dependencies
find . -name "build.gradle" -type f | head -5Repository: Moadong/moadong
Length of output: 447
🏁 Script executed:
cat -n backend/build.gradleRepository: Moadong/moadong
Length of output: 3934
불필요한 스냅샷/마일스톤 저장소 제거
현재 선언된 모든 종속성이 안정 릴리스 버전을 사용하고 있어, milestone, snapshot, Central Portal Snapshots 저장소가 필요하지 않습니다. 이 저장소들을 제거하여 의존성 해석 시간을 단축하고 의도치 않은 불안정한 아티팩트 해석 위험을 제거하세요.
🤖 Prompt for AI Agents
In `@backend/build.gradle` around lines 24 - 29, Remove the unnecessary
snapshot/milestone Maven repositories declared as the three maven blocks (the
ones with url 'https://repo.spring.io/milestone',
'https://repo.spring.io/snapshot', and the block named 'Central Portal
Snapshots' pointing to
'https://central.sonatype.com/repository/maven-snapshots/') from the Gradle
repositories block so only standard release repositories remain; update the
repositories section in build.gradle by deleting those three maven {...} entries
to speed dependency resolution and avoid pulling unstable artifacts.
backend/src/main/java/moadong/club/controller/PromotionArticleController.java
Show resolved
Hide resolved
| Criteria.where("recruitmentInformation.tags").regex(regexPattern, "i") | ||
| ))); | ||
| } | ||
| operations.add(Aggregation.unwind("club_tags", true)); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# 검색 1: "club_tags" 사용법 확인
rg -n "club_tags" --type=java -C3Repository: Moadong/moadong
Length of output: 810
🏁 Script executed:
# 검색 2: ClubSearchRepository 파일 확인
find . -name "ClubSearchRepository.java" -type fRepository: Moadong/moadong
Length of output: 132
🏁 Script executed:
# 검색 3: "recruitmentInformation.tags" 참조 확인
rg -n "recruitmentInformation\.tags" --type=java -C2Repository: Moadong/moadong
Length of output: 1201
🏁 Script executed:
# ClubSearchRepository.java 라인 50-80 확인
sed -n '50,80p' backend/src/main/java/moadong/club/repository/ClubSearchRepository.java | cat -nRepository: Moadong/moadong
Length of output: 1531
🏁 Script executed:
# Club 모델/엔티티 파일 찾기
find . -name "Club.java" -type f | grep -E "(model|entity|domain)"Repository: Moadong/moadong
Length of output: 112
🏁 Script executed:
# "club_tags"가 다른 곳에서 생성되거나 사용되는지 확인
rg -n "club_tags" --type=javaRepository: Moadong/moadong
Length of output: 190
🏁 Script executed:
# Club.java 엔티티 파일 내용 확인 (필드명 확인)
cat backend/src/main/java/moadong/club/entity/Club.java | grep -E "(club_tags|recruitmentInformation|@Field|@Document)" -A2 -B2Repository: Moadong/moadong
Length of output: 339
🏁 Script executed:
# 더 상세한 Club 엔티티 구조
sed -n '1,100p' backend/src/main/java/moadong/club/entity/Club.java | cat -nRepository: Moadong/moadong
Length of output: 3930
unwind 작업의 필드명을 "recruitmentInformation.tags"로 변경 필요합니다.
MongoDB 엔티티 정의 확인 결과, "club_tags" 필드는 Club 도큐먼트에 존재하지 않습니다. 실제 필드는 "recruitmentInformation.tags"입니다. 현재 코드는 존재하지 않는 필드를 unwind하고 있어 작업이 무효화됩니다.
- Aggregation.unwind("club_tags", true);
+ Aggregation.unwind("recruitmentInformation.tags", true);
이는 매치 조건(라인 61: "recruitmentInformation.tags")과 프로젝션 단계(라인 72: "$recruitmentInformation.tags")에서 사용되는 필드와 일치해야 합니다.
🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/club/repository/ClubSearchRepository.java` at
line 64, The Aggregation.unwind call is using a non-existent field "club_tags",
so change the unwind field to "recruitmentInformation.tags" to match the match
stage and projection; update the Aggregation.unwind("club_tags", true)
invocation to Aggregation.unwind("recruitmentInformation.tags", true) so the
unwind operates on the actual document field referenced elsewhere in the
pipeline (search for Aggregation.unwind and the match/projection that reference
"recruitmentInformation.tags").
| if (wordDictionaryRepository.findByStandardWord(request.standardWord()).isPresent()) { | ||
| throw new RestApiException(ErrorCode.USER_INVALID_FORMAT); | ||
| } |
There was a problem hiding this comment.
잘못된 ErrorCode 사용: 중복 표준단어에 USER_INVALID_FORMAT 사용
USER_INVALID_FORMAT은 단어사전 중복과 의미적으로 맞지 않습니다. DUPLICATE_STANDARD_WORD와 같은 전용 에러 코드를 추가하거나, 최소한 의미가 통하는 에러 코드를 사용해 주세요. API 소비자가 에러 원인을 파악하기 어렵습니다.
🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/club/service/WordDictionaryAdminService.java`
around lines 41 - 43, 현재 WordDictionaryAdminService의 중복 표준단어 검사에서
RestApiException에 ErrorCode.USER_INVALID_FORMAT를 던지고 있는데 의미가 맞지 않습니다;
wordDictionaryRepository.findByStandardWord(...) 결과가 존재할 때 던지는 예외의 ErrorCode를
의미에 맞는 값으로 교체하거나 새 enum 값을 추가하세요 (예: ErrorCode.DUPLICATE_STANDARD_WORD), 해당
enum을 ErrorCode 선언부에 추가하고 WordDictionaryAdminService에서 RestApiException 생성 시 새
코드로 바꾸며 관련 테스트나 에러-응답 매핑이 있다면 함께 갱신하세요.
| log.error("AES_CIPHER_ERROR", e); | ||
| throw new RestApiException(ErrorCode.AES_CIPHER_ERROR); | ||
| } | ||
| log.info(prompt.toString()); |
There was a problem hiding this comment.
🔴 개인정보(PII)가 INFO 레벨로 로깅됩니다.
prompt에는 복호화된 지원자 답변(이름, 학번, 전화번호 등)이 포함되어 있습니다. 이를 log.info로 출력하면 프로덕션 로그에 민감한 개인정보가 그대로 노출됩니다. 시스템 프롬프트에서 개인정보 배제를 강조하면서, 로그에는 그대로 기록하는 모순이 발생합니다.
GDPR/개인정보보호법 위반 위험이 있으므로 즉시 수정이 필요합니다.
🔒 제안된 수정
- log.info(prompt.toString());
+ log.debug("Summarization prompt built for applicant id={}", message.applicantId());📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| log.info(prompt.toString()); | |
| log.debug("Summarization prompt built for applicant id={}", message.applicantId()); |
🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/club/summary/ApplicantIdMessageConsumer.java`
at line 80, The code in ApplicantIdMessageConsumer calls
log.info(prompt.toString()) which writes decrypted applicant PII (name, student
id, phone, answers) at INFO level; remove this INFO-level logging and either
omit logging entirely or log only non-PII metadata (e.g., messageId, length) or
a redacted summary, and if you must keep a log use a secure, lower-verbosity
level (debug) with explicit redaction; update the call site that references
prompt (prompt.toString()) to instead produce a redacted string or metadata, and
ensure ApplicantIdMessageConsumer uses that redacted output for any logs to
avoid writing raw PII.
| AIResponse summarizeContent = gemmaService.getSummarizeContent(system, prompt.toString()); | ||
|
|
||
| AIResponse summarizeContent = gemmaService.getSummarizeContent(prompt.toString()); | ||
| if (summarizeContent.response() == null) return; |
There was a problem hiding this comment.
response가 빈 문자열일 경우의 처리가 누락되어 있습니다.
summarizeContent.response()가 null인 경우만 체크하고 있지만, 빈 문자열("")이나 공백만 있는 경우에도 의미 없는 요약이 저장될 수 있습니다.
💡 제안된 수정
- if (summarizeContent.response() == null) return;
+ if (summarizeContent.response() == null || summarizeContent.response().isBlank()) return;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (summarizeContent.response() == null) return; | |
| if (summarizeContent.response() == null || summarizeContent.response().isBlank()) return; |
🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/club/summary/ApplicantIdMessageConsumer.java`
at line 83, The current check in ApplicantIdMessageConsumer (where
summarizeContent.response() is inspected) only guards against null but not empty
or whitespace-only strings; update the conditional around
summarizeContent.response() to also treat empty/blank responses as missing
(e.g., check response == null || response.trim().isEmpty() or use
StringUtils.isBlank(response)) and return early in that case so no meaningless
summary is persisted.
| AIResponse response = restTemplate.postForObject(gemmaServerUrl, request, AIResponse.class); | ||
|
|
||
| if (response != null) { | ||
| log.info("Successfully received response from Gemma server."); | ||
| log.debug("Gemma response raw content: {}", response.response()); | ||
|
|
||
| return objectMapper.readValue(response.response(), AIResponse.class); | ||
| } else { | ||
| log.warn("Received null response from Gemma server."); | ||
| } | ||
| } catch (Exception e) { | ||
| log.error("Json Serialize Error: ", e); | ||
| log.error("Failed to process summarization request to Gemma server [{}]: ", gemmaServerUrl, e); | ||
| return null; | ||
| } | ||
| return null; |
There was a problem hiding this comment.
외부 AI 서버 호출에 타임아웃이 설정되어 있지 않습니다.
restTemplate.postForObject는 Gemma 서버가 응답하지 않을 경우 무한 대기할 수 있습니다. RabbitMQ consumer의 concurrency = "1" 설정과 결합하면, 단일 지연 요청이 전체 요약 큐 처리를 차단합니다.
RestTemplate 빈 설정에서 connectTimeout과 readTimeout을 설정하거나, 이 호출 전용으로 타임아웃이 설정된 RestTemplate을 사용하는 것을 권장합니다.
🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/gemma/service/GemmaService.java` around lines
50 - 64, The call in GemmaService that does
restTemplate.postForObject(gemmaServerUrl, request, AIResponse.class) lacks
network timeouts; update the code to use a RestTemplate instance configured with
connect and read timeouts (or inject a dedicated timeout-configured
RestTemplate) before invoking postForObject, ensuring you set sensible
connectTimeout and readTimeout (or set the client's request factory/HttpClient
timeout settings) so GemmaService's call to postForObject(gemmaServerUrl, ...)
cannot block indefinitely.
| } catch (Exception e) { | ||
| log.error("Json Serialize Error: ", e); | ||
| log.error("Failed to process summarization request to Gemma server [{}]: ", gemmaServerUrl, e); | ||
| return null; | ||
| } | ||
| return null; |
There was a problem hiding this comment.
null 반환 시 호출자(ApplicantIdMessageConsumer)에서 NPE 위험이 있습니다.
예외 발생 시 null을 반환하는데, ApplicantIdMessageConsumer의 Line 83에서 summarizeContent.response()를 호출합니다. getSummarizeContent가 null을 반환하면 summarizeContent 자체가 null이므로 NPE가 발생합니다.
🐛 ApplicantIdMessageConsumer.java에서의 수정 제안
AIResponse summarizeContent = gemmaService.getSummarizeContent(system, prompt.toString());
- if (summarizeContent.response() == null) return;
+ if (summarizeContent == null || summarizeContent.response() == null) return;🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/gemma/service/GemmaService.java` around lines
60 - 64, The catch in GemmaService.getSummarizeContent currently logs and
returns null (causing NPE in ApplicantIdMessageConsumer when calling
summarizeContent.response()); change the behavior to not return null—either
throw a descriptive unchecked exception (e.g., new IllegalStateException("Failed
to get summarize content from "+ gemmaServerUrl, e)) from the catch block or
return a non-null fallback SummarizeContent object; update the method signature
if needed and ensure ApplicantIdMessageConsumer handles the exception or
fallback (reference: GemmaService.getSummarizeContent and
ApplicantIdMessageConsumer.summarizeContent.response()) so callers never receive
null.
| private static String toWebpKey(String key) { | ||
| if (key == null || key.isEmpty()) { | ||
| return key; | ||
| } | ||
| int lastDot = key.lastIndexOf('.'); | ||
| if (lastDot > 0) { | ||
| return key.substring(0, lastDot) + ".webp"; | ||
| } | ||
| return key + ".webp"; | ||
| } |
There was a problem hiding this comment.
경로 중간에 점(.)이 포함된 키에 대한 방어 로직 확인 필요.
lastIndexOf('.')는 파일명이 아닌 경로 세그먼트의 점도 매칭할 수 있습니다. 예: "v1.0/imagefile" → "v1.webp". 실제로 확장자 없는 키가 경로에 점을 포함할 경우, 경로의 일부가 잘릴 수 있습니다.
경로 구분자(/) 이후의 마지막 점만 고려하면 더 안전합니다:
🛡️ 제안
private static String toWebpKey(String key) {
if (key == null || key.isEmpty()) {
return key;
}
- int lastDot = key.lastIndexOf('.');
+ int lastSlash = key.lastIndexOf('/');
+ int lastDot = key.lastIndexOf('.');
- if (lastDot > 0) {
+ if (lastDot > 0 && lastDot > lastSlash) {
return key.substring(0, lastDot) + ".webp";
}
return key + ".webp";
}🤖 Prompt for AI Agents
In
`@backend/src/main/java/moadong/media/resolver/PreferWebpImageDisplayUrlResolver.java`
around lines 122 - 131, The toWebpKey method incorrectly uses lastIndexOf('.')
across the whole key which can match dots in earlier path segments (e.g.,
"v1.0/image" -> "v1.webp"); change the logic to find the last slash (path
separator) first and only treat a dot as the extension separator if its index is
after that slash (i.e., compute lastSlash = key.lastIndexOf('/') and then only
use lastDot when lastDot > lastSlash); keep the existing behaviors for
null/empty keys and for keys with no extension by appending ".webp" when no
valid dot-after-slash is found.
v 1.1.4
Summary by CodeRabbit
릴리스 노트
새로운 기능
개선사항