성능 최적화 - LikeLionTeam/BootHouse GitHub Wiki

Criteria API를 QueryDSL의 Projections을 이용해 성능 최적화 (N+1 문제해결)


1.1 인트로

이전 코드에서는 Criteria API로 작성한 동적쿼리문에서 N+1 문제를 해결하기 위해 fetch join을 적용해 쿼리문의 개수를 줄였었다.

그런데, 실제로 실행되는 쿼리를 살펴보니, fetch join을 하면서 필요한 칼럼뿐만 아니라, 불필요한 칼럼들까지도 조회되고 있었다.

이에, select절에서 필요한 칼럼들만 DTO로 매핑되어 조회하도록 코드를 수정했다. 이렇게 하면 코드의 가독성이 향상될 뿐만 아니라, 성능 개선에도 도움이 될 것이라 예상했다. 특히, 1개의 데이터에서는 큰 차이가 없지만, 10만건 이상의 데이터셋에서 동적쿼리를 사용할 때는 불필요한 칼럼을 조회하는 것보다 DTO에 정의된 칼럼들만 선택하는 것이 훨씬 효과적일 것이기 때문이다.

그래서 이번 글에서는, 컴파일 시점에 쿼리를 미리 검증하고, 필요한 필드만 선택해 DTO를 생성할 수 있는 QueryDSL의 Projections를 사용해, 쿼리 성능과 안정성을 개선시킨 과정을 정리해보겠다.


1.2 개요

제일 먼저 성능 비교를 위해, Course 테이블에 대량의 데이터셋을 넣을 것이다. (select 쿼리의 대상이 되는 테이블이 Course 테이블이기 때문)

다음은, Course 테이블에 약 2만건의 데이터셋을 넣은 결과이다.

다음과 같은 순서로, 성능개선을 진행하고 비교해보겠다.

2. JMeter로 성능 분석 : 성능개선 전 상태

  • JMeter로 성능분석할 때, 주목해서 볼 부분
    • 기존코드(Criteria API)에서의 문제점과 개선방안
    • 기존코드(Criteria API)일 때의 성능테스트

3. Criteria API를 QueryDSL로 리팩토링 : 동적쿼리에서 DTO를 사용해 성능 개선

- 코드개선시 배운, QueryDSL의 Projections 개념
- 코드개선점 4가지
  - 특히, ```N+1/ fetch join, batch size /JPQL과 QueryDSL에 대한 깊은 이해```
- 개선한 코드
  1. 개선된 쿼리비교와 성능비교
    • 개선 전/후, 날라간 쿼리문 비교
    • 개선후, 성능테스트
    • 성능분석



1. JMeter로 성능 분석 : 성능개선 전 상태

JMeter로 성능분석할 때, 주목해서 볼 부분

JMeter는 Apache에서 개발한 자바로 만들어진 웹 어플리케이션 성능테스트 오픈소스이다. JMeter는 응답을 받아 리포팅,검증,그래프 등 다양한 결과를 제공하는 Listener 기능을 통해 성능을 분석할 수 있다. 이번 테스트에서는 Listner의 다음 항목에 집중해 분석했다.

Listener 중 Summary report에서 핵심적으로 본 지표는 다음의 3가지이다.

  1. 시간
    • Average : 평균걸린시간이 얼마나 짧은지
  2. 처리량(Throughout)과 에러율
    • Throughout 요청개수가 얼마나 많고, 그와 동시에 에러율(error)가 얼마나 낮은지
    • Throughout이 높다면, 많은 요청자를 동시에 처리할 수 있는 능력이 있다는 뜻이다. 하지만, 그와 동시에 처리량이 높아질 때, 에러율도 증가하지 않는지 확인하는 것 또한 중요하다!

기존코드(Criteria API)에서의 문제점과 개선방안

수정전 코드에서는 Criteria API로 동적쿼리를 처리해서, DTO로 필요한 필드만 가져오지 않았다.

/* CourseController의 getOpenCourses()메서드 일부 */

// 모집중인 코스를 기준으로 필터링 (모집중 고려 O, 카테고리 고려 전, 필터링 기준 고려 O)
Page<Course> coursePage = 
	courseService.findCoursesByFilters(categoryId, courseFilter, sort, search, pageable);

// 도메인을 dto로 변환
List<CourseDto> courseDtos = coursePage.getContent().stream()
        .map(course -> new CourseDto(course))
        .collect(Collectors.toList());

즉, findCourseByFilters()함수에서는 Criteria API를 사용해 동적쿼리를 처리했다. 하지만 이 과정에서 생성된 쿼리문은 모든 필드(사용하지 않는 필드도, 심지어 fetch join으로 인해 많은 필드)를 조회한 후, 후처리과정에서 DTO로 변환시켰다.

이로 인해 불필요한 칼럼까지 메모리로 로드된다는 단점이 있었다.

그래서, QueryDSL의 Projections를 사용해 애초에 조회쿼리를 날릴 때부터, 모든 필드가 아닌 필요한 필드(CourseDto)만 조회하도록 바꾸고자 했다.

이렇게 필요한 데이터만 조회함으로써, 성능향상을 기대할 수 있었다.


기존코드(Criteria API)일 때의 성능테스트

기존에 Course 데이터셋에 약 2만건(22,286건)의 데이터가 들어가있는 것을 기억하자.

이번 테스트의 목표는 JMeter를 사용해 Criteria API로 작성한 동적 쿼리의 성능을 분석하는 것이다.

JMeter에서 성능테스트할 때, 다음의 Thread Group(낮은부하/중간부하/높은부하), Sampler(HTTP request 6개)를 기준으로 했다.

Thread Group (낮은,중간,높은 부하)

설정값\요청횟수 낮은 부하 중간 부하 높은 부하
사용자수(명) 10 50 100
ramp-up period(초) 10 10 10
loop count(번) 10 10 9
총요청횟수(번) 600 3000 5400
  • 하나의 쓰레드그룹에 HTTP request가 6개가 존재한다.
  • 따라서, 총요청횟수 = 사용자수 * loop count * HTTP request 개수(6) 로 계산한다.

Sampler (Http request 6개)

CourseController의 findCoursesByFilters()함수에서는, 다양한 필터조건에 맞춘 동적쿼리를 날리기 때문에, 최대한 다양하게 필터링된 request를 만들어 테스트하고자 한다.

하나의 쓰레드그룹에 설정한 HTTP request 6개는 다음과 같다.

1. 카테고리만 선택한 쿼리
    - GET /boothouse/camps?categories=1
    
2. courseFilter 필터링만 걸린 쿼리
    - GET /boothouse/camps?onlineOffline=online
    
