본문 바로가기

Spring/JPA

[JPA] 양방향 매핑 관계 로딩 방법 별로 살펴보기 (N+1 문제는 도대체 언제?)

728x90

개인적으로 너무 헷갈리고, 진심인데 블로그 포스트마다 내용이 다른 것 같기도 하다. 그냥 직접 하면서 최대한 가능한 상황들을 정리해보려고 한다. 더 헷갈리게만 만드는 장황한 설명따윈 없이, 그냥 내가 정리하고 싶기도 해서 특정 CASE 에 대한 결과만 정리하니, 참고하실 분들은 결과만 참고하시면 좋을 것 같다. 

 

 

기본적으로 다음과 같이 클래스들이 있다. 이하로 정리되는 내용은 <Group 클래스 안에서 User List 의 Fetch Type, User 클래스 안에서 Group List 의 Fetch Type, 상황> 의 제목으로 정리된다.

 

 

@Entity
@Getter
@NoArgsConstructor
@Table(name = "groups")
public class Group {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "group", fetch = FetchType.LAZY)
    private List<User> users = new ArrayList<>();

    public Group(String name) {
        this.name = name;
    }
}


@Entity
@Getter
@NoArgsConstructor
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "group_id")
    private Group group;

    public User(String name, Group group) {
        this.name = name;
        this.group = group;
    }
}

 

 

 

1. Group Lazy, User Lazy, Group 단순 조회

 

 

Group group = em.createQuery("SELECT g FROM Group g " +
                "WHERE g.name = :groupName", Group.class)
        .setParameter("groupName", "그룹A")
        .getSingleResult();

 

 

> 단순한 group 조회 쿼리만 발생한다

 

> group 안에 Users 는 PersistenceBag 인 프록시 컬렉션으로 보관한다. 이후 활용은 2번 케이스.

 

 

 

2. Group Lazy, User Lazy, Group 단순 조회 후 group.getUsers() 활용 (활용하지 않으면 쿼리 미발생)

 

 

> 단순한 group 쿼리 발생

 

> 이후 List<User> users 를 활용할 시 select * from user u where u.group_id = {group_id} 발생, List 안에 있는 애들을 한번에 받아온다.

 

 

 

(참고) 1번과 2번 상황에 대하여, getUsers() 의 결과인 PersistenceBag 이 쿼리가 발생하는 활용 범위를 테스트 해본 결과는 다음과 같다 

 

List<User> users = groups.getUsers();
System.out.println("--");
System.out.println("groups.getUsers().size() = " + users.size());
System.out.println("--");
System.out.println("groups.getUsers().get(0) = " + users.get(0));
System.out.println("--");
System.out.println("groups.getUsers().get(0).getName() = " + users.get(0).getName());

 

 

> 결과 size() 를 가져올 때 쿼리가 발생한다. 즉, PersistenceBag 는 정말 아무것도 없고, 사이즈조차 모른다 (생각해보면 당연한 것) 

 

 

 

3. Group Eager, User Lazy, Group 단순 조회

 

 

> Group 조회 할 때  left outer join 으로 모든 User 들을 조회한다 

 

 

 

4. Group Lazy, User Lazy, List Group 을 조회했을 경우 (N+1)  > 둘다 Lazy 를 많이 사용할테니 이 경우가 중요한 경우인 것이다

 

List<Group> groups = em.createQuery("SELECT g FROM Group g " +
                "WHERE g.name = :groupName", Group.class)
        .setParameter("groupName", "그룹A")
        .getResultList();

for (Group group : groups) {
    List<User> users = group.getUsers();
    for (User user : users) {
        System.out.println("user.getName() = " + user.getName());
    }
}

 

 

> 그룹을 여러개 조회했을 경우, User 를 가져와서 사용할 때마다 그 그룹의 유저들을 가져오라는 쿼리가 발생한다. (N+1) 발생

 

> 이 때 default batch size 옵션 걸려있으면 in 절로 모든 그룹들의 id 를 줘서 한번에 가져온다 (이게 그 유명한 해결책)

 

> 이 때 Distinct + Fetch Join 또한 해결책이 될 수 있는 것 (Distinct 안 붙이면 중복됩니다)

 

> 중복이라함은, Group 1개 에 UserA, UserB 가 있으면, 위 쿼리 결과상 Group List 에 두 개가 들어있게 됨

 

> 근데 Distinct + Fetch Join 처럼 컬렉션 Fetch Join 은 페이징 안된다로 이어지는 것 (근데 생각해보면 당연히 안됨)

 

> 근데 이 쯤 됐으면 내가보기엔 로직을 재점검 하는게 좋아보였다. (default batch size 같은 옵션에 의존해서 해결하는건 좋아 보이지 않는다)

 

 

 

4. Group Eager, User Lazy, List Group 을 조회했을 경우 (N+1) 

 

 

List<Group> groups = em.createQuery("SELECT g FROM Group g " +
                "WHERE g.name = :groupName", Group.class)
        .setParameter("groupName", "그룹A")
        .getResultList();

 

 

