JPQL -> Query-DSL 리팩토링

징징이·2024년 8월 11일

JPA

목록 보기
7/7
post-thumbnail


프로젝트를 진행하면서 크리에이터를 검색하는 쿼리를 짠 경험이 있다.

초기 작성 (JPQL)

생성일 기준 오름차순, 내림차순 혹은 이름 기준 오름차순, 내림차순 혹은 키워드 검색이 있을 수 있고 무한스크롤링 구현을 위한 top값도 필요했다. 처음에는 정렬 조건이 creatorName일 때, createdDate일 때 기준값이 다르기 때문에 topName, topDate 이렇게 요상하게 param을 두고 api를 작성하였다. (JPA 처음 공부하던 시절..)

api/creator/search?name={keyword}&sort={asc/desc}&condition={creatorName/createdDate}&topDate={createdDate}&topName={creatorName}

보기만해도 api가 더럽다.

param

  • name - 크리에이터 검색 키워드
  • sort - 정렬 오름차순 내림차순
  • condition - 정렬 기준 컬럼
  • topDate - 날짜기준 조회일때 마지막으로 받은 데이터의 createdDate
  • topName - 크리에이터 이름 기준 조회일때 마지막으로 받은 데이터의 creatorName

무한 스크롤을 위한 top, 이름순, 최신순, 오래된순과 같은 정렬 조건, 키워드 검색 등 동적인 쿼리가 많아 단순 JPA@NamedEntityGraph를 이용하기에는 동적인 쿼리문이 필요하기 때문에 복잡하다. JPQL를 이용해서 짤 수는 있지만 개발자가 이해하기 어려워진다.

JPQL을 이용한 크리에이터 검색

@Query("""
            select c from Creator c
		    where (:name is null or c.creatorName like %:name%)
		    AND (
		        (:condition = 'createDate' and (:topDate is null or (:sort = 'desc' and c.creatorProfileId < :topDate) or (:sort = 'asc' and c.creatorProfileId > :topDate)))
		        OR
		        (:condition = 'creatorName' and (:topName is null or (:sort = 'desc' and c.creatorName < :topName) or (:sort = 'asc' and c.creatorName > :topName)))
		    )
		""")
	Slice<Creator> findCreatorsWithScrolling(@Param("name") String name,
		@Param("topDate") Long topDate,
		@Param("topName") String topName,
		@Param("condition") String condition,
		@Param("sort") String sort,
		Pageable pageable);

한 눈에 봤을 때 이해가 쉬운가? param이 있을 수도 있고 없을 수도 있는걸 모두 고려하면서 정렬 조건까지 고려했기 때문에 and,or 연산 파티에 정신이 없는 쿼리가 탄생했다.

나도 짜면서 이해하기 쉽지 않았는데 어떻게 이해하냐고!~!!~~ 이를 개선하기 위해 queryDSL을 이용하여 리팩토링 해줬다.

리팩토링 (Query-DSL을 이용한 크리에이터 검색)

먼저 QueryDSL은 타입 안전한 쿼리를 작성할 수 있도록 지원하는 Java 기반의 ORM 프레임워크다. SQL, JPQL, MongoDB 등의 쿼리를 자바 코드로 작성할 수 있게 해주며, 컴파일 시점에 쿼리의 오류를 검출할 수 있어 코드의 안정성을 높이는 데 도움을 준다. 또한 복잡한 쿼리 작성 시 유용하게 사용된다.

초기 설정

  • gradle 설정
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
  • config 설정

@Configuration
public class QueryDslConfig {

	@PersistenceContext
	private EntityManager em;

	@Bean
	public JPAQueryFactory queryFactory() {
		return new JPAQueryFactory(em);
	}
}
  • Repository 설정

    @Repository
    public interface CreatorCustomRepository {
    
        List<Creator> findCreatorByCreatedDateWithScrolling(String sort, String keyword, LocalDateTime top);
    
        List<Creator> findCreatorByCreatorNameWithScrolling(String sort, String keyword, String top);
    }
    • 커스텀한 repository inteface를 만든다.
    
    @Repository
    public interface CreatorRepository extends JpaRepository<Creator, Long>, CreatorCustomRepository {
    ...
    }
    • 기존 repository에 CustomRepository를 implement해준다.

query-dsl로 리팩토링한 api는 다음과 같다.

api/creator/search?name={keyword}&sort={asc/desc}&condition={creatorName/createdDate}&top={creatorId}

param

  • name - 크리에이터 검색 키워드
  • sort - 정렬 오름차순 내림차순
  • condition - 정렬 기준 컬럼
  • top - 마지막으로 받은 크리에이터의 pk

훨씬 깔끔한 param으로 변경됐다.
먼저 서비스 단에서 top param이 있다면 pk를 가지고 Creator Entity를 찾는다.

Creator creator = creatorRepository.findCreatorByCreatorId(creatorId)

그 후 condition 컬럼에 따라 해당 크리에이터의 이름, 생성일을 추출한다. 그 이후 queryDSL을 통해 상위 10개의 데이터를 불러온다.

전체 코드

@RequiredArgsConstructor
public class CreatorCustomRepositoryImpl implements CreatorCustomRepository {
	private final JPAQueryFactory jpaQueryFactory;

