-
이름을 박아버려 : Querydsl 오픈소스 개선하기백엔드 : 서버공부/Spring 2025. 11. 7. 10:51728x90
들어가며
요즘 내 인생에 귀감이 되는 일이 많은 것 같다. 얼마전에 LA 다져스가 우승했는데, 그 과정에서 야마모토의 엄청난 희생이 있었다.
전날 완투를 하고 다음날 마무리 투수로 나온다는 것은 야구선수로썬 정말 불가능에 가까운 헌신인데
최고의 위치에서 저런 노력을 한다는 것, 자신의 일을 진심으로 사랑한다는 것
무엇보다 자신이 결정하고 맡은 일에 대한 책임감에 대해 참 많은 것을 느끼게 하는 선수인것 같다.
이쯤되면 고시엔 출신 야구선수들은 다들 엄청난 것 같다는 생각이 든다. 오타니도 그렇고
야마모토 : 팀이 벼랑 끝에 몰렸는데 '팔이 아파서' 따위 이유로 외면하는 선수로 남을 순 없었다.
하여간 지금은 인생에서 몰입해야하는 시기라는게 피부로 느껴지는 시기인거 같다.
배경
1년 전에 내가 만든
SimpleDtoProjection아이디어가 Querydsl 6.8 릴리스에 반영되었었다. (새삼 시간 참 빠르다는 생각)
https://github.com/OpenFeign/querydsl/releases/tag/6.8Release QueryDSL 6.8 · OpenFeign/querydsl
What's Changed Sort constructors in DefaultProjectionSerializer by number of parameters by @rimuln in #572 A little bit of cleanup by @kamilkrzywanski in #585 Add SimpleDTOProjection for dynamic ...
github.com
초기 목표는 단순했다. DTO 생성자 파라미터(또는 필드) 이름과 Q타입의 필드 이름이 같다면, 매번
Projections.fields(...)를 나열하지 않고 자동으로 매핑되게 하자는 것이었다.이때 구현은 “단일 엔티티(Q타입) → 단일 DTO” 만 가정했었다.
이번에 회사에서 실제로 쓰려 하니 실전 요구와의 괴리가 보였다.
실무의 DTO는 흔히 여러 도메인 필드를 합쳐서 만든다. 그래서 다음과 같이 확장했다.- 여러
EntityPathBase<?>(Q타입)을 varargs 로 받게 했다. - 생성자 이름 기반 매칭을 1순위로, 파라미터 수 기반 fallback을 2순위로 두었다.
- 자바가
-parameters없이 컴파일되어 파라미터 이름이arg0,arg1로 나오는 경우를 대비해, DTO 필드 선언 순서를 보조 키로 쓰는 두 번째 fallback을 추가했다.
작업 중에 이름을 더 직관적으로 바꿨으나, 리뷰에서 diff 추적을 쉽게 하려고 일단 원래 이름으로 되돌려 리뷰를 받고,
리뷰가 끝난 후 레포 오너의 허락을 받아 다시 의도한 이름으로 정리했다.
또, 처음 보는 사용자도 바로 쓸 수 있도록 주석(Javadoc)을 상세하게 작성했다.전체 흐름(원리) 한눈에 보기
이 클래스는 DTO 타입과 여러 Q타입 인스턴스를 받아 다음 과정을 수행한다.
수집 단계 – Q타입에서 Expression 뽑기
- 입력:
EntityPathBase<?>... entities(예:QUser user, QTeam team) - 각 Q타입 인스턴스의 public 필드를 리플렉션으로 훑는다.
- 필드 값을
f.get(entity)로 꺼내Expression<?>인 것만 모은다. Map<String, Expression<?>> exprByName에 “필드명 → Expression” 으로 저장한다.- 중복 키가 들어오면 첫 번째 엔티티가 우선하도록, 이미 존재하는 키는 덮어쓰지 않는다.
생성자 선택 단계 – DTO의 어느 생성자를 쓸지 정한다
- 대상:
type.getDeclaredConstructors()→ DTO 클래스의 모든 생성자(= “누구 생성자인지”는 항상 DTO다). - 1순위: 이름 기반 매칭 가능한 생성자를 찾는다.
- 각 생성자의
Parameter[]를 보고, 모든 파라미터 이름이exprByName의 키로 존재하면 채택한다.
- 각 생성자의
- 2순위: 파라미터 수 기반 fallback
- 이름으로 맞는 생성자가 없으면,
exprByName.size()이하의 파라미터 개수를 가진 생성자 중 가장 파라미터가 많은 것을 고른다. - 이유: 실전에서 “가장 풍부한 생성자”가 의도한 바인딩일 가능성이 높다.
- 이름으로 맞는 생성자가 없으면,
- 중요:
params.length == 0(기본 생성자) 는 스킵한다.- 이 클래스는 생성자 인자 기반 매핑만 지원한다.
- 기본 생성자를 고르면 setter/필드 주입 로직이 추가로 필요한데, 해당 기능은 제공하지 않기 때문이다.
- 따라서
if (params.length == 0) continue;로 의도적으로 제외한다.
인자 바인딩 단계 – 선택된 생성자의 각 파라미터에 Expression 매칭
- 선택된 생성자의
Parameter[] params를 순회한다. - 일반 케이스: 파라미터 이름을 키로
exprByName에서Expression<?>를 찾는다. - 특수 케이스: 파라미터 이름이
arg0,arg1… 라면(=-parameters없이 컴파일됨)- DTO 클래스의
getDeclaredFields()로 필드 선언 순서 배열을 가져와 - 동일한 인덱스(i) 의 필드명을 대신 키로 사용한다.
- 즉,
params[i]의 실제 이름 대신fields[i].getName()으로 이름을 삼아exprByName에서 찾는다.
- DTO 클래스의
생성 호출 –
constructor.newInstance(args...)- 위에서 매칭된
Expression<?>들을 Querydsl이 채워줄 값 시그니처로 넘겨, - 최종적으로 선택된 DTO 생성자를 호출해 인스턴스를 만든다.
누구를 훑고, 어디에 담는가
무엇을 훑는가
- 입력으로 받은 Q타입 인스턴스들(
EntityPathBase<?>)의 public 필드를 훑는다. - 예:
QUser에는public StringPath name; public NumberPath<Long> id;같은 필드가 있다.
어떻게 훑는가
for (EntityPathBase<?> entity : entities) { for (Field f : entity.getClass().getFields()) { // public field만 Object val = f.get(entity); // 필드 값을 실제 인스턴스에서 꺼냄 if (val instanceof Expression) { map.putIfAbsent(f.getName(), (Expression<?>) val); } } }어디에 담는가
HashMap<String, Expression<?>> exprByName에 “필드명 → Expression” 으로 담는다.- 중복 키는 무시한다(
putIfAbsent). 즉 먼저 등장한 엔티티가 우선된다.
왜 이렇게 담는가
- 이후 단계에서 순서에 독립적으로 이름 기반 매칭을 하기 위해서다.
- varargs 로 받은 엔티티들을 “하나의 이름 공간” 으로 통합한다.
누구 생성자인가 ( 기본 생성자 건너뛰는 이유 )
누구 생성자인가
- 항상 DTO 클래스(
type)의 생성자들을 본다. - 즉
getDeclaredConstructors()로 가져오는 건 DTO 자신의 모든 생성자다.
이름 기반 매칭(1순위)
- 각 생성자의
Parameter[]의 이름이 전부exprByName에 존재하면 그 생성자를 즉시 채택한다. - 이 경우, 파라미터 순서는 중요하지 않다. 이름으로 매칭하기 때문이다.
파라미터 수 기반 fallback(2순위)
사실 이 부분이 실행되면 안되긴하다..
- 위 조건을 만족하는 생성자가 없으면,
exprByName.size()보다 개수가 많지 않고,- 0개가 아니며(중요),
- 그중 가장 파라미터 수가 많은 생성자를 고른다.
- 실제 서비스 코드에서 의미 있는 값 바인딩을 가장 많이 담을 수 있는 생성자가 의도일 확률이 높다는 가정이다.
if (params.length == 0) continue;의 의미
최초 구현에서는 해당 부분없이 생성자를 찾도록하니까 기본생성자가 선택 되어서 인자갯수가 안맞는 오류가 테스트 과정에서 계속해서 터졌다. 이유는 생성자를 매칭하는 원리에 있었는데, 생성자를 매칭할때 파라미터이름들과 미리수집한 엔티티 필드명들이었기 때문이다.
쉽게말해서 검사할 게 없으니까 모든 조건을 만족한다고 간주되는 문제가 있던 것이다.
그래서 findMatchingConstructor 내부에서 조건문을 통해 기본생성자를 후보에서 건너뛰게 했다.
- 기본 생성자(파라미터 0개) 는 아예 후보에서 제외한다.
- 이유: 이 클래스는 생성자 인자 주입 방식만 제공한다.
- 기본 생성자를 선택해버리면, 이후에 setter/필드 주입이 필요한데, 해당 기능은 제공하지 않는다.
- 잘못된 생성자 선택으로 침묵 실패하거나 의도치 않은 인스턴스가 만들어지는 것을 막기 위해 명시적으로 배제한다.
인자 바인딩 단계 상세: , 대응
정상 케이스(파라미터 이름이 보이는 경우)params[i].getName()으로 이름을 얻고,exprByName.get(name)으로Expression<?>를 찾는다.
특수 케이스(파라미터가
arg0,arg1로 보이는 경우)- 이는 DTO가
-parameters옵션 없이 컴파일되어 리플렉션에 파라미터 이름 정보가 없다는 뜻이다. - 이때는 DTO 클래스의 필드 선언 순서를 대체 키로 사용한다.
Field[] fields = ctor.getDeclaringClass().getDeclaredFields();String fallbackName = fields[i].getName();exprByName.get(fallbackName)으로 매칭한다.
- 즉, 생성자 i번째 파라미터 ↔ DTO의 i번째 필드명 을 같은 것으로 취급하여 이름 매칭을 흉내 낸다.
참고: 자바 리플렉션의
getDeclaredFields()순서는 명세상 보장되지 않는다.
다만 현대 JVM에서는 선언 순서를 보존하는 구현이 일반적이라 실무에서는 대체로 기대대로 동작한다.
선언 순서 변화에 민감해질 수 있으므로, DTO 필드 순서를 임의로 변경하지 않는 사용 습관 또는-parameters를 켠 빌드를 권장한다.예외 처리와 실패 모드
- 바인딩 과정에서 이름(혹은 대체 이름)으로
exprByName에서 Expression을 찾지 못하면
→throw new RuntimeException("No expression for parameter: " + name);로 즉시 실패한다. null을 주입하지 않는 이유는, 나중에 더 큰 곳에서 NPE로 터지는 것보다 초기에 명확하게 실패하는 편이 디버깅 비용이 낮기 때문이다.
사실 구현하다가 우연하게 맞아 떨어짐
사실 리플렉션을 통해 사용할 DTO 생성자를 미리 알아내고,
fetch 시점에 각 필드에 대응하는 값을 정확한 순서로 채워 넣는 과정은 꽤 정밀한 작업이다.
내부적으로는 결국 newInstance()를 통해 생성자를 직접 호출하기 때문에,
이름 기반 매칭이라 하더라도 실제로는 생성자 파라미터 순서에 맞게 값이 배열되어야 한다.
의도적으로 설계한 것은 아니었지만,
구현 과정에서 우연히 이 순서가 일치하면서 정확히 동작하게 된 셈이다.
하하하하...
한계와 사용시 주의점
- DTO 필드/파라미터 이름과 Q타입 필드 이름이 같아야 한다.
이름이 다르면 매칭되지 않으며 예외가 난다. - 여러 엔티티에서 같은 이름이 나오면 첫 번째 인자의 엔티티가 우선된다.
의도된 정책이며 문서화되어야 한다. - 기본 생성자(0-파라미터)는 지원하지 않는다.
setter/필드 주입은 이 클래스의 역할이 아니다. -parameters없이 컴파일된 DTO 는 필드 선언 순서에 민감해진다.
순서가 바뀌면 바인딩이 달라질 수 있다.
가능하면-parameters옵션 사용을 권장한다.
이름 변경과 리뷰 전략

- 가독성을 위해 클래스 이름을 더 직관적으로 바꾸었으나, PR 리뷰에서 diff가 너무 커져
→ 원래 이름으로 잠시 되돌려 리뷰를 받았다. - 리뷰가 끝난 뒤 레포 오너가 다시 바꿔도 된다고 해서, 의도한 이름으로 최종 정리했다.
- 이런 절차를 통해 리뷰어가 변경 논리를 파악하기 쉬운 최소 diff 를 유지하면서도, 최종 설계 의도를 반영했다.
마무리

이번 개선으로, 초기의 “단일 엔티티 → 단일 DTO” 전제에서 벗어나 여러 엔티티의 필드를 이름 기반으로 통합 매핑할 수 있게 되었고, 자바 파라미터 이름 손실(
arg0,arg1) 상황까지 커버하는 이중 fallback(이름 → 파라미터 수/필드 순서)을 갖추게 되었다.또한, 중복 필드 충돌 정책(선행 엔티티 우선) 과 기본 생성자 배제 이유를 명확히 해두어, 사용자가 예측 가능한 방식으로 쓸 수 있게 했다.
욕심을 좀 부려서 실제 사용자가 있었으면 하는 바램이 문서도 열심히 적었다. 하하하 ( 구현자도 적어두고 하하하 내 이름을 세계로 하하 )
'백엔드 : 서버공부 > Spring' 카테고리의 다른 글
책임 : 유스케이스는 입력 검증이 아니라 도메인 상태 변경 (0) 2025.11.07 MVC에서 헥사고날 아키텍처로의 여정 - 2편: 매핑 전략 (0) 2025.11.02 가상 스레드 (Virtual Thread) 성능 테스트 해보기 (0) 2025.10.30 Java Virtual Threads: 개념, 등장 배경 (0) 2025.10.29 MVC에서 헥사고날 아키텍처로의 여정 - 1편: 구조의 재배열 (0) 2025.10.28 - 여러