JPA Basic 11. Fetch Join과 N+1 문제

zdpk·2024년 3월 16일

JPA Basic

목록 보기
11/11
post-thumbnail

9장에서 살펴본 N+1 문제는 EAGER, LAZY를 가리지 않고 발생했다.

이전 장에서 봤던 내용 중 핵심 내용을 코드와 함께 다시 살펴보자.

// UserTest.java
@Test  
void test() {  
  for (int i = 0; i < 10; i++) {  
    User user = User.builder()  
        .name("user" + i)  
        .build();  
    em.persist(user);
    for (int j = 0; j < 10; j++) {  
  
      Post post = Post.builder()  
          .author(user)  
          .title("post" + j)  
          .content("content" + j)  
          .build();
      em.persist(post);  
      user.getPosts().add(post);
    }    
  }  
  em.flush();  
  em.clear();

  System.out.println("----------em.createQuery----------");  
  List<User> foundUsers = em.createQuery("select u from User u", User.class)  
      .getResultList();  

  for (User foundUser : foundUsers) {  
    foundUser.getPosts();  
  }  
}

코드의 순서와 post의 개수를 10개로 살짝 바꿨을 뿐, 지난 번과 큰 차이는 없다.

EAGER, LAZY 2가지의 로딩 전략이 존재했었고, 상대편이 N인 경우는 자동으로 LAZY, 1인 경우는 EAGER가 기본으로 적용되었다.

EAGER, LAZY는 상관 없이, 본 SQL가 전송된 이후 연관 관계 필드에 대한 추가 SQL가 전송되는데,

이 타이밍이 본 SQL 전송 직후인 전략이 EAGER

조회 시에 추가 SQL이 전송되는 것이 LAZY였다.

LAZY는 조회를 할 때까지 추가 SQL 전송을 미루는데, 조회를 아예 하지 않으면 추가 SQL을 전송할 타이밍이 오지 않는 것이다.

그래서 원하는 연관 관계 필드만 조회해서 선택적으로 추가 SQL을 전송할 수 있었다.

EAGER는 사용하지 않을 연관 관계 필드까지 모두 추가 SQL을 보내야만 했었고.

그렇기 때문에 모든 FetchType에 LAZY를 적용하는 것이 더 나은 방법이었다.

글로벌 FetchType 적용 방법은 없기 때문에 LAZY를 모든 연관 관계 Annotation에 수기로 적용할 수밖에 없었다.

마지막으로 대략적인 추가 SQL의 개수는 본 SQL로 가져온 Entity 수 x 연관 관계의 수였다.

본 SQL을 실행하는 것 만으로, 본 SQL 1개 + 추가 SQL N개가 항상 생성되기 때문에 N+1문제가 발생하는 것이었다.


Fetch Join

문제는 현재까지의 지식으로는 LAZY 전략을 통해 추가 SQL을 미룰 수는 있어도, 없앨 수는 없다는 것이다.

user, post를 가져오려면 어쨌든 가져온 user의 수만큼 post에 대한 추가 SQL N개가 수반된다.

이를 아예 없애는 방법이 Fetch Join이다.

그래서 가장 대표적인 N+1문제 해결 방법으로 불린다.

사용법은 매우 간단하다.

// UserTest.java
List<User> foundUsers = em.createQuery("select u from User u join fetch u.posts", User.class)  
    .getResultList();

em.createQuery의 JPQL 부분에 join fetch u.posts만 붙여주면 된다.

----------em.createQuery----------
Hibernate: 
    select
        u1_0.id,
        u1_0.name,
        p1_0."author_id",
        p1_0.id,
        p1_0.content,
        p1_0.title 
    from
        "user" u1_0 
    join
        post p1_0 
            on u1_0.id=p1_0."author_id"

깔끔하게 SQL 하나만 호출되는 것을 볼 수 있다.

본 SQL을 보낼 때, user만 가져오는 것이 아니라, join fetch로 명시한 post까지 한 번에 가져오는 매우 간단한 개념이다.

본 SQL 자체에 JOIN이 추가되어 전송된다고 보면 된다.

그럼 EAGER, LAZY 상관 없이 추가 SQL이 나갈 일이 사라진다.

왜냐하면 이미 user, post가 모두 Persistence Context에 존재하며,

user.posts도 초기화 된 상태로 표시되기 때문이다.

이미 Persistence Context에 있으면 추가 SQL이 발생하지 않는다.

이를 순차적으로 정리하면

  1. 본 SQL 전송
  2. EAGER 전략인 경우, 본 SQL 전송 직후 초기화 되지 않은 연관 관계에 대한 추가 SQL 전송
  3. LAZY 전략인 경우, 해당 연관 관계 조회 시 초기화 되지 않은 연관 관계에 대한 추가 SQL 전송

