
개요
JPA를 사용하다 보면 게시글과 댓글, 주문과 주문 상품 같은 1:N 관계를 조회해야 할 일이 정말 많습니다. 하지만 DB에서 조인(Join)을 하면 결과는 계층적 구조가 아닌 언제나 평면적입니다.
오늘은 이 간극을 메우기 위해 우리가 흔히 하던 'Map 노가다'를 버리고, QueryDSL의 transform을 사용해 데이터를 가져오는 방법을 알아보겠습니다.
즉, 연관관계가 있는 데이터를 조회할 때 평면적인(flat) 결과를 계층적인(hierarchical) 구조로 변환해야 하는 경우가 자주 발생하는데, Map을 통한 수동 변환 작업은 번거롭고 실수하기 쉽습니다. QueryDSL의 transform은 바로 이 문제를 해결해줍니다.
이번 글에서는 회사에서 실제로 겪었던 사례를 바탕으로 QueryDSL의 transform 기능을 정리해보려 합니다. 보안상 실제 코드는 가져올 수 없어, 이해를 돕기 위한 간단한 예제 코드와 함께 설명하겠습니다.
Qeury DSL - transform&groupBy란❓
transform은 QueryDSL에서 제공하는 결과 변환 및 그룹핑 기능입니다. 하지만 Transform 혼자서는 아무것도 할 수 없습니다.
Transform의 핵심은 바로 GroupBy입니다.
GroupBy와 관계
.transform(
groupBy(post.id).list(...) // GroupBy가 핵심!
)
transform()은 쿼리 결과를 변환하는 프레임워크이고, groupBy()는 ResultTransformer의 구현체로서 평면 데이터를 키 기준으로 묶는 역할을 합니다.
- Transform: 전체 변환 프레임워크
- GroupBy: 평면 데이터를 키 기준으로 묶는 핵심 메커니즘
- 둘은 함께 사용되며, groupBy()가 transform()의 인자로 들어가는 구조입니다.
주로 다음과 같은 상황에서 사용됩니다.
- 1:N 관계의 데이터를 계층 구조로 변환
- 평면 쿼리 결과를 복잡한 DTO 구조로 매핑
- 그룹핑과 집계를 동시에 처리
QueryDSL 5.x부터 transform은 deprecated 되었습니다.
공식 문서에서는 직접 구현을 권장하지만, 여전히 많은 프로젝트에서 사용되고 있으며 코드 간결성 측면에서 장점이 있습니다.
transform & groupBy 동작 원리
예를 들어, Post와 Comment의 1:N 관계에서 Post 목록을 조회하면서 각 Post에 달린 Comment들을 함께 가져오고 싶을 때, 일반적인 JOIN 쿼리는 다음과 같은 평면 결과를 반환합니다.
Post1 - Comment1
Post1 - Comment2
Post2 - Comment3
하지만 우리가 원하는 것은 다음과 같은 계층 구조입니다.
Post1
- Comment1
- Comment2
Post2
- Comment3
이러한 변환 작업을 수동으로 처리하면 코드가 복잡해지고 실수하기 쉽습니다. QueryDSL의 transform 기능은 바로 이 문제를 해결하기 위해 만들어졌습니다.
📢Deprecated 배경과 대안
QueryDSL 5.x부터 transform은 deprecated 되었습니다.
이유
- 복잡한 변환 로직을 라이브러리에 의존하는 것보다 명시적인 자바 코드로 구현하는 것이 유지보수에 유리하다는 판단
- 성능 이슈나 예상치 못한 동작에 대한 제어가 어려움
하지만 여전히 많은 레거시 프로젝트에서 사용 중이며, 코드 간결성 측면에서는 여전히 장점이 있어 실무에서 유용합니다.
GroupBy와 동작 원리

