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.class, 8); 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.class, 8); 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 |
'프로그래밍' 카테고리의 다른 글
16. [JPA] Bulk Insert (0) | 2017.12.14 |
---|---|
15. [JPA] Spring Data JPA (0) | 2017.12.14 |
20. [JPA] lombok 사용 시 주의사항 (0) | 2017.11.28 |
19. [JPA] 트랜잭션 테스트 (0) | 2017.11.27 |
17. [JPA] N+1 문제 (@BatchSize 해결 방안) (0) | 2017.11.23 |
2. Spring Cloud를 이용한 MSA 구축하기 - config server (0) | 2017.09.22 |
1. Spring Cloud를 이용한 MSA 구축하기 - spring cloud (0) | 2017.09.21 |
14. [JPA] Lock - 잠금 (0) | 2017.09.20 |