From f35eb659b9189f393853cceb1146d618a8e833d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 00:53:14 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B3=B5=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=ED=98=95=20=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5,?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 + .../repository/UserInterestRepository.java | 18 + .../bookmark/service/BookmarkService.java | 8 + .../mongodb/SearchInfoController.java | 71 +++- ...ler.java => ServiceMatchedController.java} | 2 +- .../dto/mongodb/ServiceSearchCriteriaDto.java | 43 +++ .../dto/mongodb/ServiceSearchResultDto.java | 30 ++ .../entity/mongodb/ServiceInfo.java | 34 ++ .../mapper/mongodb/ServiceInfoMapper.java | 15 + .../mongodb/ServiceSearchClient.java | 345 ++++++++++++++++++ .../service/PublicServiceHandler.java | 23 +- .../service/mongodb/ServiceSearchService.java | 134 +++++++ 12 files changed, 702 insertions(+), 25 deletions(-) rename src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/{PublicServiceMatchedController.java => ServiceMatchedController.java} (89%) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchResultDto.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/ServiceInfo.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/mongodb/ServiceInfoMapper.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java diff --git a/build.gradle b/build.gradle index baf0348..7d40ded 100644 --- a/build.gradle +++ b/build.gradle @@ -70,6 +70,10 @@ dependencies { // implementation 'org.mongodb:mongodb-driver-sync' // implementation 'org.mongodb:mongodb-driver-core' + // Caffeine + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'com.github.ben-manes.caffeine:caffeine' + // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java index 907ebcf..560495b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/UserInterest/repository/UserInterestRepository.java @@ -4,7 +4,25 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Arrays; +import java.util.List; + @Repository public interface UserInterestRepository extends JpaRepository { + /** + * 사용자 ID에 해당하는 모든 관심사 항목을 조회합니다. + * + * @param userId 사용자 ID + * @return 사용자의 관심사 목록 + */ + List findByUserId(Long userId); + + /** + * 사용자 ID로 관심사 존재 여부 확인 + * + * @param userId 사용자 ID + * @return 관심사 존재 여부 + */ + boolean existsByUserId(Long userId); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java b/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java index fb9d821..989a6e8 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/bookmark/service/BookmarkService.java @@ -5,6 +5,7 @@ import com.hyetaekon.hyetaekon.common.exception.GlobalException; import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; import com.hyetaekon.hyetaekon.publicservice.repository.PublicServiceRepository; +import com.hyetaekon.hyetaekon.publicservice.service.PublicServiceHandler; import com.hyetaekon.hyetaekon.user.entity.User; import com.hyetaekon.hyetaekon.user.repository.UserRepository; import jakarta.transaction.Transactional; @@ -20,6 +21,7 @@ public class BookmarkService { private final BookmarkRepository bookmarkRepository; private final UserRepository userRepository; private final PublicServiceRepository publicServiceRepository; + private final PublicServiceHandler publicServiceHandler; public void addBookmark(String serviceId, Long userId) { User user = userRepository.findById(userId) @@ -42,6 +44,9 @@ public void addBookmark(String serviceId, Long userId) { // 북마크 수 증가 publicService.increaseBookmarkCount(); + + // 인기 서비스 캐시 무효화 + publicServiceHandler.refreshPopularServices(); } @Transactional @@ -54,5 +59,8 @@ public void removeBookmark(String serviceId, Long userId) { // 북마크 수 감소 PublicService publicService = bookmark.getPublicService(); publicService.decreaseBookmarkCount(); + + // 인기 서비스 캐시 무효화 + publicServiceHandler.refreshPopularServices(); } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/SearchInfoController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/SearchInfoController.java index 68e14f7..85cb793 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/SearchInfoController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/SearchInfoController.java @@ -1,29 +1,60 @@ package com.hyetaekon.hyetaekon.publicservice.controller.mongodb; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchCriteriaDto; +import com.hyetaekon.hyetaekon.publicservice.service.mongodb.ServiceSearchService; +import com.hyetaekon.hyetaekon.common.util.AuthenticateUser; + +import java.util.List; @Validated @RestController -@RequestMapping("/api/services/search") +@RequestMapping("/api/mongo/services") @RequiredArgsConstructor public class SearchInfoController { - - // 검색(키워드 + 서비스 조건, 분야 선택) - - - // 자동완성 - - - // 검색 기록 전체 조회 - - - // 검색 기록 삭제 - - - // 검색 기록 전체 삭제 - - -} + private final ServiceSearchService searchService; + private final AuthenticateUser authenticateUser; + + // 검색 API (로그인/비로그인 통합) + @GetMapping("/search") + public ResponseEntity> searchServices( + @RequestParam(name = "searchTerm", required = false, defaultValue = "") String searchTerm, + @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(name = "size", defaultValue = "9") @Positive @Max(50) int size + ) { + // 검색 조건 생성 + ServiceSearchCriteriaDto searchCriteria = ServiceSearchCriteriaDto.builder() + .searchTerm(searchTerm) + .pageable(PageRequest.of(page, size)) + .build(); + + // 사용자 인증 여부 확인 + Long userId = authenticateUser.authenticateUserId(); + + // 인증된 사용자면 맞춤 검색, 아니면 기본 검색 + if (userId != 0L) { + return ResponseEntity.ok(searchService.searchPersonalizedServices(searchCriteria, userId)); + } else { + return ResponseEntity.ok(searchService.searchServices(searchCriteria)); + } + } + + // 자동완성 API + @GetMapping("/search/autocomplete") + public ResponseEntity> getAutocompleteResults( + @RequestParam(name = "word") String word + ) { + return ResponseEntity.ok(searchService.getAutocompleteResults(word)); + } +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/PublicServiceMatchedController.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java similarity index 89% rename from src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/PublicServiceMatchedController.java rename to src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java index 5d44241..704344f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/PublicServiceMatchedController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/controller/mongodb/ServiceMatchedController.java @@ -9,7 +9,7 @@ @RestController @RequestMapping("/api/services/matched") @RequiredArgsConstructor -public class PublicServiceMatchedController { +public class ServiceMatchedController { } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java new file mode 100644 index 0000000..ebf0818 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchCriteriaDto.java @@ -0,0 +1,43 @@ +package com.hyetaekon.hyetaekon.publicservice.dto.mongodb; + +import lombok.Getter; +import lombok.Builder; +import org.springframework.data.domain.Pageable; +import org.springframework.util.StringUtils; + +import java.util.List; + +@Getter +@Builder +public class ServiceSearchCriteriaDto { + private final String searchTerm; // 검색어 + private final List userInterests; // 사용자 관심사 + private final String userGender; // 사용자 성별 + private final Integer userAge; // 사용자 나이 + private final String userJob; // 사용자 직종 + private final String userIncomeLevel; // 사용자 소득수준 + private final Pageable pageable; + + // 사용자 정보 추가 메서드 + public ServiceSearchCriteriaDto withUserInfo( + List userInterests, + String userGender, + Integer userAge, + String userIncomeLevel, + String userJob) { + return ServiceSearchCriteriaDto.builder() + .searchTerm(this.searchTerm) + .userInterests(userInterests) + .userGender(userGender) + .userAge(userAge) + .userIncomeLevel(userIncomeLevel) + .userJob(userJob) + .pageable(this.pageable) + .build(); + } + + // 검색 조건 유무 확인 + public boolean hasSearchCriteria() { + return StringUtils.hasText(searchTerm); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchResultDto.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchResultDto.java new file mode 100644 index 0000000..74d6cc6 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/dto/mongodb/ServiceSearchResultDto.java @@ -0,0 +1,30 @@ +package com.hyetaekon.hyetaekon.publicservice.dto.mongodb; + +import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.ServiceInfo; +import lombok.AllArgsConstructor; +import lombok.*; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +public class ServiceSearchResultDto { + private final List results; + private final long total; + private final int currentPage; + private final int totalPages; + private final boolean hasNext; + + public static ServiceSearchResultDto of(List results, long total, Pageable pageable) { + int totalPages = (int) Math.ceil((double) total / pageable.getPageSize()); + return ServiceSearchResultDto.builder() + .results(results) + .total(total) + .currentPage(pageable.getPageNumber()) + .totalPages(totalPages) + .hasNext(pageable.getPageNumber() + 1 < totalPages) + .build(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/ServiceInfo.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/ServiceInfo.java new file mode 100644 index 0000000..50b965f --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/ServiceInfo.java @@ -0,0 +1,34 @@ +package com.hyetaekon.hyetaekon.publicservice.entity.mongodb; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.List; + + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "service_info") // 실제 몽고 DB 컬렉션 이름 +public class ServiceInfo { + private String publicServiceId; + private String serviceName; + private String summaryPurpose; + + // 해시태그 + private String serviceCategory; // 서비스 분야 + private List specialGroup; // 특수 대상 그룹 + private List familyType; // 가구 형태 + + private List occupations; + private List businessTypes; + + // Support conditions fields + private String targetGenderMale; + private String targetGenderFemale; + private Integer targetAgeStart; + private Integer targetAgeEnd; + private String incomeLevel; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/mongodb/ServiceInfoMapper.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/mongodb/ServiceInfoMapper.java new file mode 100644 index 0000000..2a46e27 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/mapper/mongodb/ServiceInfoMapper.java @@ -0,0 +1,15 @@ +package com.hyetaekon.hyetaekon.publicservice.mapper.mongodb; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.ServiceInfo; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface ServiceInfoMapper { + + @Mapping(source = "specialGroup", target = "specialGroup") + @Mapping(source = "familyType", target = "familyType") + PublicServiceListResponseDto toDto(ServiceInfo serviceInfo); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java new file mode 100644 index 0000000..805f852 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java @@ -0,0 +1,345 @@ +package com.hyetaekon.hyetaekon.publicservice.repository.mongodb; + +import lombok.RequiredArgsConstructor; +import org.bson.Document; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationOperation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchCriteriaDto; +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchResultDto; +import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.ServiceInfo; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class ServiceSearchClient { + private final MongoTemplate mongoTemplate; + private static final String SEARCH_INDEX = "searchIndex"; + private static final String AUTOCOMPLETE_INDEX = "serviceAutocompleteIndex"; + private static final String COLLECTION_NAME = "service_info"; + + private static final String PROJECT_STAGE = """ + { + $project: { + publicServiceId: 1, + serviceName: 1, + summaryPurpose: 1, + serviceCategory: 1, + specialGroup: 1, + familyType: 1, + occupations: 1, + businessTypes: 1, + targetGenderMale: 1, + targetGenderFemale: 1, + targetAgeStart: 1, + targetAgeEnd: 1, + incomeLevel: 1, + score: {$meta: 'searchScore'} + } + }"""; + + public ServiceSearchResultDto search(ServiceSearchCriteriaDto criteria) { + List operations = new ArrayList<>(); + + // 검색 쿼리 추가 + operations.add(context -> Document.parse(buildSearchQuery(criteria))); + + // 프로젝션 추가 + operations.add(context -> Document.parse(PROJECT_STAGE)); + + // 페이징 처리 + operations.add(context -> Document.parse(buildFacetStage(criteria.getPageable()))); + + AggregationResults results = mongoTemplate.aggregate( + Aggregation.newAggregation(operations), + COLLECTION_NAME, + Document.class + ); + + return processResults(results, criteria.getPageable()); + } + + private String buildSearchQuery(ServiceSearchCriteriaDto criteria) { + List shouldClauses = new ArrayList<>(); + String mustClause = buildUserMustClause(criteria); + + // 검색어 관련 조건 추가 + if (StringUtils.hasText(criteria.getSearchTerm())) { + addSearchTermClauses(shouldClauses, criteria.getSearchTerm()); + } + + // 사용자 관심사 관련 조건 추가 + if (criteria.getUserInterests() != null && !criteria.getUserInterests().isEmpty()) { + for (String interest : criteria.getUserInterests()) { + shouldClauses.add(createSearchClause("serviceCategory", interest, 2.5f, 0)); + shouldClauses.add(createSearchClause("specialGroup", interest, 2.5f, 0)); + shouldClauses.add(createSearchClause("familyType", interest, 2.5f, 0)); + } + } + + // 소득 수준 일치 가산점 (should 조건) + addUserMatchBoosts(shouldClauses, criteria); + + if (StringUtils.hasText(criteria.getUserIncomeLevel())) { + shouldClauses.add(createSearchClause("incomeLevel", criteria.getUserIncomeLevel(), 2.8f, 0)); + shouldClauses.add(createSearchClause("incomeLevel", "ANY", 1.0f, 0)); + } + + String shouldClausesStr = shouldClauses.isEmpty() ? "[]" : "[" + String.join(",", shouldClauses) + "]"; + + // must 조건이 있으면 추가, 없으면 생략 + String compoundQuery = """ + compound: { + should: %s%s + } + """.formatted(shouldClausesStr, mustClause); + + return """ + { + $search: { + index: '%s', + %s + } + }""".formatted(SEARCH_INDEX, compoundQuery); + } + + private void addSearchTermClauses(List clauses, String searchTerm) { + // 서비스명 검색 + clauses.add(createSearchClause("serviceName", searchTerm, 5.0f, 1)); + clauses.add(createSearchClause("serviceName", searchTerm, 4.5f, 2)); + + // 요약 검색 + clauses.add(createSearchClause("summaryPurpose", searchTerm, 3.5f, 0)); + + // 서비스 분야 검색 + clauses.add(createSearchClause("serviceCategory", searchTerm, 4.5f, 1)); + + // 특수그룹 검색 + clauses.add(createSearchClause("specialGroup", searchTerm, 4.0f, 1)); + + // 가족유형 검색 + clauses.add(createSearchClause("familyType", searchTerm, 4.0f, 1)); + + // 직업 검색 + clauses.add(createSearchClause("occupations", searchTerm, 3.0f, 0)); + + // 사업자 유형 검색 + clauses.add(createSearchClause("businessTypes", searchTerm, 3.0f, 0)); + + // 정규식 전방 일치 검색 (서비스명) + clauses.add(""" + {regex: { + query: '%s.*', + path: 'serviceName', + allowAnalyzedField: true, + score: {boost: {value: 4.0}} + }}""".formatted(searchTerm)); + } + + private String buildUserMustClause(ServiceSearchCriteriaDto criteria) { + List mustClauses = new ArrayList<>(); + + // 성별 필수 조건 + if (StringUtils.hasText(criteria.getUserGender())) { + String genderField = "MALE".equalsIgnoreCase(criteria.getUserGender()) + ? "targetGenderMale" : "targetGenderFemale"; + + // 대상 성별이 null이거나 Y인 서비스만 포함 + mustClauses.add(""" + { + compound: { + should: [ + {exists: {path: "%s", exists: false}}, + {equals: {path: "%s", value: "Y"}} + ] + } + }""".formatted(genderField, genderField)); + } + + // 나이 필수 조건 + if (criteria.getUserAge() != null) { + int age = criteria.getUserAge(); + + // 대상 나이 범위가 null이거나 사용자 나이를 포함하는 서비스만 포함 + mustClauses.add(""" + { + compound: { + should: [ + {exists: {path: "targetAgeStart", exists: false}}, + {range: {path: "targetAgeStart", lte: %d}} + ] + } + }""".formatted(age)); + + mustClauses.add(""" + { + compound: { + should: [ + {exists: {path: "targetAgeEnd", exists: false}}, + {range: {path: "targetAgeEnd", gte: %d}} + ] + } + }""".formatted(age)); + } + + // must 조건이 없으면 빈 문자열 반환 + if (mustClauses.isEmpty()) { + return ""; + } + + // must 조건이 있으면 문자열 형식으로 반환 + return ", must: [" + String.join(",", mustClauses) + "]"; + } + + private void addUserMatchBoosts(List clauses, ServiceSearchCriteriaDto criteria) { + // 직업 일치 가산점 + if (StringUtils.hasText(criteria.getUserJob())) { + String userJob = criteria.getUserJob(); + + // Occupation 필드와 일치 시 가산점 + clauses.add(createSearchClause("occupations", userJob, 3.0f, 0)); + + // BusinessType 필드와 일치 시 가산점 + clauses.add(createSearchClause("businessTypes", userJob, 3.0f, 0)); + } + + // 소득 수준 일치 가산점 + if (StringUtils.hasText(criteria.getUserIncomeLevel())) { + String userIncomeLevel = criteria.getUserIncomeLevel(); + + // 1. 정확히 일치하는 소득수준에 높은 가산점 + clauses.add(""" + {text: { + query: '%s', + path: 'incomeLevel', + score: {boost: {value: 2.8}} + }}""".formatted(userIncomeLevel)); + + // 2. ANY 값은 모든 소득수준에 매칭 가능 + clauses.add(""" + {text: { + query: 'ANY', + path: 'incomeLevel', + score: {boost: {value: 1.0}} + }}"""); + + // 3. 사용자 소득수준보다 낮은 범위도 포함 (범위별 가산점) + addIncomeLevelRangeBoosts(clauses, userIncomeLevel); + } + } + + // 소득수준 범위에 따른 가산점 추가 + private void addIncomeLevelRangeBoosts(List clauses, String userIncomeLevel) { + switch (userIncomeLevel) { + case "HIGH": + clauses.add(createSearchClause("incomeLevel", "MIDDLE_HIGH", 2.5f, 0)); + clauses.add(createSearchClause("incomeLevel", "MIDDLE", 2.0f, 0)); + clauses.add(createSearchClause("incomeLevel", "MIDDLE_LOW", 1.5f, 0)); + clauses.add(createSearchClause("incomeLevel", "LOW", 1.0f, 0)); + break; + case "MIDDLE_HIGH": + clauses.add(createSearchClause("incomeLevel", "MIDDLE", 2.5f, 0)); + clauses.add(createSearchClause("incomeLevel", "MIDDLE_LOW", 2.0f, 0)); + clauses.add(createSearchClause("incomeLevel", "LOW", 1.5f, 0)); + break; + case "MIDDLE": + clauses.add(createSearchClause("incomeLevel", "MIDDLE_LOW", 2.0f, 0)); + clauses.add(createSearchClause("incomeLevel", "LOW", 1.5f, 0)); + break; + case "MIDDLE_LOW": + clauses.add(createSearchClause("incomeLevel", "LOW", 2.0f, 0)); + break; + default: + break; + } + } + + private String createSearchClause(String path, String query, float boost, int maxEdits) { + return """ + {text: { + query: '%s', + path: '%s', + score: {boost: {value: %.1f}}%s + }}""".formatted( + query, + path, + boost, + maxEdits > 0 ? ", fuzzy: {maxEdits: " + maxEdits + "}" : "" + ); + } + + private String buildFacetStage(Pageable pageable) { + return """ + { + $facet: { + results: [{$skip: %d}, {$limit: %d}], + total: [{$count: 'count'}] + } + }""".formatted(pageable.getOffset(), pageable.getPageSize()); + } + + private ServiceSearchResultDto processResults(AggregationResults results, Pageable pageable) { + Document result = results.getUniqueMappedResult(); + if (result == null) { + return ServiceSearchResultDto.of(List.of(), 0L, pageable); + } + + List resultDocs = result.get("results", List.class); + List totalDocs = result.get("total", List.class); + + if (resultDocs == null) { + return ServiceSearchResultDto.of(List.of(), 0L, pageable); + } + + List searchResults = resultDocs.stream() + .map(doc -> mongoTemplate.getConverter().read(ServiceInfo.class, doc)) + .toList(); + + long total = 0L; + if (totalDocs != null && !totalDocs.isEmpty()) { + Number count = totalDocs.getFirst().get("count", Number.class); + total = count != null ? count.longValue() : 0L; + } + + return ServiceSearchResultDto.of(searchResults, total, pageable); + } + + // 검색어 자동완성 + public List getAutocompleteResults(String word) { + if (!StringUtils.hasText(word) || word.length() < 2) { + return new ArrayList<>(); + } + + return mongoTemplate.aggregate( + Aggregation.newAggregation( + context -> Document.parse(""" + { + $search: { + index: '%s', + autocomplete: { + query: '%s', + path: 'serviceName', + fuzzy: {maxEdits: 1} + } + } + }""".formatted(AUTOCOMPLETE_INDEX, word)), + Aggregation.project("serviceName"), + Aggregation.limit(8) + ), + COLLECTION_NAME, + Document.class + ) + .getMappedResults() + .stream() + .map(doc -> doc.getString("serviceName")) + .distinct() + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java index b11117b..0cae309 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java @@ -14,6 +14,8 @@ import com.hyetaekon.hyetaekon.publicservice.repository.PublicServiceRepository; import com.hyetaekon.hyetaekon.publicservice.util.PublicServiceValidate; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -48,6 +50,10 @@ public class PublicServiceHandler { }); }*/ + /* public ServiceCategory getServiceCategory(String categoryName) { + return publicServiceValidate.validateServiceCategory(categoryName); + }*/ + // 서비스 상세 조회 @Transactional public PublicServiceDetailResponseDto getServiceDetail(String serviceId, Long userId) { @@ -72,7 +78,8 @@ public PublicServiceDetailResponseDto getServiceDetail(String serviceId, Long us return dto; } - // 인기 서비스 목록 조회(6개 고정) + // 인기 서비스 목록 조회(6개 고정) - 캐싱적용 + @Cacheable(value = "popularServices", key = "'top6'", unless = "#result.isEmpty()") public List getPopularServices(Long userId) { // 북마크 수 기준으로 상위 6개 서비스 조회 @@ -90,8 +97,9 @@ public List getPopularServices(Long userId) { .collect(Collectors.toList()); } - public ServiceCategory getServiceCategory(String categoryName) { - return publicServiceValidate.validateServiceCategory(categoryName); + // 인기 서비스 캐시 무효화 - 데이터 변경 시 호출 + @CacheEvict(value = "popularServices", key = "'top6'") + public void refreshPopularServices() { } // 공공서비스 전체 목록 조회 (정렬 및 필터링 적용) @@ -200,7 +208,8 @@ public Page getAllServices( }); } - // 기존 코드에 아래 메서드 추가 + // 필터 옵션 조회 (캐싱 적용) + @Cacheable(value = "filterOptions") public Map> getFilterOptions() { Map> filterOptions = new HashMap<>(); @@ -225,6 +234,11 @@ public Map> getFilterOptions() { return filterOptions; } + // 필터 옵션 캐시 무효화 - Enum이 변경될 때 + @CacheEvict(value = "filterOptions", allEntries = true) + public void refreshFilterOptions() { + } + public Page getBookmarkedServices(Long userId, Pageable pageable) { Page bookmarkedServices = publicServiceRepository.findByBookmarks_User_Id(userId, pageable); @@ -239,4 +253,5 @@ public Page getBookmarkedServices(Long userId, Pag return new PageImpl<>(serviceDtos, pageable, bookmarkedServices.getTotalElements()); } + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java new file mode 100644 index 0000000..6ab2bf8 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/ServiceSearchService.java @@ -0,0 +1,134 @@ +package com.hyetaekon.hyetaekon.publicservice.service.mongodb; + +import com.hyetaekon.hyetaekon.UserInterest.entity.UserInterest; +import com.hyetaekon.hyetaekon.UserInterest.repository.UserInterestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import com.hyetaekon.hyetaekon.bookmark.repository.BookmarkRepository; +import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto; +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchCriteriaDto; +import com.hyetaekon.hyetaekon.publicservice.dto.mongodb.ServiceSearchResultDto; +import com.hyetaekon.hyetaekon.publicservice.repository.mongodb.ServiceSearchClient; +import com.hyetaekon.hyetaekon.publicservice.mapper.mongodb.ServiceInfoMapper; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; + +import java.time.LocalDate; +import java.time.Period; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ServiceSearchService { + private final ServiceSearchClient serviceSearchClient; + private final BookmarkRepository bookmarkRepository; + private final ServiceInfoMapper serviceInfoMapper; + private final UserRepository userRepository; + private final UserInterestRepository userInterestRepository; + private final IncomeEstimationHandler incomeEstimationHandler; + + // 기본 검색 (비로그인) + public Page searchServices(ServiceSearchCriteriaDto criteria) { + // 검색 조건이 없는 경우 빈 결과 반환 + if (!criteria.hasSearchCriteria()) { + return Page.empty(criteria.getPageable()); + } + + // MongoDB 검색 수행 + ServiceSearchResultDto searchResult = serviceSearchClient.search(criteria); + + // 검색 결과를 DTO로 변환 (북마크 정보 없이) + return convertToPageResponse(searchResult, null); + } + + // 맞춤 검색 (로그인) + public Page searchPersonalizedServices( + ServiceSearchCriteriaDto criteria, Long userId) { + + // 검색 조건이 없는 경우 빈 결과 반환 + if (!criteria.hasSearchCriteria()) { + return Page.empty(criteria.getPageable()); + } + + // 사용자 정보 가져오기 + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 사용자 ID입니다.")); + + String userIncomeLevel = incomeEstimationHandler.determineIncomeLevelFromJob(user.getJob()); + + // 사용자 관심사 목록 추출 + List userInterests = userInterestRepository.findByUserId(userId).stream() + .map(UserInterest::getInterest) + .collect(Collectors.toList()); + + // 사용자 정보로 검색 조건 보강 + ServiceSearchCriteriaDto enrichedCriteria = criteria.withUserInfo( + userInterests, + user.getGender(), + calculateAge(user.getBirthAt()), + userIncomeLevel, + user.getJob() + ); + + // MongoDB 검색 수행 + ServiceSearchResultDto searchResult = serviceSearchClient.search(enrichedCriteria); + + // 검색 결과를 DTO로 변환 (북마크 정보 포함) + return convertToPageResponse(searchResult, userId); + } + + private Page convertToPageResponse( + ServiceSearchResultDto searchResult, Long userId) { + + List dtoList = searchResult.getResults().stream() + .map(serviceInfo -> { + // Entity를 DTO로 변환 + PublicServiceListResponseDto dto = serviceInfoMapper.toDto(serviceInfo); + + // 사용자 ID가 제공된 경우 북마크 정보 설정 + if (userId != null) { + dto.setBookmarked(bookmarkRepository.existsByUserIdAndPublicServiceId( + userId, serviceInfo.getPublicServiceId())); + } + + return dto; + }) + .collect(Collectors.toList()); + + return new PageImpl<>( + dtoList, + PageRequest.of(searchResult.getCurrentPage(), + searchResult.getResults().isEmpty() ? 10 : searchResult.getResults().size()), + searchResult.getTotal() + ); + } + + // 나이 계산 헬퍼 메서드 + private Integer calculateAge(LocalDate birthDate) { + if (birthDate == null) return null; + return Period.between(birthDate, LocalDate.now()).getYears(); + } + + // 자동완성 기능 - 캐싱 적용 + @Cacheable(value = "serviceAutocomplete", key = "#word", unless = "#result.isEmpty()") + public List getAutocompleteResults(String word) { + if (!StringUtils.hasText(word) || word.length() < 2) { + return new ArrayList<>(); + } + return serviceSearchClient.getAutocompleteResults(word); + } + + // 자동완성 캐시 무효화 + @CacheEvict(value = "serviceAutocomplete", allEntries = true) + public void refreshAutocompleteCache() { + } + +} From 610507dc776c59f72b1f5b32bba8f5584a6f5ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 00:53:28 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B3=B5=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=BA=90=EC=8B=B1=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/common/config/CacheConfig.java | 35 +++++++++++++++++++ .../entity/mongodb/CacheType.java | 17 +++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/common/config/CacheConfig.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/CacheType.java diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/CacheConfig.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/CacheConfig.java new file mode 100644 index 0000000..be25a93 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/CacheConfig.java @@ -0,0 +1,35 @@ +package com.hyetaekon.hyetaekon.common.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.CacheType; + +import java.time.Duration; +import java.util.Arrays; + +@Configuration +@EnableCaching +public class CacheConfig { + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + + // 각 캐시 타입에 대한 설정 등록 + Arrays.stream(CacheType.values()) + .forEach(cacheType -> { + cacheManager.registerCustomCache(cacheType.getCacheName(), + Caffeine.newBuilder() + .recordStats() // 캐시 통계 기록 + .expireAfterWrite(Duration.ofHours(cacheType.getExpiredAfterWrite())) // 항목 만료 시간 + .maximumSize(cacheType.getMaximumSize()) // 최대 크기 + .build() + ); + }); + + return cacheManager; + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/CacheType.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/CacheType.java new file mode 100644 index 0000000..ecf3b36 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/CacheType.java @@ -0,0 +1,17 @@ +package com.hyetaekon.hyetaekon.publicservice.entity.mongodb; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CacheType { + SERVICE_AUTOCOMPLETE("serviceAutocomplete", 2, 1200), // 자동완성 캐시 + FILTER_OPTIONS("filterOptions", 24, 50), // 필터 옵션 캐시 (하루 유지) + POPULAR_SERVICES("popularServices", 1, 100), // 인기 서비스 캐시 + MATCHED_SERVICES("matchedServices", 2, 100); // 맞춤 서비스 캐시 + + private final String cacheName; + private final int expiredAfterWrite; // 시간(hour) 단위 + private final int maximumSize; // 최대 캐시 항목 수 +} \ No newline at end of file From f35144cbc98c7abc6d7a952d72a822c9d3a30024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 00:54:09 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=EC=9D=B5=20=EB=B0=8F=20=EC=A7=81=EC=97=85=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EC=97=90=20=EB=94=B0=EB=A5=B8=20incomelevel=20?= =?UTF-8?q?=EC=84=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/mongodb/IncomeLevel.java | 38 ++++++ .../mongodb/IncomeEstimationHandler.java | 120 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/IncomeLevel.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/IncomeEstimationHandler.java diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/IncomeLevel.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/IncomeLevel.java new file mode 100644 index 0000000..e67b89c --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/mongodb/IncomeLevel.java @@ -0,0 +1,38 @@ +package com.hyetaekon.hyetaekon.publicservice.entity.mongodb; + +public enum IncomeLevel { + VERY_LOW("0-50%", "LOW"), + LOW("51-75%", "MIDDLE_LOW"), + MEDIUM("76-100%", "MIDDLE"), + HIGH("101-200%", "MIDDLE_HIGH"), + VERY_HIGH("200%+", "HIGH"), + ANY("ANY", "ANY"); // 모든 소득수준 허용 + + private final String percentageRange; + private final String code; + + IncomeLevel(String percentageRange, String code) { + this.percentageRange = percentageRange; + this.code = code; + } + + // 퍼센트 범위에서 IncomeLevel 찾기 + public static IncomeLevel findByPercentageRange(String percentageRange) { + for (IncomeLevel level : values()) { + if (level.percentageRange.equals(percentageRange)) { + return level; + } + } + return null; + } + + // 코드에서 IncomeLevel 찾기 + public static IncomeLevel findByCode(String code) { + for (IncomeLevel level : values()) { + if (level.code.equals(code)) { + return level; + } + } + return null; + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/IncomeEstimationHandler.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/IncomeEstimationHandler.java new file mode 100644 index 0000000..8c5e4d7 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/mongodb/IncomeEstimationHandler.java @@ -0,0 +1,120 @@ +package com.hyetaekon.hyetaekon.publicservice.service.mongodb; + +import com.hyetaekon.hyetaekon.publicservice.entity.BusinessTypeEnum; +import com.hyetaekon.hyetaekon.publicservice.entity.OccupationEnum; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class IncomeEstimationHandler { + + public String determineIncomeLevelFromJob(String job) { + if (job == null || job.isEmpty()) { + return "MIDDLE"; // 기본값 + } + + // OccupationEnum에서 일치하는 항목 찾기 + for (OccupationEnum occupation : OccupationEnum.values()) { + if (occupation.getType().equals(job)) { + return getIncomeByOccupation(occupation); + } + } + + // BusinessTypeEnum에서 일치하는 항목 찾기 + for (BusinessTypeEnum businessType : BusinessTypeEnum.values()) { + if (businessType.getType().equals(job)) { + return getIncomeByBusinessType(businessType); + } + } + + // 일치하는 것이 없으면 일반 직업 분류로 추정 + return getIncomeByGenericJob(job); + } + + private String getIncomeByOccupation(OccupationEnum occupation) { + switch (occupation) { + case IS_ELEMENTARY_STUDENT: + case IS_MIDDLE_SCHOOL_STUDENT: + case IS_HIGH_SCHOOL_STUDENT: + case IS_JOB_SEEKER: + return "LOW"; + + case IS_UNIVERSITY_STUDENT: + return "MIDDLE_LOW"; + + case IS_FARMER: + case IS_FISHERMAN: + case IS_STOCK_BREEDER: + case IS_FORESTER: + return "MIDDLE"; + + case IS_WORKER: + return "MIDDLE_HIGH"; + + default: + return "MIDDLE"; + } + } + + private String getIncomeByBusinessType(BusinessTypeEnum businessType) { + switch (businessType) { + case IS_BUSINESS_HARDSHIP: + return "LOW"; + + case IS_STARTUP_PREPARATION: + case IS_FOOD_INDUSTRY: + return "MIDDLE_LOW"; + + case IS_BUSINESS_OPERATING: + case IS_OTHER_INDUSTRY: + case IS_OTHER_INDUSTRY_TYPE: + return "MIDDLE"; + + case IS_MANUFACTURING_INDUSTRY: + case IS_MANUFACTURING_INDUSTRY_TYPE: + case IS_SMALL_MEDIUM_ENTERPRISE: + return "MIDDLE_HIGH"; + + case IS_INFORMATION_TECHNOLOGY_INDUSTRY: + case IS_ORGANIZATION: + case IS_SOCIAL_WELFARE_INSTITUTION: + return "HIGH"; + + default: + return "MIDDLE"; + } + } + + private String getIncomeByGenericJob(String job) { + // 일반적인 직업 키워드 기반 추정 + String lowercaseJob = job.toLowerCase(); + + if (lowercaseJob.contains("학생") || lowercaseJob.contains("무직") || + lowercaseJob.contains("구직") || lowercaseJob.contains("실업")) { + return "LOW"; + } + + if (lowercaseJob.contains("알바") || lowercaseJob.contains("프리랜서") || + lowercaseJob.contains("인턴")) { + return "MIDDLE_LOW"; + } + + if (lowercaseJob.contains("공무원") || lowercaseJob.contains("직장인") || + lowercaseJob.contains("회사원")) { + return "MIDDLE"; + } + + if (lowercaseJob.contains("전문가") || lowercaseJob.contains("매니저") || + lowercaseJob.contains("관리자")) { + return "MIDDLE_HIGH"; + } + + if (lowercaseJob.contains("대표") || lowercaseJob.contains("임원") || + lowercaseJob.contains("의사") || lowercaseJob.contains("변호사")) { + return "HIGH"; + } + + return "MIDDLE"; // 기본값 + } +} From 248f025d67adc8a09b990ab5f2c21f07bd9f856a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 01:01:49 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20securityPath=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/hyetaekon/hyetaekon/common/config/SecurityPath.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java index 325e229..98abd07 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java @@ -14,7 +14,9 @@ public class SecurityPath { "/api/services/popular", "/api/services/category/*", "/api/services/detail/*", - "/api/public-data/serviceList/test" + "/api/public-data/serviceList/test", + "/api/mongo/services/search", + "/api/mongo/services/search/autocomplete" }; // hasRole("USER") From 12e8a293baf1e3283ed0e885428b6bed708172fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 02:36:44 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20mongodb=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=97=85=EC=84=9C=ED=8A=B8?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EB=8F=99=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/common/config/SecurityPath.java | 2 +- .../controller/DataAdminController.java | 21 +++++ .../repository/PublicDataMongoRepository.java | 7 ++ .../mongodb/service/CleanupOnService.java | 17 ++++ .../service/PublicDataMongoService.java | 80 ++++++++++++++----- .../service/PublicServiceDataServiceImpl.java | 18 ++--- .../mongodb/ServiceSearchClient.java | 20 ++++- .../service/PublicServiceHandler.java | 1 + 8 files changed, 132 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/controller/DataAdminController.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/CleanupOnService.java diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java index 98abd07..799ce8b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java @@ -33,7 +33,7 @@ public class SecurityPath { // hasRole("ADMIN") public static final String[] ADMIN_ENDPOINTS = { - "/api/admin/users/**", + "/api/admin/**", "/api/public-data/serviceDetailList", "/api/public-data/supportConditionsList", "/api/public-data/serviceList" diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/controller/DataAdminController.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/controller/DataAdminController.java new file mode 100644 index 0000000..b874862 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/controller/DataAdminController.java @@ -0,0 +1,21 @@ +package com.hyetaekon.hyetaekon.common.publicdata.mongodb.controller; + +import com.hyetaekon.hyetaekon.common.publicdata.mongodb.service.PublicDataMongoService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin/mongo") +@RequiredArgsConstructor +public class DataAdminController { + private final PublicDataMongoService publicDataMongoService; + + @PostMapping("/data/deduplicate") + public ResponseEntity deduplicateMongo() { + publicDataMongoService.deduplicateMongoDocuments(); + return ResponseEntity.ok("MongoDB 중복 데이터 정리가 시작되었습니다."); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/repository/PublicDataMongoRepository.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/repository/PublicDataMongoRepository.java index ac8f87d..afea3bf 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/repository/PublicDataMongoRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/repository/PublicDataMongoRepository.java @@ -2,12 +2,19 @@ import com.hyetaekon.hyetaekon.common.publicdata.mongodb.document.PublicData; import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; import org.springframework.stereotype.Repository; +import java.util.Collection; import java.util.List; import java.util.Optional; @Repository public interface PublicDataMongoRepository extends MongoRepository { Optional findByPublicServiceId(String publicServiceId); + List findAllByPublicServiceId(String publicServiceId); + List findAllByPublicServiceIdIn(Collection publicServiceIds); + + @Query(value = "{}", fields = "{ 'publicServiceId' : 1 }") + List findAllPublicServiceIds(); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/CleanupOnService.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/CleanupOnService.java new file mode 100644 index 0000000..6d55f8e --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/CleanupOnService.java @@ -0,0 +1,17 @@ +package com.hyetaekon.hyetaekon.common.publicdata.mongodb.service; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CleanupOnService { + private final PublicDataMongoService publicDataMongoService; + + @PostConstruct + public void cleanupOnStartup() { + // 서버 시작 시 중복 데이터 정리 실행 + publicDataMongoService.deduplicateMongoDocuments(); + } +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java index a03cf59..f5e0279 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java @@ -3,10 +3,14 @@ import com.hyetaekon.hyetaekon.common.publicdata.mongodb.document.PublicData; import com.hyetaekon.hyetaekon.common.publicdata.mongodb.repository.PublicDataMongoRepository; import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.index.Index; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Map; @@ -29,17 +33,6 @@ public PublicData saveToMongo(PublicService publicService) { return mongoRepository.save(document); } - /** - * 여러 공공서비스 엔티티를 MongoDB에 저장 - */ - public List saveAllToMongo(List publicServices) { - List documents = publicServices.stream() - .map(this::convertToDocument) - .collect(Collectors.toList()); - - return mongoRepository.saveAll(documents); - } - /** * 공공서비스 엔티티를 MongoDB 문서로 변환 */ @@ -109,29 +102,76 @@ public PublicData updateOrCreateDocument(PublicService publicService) { /** * 여러 서비스 문서 업데이트 또는 생성 */ - public List updateOrCreateBulkDocuments(List services) { - // 기존 ID 목록 가져오기 + public void updateOrCreateBulkDocuments(List services) { + // 모든 service ID 목록 List serviceIds = services.stream() .map(PublicService::getId) .collect(Collectors.toList()); - // ID에 해당하는 문서 맵 생성 - Map existingDocsMap = mongoRepository.findAllById(serviceIds).stream() - .collect(Collectors.toMap(PublicData::getPublicServiceId, doc -> doc, (a, b) -> a)); + // publicServiceId로 기존 문서 조회 (중요: findAllByPublicServiceId를 사용) + Map existingDocsMap = mongoRepository.findAllByPublicServiceIdIn(serviceIds).stream() + .collect(Collectors.toMap( + PublicData::getPublicServiceId, + doc -> doc, + (a, b) -> a // 중복 시 첫 번째 문서 유지 + )); - // 각 서비스 처리 + // 처리할 문서 준비 List docsToSave = services.stream() .map(service -> { PublicData doc = convertToDocument(service); if (existingDocsMap.containsKey(service.getId())) { - // 기존 문서 ID 유지 + // 기존 문서의 ID 유지 doc.setId(existingDocsMap.get(service.getId()).getId()); } return doc; }) .collect(Collectors.toList()); - // 일괄 저장 - return mongoRepository.saveAll(docsToSave); + // 저장 + mongoRepository.saveAll(docsToSave); + } + + @PostConstruct + public void ensureIndexes() { + mongoTemplate.indexOps("service_info").ensureIndex( + new Index().on("publicServiceId", Sort.Direction.ASC).unique() + ); + log.info("MongoDB 인덱스 설정 완료: publicServiceId (unique)"); + } + + @Transactional + public void deduplicateMongoDocuments() { + log.info("MongoDB 문서 중복 제거 시작"); + + // 1. 모든 publicServiceId 목록 조회 + List allServiceIds = mongoRepository.findAllPublicServiceIds(); + int totalProcessed = 0; + int totalRemoved = 0; + + // 2. 각 ID별로 처리 + for (String serviceId : allServiceIds) { + List duplicates = mongoRepository.findAllByPublicServiceId(serviceId); + + if (duplicates.size() > 1) { + // 가장 최근 문서를 남기고 나머지 삭제 + PublicData latestDoc = duplicates.get(0); // 또는 타임스탬프로 정렬하여 최신 문서 선택 + + // 나머지 중복 문서 삭제 + for (int i = 1; i < duplicates.size(); i++) { + mongoRepository.delete(duplicates.get(i)); + totalRemoved++; + } + } + + totalProcessed++; + if (totalProcessed % 1000 == 0) { + log.info("중복 제거 진행 중: {}/{} 처리, {}개 제거됨", + totalProcessed, allServiceIds.size(), totalRemoved); + } + } + + log.info("MongoDB 문서 중복 제거 완료: 총 {}개 중 {}개 중복 제거됨", + totalProcessed, totalRemoved); } } \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java index 8f23761..fd68168 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/service/PublicServiceDataServiceImpl.java @@ -240,8 +240,7 @@ public List upsertServiceData(List= 1000) { List savedEntities = publicServiceRepository.saveAll(entitiesToSave); - - publicDataMongoService.saveAllToMongo(savedEntities); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); entitiesToSave.clear(); } } @@ -249,8 +248,7 @@ public List upsertServiceData(List savedEntities = publicServiceRepository.saveAll(entitiesToSave); - - publicDataMongoService.saveAllToMongo(savedEntities); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); } log.info("공공서비스 목록 데이터 {}건 저장 완료", validatedData.size()); @@ -296,8 +294,7 @@ public List upsertServiceDetailData(List= 1000) { List savedEntities = publicServiceRepository.saveAll(entitiesToSave); - - publicDataMongoService.saveAllToMongo(savedEntities); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); entitiesToSave.clear(); } } @@ -305,8 +302,7 @@ public List upsertServiceDetailData(List savedEntities = publicServiceRepository.saveAll(entitiesToSave); - - publicDataMongoService.saveAllToMongo(savedEntities); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); } log.info("공공서비스 상세정보 데이터 {}건 저장 완료", validatedData.size()); @@ -356,8 +352,7 @@ public List upsertSupportConditionsData(Lis // 배치 처리 최적화: 1000개 단위로 저장 if (entitiesToSave.size() >= 1000) { List savedEntities = publicServiceRepository.saveAll(entitiesToSave); - - publicDataMongoService.saveAllToMongo(savedEntities); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); entitiesToSave.clear(); } } @@ -365,8 +360,7 @@ public List upsertSupportConditionsData(Lis // 나머지 데이터 저장 if (!entitiesToSave.isEmpty()) { List savedEntities = publicServiceRepository.saveAll(entitiesToSave); - - publicDataMongoService.saveAllToMongo(savedEntities); + publicDataMongoService.updateOrCreateBulkDocuments(savedEntities); } log.info("공공서비스 지원조건 데이터 {}건 저장 완료", validatedData.size()); diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java index 805f852..68ae2e4 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/ServiceSearchClient.java @@ -1,6 +1,7 @@ package com.hyetaekon.hyetaekon.publicservice.repository.mongodb; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.bson.Document; import org.springframework.data.domain.Pageable; import org.springframework.data.mongodb.core.MongoTemplate; @@ -14,9 +15,12 @@ import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.ServiceInfo; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; +@Slf4j @Component @RequiredArgsConstructor public class ServiceSearchClient { @@ -308,7 +312,21 @@ private ServiceSearchResultDto processResults(AggregationResults resul total = count != null ? count.longValue() : 0L; } - return ServiceSearchResultDto.of(searchResults, total, pageable); + // 중복 제거: publicServiceId가 같은 경우 하나만 유지 + Map uniqueResults = new LinkedHashMap<>(); + for (ServiceInfo info : searchResults) { + uniqueResults.putIfAbsent(info.getPublicServiceId(), info); + } + + List dedupedResults = new ArrayList<>(uniqueResults.values()); + + // 중복 제거 로깅 + int removedDuplicates = searchResults.size() - dedupedResults.size(); + if (removedDuplicates > 0) { + log.warn("검색 결과에서 중복된 항목 {}개가 제거되었습니다.", removedDuplicates); + } + + return ServiceSearchResultDto.of(dedupedResults, total, pageable); } // 검색어 자동완성 diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java index 0cae309..4cd42b7 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/service/PublicServiceHandler.java @@ -239,6 +239,7 @@ public Map> getFilterOptions() { public void refreshFilterOptions() { } + // 내가 북마크한 서비스 목록 조회 public Page getBookmarkedServices(Long userId, Pageable pageable) { Page bookmarkedServices = publicServiceRepository.findByBookmarks_User_Id(userId, pageable); From fd5cd351f2f3812397cb9fef30963b966c2e8a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 02:45:45 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20mongodb=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=97=85=EC=84=9C=ED=8A=B8?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EB=8F=99=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mongodb/service/CleanupOnService.java | 17 ---------- .../service/PublicDataMongoService.java | 32 ++++++++++++++++--- 2 files changed, 28 insertions(+), 21 deletions(-) delete mode 100644 src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/CleanupOnService.java diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/CleanupOnService.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/CleanupOnService.java deleted file mode 100644 index 6d55f8e..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/CleanupOnService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.hyetaekon.hyetaekon.common.publicdata.mongodb.service; - -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class CleanupOnService { - private final PublicDataMongoService publicDataMongoService; - - @PostConstruct - public void cleanupOnStartup() { - // 서버 시작 시 중복 데이터 정리 실행 - publicDataMongoService.deduplicateMongoDocuments(); - } -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java index f5e0279..da6f5fa 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java @@ -9,6 +9,7 @@ import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.index.Index; +import org.springframework.data.mongodb.core.index.IndexInfo; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -132,12 +133,35 @@ public void updateOrCreateBulkDocuments(List services) { mongoRepository.saveAll(docsToSave); } + // 첫 실행 시에만 중복 제거 및 인덱스 생성 @PostConstruct public void ensureIndexes() { - mongoTemplate.indexOps("service_info").ensureIndex( - new Index().on("publicServiceId", Sort.Direction.ASC).unique() - ); - log.info("MongoDB 인덱스 설정 완료: publicServiceId (unique)"); + try { + // 인덱스 존재 여부 확인 + boolean indexExists = false; + for (IndexInfo indexInfo : mongoTemplate.indexOps("service_info").getIndexInfo()) { + if ("publicServiceId_1".equals(indexInfo.getName())) { + indexExists = true; + break; + } + } + + if (!indexExists) { + // 인덱스가 없을 때만 중복 제거 및 인덱스 생성 수행 + log.info("MongoDB 중복 제거 및 인덱스 생성 시작"); + deduplicateMongoDocuments(); + + mongoTemplate.indexOps("service_info").ensureIndex( + new Index().on("publicServiceId", Sort.Direction.ASC).unique() + ); + log.info("MongoDB 인덱스 설정 완료: publicServiceId (unique)"); + } else { + log.info("MongoDB 인덱스 이미 존재함: publicServiceId (unique)"); + } + } catch (Exception e) { + log.error("MongoDB 인덱스 설정 중 오류 발생: {}", e.getMessage()); + // 인덱스 생성 실패해도 애플리케이션은 시작되도록 함 + } } @Transactional From 552b2e47553d922016673b4acb7d230ce772bd0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 18:25:50 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20mongodb=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=9C=A0=EB=8B=88=ED=81=AC=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=9E=AC=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/DataAdminController.java | 21 ----- .../service/PublicDataMongoService.java | 85 +++++++++++++------ 2 files changed, 60 insertions(+), 46 deletions(-) delete mode 100644 src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/controller/DataAdminController.java diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/controller/DataAdminController.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/controller/DataAdminController.java deleted file mode 100644 index b874862..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/controller/DataAdminController.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.hyetaekon.hyetaekon.common.publicdata.mongodb.controller; - -import com.hyetaekon.hyetaekon.common.publicdata.mongodb.service.PublicDataMongoService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/admin/mongo") -@RequiredArgsConstructor -public class DataAdminController { - private final PublicDataMongoService publicDataMongoService; - - @PostMapping("/data/deduplicate") - public ResponseEntity deduplicateMongo() { - publicDataMongoService.deduplicateMongoDocuments(); - return ResponseEntity.ok("MongoDB 중복 데이터 정리가 시작되었습니다."); - } -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java index da6f5fa..8cd794a 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/publicdata/mongodb/service/PublicDataMongoService.java @@ -8,8 +8,13 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; import org.springframework.data.mongodb.core.index.Index; import org.springframework.data.mongodb.core.index.IndexInfo; +import org.bson.Document; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -137,26 +142,42 @@ public void updateOrCreateBulkDocuments(List services) { @PostConstruct public void ensureIndexes() { try { - // 인덱스 존재 여부 확인 - boolean indexExists = false; + // 1. 기존 인덱스 확인 + boolean hasUniqueIndex = false; for (IndexInfo indexInfo : mongoTemplate.indexOps("service_info").getIndexInfo()) { if ("publicServiceId_1".equals(indexInfo.getName())) { - indexExists = true; + hasUniqueIndex = indexInfo.isUnique(); break; } } - if (!indexExists) { - // 인덱스가 없을 때만 중복 제거 및 인덱스 생성 수행 - log.info("MongoDB 중복 제거 및 인덱스 생성 시작"); + // 2. 유니크 인덱스가 없는 경우만 처리 + if (!hasUniqueIndex) { + // 2.1 일반 인덱스 존재 여부 확인 + boolean hasNonUniqueIndex = false; + for (IndexInfo indexInfo : mongoTemplate.indexOps("service_info").getIndexInfo()) { + if ("publicServiceId_1".equals(indexInfo.getName()) && !indexInfo.isUnique()) { + hasNonUniqueIndex = true; + break; + } + } + + // 2.2 일반 인덱스가 있다면 삭제 + if (hasNonUniqueIndex) { + mongoTemplate.indexOps("service_info").dropIndex("publicServiceId_1"); + log.info("기존 비유니크 인덱스 삭제: publicServiceId_1"); + } + + // 2.3 최적화된 중복 제거 실행 deduplicateMongoDocuments(); + // 2.4 유니크 인덱스 생성 mongoTemplate.indexOps("service_info").ensureIndex( new Index().on("publicServiceId", Sort.Direction.ASC).unique() ); log.info("MongoDB 인덱스 설정 완료: publicServiceId (unique)"); } else { - log.info("MongoDB 인덱스 이미 존재함: publicServiceId (unique)"); + log.info("MongoDB 유니크 인덱스 이미 존재함: publicServiceId_1"); } } catch (Exception e) { log.error("MongoDB 인덱스 설정 중 오류 발생: {}", e.getMessage()); @@ -166,36 +187,50 @@ public void ensureIndexes() { @Transactional public void deduplicateMongoDocuments() { - log.info("MongoDB 문서 중복 제거 시작"); + log.info("MongoDB 문서 중복 제거 시작 (최적화 버전)"); + + // 모든 publicServiceId와 해당 문서 ID를 그룹화하여 한 번에 조회 + AggregationResults results = mongoTemplate.aggregate( + Aggregation.newAggregation( + Aggregation.group("publicServiceId") + .first("_id").as("firstId") + .push("_id").as("allIds") + .count().as("count") + ), + "service_info", + Document.class + ); - // 1. 모든 publicServiceId 목록 조회 - List allServiceIds = mongoRepository.findAllPublicServiceIds(); int totalProcessed = 0; int totalRemoved = 0; - // 2. 각 ID별로 처리 - for (String serviceId : allServiceIds) { - List duplicates = mongoRepository.findAllByPublicServiceId(serviceId); - - if (duplicates.size() > 1) { - // 가장 최근 문서를 남기고 나머지 삭제 - PublicData latestDoc = duplicates.get(0); // 또는 타임스탬프로 정렬하여 최신 문서 선택 - - // 나머지 중복 문서 삭제 - for (int i = 1; i < duplicates.size(); i++) { - mongoRepository.delete(duplicates.get(i)); - totalRemoved++; + for (Document doc : results.getMappedResults()) { + int count = doc.getInteger("count"); + + // 중복이 있는 경우에만 처리 + if (count > 1) { + String publicServiceId = doc.getString("_id"); + List allIds = (List) doc.get("allIds"); + Object firstId = doc.get("firstId"); + + // 첫 번째 문서를 제외한 나머지 문서 삭제 + for (int i = 0; i < allIds.size(); i++) { + Object currentId = allIds.get(i); + if (!currentId.equals(firstId)) { + mongoTemplate.remove(Query.query(Criteria.where("_id").is(currentId)), "service_info"); + totalRemoved++; + } } } totalProcessed++; if (totalProcessed % 1000 == 0) { - log.info("중복 제거 진행 중: {}/{} 처리, {}개 제거됨", - totalProcessed, allServiceIds.size(), totalRemoved); + log.info("중복 제거 진행 중: {}/{} 그룹 처리, {}개 제거됨", + totalProcessed, results.getMappedResults().size(), totalRemoved); } } - log.info("MongoDB 문서 중복 제거 완료: 총 {}개 중 {}개 중복 제거됨", + log.info("MongoDB 문서 중복 제거 완료: 총 {}개 그룹 중 {}개 중복 제거됨", totalProcessed, totalRemoved); } } \ No newline at end of file From 44893e702cffe55646fb153dd811dced4d8cd739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Thu, 1 May 2025 19:33:57 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20mongodb=20=EC=A0=91=EC=86=8D=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=97=B0=EC=9E=A5,=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=ED=95=84=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java | 2 +- .../com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java | 2 +- .../com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java | 2 +- .../com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java | 2 +- .../com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java | 2 +- src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java | 2 +- src/main/resources/application.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java index 0767fbf..8d5cf99 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/MyPostListResponseDto.java @@ -15,7 +15,7 @@ public class MyPostListResponseDto { private Long postId; private String title; private String content; - private Long nickName; // 작성자 닉네임 + private String nickName; // 작성자 닉네임 private LocalDateTime createdAt; private int recommendCnt; private int commentCnt; diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java index e542bd5..c574aac 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java @@ -12,7 +12,7 @@ @NoArgsConstructor @AllArgsConstructor public class PostCreateRequestDto { - private Long nickName; + private String nickName; private String title; private String content; private LocalDateTime createdAt; diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java index a4ccc5a..2f75f66 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java @@ -13,7 +13,7 @@ @AllArgsConstructor public class PostDetailResponseDto { private Long postId; - private Long nickName; // 작성자 닉네임 + private String nickName; // 작성자 닉네임 private String title; private String content; private LocalDateTime createdAt; diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java index d0b4c98..037592c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java @@ -12,7 +12,7 @@ public class PostListResponseDto { private Long postId; private String title; - private Long nickName; // 작성자 닉네임 + private String nickName; // 작성자 닉네임 private LocalDateTime createdAt; private int viewCnt; private String postType; diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java index 8d6419c..600b6a2 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java @@ -12,7 +12,7 @@ @NoArgsConstructor @AllArgsConstructor public class PostUpdateRequestDto { - private Long nickName; + private String nickName; private String title; private String content; // private LocalDateTime createdAt; diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java index ce3c8fa..c64df29 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java @@ -48,7 +48,7 @@ public class Post { private int recommendCnt = 0; // 추천수 @Builder.Default - @Column(name = "view_cnt") + @Column(name = "view_count") private int viewCnt = 0; // 조회수 // TODO: 댓글 생성/수정 시 업데이트 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ea60cb6..7fc6fde 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,7 +1,7 @@ spring: data: mongodb: - uri: mongodb+srv://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@${MONGODB_URL}/${MONGODB_NAME}?retryWrites=true&w=majority&appName=HyetaekOn + uri: mongodb+srv://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@${MONGODB_URL}/${MONGODB_NAME}?retryWrites=true&w=majority&serverSelectionTimeoutMS=30000&appName=HyetaekOn auto-index-creation: true redis: port: 6379