groupBy 주요 메서드
1. groupBy(key): 특정 필드를 기준으로 그룹핑
groupBy(post.id) // post.id로 그룹핑
데이터를 하나로 묶을 부모의 고유 키를 지정합니다.
- 보통 PK(Primary Key)인 id를 기준으로 삼습니다.
- 이 기준을 중심으로 여러 개의 Row가 하나의 객체(Post)로 합쳐집니다.
2. list(...) : 각 그룹을 List로 수집
groupBy(post.id).list(
new QPostResponse(...) // 각 그룹을 PostResponse로 변환
)
그룹핑된 데이터를 어떤 형태의 결과 리스트로 만들지 정의합니다.
- groupBy(post.id).list(...)라고 작성하면, 중복이 제거된 순수한 게시글 리스트가 반환됩니다.
- 이때 QDTO(QueryProjection)를 사용하면 생성자 파라미터에 맞춰 안전하게 매핑됩니다.
3. list(expression) : 그룹 내 자식 Entity들을 List로 수집
list(new QCommentDto(...)) // Comment들을 List<CommentDto>로
부모 객체 안에서 자식 리스트를 수집할 때 사용합니다.
- 조인으로 인해 불어난 Row들 중에서 자식 데이터(Comment)들만 골라내어 부모 DTO의 List<CommentDto> 필드에 차곡차곡 채워줍니다.
- list(new QCommentDto(...))처럼 중첩해서 사용하면 계층 구조가 완성됩니다.
4. skipNulls() : null 값 자동 제외
list(new QCommentDto(...).skipNulls())
Left Join으로 자식 엔티티를 조회할 때, 자식 데이터가 없으면 comment.id, comment.content가 모두 null이 되어 결과 리스트에 [CommentDto(null, null)] 과 같은 형태로 담기게 됩니다.
skipNulls()를 사용하면 이런 null 객체를 자동으로 필터링하여 빈 리스트([])를 반환합니다.
transform 사용 이유
1. 코드 간결성
수동 그룹핑 코드를 작성하면 다음과 같은 작업이 필요합니다.
- 평면 DTO 정의
- Map을 사용한 그룹핑 로직 구현
- null 체크 및 중복 제거
- 최종 결과 변환
그러나, Transform + GroupBy를 사용하면 이 모든 과정을 선언적으로 표현할 수 있습니다.
2. 가독성 향상
비즈니스 로직과 데이터 변환 로직이 분리되어 코드의 의도가 명확해집니다.
// Transform + GroupBy: 의도가 명확
groupBy(post.id).list(
new QPostResponse(
post.id,
post.title,
list(new QCommentDto(...))
)
)
// 수동 그룹핑: 구현 세부사항에 집중
Map<Long, PostResponse> map = new LinkedHashMap<>();
for (PostFlatDto row : flatResults) {
PostResponse postRes = map.computeIfAbsent(...);
if (row.getCommentId() != null) {
postRes.getComments().add(...);
}
}
주의사항 : 성능 고려 사항
1. 메모리 내 그룹핑
- Transform은 DB에서 받은 모든 평면 데이터를 메모리에 올린 후 그룹핑합니다.
- 대량 데이터(수만 건 이상) 조회 시 OutOfMemoryError 위험이 있습니다
2. 페이징 불가
- groupBy().list()는 전체 데이터를 가져온 후 그룹핑하므로 DB 레벨 페이징이 불가능합니다.
- 페이징이 필요한 경우 Batch Size 조정이나 별도 쿼리 분리를 고려해야 합니다.
3. N+1 문제는 여전히 존재
- Transform은 단순히 변환 로직일 뿐, Fetch Join 전략과는 별개입니다.
- 따라서, 여전히 적절한 Join 전략이 필요합니다.
groupBy & transform 사용 예시
1. 엔티티 설계
- Post(게시물)
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@OneToMany(mappedBy = "post")
private List<Comment> comments = new ArrayList<>();
public Post(String title) {
this.title = title;
}
}
- Comment(댓글)
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
public Comment(String content, Post post) {
this.content = content;
this.post = post;
}
}
2. DTO 설계
@Data
public class PostResponse {
private Long postId;
private String title;
private List<CommentDto> comments;
@QueryProjection
public PostResponse(Long postId, String title, List<CommentDto> comments) {
this.postId = postId;
this.title = title;
this.comments = comments;
}
}
@Data
public class CommentDto {
private Long commentId;
private String content;
@QueryProjection
public CommentDto(Long commentId, String content) {
this.commentId = commentId;
this.content = content;
}
}
// 수동 그룹핑용 평면 DTO
@Data
@AllArgsConstructor
public class PostFlatDto {
private Long postId;
private String title;
private Long commentId;
private String content;
}
3. Transform 사용 vs 미사용 비교
Before : transform 미사용(수동 그룹핑)
@Override
public List<PostResponse> searchBefore() {
// 1. 평면 DTO로 조회
List<PostFlatDto> flatResults = queryFactory
.select(Projections.constructor(PostFlatDto.class,
post.id, post.title, comment.id, comment.content))
.from(post)
.leftJoin(post.comments, comment)
.fetch();
// 2. 메모리에서 수동 그룹핑
Map<Long, PostResponse> map = new LinkedHashMap<>();
for (PostFlatDto row : flatResults) {
PostResponse postRes = map.computeIfAbsent(row.getPostId(),
id -> new PostResponse(id, row.getTitle(), new ArrayList<>()));
if (row.getCommentId() != null) {
postRes.getComments().add(
new CommentDto(row.getCommentId(), row.getContent())
);
}
}
return new ArrayList<>(map.values());
}
이 방식은 데이터베이스에서 가져온 평면적인 데이터를 자바 코드로 직접 조립하는 방식입니다.
- Projections.constructor
- DB의 Join 결과는 게시글 1개에 댓글이 3개라면 총 3개의 행(Row)이 반환됩니다.
- 이를 담기 위해 모든 필드가 펼쳐진 PostFlatDto를 임시로 사용합니다.
- LinkedHashMap & computeIfAbsent
- 중복된 게시글 데이터를 통합하기 위해 Map을 사용합니다.
- 게시글 ID가 Map에 없으면 새로 만들고, 있으면 기존 객체를 가져와 댓글만 리스트에 추가합니다.
- 수동 Null 체크
- leftJoin 시 댓글이 없는 경우 commentId가 null로 들어오기 때문에, 이를 방어하는 if문 로직이 반드시 포함되어야 합니다.
after : transform 사용(선언적 그룹핑)
@Override
public List<PostResponse> searchAfter() {
return queryFactory
.from(post)
.leftJoin(post.comments, comment)
.transform(
groupBy(post.id).list(
new QPostResponse(
post.id,
post.title,
list(new QCommentDto(
comment.id,
comment.content
).skipNulls())
)
)
);
}
QueryDSL의 transform을 사용하면 자바의 복잡한 컬렉션 가공 로직이 간단한 선언문으로 해결이 됩니다.
- groupBy(post.id)
- "게시글 ID를 기준으로 행들을 묶겠다"는 기준을 세웁니다.
- list(new QPostResponse(...))
- 그룹핑된 덩어리를 PostResponse 객체로 변환합니다.
- list(new QCommentDto(...))
- 게시글 내부의 댓글 리스트를 수집합니다.
- 핵심 포인트
- QueryDSL이 내부적으로 데이터를 자동으로 순회하며 CommentDto를 생성해 리스트에 넣어줍니다.
- 개발자가 직접 for문을 돌리며
리스트에 데이터를 추가(add)할 필요가 전혀 없습니다.
- skipNulls()
- 댓글이 없는 경우 리스트에 null 요소가 들어가는 것을 방지하여, 깨끗한 빈 리스트([])를 보장합니다.
transform 사용 코드와 미사용 코드의 응답결과를 보겠습니다.

