Skip to content

[이슈] 빈집 매물 추천 태그 필터링 시, NPE 발생

이은비 edited this page Jul 27, 2023 · 1 revision

배경

빈집 거래 게시판에서는 일반 사용자와 공인중개사로부터 빈집 매물 정보를 담은 게시글을 작성하고 조회할 수 있다. 이때, 메인 페이지에서 사용자는 아래와 같은 필터링을 거칠 수 있다.

image

필터링 조건은 아래와 같다.

  1. 빈집 매물 유형 ( 매매 / 전세 / 월세 )
  2. 빈집 매물 위치 ( 수도권 / 강원 / 충청 / 전라 / 경상 / 제주 )

수도권에는 서울 / 인천 / 경기 가 포함된다.

  1. 빈집 매물 추천 태그 ( 하단에 버튼 형식으로 지정된 태그 )
  2. 검색어

검색어는 게시글의 제목과 게시글 작성자 닉네임에 대해 LIKE 검색을 한다.

빈집 게시글은 사용자가 필터링 하는 것 외에도 내부적으로 필터링을 거친다.

  • 일반 사용자가 게시글을 업로드 할 경우, 허위매물 혹은 잘못된 정보 기재 등의 상황을 고려하여 관리자로부터 승인을 받는 프로세스를 거친다. 따라서 관리자의 승인여부에 대한 조건이 추가된다.
  • 타 사용자가 게시글의 내용을 확인 후, 허위매물 혹은 과대광고 등의 이유로 게시글을 관리자에게 신고할 수 있다. 따라서 신고여부에 대한 조건이 추가된다.
  • 게시글을 작성 시, 임시저장 기능을 제공하기에 임시저장 여부에 대한 조건이 추가된다.
  • 게시글 삭제 시, 논리적 삭제 방식을 적용하기에 삭제여부에 대한 조건이 추가된다.

문제상황 및 원인

빈집 게시글 테이블에 추천 태그를 Enum으로 구성하여 List으로 하여 컬럼으로 만들었다.

이때, Converter를 이용해 Enum의 name을 "," 구분자로 하여 String 형태로 DB에 저장하도록 컨버팅 로직을 구현했다.

태그에 대한 필터링 조건을 적용하기 이전 앞서 다른 필터링에 대해서는 원할하게 동작하는 것을 확인했다.

하지만, 태그에 대한 필터링을 적용하기 위해 아래와 같이 로직을 구성했더니 NPE 가 발생했다.

    private fun filterWithRecommendedTags(recommendedTag: List<RecommendedTag>?): BooleanExpression? {
        return if (recommendedTag != null) {
            if(house.recommendedTags.equals("")) null else Expressions.allOf(
            recommendedTag.stream().map(this::filterWithRecommendedTag).toArray { arrayOfNulls<BooleanExpression>(it)})

        } else null
        return recommendedTag?.let {
            if (it.isNotEmpty()) {
                Expressions.allOf(*it.map(this::filterWithRecommendedTag).toTypedArray())
            } else {
                null // Empty condition, will be ignored in the query
            }
        }
        return if(recommendedTag == null) null else house.recommendedTags.any().`in`(recommendedTag)
    }
    private fun filterWithRecommendedTag(tagName: RecommendedTag) : BooleanExpression? {
        val tagName = RecommendedTag.getTagByName(recommendedTag)
        println("++++++++++++++++++++ $tagName    ${house.recommendedTags.contains(tagName)}")
        return house.recommendedTags.isNotEmpty.and(house.recommendedTags.contains(tagName))
    }

NPE가 발생한 지점은 fetch()이다.

override fun getHouseAll(houseListDto: HouseListDto, pageable: Pageable) : Page<House> {
        val result = jpaQueryFactory
            .selectFrom(house)
            .innerJoin(house.user, user).fetchJoin()
            .where(
                house.useYn.eq(true), // 삭제 X
                house.houseType.eq(RentalType.valueOf(houseListDto.rentalType)), // 매물 타입 필터링
                filterWithCity(houseListDto.city), // 매물 위치 필터링
                searchWithKeyword(houseListDto.search), // 키워드 검색어
                house.reported.eq(false), // 신고 X
                house.applied.eq(HouseReviewStatus.APPROVE), // 게시글 미신청 ( 관리자 승인 혹은 공인중개사 게시글 )
                house.tmpYn.eq(false), // 임시저장 X
                filterWithRecommendedTags(houseListDto.recommendedTag), // 매물 추천 태그 필터링
            )
            .limit(pageable.pageSize.toLong())
            .offset(pageable.offset)
            .fetch() // <<<<<<- 여기서 NPE가 발생!!!!!
        val countQuery = jpaQueryFactory
            .selectFrom(house)
            .innerJoin(house.user, user).fetchJoin()
            .where(
                house.useYn.eq(true), // 삭제 X
                house.houseType.eq(RentalType.valueOf(houseListDto.rentalType)), // 매물 타입 필터링
                filterWithCity(houseListDto.city), // 매물 위치 필터링
                searchWithKeyword(houseListDto.search), // 키워드 검색어
                house.reported.eq(false), // 신고 X
                house.applied.eq(HouseReviewStatus.APPROVE), // 게시글 미신청 ( 관리자 승인 혹은 공인중개사 게시글 )
                house.tmpYn.eq(false), // 임시저장 X
                filterWithRecommendedTags(houseListDto.recommendedTag), // 매물 추천 태그 필터
            )
        return PageableExecutionUtils.getPage(result, pageable) { countQuery.fetch().size.toLong()}
    }

