Spring

N+1 해결 (QueryDSL)

쭈녁 2024. 2. 11. 19:49

 

 

 

프로젝트 QueryDSL 적용기

사이드 프로젝트에 필터 및 검색기능을 추가해달라는 요청이 있었다. 기존 페이지네이션으로 결과 값을 주고 있던 API에 QueryDSL로 검색 필터를 적용하였다. 요청 사항: DB에 대한 검색 기능 키워

programmingjun.tistory.com

 

프로젝트에 QueryDSL을 적용하여 동적 쿼리를 통한 조회 기능을 추가하였다. 하지만 하나의 조회 쿼리에 필요 이상의 join 쿼리가 나갔었다. 이를 해결하기 위해 fetch Join을 적용하였다.

 

fetch join 적용

public Page<Project> searchProjectByPage(ProjectSearchCond cond, Pageable pageable) {
        String keyword = cond.getKeyword();
        Integer positionCode = cond.getPositionCode();
        Integer parentCode = cond.getParentCode();
        Integer status = cond.getStatus();

        List<Project> projects = query
                .select(project)
                .from(project)
                .join(project.positionCodeList, projectPosition)
                .fetchJoin()
                .join(projectPosition.codeGroup, codeGroup)
                .fetchJoin()
                .join(codeGroup.parent, parent)
                .fetchJoin()
                .join(project.audit, projectAudit)
                .fetchJoin()
                .where(nameLike(keyword), contentLike(keyword), statusSame(status), projectPositionSame(positionCode), projectPositionParentMatch(parentCode))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize() + 1).fetch();

        JPAQuery<Long> count = query.select(project.count())
                .from(project)
                .join(project.positionCodeList, projectPosition)
                .where(nameLike(keyword), contentLike(keyword), statusSame(status), projectPositionSame(positionCode));

        return PageableExecutionUtils.getPage(projects, pageable, count::fetchOne);
    }

 

Project 객체를 가져올 때 필요에 의해 join 해오던 연관된 객체들을 한번에 조회하도록 fetch join을 적용하였다. projectPosition 객체는 codeGroup(포지션 code)을 갖고 codeGroup은 같은 테이블을 부모로 갖는 구조로 되어 있어 codeGroup을 2번( codeGroup , parent )으로 붙여 왔다. 실제 사용하지 않는 audit이라는 클래스도 붙여온 것을 확인할 수 있는데 이는 양방향 연관관계에서 연관관계의 주인이 반대편에 되어있어 N+1에 해당하는 추가 쿼리가 나가는 것을 발견하였고 이를 해결하기 위해 fetch join으로 붙여왔다. 이 부분도 연관관계를 Project가 갖도록 변경한다면 성능상 이점을 얻을 수 있을 것 같다 (이후 적용해 봐야지) 

 

이러한 패치조인을 적용하여 쿼리를 한번만 나가게 만들 수 있었다.

 

 

조회 쿼리

 

 

API 요청

 

 

API 응답값

{
  "code": 1,
  "message": "정상적으로 처리되었습니다.",
  "data": {
    "content": [
      {
        "id": 41,
        "name": "포지션 테스트용",
        "recruitStartDate": "2024-02-03",
        "recruitEndDate": "2024-02-03",
        "content": "테스트 용",
        "deposit": 200000,
        "count": 5,
        "positionCodeList": [
          {
            "positionCode": 14,
            "name": "프론트",
            "parentCode": 11,
            "parentName": "개발"
          }
        ]
      },
      {
        "id": 42,
        "name": "쿼리 DSL 확인 테스트,",
        "recruitStartDate": "2024-02-07",
        "recruitEndDate": "2024-02-15",
        "content": "내용내용",
        "deposit": 200000,
        "count": 5,
        "positionCodeList": [
          {
            "positionCode": 14,
            "name": "프론트",
            "parentCode": 11,
            "parentName": "개발"
          }
        ]
      },
      {
        "id": 43,
        "name": "필터 확인 테스트,",
        "recruitStartDate": "2024-02-07",
        "recruitEndDate": "2024-02-15",
        "content": "내용내용",
        "deposit": 200000,
        "count": 5,
        "positionCodeList": [
          {
            "positionCode": 14,
            "name": "프론트",
            "parentCode": 11,
            "parentName": "개발"
          }
        ]
      },
      {
        "id": 44,
        "name": "검색 확인 테스트,",
        "recruitStartDate": "2024-02-07",
        "recruitEndDate": "2024-02-15",
        "content": "내용내용",
        "deposit": 200000,
        "count": 5,
        "positionCodeList": [
          {
            "positionCode": 14,
            "name": "프론트",
            "parentCode": 11,
            "parentName": "개발"
          }
        ]
      }
    ],
    "number": 0,
    "size": 10,
    "totalPages": 1,
    "totalElement": 4,
    "numberOfElement": 4
  },
  "result": true
}

 

정상적으로 조회가 되긴 했지만 요청사항은 positionCode에 대한 리스트를 응답값으로 줘야 하는 상황이다. 하지만 현재 상황은 검색 대상인 position 만 필터 되어 나오는 문제가 있다. 이를 해결하기 위해 QueryDSL에 서브쿼리를 적용하였다.

 

 