3. courseFilter 필터링 1개, 정렬조건이 같이 걸린 쿼리
    - GET /boothouse/camps?onlineOffline=online&sort=deadline
    
4. 카테고리랑, courseFilter 1개, 정렬조건이 같이 걸린 쿼리 (다중필터링)
    - GET /boothouse/camps?categories=1&onlineOffline=online&sort=deadline
    
5. 페이징 설정
    - GET /boothouse/camps?page=1
    
6. 페이징, 사이즈 설정
    - GET /boothouse/camps?page=1&size=10

이제 본격적으로 JMeter로 앞서 설정한 HTTP request 6개에 각각 낮은 부하, 중간 부하, 높은 부하를 주어 성능을 측정해보았다. (아직 기존코드로, 성능개선 전이다!)

  1. 낮은 부하일 때, Summary Report

    총 요청횟수(#Samples)는 의도했던대로 600번 실행되었다.

    처리시간과 처리량을 기준으로 성능분석해보면 다음과 같다.

    • 처리시간(Average; 요청 1건이 처리되는 평균시간) : 약 155ms
    • 처리량 Error는 0%일 때, Throughout(1초당 처리가능한 요청개수) : 약 34.1개
  2. 중간 부하일 때, Summary Report

    총 요청횟수(#Samples)는 의도했던대로 3000번 실행되었다.

    처리시간과 처리량을 기준으로 성능분석해보면 다음과 같다.

    • 처리시간(Average; 요청 1건이 처리되는 평균시간) : 약 866ms
    • 처리량 Error는 0%일 때, Throughout(1초당 처리가능한 요청개수) : 약 48.0개
  3. 높은 부하일 때, Summary Report

    총 요청횟수(#Samples)는 의도했던대로 5400번 실행되었다.

    처리시간과 처리량을 기준으로 성능분석해보면 다음과 같다.

    • 처리시간(Average; 요청 1건이 처리되는 평균시간) : 약 1830ms
    • 처리량 Error는 0%일 때, Throughout(1초당 처리가능한 요청개수) : 약 48.7개

성능개선 전(기존코드-Criteria API)에서의 성능테스트 해석결과

처리시간(Average) 에러율 처리량(Throughout)
낮은 부하 평균 155ms 0% 약 34.1개/초
중간 부하 평균 866ms 0% 약 48.0개/초
높은 부하 평균 1830ms 0% 약 48.7개/초
  • 처리시간: 부하가 증가함에 따라, 요청 1건당 처리하는데 걸리는 평균시간이 늘어난다. 이는, 서버가 더 많은 요청을 처리해야 하므로, 처리시간이 증가한 것이다. 즉, 부하가 커질수록 서버의 응답속도가 느려졌다.

  • 에러율 : 모든 부하단계에서 0%다. 긍정적이다.

  • 처리량 : 부하가 증가할수록 Throughout ‘증가율’이 증가하다가, 높은 부하에서는 약간 감소된다.

    이는, 서버가 중간 부하까지는 효율적으로 많은 요청을 처리할 수 있었지만, 높은 부하에서는 서버가 이미 최대 처리 능력에 도달했기 때문에 Throuhout 증가율이 둔화되기 시작한 것이다.

JMeter로 성능개선 전 Criteria API 성능테스트를 했으니, 이제 QueryDSL로 코드 개선을 해보자!



2. Criteria API를 QueryDSL로 리팩토링 : 동적쿼리에서 DTO를 사용해 성능 개선

코드개선시 배운, QueryDSL Projections 개념

QueryDSL에서 엔디티가 아닌 DTO로 select쿼리를 날릴 때는 QueryDSL의 Projections 개념을 배워 적용해야했다.

QueryDSL Projections이란

QueryDSL를 이용해 entity 전체의 칼럼을 가져오는 것이 아니라, 조회대상을 지정해 원하는 칼럼(DTO)만 조회하고 싶을 때, 사용한다.

QueryDSL Projections 구현방법 3가지

각 구현방법 3가지 모두 다음의 공통된 DTO를 기반으로 한다.

@Data
public class MemberDto{
	private String username;
	private int age;
	
	public MemberDto(){
	}
	
	public MemberDto(String username, int age){
		this.username = username;
		this.age = age;
	}

}
  • 방법1. 프로퍼티 접근
    Projections.bean 방식은 setter기반으로 동작하게 된다. 다시 말해, MemberDto 객체의 setter 메서드를 사용한다. 하지만 일반적으로 Response, Request 객체는 불변객체를 지향하는 것이 바람직하기에 추천하지 않는 방식이다.

    List<MemberDto> result = queryFactory
      		.select(Projections.bean(MemberDto.class,
      						member.username,
      						member.age))
      		.from(member)
      		.fetch();
  • 방법2. 생성자 사용
    Projections.constructor 방식은 생성자 기반으로 바인딩하기에, MemberDto객체의 불변성을 보장할 수 있다! 다만, 값을 넘길 때, 생성자와 순서가 정확히 일치해야 데이터를 불러오므로, 칼럼개수가 많다면 비추.

    List<MemberDto> result = queryFactory
      		.select(Projections.constructor(MemberDto.class,
      						member.username,
      						member.age))
      		.from(member)
      		.fetch();
  • 방법3. @QueryProjection
    @QueryProjection 어노테이션을 달아주고, 실행시켜주면 DTO 또한 Q파일로 생성해준다. Constructor는 컴파일 오류를 잡지 못하고 런타임 오류가 일어나기에 유저가 코드를 실행하는 순간에서야 문제를 발생할 수 있지만, QueryProjection는 컴파일러로 타입을 체크할 수 있으므로, 가장 안전한 방법이다. 그러나, DTO까지 QueryDSL에 종속적이라는 단점이 있다.

    // 대상이 되는 DTO에 @QueryProjection을 먼저 적용해야 한다!!
    
    List<MemberDto> result = queryFactory
                .select(new QMemberDto(member.username, member.age))
                .from(member)
                .fetch();

개선한 코드

우선 이해를 쉽게 하기 위해, Criteria API를 QueryDSL로 리팩토링한 코드 먼저 살펴보자.

// CourseQueryDSLRepositoryImpl.java
@Repository
@RequiredArgsConstructor
public class CourseQueryDSLRepositoryImpl {
    private final JPAQueryFactory queryFactory;

    // 필터조건에 맞는 코스 리스트 반환 (QueryDSL 사용)
    public Page<CourseDto> findCoursesByFiltersQueryDSL(Long categoryId,
                                                        CourseFilter courseFilter,
                                                        String sort,
                                                        String search,
                                                        Pageable pageable){
        QCourseEntity courseEntity = QCourseEntity.courseEntity;

        // QueryDSL의 경우, entity전체를 가져오는 것이 아니라 조회대상을 지정하고 싶을 때는 Projections을 사용함
        List<CourseDto> results = queryFactory
                .select(new QCourseDto(
                        courseEntity.id,
                        courseEntity.name,
                        courseEntity.subCourseEntity.name,
                        courseEntity.closingDate,
                        courseEntity.tuitionType,
                        courseEntity.onlineOffline,
                        courseEntity.location,
                        courseEntity.startDate,
                        courseEntity.endDate,
                        courseEntity.participationTime,
                        courseEntity.codingTestExempt
                ))
                .from(courseEntity)
// 이전 코드에서는, toCourse로 컨버터에서 변환하면서 N+1문제가 발생했는데, 여기서는 바로 CourseDto로 받으니까, fetch join을 할 필요가 없다!
//                .leftJoin(courseEntity.bootcampEntity).fetchJoin()
//                .leftJoin(courseEntity.categoryEntity).fetchJoin()
//                .leftJoin(courseEntity.subCourseEntity).fetchJoin()
                .where(
                        // 각 표현식(BooleanExpression)이 true인 경우에만, 해당조건이 쿼리에 포함됨 (null이면 포함안됨)
                        categoryEq(categoryId),
                        onlineOfflineEq(courseFilter.getOnlineOffline()),
                        locationEq(courseFilter.getLocation()),
                        tuitionTypeEq(courseFilter.getCost()),
                        participationTimeEq(courseFilter.getParticipationTime()),
                        selectionProcedureEq(courseFilter.getSelectionProcedure()),
                        subCourseEq(courseFilter.getSubCourse()),
                        searchLike(search)
                )
                .orderBy(sortBy(sort)) // 이 부분에서 null인 경우, 기본정렬 반환되도록 처리
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch(); // 리스트로 결과 반환

        // 전체 개수를 위한 카운트 쿼리
        long total = queryFactory
                .select(courseEntity.count())
                .from(courseEntity)
                .where( // 동일한 필터링 조건을 걸어야, courseEntity의 전체개수(페이지로 offset, limit 제한을 두지 않은)를 알 수 있기에.
                        categoryEq(categoryId),
                        onlineOfflineEq(courseFilter.getOnlineOffline()),
                        locationEq(courseFilter.getLocation()),
                        tuitionTypeEq(courseFilter.getCost()),
                        participationTimeEq(courseFilter.getParticipationTime()),
                        selectionProcedureEq(courseFilter.getSelectionProcedure()),
                        subCourseEq(courseFilter.getSubCourse()),
                        searchLike(search)
                )
                .fetchOne(); // 단건 조회 반환

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

    // Category 필터 조건
    private BooleanExpression categoryEq(Long categoryId) {
        return categoryId != null ? QCourseEntity.courseEntity.categoryEntity.id.eq(categoryId) : null;
    }

    // 온라인/오프라인 필터 조건
    private BooleanExpression onlineOfflineEq(String onlineOffline) {
        if (onlineOffline != null) {
            boolean isOnline = "online".equalsIgnoreCase(onlineOffline);
            return QCourseEntity.courseEntity.onlineOffline.eq(isOnline);
        }
        return null;
    }

    // 지역 필터 조건
    private BooleanExpression locationEq(String location) {
        String mappedLocation = mapLocation(location);
        return mappedLocation != null ? QCourseEntity.courseEntity.location.eq(mappedLocation) : null;
    }

    // 비용 필터 조건
    private BooleanExpression tuitionTypeEq(String cost) {
        if (cost != null) {
            if ("free".equalsIgnoreCase(cost)) {
                return QCourseEntity.courseEntity.tuitionType.eq("무료");
            } else if ("paid".equalsIgnoreCase(cost)) {
                return QCourseEntity.courseEntity.tuitionType.ne("무료");
            }
        }
        return null;
    }

    // 참여 시간 필터 조건
    private BooleanExpression participationTimeEq(String participationTime) {
        if (participationTime != null) {
            boolean isFullTime = "full-time".equalsIgnoreCase(participationTime);
            return QCourseEntity.courseEntity.participationTime.eq(
                    isFullTime ? ParticipationTime.FULL_TIME : ParticipationTime.PART_TIME
            );
        }
        return null;
    }

    // 선발 절차 필터 조건
    private BooleanExpression selectionProcedureEq(String selectionProcedure) {
        if (selectionProcedure != null) {
            boolean isCodingTestExempt = "hard".equalsIgnoreCase(selectionProcedure);
            return QCourseEntity.courseEntity.codingTestExempt.eq(isCodingTestExempt);
        }
        return null;
    }

    // SubCourse 필터 조건
    private BooleanExpression subCourseEq(Long subCourseId) {
        return subCourseId != null ? QCourseEntity.courseEntity.subCourseEntity.id.eq(subCourseId) : null;
    }

    // 검색어 필터 조건 -- like 'name%' --> 한쪽에만 % 붙임
    private BooleanExpression searchLike(String search) {
        return search != null ? QCourseEntity.courseEntity.name.startsWith(search) : null;
    }

    // 정렬 조건 추가
    private OrderSpecifier<?> sortBy(String sort) {
        if (sort != null) {
            switch (sort) {
                case "deadline":
                    return QCourseEntity.courseEntity.closingDate.asc();
                case "startDate":
                    return QCourseEntity.courseEntity.startDate.asc();
                case "lowCost":
                    return QCourseEntity.courseEntity.tuitionType.asc();
                case "highCost":
                    return QCourseEntity.courseEntity.tuitionType.desc();
//                case "shortDuration":
//                    return QCourseEntity.courseEntity.durationDays.asc();
//                case "longDuration":
//                    return QCourseEntity.courseEntity.durationDays.desc();
            }
        }
        // sort가 null이면, 기본정렬 설정
        return QCourseEntity.courseEntity.closingDate.asc();
    }

    // html 값 - DB 값 매핑함수
    private String mapLocation(String location) {
        if (location != null) {
            switch (location) {
                case "seoul":
                    return "서울";
                case "busan":
                    return "부산";
                default:
                    return null;
            }
        }
        return null;
    }
}

코드개선점 4가지

위 코드를 구현할 때, 어떤 점들을 고려했는지 정리해보고자 한다.

나는 QueryDSL의 Projections 방법 중, 컴파일러로 타입을 체크할 수 있어 가장 안전한 방법인 QueryProjection을 사용해 구현했다!

또한 다음의 코드를 리팩토링하며, 다음의 4가지 내용을 고려했다.

  1. QueryDSL를 사용해서 바로 CourseDto로 select절 날림 (이때 칼럼개수가 33개 -> 11개로, 1/3이 줄었다!)

  2. fetch join을 할 필요가 없다. -> 기존에 Criteria API에서는 select절로 33개의 칼럼을 날려 CourseEntity를 받고, 이를 다시 Course 모델로 변환하는 과정에서, toCourse 컨버터를 사용해 다른 지연로딩설정된 엔디티(Bootcamp, Category, SubCourse)에도 접근이 일어나, N+1 문제가 발생했다.

-> 그러나, 지금은 select절에서 애초에 처음부터 CourseDto로 반환받기 때문에, 다른 지연로딩엔디티에 접근할 일이 없어 N+1 문제가 발생하지 않아, fetch join을 해주지 않아도 됐다!

  1. 검색어 조건 -> startswith로 바꿔서, '%name%'이 아니라 'name%'으로 쿼리가 나가게끔 수정했다. (인덱스를 효율적으로 사용하기 위해서)

  2. QueryDSL에서 courseEntity.subCourseEntity.name 부분이 걸린다. 근데 왜, N+1 문제가 발생하지 않는걸까??

4번 문제는 특히, N+1 문제와 그 해결책인 fetch join, batch size, 그리고 JPQL과 QueryDSL이 이 문제를 어떻게 다루는지에 대한 깊은 이해가 필요했다. 이 부분을 더 자세히 설명해보겠다!

왜 N+1 문제가 안 일어났을까?

사실 나는 courseEntity.subCourseEntity.name 부분에서 N+1 문제가 발생할 것이라 예상했다.

왜냐하면, subCourseEntity가 지연로딩으로 설정되어 있는 와중에, 프록시 객체의 실제값(subCourseEntity의 name)에 접근했기에, 추가적인 쿼리가 발생할 것이라 생각했기 때문이다.

그런데, 왜 실제로는 N+1 문제가 안 일어나고, Hibernate가 join을 시켜줬을까??

# categories=1로 설정했을 때
[Hibernate] 
    /* select
        courseEntity.id,
        courseEntity.name,
        courseEntity.subCourseEntity.name,
        courseEntity.closingDate,
        courseEntity.tuitionType,
        courseEntity.onlineOffline,
        courseEntity.location,
        courseEntity.startDate,
        courseEntity.endDate,
        courseEntity.participationTime,
        courseEntity.codingTestExempt 
    from
        CourseEntity courseEntity 
    where
        courseEntity.categoryEntity.id = ?1 
    order by
        courseEntity.closingDate asc */ select
            ce1_0.course_id,
            ce1_0.name,
            sce1_0.name,
            ce1_0.closing_date,
            ce1_0.tuition_type,
            ce1_0.online_offline,
            ce1_0.location,
            ce1_0.start_date,
            ce1_0.end_date,
            ce1_0.participation_time,
            ce1_0.coding_test_exempt 
        from
            courses ce1_0 
        join
            sub_courses sce1_0 
                on sce1_0.sub_course_id=ce1_0.sub_course_id 
        where
            ce1_0.category_id=? 
        order by
            ce1_0.closing_date 
        offset
            ? rows 
        fetch
            first ? rows only

원래 N+1 문제는 언제 발생하고, 해결방법이 어떻게 적용되는데?

왜 N+1 문제가 발생하지 않았는지 궁금해서, 결국 N+1 문제는 정확히 언제 발생하는지와 이 문제해결방법인 fetch join과 batch size가 어떻게 작동하는지 다시 정리해보았다.

내벨로그 - N+1문제와 그 해결법

SubCourseEntity가 지연로딩으로 설정되어 있는데도, N+1 문제가 발생하지 않고, Hibernate가 자동으로 join을 통해 데이터를 가져오는 이유

핵심은, QueryDSLJPQL이 select절의 필드를 보고, 서로 다른 동작을 수행하기 때문이다.

JPQL은 기본적으로 지연로딩된 엔디티에 대해 별도의 쿼리를 날리는 방식으로 동작하는 것이 일반적이다. 이로 인해 연관된 엔디티 개수만큼 추가 쿼리가 나가는 N+1 문제가 발생할 수 있다. 그래서 JPQL에서는 이를 해결하려면, 명시적으로 fetch join을 사용해, 강제로 연관된 엔디티를 가져와야 했다.

하지만 이때, select절에서는 원본 엔디티의 필드뿐만 아니라, fetch join된 연관된 엔디티들의 필드까지 모두 가져와서 필요이상의 필드를 조회하는 단점이 있었다!


반면, QueryDSL에서 Projections를 사용해서 특정 필드를 명시적으로 선택할 때(DTO), 내부적으로 해당 필드가 어떤 엔디티와 연관되어 있는지 파악하고, 필요한 경우** 자동으로 join**을 수행해준다!! 즉, select절에서 명시한 필드에 지연로딩으로 설정된 연관관계가 있으면, 그 필드를 가져오기 위해 join을 사용해 한번의 쿼리로 데이터를 가져오는 방식으로 최적화할 수 있다.

위 예시에서는, CourseEntity와 SubCourseEntity의 연관관계에서 CourseEntity.subCourseEntity.name같은 필드를 Projections에서 명시했다면, QueryDSL은 이 필드가 지연로딩된 연관객체라는 것을 알고, 이를 해결하기 위해 join을 자동으로 추가해준다. 이 과정에서 추가쿼리가 발생하지 않도록 하는 것이다!

생각해보면 그런 것 같다.

애초에 JPQL에서 N+1 문제를 해결하려고 fetch join을 사용하면, 또다른 문제로 select절 칼럼개수가 필요이상으로 많아지는 단점이 있었다. 그런데 해당 단점을 개선하기 위해 QueryDSL의 Projections를 사용했는데, 여기서 다시 fetch join을 사용하면 말짱도루묵이다..

4번째 코드개선점, 결론

물론, QueryDSL에서 fetch join을 사용할 수는 있지만, 굳이 사용하지 않아도 Hibernate가 자동으로 최적화된 쿼리를 생성하여 N+1 문제를 방지할 수 있다.

따라서, fetch join은 필요할 때만 명시적으로 사용하고, QueryDSL과 Hibernate 조합에서는 기본 설정만으로 대부분의 N+1 문제를 해결할 수 있다.


다시 한번, 개선한 코드를 보자!

위의 4가지 코드개선점들 알고, 다시 개선된 코드를 보면 이해가 쉽다.

@Repository
@RequiredArgsConstructor
public class CourseQueryDSLRepositoryImpl {
    private final JPAQueryFactory queryFactory;

    // 필터조건에 맞는 코스 리스트 반환 (QueryDSL 사용)
    public Page<CourseDto> findCoursesByFiltersQueryDSL(Long categoryId,
                                                        CourseFilter courseFilter,
                                                        String sort,
                                                        String search,
                                                        Pageable pageable){
        QCourseEntity courseEntity = QCourseEntity.courseEntity;

        // QueryDSL의 경우, entity전체를 가져오는 것이 아니라 조회대상을 지정하고 싶을 때는 Projections을 사용함
        List<CourseDto> results = queryFactory
                .select(new QCourseDto(
                        courseEntity.id,
                        courseEntity.name,
                        courseEntity.subCourseEntity.name,
                        courseEntity.closingDate,
                        courseEntity.tuitionType,
                        courseEntity.onlineOffline,
                        courseEntity.location,
                        courseEntity.startDate,
                        courseEntity.endDate,
                        courseEntity.participationTime,
                        courseEntity.codingTestExempt
                ))
                .from(courseEntity)
// 이전 코드에서는, toCourse로 컨버터에서 변환하면서 N+1문제가 발생했는데, 여기서는 바로 CourseDto로 받으니까, fetch join을 할 필요가 없다!
//                .leftJoin(courseEntity.bootcampEntity).fetchJoin()
//                .leftJoin(courseEntity.categoryEntity).fetchJoin()
//                .leftJoin(courseEntity.subCourseEntity).fetchJoin()
                .where(
                        // 각 표현식(BooleanExpression)이 true인 경우에만, 해당조건이 쿼리에 포함됨 (null이면 포함안됨)
                        categoryEq(categoryId),
                        onlineOfflineEq(courseFilter.getOnlineOffline()),
                        locationEq(courseFilter.getLocation()),
                        tuitionTypeEq(courseFilter.getCost()),
                        participationTimeEq(courseFilter.getParticipationTime()),
                        selectionProcedureEq(courseFilter.getSelectionProcedure()),
                        subCourseEq(courseFilter.getSubCourse()),
                        searchLike(search)
                )
                .orderBy(sortBy(sort)) // 이 부분에서 null인 경우, 기본정렬 반환되도록 처리
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch(); // 리스트로 결과 반환

        // 전체 개수를 위한 카운트 쿼리
        long total = queryFactory
                .select(courseEntity.count())
                .from(courseEntity)
                .where( // 동일한 필터링 조건을 걸어야, courseEntity의 전체개수(페이지로 offset, limit 제한을 두지 않은)를 알 수 있기에.
                        categoryEq(categoryId),
                        onlineOfflineEq(courseFilter.getOnlineOffline()),
                        locationEq(courseFilter.getLocation()),
                        tuitionTypeEq(courseFilter.getCost()),
                        participationTimeEq(courseFilter.getParticipationTime()),
                        selectionProcedureEq(courseFilter.getSelectionProcedure()),
                        subCourseEq(courseFilter.getSubCourse()),
                        searchLike(search)
                )
                .fetchOne(); // 단건 조회 반환

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

    // Category 필터 조건
    private BooleanExpression categoryEq(Long categoryId) {
        return categoryId != null ? QCourseEntity.courseEntity.categoryEntity.id.eq(categoryId) : null;
    }

    // 온라인/오프라인 필터 조건
    private BooleanExpression onlineOfflineEq(String onlineOffline) {
        if (onlineOffline != null) {
            boolean isOnline = "online".equalsIgnoreCase(onlineOffline);
            return QCourseEntity.courseEntity.onlineOffline.eq(isOnline);
        }
        return null;
    }

    // 지역 필터 조건
    private BooleanExpression locationEq(String location) {
        String mappedLocation = mapLocation(location);
        return mappedLocation != null ? QCourseEntity.courseEntity.location.eq(mappedLocation) : null;
    }

    // 비용 필터 조건
    private BooleanExpression tuitionTypeEq(String cost) {
        if (cost != null) {
            if ("free".equalsIgnoreCase(cost)) {
                return QCourseEntity.courseEntity.tuitionType.eq("무료");
            } else if ("paid".equalsIgnoreCase(cost)) {
                return QCourseEntity.courseEntity.tuitionType.ne("무료");
            }
        }
        return null;
    }

    // 참여 시간 필터 조건
    private BooleanExpression participationTimeEq(String participationTime) {
        if (participationTime != null) {
            boolean isFullTime = "full-time".equalsIgnoreCase(participationTime);
            return QCourseEntity.courseEntity.participationTime.eq(
                    isFullTime ? ParticipationTime.FULL_TIME : ParticipationTime.PART_TIME
            );
        }
        return null;
    }

    // 선발 절차 필터 조건
    private BooleanExpression selectionProcedureEq(String selectionProcedure) {
        if (selectionProcedure != null) {
            boolean isCodingTestExempt = "hard".equalsIgnoreCase(selectionProcedure);
            return QCourseEntity.courseEntity.codingTestExempt.eq(isCodingTestExempt);
        }
        return null;
    }

    // SubCourse 필터 조건
    private BooleanExpression subCourseEq(Long subCourseId) {
        return subCourseId != null ? QCourseEntity.courseEntity.subCourseEntity.id.eq(subCourseId) : null;
    }

    // 검색어 필터 조건 -- like 'name%' --> 한쪽에만 % 붙임
    private BooleanExpression searchLike(String search) {
        return search != null ? QCourseEntity.courseEntity.name.startsWith(search) : null;
    }

    // 정렬 조건 추가
    private OrderSpecifier<?> sortBy(String sort) {
        if (sort != null) {
            switch (sort) {
                case "deadline":
                    return QCourseEntity.courseEntity.closingDate.asc();
                case "startDate":
                    return QCourseEntity.courseEntity.startDate.asc();
                case "lowCost":
                    return QCourseEntity.courseEntity.tuitionType.asc();
                case "highCost":
                    return QCourseEntity.courseEntity.tuitionType.desc();
//                case "shortDuration":
//                    return QCourseEntity.courseEntity.durationDays.asc();
//                case "longDuration":
//                    return QCourseEntity.courseEntity.durationDays.desc();
            }
        }
        // sort가 null이면, 기본정렬 설정
        return QCourseEntity.courseEntity.closingDate.asc();
    }

    // html 값 - DB 값 매핑함수
    private String mapLocation(String location) {
        if (location != null) {
            switch (location) {
                case "seoul":
                    return "서울";
                case "busan":
                    return "부산";
                default:
                    return null;
            }
        }
        return null;
    }
}



3. 개선된 쿼리비교와 성능 비교

개선 전/후, 날라간 쿼리문 비교

6개의 HTTP 요청 URL별로 날라간 쿼리문을 개선 전후 코드로 비교해본다. (CourseService.findCoursesByFiltersQueryDSL()메서드)

결국 select절의 칼럼 개수가 줄어든 것을 확인할 수 있었다.

  1. GET /boothouse/camps?categories=1

    • 날라간 쿼리문 (개선전)
    [Hibernate] 
    /* <criteria> */ select
        distinct ce1_0.course_id,
        ce1_0.average_rating,
        be1_0.bootcamp_id,
        be1_0.description,
        be1_0.last_modified_date,
        be1_0.location,
        be1_0.logo,
        be1_0.name,
        be1_0.registration_date,
        be1_0.url,
        ce1_0.card_requirement,
        ce2_0.category_id,
        ce2_0.last_modified_date,
        ce2_0.name,
        ce2_0.registration_date,
        ce1_0.closing_date,
        ce1_0.coding_test_exempt,
        ce1_0.end_date,
        ce1_0.last_modified_date,
        ce1_0.location,
        ce1_0.max_participants,
        ce1_0.name,
        ce1_0.online_offline,
        ce1_0.participation_time,
        ce1_0.registration_date,
        ce1_0.start_date,
        sce1_0.sub_course_id,
        sce1_0.category_id,
        sce1_0.last_modified_date,
        sce1_0.name,
        sce1_0.registration_date,
        ce1_0.summary,
        ce1_0.tuition_type 
    from
        courses ce1_0 
    left join
        bootcamps be1_0 
            on be1_0.bootcamp_id=ce1_0.bootcamp_id 
    left join
        categories ce2_0 
            on ce2_0.category_id=ce1_0.category_id 
    left join
        sub_courses sce1_0 
            on sce1_0.sub_course_id=ce1_0.sub_course_id 
    where
        ce1_0.closing_date>? 
        and ce1_0.category_id=? 
    order by
        ce1_0.closing_date 
    offset
        ? rows 
    fetch
        first ? rows only
    • 날라간 쿼리문 (개선후)
    [Hibernate] 
    /* select
        courseEntity.id,
        courseEntity.name,
        courseEntity.subCourseEntity.name,
        courseEntity.closingDate,
        courseEntity.tuitionType,
        courseEntity.onlineOffline,
        courseEntity.location,
        courseEntity.startDate,
        courseEntity.endDate,
        courseEntity.participationTime,
        courseEntity.codingTestExempt 
    from
        CourseEntity courseEntity 
    where
        courseEntity.categoryEntity.id = ?1 
    order by
        courseEntity.closingDate asc */ select
            ce1_0.course_id,
            ce1_0.name,
            sce1_0.name,
            ce1_0.closing_date,
            ce1_0.tuition_type,
            ce1_0.online_offline,
            ce1_0.location,
            ce1_0.start_date,
            ce1_0.end_date,
            ce1_0.participation_time,
            ce1_0.coding_test_exempt 
        from
            courses ce1_0 
        join
            sub_courses sce1_0 
                on sce1_0.sub_course_id=ce1_0.sub_course_id 
        where
            ce1_0.category_id=? 
        order by
            ce1_0.closing_date 
        offset
            ? rows 
        fetch
            first ? rows only
  2. GET /boothouse/camps?onlineOffline=online

    • 날라간 쿼리문 (개선전)
    [Hibernate] 
    /* <criteria> */ select
        distinct ce1_0.course_id,
        ce1_0.average_rating,
        be1_0.bootcamp_id,
        be1_0.description,
        be1_0.last_modified_date,
        be1_0.location,
        be1_0.logo,
        be1_0.name,
        be1_0.registration_date,
        be1_0.url,
        ce1_0.card_requirement,
        ce2_0.category_id,
        ce2_0.last_modified_date,
        ce2_0.name,
        ce2_0.registration_date,
        ce1_0.closing_date,
        ce1_0.coding_test_exempt,
        ce1_0.end_date,
        ce1_0.last_modified_date,
        ce1_0.location,
        ce1_0.max_participants,
        ce1_0.name,
        ce1_0.online_offline,
        ce1_0.participation_time,
        ce1_0.registration_date,
        ce1_0.start_date,
        sce1_0.sub_course_id,
        sce1_0.category_id,
        sce1_0.last_modified_date,
        sce1_0.name,
        sce1_0.registration_date,
        ce1_0.summary,
        ce1_0.tuition_type 
    from
        courses ce1_0 
    left join
        bootcamps be1_0 
            on be1_0.bootcamp_id=ce1_0.bootcamp_id 
    left join
        categories ce2_0 
            on ce2_0.category_id=ce1_0.category_id 
    left join
        sub_courses sce1_0 
            on sce1_0.sub_course_id=ce1_0.sub_course_id 
    where
        ce1_0.closing_date>? 
        and ce1_0.online_offline=? 
    order by
        ce1_0.closing_date 
    offset
        ? rows 
    fetch
        first ? rows only
    • 날라간 쿼리문 (개선후)
        /* select
        courseEntity.id,
        courseEntity.name,
        courseEntity.subCourseEntity.name,
        courseEntity.closingDate,
        courseEntity.tuitionType,
        courseEntity.onlineOffline,
        courseEntity.location,
        courseEntity.startDate,
        courseEntity.endDate,
        courseEntity.participationTime,
        courseEntity.codingTestExempt 
    from
        CourseEntity courseEntity 
    where
        courseEntity.onlineOffline = ?1 
    order by
        courseEntity.closingDate asc */ select
            ce1_0.course_id,
            ce1_0.name,
            sce1_0.name,
            ce1_0.closing_date,
            ce1_0.tuition_type,
            ce1_0.online_offline,
            ce1_0.location,
            ce1_0.start_date,
            ce1_0.end_date,
            ce1_0.participation_time,
            ce1_0.coding_test_exempt 
        from
            courses ce1_0 
        join
            sub_courses sce1_0 
                on sce1_0.sub_course_id=ce1_0.sub_course_id 
        where
            ce1_0.online_offline=? 
        order by
            ce1_0.closing_date 
        offset
            ? rows 
        fetch
            first ? rows only
  3. GET /boothouse/camps?onlineOffline=online&sort=deadline

    • 날라간 쿼리문 (개선전)
    [Hibernate] 
    /* <criteria> */ select
        distinct ce1_0.course_id,
        ce1_0.average_rating,
        be1_0.bootcamp_id,
        be1_0.description,
        be1_0.last_modified_date,
        be1_0.location,
        be1_0.logo,
        be1_0.name,
        be1_0.registration_date,
        be1_0.url,
        ce1_0.card_requirement,
        ce2_0.category_id,
        ce2_0.last_modified_date,
        ce2_0.name,
        ce2_0.registration_date,
        ce1_0.closing_date,
        ce1_0.coding_test_exempt,
        ce1_0.end_date,
        ce1_0.last_modified_date,
        ce1_0.location,
        ce1_0.max_participants,
        ce1_0.name,
        ce1_0.online_offline,
        ce1_0.participation_time,
        ce1_0.registration_date,
        ce1_0.start_date,
        sce1_0.sub_course_id,
        sce1_0.category_id,
        sce1_0.last_modified_date,
        sce1_0.name,
        sce1_0.registration_date,
        ce1_0.summary,
        ce1_0.tuition_type 
    from
        courses ce1_0 
    left join
        bootcamps be1_0 
            on be1_0.bootcamp_id=ce1_0.bootcamp_id 
    left join
        categories ce2_0 
            on ce2_0.category_id=ce1_0.category_id 
    left join
        sub_courses sce1_0 
            on sce1_0.sub_course_id=ce1_0.sub_course_id 
    where
        ce1_0.closing_date>? 
        and ce1_0.online_offline=? 
    order by
        ce1_0.closing_date 
    offset
        ? rows 
    fetch
        first ? rows only
    • 날라간 쿼리문 (개선후)
    [Hibernate] 
    /* select
        courseEntity.id,
        courseEntity.name,
        courseEntity.subCourseEntity.name,
        courseEntity.closingDate,
        courseEntity.tuitionType,
        courseEntity.onlineOffline,
        courseEntity.location,
        courseEntity.startDate,
        courseEntity.endDate,
        courseEntity.participationTime,
        courseEntity.codingTestExempt 
    from
        CourseEntity courseEntity 
    where
        courseEntity.onlineOffline = ?1 
    order by
        courseEntity.closingDate asc */ select
            ce1_0.course_id,
            ce1_0.name,
            sce1_0.name,
            ce1_0.closing_date,
            ce1_0.tuition_type,
            ce1_0.online_offline,
            ce1_0.location,
            ce1_0.start_date,
            ce1_0.end_date,
            ce1_0.participation_time,
            ce1_0.coding_test_exempt 
        from
            courses ce1_0 
        join
            sub_courses sce1_0 
                on sce1_0.sub_course_id=ce1_0.sub_course_id 
        where
            ce1_0.online_offline=? 
        order by
            ce1_0.closing_date 
        offset
            ? rows 
        fetch
            first ? rows only
  4. GET /boothouse/camps?categories=1&onlineOffline=online&sort=deadline

    • 날라간 쿼리문 (개선전)
    [Hibernate] 
    /* <criteria> */ select
        distinct ce1_0.course_id,
        ce1_0.average_rating,
        be1_0.bootcamp_id,
        be1_0.description,
        be1_0.last_modified_date,
        be1_0.location,
        be1_0.logo,
        be1_0.name,
        be1_0.registration_date,
        be1_0.url,
        ce1_0.card_requirement,
        ce2_0.category_id,
        ce2_0.last_modified_date,
        ce2_0.name,
        ce2_0.registration_date,
        ce1_0.closing_date,
        ce1_0.coding_test_exempt,
        ce1_0.end_date,
        ce1_0.last_modified_date,
        ce1_0.location,
        ce1_0.max_participants,
        ce1_0.name,
        ce1_0.online_offline,
        ce1_0.participation_time,
        ce1_0.registration_date,
        ce1_0.start_date,
        sce1_0.sub_course_id,
        sce1_0.category_id,
        sce1_0.last_modified_date,
        sce1_0.name,
        sce1_0.registration_date,
        ce1_0.summary,
        ce1_0.tuition_type 
    from
        courses ce1_0 
    left join
        bootcamps be1_0 
            on be1_0.bootcamp_id=ce1_0.bootcamp_id 
    left join
        categories ce2_0 
            on ce2_0.category_id=ce1_0.category_id 
    left join
        sub_courses sce1_0 
            on sce1_0.sub_course_id=ce1_0.sub_course_id 
    where
        ce1_0.closing_date>? 
        and ce1_0.category_id=? 
        and ce1_0.online_offline=? 
    order by
        ce1_0.closing_date 
    offset
        ? rows 
    fetch
        first ? rows only
    • 날라간 쿼리문 (개선후)
    [Hibernate] 
    /* select
        courseEntity.id,
        courseEntity.name,
        courseEntity.subCourseEntity.name,
        courseEntity.closingDate,
        courseEntity.tuitionType,
        courseEntity.onlineOffline,
        courseEntity.location,
        courseEntity.startDate,
        courseEntity.endDate,
        courseEntity.participationTime,
        courseEntity.codingTestExempt 
    from
        CourseEntity courseEntity 
    where
        courseEntity.categoryEntity.id = ?1 
        and courseEntity.onlineOffline = ?2 
    order by
        courseEntity.closingDate asc */ select
            ce1_0.course_id,
            ce1_0.name,
            sce1_0.name,
            ce1_0.closing_date,
            ce1_0.tuition_type,
            ce1_0.online_offline,
            ce1_0.location,
            ce1_0.start_date,
            ce1_0.end_date,
            ce1_0.participation_time,
            ce1_0.coding_test_exempt 
        from
            courses ce1_0 
        join
            sub_courses sce1_0 
                on sce1_0.sub_course_id=ce1_0.sub_course_id 
        where
            ce1_0.category_id=? 
            and ce1_0.online_offline=? 
        order by
            ce1_0.closing_date 
        offset
            ? rows 
        fetch
            first ? rows only
  5. GET /boothouse/camps?page=1

    • 날라간 쿼리문 (개선전)
    [Hibernate] 
    /* <criteria> */ select
        distinct ce1_0.course_id,
        ce1_0.average_rating,
        be1_0.bootcamp_id,
        be1_0.description,
        be1_0.last_modified_date,
        be1_0.location,
        be1_0.logo,
        be1_0.name,
        be1_0.registration_date,
        be1_0.url,
        ce1_0.card_requirement,
        ce2_0.category_id,
        ce2_0.last_modified_date,
        ce2_0.name,
        ce2_0.registration_date,
        ce1_0.closing_date,
        ce1_0.coding_test_exempt,
        ce1_0.end_date,
        ce1_0.last_modified_date,
        ce1_0.location,
        ce1_0.max_participants,
        ce1_0.name,
        ce1_0.online_offline,
        ce1_0.participation_time,
        ce1_0.registration_date,
        ce1_0.start_date,
        sce1_0.sub_course_id,
        sce1_0.category_id,
        sce1_0.last_modified_date,
        sce1_0.name,
        sce1_0.registration_date,
        ce1_0.summary,
        ce1_0.tuition_type 
    from
        courses ce1_0 
    left join
        bootcamps be1_0 
            on be1_0.bootcamp_id=ce1_0.bootcamp_id 
    left join
        categories ce2_0 
            on ce2_0.category_id=ce1_0.category_id 
    left join
        sub_courses sce1_0 
            on sce1_0.sub_course_id=ce1_0.sub_course_id 
    where
        ce1_0.closing_date>? 
    order by
        ce1_0.closing_date 
    offset
        ? rows 
    fetch
        first ? rows only
    • 날라간 쿼리문 (개선후)
    [Hibernate] 
    /* select
        courseEntity.id,
        courseEntity.name,
        courseEntity.subCourseEntity.name,
        courseEntity.closingDate,
        courseEntity.tuitionType,
        courseEntity.onlineOffline,
        courseEntity.location,
        courseEntity.startDate,
        courseEntity.endDate,
        courseEntity.participationTime,
        courseEntity.codingTestExempt 
    from
        CourseEntity courseEntity 
    order by
        courseEntity.closingDate asc */ select
            ce1_0.course_id,
            ce1_0.name,
            sce1_0.name,
            ce1_0.closing_date,
            ce1_0.tuition_type,
            ce1_0.online_offline,
            ce1_0.location,
            ce1_0.start_date,
            ce1_0.end_date,
            ce1_0.participation_time,
            ce1_0.coding_test_exempt 
        from
            courses ce1_0 
        join
            sub_courses sce1_0 
                on sce1_0.sub_course_id=ce1_0.sub_course_id 
        order by
            ce1_0.closing_date 
        offset
            ? rows 
        fetch
            first ? rows only
  6. GET /boothouse/camps?page=1&size=10

    • 날라간 쿼리문 (개선전)
    [Hibernate] 
    /* <criteria> */ select
        distinct ce1_0.course_id,
        ce1_0.average_rating,
        be1_0.bootcamp_id,
        be1_0.description,
        be1_0.last_modified_date,
        be1_0.location,
        be1_0.logo,
        be1_0.name,
        be1_0.registration_date,
        be1_0.url,
        ce1_0.card_requirement,
        ce2_0.category_id,
        ce2_0.last_modified_date,
        ce2_0.name,
        ce2_0.registration_date,
        ce1_0.closing_date,
        ce1_0.coding_test_exempt,
        ce1_0.end_date,
        ce1_0.last_modified_date,
        ce1_0.location,
        ce1_0.max_participants,
        ce1_0.name,
        ce1_0.online_offline,
        ce1_0.participation_time,
        ce1_0.registration_date,
        ce1_0.start_date,
        sce1_0.sub_course_id,
        sce1_0.category_id,
        sce1_0.last_modified_date,
        sce1_0.name,
        sce1_0.registration_date,
        ce1_0.summary,
        ce1_0.tuition_type 
    from
        courses ce1_0 
    left join
        bootcamps be1_0 
            on be1_0.bootcamp_id=ce1_0.bootcamp_id 
    left join
        categories ce2_0 
            on ce2_0.category_id=ce1_0.category_id 
    left join
        sub_courses sce1_0 
            on sce1_0.sub_course_id=ce1_0.sub_course_id 
    where
        ce1_0.closing_date>? 
    order by
        ce1_0.closing_date 
    offset
        ? rows 
    fetch
        first ? rows only
    • 날라간 쿼리문 (개선후)
    [Hibernate] 
    /* select
        courseEntity.id,
        courseEntity.name,
        courseEntity.subCourseEntity.name,
        courseEntity.closingDate,
        courseEntity.tuitionType,
        courseEntity.onlineOffline,
        courseEntity.location,
        courseEntity.startDate,
        courseEntity.endDate,
        courseEntity.participationTime,
        courseEntity.codingTestExempt 
    from
        CourseEntity courseEntity 
    order by
        courseEntity.closingDate asc */ select
            ce1_0.course_id,
            ce1_0.name,
            sce1_0.name,
            ce1_0.closing_date,
            ce1_0.tuition_type,
            ce1_0.online_offline,
            ce1_0.location,
            ce1_0.start_date,
            ce1_0.end_date,
            ce1_0.participation_time,
            ce1_0.coding_test_exempt 
        from
            courses ce1_0 
        join
            sub_courses sce1_0 
                on sce1_0.sub_course_id=ce1_0.sub_course_id 
        order by
            ce1_0.closing_date 
        offset
            ? rows 
        fetch
            first ? rows only

개선후, 성능테스트

일전에 Criteria API에서 성능테스트할 때의 조건과 동일하다. 단지 Criteria API에서 QueryDSL로 리팩토링했을 뿐이다.

select절에서 필드가 줄어든 것이 성능에 어떤 영향을 주었을까?

  1. 낮은 부하일 때, Summary Report

    총 요청횟수(# Samples)는 의도했던대로 600번 실행되었다.

    처리시간과 처리량을 기준으로 성능분석을 해보면 다음과 같다.

    • 처리시간(Average; 요청 1건이 처리되는 평균시간_ : 약 24ms
    • 처리량 Error는 0%일 때, Throughout(1초당 처리가능한 요청개수) : 약 57.8개
  2. 중간 부하일 때, Summary Report

    총 요청횟수(# Samples)는 의도했던대로 3000번 실행되었다.

    처리시간과 처리량을 기준으로 성능분석을 해보면 다음과 같다.

    • 처리시간(Average; 요청 1건이 처리되는 평균시간_ : 약 61ms
    • 처리량 Error는 0%일 때, Throughout(1초당 처리가능한 요청개수) : 약 236.5개
  3. 높은 부하일 때, Summary Report

    총 요청횟수(# Samples)는 의도했던대로 5400번 실행되었다.

    처리시간과 처리량을 기준으로 성능분석을 해보면 다음과 같다.

    • 처리시간(Average; 요청 1건이 처리되는 평균시간_ : 약 209ms
    • 처리량 Error는 0%일 때, Throughout(1초당 처리가능한 요청개수) : 약 251.5개

성능분석

아래 포에서 왼쪽은 성능개선 전, 오른쪽은 성능개선 후 값들이다.

처리시간(Average)(ms) 에러율(%) 처리량(Throughout)(초)
낮은 부하 155 -> 24 0 34.1개/초 -> 57.1개/초
중간 부하 866 -> 61 0 48.0개/초 -> 236.5개/초
높은 부하 1830 -> 209 0 48.7개/초 -> 251.5개/초

위 값들을 참고해 성능분석을 하면 다음과 같다.


결론

성능개선 전후를 비교했을 때, 처리시간이 평균적으로 84.2% ~ 92.9%까지 크게 감소했다.

에러율은 항상 0%로 유지되었고, 처리량 또한 적게는 67.4% 증가 ~ 크게는 416.4%까지 크게 증가했다!

전체적으로 성능 개선에 성공했다!



⚠️ **GitHub.com Fallback** ⚠️