NPE가 발생한 이유를 명확히 캐치한 것은 아니지만, 나름대로 디버깅을 하며 추론을 해보았다.

** Converter로 커스터마이징을 하여 DB에 적재하는데, querydsl에서 기본적으로 제공하는 contains와 호환되지 않는다. **

Primitive Type의 경우, contains()를 사용하면 해당 값이 존재하는 row를 반환한다. 하지만, EnumType + "," 구분자로 적재하는 방식에는 이것이 적용되지 않는 것 같다. contains()를 통해 반환되는 값이 존재하지 않기에 NPE가 발생하는 것으로 추측한다.

그리고 해당 이슈와 관련해 인프런에 개제된 Q&A를 찾았다. 인프런 질문

해결 방법

처음 태그 컬럼을 추가할 때, 2가지 고민을 했다.

요구사항 : 태그를 여러개 가질 수도 있고 하나도 갖지 않을 수도 있다.

  1. 태그를 위한 별도의 테이블을 둘 것인가

  2. 태그를 "," 구분자를 통해 하나의 컬럼에 리스트 형태로 넣을 것인가

조회할 때마다 조인이 발생하는 것보다 하나의 테이블 내에서 데이터를 뽑아내는 것이 성능상 유리할 것이라 판단했다. 그래서 2번을 택했고 구현했다. 하지만, 위와 같은 이슈가 발생했으며 1번 방식으로 테이블 구성을 다시 하여 이슈를 해결했다.

여담

별도의 테이블로 빼지 않고, @Entity 어노테이션을 붙이지 않고 @ElementCollection를 이용해서 Enum class를 관리하려 했다.

   select
        house0_.id as id1_4_0_,
        user1_.id as id2_13_1_,
        house0_.created_at as created_2_4_0_,
        house0_.updated_at as updated_3_4_0_,
        house0_.city as city4_4_0_,
        house0_.zipcode as zipcode5_4_0_,
        house0_.agent_name as agent_na6_4_0_,
        house0_.applied as applied7_4_0_,
        house0_.code as code8_4_0_,
        house0_.contact as contact9_4_0_,
        house0_.content as content10_4_0_,
        house0_.created_date as created11_4_0_,
        house0_.deal_state as deal_st12_4_0_,
        house0_.floor_num as floor_n13_4_0_,
        house0_.house_type as house_t14_4_0_,
        house0_.image_urls as image_u15_4_0_,
        house0_.monthly_price as monthly16_4_0_,
        house0_.price as price17_4_0_,
        house0_.purpose as purpose18_4_0_,
        house0_.reject_reason as reject_19_4_0_,
        house0_.report_reason as report_20_4_0_,
        house0_.report_type as report_21_4_0_,
        house0_.reported as reporte22_4_0_,
        house0_.size as size23_4_0_,
        house0_.title as title24_4_0_,
        house0_.tmp_yn as tmp_yn25_4_0_,
        house0_.use_yn as use_yn26_4_0_,
        house0_.user_id as user_id27_4_0_,
        recommende2_.house_id as house_id1_6_0__,
        recommende2_.recommended_tags as recommen2_6_0__,
        user1_.created_at as created_3_13_1_,
        user1_.updated_at as updated_4_13_1_,
        user1_.age as age5_13_1_,
        user1_.authority as authorit6_13_1_,
        user1_.email as email7_13_1_,
        user1_.nick_name as nick_nam8_13_1_,
        user1_.password as password9_13_1_,
        user1_.phone_num as phone_n10_13_1_,
        user1_.profile_image_url as profile11_13_1_,
        user1_.user_type as user_ty12_13_1_,
        user1_.withdrawal_id as withdra24_13_1_,
        user1_.withdrawal_status as withdra13_13_1_,
        user1_.agent_code as agent_c14_13_1_,
        user1_.agent_name as agent_n15_13_1_,
        user1_.assistant_name as assista16_13_1_,
        user1_.business_code as busines17_13_1_,
        user1_.company_address as company18_13_1_,
        user1_.company_email as company19_13_1_,
        user1_.company_name as company20_13_1_,
        user1_.company_phone_num as company21_13_1_,
        user1_.estate as estate22_13_1_,
        user1_.status as status23_13_1_,
        user1_.dtype as dtype1_13_1_,
        recommende2_.house_id as house_id1_6_0__,
        recommende2_.recommended_tags as recommen2_6_0__ 
    from
        house house0_ 
    inner join
        user user1_ 
            on house0_.user_id=user1_.id 
    inner join
        recommended_tags recommende2_ 
            on house0_.id=recommende2_.house_id 
    where
        exists (
            select
                1 
            from
                house house3_ 
            inner join
                recommended_tags recommende4_ 
                    on house3_.id=recommende4_.house_id 
            where
                house3_.id=house0_.id 
                and recommende4_.recommended_tags=?
        )

