project list 조회 api 3차 refactoring

권희·2025년 10월 6일

졸업작품

목록 보기
4/7

목차

  1. 이전 refactoring 요약
  2. 3차 refactoring의 필요성
  3. refactoring
  4. 결과

1. 이전 refactoring 요약

1차 refactoring (query dsl도입) : native query -> query dsl
2차 refactoring (기능 확장) : query dsl의 where, order by절을 service로직에서 구현하게 설정

query dsl함수 위에 builder class를 두어서 필요한 where, order by절을 service 로직에서 미리 주입 받는다
장점 : 다양한 where, order by를 주입받아 project list를 조회함으로 확장에 용이하게 refactoring되었음
단점 : 다양한 where, order by에 필요한 모든 table을 언제나 left join해줘야 한다 (성능 저하)
추가 보안 예정 : left join이 필요한 table들 또한 외부 service로직에서 받아서 처리한다

2. 3차 refactoring의 필요성

1. 새로운 기능 확장 (funding 정보 추가)

my page에서 조회하는 project list api 기능에 해당 project 마다의 funding정보가 추가되었다
이를 이전 방식으로 구현하기 위해서는 모든 project list api조건에 funding table들을 left join해줘야한다
추가로 left join이 필요한 table들

free funding entity
option entity
option funding entity
coupon entity

my page에서 project list api에 funding 정보를 추가하기 위해서 모든 project list api에 총합 4개의 table을 left join해야 한다
이는 엄청난 성능 저하를 발생시키고, 유지보수를 어렵게 만들 것이다

2. api속도의 문제 발견

project list api를 처음 실행 시켰을 때 log aop에 의해 측정된 4000ms의 시간을 보게되었다
뒤늦게 알았지만 첫 번째 api 실행은 원래 오래 걸린다고 한다 (대충 db connection, jpa가 준비하는데 시간이 걸린다고 한다)
어찌됬든 api 호출에 4초 라는 시간을 본이상 api속도를 무시할 수는 없었고 추가 기능확장인 funding table들을 모두 left join하면 더 느려질 것을 알기 때문에 from절 외부 주입을 더 이상 미룰 수 없었다

3. refactoring

refactoring 목표

  1. 다양한 where, order by에 필요한 모든 table left join -> 각각의 service에서 필요한 table만 left join
    (기본적으로 필요한 table(like, view, img)은 left join 이외 추가 table은 service에서 설정)
  2. 이전 sub query들 left join으로 처리 (like count, view count)

    project list조회에 기본적으로 필요한 user(author정보), img, like, project view 4개 table만 기본 join
    이후 service로직에서 필요한 table을 추가 left join하여 사용

from절을 service에서 주입 받기

사실 where, order by부분과 크게 다르지 않을 것이라 예상했지만 구현하다 보니 여러 문제점을 만나게되었다
(정말 다양한 문제를 마주쳤고 정말 여러번 수정해야만 했다)

builder class에서 필요한 from절 정보를 받아두고 dsl 함수에게 넘겨주는 방식

left join을 위해 from절에서 dsl함수로 넘야 할 정보가 생각 보다 복잡하다

List<QEntity tagetEntity, BooleanExpression on절 조건, boolean isFetch여부>

  • 해결 : builder class내부에서 query dsl을 직접 실행한다
    left join으로 받은 QEntity와 on절 조건을 builder class 내부 변수에 저장해 두었다가 dsl함수로 넘겨주기 보다는 builder class에서 직접 dsl query를 실행해버리면 된다
  • 단점 : 이전 dsl query 함수 자체를 수정함으로 refactoring보다는 기능을 다시 만드는 것에 가깝다(이전 기능을 보장하지 못할 수 있다)

어느 정도 이전 함수의 수정을 감만했음으로 builder class에서 바로 dsl query를 생성한다 (select 부분만 작성한다)

