[Spring Boot] Query DSL 적용기

박철현·2023년 11월 17일
0

스프링부트

목록 보기
3/8
  • Query DSL 사용하기

    • 기존 JPARepository 인터페이스의 경우, 스프링부트가 구현체를 자동으로 생성하여 save 등의 메서드를 직접 구현할 필요가 없었습니다.

    • 그러나 Query DSL에서는 사용자 정의 Repository를 사용하려면 인터페이스를 정의하고 해당 인터페이스에 대한 구현체까지 만들어 줘야 합니다.

    • 규칙

      • Custom interface를 하나 만들기
      • 구현체 클래스를 해당 인터페이스 이름 뒤에 impl을 붙여서 만들어줘야함.
      • JPA Repository 뒤에 ,CustomInterface 붙여주기

설정 방법

  • 의존성 추가

      // Querydsl JPA 모듈을 사용하기 위한 의존성. 
      implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"
      
      // Q 클래스를 생성
      implementation "com.querydsl:querydsl-apt:5.0.0:jakarta"
      annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
      annotationProcessor "jakarta.annotation:jakarta.annotation-api"
      
      // Jakarta Persistence API. 데이터베이스의 데이터를 자바 객체로 매핑해주는 ORM을 위한 표준 인터페이스입니다.
      annotationProcessor "jakarta.persistence:jakarta.persistence-api"
  • Q클래스 : 엔티티 클래스 속성과 구조를 설명해주는 메타데이터

    • Type-safe 하게 쿼리 조건 설정 가능
  • QClass 생성 위치 설정 필요 : 설정에 따라 위치가 달라지지 않게 하기 위함

    • gradle script 추가하는 것이 좋음
    • IntelliJ Build Tools -> Gradle 설정에 따라 다른 위치에 생김
      • Gradle : 생성한 엔티티 폴더 아래 생김
      • IntelliJ : src - main - generated 에 생김
  • .gitignore 등록 추천(/src/main/generated/)

    • Querydsl 라이브러리 버전에 따라서 Qclass의 생김새가 달라질 수 있음
  • JPA Repository와 함께 사용하기

    • JPA Repository 생성

      public interface ExpenditureRepository extends JpaRepository<Expenditure, Long> {}
    • JPA Query Factory 빈 등록

      @Configuration
      public class BaseConfig {
      
      				@Bean
      				public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
      				return new JPAQueryFactory(entityManager);
      				}
      			 }
    • CustomRepository 인터페이스 생성 : Query dsl로 생성할 메서드 선언 필요

      public interface CustomExpenditureRepository {
      			 Page<Expenditure> searchExpenditure(Member member, SearchRequestDTO searchRequestDTO);
      
      			 TotalAndCategorySumDTO getTotalAndCategorySum(Member member, SearchRequestDTO searchRequestDTO);
      
      			 TotalAndOthersAverage getTotalAndOthersAverage(Member member, SearchRequestDTO searchRequestDTO);
       }
      
  • CustomRepositoryImpl 클래스 구현 : 인터페이스 이름 뒤 Impl을 뒤에 작성

    @RequiredArgsConstructor
    public class CustomExpenditureRepositoryImpl implements CustomExpenditureRepository {
    	private final JPAQueryFactory jpaQueryFactory;
    
    	@Override
    	public Page<Expenditure> searchExpenditure(Member member, SearchRequestDTO searchRequestDTO) {
    	// 구현
        }
  • JPA 레포지토리 수정

public interface ExpenditureRepository extends JpaRepository<Expenditure, Long>, CustomExpenditureRepository {
}

기본 문법

  • select와 from이 같을 경우 .selectfrom(엔티티명)으로 합해서 사용 가능
  • where절
    • .eq : 같다
    • .goe : 이상
    • .loe : 이하
    • between : 사이
    • .ne / .eq().not() : 같지 않다
    • .like : 검색 like 사용과 같음 %a, %a%, a%
    • .contains() : %를 쓰지 않아도 자동으로 %a% 형태
    • .startsWith() : %를 쓰지 않아도 a%형태
  • groupBy, Having 사용 가능
  • 반환 방식
    • fetch() : 리스트 반환
    • fetchOne() : 단건 조회, 결과 없으면 null / 결과 2개 이상 : NonUniqueResultException 발생
    • fetchFirst() : 처음 한건을 가져오는 것
      • .limit(1).fetchOne()과 같음
    • fetchCount() : count 쿼리를 날릴 수 있음.

Query DSL 적용 1 : 총액을 입력받아 카테고리별 평균 비용 추천

  • 구현 기능 : 각 예산 카테고리별로 비율을 적용하여 금액을 추천해준다.
    • 예 : 10만원 입력 -> 다른 사람들 식비가 전체 예산에서 평균 10% 정도 차지 -> 10만원 반환
    • 그룹함수인 avg를 적용하여 각 카테고리별 평균 비율을 구하고
    • PlanPercentDTO Class 각각 Setter 메서드를 적용하여 반환
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PlanPercentDTO {
	private Double food;
	private Double cafe;
	private Double education;
	private Double dwelling; // 주거비
	private Double communication; // 통신비
	private Double shopping;
	private Double transfer;
	private Double others;
}
@Override
	public PlanPercentDTO recommendPercent() {

		// 카테고리별 평균 비율
		List<Tuple> results = jpaQueryFactory
			.select(plan.category.nameE, plan.categoryRatio.avg())
			.from(plan)
			.groupBy(plan.category.id)
			.fetch();
		// DTO 객체 생성
		PlanPercentDTO dto = new PlanPercentDTO();
		for(Tuple t : results) {
			String nameE = t.get(plan.category.nameE);
			Double average = t.get(plan.categoryRatio.avg());

			try {
				// 속성명으로 메서드를 찾아내고, 값을 설정합니다.
				// setter 메서드 호출하여 값 지정
				Method method = dto.getClass().getMethod("set" + Character.toUpperCase(nameE.charAt(0)) + nameE.substring(1), Double.class);
				method.invoke(dto, average);
			} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
				// 로그를 남깁니다.
				log.error("비율 계산 DTO 설정 예외 발생", e);
			}
		}
		return dto;
	}
  1. 그룹함수인 avg를 적용하여 각 카테고리별 평균 비율 구하기
		// 카테고리별 평균 비율
		List<Tuple> results = jpaQueryFactory
			.select(plan.category.nameE, plan.categoryRatio.avg())
			.from(plan)
			.groupBy(plan.category.id)
			.fetch();
  1. Java Reflection API를 사용하여 동적으로 Setter 메서드를 호출하여 각 튜플에 저장된 평균값을 DTO 객체에 값 지정
  • Reflection API 기법 : 클래스에 대한 모든 정보를 런타임 단에서 코드 로직으로 얻을 수 있음

    • 구체적인 클래스 타입을 알지 못해도 그 클래스의 정보(메소드, 타입, 변수, ...)에 접근할 수 있게 해주는 자바 기법
    • 단점 : 컴파일 시에 오류를 잡을 수 없음
  • Class 클래스에 대한 간단한 설명

    • 클래스를 동적으로 불러와 다뤄야 할 경우에 Class 클래스를 사용한다.
      • 컴파일 단이 아닌 런타임 단에서 다이나믹하게 클래스를 핸들링 하는 것
      • 자바의 모든 클래스와 인터페이스는 컴파일 후 .java -> .class 파일 변환
      • JVM의 클래스 로더에 의해 클래스 파일이 메모리에 올라갈 때 Class 클래스는 .class 파일의 클래스 정보들을 가져와 힙 영역에 자동으로 객체화 된다.
        • 따라서 new 인스턴스화 없이 바로 가져와 사용할 수 있다.
        • 클래스로더 : 실행 시 필요한 클래스를 동적으로 메모리에 로드하는 역할

    • 사용법
      • Object.getClass() 메서드를 활용해 Class 객체를 가져온다.
        • dto.getClass() : DTO 인스턴스를 가져옴
      • Class.getMethod(String name, Class ...parameterTypes) 메서드를 활용해 메서드 이름과 매개변수 타입을 알려줘 세터 메서드를 가져온다.

        Method method = dto.getClass().getMethod("set" + Character.toUpperCase(nameE.charAt(0)) + nameE.substring(1), Double.class);

        - dto.getClass().getMethod("setFood", Double) : Tuple에 있는 데이터를 하나씩 가져와 DTO 내부에 있는 각 카테고리별 setter 메서드를 호출함
        - method.invoke() 메서드를 통해 메서드를 실행산다
        - 이 경우 Setter가 실행됨

