프로젝트 논의 - N+1

동준·2024년 5월 5일
0

개인공부(스프링)

목록 보기
12/14

1. N+1 문제

프로젝트에서 JPA를 사용하게 됨으로써 마주하는 문제가 바로 N+1 문제다.
N+1 문제는 1번의 쿼리를 날렸을 때 의도하지 않은 N번의 쿼리가 추가적으로 실행되는 것.

얘를 이해하려면, 연관관계, 페치 타입에 대해 알아야 함

1) 연관관계, 페치 타입

앞서 JPA는 객체 관계형 매핑을 실현하기 위한 인터페이스라고 했다.
자바의 엔티티 클래스가 곧 데이터베이스의 테이블을 맡게 되는 셈이다.

데이터베이스의 테이블에는 PK(Primary Key)가 존재하고, 테이블에서 다른 테이블을 거쳐가서 같이 조회해야되는 경우, 둘을 중복해서 조회하는 것보다 더 좋은 방법이 다른 테이블의 PK를 해당 테이블의 FK(Foreign Key)로 삼아서 한 번에 조회하는 것이 효율적이다. 두 테이블 간의 관계가 명확해지므로 데이터 무결성이 유지되고 데이터의 일관성을 지키게 된다.

이것을 JPA에서 구현한 것이 연관관계 설정이다. @OneToOne, @ManyToOne, @OneToMany, @ManyToMany 설정이 있다.

또한, 데이터의 조회 방법에서 즉시 로딩(Eager)지연 로딩(Lazy)라는 것이 존재한다.

  • 즉시 로딩: 데이터를 조회할 때 연관된 데이터까지 한 번에 불러오기
  • 지연 로딩: 데이터를 조회할 때 필요한 시점에 연관된 데이터를 불러오기

JPA는 ORM 기술이기 때문에, 쿼리 생성에 집중하지 않는다. JPA에서 JPQL을 이용하여 쿼리문을 생성할 때, 해당 객체와 필드를 보고 쿼리를 생성하기 때문에 다른 객체와 연관관계 매핑이 되어있으면 그 객체들까지 조회하게 되는데, 이 객체의 조회 방식이 위에 언급한 즉시 로딩, 지연 로딩이 되겠다.

@XXToMany는 디폴트가 LAZY, @XXToOne은 디폴트가 EAGER

예제를 기반으로...

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    private String username;
    
    @ManyToOne // 연관관계 설정
    @JoinColumn(name = "team_id")
    Team team;
}

@Entity
public class Team {

    @Id @GeneratedValue
    private Long id;
    private String teamname;
    
    @OneToMany // 연관관계 설정
    @JoinColumn(name = "team_id")
    User user;
}

즉시로딩(FetchType.Eager)

즉시로딩은 코드가 이렇게 짜여짐

    @ManyToOne(fetch = FetchType.Eager)
    @JoinColumn(name = "team_id")
    Team team;

여담으로 @XxxToOne 어노테이션은 디폴트가 Eager이어서 굳이 fetch 속성을 넣지 않아도 되지만... 가시성을 위해서.

여기서 JPQL로 Member 1개를 조회한다면

Member findMember = em.createQuery("select m from Member m", Member.class).getSingleResult();

실제 SQL 코드는 다음과 같음

//멤버를 조회하는 쿼리
select
    member0_.id as id1_0_,
    member0_.team_id as team_id3_0_,
    member0_.username as username2_0_ 
from
    Member member0_

//팀을 조회하는 쿼리
select
    team0_.id as id1_3_0_,
    team0_.name as name2_3_0_ 
from
    Team team0_ 
where
    team0_.id=?

즉, 즉시 로딩은 멤버를 조회하는 시점에서 팀까지 같이 조회해오는 것을 볼 수 있음

지연로딩(FetchType.Lazy)

지연로딩은 코드가 이렇게 짜여짐

    @ManyToOne(fetch = FetchType.LAZY) // 강제 지연로딩화
    @JoinColumn(name = "team_id")
    Team team;