> 그룹을 활용하기도, 유저를 활용하기도 전에 처음에 보낸 쿼리를 발생시킨 이후, Group 의 List 들을 모두 Fill 하기 위해서 바로 N+1 문제가 발생한다

 

 

 

(Eager 조회는 활용 상황이 크게 중요하진 않다)

5. Group Eager, User Eager, Group 단순 조회 했을 경우

 

> 역시 N+1 이 발생하지 않는다. User Eager 도 이미 Group 이 다 조회되었기 때문에 큰 의미 없다 (쿼리가 나가지 않음)

 

 

 

6. Group Eager, User Eager, List Group 을 조회했을 경우 (N+1) 

 

> 역시  N+1 이 발생한다. User Eager 가 큰 의미 없기 때문에 4번과 동일하게 동작한다

 

 

 

일단 여기까지 정리하면, OneToMany 인 녀석 (Group) 이 EAGER 일 경우, List 로 해당 객체를 조회할 일이 있으면 무조건 N+1 이 발생한다

 

 

OneToMany 인 녀석 (Group) 쪽이 List 로 조회되는 경우가 아니면, N+1 은 발생하지 않는다. Eager Loading 이든 Lazy Loading 이든 발생하지 않는다. Lazy Loading 을 할 때도 Child 들을 당연히 모두 한 쿼리에 가져온다

 

 

 

이젠 유저도 바꿔보자. 우리가 기본적으로 알고 있는건, User 가 LAZY 면  Group 이 프록시로 들어오고, 내부 활용할 떄 조회 쿼리 발생 또한, User 가 Eager 면 Group 에 대한 조회 쿼리가 한 번 더 바로 발생한 다는 것이다. 이 상황 말고 다른 상황들을 테스트 해보겠다. 

 

 

 

7. Group Eager, User Eager, User 단순 조회

 

 

> EAGER 답게 User 조회 쿼리가 발생 후 Group 에 대한 조회 쿼리가 한 번 더 발생하는데, 이 때 특정 Group 에 대한 단순 조회이므로, 3번과 같아진다. (N+1 발생하지 않음)

 

> 여기서 알 수 있는 중요한 점은, Group 이든 User 든, 상황에 큰 상관 없이 자신이 동작하기로 정해진대로만 동작한다.

 

 

 

8. Group Eager, User Eager, List User 조회 (N + 1  발생) 

 

List<User> user = em.createQuery("SELECT u FROM User u " +
                "WHERE u.name = :userName", User.class)
        .setParameter("userName", "유저A")
        .getResultList();

 

 

> 조회된 모든 User 들은 자신의 Eager 동작 답게, 즉시 자신이 속한 Group 쿼리를 각각 날린다 (이 부분이 N+1) 

 

> 이 때, Group 역시 Eager 이므로, left outer join 쿼리를 날린다 (3번 처럼 수행) (두 개의 그룹이라고 in 절로 날라가지 않음)

 

> 이  때 역시 default batch size 를 지정해두면 쿼리는 in 절로 발생해서 N+1 을 방지할 수 있다

 

 

 

이 쯤 해도 될 것 같다. 이 쯤 되면 어느정도 나머지는 유추할 수 있다. 가령, Group 이 Lazy 고 User 가 Eager 일 경우 List User 조회라면, List<User> 를 조회할 때는 Group 쿼리가 즉시 발생해서 N+1 이 발생할 것이다. 더욱이, Group 은 left outer join 쿼리도 아니라 Group 이 자신이 가지고 있는 다른 User 를 조회할 때마다 쿼리를 발생시킬 것이므로, 이 Case 에서 Worst 는 N^2 까지 발생하게 된다.

 

 

List 를 조회하든, 자신이 어떻게 조회되었든 아무 상관 없이, 내가 연관 객체를 Eager 인지, Lazy 인지 만이 중요한 것임을 제대로 알 수 있었다. 기본적으로 단순 조회 시키는건 안전하지만, List 조회가 들어올 때는 항상 유의하는 것이 좋겠다. 

 

 

나중에 또 현업하다가 이해 안됐다 하겠지..

 

 

N+1 관계와, OneToMany 관계시 PersistenceBag 에 대해서도 잘 알 수 있는 테스트들이였다. 앞으로 JPA 활용함에 있어서 N+1 이 발생할 상황은 정확히 알고 있도록 하자. (Lazy / Lazy 인데 Fetch Join 안 넣었으면 발생 가능성 있다는 점을 명심!) 

728x90

'Spring > JPA' 카테고리의 다른 글

[JPA] 연관 객체 불러오기.. Fetch Join 정말 괜찮을까?  (0) 2023.12.03
[JPA] - 나머지 쿼리  (0) 2023.01.04
[JPA] JPQL 쿼리 2 (FETCH JOIN의 등장)  (0) 2023.01.03
[JPA] JPQL 쿼리  (0) 2022.12.29
[JPA] - 값 타입  (0) 2022.12.29