UT 이후 OpenAI 프롬프트 개선안 설계 및 추천 시스템 리팩토링 고민

송현진·2025년 6월 10일
0

프로젝트 회고

목록 보기
6/17

UT 이후 개선 백로그와 프롬프트 리팩토링 필요성

최근 사용자 테스트(UT)를 진행한 이후, PM 분들과 UI/UX 디자이너 분들께서 피드백을 기반으로 개선 백로그를 정리해주셨다. 기능이나 디자인 단위의 개선들도 있었지만 그중에서 가장 우선순위가 높았던 이슈는 "OpenAI 프롬프트 구조 개선"이었다. 이 구조는 단순히 GPT와의 인터페이스만 바꾸는 문제가 아니라 전체 추천 시스템의 흐름과 성능, 품질에 직결되기에 백엔드 로직에도 근본적인 변화가 필요한 작업이었다.

기존 구조의 동작 방식과 문제점

1. 고정 질문(1~3번)

  • 사용자의 관계, 예산, 상황 정보를 고정된 선택지를 통해 수집

2. AI 기반 질문(4~6번)

  • 사용자의 취향이나 성향을 묻는 선택지를 기반으로 GPT에게 추천 키워드 추출 요청

3. 백엔드 추천 로직

  • {관계, 예산, 상황, GPT 키워드}를 한데 모아 다양한 조합 생성
  • 이 조합들을 기준으로 네이버 검색 API를 반복 호출
  • 검색된 상품들 중 예산에 맞는 것만 필터링
  • 최종 추천 리스트로 리턴

문제점

  • 조합 수 폭발
    GPT가 추출한 키워드가 4~5개만 되어도 관계, 예산, 상황과 조합할 경우 수십에서 수백 개의 키워드 조합이 생성된다. 이는 불필요하게 많은 경우의 수를 처리하게 만들어 시스템 자원을 과도하게 소모하게 된다.
  • API 호출 과다
    조합 수가 많아질수록 네이버 검색 API를 호출해야 하는 횟수도 기하급수적으로 늘어난다. 네트워크 비용과 외부 API 제한을 고려했을 때 매우 비효율적인 방식이다.
  • 예산과의 부조화
    조합된 키워드 중 다수는 예산 조건과 전혀 맞지 않는 경우가 많다. 즉, 검색은 되었지만 실제로 사용자에게 보여줄 수 없는 결과가 포함되므로 불필요한 리소스를 소모하고 필터링 작업도 과하게 증가한다.
  • 성능 저하
    사용자 1명의 추천 요청에 대해 수십 회의 API 호출과 조합 처리를 반복하다 보니 서버 응답 속도가 저하된다. 특히 동시 접속자가 많아지면 전체 시스템의 부하가 급격히 증가하게 된다.
  • 중복 키워드 및 결과 중복
    여러 조합을 생성하는 과정에서 비슷한 의미의 키워드나 유사한 검색 결과가 반복적으로 포함될 가능성이 크다. 이로 인해 추천 결과에서 다양성이 떨어지고 동일한 상품이 중복 노출되는 문제가 발생한다.

개선된 구조 제안

이러한 문제점을 해결하기 위해 프롬프트 구조와 API 흐름 자체를 재설계하기로 했다. 핵심은 "GPT가 더 똑똑하게 키워드를 추출하고 백엔드는 단순히 필터링된 검색 결과만 빠르게 응답"하는 구조로의 전환이다.

핵심 변경 사항

1. OpenAI 요청 구조 변경

  • 기존: GPT에 관계, 예산, 상황, 성향 등 모두 포함시켜 키워드 요청
  • 변경: 관계와 예산은 GPT에게 주지 않고 프론트/백엔드에서 별도 관리
  • GPT는 오직 AI 기반 질문(성향, 관심사 등)만 기반으로 4~5개의 실질적인 상품 검색용 키워드를 도출

2. 프론트엔드 전달 구조 변경

  • 기존: keywords 필드 하나에 모든 요소가 포함
  • 변경: relation, minPrice, maxPrice, keywords (aiKeywords) 필드로 분리

