월별/요일별/시간대별 소비를 알아보고 그래프로 표현하기 위해 통계API가 필요했다.
Spring Boot에서 집계 통계를 구현할 수 있는 대표 방식에는 JPQL과 QueryDSL이 있다.
참고 사이트
https://kangth97.tistory.com/42
https://sjh9708.tistory.com/174#google_vignette
//QueryDsl
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
IntelliJ의 오른쪽 Gradle 탭에서 clean 실행 후 build를 실행한다.
위와 같이 하면 프로젝트 -> build -> generated 에서 JPA entity class를 생성했던 디렉토리 명과 동일한 디렉토리에 Q 클래스가 생성되는 것을 볼 수 있다.

@Getter
@AllArgsConstructor
public class HourlyStatsDto {
private int hour; // 0~23
private long amount;
}
@Getter
@AllArgsConstructor
public class MonthlyStatsDto {
private int month;
private long amount;
}
@Getter
@AllArgsConstructor
public class WeekdayStatsDto {
private int weekday; // 1~7
private long amount;
}
QueryDSL로 쿼리를 작성하기 위해서 Q 클래스를 이용한다.
쿼리 작성은 queryFactory를 빌더패턴으로 생성하면 된다.
SELECT, WHERE 같은 구문들을 하나씩 연이어 붙여주고 마지막에 fetch를 해주면 된다.
주요 구문들은 다음과 같다.
select: entity를 선택하거나 필요한 특정 필드만 선택할 수 있다. 특정 필드만 선택하는 경우 Tuple 객체로 반환from: 타겟 엔티티 테이블 지정selectFrom: 지정된 Q 클래스 엔터티에서 데이터를 선택한다.where: 필터링 조건 지정fetch: 쿼리를 실행하고 결과를 리스트로 반환join: 두 엔터티 간의 관계를 조인groupBy: 지정된 필드로 그룹화..avg(), .sum(), .count() 등을 덧붙여 데이터 집계// 월별 통계
public List<MonthlyStatsDto> getMonthlyStats(Long userId, int year){
return queryFactory.select(Projections.constructor(MonthlyStatsDto.class,
expense.dateTime.month(),
expense.amount.sum()
))
.from(expense)
.where(expense.user.id.eq(userId)
.and(expense.dateTime.year().eq(year)))
.groupBy(expense.dateTime.month())
.orderBy(expense.dateTime.month().asc())
.fetch();
}
// 요일별 통계
public List<WeekdayStatsDto> getWeekdayStats(Long userId){
return queryFactory
.select(Projections.constructor(WeekdayStatsDto.class,
expense.dateTime.dayOfWeek(),
expense.amount.sum()
))
.from(expense)
.where(expense.user.id.eq(userId))
.groupBy(expense.dateTime.dayOfWeek())
.orderBy(expense.dateTime.dayOfWeek().asc())
.fetch();
}
// 시간대별 통계
public List<HourlyStatsDto> getHourlyStats(Long userId) {
return queryFactory
.select(Projections.constructor(HourlyStatsDto.class,
expense.dateTime.hour(),
expense.amount.sum()
))
.from(expense)
.where(expense.user.id.eq(userId))
.groupBy(expense.dateTime.hour())
.orderBy(expense.dateTime.hour().asc())
.fetch();
}
더미 데이터 50개를 생성해 postman 테스트를 진행했다.
이 때 오류가 발생했다.
지출 금액(amount)를 엔티티에는 Integer로 선언했는데,
통계 DTO에는 Long으로 선언해 Type 불일치가 생긴 것이다.
Integer로 금액을 선언하면 큰 금액을 입력했을 때 에러가 발생하기 때문에 Long으로 타입을 변경했다.
getWeekdayStats에서 요일을 추출할 때 MySQL 문법을 썼더니 에러가 발생했다.
프로젝트에서 PostgreSQL을 사용하고 있기 때문이다.
PostgreSQL 요일 함수는 아래와 같다.
| 목적 | PostgreSQL 함수 | 설명 |
|---|---|---|
| 0~6 (일~토) | EXTRACT(DOW FROM timestamp) | DOW = day of week |
| 1~7 (월=1~일=7) | EXTRACT(ISODOW FROM timestamp) | ISO 기준 |
| 문자열 | TO_CHAR(timestamp, 'Day') | Monday, Tuesday 등 |
QueryDSL 문법으로 PostgreSQL 요일을 추출하기 위해
Expressions.numberTemplate 사용하면 된다.
즉, 요일별 지출 통계 코드를
public List<WeekdayStatsDto> getWeekdayStats(Long userId) {
NumberExpression<Integer> weekdayExpression = Expressions.numberTemplate(
Integer.class,
"extract(isodow from cast({0} as timestamp))",
expense.dateTime
);
return queryFactory
.select(Projections.constructor(
WeekdayStatsDto.class,
weekdayExpression,
expense.amount.sum()
))
.from(expense)
.where(expense.user.id.eq(userId))
.groupBy(weekdayExpression)
.orderBy(weekdayExpression.asc())
.fetch();
}
이렇게 수정하면 되는 줄 알았다.
그런데 실행하자마자 다음 오류가 터졌다.
org.hibernate.query.SyntaxException:
no viable alternative at input
'select extract(isodow *from expense.dateTime)'
Hibernate가 from 앞에 알 수 없는 * 를 붙여버리면서
SQL 문법이 깨지고, PostgreSQL도 실행할 수 없는 것이었다.
왜 이런 이상한 *from 이 생기는가?
이 문제는 Hibernate + PostgreSQLDialect 의 버그에서 온다.
extract( … from exp ) 형태의 SQL 조각을 Hibernate 가 내부적으로 분석하려고 하며*from을 생성하는 것이었다.즉, Hibernate 파서가 extract() 구문을 조작하면서 망가뜨리는 것이다.
그래서 어떤 방식으로 템플릿을 작성해도 동일한 에러가 터졌다.
결국 최종 해결책으로 완전 Native SQL로 우회하기를 선택했다.
Hibernate가 extract() 내부를 파싱하지 못하도록 QueryDSL을 사용하지 않고 Native SQL을 그대로 DB로 보내는 방식으로 변경했다.
@PersistenceContext
private EntityManager em;
public List<WeekdayStatsDto> getWeekdayStats(Long userId) {
String sql = """
SELECT
extract(isodow from e.date_time) AS weekday,
SUM(e.amount) AS total
FROM expense e
WHERE e.user_id = :userId
GROUP BY extract(isodow from e.date_time)
ORDER BY extract(isodow from e.date_time)
""";
List<Object[]> rows = em.createNativeQuery(sql)
.setParameter("userId", userId)
.getResultList();
List<WeekdayStatsDto> result = new ArrayList<>();
for (Object[] row : rows) {
result.add(new WeekdayStatsDto(
((Number) row[0]).intValue(),
((Number) row[1]).longValue()
));
}
return result;
}

각 요일별로 지출 통계가 나오는 것을 확인할 수 있다.
마찬가지로 getHourlyStats 함수도 수정해야 한다.
PostgreSQL에서 “hour(시간)” 추출하는 함수는 EXTRACT(HOUR FROM timestamp)이다.
public List<HourlyStatsDto> getHourlyStats(Long userId) {
String sql = """
SELECT
extract(hour from e.date_time) AS hour,
SUM(e.amount) AS total
FROM expense e
WHERE e.user_id = :userId
GROUP BY extract(hour from e.date_time)
ORDER BY extract(hour from e.date_time)
""";
List<Object[]> rows = em.createNativeQuery(sql)
.setParameter("userId", userId)
.getResultList();
List<HourlyStatsDto> result = new ArrayList<>();
for (Object[] row : rows) {
result.add(new HourlyStatsDto(
((Number) row[0]).intValue(), // hour
((Number) row[1]).longValue() // total amount
));
}
return result;
}
이렇게 시간대별로 지출 금액을 합해 반환하는 것을 볼 수 있다.