선물 추천 서비스에서 사용자 테스트 피드백을 수집하던 중 추천 결과가 다소 단조롭고 중복된 상품이 자주 노출되는 현상이 있다는 평가를 받았다. 사용자 입장에서는 다양한 키워드(예: 여자친구, 생일, 모던원피스, 선물)를 입력했음에도 불구하고 비슷한 상품이 반복적으로 등장하거나 특정 키워드에만 과도하게 치우친 결과가 추천되는 것처럼 느껴진다는 점이 문제였다. 이 문제를 분석해보니 추천 로직에서 다음과 같은 한계점들이 존재하고 있었다.
첫째, 추천 결과가 소수의 키워드에 과도하게 집중되어 있었다. 예를 들어 7개의 키워드가 입력되었을 때 전체 키워드 리스트를 하나의 조합으로 묶어 검색하고 유사도가 높은 순서대로 4개만 추천하는 구조였다. 이 방식은 특정 키워드(예: 모던원피스)에 해당하는 상품이 여러 개 매칭될 경우 나머지 키워드는 무시되고 결과적으로 다양성이 떨어지는 문제가 발생했다.
둘째, 중복 상품이 너무 자주 등장했다. 네이버 쇼핑 API의 경우 리뷰 수나 인기순과 같은 정량적 필드 없이 제목, 이미지 URL, 브랜드 정도의 정보만 제공되므로 유사도 판단이 어렵다. 이로 인해 제목이 다르지만 실질적으로는 동일한 상품이 여러 번 노출되거나 브랜드가 다르다는 이유만으로 거의 동일한 상품이 반복되는 문제가 있었다.
셋째, AI 기반 질문의 특징을 반영하지 못했다는 점이다. AI 질문은 복수의 키워드를 유도하고 그에 따른 다양한 취향 기반 추천을 제공하는 것이 목적이다. 하지만 기존 로직은 이 다양한 키워드를 아래와 같은 조합으로 호출해 제한을 두지 않았기 때문에 추천에 필요한 갯수만 채워지면 결과로 보여지게 했었다.
[대상자 + 태그들(r개) + 상황]
[대상자 + 태그들(r - 1개) + 상황]
[대상자 + 태그들(2개) + 상황]
[태그들(r개)]
이렇게 했을 때 비슷한 제목, 비슷한 이미지인 가격만 다른 상품들이 노출되거나 제목과 이미지가 다르지만 유사한 상품들이 노출되서 신뢰도가 낮아진다는 피드백을 받았었다.
위 문제들을 해결하기 위해 로직을 전면 리팩토링하였다. 이번 리팩토링에서는 다음과 같은 핵심 방향을 설정했다.
2.상품 유사도 판단 강화 및 중복 제거
상품 제목에서 핵심 단어만 추출하는 유틸리티(extractBaseTitle)를 만들고 이미지 URL과 함께 조합하여 상품 키를 생성했다. 이 키를 기준으로 추천 목록 내 동일하거나 유사한 상품은 한 번만 등장하도록 필터링했다.또한 브랜드 중복을 막기 위해 추천 결과에 포함된 브랜드를 따로 저장하고, 새로운 상품이 기존 브랜드와 중복될 경우 제외되도록 하였다.
public static List<List<String>> generatePriorityCombos(List<String> tags, String receiver, String reason) {
List<List<String>> result = new ArrayList<>();
int n = tags.size();
for (int r = 1; r <= n; r++) {
List<List<String>> combinations = new ArrayList<>();
generateCombinations(tags, 0, r, new ArrayList<>(), combinations);
for (List<String> tagCombo : combinations) {
List<String> combo = new ArrayList<>();
if (!receiver.isBlank()) combo.add(receiver);
combo.addAll(tagCombo);
if (!reason.isBlank()) combo.add(reason);
result.add(combo);
}
}
// 태그가 없을 경우 예외 처리용 fallback
if (tags.isEmpty() && !receiver.isBlank() && !reason.isBlank()) {
result.add(List.of(receiver, reason));
}
return result;
}
이전에는 receiver + tags + reason
, tags + reason
, tags
등의 몇 가지 고정 조합만 사용했는데 이번 리팩토링을 통해 1개~N개까지 태그의 모든 조합에 대해 receiver
와 reason
을 붙이는 방식으로 유연하게 변경하였다. 이로써 매우 다양한 조합이 만들어지고 더 풍부한 후보 상품 풀을 생성할 수 있게 되었다.
예를 들어 [여자친구, 3~5만원, 생일, 모던원피스, 여성자켓, 트렌디블라우스, 선물]
로 키워드 요청이 오면 아래와 같은 조합으로 검색한다.
[여자친구, 모던원피스, 생일]
[여자친구, 여성자켓, 생일]
[여자친구, 트렌디블라우스, 생일]
[여자친구, 선물, 생일]
[여자친구, 모던원피스, 여성자켓, 생일]
[여자친구, 모던원피스, 트렌디블라우스, 생일]
private List<Product> findBestMatched(List<List<String>> combos, int minPrice, int maxPrice) {
List<Product> allResults = new ArrayList<>();
Set<String> seenProductKeys = new HashSet<>();
Set<String> seenBrands = new HashSet<>();
int maxTotal = 10;
int maxPerCombo = 2;
for (List<String> combo : combos) {
if (allResults.size() >= maxTotal) break;
List<Product> candidates = productRepository.findTopByTagsAndPriceRange(combo, minPrice, maxPrice);
Set<String> comboSet = new HashSet<>(combo);
List<Product> selected = candidates.stream()
.filter(p -> {
Set<String> keywords = p.getKeywordGroups().stream()
.map(KeywordGroup::getMainKeyword)
.collect(Collectors.toSet());
long matched = comboSet.stream().filter(keywords::contains).count();
return matched >= Math.ceil(comboSet.size() * 0.5); // 유사도 50% 이상
})
.filter(p -> {
String key = RecommendationUtil.extractBaseTitle(p.getTitle()) + "::" + p.getImageUrl();
String brand = p.getBrand();
boolean isDuplicate = seenProductKeys.contains(key) || seenBrands.contains(brand);
if (!isDuplicate) {
seenProductKeys.add(key);
seenBrands.add(brand);
return true;
}
return false;
})
.limit(maxPerCombo)
.toList();
allResults.addAll(selected);
}
return allResults.size() >= 10 ? allResults : Collections.emptyList();
}
여기서는 키워드 유사도 판단 기준을 50% 이상 일치로 설정했고 중복 판단은 제목 핵심어 + 이미지 URL 그리고 브랜드 기준으로 필터링했다. 이 덕분에 겉으로 보기엔 비슷하지만 실질적으로는 동일한 상품들이 줄어들었고 사용자 경험이 개선됐다. 또한 한 키워드 조합당 최대 2개만 추천하도록 설정해 최대한 중복 상품이 적게 노출되도록 수정하였다. 2개는 나오게 한 이유가 많은 조합을 만들어 각 1개씩 하게되면 더 좋겠지만 첫 번째로 호출량이 많아지면 쿼터가 부족할 수도 있고 로딩시간이 길어지기 떄문에 이또한 사용성의 문제가 될 거 같다고 생각했다. 그리고 결국 태그들을 위주로 보여주기 때문에 결국 태그들에서 2개의 조합으로 들어가도 비슷한 상품이 나올 거 같기 때문에 이렇게 설계하게 되었다.
List<String> forbiddenWords = List.of("유아", "아동", "키즈", "어린이", "아이", "장난감", "초등", "유치원", "베이비");
String lowerTitle = dto.title().toLowerCase();
if (forbiddenWords.stream().anyMatch(lowerTitle::contains)) {
log.debug("유아/아동 상품 제외: {}", dto.title());
continue;
}
네이버 쇼핑 API를 사용하다 보면 키워드만 보고 추천했을 때 유아복이나 아동 선물이 같이 섞여 들어오는 경우가 발생한다. 예를 들어 트렌디블라우스, 스타일스웨터 같은 키워드를 넣으면 아동용 의류가 섞여있는 경우가 있었다. 그리고 테스트를 하면 할수록 아동용품이 꽤나 많이 추천되는 걸 확인했고 이 서비스의 타겟층은 성인층을 생각하고 있기 때문에 추천 결과에 대해서 "이런 선택을 했는데 왜 아동용품을 추천하지?"라는 생각에 신뢰도를 잃을 수 있다고 생각했기 때문에 과감하게 필터링했다.
이를 해결하기 위해 상품 제목이나 태그에 아동, 유아, 베이비
등이 포함된 경우 해당 상품은 저장 단계에서 제외하도록 사전 필터링을 적용했다. 이를 통해 성인 대상 선물 추천에만 집중할 수 있게 되었고 잘못된 추천이 나갈 가능성을 줄일 수 있었다.
이번 리팩토링을 통해 다음과 같은 개선 효과를 확인할 수 있었다.
이번 리팩토링은 단순한 기능 개선을 넘어서 사용자 경험 전반을 고민하는 과정이었다. "기술적으로 가능한 것"과 "사용자에게 신뢰를 주는 것"은 다르다는 점을 체감했다. 특히 네이버 API처럼 정량 데이터 없이 제목 중심으로 검색하는 시스템에서는 직접 유사도를 정의하고 중복 제거 기준을 세우는 작업이 필수적이라는 점을 알게 되었다.
또한, AI 질문 기반으로 여러 키워드를 수집하는 구조에서는 단순히 상위 키워드 몇 개로만 검색하는 방식이 아니라 조합 기반 검색과 소량 필터링 방식이 훨씬 더 효과적임을 확인할 수 있었다. 앞으로도 데이터 기반 서비스에서는 사용자 경험을 최우선에 두고 설계하는 것이 가장 중요하다는 걸 다시금 느꼈다.