서브쿼리 적용

    QCodeGroup parent = new QCodeGroup("parent");


    public Page<Project> searchProjectByPage(ProjectSearchCond cond, Pageable pageable) {
        String keyword = cond.getKeyword();
        Integer positionCode = cond.getPositionCode();
        Integer parentCode = cond.getParentCode();
        Integer status = cond.getStatus();

        List<Project> projects = query
            .select(project)
            .from(project)
            .join(project.positionCodeList, projectPosition)
            .fetchJoin()
            .join(projectPosition.codeGroup, codeGroup)
            .fetchJoin()
            .join(codeGroup.parent, parent)
            .fetchJoin()
            .join(project.audit, projectAudit)
            .fetchJoin()
            .where(
                project.id.in(
                    JPAExpressions
                        .select(project.id)
                        .from(project)
                        .join(project.positionCodeList, projectPosition)
                        .where(nameLike(keyword), contentLike(keyword), statusSame(status), projectPositionSame(positionCode), projectPositionParentMatch(parentCode))
                        .offset(pageable.getOffset())
                        .limit(pageable.getPageSize()+1)
                )
            )
            .orderBy(project.id.desc())
            .fetch();

        JPAQuery<Long> count = query.select(project.count())
                .from(project)
                .join(project.positionCodeList, projectPosition)
                .where(nameLike(keyword), contentLike(keyword), statusSame(status), projectPositionSame(positionCode));

        return PageableExecutionUtils.getPage(projects, pageable, count::fetchOne);
    }

 

project.id.in 안쪽에 서브쿼리를 통해 project 검색필터에 해당하는 객체의 id를 조회해 오고 where in 쿼리를 통해 ID가 같은 객체를 가져오도록 짰다. 여기서 특이사항은 codeGroup안에 있는 parent 객체에 접근하기 위해 parent라는 서브쿼리용 Qclass를 만들었다.

 

 

변경 후 API 결과 값

{
  "code": 1,
  "message": "정상적으로 처리되었습니다.",
  "data": {
    "content": [
      {
        "id": 44,
        "name": "검색 확인 테스트,",
        "recruitStartDate": "2024-02-07",
        "recruitEndDate": "2024-02-15",
        "content": "내용내용",
        "deposit": 200000,
        "count": 5,
        "positionCodeList": [
          {
            "positionCode": 14,
            "name": "프론트",
            "parentCode": 11,
            "parentName": "개발"
          },
          {
            "positionCode": 15,
            "name": "백엔드",
            "parentCode": 11,
            "parentName": "개발"
          }
        ]
      },
      {
        "id": 43,
        "name": "필터 확인 테스트,",
        "recruitStartDate": "2024-02-07",
        "recruitEndDate": "2024-02-15",
        "content": "내용내용",
        "deposit": 200000,
        "count": 5,
        "positionCodeList": [
          {
            "positionCode": 14,
            "name": "프론트",
            "parentCode": 11,
            "parentName": "개발"
          }
        ]
      },
      {
        "id": 42,
        "name": "쿼리 DSL 확인 테스트,",
        "recruitStartDate": "2024-02-07",
        "recruitEndDate": "2024-02-15",
        "content": "내용내용",
        "deposit": 200000,
        "count": 5,
        "positionCodeList": [
          {
            "positionCode": 14,
            "name": "프론트",
            "parentCode": 11,
            "parentName": "개발"
          },
          {
            "positionCode": 15,
            "name": "백엔드",
            "parentCode": 11,
            "parentName": "개발"
          },
          {
            "positionCode": 16,
            "name": "서버/인프라",
            "parentCode": 11,
            "parentName": "개발"
          }
        ]
      },
      {
        "id": 41,
        "name": "포지션 테스트용",
        "recruitStartDate": "2024-02-03",
        "recruitEndDate": "2024-02-03",
        "content": "테스트 용",
        "deposit": 200000,
        "count": 5,
        "positionCodeList": [
          {
            "positionCode": 14,
            "name": "프론트",
            "parentCode": 11,
            "parentName": "개발"
          },
          {
            "positionCode": 15,
            "name": "백엔드",
            "parentCode": 11,
            "parentName": "개발"
          },
          {
            "positionCode": 16,
            "name": "서버/인프라",
            "parentCode": 11,
            "parentName": "개발"
          }
        ]
      }
    ],
    "number": 0,
    "size": 10,
    "totalPages": 1,
    "totalElement": 4,
    "numberOfElement": 4
  },
  "result": true
}

 

14에 해당하는 값만이 아닌 해당 project에 포함된 모든 list를 조회해 오도록 변경 되었다.

 

 

'Spring' 카테고리의 다른 글

SpringSecurity 아키텍쳐이해하기  (0) 2024.02.24
N+1 문제 해결(JPA)  (1) 2024.02.12
프로젝트 QueryDSL 적용기  (0) 2024.02.08
Swagger 적용  (1) 2024.02.05
Spring Profile  (0) 2024.02.03