기록해야 성장한다

쿠팡 파트너스 상품 검색 API 정확도 개선 본문

TIL

쿠팡 파트너스 상품 검색 API 정확도 개선

sodapapa-dev 2025. 4. 21. 16:07

 

쿠팡 파트너스를 활용하여 도서 정보를 사용자에게 제공하는 기능을 개발하면서, 단순한 API 검색만으로는 만족할 만한 결과를 얻기 어렵다는 문제에 직면했다. 이 글에서는 기존 방식의 문제점과 이를 개선해 나간 과정을 정리해본다.

문제 상황

처음에는 단순하게 출판사 + 도서명 키워드로 검색하고 limit=1 파라미터를 통해 첫 번째 결과만 가져오는 방식을 사용했다. 이 방식에서 다음과 같은 문제점이 발견되었다.

  • 매칭 실패 시 완전히 관련 없는 상품(음료수, 과일 등)이 노출됨
  • 책의 부제목, 특수문자, 띄어쓰기 등으로 인해 검색 결과가 왜곡됨
  • 실제로 책이 쿠팡에 존재함에도 검색에 실패하는 경우가 많음

이로 인해 사용자에게 정확한 도서 정보를 제공하지 못하는 상황이 자주 발생했다.

첫 번째 개선 시도: 백오피스 수동 매칭

첫 번째 개선 방법으로 백오피스에 수동 매칭 기능을 추가했다.

  • 도서 목록과 쿠팡 검색 결과를 병렬로 보여주는 화면 구성
  • 관리자가 직접 적절한 상품을 선택할 수 있는 인터페이스 제공

이 방식으로 정확도는 개선되었으나 여전히 약 60% 정도의 매칭률에 그쳤고, 도서 데이터가 증가함에 따라 관리자의 수작업 부담이 증가하는 문제가 있었다.

알고리즘 기반 개선: 전처리와 유사도 계산

운영 프로세스 개선만으로는 한계가 있다고 판단해 기술적인 해결책을 모색했다. 검색 키워드 전처리, 유사도 기반 매칭, 카테고리 필터링을 조합하여 정확도를 높이는 방향으로 접근했다.

전처리 로직 구현

검색어와 상품명 모두에 전처리 로직을 적용했다. 실제 구현 코드는 다음과 같다.

@Component
@RequiredArgsConstructor
public class StringSimilarityUtil {

    private final List<String> UNWANTED_PATTERNS = Arrays.asList(
            "\\[.*?\\]",       // 대괄호 [ ... ] 제거
            "\\(.*?\\)",       // 소괄호 ( ... ) 제거
            "\\+(?!\\d).*?",   // + 뒤에 숫자가 없는 경우만 제거 (예: + 사은품)
            "사은품.*?",
            "증정",
            "베스트셀러",
            "오늘 출발",
            "선착순",
            "할인",
            "무료배송",
            "전[0-9]+권",
            "전\\d+권",
            "세트",
            "구성",
            "제공"
    );

    public String cleanTitle(String input) {
        if (input == null) return "";

        String cleaned = input.toLowerCase();

        for (String pattern : UNWANTED_PATTERNS) {
            cleaned = cleaned.replaceAll(pattern, "");
        }

        return cleaned.trim();
    }
}

이 전처리 과정은 다음과 같은 효과가 있었다.

  • "해리포터 + 책갈피 증정" → "해리포터"
  • "클린 코드(Clean Code)" → "클린 코드"
  • "베스트셀러! 오늘 출발!" → 제거
  • "전12권 세트 구성" → 제거

 

유사도 기반 매칭

단순 키워드 검색이 아닌 레벤슈타인 거리(Levenshtein Distance) 알고리즘을 활용한 유사도 계산 방식을 도입했다.

// Levenshtein Distance 기반 유사도 계산 - 실제 구현 코드
private int levenshteinDistance(String a, String b) {
    int[][] dp = new int[a.length() + 1][b.length() + 1];

    for (int i = 0; i <= a.length(); i++) {
        for (int j = 0; j <= b.length(); j++) {
            if (i == 0) {
                dp[i][j] = j;
            } else if (j == 0) {
                dp[i][j] = i;
            } else if (a.charAt(i - 1) == b.charAt(j - 1)) {
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                dp[i][j] = 1 + Math.min(
                    dp[i - 1][j - 1],
                    Math.min(dp[i][j - 1], dp[i - 1][j])
                );
            }
        }
    }

    return dp[a.length()][b.length()];
}

