📌 고정 게시글

📢 공지합니다

이 게시글은 메인 페이지에 항상 고정되어 표시됩니다.

최코딩의 개발

N+1문제 본문

CS

N+1문제

seung_ho_choi.s 2025. 6. 10. 23:36
728x90

https://balhae.tistory.com/156

 

섹션1 영속성 관리 - 내부 동작 방식

최코딩의 개발 섹션1 영속성 관리 - 내부 동작 방식 본문 JPA/JPA 기본 핵심 원리 섹션1 영속성 관리 - 내부 동작 방식 seung_ho_choi.s 2024. 1. 25. 23:50

balhae.tistory.com

 

🔍 N+1 문제란?

N+1 문제는 1번의 쿼리로 N개의 엔티티를 조회한 후, 각 엔티티의 연관 데이터를 가져오기 위해 추가로 N번의 쿼리가 실행되는 문제입니다.

예를 들어, 회원 100명을 조회한 후 각 회원의 게시글을 확인하려고 할 때:

  • 1번: 회원 100명 조회
  • N번: 각 회원별 게시글 조회 (100번)
  • 총 101번의 쿼리 실행!

📊 엔티티 관계 설정

먼저 1:N 관계의 엔티티를 정의해보겠습니다.

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Todo> todos = new ArrayList<>();
    
    // getter, setter...
}

@Entity
public class Todo {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String title;
    private String content;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;
    
    // getter, setter...
}

 

데이터 구조:

users 테이블:
id | name
1  | 김철수
2  | 이영희
3  | 박민수

todos 테이블:
id | title     | content | user_id
1  | 운동하기   | 헬스장   | 1
2  | 공부하기   | JPA     | 1
3  | 쇼핑하기   | 마트     | 2
4  | 독서하기   | 소설     | 3
5  | 요리하기   | 파스타   | 3

🐌 지연로딩(LAZY)에서 N+1 문제

설정

@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Todo> todos = new ArrayList<>();

문제 상황

// 1. 모든 사용자 조회 (1번 쿼리)
List<User> users = userRepository.findAll();

// 2. 각 사용자의 할일 목록 출력 (N번 쿼리 발생!)
for (User user : users) {
    System.out.println("사용자: " + user.getName());
    System.out.println("할일 개수: " + user.getTodos().size()); // 여기서 쿼리 발생!
}

실행되는 쿼리

-- 1번째 쿼리: 모든 사용자 조회
SELECT * FROM users;

-- 2번째 쿼리: 김철수의 할일 조회 
SELECT * FROM todos WHERE user_id = 1;

-- 3번째 쿼리: 이영희의 할일 조회
SELECT * FROM todos WHERE user_id = 2;

-- 4번째 쿼리: 박민수의 할일 조회
SELECT * FROM todos WHERE user_id = 3;

총 4번의 쿼리 실행 (1 + 3 = N+1)

지연로딩의 동작 원리

  1. userRepository.findAll()로 User 엔티티들만 조회
  2. 각 User의 todos 필드는 프록시 객체로 설정됨
  3. user.getTodos().size() 호출 시점에 실제 데이터베이스 조회 발생
  4. 각 User마다 별도의 쿼리가 실행됨

⚡ 즉시로딩(EAGER)에서도 N+1 문제 발생

설정

@OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
private List<Todo> todos = new ArrayList<>();

문제 상황

// 모든 사용자 조회 시점에 N+1 발생!
List<User> users = userRepository.findAll();

실행되는 쿼리

-- 1번째 쿼리: 모든 사용자 조회
SELECT * FROM users;

-- 2번째 쿼리: 김철수의 할일 조회 (즉시로딩)
SELECT * FROM todos WHERE user_id = 1;

-- 3번째 쿼리: 이영희의 할일 조회 (즉시로딩)
SELECT * FROM todos WHERE user_id = 2;

-- 4번째 쿼리: 박민수의 할일 조회 (즉시로딩)
SELECT * FROM todos WHERE user_id = 3;

왜 즉시로딩도 JOIN을 사용하지 않을까?

단건 조회 시 (JOIN 사용)

User user = userRepository.findById(1L);
-- 한 번의 JOIN 쿼리로 해결
SELECT u.*, t.* 
FROM users u 
LEFT JOIN todos t ON u.id = t.user_id 
WHERE u.id = 1;

여러건 조회 시 (분리 조회)

만약 JOIN으로 모든 사용자를 조회한다면?

-- 모든 사용자 + 할일을 JOIN으로 조회
SELECT u.*, t.* 
FROM users u 
LEFT JOIN todos t ON u.id = t.user_id;

결과셋 예시:

u.id | u.name | t.id | t.title   | t.user_id
1    | 김철수  | 1    | 운동하기   | 1
1    | 김철수  | 2    | 공부하기   | 1      ← 김철수 정보 중복 전송!
2    | 이영희  | 3    | 쇼핑하기   | 2
3    | 박민수  | 4    | 독서하기   | 3
3    | 박민수  | 5    | 요리하기   | 3      ← 박민수 정보 중복 전송!

문제점 분석:

  • 원본 데이터: 사용자 3명 + 할일 5개
  • JOIN 결과: 5행 반환 (사용자 정보가 중복 전송)
  • 김철수: 2번 전송, 박민수: 2번 전송

