본문 바로가기
프로그래밍

18. [JPA] N+1 문제 (fetch 조인 해결 방안)

by 탁구치는 개발자 2017. 11. 24.

fetch 조인을 통해 N+1 문제 해결 방안에 대해서 알아보도록 하자.

다음과 같은 테이블 관계가 있다.

[테이블 구조]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
create table nplus_order (
    id integer not null,
    primary key (id)
)
    
create table nplus_shipping (
    id integer not null,
    order_id integer not null,
    primary key (id)
)
    
create table nplus_order_product (
    id integer not null,
    order_id integer,
    primary key (id)
)
cs


[엔티티 연관 관계]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Entity
@Table(name = "NPLUS_ORDER")
class Order {
    @Id
    private int id;
 
    @OneToOne(fetch = FetchType.LAZY, mappedBy = "order", cascade = CascadeType.PERSIST, optional = false)
    private Shipping shipping;
 
    @OneToMany(fetch = FetchType.LAZY, mappedBy = "order", cascade = CascadeType.PERSIST)
    private List<OrderProduct> orderProducts = new ArrayList<>();
 
    public void setShipping(Shipping shipping) {
        this.shipping = shipping;
        shipping.setOrder(this);
    }
    
    public void addOrderProducts(OrderProduct orderProduct) {
        orderProducts.add(orderProduct);
    }
}
 
@Entity
@Table(name = "NPLUS_SHIPPING")
class Shipping {
 
    @Id
    private int id;
 
    @OneToOne(fetch = FetchType.LAZY, optional = false)
    private Order order;
}
 
@Entity
@Table(name = "NPLUS_ORDER_PRODUCT")
class OrderProduct {
 
    @Id
    private int id;
 
    @ManyToOne(fetch = FetchType.LAZY)
    private Order order;
 
    public void setOrder(Order order) {
        this.order = order;
        order.addOrderProducts(this);
    }
}
cs


fetch 조인에 대한 테스트를 위해 JPQL을 사용하였다.

먼저 fetch 조인을 적용하지 않은 상태에서 어떻게 N+1 문제가 발생되는지를 확인해 보자.

1
select o from Order o left join o.shipping
cs

위의 JPQL을 실행하게 되면 N+1 문제가 발생한다. 왜 발생하는지는 DB로 전송되는 쿼리를 보면 알 수 있다.

1
2
3
4
5
6
7
select
    order0_.id as id1_36_ 
from
    nplus_order order0_ 
left outer join
    nplus_shipping shipping1_ 
        on order0_.id=shipping1_.order_id
cs


보이는가? left outer join 을 해서 쿼리를 하지만 조회 필드에는 nplus_shipping 에 대한 필드 정보가 없다. 그렇기에 영속성 컨텍스트는 nplus_shipping 엔티티를 초기화하기 위해 DB에 다음의 쿼리문을 여러번 전송하게 된다.

1
2
3
4
5
6
7
select
    shipping0_.id as id1_38_0_,
    shipping0_.order_id as order_id2_38_0_ 
from
    nplus_shipping shipping0_ 
where
    shipping0_.order_id=?
cs


이와 같은 N+1 문제를 해결하기 위한 방법은 fetch 조인을 사용하는 것이다.

fetch의 사전적 의미처럼 뭔가를 가져오기 위한 설정이다.

설정은 left join 뒤에 fetch를 추가만 해주면 된다.

1
select o from Order o left join fetch o.shipping
cs


fetch가 추가된 상태에서 쿼리가 어떻게 전송되는지 확인해 보자.

쿼리 한번에 nplus_order, nplus_shipping 테이블의 필드 데이터를 모두 가져오는 것을 확인할 수 있다. 이와 같이 한번의 쿼리로 두 테이블의 필드 데이터를 가져와야 N+1문제가 발생하지 않는다.

1
2
3
4
5
6
7
8
9
select
    order0_.id as id1_36_0_,
    shipping1_.id as id1_38_1_,
    shipping1_.order_id as order_id2_38_1_ 
from
    nplus_order order0_ 
left outer join
    nplus_shipping shipping1_ 
        on order0_.id=shipping1_.order_id
cs


fetch 사용 시 유의할 점이 한 가지 있다.

위의 테스트는 OneToOne 관계에 대한 테스트였다.

OneToMany 관계인 경우 fetch join을 사용하게 되면 데이터가 중복으로 노출되게 된다.

중복으로 노출된다고 문제라고 판단하면 안된다.

1:N 관계의 테이블에서 join을 하게 되면 당연히 로우수는 증가하기 때문이다.


nplus_order 테이블에 데이터 10 rows

nplus_order_product 테이블에 데이터 5 rows

1
select o from Order o left join fetch o.orderProducts
cs


위의 JPQL을 실행하게 되면 총 50개의 데이터가 리턴된다.

50개의 데이터를 가져와서 출력해도 되는 화면이라면 그냥 사용해도 된다.

만약 nplus_order 의 데이터만을 보여주고 싶다면 distinct 옵션을 사용하면 된다.

