-
헥사고날에서 QueryDSL 지키기백엔드 : 서버공부 2025. 11. 30. 23:08728x90
들어가며

박정민 배우의 불안에 대한 인터뷰를 보며, 나의 불안이라는 감정에 대해 다시 생각하게 되었다.
곱씹어 보면 나의 불안은 대부분 “하지 않아서” 생겨나는 경우가 많다. 오늘 해야 할 일을 미루었을 때 드는 불안, 기간 안에 정말 끝낼 수 있을까 하는 걱정, 오늘 미룬 일을 내일은 다 해낼 수 있을까 하는 막연한 압박감 같은 것들이다. 하지만 막상 부딪혀 보면, 그런 불안의 대부분은 생각보다 아무것도 아닌 경우가 많았다.
그리고 또 하나의 불안은, 더 잘하고싶다는 마음에서 비롯된다.
오늘 이 글도 사실 그런 불안에서 시작되었다. 하하...
record vs @QueryProjection를 둘러싼 고민 정리
QueryDSL을 실무에 도입하면 어느 시점에는 반드시 이런 질문과 마주하게 된다.
“Projection은 어떤 방식을 쓰는 것이 가장 바람직할까?”

이번에 회사에서 맡게된 기능을 개발하며, 다음 두 가지 방식에서의 고민이 있었다.
record + Projections.constructorclass + @QueryProjection
이 글에서는 지피티의 예시코드와 함께
“주문(Order)과 주문 항목(OrderItem)을 함께 조회하는 API”를 예시로 비교·분석해 보겠다!!!
주문 + 주문 항목 조회 시나리오
예를 하나 들어보자.
- 하나의 주문(Order)은 여러 개의 주문 항목(OrderItem)을 가진다.
- 우리는 특정 고객의 “주문 목록 + 각 주문별 주문 항목 목록”을 한 번에 조회하는 API를 만들고 싶다.
- 반환하고 싶은 구조는 대략 이런 형태다.
{ "orders": [ { "orderId": 1, "orderNumber": "2025-00001", "orderDate": "2025-11-01", "totalAmount": 120_000, "items": [ { "productId": 10, "productName": "상품 A", "quantity": 2, "amount": 40_000 }, ... ] }, ... ] }DB 레벨에서는
orders와order_items를 조인해야 하고,
쿼리 결과는 여러 컬럼들의 데이터가 동일한 레벨에 있는 “FlatRow” 형태가 된다.QueryDSL 레벨에서 이 결과를
OrderSummaryFlatRow같은 객체를 만들어 Projection 기능을 사용해 받고,Application 계층의 Service 클래스에서 이를 다시 적절한 읽기 모델로 재구성하는 것이 흔한 패턴이다.
문제는 바로 이 지점이다.
이 FlatRow를 QueryDSL로 Projection할 때
record + constructor와@QueryProjection중 무엇을 선택할 것인가?QueryDSL Projection 방식들
QueryDSL이 제공하는 Projection 방식은 여러 가지가 있다.
Projections.constructorProjections.fieldsProjections.bean@QueryProjection(Q타입 기반 생성자)Tuple직접 사용
수 많은 방법들 중에서 장기적인 유지보수, 타입 안정성을 함께 고려했을 때
내가 선택한 것은 아래 두 가지다.Projections.constructor@QueryProjection
record + constructor 방식
먼저 선택한 방식은
record와Projections.constructor의 조합이었다.dto는 불변객체여야하고 이에 적합한 것은 record 였기때문에 관습적으로 사용했다.
public record OrderSummaryFlatRow( Long orderId, String orderNumber, String orderDate, Long totalAmount, Long itemId, Long productId, String productName, Integer quantity, Long amount ) {}QueryDSL 쿼리는 다음과 같이 작성할 수 있다.
return queryFactory .select(Projections.constructor( OrderSummaryFlatRow.class, order.id, order.orderNumber, order.orderDate.stringValue(), order.totalAmount, orderItem.id, orderItem.productId, orderItem.productName, orderItem.quantity, orderItem.amount )) .from(order) .leftJoin(orderItem) .on(orderItem.orderId.eq(order.id)) .where(order.customerId.eq(customerId)) .fetch();record 방식의 장점은 아래와 같다.
- DTO는 QueryDSL에 전혀 의존하지 않기때문에 순수하다.
- 따라서, 아키텍처 관점에서 깔끔하다 : 헥사고날 아키텍처, 레이어드 아키텍처, DDD 등에서 Application/Domain DTO가 특정 Infra 기술에 오염되지 않는 것은 중요하다. 이 방식은 “DB 접근 기술은 Adapter에서, 읽기 모델은 Core/Application에서”라는 경계를 지키기 쉽다.
핵심적인 단점은 하나다.
생성자 매핑 오류를 컴파일 타임에 잡지 못한다는 것이다.
- 생성자 파라미터 순서를 바꾸어도 컴파일은 통과한다.
- 타입이 살짝 어긋나더라도 런타임까지 가지 않으면 발견되지 않을 수 있다.
- 데이터가 뒤섞이는 미묘한 버그가 잠복할 수 있다. <- 이게 어쩌면 나중에 큰 문제를 야기할지도 모른다.
QueryDSL의 핵심 가치인 “타입 세이프”를 생각하면 이 점은 분명 아쉬운 부분이다.
@QueryProjection 방식
두 번째 방식은
@QueryProjection을 사용하는 것이다.@Getter public class OrderSummaryFlatRow { private final Long orderId; private final String orderNumber; private final String orderDate; private final Long totalAmount; private final Long itemId; private final Long productId; private final String productName; private final Integer quantity; private final Long amount; @QueryProjection public OrderSummaryFlatRow( Long orderId, String orderNumber, String orderDate, Long totalAmount, Long itemId, Long productId, String productName, Integer quantity, Long amount ) { this.orderId = orderId; this.orderNumber = orderNumber; this.orderDate = orderDate; this.totalAmount = totalAmount; this.itemId = itemId; this.productId = productId; this.productName = productName; this.quantity = quantity; this.amount = amount; } }해당 어노테이션을 사용하면 QueryDSL은 빌드 시 이 DTO를 기반으로
QOrderSummaryFlatRow를 생성하게된다.쿼리는 다음과 같이 작성한다.
return queryFactory .select(new QOrderSummaryFlatRow( order.id, order.orderNumber, order.orderDate.stringValue(), order.totalAmount, orderItem.id, orderItem.productId, orderItem.productName, orderItem.quantity, orderItem.amount )) .from(order) .leftJoin(orderItem) .on(orderItem.orderId.eq(order.id)) .where(order.customerId.eq(customerId)) .fetch();해당 방식의 장점은 아래와 같다.
- 컴파일 타임 타입 안정성
- 생성자의 파라미터 개수, 순서, 타입이 DTO 정의와 다르면 바로 컴파일 에러가 발생한다. Projection 매핑 실수를 빌드 시점에 강하게 제어할 수 있다.
- 1, 2번과 이어지는 내용인데 컴파일시점에 검증이 되므로, IDE에서 Q타입 생성자 시그니처를 바로 확인할 수 있고,
리팩터링 시 파라미터 변경 사항이 즉시 에러로 드러난다. - 내부적으로 리플렉션 기반 주입이 아니라 정적 생성자 호출로 동작해 비교적 명확하고 안전하다.
QueryDSL이 내세우는 “타입 안전한 DSL”이라는 철학에 가장 부합하는 방식이라고 생각했다.
물론 이 방식에도 단점이 있다.
단점은 record를 사용했을때의 장점의 반대라고 보면 된다.
- DTO가 QueryDSL에 종속된다
Application/Domain 및 DTO 계층이 Infrastructure 기술에 의존하게 된다.import com.querydsl.core.annotations.QueryProjection; - 계층 경계가 흐려질 수 있다 : Projection DTO를 상위 계층으로 올리기 시작하면, 결국 Application/Domain 쪽이 QueryDSL에 끌려 들어가는 형태가 된다. 의존성 역전 원칙과 계층 분리 관점에서 경계가 약해질 수 있다.
Projection을 평가하는 기준