class Builder {
  public Builder(JPAQueryFactory queryFactory, Long userId) {
  this.userId = userId;
  mainQuery = queryFactory
    .select(Projections.constructor(ResponseProjectListDetailDto.class,
      ...(생략),
      fundingDslRepository.completeRate(projectEntity),
      likeEntity.count(),
      fundingDslRepository.fundingUserCount(projectEntity),
      isLikeEntity.count(),
      viewEntity.count()
      ))
    .from(projectEntity);
  }

join할 QEntity를 관리할 위치

service에서 left join할 table을 준다면 구지 builder class에서 join할 table을 관리하고 where, order by절에서 다시 service에게 제공할 필요가 있을까?

  1. QEntity를 각 service에서 내부 변수로 두고 builder class는 left join만 실행한다
    장점
  • builder class에서 필수 4개의 entity만 관리하면 된다
  • service에서 where, order by작성시 builder class로 부터 필요한 entity를 다시 요청할 필요 없다
    단점
  • left join할 QEntity를 builder class에서 검증 할 수 없다
  • service 코드문제로 builder class가 작동하지 않을 수 있다
  1. builder class내부에서 Map을 통해 모든 QEntity class들을 관리한다
    장점
  • left join할 모든 QEntity들을 검증할 수 있다
    단점
  • builder class 구현이 어려워진다
  • service에서는 where, order by절에서 필요한 QEntity를 받아와야한다

결정 : 1번 방법 QEntity는 각각의 service코드에서 관리한다
이유

  • Map<String, QEntity>으로 QEntity를 관리 한다 했을 때 필수 4개의 QEntity와 추가 QEntity를 구분할 방법이 없다
  • 구상해본 left join함수에 QEntity를 구분하기 위한 String값이 한 개 더 추가되어야 한다
// builder class 내부
        public <T extends EntityPathBase,J extends EntityPathBase> Builder leftJoin(
                T joinEntity,
                Class<J> entityClass, FunctionInterface<BooleanExpression, J> onFunction,
                boolean isFetch
        ){
            J entity = (J) getEntity(entityClass);
            if(isFetch)
                mainQuery.join(projectEntity.options).fetchJoin();
            else
                mainQuery.leftJoin(joinEntity).on(onFunction.function(entity));
            return this;
        }
// service 사용 예시
QProjectTagEntity projectTag = new QProjectTagEntity("project_tag");
    SelectProjectList
        .builder(userId)
        .leftJoin(
            projectTag,
            QProjectEntity.class, // can null
            (project)->projectTag.project.id.eq(project.id),
            true
        )
        .where(()->projectTag.id.in(tagIds))
        ... (생략)

집계함수와 일반 column을 동시에 조회 할 수 없다

mysql native query에서 가능하기 때문에 염두조차 하지 못했던 부분이다

select (project.title, count(projectView)) from project left join project.projectView

query dsl에서 group by를 사용하지 않고는 일반 column인 project 정보(title, content, ...)와 집계함수(count(projectView), count(like))를 동시에 사용할 수 없다
때문에 group by는 필수로 사용해야만 한다

select (project.title, count(view)) from project left join view group by project

dto에서 fetch left join 사용 불가능

결과 값을 dto를 통해서 가져오는 방식에서는 fetch join을 사용할 수 없다

fetch 정보를 가져오는 query를 자세히 본적있다면 당연한 부분이였지만 간과하고 있던 부분이었다
1. fetch 정보는 select문 내부에 필요한 column들을 추가 조회하는 방법으로 처리된다
2. query dsl에서 조회 결과를 dto로 받아오기 위해서는 dto 생성자와 select 조회 column이 완전 일치해야한다
1번과 2번을 동시에 만족시킬 방법이 필요하다

방법
1. dto에 필요한 모든 정보에 알맞은 생성자를 만든다

