[트러블슈팅] TrendPick 프로젝트 추천 기능 구현 / 연관관계 및 쿼리 이슈

2023. 6. 7. 09:26트러블슈팅

TrendPick의 주요기능 중 하나인 상품 추천 기능에 관해서 간단히 설명하자면

 

판매자는 상품을 등록할 때 상품에 맞는 태그를 선택하여 등록할 수 있다.

 

회원은 회원가입시 관심 있는 태그를 선택할 수 있다.

 

회원은 상품 조회, 장바구니 담기, 구매 등 행동패턴에 따라 태그의 점수가 누적된다.

 

만약 회원이 가지고 있지 않은 태그라면 새롭게 추가되고 기존에 있던 태그면 점수가 누적된다.

 

아래 코드는 해당 내용을 구현한 것이다.

==FavoriteTagService==
public void updateTag(Member member, Product product, TagType type) {
        Set<Tag> tagList = tagRepository.findAllByProduct(product);
        Set<FavoriteTag> tags = favoriteTagRepository.findAllByMember(member);

        for(Tag tagByProduct : tagList){
            boolean hasTag = false;
            for(FavoriteTag tagByMember : tags){
                if(tagByProduct.getName().equals(tagByMember.getName())){
                    tagByMember.increaseScore(type);
                    hasTag = true;
                    break;
                }
            }
            if(!hasTag){
                FavoriteTag favoriteTag = new FavoriteTag(tagByProduct.getName());
                favoriteTag.increaseScore(type);
                member.addTag(favoriteTag);
                favoriteTagRepository.save(favoriteTag);
            }
        }
    }
==FavoriteTag==
public void increaseScore(TagType type){
        switch (type) {
            case ORDER -> score += 10;
            case CART -> score += 5;
            case REGISTER -> score += 30;
            default -> score += 1;
        }
    }

이렇게 회원과 상품은 각각 태그를 가지고 있고, 회원의 행동패턴에 따라 누적된 점수를 기반으로 추천 카테고리에 상품을 리스팅한다.


다음은 실제 상품이 각 회원별로 정렬되게끔 내부적으로 구현한 알고리즘 내용이다.

 

우선 해당 회원의 태그와 하나라도 겹치는 상품을 모두 가져온다. 그리고 회원의 태그와 상품의 태그를 비교하며 상품이 해당 회원에게 몇점의 점수를 얻을 수 있는지 계산한 후 점수를 내림차순으로 정렬하여 추천 카테고리에 리스팅한다.

 

예시를 들면 다음과 같다.

 

<예시>

member1 은 시티보이룩, 빈티지룩, 로맨틱룩을 가지고 있으며, 각각 10점, 5점, 1점이 누적되어 있다.

상품1 은 시티보이룩을 가지고 있다.

상품2는 시티보이룩, 빈티지룩을 가지고 있다.

상품3은 시티보이룩, 빈티지룩, 로맨틱룩을 모두 가지고 있다.

상품4는 오버핏룩을 가지고 있다.

 

member1에 대한 추천 알고리즘을 적용하면

상품1 -> 10점

상품2 -> 10점 + 5점

상품3 -> 10점 + 5점 + 1점

이라는 과정을 통해 상품3 -> 상품2 -> 상품1 순서대로 리스팅되어야 한다.

 

상품4는 member1이 가지고 있는 태그와 겹치는 태그가 하나도 없기에 제외된다.


[문제발생]

이를 처음에는 Querydsl을 통해 구현했다.