public double similarity(String bookTitle, String productName) {
    String cleanBookTitle = cleanTitle(bookTitle);
    String cleanProductName = cleanTitle(productName);

    int distance = levenshteinDistance(cleanBookTitle, cleanProductName);
    int maxLen = Math.max(cleanBookTitle.length(), cleanProductName.length());
    return maxLen == 0 ? 1.0 : (1.0 - (double) distance / maxLen);
}

이를 활용한 매칭 로직은 다음과 같이 구현했다.

return searchResults.stream()
    .map(product -> {
        String cleanedProductName = cleanTitle(product.getProductName());
        double similarity = similarity(cleanedSearchQuery, cleanedProductName);
        log.debug("상품명: [{}], 유사도: {}", product.getProductName(), similarity);
        return new AbstractMap.SimpleEntry<>(product, similarity);
    })
    .max(Comparator.comparingDouble(Map.Entry::getValue))
    .filter(entry -> entry.getValue() >= 0.3) // 유사도 30% 이상만 매칭
    .map(Map.Entry::getKey);

 

실제 검색 결과와 유사도 분석

실제 검색 과정에서의 로그를 살펴보면 유사도 계산이 어떻게 작동하는지 이해할 수 있다. 다음은 몇 가지 실제 검색 사례다.

사례 1: "흔한 남매 17" 검색

원본 상품명 정제된 상품명 유사도
흔한남매 흔한남매 0.5
흔한남매 17 18 전2권 세트 (사은품증정) 흔한남매 17 18 0.6
흔한남매 18 + 17 세트 (메모수첩증정) 흔한남매 18 17 0.545
(오늘 출발) 흔한남매 14권 + 15권 + 16권 + 17권 + 18권 세트(전5권) 흔한남매 14권 15권 16권 17권 18권 0.214
(오늘 출발) 흔한남매 17권 + 사은품 제공 흔한남매 17권 0.750 ✅

 

이 사례에서 유사도 0.750으로 가장 높은 "(오늘 출발) 흔한남매 17권 + 사은품 제공" 상품이 선택되었다. 전처리 후 "흔한남매 17권"으로 정제되어 검색어 "흔한 남매 17"과 가장 유사하다고 판단되었다.

 

사례 2: "그릿" 검색

원본 상품명 정제된 상품명 유사도
그릿(50만부 판매 기념 리커버 골드에디션)
, 재능, 환경을 뛰어넘는 열정적 끈기의 힘
그릿, 재능, 환경을 뛰어넘는 열정적 끈기의 힘 0.069
그릿 개정판 그릿 개정판 0.333
그릿 /비즈니스북스 ( 사 은 품 증 정) 그릿 /비즈니스북스 0.2
그릿 책 GRIT 골드에디션 (리커버) 그릿 책 grit 골드에디션 0.133
그릿 (전면 개정판) (이엔제이 전용 사 은 품 증 정) 그릿 1.000 ✅

 

이 경우 전처리 후 "그릿"으로 정제된 상품이 유사도 1.000으로 완벽히 일치하여 선택되었다. 괄호 내 부가 정보와 공백이 제거되어 정확한 매칭이 가능했다.

 

사례 3: "세 마리 토끼 잡는 독서 논술 D2" 검색

원본 상품명 정제된 상품명 유사도
세 마리 토끼 잡는 독서 논술 D2 세 마리 토끼 잡는 독서 논술 d2 1.000 ✅
능률 세마리 토끼잡는 독서 논술 P 세트, 단품 능률 세마리 토끼잡는 독서 논술 p , 단품 0.583
세 마리 토끼 잡는 독서 논술 D1 세 마리 토끼 잡는 독서 논술 d1 0.947
세 마리 토끼 잡는 독서 논술 D3 세 마리 토끼 잡는 독서 논술 d3 0.947

이 검색에서는 대소문자 구분 없이 완벽히 일치하는 상품이 유사도 1.000으로 선택되었다. 흥미로운 점은 "D1", "D3" 등 비슷한 시리즈도 0.947의 높은 유사도를 보였다는 것이다.

