QueryDSL에서 Projections.constructor 사용해서 SQL 함수 사용하기
문제 상황
SubComment와 Recipe 엔티티에는 다음과 같이 LocalDateTime 타입으로 선언된 생성 날짜가 있다.
@CreatedDate
@Column(name = "create_dttm", nullable = false)
private LocalDateTime createDateTime;
이렇게 선언되어 있는 생성 날짜를 각각의 DTO객체인 SubCommentListResponseDto와 RecipeListResponseDto에서 String 객체인 createDate로 맵핑해서 조회하려고 시도했다.
private String createDate;
QueryDSL에서는 TO_CHAR 함수를 사용하여 LocalDateTime 타입의 필드를 조회했다. 이 함수는 LocalDateTime을 문자열(String) 형태로 변환하는 데 사용된다.
Expressions.stringTemplate("TO_CHAR({0}, 'YYYY-MM-DD')", entity.createDateTime)
위 코드는 createDateTime 필드(날짜와 시간을 포함하는 LocalDateTime 타입)를 'YYYY-MM-DD' 형태의 문자열로 변환하여 반환한다.
이 함수는 SubCommentListResponseDto에서는 정상적으로 작동했지만, RecipeListResponseDto에서는 InvalidDataAccessApiUsageException 에러가 발생했다.
실제 코드는 각각 다음과 같다.
SubCommentListResponseDto에서는 정상 작동하는 코드
JPAQuery<SubCommentListResponseDto> query = queryFactory
.select(Projections.constructor(
SubCommentListResponseDto.class,
// .. 다른 필드
Expressions.stringTemplate("TO_CHAR({0}, 'YYYY-MM-DD')", subCommentEntity.createDateTime),
// .. 다른 필드
))
.from(subCommentEntity);
RecipeListResponseDto에서는 에러를 발생시키는 코드
JPAQuery<RecipeListResponseDto> query = queryFactory
.select(Projections.fields(RecipeListResponseDto.class,
// .. 다른 필드
Expressions.stringTemplate("TO_CHAR({0}, 'YYYY-MM-DD')", recipeEntity.createDateTime)
))
.from(recipeEntity)
.where(whereCondition);
에러 로그는 다음과 같다.
java.lang.RuntimeException: org.springframework.dao.InvalidDataAccessApiUsageException: Unsupported expression TO_CHAR(recipeEntity.createDateTime, 'YYYY-MM-DD') at com.recipia.recipe.config.aop.LoggingAspect.logMethodExecution(LoggingAspect.java:27) ~[classes/:na] at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
에러 코드 분석
이 오류는 TO_CHAR 함수가 지원되지 않음을 나타낸다. 이는 JPA/Hibernate 설정이나 지원되는 SQL 함수와 관련된 문제일 수 있다.
문제 원인
QueryDSL에서는 Projections.fields와 Projections.constructor 두 가지 방식으로 결과를 매핑할 수 있다. 이 둘 사이에는 처리 방식의 차이가 있다.
Projections.constructor
Projections.constructor는 DTO의 생성자를 사용하여 결과를 매핑한다. 이 방식에서는 생성자의 매개변수 타입과 순서가 중요하며, QueryDSL이 DTO 생성자와 정확히 일치하는 방식으로 결과를 매핑한다.
Projections.fields
Projections.fields는 리플렉션을 사용하여 DTO의 필드에 직접 값을 할당한다. 이 방식은 필드의 이름이 중요하다.
문제의 핵심은 TO_CHAR 함수의 사용이다.
TO_CHAR 함수는 SQL 함수이며, 특정 데이터베이스에서만 지원된다. JPA 또는 Hibernate에서 이를 직접 지원하지 않을 수 있고 이는 특정 쿼리 매핑 방식에서 오류를 발생시킬 수 있다.
Projections.fields를 사용할 때 TO_CHAR 함수가 데이터베이스 레벨에서 지원되지 않거나, JPA/Hibernate와 호환되지 않는 경우에 InvalidDataAccessApiUsageException이 발생할 수 있다.
문제 해결 방법
1. Projections.constructor 사용하여 해결
이 방식에서는 생성자의 매개변수가 TO_CHAR 함수의 결과 타입(String)과 일치하기 때문에 문제가 발생하지 않는다.
2. 대체 함수 또는 방식 사용
TO_CHAR 함수가 지원되지 않는 경우, JPA/Hibernate에서 지원하는 다른 함수를 사용하거나, 쿼리 레벨에서 문자열 변환 로직을 적용할 수 있다.
예를 들어, LocalDateTime 타입을 String 타입으로 변환하는 자바 코드를 쿼리 실행 후에 적용할 수 있다.
문제 해결 방법 적용
우리는 Projection.constructor를 사용해서 해결을 하기로 했다.
일단 RecipeListResponseDto 클래스에 querydsl에서 사용할 생성자를 만들어주었다.
// 기존에 있던 생성자
// 모든 필드를 매개변수로 받는 생성자
private RecipeListResponseDto(Long id, String recipeName, String nickname, Long bookmarkId, List<String> subCategoryList, String thumbnailFullPath, String thumbnailPreUrl, String createDate) {
this.id = id;
this.recipeName = recipeName;
this.nickname = nickname;
this.bookmarkId = bookmarkId;
this.subCategoryList = subCategoryList;
this.thumbnailFullPath = thumbnailFullPath;
this.thumbnailPreUrl = thumbnailPreUrl;
this.createDate = createDate;
}
// querydsl select문에서 Projections.constructor 사용하기 위함
public RecipeListResponseDto(Long id, String recipeName, String nickname, Long bookmarkId, String thumbnailFullPath, String createDate) {
this.id = id;
this.recipeName = recipeName;
this.nickname = nickname;
this.bookmarkId = bookmarkId;
this.thumbnailFullPath = thumbnailFullPath;
this.createDate = createDate;
}
이런 식으로 생성자를 만들어주고 querydsl에서 Projections.fields를 Projections.constructor로 수정해 주니 아주 잘 작동 됐다.
JPAQuery<RecipeListResponseDto> query = queryFactory
.select(Projections.constructor(RecipeListResponseDto.class,
// .. 다른 필드
Expressions.stringTemplate("TO_CHAR({0}, 'YYYY-MM-DD')", recipeEntity.createDateTime)
))
.from(recipeEntity)
.where(whereCondition);
결론
이번 문제 해결 과정에서 우리가 선택한 Projections.constructor를 사용하는 방법은 현재 상황에 적합한 해결책일 수 있으나, 이는 가장 베스트인 해결책이라고 단언할 수는 없을 것 같다. 특히, PostgreSQL과 같은 특정 RDBMS에 대한 의존성을 고려할 필요가 있다.
현재 우리 시스템이 PostgreSQL을 사용하고 있으며, 향후 3년간 변경될 가능성이 낮다는 가정하에 TO_CHAR 함수를 사용하는 것은 합리적인 선택일 수 있다. 그러나 이러한 접근은 특정 데이터베이스 시스템에 대한 의존성을 증가시킨다.
실무적 관점에서 보면, 데이터베이스 시스템이 변경될 수 있다는 가능성을 염두에 두어야 한다. 데이터베이스 변경이 발생할 경우, 데이터베이스에 종속적인 SQL 함수를 사용하는 코드는 전부 수정해야 할 상황에 직면할 수 있기 때문이다.
이러한 상황을 대비하기 위해, QueryDSL을 사용하여 LocalDateTime 타입으로 데이터를 먼저 가져오고, 애플리케이션 단계에서 필요한 형식으로 데이터를 변환하는 더 유연한 접근 방법을 고려할 수 있다. 이 방법은 데이터베이스 시스템에 대한 의존성을 줄이며, 향후 데이터베이스 변경에 보다 유연하게 대응할 수 있게 해 준다.
장기적인 관점에서 이러한 접근은 소프트웨어의 유지보수성을 향상하고, 데이터베이스 변경에 따른 코드 수정 부담을 감소시킬 수 있다. 따라서, 데이터베이스 변경 가능성이 있는 상황에서는 이를 고려한 설계 결정이 중요하다.
우리는 프로젝트를 진행하는 과정에서 다양한 방법을 시도하며 경험을 쌓는 것이 중요하다고 생각했다. 따라서 이 방법으로도 적용해 보고 애플리케이션 단계에서 데이터 변환 방법도 사용해 보면서 여러 가지 해결 방법을 적용해보고 있다.
📣 이 글은 내가 소속된 Team Chillwave에서 진행한 사이드 프로젝트에 적용한 내용을 다시 공부하고 정리한 것이다.
다른 팀원인 "개발자의 서랍" 님의 블로그도 방문하면 도움이 될 것 같다 :)
'Spring > Spring 트러블 슈팅' 카테고리의 다른 글
MSSQL에서 offset 관련 UnsupportedOperationException: query result offset is not supported 에러 발생 (0) | 2024.04.01 |
---|---|
스칼라 서브쿼리(Scalar Subquery)에서 Limit절 오류 해결 (0) | 2024.01.25 |
QueryDSL에서 NPE 해결하기 - 서브쿼리와 외부 조인의 활용 (2) | 2024.01.16 |
단위테스트에서 Static 메소드 Mock 주입 문제 해결 방법 (5) | 2023.12.25 |
스프링 시큐리티: 커스텀 에러 처리 실패 원인 분석 및 해결 방법 (0) | 2023.12.23 |