Query DSL 사용하기
기존 JPARepository 인터페이스의 경우, 스프링부트가 구현체를 자동으로 생성하여 save 등의 메서드를 직접 구현할 필요가 없었습니다.
그러나 Query DSL에서는 사용자 정의 Repository를 사용하려면 인터페이스를 정의하고 해당 인터페이스에 대한 구현체까지 만들어 줘야 합니다.
규칙
의존성 추가
// 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클래스 : 엔티티 클래스 속성과 구조를 설명해주는 메타데이터
QClass 생성 위치 설정 필요 : 설정에 따라 위치가 달라지지 않게 하기 위함
src - main - generated
에 생김.gitignore 등록 추천(/src/main/generated/)
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 {
}
.limit(1).fetchOne()
과 같음@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;
}
// 카테고리별 평균 비율
List<Tuple> results = jpaQueryFactory
.select(plan.category.nameE, plan.categoryRatio.avg())
.from(plan)
.groupBy(plan.category.id)
.fetch();
Reflection API 기법 : 클래스에 대한 모든 정보를 런타임 단에서 코드 로직으로 얻을 수 있음
Class 클래스에 대한 간단한 설명
.java -> .class
파일 변환.class
파일의 클래스 정보들을 가져와 힙 영역에 자동으로 객체화 된다.Method method = dto.getClass().getMethod("set" + Character.toUpperCase(nameE.charAt(0)) + nameE.substring(1), Double.class);
@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
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;
}
LocalDate.now()로 현재 날짜로 설정
하였습니다.@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()
메서드는 특정 필드의 중복을 제거한 후 그 수를 세는 데 사용합니다.COUNT(DISTINCT column)
와 동일한 기능 수행건당 데이터
입니다.count(E.member_id)
로 할 경우 사용자의 지출 데이터 총 개수 반환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';
@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);
}
/*
카테고리 한글 이름 추출 메서드
*/
private String getCategoryNameH(Long findCategoryId) {
return jpaQueryFactory
.select(category.nameH)
.from(category)
.where(category.id.eq(findCategoryId))
.fetchOne();
}