3. 백엔드 추천 로직 변경

  • 키워드 조합 로직 제거
  • GPT가 추출한 keywords 하나씩 네이버 API에 단건 요청
  • 예산 필터는 상품 검색 후 각 키워드 단위로 적용
  • 추천 수 8개 이상 확보되면 빠르게 응답

기대 효과

이번 프롬프트 구조 개선은 단순히 GPT 요청 형식을 바꾸는 수준을 넘어 전체 추천 로직의 효율성, 정확도, 사용자 경험 품질까지 전반적인 향상을 이끌어낼 수 있는 구조적인 변화다. 구체적으로는 다음과 같은 기대 효과가 있다.

  • 성능 개선
    기존에는 키워드 조합을 기반으로 다수의 API 호출이 발생했다. 경우에 따라 하나의 추천 요청에 많은 외부 API 요청이 생길 수 있었고 이는 전체 서버 응답 시간을 늘리는 주요 원인이었다. 개선된 구조에서는 GPT가 의도적으로 정제된 키워드 4~5개만 제공하고 각 키워드마다 단일 검색을 수행하므로 호출 횟수가 절감된다. 또한, 키워드 조합에 따른 후처리 로직(필터링, 중복 제거, 결과 병합 등)도 간소화되어 전체 처리 속도가 대폭 개선된다.

  • 추천 정확도 향상
    기존 방식은 GPT가 생성한 키워드와 관계, 예산, 상황을 조합해 만들다 보니 결과적으로 사용자의 핵심 취향이 흐려지거나 왜곡되는 경우가 발생했다. 반면 이번 개선에서는 사용자의 취향/성향만을 중심으로 GPT가 직접 검색용 키워드를 도출하고 예산 및 관계는 별도로 필터링 단계에서 적용되므로 보다 집중적이고 정제된 추천 결과를 얻을 수 있다. 특히, GPT가 "검색에 바로 활용 가능한 키워드"로 응답하게 유도하기 때문에 실제 쇼핑몰 검색 결과와도 잘 매칭되어 추천 품질이 자연스럽게 높아진다.

  • 응답 속도 및 안정성 개선
    병렬 검색이 아닌 조건 만족 시 빠르게 종료하는 구조로 바뀌었기 때문에 빠른 응답을 기대할 수 있다. 또한 Redis Quota 제어도 키워드 단위로 작동하므로 기존처럼 무분별하게 할당량을 소모하지 않게 되어 외부 API와의 연동 안정성도 높아진다.

  • 코드 구조 및 유지보수성 개선
    기존에는 키워드 조합 생성 로직, API 호출 제어 로직, 결과 병합 로직이 복잡하게 얽혀 있었고 하나라도 바꾸면 전체 흐름에 영향을 주는 방식이었다. 하지만 개선된 구조에서는 GPT 키워드 -> 단일 API 검색 -> 조건 필터링으로 단방향 구조가 형성되어 각 모듈의 책임이 명확해졌고 테스트/디버깅도 쉬워졌다. 특히 ProductImportService와 RecommendationService의 책임이 분리되면서 역할과 관심사의 단일화(SRP)도 훨씬 깔끔해졌다.

현재 코드에서 어떻게 리팩토링할까?

기존 구조 예시

RecommendationService (Before)

public List<Product> recommendProducts(RecommendationCommand command) {
    List<Product> result = new ArrayList<>();

    for (String keyword : command.getAiKeywords()) {
        List<Product> searched = naverSearchService.search(keyword);
        List<Product> filtered = filterByBudget(searched, command.getBudget());
        result.addAll(filtered);

        if (result.size() >= 8) break;
    }

    return deduplicateByBrand(result)
           .stream()
           .limit(10)
           .collect(Collectors.toList());
}

ProductImportService (Before)

public void importUntilEnough(List<String> tagKeywords, String priceKeyword, String receiverKeyword,
                              String reasonKeyword, int neededCount) {
    List<List<String>> combos = generatePriorityCombos(tagKeywords, receiverKeyword, reasonKeyword);
    for (List<String> combo : combos) {
        for (int page = 1; page <= 10; page++) {
            List<ProductResponseDto> items = naverApiClient.search(String.join(" ", combo), page, 100);

            for (ProductResponseDto dto : items) {
                if (!matchesPrice(dto.getPrice(), priceKeyword)) continue;
                if (isDuplicateOrSimilar(dto)) continue;
                if (exceedsComboOrKeywordLimit(dto)) continue;

                toSave.add(Product.from(dto, keywordGroups));
                if (enough()) break;
            }
            if (enough()) break;
        }
        if (enough()) break;
    }
    productRepository.saveAll(toSave);
}