현실적인 예시로 보면:

사용자 1,000명 × 할일 평균 10개 = 10,000행 반환
→ 사용자 정보가 10배 중복으로 네트워크 전송!

네트워크 전송량 비교:
- 원본: 사용자 1,000명 + 할일 10,000개
- JOIN: (사용자+할일) 조합 10,000행 = 사용자 정보 10배 낭비

 

그래서 Hibernate가 선택한 분리 조회:

-- 1번째: 사용자들만 조회 (중복 없이 한 번만)
SELECT * FROM users;

-- 2번째~1001번째: 각 사용자별 할일 조회
SELECT * FROM todos WHERE user_id = 1;
SELECT * FROM todos WHERE user_id = 2;
-- ... 1,000번 반복

 

Hibernate 개발팀의 판단:

  • "쿼리 1,001번 실행" vs "데이터 10배 중복 전송"
  • 네트워크 비용과 메모리 효율성을 고려하여 분리 조회를 선택

추가 고려사항:

  1. 메모리 사용량: JOIN 결과셋이 클수록 메모리 부담 증가
  2. 쿼리 복잡성: 여러 @OneToMany 관계가 있을 때 JOIN이 복잡해짐
  3. DB 옵티마이저: 복잡한 JOIN을 DB가 잘못 최적화할 가능성

 

🎯 해결 방법들

1. JOIN FETCH (가장 권장)

@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.todos")
List<User> findAllWithTodos();

실행되는 쿼리:

SELECT DISTINCT u.*, t.* 
FROM users u 
LEFT JOIN todos t ON u.id = t.user_id;

 

장점:

  • 한 번의 쿼리로 모든 데이터 조회
  • 명시적이고 예측 가능한 성능

2. @EntityGraph

@EntityGraph(attributePaths = {"todos"})
List<User> findAll();

JOIN FETCH와 동일한 효과를 어노테이션으로 간단하게 구현

3. @BatchSize (N+1 완화)

@BatchSize(size = 10)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Todo> todos = new ArrayList<>();

실행되는 쿼리:

-- 1번째 쿼리: 사용자 조회
SELECT * FROM users;

-- 2번째 쿼리: 10명씩 묶어서 할일 조회 (N+1을 N/10+1로 감소)
SELECT * FROM todos WHERE user_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

4. 두 단계 조회

// 1단계: 사용자 조회
List<User> users = userRepository.findAll();
List<Long> userIds = users.stream().map(User::getId).toList();

// 2단계: 할일 일괄 조회
List<Todo> todos = todoRepository.findByUserIdIn(userIds);

// 3단계: 애플리케이션에서 매핑
Map<Long, List<Todo>> todoMap = todos.stream()
    .collect(Collectors.groupingBy(todo -> todo.getUser().getId()));

5. Projection 활용

// 필요한 데이터만 조회하는 DTO
public interface UserTodoSummary {
    String getUserName();
    Long getTodoCount();
}

@Query("SELECT u.name as userName, COUNT(t) as todoCount " +
       "FROM User u LEFT JOIN u.todos t GROUP BY u.id, u.name")
List<UserTodoSummary> findUserTodoSummary();

⚠️ 주의사항과 권장사항

JOIN FETCH 사용 시 주의점

// ❌ 잘못된 사용 - 반복 호출
for (Long userId : userIds) {
    User user = userRepository.findWithTodos(userId); // 각각 페치 조인
}

// ✅ 올바른 사용 - 한 번에 조회
@Query("SELECT DISTINCT u FROM User u JOIN FETCH u.todos WHERE u.id IN :ids")
List<User> findAllWithTodos(@Param("ids") List<Long> ids);

권장하는 전략

  1. 기본 설정: 모든 연관관계를 LAZY로 설정
  2. 선택적 최적화: 필요한 곳에서만 JOIN FETCH 사용
  3. 성능 측정: 실제 데이터로 성능 테스트 후 최적화 방법 결정

언제 어떤 방법을 사용할까?

  • 데이터량이 적고 항상 함께 사용: JOIN FETCH
  • 데이터량이 많고 가끔 사용: @BatchSize + LAZY
  • 통계나 집계만 필요: Projection 활용
  • 복잡한 비즈니스 로직: 두 단계 조회

🎯 결론

N+1 문제는 JPA를 사용할 때 반드시 마주치게 되는 성능 이슈입니다. 중요한 것은:

  1. 문제를 인식하는 것: 쿼리 로그를 확인하여 N+1 발생 여부 파악
  2. 적절한 해결책 선택: 상황에 맞는 최적화 방법 적용
  3. 성능 측정: 실제 환경에서의 성능 개선 효과 확인

실무에서는 LAZY + JOIN FETCH 조합을 기본으로 하되, 상황에 따라 다른 해결책을 유연하게 적용하는 것이 가장 효과적입니다.

728x90

'CS' 카테고리의 다른 글

데이터베이스의 지식  (0) 2025.06.11
[리메이크] DB의 기술  (1) 2025.05.30
SSR, CSR, SSG 등  (0) 2025.05.17
JWT vs 세션  (0) 2025.05.15
운영체제의 핵심 개념: 프로그램, 프로세스, 스레드  (0) 2025.05.13