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
    1. tutor1~3 조회 및 1차 캐시에 저장
    2. tutor1 출력
    3. sparta 조회 및 1차 캐시에 저장
    4. sparta 출력
  • 반복문 2
    1. 1차 캐시의 tutor2 출력
    2. etc 조회 및 1차 캐시에 저장
    3. etc 출력
  • 반복문 3
    1. 1차 캐시의 tutor3 출력
    2. 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 JoinJPQL에서 연관된 엔티티를 한 번에 가져오도록 명시적으로 지정하는 방법입니다.

  • 장점:
    • 여러 테이블을 조인하여 필요한 데이터를 한 번에 가져옴.
    • 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의 장점

  1. 성능 최적화:
    • 한 번의 쿼리로 연관된 엔티티를 함께 로드하므로 데이터베이스 접근 횟수를 줄임.
  2. N+1 문제 해결:
    • Fetch Join을 사용하면 지연 로딩으로 인한 추가 쿼리(N+1 문제)가 발생하지 않음.
  3. 일관된 데이터 상태:
    • 연관된 엔티티가 즉시 로드되어 프록시 객체 대신 실제 객체로 사용 가능.

주의사항

  1. 데이터 양 증가:
    • Fetch Join으로 많은 연관 데이터를 한 번에 로드하면, 불필요한 데이터까지 조회하여 메모리 사용량이 증가할 수 있음.
  2. 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());
    }
}

  1. Company 전체 조회
  2. 조회 결과 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의 한계
    1. 동적 쿼리를 사용하기 어렵다.
    2. SQL을 문자열로 작성하여 사용하기 까다롭다.
    3. SQL의 모든 기능을 사용할 수 없다(Native Query 사용).
  • fetch join 정리
    1. SQL의 JOIN과 비슷하지만 연관된 엔티티나 컬렉션을 한 번의 쿼리로 함께 로드하는 기능.
      • 지연 로딩 설정된 엔티티도 fetch join으로 사용할 수 있다.
    2. N + 1 문제를 해결할 수 있다.
    3. 데이터의 중복이 발생할 수 있다. (DISTINCT)
    4. 컬렉션 페이징 처리가 힘들다.
      • @BatchSize 적용
    5. 조회 시 여러 테이블이 JOIN 된다면 일반 JOIN을 사용하면 된다.
      • JPQL 또는 QueryDSL로 필요한 데이터만 조회하여 DTO로 반환한다.

 

'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

+ Recent posts