Conversation
WalkthroughElasticsearch 기반 클럽 색인·검색 및 동기화 기능을 추가했습니다. ES 문서·리포지토리·서비스와 인덱스 설정·동의어·불용어 자원을 도입하고, SearchService는 키워드 있을 땐 ES, 없을 땐 MySQL로 분기합니다. ClubService에서 클럽 생성/수정/가입/탈퇴 시 비동기 재시도 방식으로 ES 동기화를 호출합니다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor U as 사용자
participant CS as ClubService
participant ESvc as ClubElasticsearchService
participant ESRepo as ClubElasticsearchRepository
note over CS,ESvc: 클럽 생성/수정/가입/탈퇴 → 비동기 동기화 (@Async + @Retryable)
U->>CS: create/update/join/leave Club
CS->>ESvc: indexClub/updateClub(club)
rect rgba(230,245,255,0.6)
note right of ESvc: 비동기 + 재시도
ESvc->>ESRepo: save/deleteById(document)
ESRepo-->>ESvc: ack
end
ESvc-->>CS: 완료(비동기)
sequenceDiagram
autonumber
actor U as 사용자
participant SS as SearchService
participant ESRepo as ClubElasticsearchRepository
participant JPA as ClubRepository
participant UCR as UserClubRepository
U->>SS: searchClubs(keyword?, city?, district?, interestId?, sort, page)
alt 키워드 존재
SS->>ESRepo: ES 검색 (키워드 + 선택 필터, 페이징)
ESRepo-->>SS: List<ClubDocument>
else 키워드 없음
SS->>JPA: DB 검색 (선택 필터, 페이징)
JPA-->>SS: List<Club>
end
SS->>UCR: findByClubIdsByUserId(userId)
UCR-->>SS: List<Long>
SS-->>U: DTO 리스트(가입 여부 포함)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests
Tip 👮 Agentic pre-merge checks are now available in preview!Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.
Please see the documentation for more information. Example: reviews:
pre_merge_checks:
custom_checks:
- name: "Undocumented Breaking Changes"
mode: "warning"
instructions: |
Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).Please share your feedback with us on this Discord post. 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 |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (7)
src/main/java/com/example/onlyone/domain/club/document/ClubDocument.java (1)
63-80: 팩토리 메소드에서 NPE 가능성Line 64에서
club.getInterest().getCategory()를 호출할 때 interest나 category가 null일 경우 NullPointerException이 발생할 수 있습니다.다음과 같이 null 체크를 추가하는 것을 고려해보세요:
public static ClubDocument from(Club club) { - Category category = club.getInterest().getCategory(); + Interest interest = club.getInterest(); + Category category = (interest != null) ? interest.getCategory() : null; return ClubDocument.builder() .clubId(club.getClubId()) .name(club.getName()) .description(club.getDescription()) .city(club.getCity()) .district(club.getDistrict()) .clubImage(club.getClubImage()) .memberCount(club.getMemberCount()) - .interestId(club.getInterest().getInterestId()) - .interestCategory(category.name()) - .interestKoreanName(category.getKoreanName()) + .interestId(interest != null ? interest.getInterestId() : null) + .interestCategory(category != null ? category.name() : null) + .interestKoreanName(category != null ? category.getKoreanName() : null) .createdAt(club.getCreatedAt()) .searchText(club.getName() + " " + club.getDescription()) .build(); }src/main/resources/elasticsearch/club-create-settings.json (1)
45-51: 분석기 필터 순서 확인club_analyzer에서 club_synonyms가 club_stopwords보다 먼저 적용되고 있습니다. 일반적으로는 stopwords 제거 후 synonyms를 적용하는 것이 권장됩니다.
필터 순서를 다음과 같이 변경하는 것을 고려해보세요:
"club_analyzer": { "type": "custom", "tokenizer": "nori_tokenizer", "filter": [ "nori_part_of_speech", "nori_readingform", "lowercase", - "club_synonyms", - "club_stopwords" + "club_stopwords", + "club_synonyms" ] }src/main/java/com/example/onlyone/domain/search/service/ClubElasticsearchService.java (1)
28-29: 불필요한 의존성 주입ClubRepository가 주입되어 있지만 현재 코드에서는 사용되지 않고 있습니다. 사용하지 않는다면 제거하는 것이 좋습니다.
@Service @RequiredArgsConstructor @Slf4j public class ClubElasticsearchService { private final ClubElasticsearchRepository clubElasticsearchRepository; - private final ClubRepository clubRepository;src/main/java/com/example/onlyone/domain/search/service/SearchService.java (1)
268-279: 정렬 옵션을 Enum으로 관리하여 확장성 개선현재 정렬 기준이 LATEST와 DEFAULT(memberCount) 두 가지만 있는데, 향후 다른 정렬 기준(예: 관련도, 거리 등)이 추가될 가능성을 고려하여 더 명시적인 구조로 개선하는 것이 좋습니다.
private Pageable createPageable(SearchFilterDto filter) { Sort sort; if (filter.getSortBy() == SearchFilterDto.SortType.LATEST) { sort = Sort.by(Sort.Order.desc("createdAt")); - } else { + } else if (filter.getSortBy() == SearchFilterDto.SortType.POPULAR || filter.getSortBy() == null) { sort = Sort.by(Sort.Order.desc("memberCount")); + } else { + // 기본값 + sort = Sort.by(Sort.Order.desc("memberCount")); } return PageRequest.of(filter.getPage(), 20, sort); }src/main/java/com/example/onlyone/domain/club/repository/ClubElasticsearchRepositoryImpl.java (2)
1-149: 코드 중복 제거를 위한 리팩토링 제안4개의 메서드에서 multi_match 쿼리 부분이 반복되고 있습니다. 공통 쿼리 빌더 메서드를 추출하여 코드 중복을 줄이는 것이 좋습니다.
private MultiMatchQuery createKeywordQuery(String keyword) { return MultiMatchQuery.of(mm -> mm .query(keyword) .fields("name^2.0", "description") .type(TextQueryType.MostFields) .minimumShouldMatch("70%") ); } private Query buildSearchQuery(String keyword, String city, String district, Long interestId, Pageable pageable) { BoolQuery.Builder boolBuilder = new BoolQuery.Builder() .must(createKeywordQuery(keyword)._toQuery()); if (city != null && district != null) { boolBuilder.must(TermQuery.of(t -> t.field("city.keyword").value(city))._toQuery()) .must(TermQuery.of(t -> t.field("district.keyword").value(district))._toQuery()); } if (interestId != null) { boolBuilder.must(TermQuery.of(t -> t.field("interestId").value(interestId))._toQuery()); } return NativeQuery.builder() .withQuery(boolBuilder.build()._toQuery()) .withPageable(pageable) .withTrackTotalHits(false) .build(); }
59-69: city/district에 대해 .keyword 서브필드 제거 권장elasticsearch 매핑(src/main/resources/elasticsearch/club-mapping.json)에서 city와 district가 "type": "keyword"로 정의되어 있으므로 .keyword 서브필드는 불필요합니다. 변경: .field("city.keyword") → .field("city"), .field("district.keyword") → .field("district").
파일: src/main/java/com/example/onlyone/domain/club/repository/ClubElasticsearchRepositoryImpl.java (59-69, 122-139)
.gitignore (1)
67-67: 보안상 적절한 무시 규칙 추가로컬 MySQL 자격증명(.my.cnf) 커밋 방지에 유용합니다. 함께
.mylogin.cnf도 무시하면 실수 여지를 더 줄일 수 있습니다.+.mylogin.cnf
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
spy.logis excluded by!**/*.log
📒 Files selected for processing (18)
.gitignore(1 hunks)build.gradle(1 hunks)src/main/java/com/example/onlyone/OnlyoneApplication.java(2 hunks)src/main/java/com/example/onlyone/domain/club/document/ClubDocument.java(1 hunks)src/main/java/com/example/onlyone/domain/club/repository/ClubElasticsearchRepository.java(1 hunks)src/main/java/com/example/onlyone/domain/club/repository/ClubElasticsearchRepositoryCustom.java(1 hunks)src/main/java/com/example/onlyone/domain/club/repository/ClubElasticsearchRepositoryImpl.java(1 hunks)src/main/java/com/example/onlyone/domain/club/repository/ClubRepositoryImpl.java(3 hunks)src/main/java/com/example/onlyone/domain/club/repository/UserClubRepository.java(1 hunks)src/main/java/com/example/onlyone/domain/club/service/ClubService.java(6 hunks)src/main/java/com/example/onlyone/domain/search/service/ClubElasticsearchService.java(1 hunks)src/main/java/com/example/onlyone/domain/search/service/SearchService.java(7 hunks)src/main/java/com/example/onlyone/global/exception/ErrorCode.java(1 hunks)src/main/resources/elasticsearch/club-create-settings.json(1 hunks)src/main/resources/elasticsearch/club-mapping.json(1 hunks)src/main/resources/elasticsearch/club-settings.json(1 hunks)src/main/resources/elasticsearch/club-stopwords.txt(1 hunks)src/main/resources/elasticsearch/club-synonyms.txt(1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-06T07:54:25.923Z
Learnt from: ghkddlscks19
PR: GoormOnlyOne/OnlyOne-Back#86
File: src/main/java/com/example/onlyone/domain/club/repository/ClubRepository.java:14-18
Timestamp: 2025-08-06T07:54:25.923Z
Learning: In the OnlyOne-Back project, the ClubRepository search methods use List<Object[]> return type instead of Page<Object[]> even with Pageable parameters. This is intentionally designed for infinite scrolling implementation on the frontend, where only the actual data is needed without pagination metadata.
Applied to files:
src/main/java/com/example/onlyone/domain/club/repository/ClubElasticsearchRepositoryCustom.javasrc/main/java/com/example/onlyone/domain/club/repository/ClubElasticsearchRepository.javasrc/main/java/com/example/onlyone/domain/club/repository/ClubRepositoryImpl.javasrc/main/java/com/example/onlyone/domain/search/service/ClubElasticsearchService.javasrc/main/java/com/example/onlyone/domain/club/repository/ClubElasticsearchRepositoryImpl.javasrc/main/java/com/example/onlyone/domain/search/service/SearchService.java
🧬 Code graph analysis (1)
src/main/java/com/example/onlyone/domain/club/document/ClubDocument.java (1)
src/main/java/com/example/onlyone/domain/club/dto/request/ClubRequestDto.java (1)
AllArgsConstructor(17-55)
🔇 Additional comments (25)
src/main/resources/elasticsearch/club-synonyms.txt (1)
1-37: 한국어 검색을 위한 동의어 설정클럽 검색 기능을 위한 포괄적인 한국어-영어 동의어 매핑을 제공합니다. 스포츠, 취미, 학습, IT, 금융 등 다양한 카테고리를 포함하여 사용자 친화적인 검색 경험을 제공할 수 있습니다.
src/main/resources/elasticsearch/club-stopwords.txt (1)
1-595: 한국어 불용어 목록 설정검색 품질 향상을 위한 한국어 불용어 목록이 잘 구성되어 있습니다. 일반적인 조사, 접속사, 감탄사 등을 포함하여 의미 있는 검색 결과를 제공할 수 있도록 설정되었습니다.
src/main/java/com/example/onlyone/domain/club/repository/UserClubRepository.java (1)
21-22: 사용자별 클럽 ID 조회 메서드 추가SearchService에서 사용자 소속 클럽 필터링을 위한 메서드가 적절하게 구현되었습니다. JPQL을 사용하여 직접 클럽 ID 리스트를 반환하므로 성능상 효율적입니다.
src/main/resources/elasticsearch/club-mapping.json (1)
1-53: Elasticsearch 클럽 인덱스 매핑 정의클럽 도메인의 모든 필드를 적절하게 매핑했습니다. 특히 주목할 점들:
nori분석기와club_analyzer검색 분석기를 사용하여 한국어 검색 지원name필드에 keyword 서브필드 제공으로 정확 검색과 텍스트 검색 모두 지원clubImage는index: false로 설정하여 검색 대상에서 제외createdAt에 다양한 날짜 형식 지원src/main/java/com/example/onlyone/domain/club/service/ClubService.java (7)
21-21: ClubElasticsearchService 의존성 추가Elasticsearch 동기화를 위한 서비스 의존성을 추가했습니다.
46-46: ClubElasticsearchService 필드 선언Elasticsearch 동기화 작업을 위한 서비스 필드가 적절하게 선언되었습니다.
76-78: 클럽 생성 시 Elasticsearch 인덱싱클럽 생성 후 비동기적으로 Elasticsearch에 인덱싱하는 로직이 추가되었습니다. 트랜잭션 커밋 후에 ES 작업이 수행되도록 적절하게 배치되었습니다.
104-106: 클럽 수정 시 Elasticsearch 업데이트클럽 정보 수정 후 Elasticsearch 문서를 업데이트하는 로직이 추가되었습니다.
153-155: 클럽 가입 시 멤버수 동기화클럽 가입 시 memberCount 변경사항을 Elasticsearch에 반영하는 로직이 추가되었습니다.
173-175: 클럽 탈퇴 시 멤버수 동기화클럽 탈퇴 시 memberCount 변경사항을 Elasticsearch에 반영하는 로직이 추가되었습니다.
76-156: Elasticsearch 동기화 시점 검토 — 수동 확인 필요자동 검색에서 ClubElasticsearchService 클래스를 찾지 못했습니다. 아래 항목을 수동으로 확인하세요:
- ClubElasticsearchService.indexClub / updateClub 등이 비동기인지 확인: 메서드에 @async가 붙었거나 CompletableFuture/Future 등을 반환하는지, 그리고 @EnableAsync 등 async 설정이 활성화되어 있는지.
- ES 작업 실패가 DB 트랜잭션에 영향(롤백 등)을 주지 않도록 예외 처리가 되어 있는지 확인: 비동기 내부에서 try-catch로 처리하거나 CompletableFuture.exceptionally/handle 등으로 실패를 흡수·로깅 하는지.
- 재시도/백오프(@retryable, Resilience4j 등) 또는 실패 시 큐 적재·알림 같은 복구 전략이 있는지 확인.
- 만약 현재 동기 호출이면 ES 호출을 트랜잭션 외부로 이동(예: TransactionSynchronizationManager.registerSynchronization AFTER_COMMIT 또는 ApplicationEvent 발행)하거나 메서드를 비동기화할 것.
ClubElasticsearchService 소스(파일 경로 또는 코드)를 올려주면 재검증 후 구체적 수정 지점을 지적하겠습니다.
build.gradle (1)
73-77: Elasticsearch 의존성 추가 — Spring Boot 3.5.4 호환성 확인 완료검증 결과: spring-boot-starter-data-elasticsearch 3.5.4(관리되는 Spring Data Elasticsearch 5.5.2)는 Elasticsearch 8.18.1 및 elasticsearch-java 8.18 계열과 테스트/호환됩니다. 따라서 현재 build.gradle에 추가된 의존성은 호환성 문제가 없습니다. spring-retry와 spring-aspects 추가도 적절합니다.
src/main/java/com/example/onlyone/domain/club/repository/ClubElasticsearchRepository.java (1)
11-12: Repository 인터페이스 구조가 올바릅니다.ElasticsearchRepository와 ClubElasticsearchRepositoryCustom을 모두 확장하여 표준 ES 작업과 커스텀 쿼리를 함께 제공하는 구조입니다. 빈 인터페이스이지만 의도적인 설계로 보입니다.
src/main/resources/elasticsearch/club-settings.json (3)
15-15: Nori 형태소 분석기 stoptags 설정이 적절합니다.한국어 검색에 불필요한 품사들(조사, 어미, 기호 등)을 제거하는 stoptags 설정이 잘 되어 있습니다. 검색 품질 향상에 도움이 될 것입니다.
22-22: 레포에 파일 존재 — 배포 시 Elasticsearch에서 접근 가능한지 확인 필요레포 경로에 파일이 존재합니다: src/main/resources/elasticsearch/club-stopwords.txt, src/main/resources/elasticsearch/club-synonyms.txt. 그러나 settings의 "stopwords_path": "analysis/club-stopwords.txt"는 Elasticsearch 노드의 config/analysis/를 참조하므로, 배포(또는 Docker 이미지/프로비저닝)에서 해당 파일들이 각 노드의 config/analysis 디렉토리에 복사되거나 경로 대신 인라인 stopwords로 대체되는지 확인하세요.
2-4: 샤드 수(5)·복제본(1) 설정 — 클러스터 상태 확인 필요파일: src/main/resources/elasticsearch/club-settings.json (lines 2–4) — 현재 number_of_shards: 5, number_of_replicas: 1. 로컬에서 실행한 검증 스크립트가 출력 없이 실패(Elasticsearch 연결 불가)해 클러스터 상태를 확인하지 못했습니다.
다음 명령을 실행해 출력(또는 발생한 오류)을 붙여 주세요:
curl -sS http://localhost:9200/_cat/nodes?v curl -sS http://localhost:9200/_cluster/health?pretty curl -sS http://localhost:9200/_cat/indices?v curl -sS http://localhost:9200/_cat/shards?h=index,shard,prirep,state,node권장: 샤드 수는 데이터 크기와 노드 수 기반으로 조정 필요; 복제본 1은 보통 적절하나 초기 환경이면 0 고려.
src/main/java/com/example/onlyone/domain/club/document/ClubDocument.java (1)
16-18: Elasticsearch 설정 파일 참조가 정확합니다.@document, @setting, @mapping 애노테이션을 사용하여 ES 인덱스 설정을 외부 파일로 분리한 것은 좋은 접근입니다. 설정 관리가 용이하고 유지보수성이 향상됩니다.
src/main/java/com/example/onlyone/global/exception/ErrorCode.java (1)
137-145: Elasticsearch 관련 에러 코드 추가가 적절합니다.새로운 Elasticsearch 기능에 대한 명확한 에러 처리를 위한 에러 코드들이 잘 정의되어 있습니다. HTTP 상태 코드와 메시지도 적절합니다.
src/main/java/com/example/onlyone/domain/club/repository/ClubRepositoryImpl.java (2)
146-174: 안전한 null 체크 로직이 개선되었습니다.BooleanBuilder를 사용하여 null 값과 빈 문자열에 대한 안전한 처리가 추가되었습니다. 이는 검색 조건이 선택적일 때 발생할 수 있는 잘못된 쿼리를 방지합니다.
225-235: 일관된 null 안전 패턴이 적용되었습니다.searchByInterest와 searchByLocation 메소드에도 동일한 BooleanBuilder 패턴이 적용되어 코드 일관성이 향상되었습니다.
Also applies to: 250-265
src/main/java/com/example/onlyone/domain/club/repository/ClubElasticsearchRepositoryCustom.java (1)
8-16: ES 검색 메소드 시그니처가 명확합니다.키워드 기반 검색에 위치와 관심사 필터를 조합할 수 있는 4가지 메소드가 잘 정의되어 있습니다. 메소드명이 직관적이고 매개변수도 명확합니다.
src/main/java/com/example/onlyone/domain/search/service/ClubElasticsearchService.java (1)
32-34: 비동기 및 재시도 설정이 적절합니다.ES 인덱싱 작업에 @async와 @retryable을 적용한 것은 좋은 선택입니다. ES 작업은 네트워크 의존적이므로 재시도 메커니즘이 유용합니다.
src/main/java/com/example/onlyone/domain/club/repository/ClubElasticsearchRepositoryImpl.java (1)
37-39: withTrackTotalHits(false) 재검토 — 페이징 전략(프론트엔드 연동) 확인 필요withTrackTotalHits(false)는 전체 건수 반환을 차단하므로 page-number 기반 UI(총페이지 표시 등)를 지원하지 못함.
- 권장: search_after + PIT(커서 기반)로 전환 — 정렬에 유니크 tiebreaker(_id) 포함, PIT keep_alive 관리.
- 대안: 최초 요청에만 track_total_hits:true 호출해 총건수 제공(비용 제한).
- UX 대안: '더 보기/무한 스크롤' 채택하고 서버는 마지막 hit의 sort 값(커서 토큰)을 반환해 다음 페이지를 조회하도록 설계.
- 피해야 할 것: deep paging용 큰 from/size 및 scroll API(실시간 사용자 페이징엔 부적절).
확인 필요: 프론트엔드가 현재 총건수(총페이지) 기반 UI인지, 아니면 '더 보기/무한 스크롤'로 다음 페이지 존재 여부를 판단하는지 명시해 주세요. 필요하면 search_after+PIT API 예제나 클라이언트-서버 커서 설계 샘플 제공함.
위치: src/main/java/com/example/onlyone/domain/club/repository/ClubElasticsearchRepositoryImpl.java — lines 37-39, 72-74, 101-103, 142-144 (withTrackTotalHits(false) 호출부).
src/main/java/com/example/onlyone/domain/search/service/SearchService.java (1)
242-256: ES 인덱스 동기화 확인 필요 — indexClub 존재하지만 가입/탈퇴 시 재인덱스 확인 필요src/main/java/com/example/onlyone/domain/search/service/ClubElasticsearchService.java에 indexClub(Club) 메서드가 있고, src/main/java/com/example/onlyone/domain/club/service/ClubService.java에서 호출됩니다(검색 결과: ClubElasticsearchService.java:34, ClubService.java:78). 멤버 가입/탈퇴(또는 memberCount 변경) 흐름에서 clubElasticsearchService.indexClub(...)가 호출되어 ES의 memberCount 등 동적 필드가 즉시 갱신되는지 검증하세요(또는 해당 흐름에 재인덱스/동기화 로직·테스트/로깅을 추가하세요).
src/main/java/com/example/onlyone/OnlyoneApplication.java (1)
9-9: EnableRetry import 추가 적합Retry 기능 활성화를 위한 필수 import입니다. 위 권고(프록시 강제/설계 분리)만 반영되면 문제 없습니다.
src/main/java/com/example/onlyone/domain/club/document/ClubDocument.java
Show resolved
Hide resolved
src/main/java/com/example/onlyone/domain/club/repository/ClubElasticsearchRepositoryImpl.java
Show resolved
Hide resolved
src/main/java/com/example/onlyone/domain/search/service/ClubElasticsearchService.java
Show resolved
Hide resolved
src/main/java/com/example/onlyone/domain/search/service/SearchService.java
Outdated
Show resolved
Hide resolved
src/main/java/com/example/onlyone/domain/search/service/SearchService.java
Show resolved
Hide resolved
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/main/java/com/example/onlyone/domain/search/service/SearchService.java (1)
115-118: 키워드 유효성 검증 조건 버그: 키워드 없는 검색이 차단될 수 있음현재는 키워드가 없을 때도
isKeywordValid()실패로 예외가 발생할 수 있습니다. “키워드가 있을 때만” 검증하도록 조건을 보완하세요.- // 키워드 유효성 검증 - if (!filter.isKeywordValid()) { + // 키워드 유효성 검증 (키워드가 있을 때만 검사) + if (filter.hasKeyword() && !filter.isKeywordValid()) { throw new CustomException(ErrorCode.SEARCH_KEYWORD_TOO_SHORT); }
♻️ Duplicate comments (1)
src/main/java/com/example/onlyone/domain/search/service/SearchService.java (1)
185-208: ES 장애 대비: 예외 처리/폴백 부재ES 연결/쿼리 오류 시 현재 서비스가 그대로 실패합니다. 최소한 서비스 가용성을 위해 예외를 캐치하고 적절한 예외 변환(또는 폴백)을 추가하세요. 이전 코멘트와 동일 이슈입니다.
private List<ClubDocument> searchWithElasticsearch(SearchFilterDto filter) { String keyword = filter.getKeyword().trim(); Pageable pageable = createPageable(filter); - - // 조건에 따라 적절한 ES 검색 메서드 호출 - if (filter.hasLocation() && filter.getInterestId() != null) { + try { + // 조건에 따라 적절한 ES 검색 메서드 호출 + if (filter.hasLocation() && filter.getInterestId() != null) { return clubElasticsearchRepository.findByKeywordAndLocationAndInterest( keyword, filter.getCity().trim(), filter.getDistrict().trim(), filter.getInterestId(), pageable); - } else if (filter.hasLocation()) { + } else if (filter.hasLocation()) { return clubElasticsearchRepository.findByKeywordAndLocation( keyword, filter.getCity().trim(), filter.getDistrict().trim(), pageable); - } else if (filter.getInterestId() != null) { + } else if (filter.getInterestId() != null) { return clubElasticsearchRepository.findByKeywordAndInterest( keyword, filter.getInterestId(), pageable); - } else { + } else { return clubElasticsearchRepository.findByKeyword(keyword, pageable); - } + } + } catch (Exception e) { + log.error("Elasticsearch 검색 실패", e); + throw new CustomException(ErrorCode.SEARCH_SERVICE_UNAVAILABLE); + } }참고: 서비스 정책에 따라 가능하면 MySQL 폴백(키워드 제외 필터 검색)으로 강등하는 옵션도 고려해 주세요.
🧹 Nitpick comments (4)
src/main/java/com/example/onlyone/domain/search/service/SearchService.java (4)
223-225: 입력 정규화 불일치: MySQL 지역 검색에서 trim 누락ES 경로에서는
trim()을 적용하지만, MySQL 지역 전용 분기에서는 누락되어 검색 결과 불일치가 생길 수 있습니다.- return clubRepository.searchByLocation(filter.getCity(), filter.getDistrict(), pageRequest); + return clubRepository.searchByLocation(filter.getCity().trim(), filter.getDistrict().trim(), pageRequest);
210-213: 페이지 크기 매직 넘버 제거 제안여러 곳에서
20이 하드코딩되어 있습니다. 상수로 통일하면 유지보수가 수월합니다.- PageRequest pageRequest = PageRequest.of(filter.getPage(), 20); + PageRequest pageRequest = PageRequest.of(filter.getPage(), DEFAULT_PAGE_SIZE);클래스 상단에 다음 상수를 추가하세요:
private static final int DEFAULT_PAGE_SIZE = 20;
109-133: 하이브리드 분기 자체는 합리적이나, 장애/성능 가드 보완 권장
- ES 경로 실패 시 폴백 또는 친절한 오류 응답이 필요합니다(위 예외 처리 코멘트 참고).
joinedClubIds는 결과 변환에서 Set으로 변환해 성능 개선(위 코멘트 적용 시 자동 해결).
83-93: 사소한 제안: 중복 코드 축소
searchClubByInterest/searchClubByLocation에서 현재 유저 조회와 가입 ID 조회가 반복됩니다. 헬퍼 메서드로 추출하면 가독성이 좋아집니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
src/main/java/com/example/onlyone/domain/search/service/SearchService.java(7 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-06T07:54:25.923Z
Learnt from: ghkddlscks19
PR: GoormOnlyOne/OnlyOne-Back#86
File: src/main/java/com/example/onlyone/domain/club/repository/ClubRepository.java:14-18
Timestamp: 2025-08-06T07:54:25.923Z
Learning: In the OnlyOne-Back project, the ClubRepository search methods use List<Object[]> return type instead of Page<Object[]> even with Pageable parameters. This is intentionally designed for infinite scrolling implementation on the frontend, where only the actual data is needed without pagination metadata.
Applied to files:
src/main/java/com/example/onlyone/domain/search/service/SearchService.java
🔇 Additional comments (3)
src/main/java/com/example/onlyone/domain/search/service/SearchService.java (3)
40-40: ES 리포지토리 의존성 주입 추가 LGTM
@RequiredArgsConstructor로 생성자 주입에 자동 포함되어 문제 없습니다.
262-272: 검증을 위해 리포지토리에서 관련 심볼(ClubDocument, SearchFilterDto 등)과 필드 매핑(createdAt, memberCount), createPageable 위치를 검색하겠습니다.#!/bin/bash set -euo pipefail echo "=== Searching for ClubDocument class ===" rg -nP "class\s+ClubDocument\b" -S || true echo "=== Files referencing ClubDocument (context) ===" rg -n "ClubDocument" -S -C3 || true echo "=== Searching for fields createdAt/memberCount (context) ===" rg -n --hidden --no-ignore -S "\b(createdAt|memberCount)\b" -C3 || true echo "=== Show candidate ClubDocument files (first match content) ===" for f in $(rg -nP "class\s+ClubDocument\b" -S --hidden --no-ignore --files); do echo "----- $f -----" sed -n '1,240p' "$f" done || true echo "=== Show lines around createPageable in SearchService ===" rg -n "createPageable\(" -S || true rg -n "createPageable\(" src/main/java -S -C12 || true for f in $(rg -n "createPageable\(" src/main/java -S --files); do echo "----- $f -----" sed -n '1,400p' "$f" | sed -n '1,400p' done || true echo "=== Search for SearchFilterDto definition and helper methods (hasKeyword, SortType, DEFAULT_PAGE_SIZE) ===" rg -nP "class\s+SearchFilterDto\b" -S || true rg -n "SearchFilterDto" -S -C6 || true rg -n "hasKeyword\(|getSortBy\(|SortType\.LATEST|DEFAULT_PAGE_SIZE" -S -C3 || true echo "=== Search for DEFAULT_PAGE_SIZE and PAGE_SIZE constants ===" rg -n "DEFAULT_PAGE_SIZE|PAGE_SIZE" -S -C3 || true echo "=== Search for Spring Data Elasticsearch @Field annotations and FieldType usages ===" rg -n "@Field\(" -S -C4 || true rg -n "FieldType\." -S -C4 || true echo "=== Done ==="
216-221:userId = null처리가 안전하게 구현되어 검증 완료
searchByUserInterestAndLocation메서드에서userId가null일 때 안전하게 처리됩니다:// 안전한 userId 처리 if (userId != null) { whereCondition.and( JPAExpressions.selectOne() .from(excludeUserClub) .where( excludeUserClub.club.clubId.eq(club.clubId) .and(excludeUserClub.user.userId.eq(userId)) ) .notExists() ); }
userId가null인 경우 해당 조건이 추가되지 않아 전체 클럽을 대상으로 검색이 수행되므로, NPE나 SQL 오류 없이 정상 작동합니다.
src/main/java/com/example/onlyone/domain/search/service/SearchService.java
Outdated
Show resolved
Hide resolved
src/main/java/com/example/onlyone/domain/search/service/SearchService.java
Show resolved
Hide resolved
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (3)
src/main/java/com/example/onlyone/domain/search/service/SearchService.java (3)
235-250: contains 선형 탐색 → Set으로 변경대량 결과 시 병목입니다. Set으로 변환해 O(1) 조회로 개선하세요.
private List<ClubResponseDto> convertElasticsearchResultsWithJoinStatus(List<ClubDocument> results, List<Long> joinedClubIds) { - return results.stream().map(document -> { - boolean isJoined = joinedClubIds.contains(document.getClubId()); + final java.util.Set<Long> joined = new java.util.HashSet<>(joinedClubIds); + return results.stream().map(document -> { + boolean isJoined = joined.contains(document.getClubId()); return ClubResponseDto.builder() .clubId(document.getClubId()) .name(document.getName()) .description(document.getDescription()) .district(document.getDistrict()) .image(document.getClubImage()) .interest(document.getInterestKoreanName()) .memberCount(document.getMemberCount()) .isJoined(isJoined) .build(); }).toList(); }
253-260: contains O(n²) + 집계 타입 안전화(Number → longValue)JPA 집계값은 BigInteger/BigDecimal일 수 있습니다. 캐스팅 NPE/CCE 방지와 성능 개선을 함께 반영하세요.
private List<ClubResponseDto> convertMysqlResultsWithJoinStatus(List<Object[]> results, List<Long> joinedClubIds) { - return results.stream().map(result -> { - Club club = (Club) result[0]; - Long memberCount = (Long) result[1]; - boolean isJoined = joinedClubIds.contains(club.getClubId()); - return ClubResponseDto.from(club, memberCount, isJoined); - }).toList(); + final java.util.Set<Long> joined = new java.util.HashSet<>(joinedClubIds); + return results.stream().map(result -> { + Club club = (Club) result[0]; + Number memberCountNum = (Number) result[1]; + long memberCount = memberCountNum == null ? 0L : memberCountNum.longValue(); + boolean isJoined = joined.contains(club.getClubId()); + return ClubResponseDto.from(club, memberCount, isJoined); + }).toList(); }
187-209: ES 장애 시 예외 처리/폴백 추가 필요네트워크/클러스터 장애에서 서비스 전체가 실패합니다. try/catch로 감싸고 적절한 에러 코드 또는 MySQL 폴백을 적용하세요.
private List<ClubDocument> searchWithElasticsearch(SearchFilterDto filter) { @@ - // 조건에 따라 적절한 ES 검색 메서드 호출 - if (filter.hasLocation() && filter.getInterestId() != null) { + try { + // 조건에 따라 적절한 ES 검색 메서드 호출 + if (filter.hasLocation() && filter.getInterestId() != null) { ... - } else if (filter.getInterestId() != null) { + } else if (filter.getInterestId() != null) { ... - } else { + } else { ... - } + } + } catch (Exception e) { + log.error("Elasticsearch 검색 실패: {}", e.getMessage()); + throw new CustomException(ErrorCode.SEARCH_SERVICE_UNAVAILABLE); + }
🧹 Nitpick comments (6)
src/main/java/com/example/onlyone/domain/user/service/UserService.java (2)
84-99: Anonymous 인증 케이스 방어isAuthenticated가 true여도 익명 토큰일 수 있습니다. AnonymousAuthenticationToken 여부(또는 principal == "anonymousUser")를 함께 차단하는 방어를 권장합니다.
163-163: Access 토큰 subject 변경에 따른 운영 영향 점검
- 기존 발급된 토큰 즉시 무효화/재로그인 정책 필요
- JWT 필터가 subject=userId를 principal로 세팅하는지 확인
- HS512 키 길이(최소 64바이트)와 키 롤테이션 전략 점검
src/main/java/com/example/onlyone/domain/search/service/SearchService.java (4)
40-40: Elasticsearch 리포지토리 주입 시 프로파일/옵셔널 빈 처리 검토로컬/테스트 환경에 ES 미구동 시 빈 생성 실패 위험. @ConditionalOnProperty 또는 fail‑safe 설정(옵셔널 빈/feature flag) 고려하세요.
90-92: 가입 모임 ID 조회는 OK, 이후 contains 병목은 Set으로 최적화아래 변환 메서드에서 joinedClubIds.contains(...)가 O(n²)로 동작합니다. Set 변환으로 상수 시간 조회로 바꾸세요(아래 코멘트 참조).
Also applies to: 103-105
120-132: 불필요한 DB 호출 축소 + TODO 정리
- TODO 주석은 부정확합니다. 커넥션/트랜잭션 관리는 Spring/JPA가 처리합니다. 제거 권장.
- 결과가 0건일 때 joinedClubIds 조회를 생략하면 DB 호출 1회를 절약할 수 있습니다.
예시 리팩터:
@@ - Long userId = userService.getCurrentUserId(); //TODO: 이 쿼리가 실행 후 히카리에 스레드기 반환되는지 확인.. - List<Long> joinedClubIds = userClubRepository.findByClubIdsByUserId(userId); - - if (filter.hasKeyword()) { + if (filter.hasKeyword()) { // ES 검색 (키워드 있는 경우) List<ClubDocument> esResults = searchWithElasticsearch(filter); - return convertElasticsearchResultsWithJoinStatus(esResults, joinedClubIds); + if (esResults.isEmpty()) return List.of(); + Long userId = userService.getCurrentUserId(); + List<Long> joinedClubIds = userClubRepository.findByClubIdsByUserId(userId); + return convertElasticsearchResultsWithJoinStatus(esResults, joinedClubIds); } else { // MySQL 검색 (키워드 없는 경우) List<Object[]> resultList = searchWithMysql(filter); - return convertMysqlResultsWithJoinStatus(resultList, joinedClubIds); + if (resultList.isEmpty()) return List.of(); + Long userId = userService.getCurrentUserId(); + List<Long> joinedClubIds = userClubRepository.findByClubIdsByUserId(userId); + return convertMysqlResultsWithJoinStatus(resultList, joinedClubIds); }
211-233: MySQL 경로 입력 정규화 누락(공백 포함 케이스)ES 경로는 trim을 적용하지만 MySQL 경로(searchByLocation)는 trim이 빠져 있습니다. 일관된 입력 정규화가 필요합니다.
- return clubRepository.searchByLocation(filter.getCity(), filter.getDistrict(), pageRequest); + return clubRepository.searchByLocation(filter.getCity().trim(), filter.getDistrict().trim(), pageRequest);
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
src/main/java/com/example/onlyone/domain/search/service/SearchService.java(8 hunks)src/main/java/com/example/onlyone/domain/user/service/UserService.java(2 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-06T07:54:25.923Z
Learnt from: ghkddlscks19
PR: GoormOnlyOne/OnlyOne-Back#86
File: src/main/java/com/example/onlyone/domain/club/repository/ClubRepository.java:14-18
Timestamp: 2025-08-06T07:54:25.923Z
Learning: In the OnlyOne-Back project, the ClubRepository search methods use List<Object[]> return type instead of Page<Object[]> even with Pageable parameters. This is intentionally designed for infinite scrolling implementation on the frontend, where only the actual data is needed without pagination metadata.
Applied to files:
src/main/java/com/example/onlyone/domain/search/service/SearchService.java
🔇 Additional comments (2)
src/main/java/com/example/onlyone/domain/search/service/SearchService.java (2)
3-6: ES 연동 import 및 Pageable/Sort 도입 LGTMAlso applies to: 21-22
263-273: ES 정렬 필드 검증 필요(createdAt/memberCount 매핑 확인)ClubDocument 매핑에 createdAt, memberCount가 정확히 존재/타입 호환되는지 확인하세요. 미존재/텍스트 필드면 정렬 실패합니다.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
src/main/java/com/example/onlyone/global/filter/JwtAuthenticationFilter.java (4)
20-20: JJWT 예외 타입 미스매치로 인해 파싱 예외가 catch되지 않습니다.
parseClaimsJws()는io.jsonwebtoken.JwtException계열을 던집니다. 현재는 Spring의org.springframework.security.oauth2.jwt.JwtException을 import하여 catch 블록(Line 82)이 동작하지 않을 수 있습니다(500 가능).다음으로 교체해 주세요:
-import org.springframework.security.oauth2.jwt.JwtException; +import io.jsonwebtoken.JwtException;
53-57: jjwt 버전 중복 선언(0.11.5 vs 0.12.4) — 의존성/코드 호환성 정리 필요build.gradle에 io.jsonwebtoken:jjwt-api/impl/jackson가 0.11.5와 0.12.4로 중복 선언되어 있습니다 (build.gradle:50–59). 코드에서는 Jwts.parser()를 사용하고 있으니 의존성을 단일 버전으로 통일하거나 코드와 의존성의 API를 맞추십시오.
영향 파일: src/main/java/com/example/onlyone/global/filter/JwtAuthenticationFilter.java:53–55, src/main/java/com/example/onlyone/global/filter/SseAuthenticationFilter.java:69–71
75-81: principal을 JWT subject(userId)로 설정하고 관련 테스트를 수정하세요.UserService는 토큰 subject를 user.getUserId()로 발급하고 있으므로 JwtAuthenticationFilter의 principal은 userId를 사용해야 합니다. 테스트들은 아직 subject(kakaoId.toString())로 토큰을 생성하고 있어 함께 수정 필요합니다.
- UsernamePasswordAuthenticationToken auth = - new UsernamePasswordAuthenticationToken( - kakaoIdString, // principal - null, // credentials - Collections.emptyList() // 권한 목록 - ); + String principal = user.getUserId() != null ? user.getUserId().toString() : kakaoIdString; + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken( + principal, + null, + Collections.emptyList() + );수정 포인트:
- 적용 파일: src/main/java/com/example/onlyone/global/filter/JwtAuthenticationFilter.java (principal 변경)
- 테스트 수정: src/test/java/com/example/onlyone/global/sse/SseStreamControllerTest.java, src/test/java/com/example/onlyone/global/filter/SseAuthenticationFilterTest.java — 토큰 생성 시 subject를 userId로 변경하거나 테스트에서 kakaoId subject를 처리하도록 보완
- 대안(호환성): user 조회 로직이 subject를 먼저 userId로 조회하고 실패하면 kakaoId로 폴백하도록 보장
63-74: 치명적: 존재하지 않는 사용자에 대해 Authentication을 설정하는 fail-open 경로 — 즉시 401로 거부 필요JwtAuthenticationFilter가 findByKakaoId 미매칭 시에도 토큰 클레임만으로 Authentication을 세팅합니다. 사용자 미존재는 UNAUTHORIZED(401)로 즉시 거부해야 합니다.
파일: src/main/java/com/example/onlyone/global/filter/JwtAuthenticationFilter.java (약 62–81행)
- Optional<User> userOpt = userRepository.findByKakaoId(kakaoId); - if (userOpt.isPresent()) { - User user = userOpt.get(); - - if (Status.INACTIVE.name().equals(user.getStatus())) { - // 로그아웃 요청은 탈퇴한 사용자도 허용 (토큰 정리를 위해) - if (!"/auth/logout".equals(request.getRequestURI())) { - throw new CustomException(ErrorCode.USER_WITHDRAWN); - } - } - } + Optional<User> userOpt = userRepository.findByKakaoId(kakaoId); + User user = userOpt.orElseThrow(() -> new CustomException(ErrorCode.UNAUTHORIZED)); + // 로그아웃 요청은 탈퇴한 사용자도 허용 (토큰 정리를 위해) + if (Status.INACTIVE.name().equals(user.getStatus())) { + if (!"/auth/logout".equals(request.getRequestURI())) { + throw new CustomException(ErrorCode.USER_WITHDRAWN); + } + }
🧹 Nitpick comments (2)
src/main/java/com/example/onlyone/global/filter/JwtAuthenticationFilter.java (2)
52-52: 시스템 기본 인코딩 의존 제거.비밀키 바이트 변환에 기본 Charset을 쓰지 마세요. UTF‑8을 명시하세요.
- SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes()); + SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(java.nio.charset.StandardCharsets.UTF_8));
42-42: 미사용 변수 제거.
requestURI가 사용되지 않습니다. 정리해 주세요.- String requestURI = request.getRequestURI();
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
src/main/java/com/example/onlyone/global/filter/JwtAuthenticationFilter.java(1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-09-16T00:20:44.262Z
Learnt from: ghkddlscks19
PR: GoormOnlyOne/OnlyOne-Back#145
File: src/main/java/com/example/onlyone/domain/user/service/UserService.java:84-99
Timestamp: 2025-09-16T00:20:44.262Z
Learning: JWT 인증에서 카카오 ID와 유저 ID 분리 시, JWT subject를 userId 기반으로 통일하고 findByKakaoId()는 OAuth 로그인 시에만 사용하는 것이 일관성 있는 아키텍처입니다.
Applied to files:
src/main/java/com/example/onlyone/global/filter/JwtAuthenticationFilter.java
📚 Learning: 2025-09-16T00:25:01.449Z
Learnt from: ghkddlscks19
PR: GoormOnlyOne/OnlyOne-Back#145
File: src/main/java/com/example/onlyone/domain/user/service/UserService.java:84-99
Timestamp: 2025-09-16T00:25:01.449Z
Learning: JWT 인증에서 kakaoId와 userId 하이브리드 지원 시, getUserBySubject() 메서드로 subject를 userId로 먼저 조회하고 실패하면 kakaoId로 fallback하는 방식을 선호합니다. 이는 기존 토큰 호환성을 유지하면서 점진적 마이그레이션을 가능하게 합니다.
Applied to files:
src/main/java/com/example/onlyone/global/filter/JwtAuthenticationFilter.java
src/main/java/com/example/onlyone/global/filter/JwtAuthenticationFilter.java
Show resolved
Hide resolved
[feat] elasticsearch를 활용한 검색 기능 추가
#️⃣ Issue Number
📝 요약(Summary)
elasticsearch를 활용한 검색 기능 추가
🛠️ PR 유형
어떤 변경 사항이 있나요?
📸스크린샷 (선택)
💬 공유사항 to 리뷰어
✅ PR Checklist
PR이 다음 요구 사항을 충족하는지 확인하세요.
Summary by CodeRabbit