본문 바로가기
SpringBoot/성능 개선 흔적들

[Spring] DTO 반환 시 필요한 응답만 QueryProjection으로 매핑하여 반환

by rla124 2024. 12. 7.

요새 병행하고 있는 일뿐만 아니라 내가 접해보지 못했던 새로운 지식을 적용해볼 수 있도록 하기 위해 기존의 사이드 프로젝트를 지속 및 유지하고 있는 중이다. 
 
지금 내가 병행하고 있는 프로젝트는 2024년 극초반부터 진행했던 WHOA이다. WHOA는 iOS 앱스토어에 2차 릴리즈까지 마치고 3차 MVP를 개발하고 있는 꽃다발 커스터마이징 서비스이다. 백엔드는 현재 내가 혼자 맡고 있는데 이전에 함께 작업하시던 분께서 작업하시던 코드를 코드 성능 개선 및 리펙토링 목적으로 찬찬히 뜯어보고 있던 도중 비효율적이고 반복적인 쿼리가 발생하는 것을 알게 되었다.
현재 게시글은 모두 다른 분의 코드를 보고 이해하며 성능 개선을 한 사례에 대한 글이다. 
 

문제 상황

성능 개선이 완료된 pr 링크
WHOA 프로젝트는 화훼 꽃 유통 사이트에서 API-KEY를 이용해서 외부 데이터를 주기적으로 호출하는 스케쥴러가 작동하고 있다. 스케쥴러를 통해서 화훼 사이트에서 꽃의 가격 등등의 데이터를 받아오고 flowerRanking 테이블에 저렴한 가격 순으로 1위부터 5위까지 5개의 row 정보를 dump update 시켜 저장한다.
스케쥴러 작동 방식에 대해서도 아이디어가 떠올라 작업 중인 상황이라 추후에 이 부분은 다시 블로그에서 언급할 예정이다. 이 게시글에서 내가 기록할 첫 번째 개선할 API는 외부 API 호출 이후 위와 같이 랭킹 테이블에 업데이트 시키고 1~5위까지 저렴한 꽃 5개의 row를 조회하는! 저렴한 꽃 순위 랭킹 조회 API이다. 이 api에서 비효율적인 로직을 통해 response를 반환하고 있었다는 점을 알게 되었다.
 

@Transactional
public List<FlowerRankingResponseDto> getFlowerRanking(){
    List<FlowerRankingResponseDto> flowerRankings = new ArrayList<>();
    for (long i=0; i<5; i++){
        FlowerRanking flowerRankingOne = flowerRankingRepository.findByFlowerRankingId(i+1);
        FlowerRankingResponseDto flowerRankingResponseDtoOne = new FlowerRankingResponseDto(
            flowerRankingOne.getFlowerRankingId(), 
            flowerRankingOne.getFlowerRankingName(), 
            flowerRankingOne.getFlowerRankingLanguage(), 
            flowerRankingOne.getFlowerRankingPrice(), 
            flowerRankingOne.getFlowerRankingDate(), 
            flowerRankingOne.getFlowerImage(), 
            flowerRankingOne.getFlowerId());
        flowerRankings.add(flowerRankingResponseDtoOne);
    }
    return flowerRankings;
}

 
 
이 코드를 보고 repository에서 findAll을 하면 되는데 왜 하나씩 들고오려고 쿼리를 날려서 dto를 만들고, 또 dto list에 add를 하고 있을까 라는 생각이 들었다.
 
실제 5개의 꽃을 조회하기 위해 아래와 같이 매번 5번의 쿼리가 나가는 상황이었다.