의도한 쿼리는 username에 member가 가지고 있는 태그 이름을 기반으로 포함된 상품을 가져와서 중복을 제거하고, 점수의 합계에 따른 정렬까지 수행한 후 정렬된 상품 List를 반환하는 것이다.

 public List<Product> findProductByRecommended(String username) {

        List<Tuple> sortProducts = queryFactory
                .select(product.id, favoriteTag.score.sum())
                .from(product)
                .leftJoin(member.tags, favoriteTag)
                .leftJoin(product.tags, tag)
                .leftJoin(favoriteTag.member, member)
                .where(favoriteTag.name.in(
                        JPAExpressions.select(tag.name)
                        .from(tag)
                        .where(tag.product.eq(product)
                        )),
                        member.username.eq(username))
                .groupBy(product.id)
                .orderBy(favoriteTag.score.sum().desc())
                .fetch();

        return sortProducts.stream()
                .sorted(Comparator.comparing(
                        tuple -> Optional.ofNullable(tuple.get(1, Long.class)).orElse(Long.MIN_VALUE),
                        Comparator.nullsLast(Comparator.naturalOrder())))
                .map(tuple -> queryFactory
                        .selectFrom(product)
                        .where(product.id.eq(tuple.get(0, Long.class)))
                        .fetchOne())
                .toList();

하지만 당연히 의도한 쿼리문이 생성되지 않았고, 중복된 상품으로 도배되었으며 정렬도 제대로 되지 않았다.

그래서 Querydsl을 하나씩 뜯어보면 당연한 결과였다.

 

.where(favoriteTag.name.in(
                        JPAExpressions.select(tag.name)
                        .from(tag)
                        .where(tag.product.eq(product)
                        )),
                        member.username.eq(username))

우선 위의 쿼리에서 product는 parameter로 받아온 product도 아닐 뿐더러 진행방향도 서브쿼리가 먼저 수행됨을 완전히 간과하고 있음을 볼 수 있다.

.groupBy(product.id)
.orderBy(favoriteTag.score.sum().desc())

이 쿼리는 중복 상품을 제거하고 tag의 점수 합을 기반으로 내림차순 정렬을 하려고 한 것처럼 보인다.

하지만 중복제거도 되지 않았고, favoriteTag는 상품과 연관관계 설정도 되있지 않은데 favoriteTag의 점수로 정렬을 시도하는 것은 접근 자체가 잘못되었다.

 

.leftJoin(member.tags, favoriteTag)
.leftJoin(product.tags, tag)
.leftJoin(favoriteTag.member, member)

쿼리를 작성할 때 JOIN문은 특히나 조심해야 된다.

연관관계를 생각하지 않고 위처럼 방향도 없이 LEFT 조인문을 작성한 탓에 N+1문제가 발생했으며, 성능 저하를 일으켰다.

member에 따라서 때로는 순환참조가 발생하며 에러가 터졌다.

 

[문제해결]

추천 알고리즘에 필요한 엔티티들의 연관관계를 제대로 검토해볼 필요성을 느끼고 처음부터 다시 설계하기 시작했다.

FavortieTag 는 Member와 Tag을 연결하기 위한 중간테이블로 재구현했다.

 

따라서 FavoriteTag는 Member가 가지고 Tag는 Product가 가지도록 완전히 분리하였다.

 

그리고 참조할 일이 생기면 Tag의 이름으로 참조할 수 있도록 하였으며 Tag이름의 중복을 없애고자 Set 자료구조를 도입했다.

 

그리고 상품의 중복을 어떻게 없애야 되는지에 대한 고민을 많이 했다.

 

쿼리문보다 자바 언어가 자신이 있었고, 불필요한 join으로 성능 저하를 야기할 바에 자바에서 최적화된 알고리즘을 구현해야겠다고 생각했다.

 

또 하나의 고민은 쿼리에서 중복된 상품을 뽑아오면 점수를 어떻게 계산하느냐 였다.

 

상품이 점수 계산을 위해서 score라는 관련도 없는 필드를 가지게 할 수는 없었고, 만약 가진다고 하더라고 수많은 회원별 점수가 아니기 때문에 의미가 없었다.

 

중요한 점은 해당 상품이 가지고 있는 태그에 대해 회원이 가지고 있는 점수를 더해줘야 하기 때문에 어떠한 태그로 인해 상품이 선택되었는지의 정보가 필요했다.

 

태그의 데이터베이스 구조를 뜯어보면 아래와 같이 상품에 대해 태그가 묶여있지 않다.

태그명 상품id
빈티지룩, 로맨틱룩, 오버핏룩 1

 

아래와 같이 각각의 레코드를 형성하고 있다. 

태그명 상품id
빈티지룩 1
로맨틱룩 1
오버핏룩 1

 

그래서 상품을 식별할 수 있는 정보, 해당 상품이 가지고 있는 태그, 그리고 점수를 계산할 수 있는 필드만을 가진 DTO를 새로 만들었다.

public class ProductByRecommended {

    private Long productId;
    private String tagName;

    private int totalScore;

 

최종 쿼리문은 다음과 같다.

public List<ProductByRecommended> findRecommendProduct(String email) {
        return queryFactory
                .select(new QProductByRecommended(
                        tag.product.id,
                        tag.name
                ))
                .from(tag)
                .where(tag.name.in(
                    JPAExpressions.select(favoriteTag.name)
                        .from(favoriteTag)
                        .where(favoriteTag.member.email.eq(email))
                    )
                )
                .distinct()
                .fetch();
    }

회원이 가지고 있는 태그를 하나라도 가진 상품들을 중복 상관없이 모두 ProductByRecommended로 변환하여 가지고 오는 쿼리문을 생성한다.

 

중복은 자바 코드로 점수를 계산하며 제거할 목적이었고, 점수를 계산하기 위해 같은 상품이더라도 다른 태그명을 가지고 리스트를 반환했기 때문에 의도대로 되었다.

 

얻어온 ProductRecommended List를  성능을 생각하여 시간복잡도 O(N)을 자랑하는 HashMap 이라는 자료구조를 사용해서 중복을 제거하며 점수를 계산했다.

해당 로직은 자세하게 주석을 달아놓았다.

		List<ProductByRecommended> tags = productRepository.findRecommendProduct(member.getEmail());
                Set<FavoriteTag> memberTags = member.getTags();

                //태그명에 따라 가지고 있는 product_id
                // : 멤버 태그명에 따라 해당 상품에 점수를 부여해야 하기 때문에
                Map<String, List<Long>> productIdListByTagName = new HashMap<>();

                //상품 id 중복을 없애기 위함
                //맴버의 태그명과 여러개가 겹쳐서 여러개의 추천상품이 반환되었을것 그 중복을 없애야 한다.
                Map<Long, ProductByRecommended> recommendProductByProductId = new HashMap<>();

                //같은 태그명을 가지고 있지만 제각각 상품을 가르키는 productId는 다를 것이다. 그래서 태그명 별로 어떤 상품들을 가르키는지 모아보자
                for (ProductByRecommended tag : tags) {
                    if (!productIdListByTagName.containsKey(tag.getTagName()))
                        productIdListByTagName.put(tag.getTagName(), new ArrayList<>());
                    productIdListByTagName.get(tag.getTagName()).add(tag.getProductId());
                }

                //마찬가지로 같은 상품을 가르키고 있지만 태그명은 제각각일 것이다. 우리가 뽑아내길 원하는 것은 추천상품이다. 즉 같은 상품이 중복되면 안된다.
                //그래서 상품Id에 대한 중복을 없애서 하나에 몰아넣는 코드이다.
                for (ProductByRecommended response : tags) {
                    if (recommendProductByProductId.containsKey(response.getProductId()))
                        continue;
                    recommendProductByProductId.put(response.getProductId(), response);
                }

                //실제로직! member 선호태그에는 점수가 있을 것이다.
                //그러니까  우리가 반환하려고 하는 추천상품이 점수가 몇점인지 갱신하는 코드이다.
                for (FavoriteTag memberTag : memberTags) {
                    if (productIdListByTagName.containsKey(memberTag.getName())) {
                        List<Long> productIdList = productIdListByTagName.get(memberTag.getName());
                        for (Long id : productIdList) {
                            recommendProductByProductId.get(id).plusTotalScore(memberTag.getScore());
                        }
                    }
                }

                List<ProductByRecommended> recommendProductList = new ArrayList<>(recommendProductByProductId.values()).stream()
                        .sorted(Comparator.comparing(ProductByRecommended::getTotalScore).reversed())
                        .toList();

                //Product 변환해서 리턴
                List<Product> products = new ArrayList<>();
                for (ProductByRecommended recommendProduct : recommendProductList) {
                    products.add(productRepository.findById(recommendProduct.getProductId()).orElseThrow(
                            () -> new ProductNotFoundException("존재하지 않는 상품입니다.")
                    ));
                }

                List<Recommend> recommends = new ArrayList<>();
                for (Product product : products) {
                    Recommend recommend = Recommend.of(product);
                    recommend.connectMember(member);
                    recommends.add(recommend);
                }

                return recommends;

 

아직 코드가 많이 복잡하기에 가독성을 높이기 위해 개선할 필요성이 느껴진다.

 

복잡한 로직을 풀어내어 뿌듯했고, 평소에 코딩테스트 문제를 풀어왔던게 도움이 되었음을 느꼈다.