개선 후 구조 예시

RecommendationService (After)

public RecommendationResponseDto recommend(UUID guestId, UUID sessionId, List<String> aiKeywords, String priceKeyword, String receiverKeyword) {
    int minPrice = ..., maxPrice = ...;

    // 단일 키워드 기반 조회
    List<Product> finalProducts = new ArrayList<>();
    for (String keyword : aiKeywords) {
        List<Product> candidates = productRepository.findTopByKeywordAndPrice(keyword, minPrice, maxPrice);
        finalProducts.addAll(deduplicateAndFilter(candidates));
        if (finalProducts.size() >= 8) break;
    }

    // 부족할 경우 외부 API 호출
    if (finalProducts.size() < 8) {
        productService.importWithAiKeywords(aiKeywords, priceKeyword, 8);
        for (String keyword : aiKeywords) {
            List<Product> more = productRepository.findTopByKeywordAndPrice(keyword, minPrice, maxPrice);
            finalProducts.addAll(deduplicateAndFilter(more));
            if (finalProducts.size() >= 8) break;
        }
    }

    if (finalProducts.isEmpty()) throw new ErrorException(RECOMMENDATION_EMPTY);
    ...
    return new RecommendationResponseDto(...);
}

ProductImportService (After)

public void importWithAiKeywords(List<String> aiKeywords, String priceKeyword, int neededCount) {
    Set<String> searched = new HashSet<>();
    Set<String> seenTitles = new HashSet<>();
    List<Product> toSave = new ArrayList<>();

    for (String keyword : aiKeywords) {
        if (!searched.add(keyword)) continue;
        for (int page = 1; page <= 5; page++) {
            redisQuotaManager.acquire();
            List<ProductResponseDto> items = naverApiClient.search(keyword, page, 100);

            for (ProductResponseDto dto : items) {
                if (!matchesPrice(dto.getPrice(), priceKeyword)) continue;
                if (!seenTitles.add(dto.title())) continue;
                if (isDuplicate(dto)) continue;

                Product p = Product.from(dto, keywordGroupRepository.findOrCreate(keyword));
                toSave.add(p);
                if (toSave.size() >= neededCount) break;
            }
            if (toSave.size() >= neededCount) break;
        }
        if (toSave.size() >= neededCount) break;
    }

    if (!toSave.isEmpty()) productRepository.saveAll(toSave);
}

고민: 일정과 품질 사이의 줄다리기

게다가 이미 기존 방식으로도 MVP 수준의 서비스는 구현이 완료된 상태였기 때문에 “일단 먼저 출시하고 나중에 개선해도 되는 것 아닐까?” 하는 유혹도 분명히 있었다. 사실 현실적인 관점에서는 그게 더 빠르고 편한 길처럼 느껴지기도 했다. 하지만 고민 끝에 내린 결론은 명확했다. 빠른 출시보다 중요한 건 ‘제대로 된 사용자 경험’이라는 점이다. 사용자가 GPT 기반 추천을 기대하며 우리 서비스를 사용했는데 막상 받아본 추천이 엉성하고 어울리지 않는 상품이라면 그 실망감은 단순한 ‘불만족’에 그치지 않는다. 이는 곧 서비스에 대한 신뢰 하락으로 이어지고 한 번 무너진 신뢰는 다시 회복하기 어렵다. 서비스에 재방문조차 하지 않게 될 수도 있다. 물론 일정도 중요하다. 하지만 일정에 쫓겨 품질을 희생한 서비스를 내보내는 건 오히려 더 큰 리스크다. 지금 손이 조금 더 가더라도 결과적으로는 기술 부채를 줄이고 장기적인 유지보수 비용을 아끼는 길이 될 것이라 확신한다.

profile
개발자가 되고 싶은 취준생

0개의 댓글