Query DSL 적용 2 : 페이지별 지출 데이터 조회

  • 지출 내역을 조회하기 위해 각종 조건이 존재합니다.
    • 특정 기간으로 조회할 수도 있고(기간 필터링)
    • 특정 예산 카테고리별 조회(미입력 시 전체) : 식비만 보고 싶다거나 등..
    • 지출 금액별로 필터 조회 : 1만 ~ 5만원 사이 지출 내역만 확인 등..
  • 이 조건들을 각각의 조건을 따져서 JPQL이나 JPA로 짜려면 메서드가 엄청 많아질 것으로 예상되어.. 동적으로 짜기 위해 BooleanBuilder를 사용한 Query DSL로 구현하였습니다.
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class SearchRequestDTO {

	@Schema(description = "조회 시작일을 입력해주세요(기본값 : 7일 전)", example = "2023-11-07")
	private LocalDate startDate = LocalDate.now().minusDays(7);
	@Schema(description = "조회 종료일을 입력해주세요(기본값 : 오늘)", example = "2023-11-14")
	private LocalDate endDate = LocalDate.now();

	@Schema(description = "조회 카테고리 Id를 입력해주세요(기본값 : 전체 데이터)", example = "2")
	private Long categoryId;
	@Schema(description = "조회 최소 금액을 입력해주세요(미입력 시 금액 상관 없이 전체 조회)", example = "3000")
	private Integer minPrice;
	@Schema(description = "조회 최대 금액을 입력해주세요(미입력 시 금액 상관 없이 전체 조회)", example = "500000")
	private Integer maxPrice;

	@Schema(description = "조회할 페이지 번호를 입력해주세요(기본값 : 0)", example = "0")
	private Integer pageNumber = 0;

	@Schema(description = "페이지당 조회할 지출 내역을 입력해주세요(기본값 : 10)", example = "10")
	private Integer pageLimit = 10;
}
/*
		페이지별 지출 데이터 조회
	 */
	@Override
	public Page<Expenditure> searchExpenditure(Member member, SearchRequestDTO searchRequestDTO) {
		Pageable pageable = PageRequest.of(pageNumber, pageLimit);

		// 여러 조건을 동적으로 추가할 수 있도록 도와주는 BooleanBuilder 도입
		BooleanBuilder builder = createBooleanBuilder(categoryId, minPrice, maxPrice);

		// 조건에 맞는 목록 구하기
		List<Expenditure> expenditures = jpaQueryFactory.selectFrom(expenditure)
			.where(
				expenditure.member.eq(member),
				expenditure.spendDate.between(startDate, endDate),
				builder // 조건 동적 추가
			)
			.offset(pageable.getOffset())
			.limit(pageable.getPageSize())
			.fetch();

		// 전체 데이터 개수 추출
		long total = jpaQueryFactory.selectFrom(expenditure)
			.where(
				expenditure.member.eq(member),
				expenditure.spendDate.between(startDate, endDate),
				builder // 조건 동적 추가
			)
			.fetchCount();

		return new PageImpl<>(expenditures, pageable, total);
	}
  • 조건 동적으로 추가하기 위한 BooleanBuilder

    • QueryDSL에서 제공하는 클래스로 조건문에 따라 다양한 쿼리를 생성할 수 있다.
    • 지출 내역 조회때 선택할 수 있는 조건은 아래와 같다
      • 카테고리 미입력 : 전체 예산 카테고리에 맞게 조회
        • 입력 시 특정 카테고리로 조회 가능
      • 특정 지출 금액 범위 조회 : 지출한 예산 범위의 내역만 조회
      • 최소 금액만 입력 : 최소 금액 이상의 값 지출 내역 조회
      • 최대 금액만 입력 : 최대 금액 이하의 값 지출 내역 조회
    private BooleanBuilder createBooleanBuilder(Long categoryId, Integer minPrice, Integer maxPrice) {
    			BooleanBuilder builder = new BooleanBuilder();
    
    			// 카테고리 입력 시에만 조건 적용되도록
    			if (categoryId != null) {
    				builder.and(expenditure.category.id.eq(categoryId));
    			}
    
    			// case 1. 최소, 최대 금액 범위가 입력된 경우 // 이 경우 minPrice ~ maxPrice 조건 추가
    			if (minPrice != null && maxPrice != null) {
    				builder.and(expenditure.spendingPrice.between(minPrice, maxPrice));
    			}
    			// case 2. 최소 금액 범위만 입력된 경우 // 이 경우 minPrice 이상인 조건 추가
    			else if (minPrice != null) {
    				builder.and(expenditure.spendingPrice.goe(minPrice));
    			}
    			// case 3. 최대 금액 범위만 입력된 경우 -> 이 경우 maxPrice 이하인 조건 추가
    			else if (maxPrice != null) {
    				builder.and(expenditure.spendingPrice.loe(maxPrice));
    			}
    
    			return builder;
    		}