	@Override
	public List<Creator> findCreatorByCreatedDateWithScrolling(String sort, String keyword, LocalDateTime top) {
		OrderSpecifier<?> orderSpecifier = getCreatedDateOrderSpecifier(sort);

		return jpaQueryFactory.selectFrom(creator1)
			.where(
				getKeyWord(keyword),
				afterCreatedDateCursor(top, sort)
			)
			.orderBy(orderSpecifier)
			.limit(10)
			.fetch();
	}

	@Override
	public List<Creator> findCreatorByCreatorNameWithScrolling(String sort, String keyword, String top) {
		OrderSpecifier<?> orderSpecifier = getCreatorNameOrderSpecifier(sort);

		return jpaQueryFactory.selectFrom(creator1)
			.where(
				getKeyWord(keyword),
				afterCreatorNameCursor(top, sort)
			)
			.orderBy(orderSpecifier)
			.limit(10)
			.fetch();
	}


	private BooleanExpression getKeyWord(String word) {
		return word != null ? creator1.creatorName.containsIgnoreCase(word) : null;
	}

	private BooleanExpression afterCreatedDateCursor(LocalDateTime top, String sort) {
		if (top == null) {
			return null;
		} else if (sort.equals("asc")) {
			return creator1.createdDate.gt(top);
		} else {
			return creator1.createdDate.lt(top);
		}
	}

	private BooleanExpression afterCreatorNameCursor(String top, String sort) {
		if (top == null) {
			return null;
		} else if (sort.equals("asc")) {
			return creator1.creatorName.gt(top);
		} else {
			return creator1.creatorName.lt(top);
		}
	}

	private OrderSpecifier<LocalDateTime> getCreatedDateOrderSpecifier(String sort) {
		if (sort.equals("desc")) {
			return creator1.createdDate.desc();
		} else {
			return creator1.createdDate.asc();
		}
	}

	private OrderSpecifier<String> getCreatorNameOrderSpecifier(String sort) {
		if(sort.equals("desc")){
			return creator1.creatorName.desc();
		} else {
			return creator1.creatorName.asc();
		}
	}
}

메인 함수

@Override
	public List<Creator> findCreatorByCreatedDateWithScrolling(String sort, String keyword, LocalDateTime top) {
		OrderSpecifier<?> orderSpecifier = getCreatedDateOrderSpecifier(sort);

		return jpaQueryFactory.selectFrom(creator1)
			.where(
				getKeyWord(keyword),
				afterCreatedDateCursor(top, sort)
			)
			.orderBy(orderSpecifier)
			.limit(10)
			.fetch();
	}
  • orderSpecifier 객체를 통해 정렬한다.
  • getKeyWord 함수를 통해 크리에이터이름을 기준으로 like 연산을 실행한다.
  • afterCreatedDateCursor함수를 통해 현재 조회된 크리에이터 기준 다음에 있는 데이터만 가져온다.
  • limit을 통해 가져올 데이터의 개수를 제한한다.

키워드 검색 함수

private BooleanExpression getKeyWord(String word) {
		return word != null ? creator1.creatorName.containsIgnoreCase(word) : null;
	}
  • 검색 키워드가 있다면 creatorName에 검색 키워드가 포함된 행만 고른다.

정렬 함수

private OrderSpecifier<LocalDateTime> getCreatedDateOrderSpecifier(String sort) {
		if (sort.equals("desc")) {
			return creator1.createdDate.desc();
		} else {
			return creator1.createdDate.asc();
		}
	}
  • condition과 sort param을 기준으로 정렬 기준을 정한다.

Top 함수(cursor 기반 함수)

private BooleanExpression afterCreatedDateCursor(LocalDateTime top, String sort) {
		if (top == null) {
			return null;
		} else if (sort.equals("asc")) {
			return creator1.createdDate.gt(top);
		} else {
			return creator1.createdDate.lt(top);
		}
	}
  • 마지막으로 불러온 데이터의 createdDate값을 기준으로 그 뒤에 있는 데이터를 불러와야 한다.
  • 예를 들어 최신순 정렬에 사용자가 현재 보고있는데이터의 createdDate가 7/20 이라면 커서를 내렸을 때 서버에서 전달해야하는 데이터는 7/20보다 뒤에 있는 데이터를 제공해야한다.
[
    {
        "creatorId": 27,
        "creatorName": "크리에이터27",
        "profileImg": "creator_img27.jpg"
    },
    {
        "creatorId": 15,
        "creatorName": "크리에이터15",
        "profileImg": "creator_img15.jpg"
    },
    ...
    {
        "creatorId": 11,
        "creatorName": "크리에이터11",
        "profileImg": "creator_img11.jpg"
    }
]
  • 만약 현재 요청이 왔다면 다음에는 api/creator/search?condition=createdDate&sort=desc&top=11을 요청할 것이고 마지막 데이터의 creatorId가 11이니 크리에이터 11보다 늦게 생성된 행만 필터링 해줘야 한다.

이렇게 자바 함수기반으로 query-dsl를 짜보니 JPQL로 짰을 때보다 훨씬 이해가 쉬운 쿼리문을 작성할 수 있었다.

코드리뷰할때도 훨씬 이해가 잘돼서 좋았음!!!

간단한 쿼리문은 JPA, JPQL을 이용해서 짜는게 편하지만 복잡한 동적 조건문이 많은 쿼리문은 query-dsl로 짜는게 유지보수 측면에서 훨씬 좋은 것 같다. 조건 하나하나를 모듈화해서 관리할 수 있으니!! 다들 query-dsl 하세요~~ (단점 초기 세팅 너무 귀찮음ㅜ)

profile
초보 개발자의 개발 공부

0개의 댓글