여기서 JPQL로 Member 1개를 조회한다면

Member findMember = em.createQuery("select m from Member m", Member.class).getSingleResult();

즉시로딩과 같지만 실제 SQL을 보면

// 멤버만 조회하고 팀을 조회하는 쿼리 x
select
    member0_.id as id1_0_,
    member0_.team_id as team_id3_0_,
    member0_.username as username2_0_ 
from
    Member member0_

즉, 멤버를 조회하는 시점에 팀을 조회하는 쿼리가 나가지 않게 된다.

2) N+1 발생

N+1 문제는 DAO에서, 1:N 또는 N:1 관계의 엔티티를 조회하는 메소드를 호출할 때 발생한다.

즉시로딩에서

JPA는 DAO의 메소드명을 분석해서 JPQL을 생성해서 실행하는데, 만약 JPQL은 TeamRepository에서 findAll()이란 메소드를 수행하였을 때 해당 엔티티를 조회하는 select * from Team 쿼리만 실행하게 된다.

JPQL 입장에서는 연관관계 데이터를 무시하고 해당 엔티티 기준으로 쿼리를 조회하기 때문에 연관된 엔티티 데이터가 필요한 경우, FetchType으로 지정한 시점에 조회를 별도로 호출하게 된다.

현재는 Eager로 설정되어있으므로 즉시 User 역시 같이 조회를 하게 된다.

모든 Team에 대해서 검색하고 싶어서 select 쿼리를 하나 날렸지만(1), 즉시로딩이 걸려있기 때문에 각각의 Team이 가진 각각의 User들을 모두 검색한다(N)라는 N+1 문제가 발생하는 것

그래서 실무에서 권장하지 않는다

즉시로딩은 Jpql로 전달되는 과정에서 Jpql 후 Eager 감지로 인한 N쿼리가 추가로 발생하는 경우가 있기 때문에 사용해서는 안된다.

지연로딩에서

그렇다면 조회 시점을 Lazy로 바꾼다면 해결이 될까. 정답은 No다.

지연로딩은 실제 데이터를 사용하는 시점까지 로딩을 미룬다고 했음. 즉, 해당 엔티티에 연관관계로 묶인 대상 엔티티의 조회를 필요로 하지 않을 때는 엔티티매니저가 프록시 객체를 반환하게 된다. 다시 지연로딩의 동작 원리를 보면...

  1. 엔터티 매니저가 엔터티를 로드할 때
    JPA는 TeamUser에 대한 프록시 객체 (HibernateProxy) 를 실제 엔터티를 대신해서 반환한다.
    프록시 객체는 실제 엔터티와 동일한 인터페이스를 가지는 껍데기라고 생각하면 된다.

  2. 프록시 객체 사용시점까지는 실제 데이터를 로드하지 않는다
    프록시 객체는 연관된 엔터티에 대한 참조를 가지고 있지만, 그 엔터티의 데이터는 로드하지 않는다.

  3. 프록시 객체의 메서드 호출 시 실제 데이터 로딩
    프록시 객체의 메서드 중에서 실제 데이터가 필요한 시점(getter 호출 등..)에 데이터베이스에서 실제 데이터를 가져와서 프록시 객체를 실제 객체로 대체한다.

  4. 초기화되면 프록시 객체는 실제 객체 User로 교체
    한 번 프록시 객체가 초기화되면(실제 데이터가 로드되면, 참고로 대체되는 것이 아니라 원래 해당 객체의 참조를 가지고 있다가 접근이 가능해지는 거), 그 이후에는 프록시 대신에 실제 객체가 사용된다.

즉, User에 대해 볼일 없이, 모든 Team들에 대해서만 조회할 때는 쿼리문이 하나만 날아가므로 발생하지 않는다고 한들(프록시 객체 생성, 1), 추후에 그 TeamUser들에 대한 조회 작업을 수행하는 순간 프록시 객체는 실제 객체 User로 초기화되면서 쿼리가 각각 User들에 대해서 또 날아가게 되는 것(N)이다.

