본문 바로가기
프로젝트/개인 프로젝트 V3

1:N 조인에서 발생한 중복 데이터 및 누락 문제 해결

by Thumper 2024. 8. 10.

이번 시간에는 검색 구현 과정에서 발생한 중복 데이터 조회 오류를 정리해보고자 한다.
프로젝트 코드는 Github에 있으니 참고해주세요.

문제

상품 페이징 조회 기능을 구현하던 중, 예상치 못한 문제가 발생했었다.
API를 통해 데이터를 조회했을 때, DB에서 직접 조회한 결과와 비교해보니 일부 데이터가 누락된 것이다.

DB에서 직접 쿼리를 실행했을 때는 12개의 데이터 항목이 모두 정상적으로 조회되었지만,
API를 통해 조회한 결과는 단 6개의 항목만 반환되었다.


POSTMAN 실행

다음과 같이, postman에 페이징 api 요청을 실행했었다.
image


문제 분석

이 문제를 해결하기 위해, 가장 먼저 살펴본 것은 search_fetch 로직이다.
이 로직은 QueryDSL을 사용하여 페이징 쿼리로 구현했었다.
LEFT JOIN을 사용해 각 테이블을 연결한 이 과정에서 문제가 발생했는지 확인하기 위해 코드를 점검하기로 했다.

Item

image

search_fetch 로직

    @Override
    public Page<ItemDtoV3> search_fetch_v5(Pageable pageable, SearchCondition condition) {
        QItem item = QItem.item;
        QItemImg itemImg = QItemImg.itemImg;
        QUserDefinedTag userDefinedTag = QUserDefinedTag.userDefinedTag;
        QRecommendedTag recommendedTag = QRecommendedTag.recommendedTag;

        BooleanBuilder whereClause = buildWhereClause(condition, item);

        // Fetch items with pagination and join with tags
        List<Tuple> results = queryFactory
                .select(item.id, item.nameKor, item.price, item.description,
                        item.createAt, item.likeCount, item.sanrioCharacters,
                        item.mainCategory, item.subCategory, itemImg.imgUrl,
                        userDefinedTag.name, recommendedTag.tagOption)
                .from(item)
                .leftJoin(itemImg).on(itemImg.item.eq(item).and(itemImg.isMainImg.eq(IsMainImg.Y)))
                .leftJoin(userDefinedTag).on(userDefinedTag.item.eq(item))
                .leftJoin(recommendedTag).on(recommendedTag.item.eq(item))
                .where(whereClause)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        // Process the results to group tags by item id
        Map<Long, ItemDtoV3> itemDtoMap = new HashMap<>();

        for (Tuple tuple : results) {
            Long itemId = tuple.get(item.id);
            itemDtoMap.computeIfAbsent(itemId, id -> new ItemDtoV3(
                    id,
                    tuple.get(item.nameKor),
                    tuple.get(item.price),
                    tuple.get(item.description),
                    tuple.get(item.createAt),
                    tuple.get(item.likeCount),
                    tuple.get(item.sanrioCharacters).getNameKor(), // Enum의 nameKor 필드를 사용하여 한글 이름을 가져옵니다.
                    tuple.get(item.mainCategory).getNameKor(),
                    tuple.get(item.subCategory).getNameKor(),
                    tuple.get(itemImg.imgUrl),
                    new ArrayList<>(),
                    new ArrayList<>()
            ));

            ItemDtoV3 itemDto = itemDtoMap.get(itemId);

            String userDefinedTagName = tuple.get(userDefinedTag.name);
            if (userDefinedTagName != null) {
                itemDto.getUserDefinedTags().add(userDefinedTagName);
            }

            String recommendedTagOption = tuple.get(recommendedTag.tagOption) != null ? tuple.get(recommendedTag.tagOption).getNameKor() : null;
            if (recommendedTagOption != null) {
                itemDto.getRecommendedTags().add(recommendedTagOption);
            }
        }

        long total = queryFactory
                .selectFrom(item)
                .leftJoin(itemImg).on(itemImg.item.eq(item).and(itemImg.isMainImg.eq(IsMainImg.Y)))
                .where(whereClause)
                .fetchCount();

        return new PageImpl<>(new ArrayList<>(itemDtoMap.values()), pageable, total);
    }

DB에 직접 쿼리 실행

문제를 더 명확히 파악하기 위해 API 요청에서 사용된 쿼리를 로그에서 확인한 뒤, 이를 직접 DB에서 실행해보기로 했다.

use sanrio3;

SELECT i1_0.item_id,
       i1_0.create_at,
       i1_0.description,
       iil1_0.item_id AS img_item_id,
       iil1_0.item_img_id,
       iil1_0.img_url,
       iil1_0.is_main_img,
       i1_0.like_count,
       i1_0.main_category,
       i1_0.name_kor,
       i1_0.price,
       rtl1_0.item_id AS rec_tag_item_id,
       rtl1_0.tag_id,
       rtl1_0.tag_frequency,
       rtl1_0.tag_option,
       i1_0.sanrio_characters,
       i1_0.sub_category,
       i1_0.uploader_id,
       udtl1_0.item_id AS user_tag_item_id,
       udtl1_0.item_tag_id,
       udtl1_0.name,
       udtl1_0.tag_frequency
