ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 이름을 박아버려 : Querydsl 오픈소스 개선하기
    백엔드 : 서버공부/Spring 2025. 11. 7. 10:51
    728x90

    들어가며

    요즘 내 인생에 귀감이 되는 일이 많은 것 같다. 얼마전에 LA 다져스가 우승했는데, 그 과정에서 야마모토의 엄청난 희생이 있었다.
    전날 완투를 하고 다음날 마무리 투수로 나온다는 것은 야구선수로썬 정말 불가능에 가까운 헌신인데 
    최고의 위치에서 저런 노력을 한다는 것, 자신의 일을 진심으로 사랑한다는 것
    무엇보다 자신이 결정하고 맡은 일에 대한 책임감에 대해 참 많은 것을 느끼게 하는 선수인것 같다.
     
    이쯤되면 고시엔 출신 야구선수들은 다들 엄청난 것 같다는 생각이 든다. 오타니도 그렇고 

    야마모토 : 팀이 벼랑 끝에 몰렸는데 '팔이 아파서' 따위 이유로 외면하는 선수로 남을 순 없었다.

     
    하여간 지금은 인생에서 몰입해야하는 시기라는게 피부로 느껴지는 시기인거 같다.
     
     

    배경

    1년 전에 내가 만든 SimpleDtoProjection 아이디어가 Querydsl 6.8 릴리스에 반영되었었다. (새삼 시간 참 빠르다는 생각)

     
    https://github.com/OpenFeign/querydsl/releases/tag/6.8

    Release 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 에서 찾는다.

     

    생성 호출 – 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(이름 → 파라미터 수/필드 순서)을 갖추게 되었다.

    또한, 중복 필드 충돌 정책(선행 엔티티 우선)기본 생성자 배제 이유를 명확히 해두어, 사용자가 예측 가능한 방식으로 쓸 수 있게 했다.
     
    욕심을 좀 부려서 실제 사용자가 있었으면 하는 바램이 문서도 열심히 적었다. 하하하 ( 구현자도 적어두고 하하하 내 이름을 세계로 하하 )

     

Designed by Tistory.