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

devdo·2025년 12월 21일

JPA

목록 보기
16/17
post-thumbnail

JPA 에서 Querydsl N+1, fetch join 문제 또 발생...

컬렉션 필드가 많고 여러 연관 데이터(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 한 곳에서 관리할 수 있습니다.

💡 실무 팁(최종 정리)

batch size 설정만 한다고 끝이 아니다! 아예 최종적으로 정리해줄게!

1. JOIN 사용 판단 기준

✅ Rule 1.1: FK 컬럼만 사용 시 JOIN 제거

원칙: 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 컬럼 직접 참조
  • 별도 테이블 조인 없이 FK 인덱스만 활용

⚠️ Rule 1.2: 연관 엔티티의 속성 사용 시 JOIN 필수

원칙: 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 테이블 컬럼 사용

🔍 Rule 1.3: JOIN 필요 여부 판단표

조건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.nameORDER BY user.name

2. Batch Fetch Size 설정

✅ Rule 2.1: 전역 Batch Fetch Size 설정 (application.yml)

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

효과:

  • Lazy Loading 시 IN (?, ?, ...) 절로 한 번에 조회
  • N+1 문제 자동 해결
  • fetchJoin() 없이도 성능 최적화

⚠️ Rule 2.2: Batch Fetch Size 사용 시 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 절로 배치 조회

🎯 Rule 2.3: Batch Fetch vs FetchJoin 선택 기준

상황추천 방식이유
1:N 관계 페이징Batch Fetch중복 데이터 없음, 정확한 페이징
1:1 관계 단건 조회FetchJoin쿼리 1회로 완료
N:M 관계Batch Fetch카테시안 곱 방지
Collection 조회Batch Fetch중복 제거 불필요

3. WHERE 조건절 작성 규칙

✅ Rule 3.1: Dynamic Query는 BooleanExpression 사용

원칙: 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();

⚠️ Rule 3.2: String 검색 시 trim() 처리 필수

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));
}

🔍 Rule 3.3: IN 조건 사용 시 빈 리스트 체크

private BooleanExpression inTaskIds(List<Long> taskIds) {
    // ✅ 빈 리스트 체크 (SQL 에러 방지)
    if (taskIds == null || taskIds.isEmpty()) {
        return null;
    }
    return task.id.in(taskIds);
}

4. 페이징 쿼리 최적화

✅ Rule 4.1: fetchJoin + 페이징 사용 금지

문제점:

// ❌ 경고 발생: "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 위험!

✅ Rule 4.2: 페이징은 Batch Fetch 활용

원칙: 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 (?, ?, ...);

5. COUNT 쿼리 최적화 및 DISTINCT 사용

📊 DISTINCT 사용 판단 기준

COUNT 쿼리에서 DISTINCT를 사용할지 여부는 JOIN 관계 타입에 따라 결정됩니다.


✅ Rule 5.1: ManyToOne/OneToOne JOIN - DISTINCT 불필요

원칙: 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 (정확함!)

이유:

  • Task 1개 → Worker 1명 매핑 (1:1 관계)
  • JOIN 결과에 Task가 중복되지 않음
  • DISTINCT 없이도 정확한 개수 반환

⚠️ Rule 5.2: OneToMany/ManyToMany JOIN - DISTINCT 필수

원칙: 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 (정확함!)

이유:

  • Task 1개 → Job N개 매핑 (1:N 관계)
  • JOIN 결과에 Task가 Job 개수만큼 중복됨
  • DISTINCT로 중복 제거 필수

🔍 Rule 5.3: COUNT DISTINCT 필요 여부 판단표

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)

📋 Rule 5.4: COUNT 쿼리는 JOIN 최소화

원칙: 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();

🎯 Rule 5.5: 실전 예시 - 상황별 COUNT 쿼리

Case 1: FK만 사용 (JOIN 제거 + count)

// ✅ 최적의 성능
Long total = queryFactory
    .select(task.count())
    .from(task)
    .where(task.project.id.eq(projectId))  // FK 직접 비교
    .fetchOne();

Case 2: ManyToOne 조건 (JOIN 유지 + count)

// ✅ 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();

Case 3: OneToMany 조건 (JOIN 유지 + countDistinct)

// ⚠️ 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();

Case 4: 복합 JOIN (최악의 경우)

// ⚠️ 여러 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개!)

💡 Rule 5.6: 성능 팁

DISTINCT는 성능 비용이 있으므로 필요한 경우에만 사용!

  1. 최우선: JOIN 자체를 제거할 수 있는지 확인 (FK만 사용)
  2. 차선: ManyToOne JOIN만 사용 (DISTINCT 불필요)
  3. 최후: OneToMany JOIN 불가피할 때만 countDistinct() 사용
// ✅ 성능 우선순위

// 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))

6. N+1 문제 방지

✅ Rule 6.1: @EntityGraph 사용 (간단한 경우)

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);
}

✅ Rule 6.2: Batch Fetch Size + Lazy Loading (권장)

# 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<>();
}

🎯 Rule 6.3: 조회 후 연관 데이터 강제 초기화

// ✅ 서비스 레이어에서 명시적 초기화
@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();
}

📋 체크리스트

쿼리 작성 시 반드시 확인할 사항

  • WHERE 절에 연관 엔티티의 일반 컬럼을 사용하면 LEFT JOIN 추가했는가?
  • Collection을 fetchJoin() + 페이징 사용하지 않았는가?
  • COUNT 쿼리에 불필요한 JOIN이 없는가?
  • OneToMany/ManyToMany JOIN 시 countDistinct()를 사용했는가?
  • ManyToOne JOIN만 있으면 count()를 사용했는가?
  • String 검색 조건에 trim() 처리를 했는가?
  • default_batch_fetch_size를 설정하고 fetchJoin()을 제거했는가?
  • Dynamic Query에서 null 체크를 BooleanExpression으로 처리했는가?
  • IN 조건 사용 시 빈 리스트 체크를 했는가?

🚀 성능 최적화 우선순위

  1. N+1 문제 해결 (Batch Fetch Size 설정)
  2. 불필요한 JOIN 제거 (FK만 사용 시)
  3. COUNT 쿼리 최적화 (JOIN 최소화 + DISTINCT 적절히 사용)
  4. 페이징 쿼리 최적화 (Batch Fetch 활용)
  5. 인덱스 활용 (WHERE/ORDER BY 컬럼)

📚 참고 자료


🎓 추가 학습 자료

DISTINCT의 성능 영향

-- 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 수행 (정렬 없음)

대용량 데이터 처리 시 고려사항

  • 1만 건 이하: DISTINCT 성능 영향 미미
  • 10만 건 이상: DISTINCT 비용 증가, JOIN 제거 검토
  • 100만 건 이상: 서브쿼리 또는 별도 집계 테이블 사용 고려

profile
자바 스프링 백엔드 개발자입니다. 배운 것을 기록합니다.

0개의 댓글