From d5da136d5113e5ff03ca5d9abb0ba0f2ddf30054 Mon Sep 17 00:00:00 2001 From: Yoonchulchung Date: Wed, 11 Feb 2026 16:25:17 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor(#372):=20Redisearch=EB=A1=9C=20?= =?UTF-8?q?=EB=A7=A4=EC=B9=AD=20=EC=95=8C=EA=B3=A0=EB=A6=AC=EC=A6=98=20?= =?UTF-8?q?=EB=B3=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/generators/redis_matching_generator.py | 22 +++ data/main.py | 34 ++-- .../application/service/MatchServiceImpl.java | 103 +++++++++--- .../redis/RedisDocumentHelper.java | 149 ++++++++++++++++++ .../dto/response/MatchResponseDto.java | 1 + 5 files changed, 268 insertions(+), 41 deletions(-) diff --git a/data/generators/redis_matching_generator.py b/data/generators/redis_matching_generator.py index 1658c0fa..439297b7 100755 --- a/data/generators/redis_matching_generator.py +++ b/data/generators/redis_matching_generator.py @@ -371,9 +371,20 @@ def create_search_indexes(self): NumericField('$.brandId', as_name='brandId'), TagField('$.brandName', as_name='brandName'), TagField('$.categories[*]', as_name='categories'), + # 이산형 태그 TagField('$.preferredFashionTags[*]', as_name='preferredFashionTags'), TagField('$.preferredBeautyTags[*]', as_name='preferredBeautyTags'), TagField('$.preferredContentTags[*]', as_name='preferredContentTags'), + # 연속형 태그 + NumericField('$.minCreatorHeight', as_name='minCreatorHeight'), + NumericField('$.maxCreatorHeight', as_name='maxCreatorHeight'), + TagField('$.preferredBodyTypeTags[*]', as_name='preferredBodyTypeTags'), + TagField('$.preferredTopSizeTags[*]', as_name='preferredTopSizeTags'), + TagField('$.preferredBottomSizeTags[*]', as_name='preferredBottomSizeTags'), + TagField('$.preferredContentsAverageViewsTags[*]', as_name='preferredContentsAverageViewsTags'), + TagField('$.preferredContentsAgeTags[*]', as_name='preferredContentsAgeTags'), + TagField('$.preferredContentsGenderTags[*]', as_name='preferredContentsGenderTags'), + TagField('$.preferredContentsLengthTags[*]', as_name='preferredContentsLengthTags'), ) }, { @@ -382,9 +393,20 @@ def create_search_indexes(self): 'schema': ( NumericField('$.campaignId', as_name='campaignId'), TagField('$.categories[*]', as_name='categories'), + # 이산형 태그 TagField('$.preferredFashionTags[*]', as_name='preferredFashionTags'), TagField('$.preferredBeautyTags[*]', as_name='preferredBeautyTags'), TagField('$.preferredContentTags[*]', as_name='preferredContentTags'), + # 연속형 태그 + NumericField('$.minCreatorHeight', as_name='minCreatorHeight'), + NumericField('$.maxCreatorHeight', as_name='maxCreatorHeight'), + TagField('$.preferredBodyTypeTags[*]', as_name='preferredBodyTypeTags'), + TagField('$.preferredTopSizeTags[*]', as_name='preferredTopSizeTags'), + TagField('$.preferredBottomSizeTags[*]', as_name='preferredBottomSizeTags'), + TagField('$.preferredContentsAverageViewsTags[*]', as_name='preferredContentsAverageViewsTags'), + TagField('$.preferredContentsAgeTags[*]', as_name='preferredContentsAgeTags'), + TagField('$.preferredContentsGenderTags[*]', as_name='preferredContentsGenderTags'), + TagField('$.preferredContentsLengthTags[*]', as_name='preferredContentsLengthTags'), ) }, { diff --git a/data/main.py b/data/main.py index f71b4aeb..89f81509 100644 --- a/data/main.py +++ b/data/main.py @@ -104,9 +104,9 @@ def _create_master_account(self): def generate_all(self, user_count=50, brand_count=20, campaign_count=30, room_count=20, messages_per_room=10, applies_per_campaign=3, reset=True): - if reset: - self._clear_existing_data() - self._create_master_account() + # if reset: + # self._clear_existing_data() + # self._create_master_account() print("[시작] 더미 데이터 생성 시작...\n") print(f"생성할 데이터:") @@ -117,26 +117,26 @@ def generate_all(self, user_count=50, brand_count=20, campaign_count=30, try: # 시드 데이터 먼저 생성 (태그, 약관 등) - seed_gen = SeedGenerator(self.connection) - seed_gen.generate_all() + # seed_gen = SeedGenerator(self.connection) + # seed_gen.generate_all() - user_gen = UserGenerator(self.connection) - user_gen.generate_all(user_count) + # user_gen = UserGenerator(self.connection) + # user_gen.generate_all(user_count) - brand_gen = BrandGenerator(self.connection) - brand_gen.generate_all(brand_count) + # brand_gen = BrandGenerator(self.connection) + # brand_gen.generate_all(brand_count) - campaign_gen = CampaignGenerator(self.connection) - campaign_gen.generate_all(campaign_count) + # campaign_gen = CampaignGenerator(self.connection) + # campaign_gen.generate_all(campaign_count) - # 캠페인 생성 후 협찬 데이터 생성 - brand_gen.generate_sponsors() + # # 캠페인 생성 후 협찬 데이터 생성 + # brand_gen.generate_sponsors() - tag_gen = TagGenerator(self.connection) - tag_gen.generate_all() + # tag_gen = TagGenerator(self.connection) + # tag_gen.generate_all() - business_gen = BusinessGenerator(self.connection) - business_gen.generate_all(applies_per_campaign) + # business_gen = BusinessGenerator(self.connection) + # business_gen.generate_all(applies_per_campaign) #chat_gen = ChatGenerator(self.connection) #chat_gen.generate_all(room_count, messages_per_room) diff --git a/src/main/java/com/example/RealMatch/match/application/service/MatchServiceImpl.java b/src/main/java/com/example/RealMatch/match/application/service/MatchServiceImpl.java index 3639dbba..2dde83df 100644 --- a/src/main/java/com/example/RealMatch/match/application/service/MatchServiceImpl.java +++ b/src/main/java/com/example/RealMatch/match/application/service/MatchServiceImpl.java @@ -67,6 +67,13 @@ public class MatchServiceImpl implements MatchService { private static final Logger LOG = LoggerFactory.getLogger(MatchServiceImpl.class); private static final int TOP_MATCH_COUNT = 10; + private static final Map> USER_TYPE_TAG_MAP = Map.of( + "유연한 연출가", List.of("기획중심", "구조탄탄", "디테일중심"), + "섬세한 설계자", List.of("연출유연", "트렌드 적용", "브랜드 이해도"), + "재치있는 스토리텔러", List.of("스토리감각", "소통형콘텐츠", "일상공감"), + "도전적인 실험가", List.of("콘셉트실험", "포맷도전", "새로움 추구") + ); + private final RedisDocumentHelper redisDocumentHelper; private final BrandRepository brandRepository; @@ -87,13 +94,9 @@ public class MatchServiceImpl implements MatchService { private final UserRepository userRepository; private final UserMatchingDetailRepository userMatchingDetailRepository; - /** - * 매칭 검사는 다음을 하나의 트랜잭션으로 처리한다. - * - 기존 UserMatchingDetail 폐기 - * - 새 UserMatchingDetail 생성 (creatorType + snsUrl만) - * - TagUser 전량 교체 저장 (나머지 정보 전부 user_tag로) - * - 브랜드/캠페인 매칭 히스토리 갱신 - */ + // ******* // + // 매칭 검사 // + // ******* // @Override @Transactional public MatchResponseDto match(Long userId, MatchRequestDto requestDto) { @@ -125,15 +128,18 @@ public MatchResponseDto match(Long userId, MatchRequestDto requestDto) { List campaignResults = findMatchingCampaignResults(userDoc, userId); saveMatchHistory(userId, brandResults, campaignResults); - String username = userRepository.findById(userId) - .map(User::getName) + String userNickname = userRepository.findById(userId) + .map(User::getNickname) .orElse("사용자"); + List userTypeTag = USER_TYPE_TAG_MAP.getOrDefault(userType, List.of()); + return MatchResponseDto.builder() - .username(username) + .username(userNickname) .userType(userType) .userTypeImage("https://ui-avatars.com/api/?name=" + userType + "&background=6366f1&color=fff&size=200") .typeTag(typeTag) + .userTypeTag(userTypeTag) .highMatchingBrandList(brandListDto) .build(); } @@ -353,14 +359,63 @@ private String determineUserType(UserTagDocument userDoc) { int fashionCount = safeSize(userDoc.getFashionTags()); int beautyCount = safeSize(userDoc.getBeautyTags()); int contentCount = safeSize(userDoc.getContentTags()); + int total = fashionCount + beautyCount + contentCount; - if (fashionCount >= beautyCount && fashionCount >= contentCount) { + if (total == 0) { return "유연한 연출가"; - } else if (beautyCount >= contentCount) { - return "트렌드 리더"; - } else { - return "콘텐츠 크리에이터"; } + + // 체형/사이즈 디테일 입력 여부 + boolean hasBodyDetail = userDoc.getHeightTag() != null + || userDoc.getBodyTypeTag() != null + || userDoc.getTopSizeTag() != null + || userDoc.getBottomSizeTag() != null; + + // SNS 시청자 정보 입력 여부 + boolean hasSnsAudience = safeSize(userDoc.getContentsAgeTags()) > 0 + || safeSize(userDoc.getContentsGenderTags()) > 0 + || safeSize(userDoc.getContentsLengthTags()) > 0 + || safeSize(userDoc.getAverageContentsViewsTags()) > 0; + + // 각 카테고리가 전체에서 차지하는 비율 + double fashionRatio = (double) fashionCount / total; + double beautyRatio = (double) beautyCount / total; + double contentRatio = (double) contentCount / total; + + // 3개 카테고리 중 비어있지 않은 카테고리 수 + int filledCategories = (fashionCount > 0 ? 1 : 0) + + (beautyCount > 0 ? 1 : 0) + + (contentCount > 0 ? 1 : 0); + + // 도전적인 실험가: 3개 카테고리 모두 보유 + 특정 카테고리 쏠림 없음 (최대 비율 50% 이하) + double maxRatio = Math.max(fashionRatio, Math.max(beautyRatio, contentRatio)); + if (filledCategories == 3 && maxRatio <= 0.5) { + return "도전적인 실험가"; + } + + // 재치있는 스토리텔러: 콘텐츠 태그가 가장 많거나, SNS 시청자 정보가 있으면서 콘텐츠 비중이 높은 경우 + if (contentRatio >= fashionRatio && contentRatio >= beautyRatio) { + if (hasSnsAudience || contentCount >= beautyCount + fashionCount) { + return "재치있는 스토리텔러"; + } + } + + // 유연한 연출가: 패션 태그가 가장 많고 체형 디테일까지 입력한 경우 + if (fashionRatio >= beautyRatio && fashionRatio >= contentRatio && hasBodyDetail) { + return "유연한 연출가"; + } + + // 섬세한 설계자: 뷰티 비중이 높거나 패션+뷰티 균형 + if (beautyRatio >= fashionRatio && beautyRatio >= contentRatio) { + return "섬세한 설계자"; + } + + // 패션이 높지만 체형 디테일 없음 → 섬세한 설계자 (트렌드/브랜드 중심) + if (fashionRatio >= contentRatio && !hasBodyDetail) { + return "섬세한 설계자"; + } + + return "유연한 연출가"; } private List determineTypeTags(UserTagDocument userDoc) { @@ -385,10 +440,10 @@ private List determineTypeTags(UserTagDocument userDoc) { // Redis에서 매칭 요청 // // **************** // private List findMatchingBrandResults(UserTagDocument userDoc, Long userId) { - List allBrandDocs = redisDocumentHelper.findAllBrandTagDocuments(); + List candidateBrandDocs = redisDocumentHelper.findCandidateBrands(userDoc); - if (allBrandDocs.isEmpty()) { - LOG.info("No brand tag documents found in Redis"); + if (candidateBrandDocs.isEmpty()) { + LOG.info("No candidate brand documents found via FT.SEARCH"); return List.of(); } @@ -398,7 +453,7 @@ private List findMatchingBrandResults(UserTagDocument userDoc, Set recruitingBrandIds = getRecruitingBrandIds(); - return allBrandDocs.stream() + return candidateBrandDocs.stream() .map(brandDoc -> new BrandMatchResult( brandDoc, MatchScoreCalculator.calculateBrandMatchScore(userDoc, brandDoc), @@ -411,10 +466,10 @@ private List findMatchingBrandResults(UserTagDocument userDoc, } private List findMatchingCampaignResults(UserTagDocument userDoc, Long userId) { - List allCampaignDocs = redisDocumentHelper.findAllCampaignTagDocuments(); + List candidateCampaignDocs = redisDocumentHelper.findCandidateCampaigns(userDoc); - if (allCampaignDocs.isEmpty()) { - LOG.info("No campaign tag documents found in Redis"); + if (candidateCampaignDocs.isEmpty()) { + LOG.info("No candidate campaign documents found via FT.SEARCH"); return List.of(); } @@ -422,12 +477,12 @@ private List findMatchingCampaignResults(UserTagDocument us .map(like -> like.getCampaign().getId()) .collect(Collectors.toSet()); - List campaignIds = allCampaignDocs.stream() + List campaignIds = candidateCampaignDocs.stream() .map(CampaignTagDocument::getCampaignId) .toList(); Map applyCountMap = getApplyCountMapForCampaignIds(campaignIds); - return allCampaignDocs.stream() + return candidateCampaignDocs.stream() .filter(campaignDoc -> campaignDoc.getRecruitEndDate() == null || campaignDoc.getRecruitEndDate().isAfter(LocalDateTime.now())) .map(campaignDoc -> new CampaignMatchResult( diff --git a/src/main/java/com/example/RealMatch/match/infrastructure/redis/RedisDocumentHelper.java b/src/main/java/com/example/RealMatch/match/infrastructure/redis/RedisDocumentHelper.java index de49d0a7..26d8d6e2 100644 --- a/src/main/java/com/example/RealMatch/match/infrastructure/redis/RedisDocumentHelper.java +++ b/src/main/java/com/example/RealMatch/match/infrastructure/redis/RedisDocumentHelper.java @@ -1,8 +1,11 @@ package com.example.RealMatch.match.infrastructure.redis; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Set; +import java.util.StringJoiner; +import java.util.stream.Collectors; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.core.RedisTemplate; @@ -10,6 +13,7 @@ import com.example.RealMatch.match.infrastructure.redis.document.BrandTagDocument; import com.example.RealMatch.match.infrastructure.redis.document.CampaignTagDocument; +import com.example.RealMatch.match.infrastructure.redis.document.UserTagDocument; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -26,14 +30,159 @@ public class RedisDocumentHelper { private static final String BRAND_PREFIX = "com.example.RealMatch.match.infrastructure.redis.document.BrandTagDocument:"; private static final String CAMPAIGN_PREFIX = "com.example.RealMatch.match.infrastructure.redis.document.CampaignTagDocument:"; + private static final String BRAND_INDEX = "com.example.RealMatch.match.infrastructure.redis.document.BrandTagDocumentIdx"; + private static final String CAMPAIGN_INDEX = "com.example.RealMatch.match.infrastructure.redis.document.CampaignTagDocumentIdx"; + + private static final int SEARCH_LIMIT = 200; + + public List findCandidateBrands(UserTagDocument userDoc) { + String query = buildTagOverlapQuery( + userDoc.getFashionTags(), "preferredFashionTags", + userDoc.getBeautyTags(), "preferredBeautyTags", + userDoc.getContentTags(), "preferredContentTags" + ); + return executeSearch(BRAND_INDEX, query, BrandTagDocument.class); + } + + public List findCandidateCampaigns(UserTagDocument userDoc) { + String query = buildTagOverlapQuery( + userDoc.getFashionTags(), "preferredFashionTags", + userDoc.getBeautyTags(), "preferredBeautyTags", + userDoc.getContentTags(), "preferredContentTags" + ); + return executeSearch(CAMPAIGN_INDEX, query, CampaignTagDocument.class); + } + + @Deprecated public List findAllBrandTagDocuments() { return findAllDocuments(BRAND_PREFIX, BrandTagDocument.class); } + @Deprecated public List findAllCampaignTagDocuments() { return findAllDocuments(CAMPAIGN_PREFIX, CampaignTagDocument.class); } + private String buildTagOverlapQuery( + Set fashionTags, String fashionField, + Set beautyTags, String beautyField, + Set contentTags, String contentField + ) { + List clauses = new ArrayList<>(); + + String fashionClause = buildTagClause(fashionTags, fashionField); + if (fashionClause != null) clauses.add(fashionClause); + + String beautyClause = buildTagClause(beautyTags, beautyField); + if (beautyClause != null) clauses.add(beautyClause); + + String contentClause = buildTagClause(contentTags, contentField); + if (contentClause != null) clauses.add(contentClause); + + if (clauses.isEmpty()) { + return "*"; + } + + return String.join(" | ", clauses); + } + + private String buildTagClause(Set tags, String fieldName) { + if (tags == null || tags.isEmpty()) { + return null; + } + String tagValues = tags.stream() + .map(String::valueOf) + .collect(Collectors.joining("|")); + return "(@" + fieldName + ":{" + tagValues + "})"; + } + + private List executeSearch(String indexName, String query, Class clazz) { + List results = new ArrayList<>(); + + try { + Object rawResult = redisTemplate.execute((RedisConnection connection) -> + connection.execute("FT.SEARCH", + indexName.getBytes(), + query.getBytes(), + "LIMIT".getBytes(), + "0".getBytes(), + String.valueOf(SEARCH_LIMIT).getBytes() + ) + ); + + if (rawResult == null) { + log.debug("FT.SEARCH returned null. index={}, query={}", indexName, query); + return results; + } + + results = parseFtSearchResult(rawResult, clazz); + log.info("FT.SEARCH found {} candidates. index={}, query={}", results.size(), indexName, query); + } catch (Exception e) { + log.error("FT.SEARCH failed. index={}, query={}, error={}", indexName, query, e.getMessage()); + // FT.SEARCH 실패 시 레거시 방식으로 fallback + log.warn("Falling back to KEYS scan for index: {}", indexName); + if (BRAND_INDEX.equals(indexName)) { + @SuppressWarnings("unchecked") + List fallback = (List) findAllDocuments(BRAND_PREFIX, BrandTagDocument.class); + return fallback; + } else { + @SuppressWarnings("unchecked") + List fallback = (List) findAllDocuments(CAMPAIGN_PREFIX, CampaignTagDocument.class); + return fallback; + } + } + + return results; + } + + @SuppressWarnings("unchecked") + private List parseFtSearchResult(Object rawResult, Class clazz) { + List results = new ArrayList<>(); + + if (!(rawResult instanceof List resultList) || resultList.size() < 2) { + return results; + } + + // 첫 번째 요소는 총 결과 수 (Long) + // 이후 [docKey, [fields...]] 쌍으로 반복 + for (int i = 1; i < resultList.size(); i += 2) { + if (i + 1 >= resultList.size()) break; + + Object fieldsObj = resultList.get(i + 1); + if (!(fieldsObj instanceof List fields)) continue; + + // JSON 인덱스: fields = ["$", "{...json...}"] + for (int j = 0; j < fields.size() - 1; j += 2) { + String fieldName = decodeBytes(fields.get(j)); + if ("$".equals(fieldName)) { + String jsonString = decodeBytes(fields.get(j + 1)); + if (jsonString != null) { + try { + T doc = objectMapper.readValue(jsonString, clazz); + if (doc != null) { + results.add(doc); + } + } catch (Exception e) { + log.warn("Failed to deserialize FT.SEARCH result: {}", e.getMessage()); + } + } + } + } + } + + return results; + } + + private String decodeBytes(Object obj) { + if (obj instanceof byte[] bytes) { + return new String(bytes); + } + if (obj instanceof String s) { + return s; + } + return null; + } + private List findAllDocuments(String prefix, Class clazz) { List results = new ArrayList<>(); diff --git a/src/main/java/com/example/RealMatch/match/presentation/dto/response/MatchResponseDto.java b/src/main/java/com/example/RealMatch/match/presentation/dto/response/MatchResponseDto.java index 1734857f..f971914c 100644 --- a/src/main/java/com/example/RealMatch/match/presentation/dto/response/MatchResponseDto.java +++ b/src/main/java/com/example/RealMatch/match/presentation/dto/response/MatchResponseDto.java @@ -17,6 +17,7 @@ public class MatchResponseDto { private String userType; private String userTypeImage; private List typeTag; + private List userTypeTag; private HighMatchingBrandListDto highMatchingBrandList; @Getter From 7fa72b1c1e9dada3896eeea3c331fb4d4551589c Mon Sep 17 00:00:00 2001 From: Yoonchulchung Date: Wed, 11 Feb 2026 16:33:13 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=B2=B4=ED=81=AC=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../redis/RedisDocumentHelper.java | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/example/RealMatch/match/infrastructure/redis/RedisDocumentHelper.java b/src/main/java/com/example/RealMatch/match/infrastructure/redis/RedisDocumentHelper.java index 26d8d6e2..adba817f 100644 --- a/src/main/java/com/example/RealMatch/match/infrastructure/redis/RedisDocumentHelper.java +++ b/src/main/java/com/example/RealMatch/match/infrastructure/redis/RedisDocumentHelper.java @@ -1,10 +1,8 @@ package com.example.RealMatch.match.infrastructure.redis; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Set; -import java.util.StringJoiner; import java.util.stream.Collectors; import org.springframework.data.redis.connection.RedisConnection; @@ -71,13 +69,19 @@ private String buildTagOverlapQuery( List clauses = new ArrayList<>(); String fashionClause = buildTagClause(fashionTags, fashionField); - if (fashionClause != null) clauses.add(fashionClause); + if (fashionClause != null) { + clauses.add(fashionClause); + } String beautyClause = buildTagClause(beautyTags, beautyField); - if (beautyClause != null) clauses.add(beautyClause); + if (beautyClause != null) { + clauses.add(beautyClause); + } String contentClause = buildTagClause(contentTags, contentField); - if (contentClause != null) clauses.add(contentClause); + if (contentClause != null) { + clauses.add(contentClause); + } if (clauses.isEmpty()) { return "*"; @@ -146,10 +150,14 @@ private List parseFtSearchResult(Object rawResult, Class clazz) { // 첫 번째 요소는 총 결과 수 (Long) // 이후 [docKey, [fields...]] 쌍으로 반복 for (int i = 1; i < resultList.size(); i += 2) { - if (i + 1 >= resultList.size()) break; + if (i + 1 >= resultList.size()) { + break; + } Object fieldsObj = resultList.get(i + 1); - if (!(fieldsObj instanceof List fields)) continue; + if (!(fieldsObj instanceof List fields)) { + continue; + } // JSON 인덱스: fields = ["$", "{...json...}"] for (int j = 0; j < fields.size() - 1; j += 2) {