N + 1 문
📌 JPA가 연관된 엔티티를 조회할 때 추가적인 쿼리를 반복적으로 실행하기 때문에 발생하는 문제
- 지연 로딩의 N+1
- Tutor N : 1 Company 양방향 연관관계
@Entity
@Table(name = "tutor")
public class Tutor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "company_id")
private Company company;
public Tutor() {
}
public Tutor(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setCompany(Company company) {
this.company = company;
}
}
@Entity
@Table(name = "company")
public class Company {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "company")
private List<Tutor> tutors = new ArrayList<>();
public Company() {
}
public Company(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public List<Tutor> getTutors() {
return tutors;
}
}
public class LazyMain {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("entity");
EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
transaction.begin();
try {
Company sparta = new Company("sparta");
Company etc = new Company("etc");
em.persist(sparta);
em.persist(etc);
Tutor tutor1 = new Tutor("tutor1" );
Tutor tutor2 = new Tutor("tutor2" );
Tutor tutor3 = new Tutor("tutor3" );
tutor1.setCompany(sparta);
tutor2.setCompany(etc);
tutor3.setCompany(sparta);
em.persist(tutor1);
em.persist(tutor2);
em.persist(tutor3);
em.flush();
em.clear();
String query = "select t from Tutor t";
List<Tutor> tutorList = em.createQuery(query, Tutor.class).getResultList();
for (Tutor tutor : tutorList) {
System.out.println("tutor.getName() = " + tutor.getName());
System.out.println("tutor.getCompany().getName() = " + tutor.getCompany().getName());
}
transaction.commit();
} catch (Exception e) {
transaction.rollback();
} finally {
em.close();
}
emf.close();
}
}
- 반복문 1
- tutor1~3 조회 및 1차 캐시에 저장
- tutor1 출력
- sparta 조회 및 1차 캐시에 저장
- sparta 출력
- 반복문 2
- 1차 캐시의 tutor2 출력
- etc 조회 및 1차 캐시에 저장
- etc 출력
- 반복문 3
- 1차 캐시의 tutor3 출력
- 1차 캐시의 sparta 출력
- 튜터(N), 회사(1)가 많아질수록 더 많은 N + 1 문제가 생긴다.
Entity fetch join
📌 JPQL에서 성능 최적화를 위해 fetch join을 제공하며 연관된 엔티티나 컬렉션을 SQL 한번으로 조회할 수 있도록 해주는 기능이다.
- fetch join은 SQL의 JOIN과는 다른 종류이고 객체 그래프를 SQL 한 번에 조회한다. 실무에서 굉장히 많이 활용한다.
- N:1 fetch join(Entity)
- Tutor N : 1 Comapny 연관관계
- 일반 JOIN
- fetch join
- 튜터 조회시 연관된 회사도 함께 조회
더보기
지연 로딩과 Fetch Join의 동작 원리
1. 지연 로딩(Lazy Loading)
지연 로딩은 연관된 엔티티 데이터를 처음에는 로드하지 않고, 실제로 필요할 때 데이터베이스에서 가져오는 전략입니다.
- 장점:
- 초기 로딩 시 불필요한 데이터를 가져오지 않아 성능 최적화.
- 연관된 엔티티가 필요하지 않을 경우 데이터베이스 접근을 줄일 수 있음.
- 작동 방식:
- 연관된 엔티티는 프록시(Proxy) 객체로 초기화.
- 프록시 객체는 원본 데이터를 대신하며, 실제 데이터가 필요할 때 데이터베이스에서 조회.
예제:
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private Team team; // 지연 로딩
}
// 사용 시
Member member = entityManager.find(Member.class, 1L);
System.out.println(member.getName()); // 즉시 로드
System.out.println(member.getTeam().getName()); // 이 시점에 Team 데이터를 DB에서 조회
2. Fetch Join
Fetch Join은 JPQL에서 연관된 엔티티를 한 번에 가져오도록 명시적으로 지정하는 방법입니다.
- 장점:
- 여러 테이블을 조인하여 필요한 데이터를 한 번에 가져옴.
- N+1 문제를 방지.
- 작동 방식:
- JPQL에서 FETCH JOIN 키워드를 사용하면, 연관된 엔티티를 즉시 로딩하여 프록시 객체 대신 실제 엔티티 객체를 가져옵니다.
- 데이터베이스 조회 시점에 JOIN 쿼리가 실행되며, 필요한 모든 데이터를 로드.
예제:
String jpql = "SELECT m FROM Member m JOIN FETCH m.team";
List<Member> members = entityManager.createQuery(jpql, Member.class).getResultList();
for (Member member : members) {
System.out.println(member.getName()); // 즉시 로드
System.out.println(member.getTeam().getName()); // Team도 즉시 로드 (프록시 X)
}
Fetch Join이 우선권을 가지는 이유
1. Fetch Join은 JPQL에서 명시적으로 즉시 로딩을 요구
- 지연 로딩(LAZY) 설정이 되어 있더라도, FETCH JOIN이 사용되면 JPA가 지연 로딩 대신 즉시 로딩을 수행합니다.
- 이는 JPQL에서 개발자가 명시적으로 데이터 로딩 방식을 지정했기 때문입니다.
2. 프록시 대신 실제 엔티티 반환
- FETCH JOIN을 사용하면 연관된 엔티티도 함께 로드됩니다.
- 따라서 연관된 엔티티는 프록시 객체가 아닌 실제 엔티티 객체로 초기화됩니다.
예제 비교:
지연 로딩 설정 (기본):
Member member = entityManager.find(Member.class, 1L); // Team은 로드되지 않음
System.out.println(member.getTeam().getName()); // 이 시점에 Team을 DB에서 조회
Fetch Join 사용:
String jpql = "SELECT m FROM Member m JOIN FETCH m.team";
Member member = entityManager.createQuery(jpql, Member.class).getSingleResult();
// 이미 Team이 로드됨
System.out.println(member.getTeam().getName()); // 추가 DB 조회 없음
Fetch Join 후 모든 엔티티가 영속성 컨텍스트로 관리
1. 영속성 컨텍스트의 기본 동작
- JPA는 엔티티를 조회할 때, 해당 엔티티를 영속성 컨텍스트에 저장하고 관리합니다.
- Fetch Join으로 조회한 연관된 엔티티도 영속성 컨텍스트에서 관리됩니다.
2. Fetch Join의 효과
- Fetch Join을 사용하면 연관된 엔티티도 데이터베이스에서 즉시 로드되며, 영속성 컨텍스트에 저장됩니다.
- 결과적으로 Fetch Join으로 조회된 엔티티는 모두 영속 상태로 관리됩니다.
예제:
String jpql = "SELECT m FROM Member m JOIN FETCH m.team";
List<Member> members = entityManager.createQuery(jpql, Member.class).getResultList();
for (Member member : members) {
System.out.println(entityManager.contains(member)); // true (영속 상태)
System.out.println(entityManager.contains(member.getTeam())); // true (영속 상태)
}
Fetch Join이 Proxy 객체 대신 실제 객체를 반환하는 이유
1. 명시적 로딩
- Fetch Join은 연관된 엔티티를 즉시 로딩하기 때문에, 데이터베이스 조회 시점에 실제 데이터로 초기화된 엔티티를 반환합니다.
2. N+1 문제 방지
- Fetch Join은 한 번의 쿼리로 여러 엔티티를 로드하므로, **지연 로딩에서 발생할 수 있는 추가 쿼리(N+1 문제)**를 방지합니다.
3. 프록시 대신 실제 엔티티 반환
- Fetch Join은 데이터베이스에서 가져온 데이터를 기반으로 연관된 엔티티를 실제 객체로 초기화하므로, 프록시 객체가 필요 없습니다.
Fetch Join의 쿼리 예제
JPQL:
String jpql = "SELECT m FROM Member m JOIN FETCH m.team";
List<Member> members = entityManager.createQuery(jpql, Member.class).getResultList();
실행된 SQL:
SELECT m.*, t.*
FROM Member m
JOIN Team t ON m.team_id = t.id;
- 결과: Member와 Team 엔티티가 함께 로드되며, 추가 쿼리가 발생하지 않음.
Fetch Join의 장점
- 성능 최적화:
- 한 번의 쿼리로 연관된 엔티티를 함께 로드하므로 데이터베이스 접근 횟수를 줄임.
- N+1 문제 해결:
- Fetch Join을 사용하면 지연 로딩으로 인한 추가 쿼리(N+1 문제)가 발생하지 않음.
- 일관된 데이터 상태:
- 연관된 엔티티가 즉시 로드되어 프록시 객체 대신 실제 객체로 사용 가능.
주의사항
- 데이터 양 증가:
- Fetch Join으로 많은 연관 데이터를 한 번에 로드하면, 불필요한 데이터까지 조회하여 메모리 사용량이 증가할 수 있음.
- Fetch Join 제한:
- JPQL에서 하나의 Fetch Join만 허용되며, 복잡한 다중 Fetch Join은 지원하지 않음.
- 예: @OneToMany와 @ManyToOne을 동시에 Fetch Join하면 문제가 발생할 수 있음.
결론
- Fetch Join이 지연 로딩보다 우선:
- JPQL에서 명시적으로 Fetch Join을 사용하면 지연 로딩 설정을 무시하고 즉시 로딩이 수행됩니다.
- 영속성 컨텍스트 관리:
- Fetch Join으로 조회된 모든 엔티티는 프록시 객체가 아닌 실제 객체로 초기화되며, 영속성 컨텍스트에서 관리됩니다.
- 성능 최적화:
- Fetch Join은 한 번의 쿼리로 연관된 데이터를 가져와 N+1 문제를 해결하고 성능을 최적화하는 강력한 도구입니다.
Collection fetch join
📌 @OneToMany 의 기본 FetchType은 LAZY 이다.
1:N fetch join(Collection)
String query = "select c from Company c join fetch c.tutorList";
List<Company> companyList = em.createQuery(query, Company.class).getResultList();
for (Company company : companyList) {
System.out.println("company.getName() = " + company.getName());
System.out.println("company.getTutorList().size() = " + company.getTutorList().size());
}
데이터 중복이 발생하지 않는다.
SQL Query의 조회 결과는 데이터가 중복된다.
Hibernate 6.0 이상 부터는 DISTINCT가 자동으로 적용된다.
JPQL의 DISTINCT
- Database의 DISTINCT 는 완전히 데이터가 같아야 중복이 제거된다.
- JPQL의 DISTINCT 는 같은 PK값을 가진 Entity를 제거한다.
주의점
- Collection 에 fetch join을 사용하면 페이징을 메모리에서 수행한다.
- 코드 예시
String query = "select c from Company c join fetch c.tutorList";
List<Company> companyList = em.createQuery(query, Company.class)
.setFirstResult(0)
.setMaxResults(1)
.getResultList();
- 전체를 조회하는 SQL이 실행된다.
- setFirstResult(), setMaxResult() 가 SQL에 반영되지 않는다.
- 모든 데이터를 조회하여 메모리에서 페이징을 처리한다.
- 필요없는 데이터까지 전체 로드한 후 필터링한다.
@BatchSize
📌 JPA에서 N+1 문제를 해결하기 위해 사용되는 설정으로 지연 로딩(Lazy Loading) 시 한 번에 로드할 엔티티의 개수를 조정하여 여러 개의 엔티티를 효율적으로 조회할 수 있다.
String query = "select c from Company c";
List<Company> companyList = em.createQuery(query, Company.class)
.setFirstResult(0)
.setMaxResults(2)
.getResultList();
System.out.println("companyList.size() = " + companyList.size());
for (Company company : companyList) {
System.out.println("company.getName() = " + company.getName());
for (Tutor tutor : company.getTutorList()) {
System.out.println("tutor.getName(): " + tutor.getName());
}
}
- Company 전체 조회
- 조회 결과 2개(sparta, etc)에 각각 지연 로딩
@BatchSize
@Entity
@Table(name = "company")
public class Company {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@BatchSize(size = 100)
@OneToMany(mappedBy = "company")
private List<Tutor> tutorList = new ArrayList<>();
public Company() {
}
public Company(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public List<Tutor> getTutorList() {
return tutorList;
}
}
- 한 번의 IN Query에 식별자(PK)를 조회된 개수만큼 넣어준다.
- 설정 파일의 hibernate.jdbc.batch_size 를 통해 Global 적용이 가능하다.
- xml, yml, properties 모두 가능
정리
- JPQL의 한계
- 동적 쿼리를 사용하기 어렵다.
- SQL을 문자열로 작성하여 사용하기 까다롭다.
- SQL의 모든 기능을 사용할 수 없다(Native Query 사용).
- fetch join 정리
- SQL의 JOIN과 비슷하지만 연관된 엔티티나 컬렉션을 한 번의 쿼리로 함께 로드하는 기능.
- 지연 로딩 설정된 엔티티도 fetch join으로 사용할 수 있다.
- N + 1 문제를 해결할 수 있다.
- 데이터의 중복이 발생할 수 있다. (DISTINCT)
- 컬렉션 페이징 처리가 힘들다.
- @BatchSize 적용
- 조회 시 여러 테이블이 JOIN 된다면 일반 JOIN을 사용하면 된다.
- JPQL 또는 QueryDSL로 필요한 데이터만 조회하여 DTO로 반환한다.
- SQL의 JOIN과 비슷하지만 연관된 엔티티나 컬렉션을 한 번의 쿼리로 함께 로드하는 기능.
'Back-End (Web) > Spring' 카테고리의 다른 글
API 예외처리 (0) | 2025.01.19 |
---|---|
Formatter (0) | 2025.01.11 |
TypeConverter (0) | 2025.01.10 |
HttpMessageConverter (0) | 2025.01.09 |
ArgumentResolver (0) | 2025.01.08 |