위와 같이 보면알 수 있듯이 미사용(before), 사용(after)의 응답 결과가 동일한 것을 알 수 있습니다. 이처럼 과정이 전혀 다름에도 같은 결과가 나오게 됩니다.
결론
위 두 가지 코드(transform 사용/미사용)를 비교해보면 그 차이가 극명하게 드러납니다.
먼저 transform을 사용하지 않을 때는 계층 구조가 1단계(게시글-댓글)인 아주 단순한 상황임에도 불구하고, 데이터를 재조합하기 위해 Map과 복잡한 반복문을 직접 구현해야 합니다.
이는 데이터 구조가 깊어지고 복잡해질수록 가독성을 해치고 유지보수를 어렵게 만드는 결정적인 원인이 됩니다. 또한, 데이터를 쿼리 단계에서 임시로 담아낼 별도의 DTO(FlatDto)를 매번 생성해야 한다는 번거로움도 존재합니다.
반면 transform을 사용하면 아무리 복잡한 계층 구조라도 Map 가공 로직이나 수동 반복문 없이, 단순한 list() 중첩만으로 설계가 가능해집니다. 개발자는 더 이상 데이터를 어떻게 묶을지 고민하지 않고, 어떤 구조로 가져올지만 기술하면 됩니다.
즉, '어떻게(How)'가 아닌 '무엇을(What)'에 집중하게 됨으로써 코드의 간결함과 생산성을 동시에 챙길 수 있습니다.
'Framework > JPA' 카테고리의 다른 글
| [JPA]JPA IDENTITY 전략에서 쓰기 지연이 작동하지 않는 이유 (0) | 2026.01.08 |
|---|---|
| [JPA] JPA 방언(Dialect)이란❓ (1) | 2025.01.05 |
| [QueryDSL] QueryDSL - @QueryProjction 어노테이션이란❓ (0) | 2024.10.06 |
| [JPA] @Modifying 어노테이션이란 무엇일까❓ (1) | 2024.10.01 |
| [JPA] 프록시(proxy)객체란 무엇일까❓ (1) | 2024.09.15 |