Query DSL 적용 3 : 주어진 기간에 사용자 지출 총액과 다른 사람들 지출 총액 평균을 반환하는 메서드

  • 특정 기간에 다른 사람들 지출액 대비 나의 지출액이 얼마나 되는지 통계 데이터 추출을 위한 쿼리입니다.
    • 나의 오늘 지출이 다른 사람 평균 지출액 대비 몇 %인지 반환합니다.
      • 예시 : 오늘 나의 지출 1만원, 다른 사람 평균 지출 10만원 -> 10%
    • DTO 객체에 startdDate와 endDate를 LocalDate.now()로 현재 날짜로 설정하였습니다.
      • 하지만 추후 특정 기간 대비 평균 데이터를 구할 수도 있어 확장성을 위해 날짜 between으로 설정하였습니다.
@Override
	public TotalAndOthersAverage getTotalAndOthersAverage(Member targetMember, SearchRequestDTO searchRequestDTO) {
		// 대상 사용자 제외한 오늘 지출금액 총합
		Integer othersSumPrice = jpaQueryFactory.select(expenditure.spendingPrice.sum())
			.from(expenditure)
			.where(expenditure.member.eq(targetMember).not(),
				expenditure.spendDate.between(searchRequestDTO.getStartDate(), searchRequestDTO.getEndDate()),
				expenditure.isTotal.eq(true))
			.fetchOne()
			.intValue();

		// 대상 사용자 제외한 오늘 지출 있는 회원 수
		Integer othersCount = jpaQueryFactory.select(expenditure.member.countDistinct())
			.from(expenditure)
			.where(expenditure.member.eq(targetMember).not(),
				expenditure.spendDate.between(searchRequestDTO.getStartDate(), searchRequestDTO.getEndDate()),
				expenditure.isTotal.eq(true))
			.fetchOne()
			.intValue();

		// 다른 사람들 평균액
		Integer othersAvg = othersSumPrice / othersCount;

		// 대상 사용자의 오늘 지출 총액을 구하는 쿼리
		Integer total = jpaQueryFactory
			.select(expenditure.spendingPrice.sum())
			.from(expenditure)
			.where(expenditure.spendDate.between(searchRequestDTO.getStartDate(), searchRequestDTO.getEndDate()),
				expenditure.member.eq(targetMember),
				expenditure.isTotal.eq(true))
			.fetchOne();

		return TotalAndOthersAverage.builder()
			.othersAverage(othersAvg)
			.totalPrice(total)
			.build();
	}
  • countDistinct() 메서드 사용

    • countDistinct() 메서드는 특정 필드의 중복을 제거한 후 그 수를 세는 데 사용합니다.
      • SQL의 COUNT(DISTINCT column)와 동일한 기능 수행
    • 지출 내역에서 각 데이터는 사용자가 지출한 건당 데이터입니다.
      • count(E.member_id)로 할 경우 사용자의 지출 데이터 총 개수 반환
      • 예를 들어, member1이 10건의 지출 데이터를 가지고 있다면, count(E.member_id)의 결과는 member1에 대해 '10'을 반환
    • 하지만 평균을 구하기 위해서는 회원 수를 알아야 합니다. 이때 count(distinct E.member_id)를 사용하면 지출 데이터 중 회원을 중복 없이 세어서 반환합니다.
      • 지출 내역에 있는 회원 수를 구할 수 있습니다.
    • 아래 코드는 '대상 사용자를 제외한 오늘 지출이 있는 회원 수'를 구하는 예제입니다.
      // 대상 사용자 제외한 오늘 지출 있는 회원 수
      		Integer othersCount = jpaQueryFactory.select(expenditure.member.countDistinct())
      			.from(expenditure)
      			.where(expenditure.member.eq(targetMember).not(),
      				expenditure.spendDate.between(searchRequestDTO.getStartDate(), searchRequestDTO.getEndDate()),
      				expenditure.isTotal.eq(true))
      			.fetchOne()
      			.intValue();
  • 실행 쿼리

