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