1
select distinct o from Order o join fetch o.orderProducts
cs


[결론]

회사에서 어떤 서비스를 개발할 때 대게 테이블만 하나 덩그러니 있는게 아닌 다양한 테이블들이 서로 관계를 형성하고 있을 것이다.

결국 실무에서의 도메인 엔티티들은 서로 연관 관계를 형성하고 있으므로 항상 로그를 확인하며 N+1 문제가 있는지를 확인해 봐야 한다.

만약 N+1 문제가 있다면 쿼리 최적화를 위해 QueryDSL 또는 JQPL을 이용하여 fetch 조인을 해야 할 것이다.


[전체 테스트 코드]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class NPlusOneProblem {    
 
    @Autowired
    private OrderRepository orderRepository;
 
    @PersistenceContext
    private EntityManager em;
   
 
    private void makeTestData() {
 
        int orderProductId = 0;
        for (int i = 0; i < 10; i++) {
            Shipping shipping = new Shipping();
            shipping.setId(i);
 
            Order order = new Order();
            order.setId(i);
            order.setShipping(shipping);
 
            for (int j = 0; j < 5; j++) {
                OrderProduct orderProduct = new OrderProduct();
                orderProduct.setId(orderProductId);
                orderProduct.setOrder(order);
                orderProductId++;
            }
 
            em.persist(order);
        }
 
        em.flush();
        em.clear();
    }
    
    @Test
    @Transactional
    public void OneToOne쿼리() {
        makeTestData();
        orderRepository.findAll();
    }
    
    @Test
    @Transactional
    public void OneToOne쿼리_fetch적용하지않음() {
        makeTestData();
 
        TypedQuery typedQuery = em.createQuery("select o from Order o left join o.shipping", Order.class);
        List<Order> orderList = typedQuery.getResultList();
        assertThat(10, is(orderList.size()));
 
        Order order = em.find(Order.class8);
        assertNotNull(order);
    }
 
    @Test
    @Transactional
    public void OneToOne쿼리_fetch적용() {
        makeTestData();
 
        TypedQuery typedQuery = em.createQuery("select o from Order o left join fetch o.shipping", Order.class);
        List<Order> orderList = typedQuery.getResultList();
        assertThat(10, is(orderList.size()));
 
        Order order = em.find(Order.class8);
        assertNotNull(order);
    }
 
    /**
     * Order 테이블에 데이터 10개
     * OrderProducts 테이블에 데이터 5개
     * 위의 두 개의 테이블을 서로 조인하면 50개가 나온다.
     */
    @Test
    @Transactional
    public void fetch적용시_데이터가_중복노출되는현상() {
        makeTestData();
 
        TypedQuery typedQuery = em.createQuery("select o from Order o left join fetch o.orderProducts", Order.class);
        System.out.println("--------------------------------------------");
        List<Order> orderList = typedQuery.getResultList();
        System.out.println("--------------------------------------------");
 
        assertThat(10, is(not(orderList.size())));
        assertThat(50, is(orderList.size()));
    }
 
    @Test
    @Transactional
    public void fetch적용시_데이터가_중복노출되는현상_distinct로_해결하기() {
        makeTestData();
 
        TypedQuery typedQuery = em.createQuery("select distinct o from Order o join fetch o.orderProducts", Order.class);
        System.out.println("--------------------------------------------");
        List<Order> orderList = typedQuery.getResultList();
        System.out.println("--------------------------------------------");
 
        assertThat(10, is(orderList.size()));
    }
}
 
interface OrderRepository extends JpaRepository<Order, Integer> {
}
 
 
@Getter
@Setter
@NoArgsConstructor
@lombok.ToString
@Entity
@Table(name = "NPLUS_ORDER")
class Order {
    @Id
    private int id;
 
    @OneToOne(fetch = FetchType.LAZY, mappedBy = "order", cascade = CascadeType.PERSIST, optional = false)
    private Shipping shipping;
 
    @OneToMany(fetch = FetchType.LAZY, mappedBy = "order", cascade = CascadeType.PERSIST)
    private List<OrderProduct> orderProducts = new ArrayList<>();
 
    public void setShipping(Shipping shipping) {
        this.shipping = shipping;
        shipping.setOrder(this);
    }
 
    public void addOrderProducts(OrderProduct orderProduct) {
        orderProducts.add(orderProduct);
    }
}
 
 
@Getter
@Setter
@NoArgsConstructor
@lombok.ToString(exclude = "order")
@Entity
@Table(name = "NPLUS_SHIPPING")
class Shipping {
 
    @Id
    private int id;
 
    @OneToOne(fetch = FetchType.LAZY, optional = false)
    private Order order;
}
 
 
@Getter
@Setter
@NoArgsConstructor
@lombok.ToString(exclude = "order")
@Entity
@Table(name = "NPLUS_ORDER_PRODUCT")
class OrderProduct {
 
    @Id
    private int id;
 
    @ManyToOne(fetch = FetchType.LAZY)
    private Order order;
 
    public void setOrder(Order order) {
        this.order = order;
        order.addOrderProducts(this);
    }
}
cs