⭐ JPA 에서 Querydsl N+1, fetch join, Batch size 그다음은...

devdo·2025년 12월 21일

JPA

목록 보기
15/17
post-thumbnail

컬렉션 필드가 많고 여러 연관 데이터(Count, 특정 Role 등)를 조합해야 하는 경우,

✨ Querydsl에서 DTO 직접 조회(Projection)메모리 맵핑(Map Binding) 전략을 사용하는 것이 성능과 코드 가독성 측면에서 가장 효율적입니다.

batch_size에만 의존하면 10만 건 이상의 데이터 처리 시 불필요한 엔티티 로딩으로 메모리 부하가 커질 수 있습니다. 다음과 같은 고도화 전략을 추천합니다.


1. @QueryProjection을 활용한 DTO 설계

엔티티 전체를 들고 오지 말고, 화면에 필요한 계산된 값(Count 등)을 포함한 전용 DTO를 만듭니다.

@Getter
@NoArgsConstructor
public class ProjectResponseDto {
    private Long projectId;
    private String projectName;
    private long workerCount;       // 계산된 값
    private List<String> labelNames; // 리스트
    private String myRole;          // 현재 사용자의 역할

    @QueryProjection
    public ProjectResponseDto(Long projectId, String projectName, long workerCount, String myRole) {
        this.projectId = projectId;
        this.projectName = projectName;
        this.workerCount = workerCount;
        this.myRole = myRole;
    }
    
    public void setLabelNames(List<String> labelNames) {
        this.labelNames = labelNames;
    }
}

2. 단일 쿼리로 처리하기 (SubQuery & Join)

workerCount 같은 수치는 JPAExpressions를 사용한 서브쿼리나 GroupBy로 처리합니다.

public List<ProjectResponseDto> findProjectDetailList(Long currentUserId) {
    return queryFactory
        .select(new QProjectResponseDto(
            project.id,
            project.name,
            ExpressionUtils.as(
                JPAExpressions.select(userProject.count())
                    .from(userProject)
                    .where(userProject.project.eq(project)
                        .and(userProject.role.eq(UserRole.WORKER))), "workerCount"),
            userProject.role.stringValue() // 현재 유저의 역할
        ))
        .from(project)
        .leftJoin(userProject).on(userProject.project.eq(project).and(userProject.user.id.eq(currentUserId)))
        .fetch();
}

3. 컬렉션 필드 최적화 (Map을 이용한 메모리 매핑)

labelInfotasks 처럼 리스트로 가져와야 하는 필드들은 쿼리를 딱 두 번으로 분리하여 메모리에서 합치는 것이 가장 빠릅니다. (N+1 완전 소멸)

public List<ProjectResponseDto> getProjectsWithDetails(Long userId) {
    // 1. 기본 정보 먼저 조회 (1번 쿼리)
    List<ProjectResponseDto> content = findProjectDetailList(userId);
    List<Long> projectIds = content.stream().map(ProjectResponseDto::getProjectId).toList();

    // 2. 관련 라벨 정보 한꺼번에 조회 (2번 쿼리)
    Map<Long, List<String>> labelMap = queryFactory
        .from(labelInfo)
        .where(labelInfo.project.id.in(projectIds))
        .transform(GroupBy.groupBy(labelInfo.project.id).as(GroupBy.list(labelInfo.name)));

    // 3. 메모리에서 셋팅 (매우 빠름)
    content.forEach(dto -> dto.setLabelNames(labelMap.getOrDefault(dto.getProjectId(), Collections.emptyList())));

    return content;
}

4. 왜 이 방식이 좋은가요?

  1. 메모리 효율성: 10만 개 이미지 데이터 환경에서 엔티티를 영속성 컨텍스트에 올리지 않으므로 GC 부하가 거의 없습니다.
  2. 네트워크 최적화: SELECT * 대신 필요한 컬럼만 가져오므로 DB와 애플리케이션 사이의 트래픽이 줄어듭니다.
  3. 가독성: 수많은 List<Entity> 필드 때문에 발생하는 복잡한 로직을 DTO 한 곳에서 관리할 수 있습니다.

💡 실무 팁

  • WorkerCount: 매번 카운트 쿼리를 날리는 것이 무겁다면, Project 테이블에 worker_count 컬럼을 따로 두고 유저가 추가될 때마다 업데이트(Denormalization)하는 방식도 고려해 보세요.
  • Complex Logic: 복잡한 역할(userProjectRole) 판별 로직은 Querydsl의 CaseBuilder를 사용하면 SQL 레벨에서 깔끔하게 처리됩니다.

현재 프로젝트에서 가장 컬렉션이 많이 꼬여있는 엔티티가 무엇인가요? 구체적인 관계를 알려주시면 실제 맵핑 코드를 더 상세히 짜드릴 수 있습니다.

profile
배운 것을 기록합니다.

0개의 댓글