select count(distinct E.member_id) 
FROM expenditure AS E
where E.member_id = 1
and E.spend_date = "2023-11-17"
and E.is_total = b'1';

Query DSL 적용 4 : 특정 사용자의 지출 총 합계 금액과 카테고리별 합계 금액 추출 기능

  • 특정 사용자의 특정 기간동안 지출 총 합계 금액, 카테고리별 지출 합계 금액 추출 기능을 담당하는 쿼리입니다.
    • Query DSL 적용 2 : 페이지별 지출 데이터 조회
    • 위에 있는 2번째 경우와 코드가 유사합니다.
      • 2번째 경우는 지출 합계에 포함되거나 포함되지 않던 내역도 모두 나옵니다.
      • 또한 총액이 아닌 실제 내역을 보여줍니다.
    • 현재 부분에서 나타나는 부분은 아래와 같습니다.
      • 지출 합계에 포함시키기로 한 내역들의 총합
      • 각 카테고리별 지출 합계에 포함시키기로 한 내역들의 합
    • createBooleanBuilder는 동일한 메서드 사용
	@Override
	public TotalAndCategorySumDTO getTotalAndCategorySum(Member member, SearchRequestDTO searchRequestDTO) {
		// 여러 조건을 동적으로 추가할 수 있도록 도와주는 BooleanBuilder 도입
		BooleanBuilder builder = createBooleanBuilder(categoryId, minPrice, maxPrice);

		// 조건에 맞는 지출 리스트 추출
		List<Expenditure> expenditures = jpaQueryFactory.selectFrom(expenditure)
			.where(
				expenditure.member.eq(member),
				expenditure.spendDate.between(startDate, endDate),
				expenditure.isTotal.eq(true),
				builder // 조건 동적 추가
			)
			.fetch();

		// 지출 합계 계산
		Integer totalSpending = 0;
		for (Expenditure expenditure : expenditures) {
			totalSpending += expenditure.getSpendingPrice();
		}

		// 카테고리별 지출 합계 추출용 map
		Map<Long, Integer> categorySums = new HashMap<>();
		for (Expenditure expenditure : expenditures) {
			Long targetCategoryId = expenditure.getCategory().getId();
			categorySums.put(targetCategoryId,
				categorySums.getOrDefault(targetCategoryId, 0) + expenditure.getSpendingPrice());
		}

		List<CategorySum> categorySumList = categorySums.entrySet().stream()
			.map(entry -> new CategorySum(
				entry.getKey(), // id 추출
				getCategoryNameH(entry.getKey()), // 이름 추출
				entry.getValue() // 합계 추출
			))
			.collect(Collectors.toList());

		return new TotalAndCategorySumDTO(totalSpending, categorySumList);
	}
  • 특정 기간 사이에 있고, 특정 유저의 지출 내역 합계에 포함한 지출 내역을 가져와 다 더하여 반환합니다.
    • categorySums Map을 통해 카테고리별 총액을 구합니다.
      • 식비 : 00 / 통신비 : 00 ..
    • categorySumList에서는 EntrySet을 통해 각 카테고리별로 key에서 카테고리 한글 이름을 아래 메서드로 추출하여 CategorySum이라는 DTO 객체를 생성합니다.
    • id / 카테고리 이름 / 지출 금액
      • 예시 : 1/ 식비 / 100000
	/*
		카테고리 한글 이름 추출 메서드
	 */
	private String getCategoryNameH(Long findCategoryId) {
		return jpaQueryFactory
			.select(category.nameH)
			.from(category)
			.where(category.id.eq(findCategoryId))
			.fetchOne();
	}
profile
비슷한 어려움을 겪는 누군가에게 도움이 되길

0개의 댓글