FROM item i1_0
LEFT JOIN item_img iil1_0 ON i1_0.item_id = iil1_0.item_id
LEFT JOIN user_defined_tag udtl1_0 ON i1_0.item_id = udtl1_0.item_id
LEFT JOIN recommended_tag rtl1_0 ON i1_0.item_id = rtl1_0.item_id
WHERE iil1_0.is_main_img = 'Y';

ttttfk

위의 실행 결과를 보면, 각 item에 연관된 이미지와 태그의 수만큼 중복 조회가 발생하고 있음을 확인할 수 있다.
이는 1:N 관계로 설정된 item과 itemImg, userDefinedTag, recommendedTag 간의 관계에서 비롯된 것으로 보인다.
하나의 item에 여러 개의 이미지나 태그가 매핑되면서, 동일한 item 데이터가 반복적으로 반환되는 상황이 발생한 것이다.

기존 로직의 문제점

코드와 코드 실행결과를 확인해보니 다음과 같은 문제점이 있음을 알 수 있었다.

  • 조인하는 테이블 간의 1:N 관계로 인해 중복된 데이터가 발생할 가능성이 큰 것 같다.
    • item과 itemImg는 1:N 관계다.
      상품 1개에 여러 이미지가 있는 경우 item 데이터가 중복될 수 있다.
  • item과 userDefinedTag, recommendedTag도 각각 1:N 관계다.
    • 상품 1개에 여러 태그가 있는 경우에도 중복된다.
  • 각각의 item에 연결된 여러 이미지 또는 태그들이 별도의 행으로 반환되면서, 실제 item의 데이터가 여러 번 반복될 수 있다.
    • 중복된 행이 반환되면서, 페이징 쿼리가 페이지 사이즈를 초과하게 되어 일부 데이터가 누락된 것이다.

조인 결과에서 각 테이블의 관계에 따라 중복된 행이 생성된 것 같다.

어떻게 개선할까?

데이터 조회와 태그 조회를 분리하고 필요한 데이터를 별도로 그룹핑하여 처리하는 방식으로 해결하고자 했다.


개선된 로직

    @Override
    public Page<ItemDtoV3> search_fetch_v6(Pageable pageable, SearchCondition condition) {
        QItem item = QItem.item;
        QItemImg itemImg = QItemImg.itemImg;
        QUserDefinedTag userDefinedTag = QUserDefinedTag.userDefinedTag;
        QRecommendedTag recommendedTag = QRecommendedTag.recommendedTag;

        BooleanBuilder whereClause = new BooleanBuilder();

        if (condition.getMainCategory() != null) {
            whereClause.and(item.mainCategory.eq(condition.getMainCategory()));
        }

        if (condition.getSubCategory() != null) {
            whereClause.and(item.subCategory.eq(condition.getSubCategory()));
        }

        if (condition.getSanrioCharacters() != null) {
            whereClause.and(item.sanrioCharacters.eq(condition.getSanrioCharacters()));
        }

        if (condition.getItemName() != null) {
            String searchTerm = "%" + condition.getItemName().toLowerCase() + "%";
            whereClause.and(item.nameKor.toLowerCase().like(searchTerm));
        }

        // Fetch items with pagination
        List<ItemTemp> items = queryFactory
                .select(new QItemTemp(
                        item.id, item.nameKor, item.price, item.description,
                        item.createAt, item.likeCount, item.sanrioCharacters, item.mainCategory, item.subCategory, itemImg.imgUrl))
                .from(item)
                .leftJoin(itemImg).on(itemImg.item.eq(item).and(itemImg.isMainImg.eq(IsMainImg.Y)))
                .where(whereClause)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        // Fetch user-defined and recommended tags
        List<Tuple> userDefinedTagTuples = queryFactory
                .select(userDefinedTag.item.id, userDefinedTag.name)
                .from(userDefinedTag)
                .fetch();

        List<Tuple> recommendedTagTuples = queryFactory
                .select(recommendedTag.item.id, recommendedTag.tagOption)
                .from(recommendedTag)
                .fetch();

        // 4. 태그 정보를 Map으로 변환
        Map<Long, List<String>> userDefinedTags = userDefinedTagTuples.stream()
                .collect(Collectors.groupingBy(
                        tuple -> tuple.get(userDefinedTag.item.id),
                        Collectors.mapping(tuple -> tuple.get(userDefinedTag.name), Collectors.toList())
                ));

        Map<Long, List<String>> recommendedTags = recommendedTagTuples.stream()
                .collect(Collectors.groupingBy(
                        tuple -> tuple.get(recommendedTag.item.id),
                        Collectors.mapping(
                                tuple -> tuple.get(recommendedTag.tagOption).getNameKor(),
                                Collectors.toList()
                        )
                ));

        // Map ItemTemp to ItemDto
        List<ItemDtoV3> finalItems = items.stream()
                .map(itemTemp -> new ItemDtoV3(
                        itemTemp.getId(),
                        itemTemp.getNameKor(),
                        itemTemp.getPrice(),
                        itemTemp.getDescription(),
                        itemTemp.getCreateAt(),
                        itemTemp.getLikeCount(),
                        itemTemp.getSanrioCharacters().getNameKor(),
                        itemTemp.getMainCategory().getNameKor(),
                        itemTemp.getSubCategory().getNameKor(),
                        itemTemp.getThumbnail(),
                        userDefinedTags.getOrDefault(itemTemp.getId(), List.of()),
                        recommendedTags.getOrDefault(itemTemp.getId(), List.of())
                ))
                .collect(Collectors.toList());

        long total = queryFactory
                .selectFrom(item)
                .leftJoin(itemImg).on(itemImg.item.eq(item).and(itemImg.isMainImg.eq(IsMainImg.Y)))
                .where(whereClause)
                .fetchCount();

        return new PageImpl<>(finalItems, pageable, total);
    }

