Refactoring/Moonkey

[졸업작품] spring boot로 작업한 뭉키를 다시 돌아보며 (5) - Entity의 1:N 매핑의 성능 하락

수짱수짱 2023. 3. 26. 18:42

5. Entity에서 1:N 매핑을 사용하여 이는 연관관계 관리를 위해 추가로 UPDATE SQL이 실행되어 성능 하락을 야기

부제: JPA


(1) N+1 문제

 

N+1문제란 1:N관계에서 1번의 쿼리를 날렸는데 추가로 N번의 쿼리가 더 발생하는 상황이다.

예를들어, 고객 한명(1)이 여러 개의 계좌(N)을 가졌을 때를 가정한다. 이때, 고객의 정보를 조회하면 연관 관계를 갖고있는 여러 개의 계좌정보들까지 N번 조회되는 경우가 발생한다.

 

* 또한, 1:N매핑을 사용하면 DB에 대한 쿼리가 복잡해진다. 

매핑된 엔티티들을 로딩하기 위해선 join을 사용해야 하는데 join은 성능 저하의 주요 원인이 된다.

 

N+1문제는 N:1, 1:N 관계에서 발생한다. 

 

(2) 1:N매핑에서의 UPDATE SQL 문제 

 

1:N 매핑에서는 외래키 제약 조건을 사용하여 연관된 엔티티를 관리한다.

따라서, 1:N 매핑을 사용하는 경우 연관된 엔티티를 CRUD할 때 외래 키를 업데이트하는 Update SQL문을 실행한다.

@Entity
public class Order {
    @Id
    private Long id;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> orderItems = new ArrayList<>();

    // getters and setters
}

@Entity
public class OrderItem {
    @Id
    private Long id;

    @ManyToOne
    private Order order;

    // getters and setters
}

예로, 위 코드에선 Order 클래스와 OrderItem 클래스 사이 1:N관계가 설정되어 있다.

Order 엔티티는 여러 개의 OrderItem 엔티티를 가질 수 있고 각 OrderItem 엔티티는 하나의 Order 엔티티에 속해있다.

 

따라서 Order 엔티티를 저장, 수정할 때 OrderItem 엔티티의 외래키(order_id)가 업데이트 된다. 이때 Update SQL이 실행된다.

UPDATE order_item SET order_id = ? WHERE id = ?;

위 SQL문은 order_item 테이블의 order_id 컬럼에 새로운 order_id 값을 설정하고 해당하는 orderItem 엔티티의 id값을 조건으로 지정하여 업데이트 한다.

 

이와 같이 1:N 매핑을 사용하는 경우엔 연관된 엔티티를 관리하기 위해 엔티티 값을 UPDATE 하는 SQL문이 실행되어 성능 하락이 야기될 수 있다.

 

 

(3) 1:N 매핑을 피하는 방법

 

1. @OneToMany 대신 @ManyToOne 매핑을 사용

 

@ManyToOne 관계를 사용하면 @OneToMany를 사용함으로 일어나는 성능 저하를 해결할 수 있다.

만약 하나의 게시글에 대한 댓글이 100개가 넘을때, @OneToMany 관계로 설정하면 게시글을 조회할 때 100개가 넘는 댓글을 가져오게 되므로 성능상 이슈가 발생한다.

 

하지만 @ManyToOne 관계를 사용함으로써 댓글을 조회할 때 해당 댓글이 속한 게시글만 가져오면 되므로 성능 하락을 방지할 수 있다.

@Entity
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String content;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;
    
    // getter, setter, constructors 생략

}

 

위 코드에선 @ManyToOne 관계를 설정하여 Post 클래스와 관계를 맺었다.

이때 fetch 속성을 LAZY로 설정함으로써 댓글을 조회할 때 해당 댓글이 속한 게시글을 가져오지 않고, 게시글이 실제로 필요한 시점에 가져오게 된다.

@ManyToOne 으로 매핑된 Comment 클래스에선 Post 클래스 객체를 참조할 수 있으므로 Post 클래스에선 Comment 클래스와의 관계를 별도로 표현할 필요가 없다.

 

만약, Post 클래스에서 Comment 클래스와의 관계를 표현하려면 Commnet 클래스에서 @ManyToOne 어노테이션과 함께 mappedBy 속성을 이용하여 매핑할 수 있다.

@Entity
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String content;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;
    
    // getter, setter, constructors 생략

}

@Entity
public class Post {
	
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String content;
    
    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
    private List<Comment> comments = new ArrayList<>();
    
    
    // Getters and Setters
}

 

 

2. LAZY 로딩을 적용

 

Spring에서 1:N매핑을 사용하면 기본적으로 LAZY 로딩이 적용된다. 

LAZY 로딩(=지연 로딩, 부모 엔티티를 조회할 때 매핑된 자식 엔티티를 로딩하지 않고 실제 사용되는 시점에 로딩)을 적용함으로써 부모 엔티티를 조회할 때 매핑된 자식 엔티티들은 로딩되지 않고 실제 사용되는 시점에만 로딩이 된다. 실제로 사용되는 시점에만 로딩하게 되므로 성능 문제를 최소화 할 수 있다.

이때, 매핑된 자식 엔티티들을 가져오기 위해 추가적인 쿼리가 실행되는데 자식 엔티티가 많은 경우 부모 엔티티 하나를 조회할 때 많은 수의 쿼리가 실행되기에 성능이 저하된다.

 

 

반대로 EAGER 로딩(=즉시 로딩, 부모 엔티티를 조회할 때 매핑된 모든 자식엔티티를 함께 조회,)을 사용하게 된다면 부모 엔티티와 매핑된 모든 자식 엔티티들이 한 번에 로딩된다.

이때 조인 쿼리가 실행되기 때문에 DB에서 대량의 데이터를 가져오게 됨으로써 성능 저하가 발생할 수 있다.

 

(3) 해결법

 

1. Fetch Join

 

JPQL을 이용하여 데이터를 가져올 때 join을 함으로써 한번에 데이터를 가져오게 하는 방법이다.

즉, N+1이 발생하는 두 테이블을 미리 join하여 1개의 쿼리만 날리는 것이다

public interface TeamRepository extends JpaRepository<Team, Long>{
    @Query("select t from Team t fetch t.member")
    List<Team> findAllWithMember();
}

단, Fetch join은 페이징처리가 까다롭고 oneToMany에선 Many를 한번에 조회하기 때문에 메모리가 과도하게 사용될 수 있다. 또한 Many 객체가 여러개면 한개에만 적용할 수 있다는 한계가 있다.

 

 

2. Batch Size

 

1번 fetch join으로 풀리지 않는 관계가 있다면 Batch Size를 조절하여 해결 할 수 있다.

select * from Member where Member_id in (?,?,?,?) 와 같이 in 절의  파라미터 값 사이즈를 정할 수 있다.

이로 인해 원래는 4번의 쿼리가 나갔었다면 1번의 쿼리로 줄일 수 있다.

@BatchSize(size = 4)
@OneToMany(mappedBy = "id", fetch = FatchType.LAZY, cascade = CascacdeType.ALL)
private List<Student> students = new ArrayList<>();
Hibernate: select account.id from student where select_id int (?, ?, ?, ?)

 

3. oneToMany에는 SUBSELCT 사용

 

서브쿼리를 사용해 N+1 문제를 해결할 수 있다. 기본적으로 지연로딩으로 설정하고 필요한 곳에 서브쿼리를 사용한다.

@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "id", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<Student> students = new ArrayList<>();

 


Reference