
컬렉션 필드가 많고 여러 연관 데이터(Count, 특정 Role 등)를 조합해야 하는 경우,
✨ Querydsl에서 DTO 직접 조회(Projection)와 메모리 맵핑(Map Binding) 전략을 사용하는 것이 성능과 코드 가독성 측면에서 가장 효율적입니다.
batch_size에만 의존하면 10만 건 이상의 데이터 처리 시 불필요한 엔티티 로딩으로 메모리 부하가 커질 수 있습니다. 다음과 같은 고도화 전략을 추천합니다.
엔티티 전체를 들고 오지 말고, 화면에 필요한 계산된 값(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;
}
}
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();
}
labelInfo나 tasks 처럼 리스트로 가져와야 하는 필드들은 쿼리를 딱 두 번으로 분리하여 메모리에서 합치는 것이 가장 빠릅니다. (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;
}
SELECT * 대신 필요한 컬럼만 가져오므로 DB와 애플리케이션 사이의 트래픽이 줄어듭니다.List<Entity> 필드 때문에 발생하는 복잡한 로직을 DTO 한 곳에서 관리할 수 있습니다.batch size 설정만 한다고 끝이 아니다! 아예 최종적으로 정리해줄게!
원칙: WHERE 절에서 연관 엔티티의 ID(FK)만 참조하면 JOIN 불필요
// ✅ 올바른 예시 (JOIN 불필요)
private BooleanExpression eqProjectId(Long projectId) {
// Task 테이블의 project_id FK 컬럼 직접 비교
return task.project.id.eq(projectId);
}
// 생성되는 SQL: WHERE task.project_id = ?
이유:
task.project.id는 Task 테이블의 project_id 컬럼 직접 참조원칙: WHERE/ORDER BY 절에서 연관 엔티티의 일반 컬럼을 사용하면 JOIN 필수
// ⚠️ JOIN 필수 예시
private BooleanExpression containsSearchKeyword(String searchKeyword) {
if (searchKeyword == null || searchKeyword.isEmpty()) {
return null;
}
// ✅ user.email은 User 테이블의 컬럼 → LEFT JOIN 필수!
return task.name.containsIgnoreCase(searchKeyword)
.or(user.email.containsIgnoreCase(searchKeyword));
}
// 반드시 JOIN 추가
List<Task> list = queryFactory
.selectFrom(task)
.leftJoin(task.worker, user) // ← 필수!
.where(containsSearchKeyword(keyword))
.fetch();
생성되는 SQL:
SELECT t.*
FROM task t
LEFT JOIN user u ON t.worker_id = u.id -- ← JOIN 필수
WHERE t.name LIKE ? OR u.email LIKE ? -- ← User 테이블 컬럼 사용
| 조건 | SQL 매핑 | JOIN 필요 |
|---|---|---|
task.project.id.eq(?) | task.project_id = ? | ❌ |
task.project.name.contains(?) | project.name LIKE ? | ✅ |
task.worker.id.eq(?) | task.worker_id = ? | ❌ |
task.worker.email.contains(?) | user.email LIKE ? | ✅ |
job.objectClasses.any().formatType.eq(?) | Collection JOIN | ✅ |
ORDER BY task.worker.name | ORDER BY user.name | ✅ |
spring:
jpa:
properties:
hibernate:
# ✅ 기본 배치 사이즈 설정 (권장: 100~1000)
default_batch_fetch_size: 1000
# ✅ JDBC 배치 설정 (INSERT/UPDATE 성능 향상)
jdbc:
batch_size: 1000
batch_versioned_data: true
# ✅ INSERT/UPDATE 순서 최적화
order_inserts: true
order_updates: true
효과:
IN (?, ?, ...) 절로 한 번에 조회fetchJoin() 없이도 성능 최적화원칙: default_batch_fetch_size 설정 시 fetchJoin() 사용 금지
// ❌ 잘못된 예시 (중복 데이터 발생!)
List<Task> tasks = queryFactory
.selectFrom(task)
.leftJoin(task.jobs, job).fetchJoin() // ← 제거 필요!
.where(task.project.id.eq(projectId))
.fetch();
// 문제점: Task 1개 × Job 10개 = 결과 행 10개 (중복!)
// totalCount가 잘못 계산됨
// ✅ 올바른 예시 (Batch Fetch 활용)
List<Task> tasks = queryFactory
.selectFrom(task)
// ✅ fetchJoin() 제거 → Batch Fetch가 자동 처리
.where(task.project.id.eq(projectId))
.fetch();
// 실행되는 SQL:
// 1. SELECT * FROM task WHERE project_id = ?
// 2. SELECT * FROM job WHERE task_id IN (?, ?, ..., ?) ← IN 절로 배치 조회
| 상황 | 추천 방식 | 이유 |
|---|---|---|
| 1:N 관계 페이징 | Batch Fetch | 중복 데이터 없음, 정확한 페이징 |
| 1:1 관계 단건 조회 | FetchJoin | 쿼리 1회로 완료 |
| N:M 관계 | Batch Fetch | 카테시안 곱 방지 |
| Collection 조회 | Batch Fetch | 중복 제거 불필요 |
원칙: null 체크와 조건 조합을 위해 BooleanExpression 반환
/**
* ✅ 표준 패턴: null-safe 동적 조건
* @return null이면 WHERE 절에서 무시됨
*/
private BooleanExpression eqStatus(TaskStatus status) {
return status == null ? null : task.status.eq(status);
}
private BooleanExpression eqType(TaskType type) {
return type == null ? null : task.type.eq(type);
}
private BooleanExpression containsKeyword(String keyword) {
if (keyword == null || keyword.isEmpty()) {
return null; // ← WHERE 절에서 자동으로 제외됨
}
return task.name.containsIgnoreCase(keyword.trim());
}
// 사용 예시
List<Task> tasks = queryFactory
.selectFrom(task)
.where(
eqStatus(request.getStatus()), // null이면 무시
eqType(request.getType()), // null이면 무시
containsKeyword(request.getKeyword()) // null이면 무시
)
.fetch();
private BooleanExpression containsSearchKeyword(String searchKeyword) {
if (searchKeyword == null || searchKeyword.isEmpty()) {
return null;
}
// ✅ 공백 제거 후 검색
searchKeyword = searchKeyword.trim();
return task.name.containsIgnoreCase(searchKeyword)
.or(user.email.containsIgnoreCase(searchKeyword));
}
private BooleanExpression inTaskIds(List<Long> taskIds) {
// ✅ 빈 리스트 체크 (SQL 에러 방지)
if (taskIds == null || taskIds.isEmpty()) {
return null;
}
return task.id.in(taskIds);
}
문제점:
// ❌ 경고 발생: "HHH000104: firstResult/maxResults specified with collection fetch"
Page<Task> tasks = queryFactory
.selectFrom(task)
.leftJoin(task.jobs, job).fetchJoin() // ← Collection fetchJoin
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// Hibernate가 모든 데이터를 메모리에 로드 후 페이징 → OOM 위험!
원칙: Collection을 포함한 페이징은 fetchJoin() 제거 후 Batch Fetch 활용
@Override
public Page<Task> findTasksByProjectId(Long projectId, TaskSearchRequest request) {
Pageable pageable = request.getPageable();
// ✅ fetchJoin 제거 → Batch Fetch가 자동 처리
List<Task> list = queryFactory
.selectFrom(task)
.leftJoin(task.worker, user) // ← WHERE 절 조건용 JOIN만 추가
.where(
eqProjectId(projectId),
containsSearchKeyword(request.getSearchKeyword()),
eqStatus(request.getStatus()),
eqType(request.getType())
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(createOrderSpecifier(pageable.getSort(), Task.class))
.fetch();
// ✅ COUNT 쿼리
Long total = queryFactory
.select(task.count()) // ← ManyToOne JOIN은 중복 없음
.from(task)
.leftJoin(task.worker, user)
.where(
eqProjectId(projectId),
containsSearchKeyword(request.getSearchKeyword()),
eqStatus(request.getStatus()),
eqType(request.getType())
)
.fetchOne();
return PageableExecutionUtils.getPage(list, pageable, () -> total != null ? total : 0L);
}
실행되는 SQL:
-- 1. Task 페이징 조회
SELECT t.*
FROM task t
LEFT JOIN user u ON t.worker_id = u.id
WHERE t.project_id = ? AND (t.name LIKE ? OR u.email LIKE ?)
LIMIT 10 OFFSET 0;
-- 2. Batch Fetch가 자동 실행 (연관 데이터 로드)
SELECT * FROM project WHERE id IN (?, ?, ...);
SELECT * FROM job WHERE task_id IN (?, ?, ...);
COUNT 쿼리에서 DISTINCT를 사용할지 여부는 JOIN 관계 타입에 따라 결정됩니다.
원칙: Many → One 방향 또는 일대일 관계는 중복이 발생하지 않음
// ✅ DISTINCT 불필요 (ManyToOne 관계)
@Entity
public class Task {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "worker_id")
private User worker; // ← ManyToOne 관계
}
// COUNT 쿼리
Long total = queryFactory
.select(task.count()) // ← count() 그대로 사용
.from(task)
.leftJoin(task.worker, user) // ← ManyToOne JOIN
.where(
eqProjectId(projectId),
user.email.containsIgnoreCase(keyword)
)
.fetchOne();
실행되는 SQL 및 결과:
SELECT COUNT(t.id)
FROM task t
LEFT JOIN user u ON t.worker_id = u.id
WHERE t.project_id = 1 AND u.email LIKE '%test%';
-- JOIN 결과 (중복 없음)
Task ID | Worker ID | Worker Email
--------|-----------|-------------
1 | 10 | worker1@test.com
2 | 20 | worker2@test.com
3 | 10 | worker1@test.com
-- COUNT(*) = 3 (정확함!)
이유:
원칙: One → Many 방향 또는 다대다 관계는 중복 발생, DISTINCT 필수
// ⚠️ DISTINCT 필수 (OneToMany 관계)
@Entity
public class Task {
@OneToMany(mappedBy = "task", fetch = FetchType.LAZY)
private List<Job> jobs; // ← OneToMany 관계
}
// COUNT 쿼리
Long total = queryFactory
.select(task.countDistinct()) // ← countDistinct() 필수!
.from(task)
.leftJoin(task.jobs, job) // ← OneToMany JOIN
.where(
eqProjectId(projectId),
job.status.eq(JobStatus.COMPLETED)
)
.fetchOne();
실행되는 SQL 및 결과:
SELECT COUNT(DISTINCT t.id)
FROM task t
LEFT JOIN job j ON j.task_id = t.id
WHERE t.project_id = 1 AND j.status = 'COMPLETED';
-- JOIN 결과 (중복 발생!)
Task ID | Job ID | Job Status
--------|--------|------------
1 | 10 | COMPLETED ← Task 1
1 | 11 | COMPLETED ← Task 1 (중복!)
1 | 12 | COMPLETED ← Task 1 (중복!)
2 | 20 | COMPLETED ← Task 2
2 | 21 | COMPLETED ← Task 2 (중복!)
-- COUNT(*) = 5 (잘못됨! Task는 2개인데 5개로 계산)
-- COUNT(DISTINCT task.id) = 2 (정확함!)
이유:
| JOIN 관계 | 중복 발생 | COUNT 방식 | 예시 |
|---|---|---|---|
| ManyToOne | ❌ 없음 | count() | task.worker (Task → User) |
| OneToOne | ❌ 없음 | count() | user.profile (User → Profile) |
| OneToMany | ✅ 있음 | countDistinct() | task.jobs (Task → Jobs) |
| ManyToMany | ✅ 있음 | countDistinct() | user.roles (User → Roles) |
원칙: COUNT는 개수만 필요하므로 불필요한 JOIN 제거
// ❌ 비효율적인 예시
Long total = queryFactory
.select(task.count())
.from(task)
.leftJoin(task.project, project) // ← 불필요!
.leftJoin(task.jobs, job) // ← 불필요!
.where(task.project.id.eq(projectId))
.fetchOne();
// ✅ 최적화된 예시
Long total = queryFactory
.select(task.count())
.from(task)
// ✅ FK만 사용하므로 JOIN 제거
.where(task.project.id.eq(projectId))
.fetchOne();
// ✅ 최적의 성능
Long total = queryFactory
.select(task.count())
.from(task)
.where(task.project.id.eq(projectId)) // FK 직접 비교
.fetchOne();
// ✅ DISTINCT 불필요
Long total = queryFactory
.select(task.count()) // ← 중복 없음
.from(task)
.leftJoin(task.worker, user) // ← WHERE 조건용
.where(
task.project.id.eq(projectId),
user.email.containsIgnoreCase(keyword)
)
.fetchOne();
// ⚠️ DISTINCT 필수
Long total = queryFactory
.select(task.countDistinct()) // ← 중복 제거 필수!
.from(task)
.leftJoin(task.jobs, job) // ← Collection JOIN
.where(
task.project.id.eq(projectId),
job.status.eq(JobStatus.COMPLETED)
)
.fetchOne();
// ⚠️ 여러 Collection JOIN 시 DISTINCT 필수
Long total = queryFactory
.select(task.countDistinct()) // ← 카테시안 곱 중복 제거!
.from(task)
.leftJoin(task.jobs, job) // ← OneToMany
.leftJoin(task.attachments, file) // ← OneToMany (카테시안 곱!)
.where(
task.project.id.eq(projectId),
job.status.eq(JobStatus.COMPLETED)
)
.fetchOne();
// JOIN 결과: Task(1) × Jobs(3) × Attachments(2) = 6 rows (Task는 1개!)
DISTINCT는 성능 비용이 있으므로 필요한 경우에만 사용!
// ✅ 성능 우선순위
// 1순위: JOIN 제거 (가장 빠름)
.select(task.count())
.from(task)
.where(task.project.id.eq(projectId))
// 2순위: ManyToOne JOIN (중복 없음)
.select(task.count())
.from(task)
.leftJoin(task.worker, user)
.where(user.email.contains(keyword))
// 3순위: OneToMany JOIN (DISTINCT 필수)
.select(task.countDistinct()) // ← 성능 비용 발생
.from(task)
.leftJoin(task.jobs, job)
.where(job.status.eq(status))
public interface TaskRepository extends JpaRepository<Task, Long> {
// ✅ @EntityGraph로 Eager Loading
@EntityGraph(attributePaths = {"project", "worker", "reviewer"})
@Query("SELECT t FROM Task t WHERE t.project.id = :projectId")
List<Task> findTasksWithAssociations(@Param("projectId") Long projectId);
}
# application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000 # ✅ 전역 설정
// Entity에 개별 설정 (전역 설정이 없는 경우)
@Entity
public class Task {
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 100) // ← 이 연관관계만 별도 설정
private Project project;
@OneToMany(mappedBy = "task", fetch = FetchType.LAZY)
@BatchSize(size = 100)
private List<Job> jobs = new ArrayList<>();
}
// ✅ 서비스 레이어에서 명시적 초기화
@Transactional(readOnly = true)
public List<TaskResponse> getTasks(Long projectId) {
List<Task> tasks = taskRepository.findByProjectId(projectId);
// ✅ Batch Fetch로 한 번에 로드
tasks.forEach(task -> {
task.getJobs().size(); // ← Collection 초기화
task.getWorker().getEmail(); // ← Entity 초기화
});
return tasks.stream()
.map(TaskResponse::fromEntity)
.toList();
}
-- DISTINCT가 있는 경우
SELECT COUNT(DISTINCT t.id) FROM task t LEFT JOIN job j ON ...
-- 1. JOIN 수행
-- 2. DISTINCT 처리 (추가 정렬/해싱 비용)
-- 3. COUNT 수행
-- DISTINCT가 없는 경우
SELECT COUNT(t.id) FROM task t LEFT JOIN user u ON ...
-- 1. JOIN 수행
-- 2. COUNT 수행 (정렬 없음)