QueryDsl Projection 생성자 관련 삽질 기록
Projection관련하여, DTO클래스의 QClass 생성자가 제대로 생성이 제대로 안되길래 아래의 내용들을 의심했으나 역시나 삽질이었고!
- SpringBoot 3.0으로 넘어오면서 의존성 주입을 잘못받았는지 or 버전이 안맞았는지 → 아니었음
- 리플렉션으로 생성자가 만들어지는 것 같은데, Getter나 Setter 기반으로 설정하는건가..? → 내가 쓴건
@QueryProjection
이나Projections.constuctor
였으므로 아니었음.
결론부터 말하자면, 생성자에 써둔 private
접근제어자 때문이었다.
- QueryDsl의 Projection 관련 코드는 생성자에
getConstructor
로만 접근한다. - 해당 메서드로는
private
생성자를 가져올 수 없다.- 참고로, private 생성자는
getDeclaredConstructors
로 가져올 수 있다. - 사용도 하고 싶은 경우에는 리플렉션의 setAccessible(true) 를 이용해야 한다.
- 참고로, private 생성자는
나는 당연히 리플렉션을 사용하니까 private
도 감안해서 설계를 해놨을 것이라고 생각하고, 해당 부분을 문제삼지 않아버렸다는 것이 삽질의 원흉이 되어버렸다.
또한, 해당 문제는 내 미천한 구글링 실력으로는 해결할 수가 없었기에, 내가 원리를 모르는구나 생각이 들어, 그냥 코드를 뜯어봤다. (사실 디버깅을 해도 됐을텐데 이를 생각하지 못해 삽질을 오지게 해버렸다. 디버깅 툴을 잘 쓰도록 하자..)
아래는 Projections.constructor
의 코드를 뜯어본 내용이다.
Projections.constructor
파라미터 설명
- type : projection으로 사용할 타입
- paramTypes : 생성자의 파라미터로 넘겨줄 타입
- exprs : 생성자의 파라미터로 넘겨줄 값
보통 우리는 맨 위의 constructor를 사용하게 되며, 아래와 같이 사용할 수 있다.
Projections.constructor(PostResponse.class,
post.id,
post.title,
post.contents,
post.author.memberName.as("author"),
post.createdAt,
post.modifiedAt);
//프로젝션에 사용할 DTO의 생성자
public PostResponse(final Long id, final String title, final String contents, final String author,
final LocalDateTime createdAt, final LocalDateTime modifiedAt) {
this.id = id;
this.title = title;
this.contents = contents;
this.author = author;
this.createdAt = createdAt;
this.modifiedAt = modifiedAt;
}
여기서 주의해야 할 점은, PostResponse
의 생성자에 작성된 파라미터 이름과, 내가 지금 ProjectionConstructor에 넘겨주고 있는 값의 필드 이름이 완전히 동일해야 한다는 것이다. (또한, public
이어야 한다!)
위와 같이 작성해줄 경우, Projections.constructor는 ConstructorExpression<T>
를 반환하게 된다.
그럼, ConstructorExpression은 뭐 하는 녀석일까? 프로젝션으로 사용할 DTO의 생성자 표현(?)을 리플렉션으로 추출해서 생성해주는 역할을 하는 클래스가 아닐까? 라고 추측했다.
ConstructorExpression
// 이게 우리가 주로 사용하게 될 생성자
protected ConstructorExpression(Class<? extends T> type, Expression<?>... args) {
this(type, getParameterTypes(args), Arrays.asList(args));
}
// 주 생성자
protected ConstructorExpression(Class<? extends T> type, Class<?>[] paramTypes, List<Expression<?>> args) {
super(type);
try {
this.parameterTypes = getConstructorParameters(type, paramTypes).clone();
this.args = Collections.unmodifiableList(args);
this.constructor = getConstructor(getType(), parameterTypes);
this.transformers = getTransformers(constructor);
} catch (NoSuchMethodException e) {
throw new IllegalArgumentException(e);
}
}
여기서 주목할만한 점이 몇 가지가 있다.
- type은 super를 통해 부모 클래스의 생성자에 넘긴다.
- 이를 쭉 타고 가보면
ExpressionBase
라는 추상 클래스의 생성자임을 확인할 수 있었다. - 이는 변경할 수 없는
Expression
구현을 위한 기본 클래스라고 한다. Expression
은Query
인스턴스에서 일반적인 타입의 표현식을 정의하는 인터페이스다.QueryDSL
에서 사용되는 표현의 기본인 것 같다.- Constant - 문자열, 숫자 및 엔터티 인스턴스와 같은 상수용
- FactoryExpression - 행 기반 결과 처리용
- Operation - 일반적으로 지원되는 작업 및 함수 호출용
- ParamExpression - 바인딩 가능한 쿼리 매개변수용
- Path - 변수, 속성 및 컬렉션 멤버 액세스용
- SubQueryExpression - 하위 쿼리용
- TemplateExpression - 사용자 지정 구문용
- 이를 쭉 타고 가보면
- 필드를 어떠한 메서드를 통해 할당한다.
나는 두 번째로 주목했던 필드를 어떠한 메서드를 통해 할당한다는 점에 삽질의 근원이 있을것이라고 판단했고, 이를 파헤쳐보기로 했다.
getParameterTypes
protected ConstructorExpression(Class<? extends T> type, Expression<?>... args) {
this(type, getParameterTypes(args), Arrays.asList(args));
}
private static Class<?>[] getParameterTypes(Expression<?>... args) {
Class<?>[] paramTypes = new Class[args.length];
for (int i = 0; i < paramTypes.length; i++) {
paramTypes[i] = args[i].getType();
}
return paramTypes;
}
이는 주 생성자에 파라미터 타입 배열을 넘겨주기 위해, 우리가 넘겨줬던 필드들을(위에서 언급한 id, title…등) Class<?>[]
배열로 반환해주는 메서드이다.
해당 메서드를 이용해서 주 생성자의 paramTypes 인자에 넘겨준다.
protected ConstructorExpression(Class<? extends T> type, Class<?>[] paramTypes, List<Expression<?>> args) {
super(type);
try {
this.parameterTypes = getConstructorParameters(type, paramTypes).clone();
this.args = Collections.unmodifiableList(args);
this.constructor = getConstructor(getType(), parameterTypes);
this.transformers = getTransformers(constructor);
} catch (NoSuchMethodException e) {
throw new IllegalArgumentException(e);
}
}
필요한 값들을 주 생성자에 넘겨주고 나면, 필드에 할당되기 시작한다. 필드값 할당에는 여러 메서드가 사용되는데, 해당 메서드들을 순서대로 파헤쳐보자.
getConstructorParameters
public static Class<?>[] getConstructorParameters(Class<?> type, Class<?>[] givenTypes) {
next_constructor:
for (Constructor<?> constructor : type.getConstructors()) {
int matches = 0;
Class<?>[] parameters = constructor.getParameterTypes();
Iterator<Class<?>> parameterIterator = Arrays
.asList(parameters)
.iterator();
if (!isEmpty(givenTypes)
&& !isEmpty(parameters)) {
Class<?> parameter = null;
for (Class<?> argument : givenTypes) {
if (parameterIterator.hasNext()) {
parameter = parameterIterator.next();
if (!compatible(parameter, argument)) {
continue next_constructor;
}
matches++;
} else if (constructor.isVarArgs()) {
if (!compatible(parameter, argument)) {
continue next_constructor;
}
} else {
continue next_constructor; //default
}
}
if (matches == parameters.length) {
return parameters;
}
} else if (isEmpty(givenTypes)
&& isEmpty(parameters)) {
return NO_ARGS;
}
}
throw new ExpressionException("No constructor found for " + type.toString()
+ " with parameters: " + Arrays.deepToString(givenTypes));
}
- 이는 지정된 타입(프로젝션으로 반환할)과 일치하는 생성자의 매개변수를 반환하는 메서드다.
getConstructors
메서드를 이용해 타입에 존재하는 모든 생성자에 대한 배열(Constructor<?>[]
)을 얻어 모든 요소를 탐색한다.- 얻은 생성자에,
getParameterTypes
메서드를 이용해 파라미터 타입에 대한 배열을 얻고, 이를 Iterator로 변환한다.- 매개변수 타입이 없는 경우,
NO_ARGS
를 반환한다. (이는 빈 클래스 타입이다. 빈 클래스 타입을 반환 해줌으로써 생성자에 매개변수가 없음을 나타내는 것 같다.)
- 매개변수 타입이 없는 경우,
- 매개변수 타입이 있는 경우
- 지정된 타입(
givenTypes
)과getParameterTypes
으로 얻은 타입이 서로 매치하는지 평가한다. - 모두 매치할 경우
getParameterTypes
로 얻은 타입을 반환한다. - 매치하지 않을 경우 예외를 발생시킨다. (생성자를 찾을 수 없다는 예외 메세지 발생)
- 지정된 타입(
생성자가 제대로 생성되지 않는 문제는 여기서 발생한 것이었다.
위 코드를 보면 생성자에 getConstructors
로 접근한다. 이는 private
접근제어자가 달린 생성자는 가져올 수 없기 때문에, private 생성자만 있는 경우에는 빈 배열을 반환한다.
빈 배열이 반환될 경우, 위 메서드의 for문을 곧바로 빠져나와 맨 아래의 예외 문장을 실행시키고 종료한다. 내 DTO의 QClass 생성자가 제대로 실행 안되는 것은 해당 메서드 때문이었던 것이다.
getConstructors vs getDeclaredConstructors
public class Test {
static class TestClass {
private final Long id;
private final String title;
private TestClass(Long id, String title) {
this.id = id;
this.title = title;
}
}
public static void main(String[] args) {
Class<TestClass> type = TestClass.class;
Constructor<?>[] constructors = type.getConstructors();
Constructor<?>[] declaredConstructors = type.getDeclaredConstructors();
System.out.println(Arrays.toString(constructors));
System.out.println(Arrays.toString(declaredConstructors));
}
}
문제를 찾아 DTO의 생성자를 public
으로 변환 해줌으로써 문제는 해결할 수 있었다. 하지만, 분석은 끝까지해야 한다는 생각에, 나머지 메서드들에 대한 내용도 간단하게나마 확인해 보았다.
getConstructor
public static <C> Constructor<C> getConstructor(Class<C> type, Class<?>[] givenTypes) throws NoSuchMethodException {
return type.getConstructor(givenTypes);
}
givenType
에 넘겨받은 파라미터 목록을 가지는type
의 생성자를 반환하는 메서드Class
의getConstructor(Class<?>... parameterTypes)
메서드를 이용한다.
getTransformers
public static Iterable<Function<Object[], Object[]>> getTransformers(Constructor<?> constructor) {
return Stream.of(
new PrimitiveAwareVarArgsTransformer(constructor),
new PrimitiveTransformer(constructor),
new VarArgsTransformer(constructor))
.filter(ArgumentTransformer::isApplicable)
.collect(Collectors.toList());
}
- 생성자에 적용 가능한 transformer를 반환한다고 한다. 정확히 어디에 쓰이는지에 대한 설명은 적혀있지 않았다. 어디에 쓰이는지 알 수 없지만, 타입 변환기처럼 작동하는 듯 하다.
마무리
속단하면 안된다는걸 다시 한번 깨닫게 되었다. 내가 설계자가 아닌 이상, 해당 프레임워크의 구동 방식은 반드시 확인해봐야 아는 것이 맞다. 세상에 당연한 것은 없으므로, 항상 모든걸 의심하고 확인해보는 습관을 가지도록 하자.