이렇게 보면 된다.

추가 SQL이 나가지 않기 때문에 N+1 문제가 발생하지 않게 되었다.

LAZY, EAGER 상관 없이 말이다.

만약 본 SQL로 가져오려는 user가 1000개였다면, 본 SQL 1개 + 1000개의 추가 SQL이 발생되어 총 1001개의 SQL이 전송 되었을 것이다.

그러나 FETCH JOIN으로 user를 가져왔다면, 1개의 SQL로 처리된다.

단순 계산으로 SQL이 1001분의 1로 줄어든다.

N개라면 N분의 1

매우 간단한 join fetch 하나로 막대한 성능 이득을 볼 수 있기 때문에 사용하지 않을 이유가 없다.

섣부른 최적화는 과오가 될 수 있다지만 그건 복잡도가 올라가서 생산성이 저하될 때의 이야기고,

이렇게 매우 간단하면서 엄청난 이득을 볼 수 있는 최적화는 오히려 선행되면 좋다고 생각한다.

이것만으로 모든 N+1문제를 모두 해결할 수 있으면 정말 좋았겠지만, FETCH JOIN에도 한계가 존재한다.


FETCH JOIN의 한계

이전에 잠시 사용했던 Item Entity를 다시 복구해보자

// Item.java
@Entity  
@Getter  
@Setter  
@NoArgsConstructor(access = AccessLevel.PROTECTED)  
@AllArgsConstructor  
@Builder  
public class Item {  
  
  @Id  @GeneratedValue(strategy = GenerationType.SEQUENCE)  
  private Long id;  
  
  private String itemName;  
  
  @ManyToOne  
  private User owner;  
  
}

// User.java
@Entity  
@Getter  
@Setter  
@Table(name = "\"user\"")  
@NoArgsConstructor(access = AccessLevel.PROTECTED)  
@AllArgsConstructor  
@Builder  
public class User {  
  
  @Id  @GeneratedValue(strategy = GenerationType.SEQUENCE)  
  private Long id;  
  
  private String name;  
  
  @OneToMany(mappedBy = "author", fetch = FetchType.EAGER)  
  @Builder.Default  private List<Post> posts = new ArrayList<>();  
  
  public void addPost(final Post post) {  
    post.setAuthor(this);  
    posts.add(post);  
  }  
  
  @OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)  
  @Builder.Default  private List<Item> items = new ArrayList<>();  
  
  public void addItem(final Item item) {  
    item.setOwner(this);  
    items.add(item);  
  }  
  
}

연관 관계 편의 메소드에 대해서도 설명했으니 post와 동일하게 만들었다.

// UserTest.java
@Test  
void test() {  
  for (int i = 0; i < 10; i++) {  
    User user = User.builder()  
        .name("user" + i)  
        .build();  
    em.persist(user);  
  
    Post post = Post.builder()  
        .title("post" + i)  
        .content("content" + i)  
        .build();  
    Item item = Item.builder()  
        .itemName("item" + i)  
        .build();  
    em.persist(post);  
    em.persist(item);  
  
    user.addPost(post);  
    user.addItem(item);  
  }  
  
  em.flush();  
  em.clear();  
  
  em.createQuery("select u from User u join fetch u.posts join fetch u.items", User.class)  
      .getResultList();  
}

그리고 item도 함께 FETCH JOIN으로 불러오는 JPQL을 작성했다.

실행해보자.

cannot simultaneously fetch multiple bags

위와 같은 오류가 발생한다.

이전 장에서 넘어가듯 말했었는데, 1:N인 연관 관계의 경우 필드에 ArrayList를 초기 값으로 할당했고,

이는 Jpa에 의해 PersistentBag으로 래핑된다고 했었다.

오류 메세지에서 말하는 multiple bags는 바로 이 PersistentBag를 의미하는 것이다.

즉, FETCH JOIN으로 PersistentBag을 여러 개 가져올 수 없다는 이야기다.

그렇다는 것은 PersistentBag이 아니라면 여러 개 연관 관계라도 FETCH JOIN 할 수 있다는 이야기가 된다.

반대편이 N인 OneToMany, ManyToMany인 관계에 한해서 단 하나만 FETCH JOIN으로 가져올 수 있다는 것이다.

OneToOne, ManyToOne과 같이 반대편이 1인 경우는 FETCH JOIN으로 여럿 가져오는 것도 가능하다.

만약 user가 반대편이 N인 연관 관계 posts, items와 1인 연관 관계 profile, profile2를 가진 경우

