📢 공지합니다
이 게시글은 메인 페이지에 항상 고정되어 표시됩니다.
https://balhae.tistory.com/156
섹션1 영속성 관리 - 내부 동작 방식
최코딩의 개발 섹션1 영속성 관리 - 내부 동작 방식 본문 JPA/JPA 기본 핵심 원리 섹션1 영속성 관리 - 내부 동작 방식 seung_ho_choi.s 2024. 1. 25. 23:50
balhae.tistory.com
N+1 문제는 1번의 쿼리로 N개의 엔티티를 조회한 후, 각 엔티티의 연관 데이터를 가져오기 위해 추가로 N번의 쿼리가 실행되는 문제입니다.
예를 들어, 회원 100명을 조회한 후 각 회원의 게시글을 확인하려고 할 때:
먼저 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
@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)
@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;
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 ← 박민수 정보 중복 전송!
문제점 분석:
현실적인 예시로 보면:
사용자 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 개발팀의 판단:
추가 고려사항:
@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;
장점:
@EntityGraph(attributePaths = {"todos"})
List<User> findAll();
JOIN FETCH와 동일한 효과를 어노테이션으로 간단하게 구현
@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);
// 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()));
// 필요한 데이터만 조회하는 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();
// ❌ 잘못된 사용 - 반복 호출
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);
N+1 문제는 JPA를 사용할 때 반드시 마주치게 되는 성능 이슈입니다. 중요한 것은:
실무에서는 LAZY + JOIN FETCH 조합을 기본으로 하되, 상황에 따라 다른 해결책을 유연하게 적용하는 것이 가장 효과적입니다.
데이터베이스의 지식 (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 |