제 취향은 jpql입니다만? Projection 방식을 단순히 “취향의 차이”로 보기보다는 개발 철학을 기준으로 선택해야한다고 생각했다.
따라서 다음과 같은 기준으로 분석해 보았다.- 타입 안정성(Type Safety)
- SQL → Projection으로의 매핑이 컴파일 타임에 얼마나 검증되는가?
- 계층 경계(Clear Boundaries)
- Projection 타입이 어느 계층의 책임인지 명확한가?
- Infrastructure 기술이 상위 계층으로 역침투하고 있지는 않은가?
- 의존성 방향성(Dependency Direction)
- DTO가 QueryDSL에 의존하는가, 아니면 QueryDSL이 DTO를 사용하는가?
이 기준으로 두 방식을 다시 정리해보니 다음과 같았다.
@QueryProjection- 타입 안정성과 리팩토링 내성 측면에서는 매우 강하다.
- 반대로, 계층 경계와 의존성 방향성 측면에서는 조심해야 한다.
record + constructor- 계층 분리, 의존성 방향성, DTO 순수성 측면에서는 이상적이다.
- 다만 타입 안정성은 상대적으로 약하다.
하이브리드 전략: 역할과 계층에 따른 분리
이 고민 끝에 도출할 수 있는 한 가지 현실적인 결론은 다음과 같다.
“프로젝션 객체의 순수성을 포기해서, QueryDsl의 개발 안정성을 지키고,
대신에 프로젝션 객체의 사용범위를 제한해서 코어계층의 순수성을 지키자!”
Persistence 전용 Projection에는 @QueryProjection 허용
예를 들어
adapter.out.persistence.projection같은 패키지에 위치한 Projection 타입은
디비 스키마와 쿼리 구조에 밀접하게 결합된 Persistence 전용 모델로 본다.즉 외부 인프라적인 스펙에 의존하는 것을 허용한다는 뜻이다.
대신, Order + OrderItem 조인 결과를 FlatRow로 담는
OrderSummaryFlatRow같은 타입을 상위 계층으로 직접 노출하지 않고, 오직 Repository/Adapter 레이어 내부에서만 사용하는 것을 원칙으로 했다.이로써 @QueryProjection 을 사용할때 얻을 수 있는 장점인
Projection 매핑을 컴파일 시점에 강하게 검증하는 것을 유지할 수 있었다.
Application계층 DTO는 record + 순수 DTO 유지
반대로, 다음과 같은 도메인으로 들어오는 Application 계층의 DTO는
항상 record 기반 순수 DTO를 유지하도록 했다.- Application 계층의 QueryResult
- Controller/API 계층의 Response DTO
public record OrderSummaryQueryResult( List<OrderInfo> orders ) { public record OrderInfo( Long orderId, String orderNumber, String orderDate, Long totalAmount, List<OrderItemInfo> items ) {} public record OrderItemInfo( Long productId, String productName, Integer quantity, Long amount ) {} public static OrderSummaryQueryResult fromFlatRowset( List<OrderSummaryFlatRow> flatRows ) { // flatRows를 그룹핑/정렬/중복 제거하여 // 도메인 관점에서 읽기 좋은 구조로 변환 } }그리고 이를 다시 Response DTO로 변환할 수 있다.
public record OrderSummaryResponse( List<OrderInfo> orders ) { public record OrderInfo( Long orderId, String orderNumber, String orderDate, Long totalAmount, List<OrderItemInfo> items ) {} public record OrderItemInfo( Long productId, String productName, Integer quantity, Long amount ) {} public static OrderSummaryResponse fromQueryResult(OrderSummaryQueryResult queryResult) { // QueryResult -> Response 변환 } }이 계층의 DTO들은 QueryDSL을 모르고, 단지 “도메인 요구사항을 표현하는 읽기 모델”과 “외부 계약을 표현하는 응답 모델”일 뿐이다.
계층별 책임 요약
- Persistence Projection (
@QueryProjection허용 가능)- DB와 쿼리 구조에 맞춰진 FlatRow 모델
- QueryDSL에 강하게 결합
- 상위 계층으로 직접 노출하지 않음
- Application QueryResult (record + constructor 기반 매핑)
- 도메인 관점의 읽기 모델
- 정렬, 그룹핑, 중복 제거, null 보정 등을 담당
- 순수 DTO, QueryDSL 비침투
- API Response DTO (record)
- 외부 소비자의 입장에서 이해하기 좋은 응답 모델
- 안정적인 API 계약을 표현
더 나아가서 ...

헥사고날 참 쉽죠? QueryDsl 참 쉽죠? 위 글에서는 이해를 돕기 위해 FlatRow 모델, QueryResult, Response DTO 정도만 놓고 이야기했지만, 실제로 현업에서 헥사고날 아키텍처를 적용하다 보면 상황이 훨씬 더 복잡해진다.
예를 들어, 서비스 계층에서 자체 도메인뿐만 아니라 다른 bounded context의 데이터를 인터널 포트를 통해 가져와야 하는 경우가 있다. 이때 외부 시스템이나 다른 도메인의 응답은 애초에 우리 코어 기준에서 보면 “외부 스펙에 강하게 의존된 형태”다.
마찬가지로, 영속성 계층에서 QueryDSL의 @QueryProjection을 사용해서 만든 FlatRow 역시 DB 스키마와 쿼리 구조에 밀착된, 인프라 성격이 강한 객체다.
그래서 이런 객체들을 서비스 계층으로 그대로 올리는 대신, 다음과 같은 흐름을 기준으로 잡았다. ( 완전 매핑 전략을 기준으로 삼았다. )
우선 영속성 계층에서는 @QueryProjection이 붙은 FlatRow로 쿼리 결과를 받되, 이 FlatRow는 adapter 레이어 안에서만 사용한다. 그리고 그 안에서 FlatRow를 한 번 정제해서, QueryDSL이나 DB 스키마에 의존하지 않는 순수한 Data DTO로 변환한 뒤에야 서비스 계층으로 넘긴다.
다른 도메인이나 외부 시스템에서 가져오는 데이터도 마찬가지로, 인터널 포트를 통해 들어오는 시점에 Data DTO 형태로 정리해서 코어 쪽으로 전달한다.
서비스 계층은 이렇게 모아진 여러 Data DTO들을 조합하고, 도메인 규칙에 따라 정렬, 그룹핑, 필터링 등을 수행해 하나의 QueryResult로 만든다.
이 QueryResult는 인프라 기술에 대한 정보는 포함하지 않는다.
마지막으로 어댑터의 컨트롤러에서 이 QueryResult를 외부에 보여줄 Response DTO로 변환해서 클라이언트에 응답한다.
정리하면, 영속성 계층에서 사용하는 @QueryProjection 기반 FlatRow는 인프라 계층의 책임이기 때문에, 그 자체로 애플리케이션 코어까지 올라오면 안 된다고 보는 것이 기본 원칙이다.
외부 스펙에 의존된 객체는 어댑터 내부에서 끊어 주고, 코어 영역에는 가능한 한 도메인 객체 자체나 순수한 Data DTO 또는 코어 도메인의 정보를 조합한 QueryResult만 흐르도록 유지하는 것이다.
이번 작업은 단순히 “record와 @QueryProjection 중에 무엇이 더 낫냐”를 비교하는 수준을 넘어, QueryDSL이 추구하는 타입 세이프티와 클린 아키텍처가 지향하는 계층 분리를 실제 코드 위에서 어떻게 조화시킬지 고민해 볼 수 있었다.
마무리
MSA 환경에서 인프라나 애플리케이션이 의존하는 외부 기술이 변경되더라도, 애플리케이션 계층의 코드는 가급적 수정되지 않도록 만드는 것이 헥사고날 아키텍처가 추구하는 핵심 가치라고 생각한다.
그리고 QueryDSL은 기존 JPA에서 복잡한 쿼리를 문자열로 작성하면서, 검증이 컴파일 시점이 아닌 런타임 시점에 이루어질 수밖에 없던 문제를 해결하고자 등장한 기술이다.
이번 작업을 통해, 헥사고날과 QueryDSL이 각각 어떤 문제를 해결하기 위해 탄생한 기술인지에 다시 한 번 포커스를 맞추게 되었고, 두 기술의 철학이 서로 충돌하지 않도록 각자의 장점을 최대한 지키는 방향으로 설계를 가져간 경험은 개인적으로도 굉장히 의미 있는 시간이었다.
'백엔드 : 서버공부' 카테고리의 다른 글
스프링 DI 기반 전략 패턴 : 레지스트리 패턴(Registry Pattern) (0) 2025.12.21 대용량 Excel 다운로드 설계: OOM피하기 (0) 2025.12.14 "나야 503" : 추석 코레일 대란 (0) 2025.09.30 Caffeine Cache 로 동시성 제어하는거 알려 드릴까요? (0) 2025.06.26 그 날 AWS는 떠올렸다 : 람다 (0) 2024.11.24