FETCH JOIN으로 한 번에 posts, items를 가져오는 것은 불가능하다.

FETCH JOIN으로 한 번에 profile, profile2를 가져오는 것도 가능하다.

헷갈리는 부분이 FETCH JOIN으로 한 번에 posts, profile을 가져오는 경우인데, 이것도 가능하다.

N인 관계는 1개니까.

N인 관계가 2개 이상이면 안 되는 것이다.

고로 posts, profile, profile2도 한 번에 가져올 수 있다.

// Profile.java
@Entity  
@Getter  
@NoArgsConstructor(access = AccessLevel.PROTECTED)  
@AllArgsConstructor  
@Builder  
public class Profile {  
  
  @Id  @GeneratedValue(strategy = GenerationType.SEQUENCE)  
  private Long id;  
  
  @OneToOne  
  private User user;  
  
}

// Profile2.java
@Entity  
@Getter  
@NoArgsConstructor(access = AccessLevel.PROTECTED)  
@AllArgsConstructor  
@Builder  
public class Profile2 {  
  
  @Id  @GeneratedValue(strategy = GenerationType.SEQUENCE)  
  private Long id;  
  
  @OneToOne  
  private User user;  
  
}

// User.java
@Entity  
@Getter  
@Setter  
@Table(name = "\"user\"")  
@NoArgsConstructor(access = AccessLevel.PROTECTED)  
@AllArgsConstructor  
@Builder  
public class User {  
  
  @Id  @GeneratedValue(strategy = GenerationType.SEQUENCE)  
  private Long id;  
  
  private String name;  
  
  
  @OneToMany(mappedBy = "author", fetch = FetchType.EAGER)  
  @Builder.Default  private List<Post> posts = new ArrayList<>();  
  
  public void addPost(final Post post) {  
    post.setAuthor(this);  
    posts.add(post);  
  }  
  
  @OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)  
  @Builder.Default  private List<Item> items = new ArrayList<>();  
  
  public void addItem(final Item item) {  
    item.setOwner(this);  
    items.add(item);  
  }  
  
  @OneToOne(mappedBy = "user")  
  private Profile profile;  
  
  @OneToOne(mappedBy = "user")  
  private Profile2 profile2;  
  
}

실험해보는 것이 빠르다.

Profile, Profile2를 만들어서 User의 연관 관계로 등록하자.

OneToOne에 대해서 설명하지는 않았지만, OneToMany에서 FK가 반드시 Many 쪽에 있어야 하던 것과 다르게

OneToOne은 원하는 곳에 FK를 줄 수 있다.

FK를 갖지 않는 Entity는 mappedBy 옵션을 주면 된다.

위 코드에서는 Profile, Profile2가 FK를 갖고, User는 FK가 없는 상태다.

// UserTest.java
@Test  
void test() {  
  for (int i = 0; i < 10; i++) {  
    User user = User.builder()  
        .name("user" + i)  
        .build();  
    em.persist(user);  
  
    Post post = Post.builder()  
        .title("post" + i)  
        .content("content" + i)  
        .build();  
    Item item = Item.builder()  
        .itemName("item" + i)  
        .build();  
    em.persist(post);  
    em.persist(item);  
  
    user.addPost(post);  
    user.addItem(item);  
  }  
  
  em.flush();  
  em.clear();  
  
	em.createQuery(  
        "select u from User u join fetch u.posts join fetch u.profile join fetch u.profile2",  
        User.class)  
    .getResultList(); 
}

em.createQuery 부분만 바꿔주면 된다.

Hibernate: 
    select
        u1_0.id,
        u1_0.name,
        p1_0."author_id",
        p1_0.id,
        p1_0.content,
        p1_0.title,
        p2_0.id,
        p3_0.id 
    from
        "user" u1_0 
    join
        post p1_0 
            on u1_0.id=p1_0."author_id" 
    join
        profile p2_0 
            on u1_0.id=p2_0."user_id" 
    join
        profile2 p3_0 
            on u1_0.id=p3_0."user_id"

FETCH JOIN이 여러 연관 관계에 대해 잘 적용되는 것을 볼 수 있다.

이제 문제가 발생하는 상황을 정확히 인지했을 것이니, 상대편이 N인 관계에 대해서만 2개 이상이 되지 않도록 주의해서 FETCH JOIN을 사용하면 되겠다.

그러면 의문이 하나 생길 것이다.

'만약 N인 연관 관계가 너무 많은 경우, 추가 SQL이 나가는 것을 방관해야 하나' 라는.

물론 방법이 있다.

N + 1 문제를 해결하는 두 번째 방법은 다른 장에서 자세히 알아보도록 하겠다.

0개의 댓글