2024-11-16T19:51:59.694+09:00 DEBUG 22620 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : GET "/api/ranking", parameters={}
2024-11-16T19:51:59.717+09:00 DEBUG 22620 --- [nio-8080-exec-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.whoa.whoaserver.crawl.controller.FlowerRankingController#getFlowerRanking()
Hibernate: 
    select
        fr1_0.flower_ranking_id,
        fr1_0.flower_id,
        fr1_0.flower_image,
        fr1_0.flower_ranking_date,
        fr1_0.flower_ranking_language,
        fr1_0.flower_ranking_name,
        fr1_0.flower_ranking_price,
        fr1_0.created_at
    from
        flower_ranking fr1_0 
    where
        fr1_0.flower_ranking_id=?
2024-11-16T19:52:00.093+09:00 TRACE 22620 --- [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [1]
Hibernate: 
    select
        fr1_0.flower_ranking_id,
        fr1_0.flower_id,
        fr1_0.flower_image,
        fr1_0.flower_ranking_date,
        fr1_0.flower_ranking_language,
        fr1_0.flower_ranking_name,
        fr1_0.flower_ranking_price,
        fr1_0.created_at
    from
        flower_ranking fr1_0 
    where
        fr1_0.flower_ranking_id=?
2024-11-16T19:52:00.192+09:00 TRACE 22620 --- [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [2]
Hibernate: 
    select
        fr1_0.flower_ranking_id,
        fr1_0.flower_id,
        fr1_0.flower_image,
        fr1_0.flower_ranking_date,
        fr1_0.flower_ranking_language,
        fr1_0.flower_ranking_name,
        fr1_0.flower_ranking_price,
        fr1_0.created_at
    from
        flower_ranking fr1_0 
    where
        fr1_0.flower_ranking_id=?
2024-11-16T19:52:00.305+09:00 TRACE 22620 --- [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [3]
Hibernate: 
    select
        fr1_0.flower_ranking_id,
        fr1_0.flower_id,
        fr1_0.flower_image,
        fr1_0.flower_ranking_date,
        fr1_0.flower_ranking_language,
        fr1_0.flower_ranking_name,
        fr1_0.flower_ranking_price,
        fr1_0.created_at
    from
        flower_ranking fr1_0 
    where
        fr1_0.flower_ranking_id=?
2024-11-16T19:52:00.375+09:00 TRACE 22620 --- [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [4]
Hibernate: 
    select
        fr1_0.flower_ranking_id,
        fr1_0.flower_id,
        fr1_0.flower_image,
        fr1_0.flower_ranking_date,
        fr1_0.flower_ranking_language,
        fr1_0.flower_ranking_name,
        fr1_0.flower_ranking_price,
        fr1_0.created_at
    from
        flower_ranking fr1_0 
    where
        fr1_0.flower_ranking_id=?
2024-11-16T19:52:00.390+09:00 TRACE 22620 --- [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [5]
2024-11-16T19:52:00.460+09:00 DEBUG 22620 --- [nio-8080-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Using 'application/json;q=0.8', given [text/html, application/xhtml+xml, image/avif, image/webp, image/apng, application/xml;q=0.9, */*;q=0.8, application/signed-exchange;v=b3;q=0.7] and supported [application/json, application/*+json, application/cbor]
2024-11-16T19:52:00.476+09:00 DEBUG 22620 --- [nio-8080-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Writing [CommonResponse[timestamp=2024-11-16T19:52:00.467695800, success=true, status=200, data=[com.whoa.who (truncated)...]
2024-11-16T19:52:00.573+09:00 DEBUG 22620 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed 200 OK

 
 

아이디어

1. findBy 쿼리로 랭킹 5위까지의 모든 정보를 얻기 위해 5개의 쿼리를 날리는 것보다 한번의 select 쿼리를 통해 모든 5개의 flowerRanking에 저장된 꽃 정보를 가져올 수 있지 않을까?
2. 지금은 마치 flowerRankingResponseDto는 flowerRanking 객체만 전달해주면 해당 필드에 객체 정보를 매핑할 수 있는 from static 메소드 정의한 결과와 동일한데 지금처럼 repository에서 하나의 엔티티 객체에 대한 모든 column를 다 들고 와서 dto에 매핑하는 것보다 필요한 객체의 column만 들고와서 dto에 매핑할 수는 없나? 
라는 생각이 들었다.
 
 

해결 방안 : queryDSL QueryProjection

한번의 from flowerRanking table을 기준으로 fetchOne()이 아닌 fetch()를 통해 리턴되는 모든 flowerRanking 객체 중에서도 && 각 객체에 대해 dto에 필요한 필드만 select를 하기 위해 queryDSL을 선택했다. 
 
특히 QueryDSL의 QueryProjection을 이용하여 querydsl 전용 생성자를 만들었어야 했고, 이 과정에서 기존 dto의 생성자와 충돌하는 문제가 발생했다.
따라서 querydsl은 querydsl이 인식할 수 있는 전용 public 생성자를 요구한다는 점을 반영해 기존 lombok constructor annotation에서 accessLevel private 설정을 지우고 기존 dto 필드에 대한 builder 생성자 방식에서 QueryProjection annotation이 붙은 && dto 모든 필드가 인자로 다 주어진 public 생성자를 생성하여 충돌을 해결했다. 

com.querydsl.core.types.ExpressionException: 
No constructor found for class com.whoa.whoaserver.crawl.dto.FlowerRankingResponseDto with parameters:

 
 
아래와 같이 query dsl의 queryProjection annotation이 붙어야 Projections.constructor이 이를 인식해 dto의 생성자를 호출할 수 있게 된다. 한번의 쿼리로 dto에 필요한 column만 select하여 dto에 바로 매핑을 시키는 방식을 적용했다. 

package com.whoa.whoaserver.crawl.repository;

import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.whoa.whoaserver.crawl.dto.FlowerRankingResponseDto;
import lombok.RequiredArgsConstructor;

import java.util.List;

import static com.whoa.whoaserver.crawl.domain.QFlowerRanking.flowerRanking;

@RequiredArgsConstructor
public class FlowerRankingRepositoryImpl implements FlowerRankingRepositoryCustom {

	private final JPAQueryFactory jpaQueryFactory;

	@Override
	public List<FlowerRankingResponseDto> findAllFlowerRankingInformation() {
		return jpaQueryFactory
			.select(Projections.constructor(FlowerRankingResponseDto.class,
				flowerRanking.flowerRankingId,
				flowerRanking.flowerRankingName,
				flowerRanking.flowerRankingLanguage,
				flowerRanking.flowerRankingPrice,
				flowerRanking.flowerRankingDate,
				flowerRanking.flowerImage,
				flowerRanking.flowerId
				))
			.from(flowerRanking)
			.fetch();
	}
}

 
두둥탁
이제 5위까지의 5개 꽃 정보를 가져오는데도 한번의 쿼리로 필요한 column만 select 하는 것을 확인했다. 

2024-11-16T19:48:31.488+09:00 DEBUG 20020 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : GET "/api/ranking", parameters={}
2024-11-16T19:48:31.504+09:00 DEBUG 20020 --- [nio-8080-exec-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.whoa.whoaserver.crawl.controller.FlowerRankingController#getFlowerRanking()
Hibernate: 
    select
        fr1_0.flower_ranking_id,
        fr1_0.flower_ranking_name,
        fr1_0.flower_ranking_language,
        fr1_0.flower_ranking_price,
        fr1_0.flower_ranking_date,
        fr1_0.flower_image,
        fr1_0.flower_id 
    from
        flower_ranking fr1_0

 
 
결과적으로 querydsl 및 query projection을 통해 불필요한 데이터 로드를 방지하여 영속성 컨텍스트의 메모리 사용량을 감소할 수 있게 되었다.
 
이러한 이점이 매력적으로 다가와서 지금처럼 저렴한 꽃 랭킹 조회 api 뿐만 아니라
꽃 전체 조회 api에서도 query projection을 적용하였다.
 
두번째 소개할 API는 꽃 전체 조회 API이다. 
 
기존 꽃 전체 조회 API에서도 각 모든 row 정보를 select하여 flower 객체들을 가져와 stream으로 순회하며 로드된 전체 column 중 dto에 필요한 필드만 매핑을 했었다.

@Transactional
public List<FlowerSearchResponseDto> getAllFlowers() {
    List<Flower> flowers = flowerRepository.findAll();

    return flowers.stream()
            .map(this::convertToDto)
            .collect(Collectors.toList());
}

private FlowerSearchResponseDto convertToDto(Flower flower) {
    return new FlowerSearchResponseDto(
            flower.getFlowerId(),
            flower.getFlowerName()
    );
}

 
이제는 처음부터 쿼리를 날릴 때 projection constructor를 이용하여 dto에 필요한 column만 select하여 dto에 매핑함으로써 성능을 개선했다. (pr 링크). Flower 테이블은 9개의 column로 구성되어있는데 아래와 같이 필요한 2개의 필드만 처음부터 select를 하여 dto에 매핑을 해주었다.
builder, noArgsConstructor가 아니라 queryProjection 생성자가 필요했다.

package com.whoa.whoaserver.flower.dto;

import com.querydsl.core.annotations.QueryProjection;
import lombok.Getter;

@Getter
public class FlowerSearchResponseDto {
	private final Long flowerId;
	private final String flowerName;

	@QueryProjection
	public FlowerSearchResponseDto(Long flowerId, String flowerName) {
		this.flowerId = flowerId;
		this.flowerName = flowerName;
	}
}

 

package com.whoa.whoaserver.flower.repository;

import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.whoa.whoaserver.flower.dto.FlowerSearchResponseDto;
import lombok.RequiredArgsConstructor;

import java.util.List;

import static com.whoa.whoaserver.flower.domain.QFlower.flower;

@RequiredArgsConstructor
public class FlowerRepositoryImpl implements FlowerRepositoryCustom {

	private final JPAQueryFactory jpaQueryFactory;

	@Override
	public List<FlowerSearchResponseDto> findAllFlowers() {
		return jpaQueryFactory
			.select(Projections.constructor(FlowerSearchResponseDto.class,
				flower.flowerId,
				flower.flowerName))
			.from(flower)
			.fetch();
	}
}

 
 
위 두 pr을 올린건 11월..
queryProjection이라는 내가 이전에 몰랐던 새로운 방식을 직접 이렇게 사이드 플젝에 적용할 수 있어서 이런게 진짜 알아가는 재미라는거구나를 느꼈다