필터링 조건 개선

search_fetch_v6에서는 검색 조건에 따라 동적으로 whereClause를 구성했다.
이를 통해 조회하고자 하는 데이터에 대해 더욱 명확하고 효율적인 필터링을 적용할 수 있었다. 예를 들어, 카테고리, 캐릭터, 이름 등의 조건에 따라 SQL 조건을 유연하게 생성했다.

데이터 조회 분리

기존 로직에서는 여러 테이블을 한 번에 조인하여 데이터를 가져왔었다.
새로운 로직에서는 아이템 정보와 태그 정보를 분리하여 각각 조회했다.
이를 통해 아이템 리스트를 먼저 조회한 후, 별도로 태그 정보를 가져와 각 아이템에 매핑하는 방식으로 개선했다.

태그 정보 매핑 및 그룹화

태그 정보는 각각의 아이템 ID를 기준으로 그룹화시켰다.
이 과정에서 userDefinedTag와 recommendedTag의 데이터를 각각 매핑하여, 아이템별로 정확한 태그 정보를 유지할 수 있었다.



DB에 직접 쿼리 실행

API 요청에서 사용된 쿼리를 로그에서 확인한 뒤, 이를 직접 DB에서 실행해보았다.

use sanrio3;

SELECT
    i.item_id,
    i.name_kor,
    i.price,
    i.description,
    i.create_at,
    i.like_count,
    i.sanrio_characters,
    i.main_category,
    i.sub_category,
    ii.img_url
FROM
    item i
LEFT JOIN
    item_img ii ON ii.item_id = i.item_id AND ii.is_main_img = 'Y';

SELECT
    udt.item_id,
    udt.name
FROM
    user_defined_tag udt;


SELECT
    rt.item_id,
    rt.tag_option
FROM
    recommended_tag rt;


SELECT
    COUNT(i.item_id)
FROM
    item i
LEFT JOIN
    item_img ii ON ii.item_id = i.item_id AND ii.is_main_img = 'Y';

image

회고


이번 문제는 item을 조회하면서 1:N 관계로 매핑된 이미지와 태그가 함께 조회했을 때 발생한 문제였다.
item에 연결된 이미지와 태그의 개수가 늘어날수록, item 데이터가 여러 번 중복 조회되는 상황이 발생했다.

LEFT JOIN으로 쿼리를 작성했지만, Postman으로 API를 테스트한 결과,
1:N 관계에서 발생한 중복 데이터로 인해 예상했던 12개의 항목 대신 6개의 고유 항목만 반환되었습니다.
이는 중복된 데이터가 페이징 과정에서 필터링되지 않아 일부 데이터가 누락된 결과로 보입니다.

(이제 item과 itemImg가 1:N 관계로 LEFT JOIN 할 때,
하나의 item에 여러 개의 itemImg가 연결되면, 쿼리 결과에서 동일한 item 데이터가 여러 번 반환됨을 이해했다.
그래서 페이징 처리가 제대로 작동하지 않으며, 일부 item 데이터가 누락될 수 있음을 이해했다.)

이 문제를 해결하기 위해 item 조회와 태그 조회를 분리하여 처리하는 방식을 선택했지만,
이렇게 처리한 경우 쿼리가 1+N번 실행되는 문제가 발생했다.
item 데이터 조회 이후 태그 데이터를 추가로 조회하는 방식으로 인해 쿼리 실행 횟수가 증가했다.

연관된 데이터를 별도의 쿼리로 조회하게 되는 문제와 이에 대한 해결 과정은 다음 포스팅 - 검색 쿼리 리팩토링에서 자세히 정리해보겠다.

댓글