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

API 페이징 오류와 데이터 중복 문제 해결

by Thumper 2024. 8. 10.

문제의 시작

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

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

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

GET /api/items/paging/search/v2

api 요청시 절반만 되는 오류

왜 API 요청에서는 절반의 데이터만 나오는 걸까? ...
문제의 원인을 파악하기 위해, 기존 API 쿼리 로직을 다시 검토해보기로 했다.

문제 분석

이 문제를 해결하기 위해, 가장 먼저 살펴본 것은 search_fetch 로직이다.
이 로직은 QueryDSL을 사용하여 페이징 쿼리로 구현했었다.
item, itemImg, userDefinedTag, recommendedTag 네 개의 테이블을 한 번에 조회하는 구조였다.
LEFT JOIN을 사용해 각 테이블을 연결했는데, 이 과정에서 데이터 중복이 발생했을 가능성이 있었던 것 같다.

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에서 실행해보기로 했다.

sql

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';

DB 조회결과

DB에서 조회한 테이블 이미지

API 요청 로그를 통해 쿼리를 확인했고,
이를 DB에서 실행해본 결과, 예상대로 중복된 데이터로 인해 일부 데이터가 누락되고 있음을 발견했다.

아이템의 ID가 6번까지 조회되었을 때, 이미 페이지 사이즈인 20을 초과했기 때문에, 나머지 데이터는 다음 페이지로 넘어가지 않고 누락되었다.

결과적으로, 이 문제는 중복된 데이터가 반환됨으로써 발생한 것이었다.

이 문제를 해결하기 위해서는 데이터 중복을 처리하거나, 쿼리 로직을 개선하여 이러한 상황이 발생하지 않도록 해야 했었다.

쿼리 로직 개선

기존 로직의 문제점

search_fetch_v5()는 여러 테이블을 조인하여 데이터를 조회하는 방식으로 구현되어 있었다.

item, itemImg, userDefinedTag, recommendedTag 테이블을 LEFT JOIN으로 연결하여 데이터를 조회했는데, 이 과정에서 데이터 중복 문제가 발생했다.


어떻게 개선할까?

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

개선된 로직

    @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의 데이터를 각각 매핑하여, 아이템별로 정확한 태그 정보를 유지할 수 있었다.



결과

이제 API 요청했을 때 모든 데이터가 정상적으로 조회된다.

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';

보완했을 때 -아이템조회

DB에서 직접 조회했을 때도 데이터 중복문제가 없었고 정상적으로 조회됨을 확인할 수 있었다.

회고

이번 문제 해결 과정에서 데이터 중복으로 인한 API 페이징 오류를 파악하고 해결하면서, 쿼리 최적화와 데이터베이스-API 간의 일관성 유지의 중요성을 다시금 깨달았다.

처음엔 단순한 데이터 누락으로 생각했지만, 실제 원인이 데이터 중복임을 발견하며 쿼리 구조의 문제를 깊이 이해할 수 있었다.


(매우 뿌듯해요,,,)

댓글