이러한 실제 사례들을 통해 알 수 있듯이, 전처리와 유사도 계산이 결합되어 정확한 도서 매칭이 가능해졌다. 특히 주목할 점은:

  1. 단행본과 세트 구성의 구분이 가능해짐
  2. 시리즈물의 경우 정확한 권수 매칭
  3. 부가 정보(사은품, 출판사 등)를 제거하여 핵심 정보만으로 비교

실제 구현 및 적용

실제 서비스에 적용한 도서 검색 및 매칭 로직은 다음과 같다.

private void makeOutlinkByCoupang(Book book) throws IOException {
    String originalTitle = book.getTitle();
    String cleanedKeyword = stringSimilarityUtil.cleanTitle(originalTitle);
    int keywordLength = Math.min(cleanedKeyword.length(), 49);

    // 쿠팡 API 검색 (검색어 길이 제한)
    ProductSearchResponse response = coupangOpenApiService.searchProduct(cleanedKeyword.substring(0, keywordLength));
    String searchUrl = response.getData().getLandingUrl();

    // 유사도 계산 및 최적 매칭 선택
    Optional<AbstractMap.SimpleEntry<ProductSearchResponse.DataContent.ProductData, Double>> bestMatchedProduct =
            response.getData().getProductData().stream()
                    .filter(product -> product.getCategoryName().contains("도서")) // 도서 카테고리 필터링
                    .map(product -> {
                        double similarity = stringSimilarityUtil.similarity(originalTitle, product.getProductName());
                        log.debug("상품명: [{}], 유사도: {}", product.getProductName(), similarity);
                        return new AbstractMap.SimpleEntry<>(product, similarity);
                    })
                    .max(Comparator.comparingDouble(Map.Entry::getValue))
                    .filter(entry -> entry.getValue() >= 0.3); // 유사도 기준

    // 매칭된 상품이 있을 경우 DB에 저장
    bestMatchedProduct.ifPresent(entry -> {
        ProductSearchResponse.DataContent.ProductData product = entry.getKey();
        String cleanedProductName = stringSimilarityUtil.cleanTitle(product.getProductName());

        booksAffiliateInfoRepository.save(
                BooksAffiliateInfo.builder()
                        .booksId(book.getId())
                        .booksName(cleanedProductName)
                        .affiliateType(COUPANG.name())
                        .productOutlink(product.getProductUrl())
                        .searchOutlink(searchUrl)
                        .price(product.getProductPrice())
                        .build()
        );
    });
}
  •  

개선 제안 및 향후 과제

현재 구현으로도 매칭 정확도가 크게 향상되었지만, 여전히 개선할 여지가 있다. 다음은 몇 가지 개선 제안 사항이다.

API 제공자(쿠팡 파트너스)에 대한 제안

  1. 카테고리 필터링 쿼리 파라미터 지원: 현재는 모든 카테고리 결과를 받아 애플리케이션에서 필터링해야 하는데, API 자체에서 category=도서 같은 파라미터를 지원하면 훨씬 효율적일 것이다.
  2. 유사도 기반 정렬 옵션: 검색 결과를 단순 인기도나 판매량이 아닌, 검색어와의 유사도 기준으로 정렬할 수 있는 옵션이 있다면 매칭 성공률을 더 높일 수 있을 것이다.
  3. 메타데이터 강화: 도서의 경우 ISBN, 저자, 출판일 등의 메타데이터를 검색 결과에 포함해준다면 더 정확한 매칭이 가능할 것이다.

내부 시스템 개선 사항

  1. 학습 기반 개선: 매칭 실패 케이스를 지속적으로 수집하여 전처리 규칙과 유사도 임계값을 개선할 필요가 있다.
  2. 도서별 특화 전처리: 만화책, 학습서, 소설 등 도서 유형에 따라 다른 전처리 로직을 적용하는 것도 고려해볼 수 있다.
  3. 캐싱 시스템 도입: 자주 검색되는 도서에 대한 결과를 캐싱하여 API 호출 횟수와 처리 시간을 줄일 수 있다.

이 글은 실제 프로젝트 경험을 바탕으로 작성되었으며, 코드 예시는 실제 구현과 다소 차이가 있을 수 있다.

 

 

반응형
Comments