목차
1. 1+N이란?
2. 즉시로딩과 지연로딩
3. 1+N이 발생하는 케이스
4. 1+N의 해결방안
🤔 1 + N 이란?
- 연관관계가 설정된 엔티티 사이에서 한 엔티티(1)를 조회하였을 때, 조회된 엔티티의 개수(N)만큼 연관된 엔티티를 조회하기 위해 추가적인 쿼리가 발생하는 문제이다
- 예를 들어 게시글(1) 과 댓글(N) 이 있을 때, 게시글과 댓글이 조인된 형태로 함께 조회되기를 기대했지만, 게시글을 조회(1)하는 쿼리와 댓글을 조회(N)하는 쿼리가 따로 날아가며 총 1+N 번 쿼리가 발생
- DBMS에서 직접 쿼리를 작성하면 발생하지 않을 문제지만, JPA와 같은 ORM이 등장하고, 쿼리가 자동화 되면서 발생되는 문제이다
- 즉시로딩 과 지연로딩 에 대한 개념을 먼저 설명하고, 간단한 예제를 통해 1 + N 을 더 자세히 알아보자!
🤔 즉시로딩
- 즉시로딩은 연관관계가 맺어진 엔티티가 있을 때, 한 엔티티를 조회하면 그 엔티티와 연관관계가 맺어져있는 엔티티를 즉시 같이 가져오는 것을 말한다.
@OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
private Set<Article> articles = emptySet();
@ManyToOne(fetch = FetchType.EAGER)
private User user;
- 사용 방법은 관계를 맺는 @OneToMany(), @ManyToOne()과 같은 어노테이션에서 fetch 옵션을 FetchType.EAGER 로 설정하여 사용할 수 있다
- Fetch type의 default는 ~ToMany()에서는 Lazy(지연로딩), ~ToOne()에서는 Eager(즉시로딩) 이다
- 즉시로딩은 연관관계가 복잡해지고 데이터가 많지 않은 곳에서는 일부 사용할 수 있으나, 거의 모든 관계에서 지연로딩이 권장된다
🤔지연로딩
- 지연로딩은 연관관계가 맺어진 엔티티가 있을 때, 한 엔티티(유저)를 조회하면 그 엔티티와 연관관계가 맺어져있는 엔티티(게시글)는 사용하기 전까지 지연시키다 사용하는 시점에 가져오는 것 을 말한다.
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private Set<Article> articles = emptySet();
@ManyToOne(fetch = FetchType.LAZY)
private User user;
- 사용 방법은 fetch 옵션을 FetchType.EAGER 로 설정하여 사용할 수 있다
- 대부분의 관계에서 지연로딩이 권장, 사용 된다
😵 1+N이 발생하는 케이스
1+N이 발생하는 케이스는 대표적으로 2가지가 있다.
- FetchType.EAGER를 사용했을 때
- Fetch join 없이 FetchType.LAZY를 사용했을 때
예제로 사용할 연관관계 (User : 1, Article : N)
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 10, nullable = false)
private String name;
@OneToMany(mappedBy = "user")
private Set<Article> articles = emptySet();
@Entity
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 50, nullable = false)
private String title;
@Lob
private String content;
@ManyToOne
private User user;
1. FetchType.EAGER를 사용했을 때
- 위와 같이 User 가 Article에 대해 FetchType을 Eager로 설정했을 때
- User를 SELECT 하면서 User가 가지고 있는 Article을 추가로 검색하게 되고 유저 모두 조회(1), 각 유저의 article 조회(N) 쿼리가 날라가게 된다
- 단순히 User만 조회하려 했어도 1 + N 가 발생하고, User와 Article을 함께 조회하려 했어도 1 + N 이 발생하게 된다
- 우리의 생각처럼 조인을 통해 결과를 가져다주지 않는다
2. Fetch join 없이 FetchType.LAZY를 사용
- User를 조회할 때는, 1 + N 이 발생하지 않는다
- 하지만 User의 Article을 조회하는 시점에 쿼리가 나가므로 여전히 1 + N은 해결되지 않는다
(추가) Fetch join과 Pagination을 같이 사용했을 때
- 이 경우는 직접적으로 1 + N 을 발생시키지는 않지만 그보다 더 심각한 문제를 일으킬 수 있음
@Test
@DisplayName("fetch join을 paging처리에서 사용하면 Out Of Memory가 발생할 수 있다.")
void pagingFetchJoinTest() {
System.out.println("== start ==");
PageRequest pageRequest = PageRequest.of(0, 2);
Page<User> users = userRepository.findAllPage(pageRequest);
System.out.println("== find all ==");
for (User user : users) {
System.out.println(user.articles().size());
}
}
- 위 코드를 실행하면 1 + N 문제는 발생하지 않지만, 페이징 할 때 사용하는 Limit과 Offset이 보이지 않고, 결과 값 자체는 잘나오는 것처럼 보이기도 한다
- 이는 스프링에서 인메모리를 적용하여 조인을 한 것인데, 모든 데이터를 select (풀스캔)하고 이를 인메모리에 저장하여 application 단에서 필요한 페이지만큼 반환을 해준 것이다
- 이렇게되면 페이징을 한 이유가 없어지고, Out of Memory가 발생할 확률이 매우 높아짐
😵 1+N의 해결방안
1. FetchType.EAGER을 FetchType.LAZY로 설정하고, Fetch join 을 통해 해결
@Query("select distinct u from User u left join fetch u.articles")
List<User> findAllJPQLFetch();
- 조인을 사용하여 쿼리 하나로 연관되어 있는 데이터를 전부 가져옴
- ManyToOne 관계인 엔티티는 여러 개 가져와도 문제가 발생하지 않는다
- OneToMany 관계인 엔티티를 두 개 이상 가지고 오려고 하면 MultiBagFetchException 이 발생하는데 아래의 @BatchSize를 통해 어느정도 해결할 수 있다.
2. @BatchSize
- BatchSize 어노테이션을 통해 해결
- 엔티티 위의 @Batch Size() 어노테이션을 통해 각 엔티티 마다 설정해줄 수 있음
- application.yml 파일에 전역으로 Batch Size (default_batch_size) 를 설정하는 방법도 있음
- 해당하는 Article에 대해서 In 절을 통해 batch size개 만큼 데이터를 가져오게 되고, 1 + N 문제를 완전히는 아니지만 해결할 수 있게 된다.