[Spring Boot] JPA 1:N 다중 이너 조인 오류

Loopy·2022년 1월 29일
0

삽질기록

목록 보기
2/28
post-thumbnail
post-custom-banner

☁️ 배경 및 문제상황

오늘의 옷 프로젝트를 하다가, 발생한 오류 해결 과정을 기록해보았다.
옷 태그 필터링 기능, 즉 querydsl 를 이용해 동적으로 쿼리를 짜야하는 상황이였다. 태그 필터링은 아래의 예시와 같다.

Closet Id가 1이고 계절이 여름 또는 가을이고 TPO가 데이트 또는 등산이고 MOOD는 미니멀 또는 시티보이인 clothes를 반환해달라!

아래는 ERD 인데, 보다시피 옷과 옷-태그 테이블이 1 : N 관계이다.
초반에는 이 점을 아예 생각하지 못하고 무작정 inner join 을 사용해 개발하였다.(초반이여서 아직 성능 최적화는 고려 못했다는 점😢)

    @Override
    public List<Clothes> searchByTag(ClothesDto.SearchRequest request) {
        return jpaQueryFactory.selectFrom(clothes)
                .join(clothes.seasonTags, clothesSeasonTag)
                .join(clothes.eventTags, clothesEventTag)
                .join(clothes.moodTags, clothesMoodTag)
                .where(closetEq(request.getClosetId()),
                        tagsEq(request.getSeasonTagIds(), request.getEventTagIds(), request.getMoodTagIds()))
                .fetch();
    }

    private BooleanBuilder tagsEq(List<Long> sids, List<Long> eids, List<Long> mids){
        Optional.ofNullable(sids).orElseGet(Collections::emptyList).stream()
                .forEach(id -> builder.or(isSeasonTag(id)));

        Optional.ofNullable(eids).orElseGet(Collections::emptyList).stream()
                .forEach(id -> builder.or(isEventTag(id)));

        Optional.ofNullable(mids).orElseGet(Collections::emptyList).stream()
                .forEach(id -> builder.or(isMoodTag(id)));

        return builder;
    }


    private BooleanBuilder isSeasonTag(Long id){
        if (Objects.isNull(id)) return null;
        return new BooleanBuilder(clothesSeasonTag.tag.id.eq(id));
    }

    private BooleanBuilder isEventTag(Long id){
        if (Objects.isNull(id)) return null;    //null 반환
        return new BooleanBuilder(clothesEventTag.tag.id.eq(id));
    }

    private BooleanBuilder isMoodTag(Long id){
        if (Objects.isNull(id)) return null;
        return new BooleanBuilder(clothesMoodTag.id.eq(id));
    }

하지만, 테스트 코드를 작성하고 돌려보니 결과값이 분명 2 개가 나와야 하는데 자꾸 0 이 나와 실패해버린다.

☁️ 로그의 SQL문 확인하기

아래는 로그를 통해 확인한 SQL 문이다. 로그를 보니 분명 where 조건도 정확히 맞는데 대체 어디서 오류가 나는지 모르겠어서 혹시 join 문제인가 하고 left join 으로 바꿨었다.

근데, 결과값이 2개가 나와야 할것이 3개가 나오는 것을 보고 원인을 찾았다.

"어..설마 그 중요하다고 말했던 그 1:N 관계에서의 데이터 뻥튀기 문제_인가...?"

따라서 이후부터는 직접 눈으로 과정이 어떻게 되는지 자세히 알고 싶어서 무작정 h2 console을 키고 하나하나 조인하면서 결과 테이블을 체크해보았다.

select clothes0_.id as id1_1_, clothes0_.created_at as created_2_1_, clothes0_.updated_at as updated_3_1_, clothes0_.closet_id as closet_i6_1_, clothes0_.content as content4_1_, clothes0_.img_url as img_url5_1_, clothes0_.user_id as user_id7_1_ 
    from clothes clothes0_ 
     inner join clothes_season_tag seasontags2_ on clothes0_.id=seasontags2_.clothes_id
     inner join clothes_event_tag eventtags3_ on clothes0_.id=eventtags3_.clothes_id 
     inner join clothes_mood_tag moodtags4_ on clothes0_.id=moodtags4_.clothes_id
    where clothes0_.closet_id=? and (seasontags2_.tag_id=? or moodtags4_.id=?)

☁️ H2 콘솔로 직접 결과 테이블 확인해보기

먼저, 현재 테스트 데이터의 상황은 이렇다(숫자는 ID)

Clothes
1
2

SeasonTag
1 //가을
2 //봄

EventTag
1 //등산

MoodTag
1 //무드

Clothes_Season_Tag(c,t)
1 1
2 1
2 2

Clothes_Mood_Tag(c,t)
1 1

Clothes_Event_Tag(c,t)
2 1

Clothes1은 가을 + 등산 태그를 가지고, Clothes2는 가을 + 봄 + 무드 태그를 가지고 있는 상황이다. 즉, 가을 + 무드 태그 로 필터링 조건을 걸면 Clothes 1,2 모두 반환되어어야 한다.

  1. where 조건을 빼고 테스트했을때 이미 join으로 인한 결과값이 안나온다.

  2. clothesclothes_season_Tag 을 조인하니 데이터가 뻥튀기 되어 3개가 나온 것을 볼 수 있다.

정리하자면, 이 결과 테이블들을 보고나니 든 생각은 두가지 였다.

1. 1:N 관계 조인 데이터 뻥튀기 문제

이 문제는 distinct 키워드 를 붙혀주면 데이터가 완전히 똑같은게 아니여서 sql 단에서는 중복 제거를 못하지만 JPA 가 제거를 해줘서 문제 해결이 가능하다.

2. 연속/다중 inner join으로 인해 결과 데이터가 삭제되버린 문제

이건 이너 조인 개념을 다시 곱씹어서 생각해보면 금방 알 수 있었다.

만약, ClothesSeasonTag 만 조인을 한 상황에서는 데이터가 뻥튀기 되어 clothes id = 1,2,2 세개의 결과 테이블이 만들어진다. 이 상황에서 다시 ClohtesMoodTag 랑 조인을 하면, clothes 1 는 무드태그를 가지고 있지 않으니 id=1가 1인 컬럼은 아예 날라가버리고 id=2만 중복된채로 나오게 되는 것 이다.

이럴때는, left outer join 을 이용해 공통으로 가지고 있지 않더라도 null 로 채워서 모두 데이터를 가져오고, 그 이후에 where 조건을 통해 필요 데이터를 걸러내는 것이 맞다.

left join을 적용한 결과 테이블

"결론은, 조인 전에는 항상 연관관계(oneToN, NToOne)를 우선으로 고려하고 코드를 짜자"

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!
post-custom-banner

0개의 댓글