현재 팀플 프로젝트에서의 TeamStudent이고, UserRegisteredSubject가 된다.

프로젝트에서의 해결책 - Fetch join

  • 전제 1
    StudentRegistredSubject가 1대 다 관계이고, SubjectRegisteredSubject가 1대 다 관계
  • 전제 2
    특정 학생에 대한 등록 과목의 정보들을 전부 조회해야 함

fetch는 지연 로딩이 걸려있는 연관관계(Student, Subject)에 대해서 한번에 같이 즉시 로딩해주는 구문

    @Query("select rs from RegisteredSubject rs " +
            "join fetch rs.subject " +
            "join fetch rs.student " +
            "where rs.student.id = :studentId")
    List<RegisteredSubject> findAllByStudentId(Long studentId);

jpql에서 fetch join을 하게 된다면 하드코딩을 하게 된다는 단점이 있는데 이를 최소화하고 싶다면 @EntityGraph를 사용하면 된다.

fetch join의 가장 큰 문제점은 페이징 처리둘 이상의 Collection fetch join(~ToMany) 불가능이라는 것이다.

페이징 처리에서는 쿼리문 발신은 한 번만 되면서 collection fetch에 대해서 paging처리가 나왔긴한데 applying in memory, 즉 인메모리를 적용해서 조인을 했다고 나온다.

실제 날아간 쿼리와 이 문구를 통합해서 이해를 해보면 일단 List의 모든 값을 select해서 인메모리에 저장하고, application 단에서 필요한 페이지만큼 반환을 알아서 해주었다는 이야기가 되는데, 이게 대용량 트래픽과 만나게 되면 당연지사 메모리 아웃 발생...

또한 둘 이상의 @XxxToMany 관계에서는 fetch join을 할 때 ToMany의 경우 한번에 fetch join을 가져오기 때문에 collection join이 2개이상이 될 경우 너무 많은 값이 메모리로 들어와 exception이 추가로 걸리게 된다.

물론 둘의 단점은 우리의 프로젝트에서는 관련이 없는 부분이기 때문에 fetch join을 선택....

: Fetch Join, EntityGraph 어노테이션

  • 장점: 낮은 러닝커브, 직접 JPQL 작성으로 최적화 쿼리에 대한 개발자의 의사 내포 가능
  • 단점: 데이터 호출 시점에 모든 연관 관계의 데이터를 가져오므로 지연 로딩 설정이 무의미, 다중 컬렉션 fetch 불가능, 중복 데이터 관리 필요

: BatchSize 어노테이션

  • 장점: 이론상 연관관계의 데이터 사이즈를 정확하게 알 수 있다면 최적화 size를 구할 수 있음
  • 단점: 업데이트가 잦은 데이터의 경우, 사실상 연관 관계 데이터의 최적화 size를 알기 어려운 편

: Query Builder

  • 장점: 쿼리 작성을 지원해주는 다양한 플러그인의 옵션을 기반으로 불필요 쿼리를 막을 수 있음
  • 단점: 러닝 커브가 높은 편

🐿️ 현재 프로젝트의 엔티티 연관관계가 간단한 편에 속하고, 조회해야 하는 연관관계 대상 엔티티가 중복되지 않는 필드로만 구성되어 있으며, 이전 작업에서 익히 다뤄봤던 SQL문 기반의 해결책인 Fetch Join을 선택하였음

https://dev-coco.tistory.com/165
https://incheol-jung.gitbook.io/docs/q-and-a/spring/n+1#n1-1
https://velog.io/@jinyoungchoi95/JPA-%EB%AA%A8%EB%93%A0-N1-%EB%B0%9C%EC%83%9D-%EC%BC%80%EC%9D%B4%EC%8A%A4%EA%B3%BC-%ED%95%B4%EA%B2%B0%EC%B1%85

profile
scientia est potentia / 벨로그 이사 예정...

0개의 댓글