Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning
|
| Cohort / File(s) | 변경 내용 |
|---|---|
빌드·앱 설정backend/build.gradle, backend/src/main/java/moadong/MoadongApplication.java, backend/src/main/java/moadong/global/config/AsyncConfig.java |
Maven 저장소 3곳 추가, @EnableAsync 추가, 포맷팅 미세 수정 |
관리자 API / 컨트롤러backend/src/main/java/moadong/club/controller/WordDictionaryAdminController.java, backend/src/main/java/moadong/club/controller/ClubProfileController.java |
단어사전 관리용 REST 컨트롤러 신규 추가(생성/벌크/CSV/조회/수정/삭제/새로고침). 기존 컨트롤러 import 정리(wildcard) |
엔티티·레포지토리backend/src/main/java/moadong/club/entity/WordDictionary.java, backend/src/main/java/moadong/club/repository/WordDictionaryRepository.java |
WordDictionary Mongo 문서 엔티티 추가(인덱스 포함) 및 findByStandardWord 리포지토리 추가 |
DTO / 요청·응답 레코드backend/src/main/java/moadong/club/payload/request/WordDictionaryCreateRequest.java, .../WordDictionaryBulkCreateRequest.java, .../WordDictionaryUpdateRequest.java, backend/src/main/java/moadong/club/payload/response/WordDictionaryResponse.java, .../WordDictionaryListResponse.java, backend/src/main/java/moadong/club/payload/dto/ClubSearchResult.java |
생성/벌크/업데이트 요청 레코드 및 응답 레코드 추가, ClubSearchResult.of(Club) 팩토리 메서드 추가 |
검색 저장소 변경backend/src/main/java/moadong/club/repository/ClubSearchRepository.java |
WordDictionaryService 주입 및 키워드 확장 적용 → 확장된 키워드로 OR 정규식 결합하여 name/category/tags 검색 패턴 구성으로 대체 |
서비스 — 단어사전 관리/확장backend/src/main/java/moadong/club/service/WordDictionaryAdminService.java, .../WordDictionaryService.java |
관리자용 CRUD/벌크/CSV 처리 서비스 및 메모리 캐시 기반 키워드 확장 서비스 추가(초기 CSV 로드, Mongo 동기화, expandKeywords, refresh) |
서비스 — 검색·프로필 변경backend/src/main/java/moadong/club/service/ClubSearchService.java, .../ClubProfileService.java |
키워드 처리 책임을 저장소로 이동, 결과 정렬 로직 재구성(모집상태 우선 → 카테고리 랜덤 우선 → 이름), @Slf4j 추가 |
유틸리티backend/src/main/java/moadong/club/util/CsvParser.java |
CSV를 WordDictionaryCreateRequest 목록으로 파싱하는 유틸리티 추가(헤더/주석/중복 처리) |
테스트 변경backend/src/test/java/moadong/club/service/ClubSearchServiceTest.java, .../ClubProfileServiceTest.java, .../ClubApplyAdminServiceTest.java |
검색 테스트 목 설정 및 준비/정리 로직 보강(키워드 인용 제거, 클럽/폼 생성·정리, 예외 카운트 등) |
Sequence Diagram(s)
sequenceDiagram
autonumber
actor Admin
participant Controller as WordDictionary\r\nAdminController
participant Service as WordDictionary\r\nAdminService
participant Parser as CsvParser
participant Repo as WordDictionary\r\nRepository
participant Cache as WordDictionary\r\nService
Admin->>Controller: POST /api/admin/word-dictionary/csv (file)
Controller->>Service: createWordDictionariesFromCsv(file)
Service->>Parser: parseWordDictionaryCsv(file)
Parser-->>Service: List<WordDictionaryCreateRequest>
loop 각 표준단어
Service->>Repo: save(WordDictionary)
Repo-->>Service: saved entity
end
Service->>Cache: refreshDictionary()
Cache->>Repo: findAll()
Repo-->>Cache: List<WordDictionary>
Cache->>Cache: 메모리 딕셔너리 재구성
Service-->>Controller: List<WordDictionaryResponse>
Controller-->>Admin: 응답
sequenceDiagram
autonumber
actor User
participant Controller as ClubSearch\r\nController
participant Service as ClubSearch\r\nService
participant Repo as ClubSearch\r\nRepository
participant Dict as WordDictionary\r\nService
participant Mongo as MongoDB
User->>Controller: GET /api/clubs/search?keyword=키워드
Controller->>Service: searchClubsByKeyword(키워드)
Service->>Repo: searchClubsByKeyword(키워드)
Repo->>Dict: expandKeywords(키워드)
Dict-->>Repo: List<String> (확장된 키워드)
Repo->>Mongo: find with OR regex (name/category/tags)
Mongo-->>Repo: List<ClubSearchResult>
Repo-->>Service: List<ClubSearchResult>
Service->>Service: 정렬(모집상태→카테고리 랜덤 우선→이름)
Service-->>Controller: ClubSearchResponse
Controller-->>User: 응답
Estimated code review effort
🎯 4 (Complex) | ⏱️ ~45분
Possibly related PRs
- [Release] BE v1.0.8 배포 #766 — ClubSearchService 정렬 로직 변경과 관련(카테고리 우선순위/무작위화 관련 코드 유사).
- [feature] 동아리 검색 결과에서 분과 순서가 고정된 것에서 분과 순서를 랜덤으로 바꾼다 #749 — ClubSearchService 검색/정렬 관련 변경이 있어 충돌 가능성 있음.
- [release] 모아동 BE ver 1.0.3 #503 — ClubSearchRepository의 검색 쿼리 변경(검색 패턴/투영)과 겹침 가능성 있음.
Suggested labels
✨ Feature, 💾 BE
Suggested reviewers
- lepitaaar
- seongwon030
- PororoAndFriends
🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
| Check name | Status | Explanation | Resolution |
|---|---|---|---|
| Docstring Coverage | Docstring coverage is 58.62% which is insufficient. The required threshold is 80.00%. | Write docstrings for the functions missing them to satisfy the coverage threshold. |
✅ Passed checks (4 passed)
| Check name | Status | Explanation |
|---|---|---|
| Title check | ✅ Passed | PR 제목 '[feature] 연관어 검색 구현'은 변경사항의 주요 목표인 연관어(관련 단어) 검색 기능 구현을 명확하게 반영하고 있습니다. |
| Linked Issues check | ✅ Passed | PR은 연관어 검색 기능(MOA-539)의 핵심 구현으로 단어사전 엔티티, 저장소, 서비스, 컨트롤러, CSV 파싱 유틸리티를 추가하고 기존 검색 로직에 단어 확장 기능을 통합했습니다. |
| Out of Scope Changes check | ✅ Passed | 일부 변경사항(import 리팩토링, 로깅 추가, 테스트 개선)은 연관어 검색 핵심 구현 외의 영역이지만, 코드 품질 개선을 위한 합리적인 범위 내 변경입니다. |
| 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
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.
Test Results72 tests 72 ✅ 18s ⏱️ Results for commit 395ec72. ♻️ This comment has been updated with latest results. |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Fix all issues with AI agents
In
`@backend/src/main/java/moadong/club/controller/WordDictionaryAdminController.java`:
- Around line 20-24: The WordDictionaryAdminController class lacks authorization
and OpenAPI security metadata; annotate the class (or each admin method) with
`@PreAuthorize`("hasRole('ADMIN')") to enforce ADMIN-only access and add
`@SecurityRequirement`(name = "bearerAuth") (from
io.swagger.v3.oas.annotations.security) to expose the security scheme in API
docs; ensure you import the annotations and that method security is enabled in
the application (e.g., `@EnableGlobalMethodSecurity`(prePostEnabled = true) on a
configuration class) so the `@PreAuthorize` checks take effect.
In
`@backend/src/main/java/moadong/club/payload/request/WordDictionaryBulkCreateRequest.java`:
- Around line 8-9: The List field wordDictionaries in class
WordDictionaryBulkCreateRequest is missing the `@Valid` annotation so nested
constraints on WordDictionaryCreateRequest elements won't be evaluated; add
`@Valid` to the wordDictionaries field declaration (in addition to the existing
`@NotEmpty`) so the validator will traverse into each WordDictionaryCreateRequest
and enforce its `@NotBlank/`@NotEmpty constraints.
In `@backend/src/main/java/moadong/club/repository/ClubSearchRepository.java`:
- Around line 44-56: The code builds regexPattern from expandedKeywords returned
by wordDictionaryService.expandKeywords(keyword) but doesn't guard against an
empty or all-blank list (which yields an empty regex matching everything); fix
by filtering expandedKeywords to remove null/blank entries (trim each entry),
then check that the filtered list is non-empty before building regexPattern and
adding the Aggregation.match(Criteria.orOperator(...)) stage; if the filtered
list is empty, skip adding the match stage (or return early) so
Criteria.where("name")/.where("category")/.where("recruitmentInformation.tags")
are not created with an empty regex.
In `@backend/src/main/java/moadong/club/service/WordDictionaryService.java`:
- Around line 155-230: The dictionary is currently mutated in place causing
corruption/race issues when refreshDictionary()/loadDictionaryFromMongo() clear
and repopulate while expandKeywords() reads it; fix by making the shared
dictionary a single atomic reference (e.g.,
AtomicReference<Map<String,List<String>>> or a volatile Map) and change
loadDictionaryFromMongo() to build a new local Map<String,List<String>> (using
HashMap/ArrayList), deduplicate and wrap with Collections.unmodifiableMap, then
atomically replace the reference (dictionary.set(...) or dictionary = newMap)
once fully built; update expandKeywords() and refreshDictionary() to read from
that atomic/volatile reference instead of mutating a shared HashMap.
In `@backend/src/main/java/moadong/club/util/CsvParser.java`:
- Around line 39-50: The first CSV line may contain a UTF-8 BOM which breaks the
header check and pollutes the first column; in CsvParser.java remove any BOM
from the read `line` before using `isFirstLine` logic and before using the value
as a key (e.g., strip '\uFEFF' or use a replaceFirst for the BOM). Update the
code around the `line = line.trim();` / `if (isFirstLine)` block so `line` is
normalized (BOM removed) prior to `line.startsWith("표준단어")` and any subsequent
key parsing to prevent corrupted keys.
🧹 Nitpick comments (5)
backend/src/main/java/moadong/MoadongApplication.java (1)
15-15: 중복된@EnableAsync어노테이션
@EnableAsync가 이미AsyncConfig.java(line 12)에 선언되어 있습니다. 동일한 어노테이션을 두 곳에 선언할 필요가 없으며, 비동기 설정이 어디서 활성화되는지 혼란을 줄 수 있습니다.
AsyncConfig에서 executor 빈과 함께@EnableAsync를 관리하는 것이 더 응집력 있는 설계입니다. 여기서는 제거하는 것을 권장합니다.♻️ 제안된 수정
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.retry.annotation.EnableRetry; -import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; `@SpringBootApplication`(exclude = {DataSourceAutoConfiguration.class}) `@RequiredArgsConstructor` `@EnableScheduling` `@EnableRetry` -@EnableAsync public class MoadongApplication {backend/build.gradle (1)
22-29: 스냅샷/마일스톤 저장소를 제거하거나 범위를 제한하세요.현재 프로젝트의 모든 의존성이 안정 버전(예: 2.3.0, 5.8.0)을 사용하고 있어 Spring.io의 스냅샷/마일스톤 저장소와 Central Portal Snapshots는 실제로 사용되지 않습니다. 불필요한 저장소는 빌드 설정을 복잡하게 하고 재현성을 해칠 수 있으므로:
- 추천: 현재 사용되지 않는 이 저장소들을 제거하세요.
- 대안: 향후 스냅샷 의존성이 필요한 경우, 반드시
mavenContent로 범위를 제한하고snapshotsOnly()를 명시하세요.🔧 범위 제한 예시 (필요 시)
repositories { mavenCentral() - 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/' - } + maven { + url 'https://repo.spring.io/snapshot' + mavenContent { + snapshotsOnly() + includeGroupByRegex 'org\\.springframework.*' + } + } }backend/src/main/java/moadong/club/payload/request/WordDictionaryCreateRequest.java (1)
12-13:inputWords리스트 요소에 대한 유효성 검사 추가를 권장합니다.
@NotEmpty는 리스트가 비어있지 않음만 검증합니다. 리스트 내 개별 문자열이 null이거나 공백일 수 있습니다.♻️ 리스트 요소 검증 추가 제안
+import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + public record WordDictionaryCreateRequest( `@NotBlank`(message = "표준단어는 필수입니다") String standardWord, - `@NotEmpty`(message = "입력단어 목록은 비어있을 수 없습니다") + `@NotEmpty`(message = "입력단어 목록은 비어있을 수 없습니다") + List<@NotBlank(message = "입력단어는 공백일 수 없습니다") String> inputWords - List<String> inputWords ) { }backend/src/main/java/moadong/club/payload/request/WordDictionaryUpdateRequest.java (1)
8-9:WordDictionaryCreateRequest와 동일하게 리스트 요소 검증 추가를 권장합니다.일관성을 위해
inputWords리스트 내 개별 문자열에 대한 유효성 검사를 추가하는 것이 좋습니다.♻️ 리스트 요소 검증 추가 제안
+import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import java.util.List; public record WordDictionaryUpdateRequest( - `@NotEmpty`(message = "입력단어 목록은 비어있을 수 없습니다") - List<String> inputWords + `@NotEmpty`(message = "입력단어 목록은 비어있을 수 없습니다") + List<@NotBlank(message = "입력단어는 공백일 수 없습니다") String> inputWords ) { }backend/src/main/java/moadong/club/service/WordDictionaryAdminService.java (1)
62-135: 일괄 생성에서 N+1 조회로 성능 저하 가능
요청 항목마다findByStandardWord를 호출해 대량 입력 시 DB 왕복이 급증합니다. 입력 표준단어 목록을 한 번에 조회(findByStandardWordIn)하거나 유니크 인덱스를 두고 중복키 예외 처리로 간소화하는 방식을 고려해주세요.
| @RestController | ||
| @RequestMapping("/api/admin/word-dictionary") | ||
| @AllArgsConstructor | ||
| @Tag(name = "Word_Dictionary_Admin", description = "단어사전 관리자 API") | ||
| public class WordDictionaryAdminController { |
There was a problem hiding this comment.
관리자 API 인증/인가 누락
관리자 전용 엔드포인트인데 클래스/메서드에 권한 체크가 없습니다. 최소한 ADMIN 권한 요구(@PreAuthorize 등)와 보안 스키마를 추가해 주세요.
🔐 보안 주석 예시
`@RestController`
`@RequestMapping`("/api/admin/word-dictionary")
`@AllArgsConstructor`
`@Tag`(name = "Word_Dictionary_Admin", description = "단어사전 관리자 API")
+@PreAuthorize("hasRole('ADMIN')")
+@SecurityRequirement(name = "BearerAuth")
public class WordDictionaryAdminController {📝 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.
| @RestController | |
| @RequestMapping("/api/admin/word-dictionary") | |
| @AllArgsConstructor | |
| @Tag(name = "Word_Dictionary_Admin", description = "단어사전 관리자 API") | |
| public class WordDictionaryAdminController { | |
| `@RestController` | |
| `@RequestMapping`("/api/admin/word-dictionary") | |
| `@AllArgsConstructor` | |
| `@Tag`(name = "Word_Dictionary_Admin", description = "단어사전 관리자 API") | |
| `@PreAuthorize`("hasRole('ADMIN')") | |
| `@SecurityRequirement`(name = "BearerAuth") | |
| public class WordDictionaryAdminController { |
🤖 Prompt for AI Agents
In
`@backend/src/main/java/moadong/club/controller/WordDictionaryAdminController.java`
around lines 20 - 24, The WordDictionaryAdminController class lacks
authorization and OpenAPI security metadata; annotate the class (or each admin
method) with `@PreAuthorize`("hasRole('ADMIN')") to enforce ADMIN-only access and
add `@SecurityRequirement`(name = "bearerAuth") (from
io.swagger.v3.oas.annotations.security) to expose the security scheme in API
docs; ensure you import the annotations and that method security is enabled in
the application (e.g., `@EnableGlobalMethodSecurity`(prePostEnabled = true) on a
configuration class) so the `@PreAuthorize` checks take effect.
There was a problem hiding this comment.
어드민용 컨트롤러라 되어있어서 권한설정필요해보이네요
There was a problem hiding this comment.
🧩 Analysis chain
@lepitaaar 네, 맞습니다! 관리자 전용 API인데 권한 체크가 없는 것은 보안 취약점입니다. 기존 코드베이스의 보안 설정을 확인해서 적절한 인증/인가 방식을 제안드리겠습니다.
🏁 Script executed:
#!/bin/bash
# Check for existing admin controllers and their security annotations
echo "=== Searching for existing admin controllers ==="
fd -e java -p "Controller.java" backend/src/main/java | xargs grep -l "@RequestMapping.*admin" | head -5
echo -e "\n=== Checking for `@PreAuthorize` usage ==="
rg -n "@PreAuthorize" backend/src/main/java --type java -C 2
echo -e "\n=== Checking for Spring Security configuration ==="
rg -n "@EnableGlobalMethodSecurity\|@EnableMethodSecurity" backend/src/main/java --type java -C 3
echo -e "\n=== Checking for existing SecurityRequirement annotations ==="
rg -n "@SecurityRequirement" backend/src/main/java --type java -C 2
echo -e "\n=== Looking for security configuration classes ==="
fd -e java "SecurityConfig" backend/src/main/java --exec cat {}
Length of output: 26199
기존 코드베이스의 보안 설정을 확인했습니다. 추가로 역할(Role) 기반 권한이 있는지 확인하겠습니다.
🏁 Script executed:
#!/bin/bash
echo "=== Checking for ROLE definitions ==="
rg -n "ROLE_ADMIN|ROLE_USER|hasRole|hasAuthority" backend/src/main/java --type java -C 2
echo -e "\n=== Checking CustomUserDetails implementation ==="
fd "CustomUserDetails.java" backend/src/main/java --exec cat {}
echo -e "\n=== Checking User entity for roles ==="
rg -n "enum.*Role|class.*Role" backend/src/main/java --type java -C 3
Length of output: 1178
@lepitaaar 코드베이스 분석 결과를 공유드립니다:
현재 상황
현재 프로젝트에는 역할(Role) 기반 권한 체계가 구현되어 있지 않고, 모든 보호된 엔드포인트가 인증 여부만 체크하고 있습니다.
권장사항
1단계: 즉시 적용 (기존 패턴 따르기)
ClubImageController와 동일하게 클래스 레벨에 보안 어노테이션을 추가하세요:
`@RestController`
`@RequestMapping`("/api/admin/word-dictionary")
`@AllArgsConstructor`
`@Tag`(name = "Word_Dictionary_Admin", description = "단어사전 관리자 API")
`@PreAuthorize`("isAuthenticated()")
`@SecurityRequirement`(name = "BearerAuth")
public class WordDictionaryAdminController {필요한 import:
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import org.springframework.security.access.prepost.PreAuthorize;2단계: 향후 개선 (역할 기반 권한)
추후 관리자 권한이 필요하다면:
User엔티티에role필드 추가 (예:ROLE_ADMIN,ROLE_USER)@PreAuthorize("hasRole('ADMIN')")로 변경
참고사항
SecurityConfig에서@EnableMethodSecurity(prePostEnabled = true)활성화되어 있어@PreAuthorize동작 가능- 현재
ClubApplyAdminController도 같은 방식(isAuthenticated())으로 보호되고 있음 /api/admin/**경로라도 현재는 인증된 사용자면 누구나 접근 가능함
관리자 전용 기능이라면 역할 기반 권한 체계 도입도 함께 고려해보시는 것을 추천드립니다! 🔐
backend/src/main/java/moadong/club/payload/request/WordDictionaryBulkCreateRequest.java
Show resolved
Hide resolved
backend/src/main/java/moadong/club/repository/ClubSearchRepository.java
Outdated
Show resolved
Hide resolved
| line = line.trim(); | ||
|
|
||
| // 빈 줄 또는 주석 라인 무시 | ||
| if (line.isEmpty() || line.startsWith("#")) { | ||
| continue; | ||
| } | ||
|
|
||
| // 헤더 라인 스킵 | ||
| if (isFirstLine) { | ||
| isFirstLine = false; | ||
| if (line.startsWith("표준단어") || line.startsWith("standard")) { | ||
| continue; |
There was a problem hiding this comment.
UTF-8 BOM 포함 시 헤더 스킵/키 값 오염 가능
엑셀 등에서 저장한 CSV가 BOM을 포함하면 startsWith("표준단어")가 실패하거나 첫 컬럼에 BOM이 남아 표준단어 키가 오염될 수 있습니다. 첫 줄에서 BOM 제거를 권장합니다.
🛠️ 제안 수정
- line = line.trim();
+ line = line.trim();
+ if (lineNumber == 1) {
+ line = line.replace("\uFEFF", "");
+ }🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/club/util/CsvParser.java` around lines 39 - 50,
The first CSV line may contain a UTF-8 BOM which breaks the header check and
pollutes the first column; in CsvParser.java remove any BOM from the read `line`
before using `isFirstLine` logic and before using the value as a key (e.g.,
strip '\uFEFF' or use a replaceFirst for the BOM). Update the code around the
`line = line.trim();` / `if (isFirstLine)` block so `line` is normalized (BOM
removed) prior to `line.startsWith("표준단어")` and any subsequent key parsing to
prevent corrupted keys.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@backend/src/main/java/moadong/club/service/WordDictionaryService.java`:
- Around line 180-189: In WordDictionaryService, guard against
wordDict.getInputWords() returning null by replacing List<String> inputWords =
wordDict.getInputWords() with a null-safe value (e.g., List<String> inputWords =
Optional.ofNullable(wordDict.getInputWords()).orElse(Collections.emptyList()) or
new ArrayList<>()), then build expandedList from that safe inputWords and
proceed with the existing logic (this prevents the new ArrayList<>(inputWords)
NPE and safe-guards the subsequent for-loop over expandedList); also consider
handling a null standardWord similarly before adding it to expandedList.
🧹 Nitpick comments (2)
backend/src/main/java/moadong/club/service/WordDictionaryService.java (2)
107-144: 중복 코드 추출을 권장합니다.이 딕셔너리 구축 로직(표준단어와 입력단어를 확장 리스트로 매핑하고 중복 제거하는 부분)이
loadDictionaryFromMongo()메서드(lines 176-211)에서도 거의 동일하게 반복됩니다. 유지보수성을 위해 공통 헬퍼 메서드로 추출하는 것이 좋겠습니다.♻️ 헬퍼 메서드 추출 예시
private Map<String, List<String>> buildDictionaryFromGroups(Map<String, ? extends Collection<String>> standardToInputs) { Map<String, List<String>> newDictionary = new HashMap<>(); for (Map.Entry<String, ? extends Collection<String>> entry : standardToInputs.entrySet()) { String standardWord = entry.getKey(); Collection<String> inputWords = entry.getValue(); List<String> expandedList = new ArrayList<>(inputWords); if (!expandedList.contains(standardWord)) { expandedList.add(standardWord); } String normalizedStandard = standardWord.toLowerCase(); newDictionary.computeIfAbsent(normalizedStandard, k -> new ArrayList<>()) .addAll(expandedList); for (String inputWord : inputWords) { String normalizedInput = inputWord.toLowerCase(); newDictionary.computeIfAbsent(normalizedInput, k -> new ArrayList<>()) .addAll(expandedList); } } newDictionary.replaceAll((k, v) -> v.stream().distinct().collect(Collectors.toList())); return newDictionary; }
246-254: 내부 리스트 직접 반환으로 인한 캡슐화 위반 가능성line 253에서 딕셔너리에 저장된 리스트를 직접 반환하면, 호출자가 이 리스트를 수정할 경우 내부 상태가 변경될 수 있습니다. 방어적 복사 또는 불변 리스트로 감싸는 것이 안전합니다.
♻️ 불변 리스트 반환
// 단어사전에서 찾기 List<String> expanded = currentDictionary.get(normalizedKeyword); if (expanded != null && !expanded.isEmpty()) { - return expanded; + return List.copyOf(expanded); }또는
loadDictionaryFromMongo()및loadFromCsvAndSaveToMongo()에서 리스트를 저장할 때Collections.unmodifiableList()로 감싸는 방법도 있습니다.
| for (WordDictionary wordDict : allDictionaries) { | ||
| String standardWord = wordDict.getStandardWord(); | ||
| List<String> inputWords = wordDict.getInputWords(); | ||
|
|
||
| // 표준단어와 모든 입력단어를 포함한 확장 리스트 생성 | ||
| List<String> expandedList = new ArrayList<>(inputWords); | ||
| // 표준단어가 inputWords에 없으면 추가 | ||
| if (!expandedList.contains(standardWord)) { | ||
| expandedList.add(standardWord); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "*.java" | xargs grep -l "class WordDictionary" | head -5Repository: Moadong/moadong
Length of output: 354
🏁 Script executed:
fd "WordDictionary.java" --type fRepository: Moadong/moadong
Length of output: 120
🏁 Script executed:
rg "class WordDictionary" --type java -A 20Repository: Moadong/moadong
Length of output: 8028
🏁 Script executed:
cat -n backend/src/main/java/moadong/club/entity/WordDictionary.javaRepository: Moadong/moadong
Length of output: 1014
🏁 Script executed:
rg "inputWords" backend/src/main/java/moadong/club/service/WordDictionaryAdminService.java -C 5Repository: Moadong/moadong
Length of output: 1743
🏁 Script executed:
rg "WordDictionaryCreateRequest" --type java -A 10Repository: Moadong/moadong
Length of output: 12277
🏁 Script executed:
cat -n backend/src/main/java/moadong/club/service/WordDictionaryService.java | sed -n '172,222p'Repository: Moadong/moadong
Length of output: 2636
🏁 Script executed:
rg "findAll\|findByStandardWord" backend/src/main/java/moadong/club/service/WordDictionaryService.java -A 3Repository: Moadong/moadong
Length of output: 41
🏁 Script executed:
rg "WordDictionary.builder\|new WordDictionary" --type java -B 2 -A 5 | head -50Repository: Moadong/moadong
Length of output: 41
inputWords가 null일 경우 NPE 발생 가능성이 있습니다.
wordDict.getInputWords()가 null을 반환하면 line 185에서 new ArrayList<>(inputWords) 호출 시 NullPointerException이 발생합니다. 또한 line 197의 for-loop에서도 null 입력단어를 순회할 때 예외가 발생합니다. WordDictionary 엔티티에 null 검증이 없어 MongoDB에 저장된 데이터가 null을 포함할 수 있으므로 방어 로직이 필수입니다.
🐛 null 방어 로직 추가
for (WordDictionary wordDict : allDictionaries) {
String standardWord = wordDict.getStandardWord();
List<String> inputWords = wordDict.getInputWords();
+ if (inputWords == null) {
+ inputWords = Collections.emptyList();
+ }
+
// 표준단어와 모든 입력단어를 포함한 확장 리스트 생성
List<String> expandedList = new ArrayList<>(inputWords);🤖 Prompt for AI Agents
In `@backend/src/main/java/moadong/club/service/WordDictionaryService.java` around
lines 180 - 189, In WordDictionaryService, guard against
wordDict.getInputWords() returning null by replacing List<String> inputWords =
wordDict.getInputWords() with a null-safe value (e.g., List<String> inputWords =
Optional.ofNullable(wordDict.getInputWords()).orElse(Collections.emptyList()) or
new ArrayList<>()), then build expandedList from that safe inputWords and
proceed with the existing logic (this prevents the new ArrayList<>(inputWords)
NPE and safe-guards the subsequent for-loop over expandedList); also consider
handling a null standardWord similarly before adding it to expandedList.
- ClubRepository 주입으로 Club을 주입
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@backend/src/test/java/moadong/club/service/ClubApplyAdminServiceTest.java`:
- Around line 49-87: The setup reuses existing Club/ClubApplicationForm and
tearDown unconditionally deletes by clubId/clubApplicationFormId which can
remove real/seed data and is brittle (also using findByClubId(...).get(0) picks
a random form). Always create fresh test entities in setUp (e.g., force-create a
new Club via new Club(user.getId()) and a new ClubApplicationForm via
ClubApplicationForm.builder()...), store boolean flags or only set
clubId/clubApplicationFormId when you created them (track e.g., createdClub =
true, createdForm = true), and in tearDown delete only those created entities
(check the flags). Replace findByClubId(...).get(0) usage with explicit creation
or a query that targets the newly created test form to avoid deleting existing
ACTIVE forms.
| if (userRepository.findUserByUserId(UserFixture.collectUserId).isEmpty()) { | ||
| userRepository.save(UserFixture.createUser(passwordEncoder)); | ||
| } | ||
| User user = userRepository.findUserByUserId(UserFixture.collectUserId).get(); | ||
| userDetails = new CustomUserDetails(user); | ||
| Club club = clubRepository.findClubByUserId(user.getId()).get(); | ||
|
|
||
| // 테스트 전에 문서를 한 번만 조회하여 ID를 확보 | ||
| if (clubApplicationFormsRepository.findByClubId(club.getId()).isEmpty()){ | ||
| throw new NoSuchElementException("테스트를 위한 ClubApplicationForm 문서가 DB에 존재하지 않습니다. 먼저 문서를 생성해주세요."); | ||
| // 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(); | ||
| } | ||
|
|
||
| // ClubApplicationForm이 없으면 생성 | ||
| if (clubApplicationFormsRepository.findByClubId(clubId).isEmpty()) { | ||
| ClubApplicationForm clubApplicationForm = ClubApplicationForm.builder() | ||
| .clubId(clubId) | ||
| .build(); | ||
| clubApplicationFormsRepository.save(clubApplicationForm); | ||
| this.clubApplicationFormId = clubApplicationForm.getId(); | ||
| } else { | ||
| ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubId(clubId).get(0); | ||
| this.clubApplicationFormId = clubApplicationForm.getId(); | ||
| } | ||
| } | ||
|
|
||
| @AfterEach | ||
| void tearDown() { | ||
| // 테스트용 ClubApplicationForm 삭제 | ||
| if (clubApplicationFormId != null) { | ||
| clubApplicationFormsRepository.deleteById(clubApplicationFormId); | ||
| } | ||
| // 테스트용 Club 삭제 | ||
| if (clubId != null) { | ||
| clubRepository.deleteById(clubId); | ||
| } |
There was a problem hiding this comment.
기존 데이터까지 삭제될 수 있어 테스트 간 간섭 위험이 큽니다.
setUp에서 기존 Club/ClubApplicationForm을 재사용하는데 tearDown은 무조건 삭제합니다. 이 경우 다른 테스트/시드 데이터가 삭제되어 테스트가 순서 의존 및 flaky해질 수 있습니다. 또한 findByClubId(...).get(0)는 여러 문서가 있는 경우 임의 문서를 선택해 삭제할 수 있습니다. 테스트용 데이터만 생성·삭제하도록 플래그를 두거나, 매번 새 테스트 문서를 생성해 정리하는 방식이 안전합니다. Based on learnings, 동일 clubId에 여러 ACTIVE 폼이 존재할 수 있어 임의 선택/삭제는 더 위험합니다.
🔧 제안 수정안(생성 여부 추적 + 테스트용 문서만 정리)
@@
- private String clubId;
- private String clubApplicationFormId;
+ private String clubId;
+ private String clubApplicationFormId;
+ private boolean createdClub;
+ private boolean createdForm;
@@
- 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(() -> {
+ createdClub = true;
+ return clubRepository.save(new Club(user.getId()));
+ });
+ this.clubId = club.getId();
@@
- if (clubApplicationFormsRepository.findByClubId(clubId).isEmpty()) {
- ClubApplicationForm clubApplicationForm = ClubApplicationForm.builder()
- .clubId(clubId)
- .build();
- clubApplicationFormsRepository.save(clubApplicationForm);
- this.clubApplicationFormId = clubApplicationForm.getId();
- } else {
- ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubId(clubId).get(0);
- this.clubApplicationFormId = clubApplicationForm.getId();
- }
+ ClubApplicationForm clubApplicationForm = ClubApplicationForm.builder()
+ .clubId(clubId)
+ .build();
+ clubApplicationFormsRepository.save(clubApplicationForm);
+ this.clubApplicationFormId = clubApplicationForm.getId();
+ this.createdForm = true;
@@
- if (clubApplicationFormId != null) {
+ if (createdForm && clubApplicationFormId != null) {
clubApplicationFormsRepository.deleteById(clubApplicationFormId);
}
@@
- if (clubId != null) {
+ if (createdClub && clubId != null) {
clubRepository.deleteById(clubId);
}📝 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 (userRepository.findUserByUserId(UserFixture.collectUserId).isEmpty()) { | |
| userRepository.save(UserFixture.createUser(passwordEncoder)); | |
| } | |
| User user = userRepository.findUserByUserId(UserFixture.collectUserId).get(); | |
| userDetails = new CustomUserDetails(user); | |
| Club club = clubRepository.findClubByUserId(user.getId()).get(); | |
| // 테스트 전에 문서를 한 번만 조회하여 ID를 확보 | |
| if (clubApplicationFormsRepository.findByClubId(club.getId()).isEmpty()){ | |
| throw new NoSuchElementException("테스트를 위한 ClubApplicationForm 문서가 DB에 존재하지 않습니다. 먼저 문서를 생성해주세요."); | |
| // 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(); | |
| } | |
| // ClubApplicationForm이 없으면 생성 | |
| if (clubApplicationFormsRepository.findByClubId(clubId).isEmpty()) { | |
| ClubApplicationForm clubApplicationForm = ClubApplicationForm.builder() | |
| .clubId(clubId) | |
| .build(); | |
| clubApplicationFormsRepository.save(clubApplicationForm); | |
| this.clubApplicationFormId = clubApplicationForm.getId(); | |
| } else { | |
| ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubId(clubId).get(0); | |
| this.clubApplicationFormId = clubApplicationForm.getId(); | |
| } | |
| } | |
| @AfterEach | |
| void tearDown() { | |
| // 테스트용 ClubApplicationForm 삭제 | |
| if (clubApplicationFormId != null) { | |
| clubApplicationFormsRepository.deleteById(clubApplicationFormId); | |
| } | |
| // 테스트용 Club 삭제 | |
| if (clubId != null) { | |
| clubRepository.deleteById(clubId); | |
| } | |
| if (userRepository.findUserByUserId(UserFixture.collectUserId).isEmpty()) { | |
| userRepository.save(UserFixture.createUser(passwordEncoder)); | |
| } | |
| User user = userRepository.findUserByUserId(UserFixture.collectUserId).get(); | |
| userDetails = new CustomUserDetails(user); | |
| // Club이 없으면 생성 | |
| Club club = clubRepository.findClubByUserId(user.getId()) | |
| .orElseGet(() -> { | |
| createdClub = true; | |
| return clubRepository.save(new Club(user.getId())); | |
| }); | |
| this.clubId = club.getId(); | |
| // ClubApplicationForm이 없으면 생성 | |
| ClubApplicationForm clubApplicationForm = ClubApplicationForm.builder() | |
| .clubId(clubId) | |
| .build(); | |
| clubApplicationFormsRepository.save(clubApplicationForm); | |
| this.clubApplicationFormId = clubApplicationForm.getId(); | |
| this.createdForm = true; | |
| } | |
| `@AfterEach` | |
| void tearDown() { | |
| // 테스트용 ClubApplicationForm 삭제 | |
| if (createdForm && clubApplicationFormId != null) { | |
| clubApplicationFormsRepository.deleteById(clubApplicationFormId); | |
| } | |
| // 테스트용 Club 삭제 | |
| if (createdClub && clubId != null) { | |
| clubRepository.deleteById(clubId); | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@backend/src/test/java/moadong/club/service/ClubApplyAdminServiceTest.java`
around lines 49 - 87, The setup reuses existing Club/ClubApplicationForm and
tearDown unconditionally deletes by clubId/clubApplicationFormId which can
remove real/seed data and is brittle (also using findByClubId(...).get(0) picks
a random form). Always create fresh test entities in setUp (e.g., force-create a
new Club via new Club(user.getId()) and a new ClubApplicationForm via
ClubApplicationForm.builder()...), store boolean flags or only set
clubId/clubApplicationFormId when you created them (track e.g., createdClub =
true, createdForm = true), and in tearDown delete only those created entities
(check the flags). Replace findByClubId(...).get(0) usage with explicit creation
or a query that targets the newly created test form to avoid deleting existing
ACTIVE forms.
lepitaaar
left a comment
There was a problem hiding this comment.
수고하셨습니다. 연관검색어 만든거 좋네요. 다만연관검색어 컨트롤러는 프로덕션에서는 제외하거나 해야할꺼같습니다!
| 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.
따로 의존성이 추가된거같지않은데 레포지토리가 왜추가됐을까요?
There was a problem hiding this comment.
벡터모델 의존성 추가하면서 생겼는데 있으면 좋데요
| @RestController | ||
| @RequestMapping("/api/admin/word-dictionary") | ||
| @AllArgsConstructor | ||
| @Tag(name = "Word_Dictionary_Admin", description = "단어사전 관리자 API") | ||
| public class WordDictionaryAdminController { |
There was a problem hiding this comment.
어드민용 컨트롤러라 되어있어서 권한설정필요해보이네요
| @RequiredArgsConstructor | ||
| public class WordDictionaryService { | ||
|
|
||
| private static final String CSV_FILE_PATH = "club_keyword_dictionary_long_expanded_cleaned.csv"; |
There was a problem hiding this comment.
추가해야할 파일이 늘어난거같아서 나중에 설명해주시면 감사하겠습니다
#️⃣연관된 이슈
#1077
📝작업 내용
1. 연관 검색 기능 개선: 벡터 검색 → 단어 사전 기반 검색
2. 단어 사전 관리 기능 추가
WordDictionary엔티티 및 Repository 추가WordDictionaryService: 단어 사전 로드 및 키워드 확장 서비스WordDictionaryAdminService: 단어 사전 CRUD 관리 서비스WordDictionaryAdminController: 관리자용 단어 사전 관리 APICsvParser: CSV 파일 파싱 유틸리티 추가3. 검색 기능 개선
ClubSearchRepository: 단어 사전 기반 키워드 확장 검색 로직 구현ClubSearchResult확장)4. Redis 및 SSE 기능 추가
RedisConfig)ApplicantsStatusShareSse)5. 기타 개선사항
ClubAward엔티티: year와 semesterTerm 분리ClubApplyAdminService: 코드 리팩토링 및 간소화ClubProfileService: 벡터 데이터 저장 기능 추가AsyncConfig: 설정 개선변경 이유
테스트
테스트 실행 여부
테스트 결과
ClubApplyAdminServiceTest: 지원폼 및 동아리 정보 수정 테스트 개선ClubProfileServiceTest: 테스트 코드 수정ClubSearchServiceTest: 단어 사전 기반 검색 테스트 추가주요 테스트 케이스
Breaking Changes
변경 사항
변경된 파일 목록
신규 파일
src/main/java/moadong/club/entity/WordDictionary.javasrc/main/java/moadong/club/repository/WordDictionaryRepository.javasrc/main/java/moadong/club/service/WordDictionaryService.javasrc/main/java/moadong/club/service/WordDictionaryAdminService.javasrc/main/java/moadong/club/controller/WordDictionaryAdminController.javasrc/main/java/moadong/club/util/CsvParser.javasrc/main/java/moadong/global/config/RedisConfig.javasrc/main/java/moadong/sse/dto/ApplicantSseDto.javasrc/main/java/moadong/sse/enums/ApplicantEventType.javasrc/main/java/moadong/sse/service/ApplicantsStatusShareSse.javasrc/main/java/moadong/club/payload/request/WordDictionaryCreateRequest.javasrc/main/java/moadong/club/payload/request/WordDictionaryBulkCreateRequest.javasrc/main/java/moadong/club/payload/request/WordDictionaryUpdateRequest.javasrc/main/java/moadong/club/payload/response/WordDictionaryResponse.javasrc/main/java/moadong/club/payload/response/WordDictionaryListResponse.java수정된 파일
build.gradle: Redis 의존성 추가src/main/java/moadong/MoadongApplication.java: 설정 변경src/main/java/moadong/club/controller/ClubApplyAdminController.javasrc/main/java/moadong/club/controller/ClubProfileController.javasrc/main/java/moadong/club/entity/ClubAward.java: year와 semesterTerm 분리src/main/java/moadong/club/payload/dto/ClubAwardDto.javasrc/main/java/moadong/club/payload/dto/ClubSearchResult.java: 관련 키워드 정보 추가src/main/java/moadong/club/repository/ClubSearchRepository.java: 단어 사전 기반 검색 로직 구현src/main/java/moadong/club/service/ClubApplyAdminService.java: 리팩토링 및 간소화src/main/java/moadong/club/service/ClubProfileService.java: 벡터 데이터 저장 기능 추가src/main/java/moadong/club/service/ClubSearchService.java: 단어 사전 확장 검색 적용src/main/java/moadong/global/config/AsyncConfig.java: 설정 개선src/test/java/moadong/club/service/ClubApplyAdminServiceTest.java: 테스트 개선src/test/java/moadong/club/service/ClubProfileServiceTest.java: 테스트 수정src/test/java/moadong/club/service/ClubSearchServiceTest.java: 테스트 추가통계
체크리스트
추가 정보
주요 커밋 내역
feat: 벡터 검색 적용- 초기 벡터 검색 기능 추가feat: 클럽 약력 수정 시에 벡터 데이터 저장되는 기능 추가feat: 벡터 기반 유사어 검색 로직 제거 후 단어 사전 방식 로직 추가- 핵심 변경fix: 키값 입력 시에도 연관어와 같은 검색 결과가 나오도록 수정chore: 단어 사전 빈값 방어 로직 추가fix: 단어 사전 map 동시성 문제 수정 및 테스트 코드 파라미터값이 잘못되어있던 문제 수정fix: ClubProfileService 테스트 코드가 올바르게 동작하지 않던 문제 수정fix: 지원폼 및 동아리 정보 수정 테스트에서 엔티티를 생성하고 테스트 끝나면 삭제하도록 개선중점적으로 리뷰받고 싶은 부분(선택)
논의하고 싶은 부분(선택)
🫡 참고사항
Summary by CodeRabbit
✏️ Tip: You can customize this high-level summary in your review settings.