  • 불가능 : service코드에서 어떤 table을 left join할지 모르기 때문에 사실상 불가능 하다
  1. dto에 project 정보를 ProjectEntity로 변경한다
    현재 project.id, project.title, project.goal, ... 형식으로 필요한 col만 조회하고 있지만 필요 없는 몇개의 추가 col을 같이 조회한다면 ProjectEntity한개로 조회한다
  • 장점 : left join fetch한 모든 값들이 ProjectEntity내부에 들어가기 때문에 dto에게 쉽게 전달 가능하다
  • 단점 : project list에는 필요없는 값들(content)까지 조회해야 한다

결론 : ProjectEntity로 조회한다
일단 필수 table인 (img, user)2개 table을 필수적으로 fetch join이 필요함으로 별 다른 방법이 없다

dto class constructor (ProjectEntity, Long, Long, ...)
select (project , count(like), count(view), ...) from ...

funding 정보를 left join할 때 너무 많은 row가 발생한다

funding정보를 모두 left join할 경우 총 9개의 table을 left join해야 한다 (projecttag, img, tag, like, view, option, optionfunding, coupon, freefunding)

funding이전 5개 table left join을 했을 때도 속도가 느려서 문제였는데 여기에 추가 4개 table을 left join fetch를 사용한다면 더욱 느려지게 될 것이다
또한 serice에서의 코드 또한 4개의 table을 left join하면서 코드가 길어지게 된다

QOptionEntity optionEntity = new QOptionEntity();
QOptionFundingEntity optionFunding = new QOptionFundingEntity();
QFreeFundingEntity freeFunding = new QFreeFundingEntity();
QCouponEntity couponEntity = new QCouponEntity();
builder()
	.leftJoin(
    	optionEntity , 
        QProjectEntity.class , (project) -> project.id.eq(optioEntity.project.id),
        true
    )
    .leftJoin(
    	optionFunding ,
        null, (entity) -> optionEntity.id.eq(optionFunding.optio.id) ,
        true
    )
    .leftJoin(
    	couponEntity ,
        null, (entity) -> couponEntity.optionFunding.id.eq(optioFunding.id), 
        true
    )
    .leftJoin(
        freeFunding ,
        (project) -> project.id.eq(freeFunding.project.id).and(freeFunding.user.id.eq(userId)) ,
        true
    )
    .where() ... 생략

service 코드에서의 코드 작성이 너무 길어지고 9개 table을 left join한 query의 속도가 심이 걱정되었기 때문에 다른 방법이 필요했다

funding 정보를 따로 조회하고 service에서 합친다

  1. project list를 조회 하고 project list 에서 List<Long> projectIds를 구한다
  2. fundingRepo.findAllByProjectIds(projectIds)를 통해 funding정보를 조회한다
  3. service 로직에서 Map<projectId, FundingData>형식으로 변환하여 project 정보에 병합한다

project list의 갯수는 10개 이하임으로 funding 정보를 가져오는 query의 속도는 크게 저하되지 않을 것이다

query 최적화, service에서 병합

앞서서 project list의 갯수가 10개 이하라는 이유로 funding정보를 service에서 병합하는 방식으로 변경했다
그렇다면 결국 다른 정보들도 모두 해당되는건 아닐까? 처음 부터 project list조회는 조건에 맞는 List<project id>를 먼저 조회하고 필요한 다른 정보들은 추가 조회하고 service에서 병합하는 방법이 올았을지도 모르겠다
열심히 3차 refactoring까지 진행해서 얻은 결론이 결국 List<project id>조회 후 추가 query라는 사실이 씁씁하다

4. 결과

마지막 결론이 많이 다른 방식의 방식으로 결정났지만 처음 refactoring목표였던 from절 service에서 구현과 기존 sub query를 left join방식으로 변경에 성공했다
일단 left join으로 이전 query의 최적화는 성공했음으로 이번 refactoring을 종료한다

이후 할 일

  • 2차, 3차 refactoring query 속도 측정
  • 4차 refactoring 여부 판단 (추가 query + service에서 병합, 객체지향 도입)
profile
나의 개발 기록

1개의 댓글

comment-user-thumbnail
2025년 10월 6일

언제나 다른 사람의 의견을 기다리고 있습니다

답글 달기