해당 방식으로 쿼리를 실행하면 위와 같이 콘솔에 출력되며 원하는 대로 값이 출력되지 않는다.

그리고 1번 방식을 적용했을 때의 실행 쿼리는 아래와 같다.

    select
        house0_.id as id1_4_0_,
        user1_.id as id2_13_1_,
        housetag2_.id as id1_5_2_,
        house0_.created_at as created_2_4_0_,
        house0_.updated_at as updated_3_4_0_,
        house0_.city as city4_4_0_,
        house0_.zipcode as zipcode5_4_0_,
        house0_.agent_name as agent_na6_4_0_,
        house0_.applied as applied7_4_0_,
        house0_.code as code8_4_0_,
        house0_.contact as contact9_4_0_,
        house0_.content as content10_4_0_,
        house0_.created_date as created11_4_0_,
        house0_.deal_state as deal_st12_4_0_,
        house0_.floor_num as floor_n13_4_0_,
        house0_.house_type as house_t14_4_0_,
        house0_.image_urls as image_u15_4_0_,
        house0_.monthly_price as monthly16_4_0_,
        house0_.price as price17_4_0_,
        house0_.purpose as purpose18_4_0_,
        house0_.reject_reason as reject_19_4_0_,
        house0_.report_reason as report_20_4_0_,
        house0_.report_type as report_21_4_0_,
        house0_.reported as reporte22_4_0_,
        house0_.size as size23_4_0_,
        house0_.title as title24_4_0_,
        house0_.tmp_yn as tmp_yn25_4_0_,
        house0_.use_yn as use_yn26_4_0_,
        house0_.user_id as user_id27_4_0_,
        user1_.created_at as created_3_13_1_,
        user1_.updated_at as updated_4_13_1_,
        user1_.age as age5_13_1_,
        user1_.authority as authorit6_13_1_,
        user1_.email as email7_13_1_,
        user1_.nick_name as nick_nam8_13_1_,
        user1_.password as password9_13_1_,
        user1_.phone_num as phone_n10_13_1_,
        user1_.profile_image_url as profile11_13_1_,
        user1_.user_type as user_ty12_13_1_,
        user1_.withdrawal_id as withdra24_13_1_,
        user1_.withdrawal_status as withdra13_13_1_,
        user1_.agent_code as agent_c14_13_1_,
        user1_.agent_name as agent_n15_13_1_,
        user1_.assistant_name as assista16_13_1_,
        user1_.business_code as busines17_13_1_,
        user1_.company_address as company18_13_1_,
        user1_.company_email as company19_13_1_,
        user1_.company_name as company20_13_1_,
        user1_.company_phone_num as company21_13_1_,
        user1_.estate as estate22_13_1_,
        user1_.status as status23_13_1_,
        user1_.dtype as dtype1_13_1_,
        housetag2_.created_at as created_2_5_2_,
        housetag2_.updated_at as updated_3_5_2_,
        housetag2_.house_id as house_id5_5_2_,
        housetag2_.recommended_tag as recommen4_5_2_,
        housetag2_.house_id as house_id5_5_0__,
        housetag2_.id as id1_5_0__ 
    from
        house house0_ 
    inner join
        user user1_ 
            on house0_.user_id=user1_.id 
    inner join
        house_tag housetag2_ 
            on house0_.id=housetag2_.house_id 
    where
        house0_.use_yn=? 
        and house0_.house_type=? 
        and (
            house0_.city like ? escape '!' 
            or house0_.city like ? escape '!' 
            or house0_.city like ? escape '!'
        ) 
        and (
            user1_.nick_name like ? escape '!' 
            or house0_.title like ? escape '!'
        ) 
        and house0_.reported=? 
        and house0_.applied=? 
        and house0_.tmp_yn=? 
        and housetag2_.recommended_tag=?

두 쿼리문의 차이가 명확히 보이는 구간이 있다. 바로 where 절이다. 첫번째 쿼리에서는 where 절에서 exist로 서브쿼리가 만들어진다. 이렇게 되면 추후 인덱싱을 적용했을 경우, 원하는 대로 동작하지 않을 수 있다. ( 물론, 저 상태에서도 원하는 값을 반환하지 않기에 올바른 쿼리는 아니다. )

마무리

검색 쿼리 구현은 마쳤으나, 여전히 마음에 들지 않는 부분들이 있다. 조회 시, 불필요한 정보를 너무 많이 불러온다는 점과 확장성에 용이하지 못한 구현이라는 점이다. 다음 포스트에서는 이 부분들을 개선하는 내용을 담아보려 한다.

Clone this wiki locally