실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 - 인프런 | 강의
스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., 본 강의는 자바 백엔드 개발의 실전 코스에 있는 활용2 강의 입니다. 스프링 부트와 J
www.inflearn.com
강의를 들으며 생각 정리 + "자바 ORM 표준 JPA 프로그래밍" 책 참고
주문 + 배송 + 회원 정보를 조회하는 API를 만들자. 지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결해보자.
+) 참고 : 지금부터의 내용은 정말 중요하다! 실무에서 JPA를 사용하려면 100% 이해해야 한다.
간단한 주문 조회 V1: 엔티티를 직접 노출
</api/OrderSimpleApiController.java>
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v1/simple-orders")
public List<Order> orderV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
return all;
}
}
<1. 단순 엔티티 직접 노출 예제>
단순히 주문 정보를 조회하는 api이다.
Order 엔티티는 Member, Delivery를 xxxToOne의 형태로 연관하고 있고. 양방향 연관관계인 상황이다.
<결과>
무한 루프를 돌게 된다.
이유 : Order 엔티티를 조회하면 Member 엔티티 역시 조회하는데 Member 엔티티 역시 Order를 연관하고 있기 때문에 무한루프에 빠지는 것이다.
<2. @JsonIgnore>
이처럼 엔티티를 직접 노출할 때, 양방향 연관관계가 걸린 곳은 한 곳을 @JsonIgnore 처리 해야 한다.
-> Order 엔티티와 연관관계를 맺고 있는 Member, OrderItem, Delivery 엔티티에서 Order 엔티티를 연관하고 있는 필드를 전부 @JsonIgnore 처리 해준다.
<결과>
500 error
이유 : Order -> Member, Order -> Delivery는 지연 로딩이다. 따라서 Order를 조회할 때, 실제 엔티티 대신에 Member, Delivery의 프록시 객체를 조회하게 된다.
jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모른다 -> 예외 발생
+) 1번 상황에서도 @JsonIgnore가 없긴 하지만, 이 역시 지연 로딩 관계인데 왜 예외가 발생하지 않고 무한루프에 빠지는 걸까?
->일단 둘다 API 응답시에 발생하는 오류다. 그러나 jackson 라이브러리에서 이슈별로 예외 처리를 조금씩 다르게 하기 때문이다. 어차피 뒤에서 엔티티를 직접 노출하지 않고 DTO를 사용할 것이기 때문에 지연 로딩으로 인한 오류가 발생한다 정도로 알고 넘어가자.
<3. Hibernate5Module>
Hibernate5Module을 스프링 빈으로 등록함으로써 프록시 객체를 json으로 생성하는 문제를 해결할 수 있다.
다음 라이브러리를 추가하자.
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'
Hibernate5Module을 스프링 빈으로 등록한다.
<JpashopApplication.java>
@SpringBootApplication
public class JpashopApplication {
public static void main(String[] args) {
SpringApplication.run(JpashopApplication.class, args);
}
@Bean
Hibernate5Module hibernate5Module() {
Hibernate5Module hibernate5Module = new Hibernate5Module();
return hibernate5Module;
}
}
Hibernate5Module은 기본적으로 초기화된 프록시 객체만 노출한다. 초기화 되지 않은 프록시 객체는 노출하지 않는다.
따라서 강제로 프록시를 초기화 해준다.
</api/OrderSimpleApiController.java - 내용 추가>
@GetMapping("/api/v1/simple-orders")
public List<Order> orderV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName(); //Lazy 강제 초기화
order.getDelivery().getAddress(); //Lazy 강제 초기화
}
return all;
}
기존 메서드에서 반복문을 추가했다.
프록시 객체는 getName(), getAddress()처럼 실제로 사용될 때 실제 객체로 초기화된다. 따라서 반복문을 통해 강제로 초기화 시켜준다.
<결과>
[
{
"id": 4,
"member": {
"id": 1,
"name": "userA",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
}
},
"orderItems": null,
"delivery": {
"id": 5,
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"status": null
},
"orderDate": "2021-05-18T18:57:35.767506",
"status": "ORDER",
"totalPrice": 50000
},
...
]
초기화 하지 않은 orderItem이 null로 처리된 것을 제외하고 정상적으로 연관된 엔티티가 잘 찍히는 것을 볼 수 있다.
<4. 강제 지연 로딩>
또 한가지 방법으로는, 3번 처럼 반복문을 사용하지 않고 모든 지연 로딩을 강제로 초기화하는 방법이 있다.
스프링 빈을 다음과 같이 수정한다.
@Bean
Hibernate5Module hibernate5Module() {
Hibernate5Module hibernate5Module = new Hibernate5Module();
hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
return hibernate5Module;
}
강제로 모든 지연로딩 관계인 엔티티를 초기화한다.
<결과>
[
{
"id": 4,
"member": {
"id": 1,
"name": "userA",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
}
},
"orderItems": [
{
"id": 6,
"item": {
"id": 2,
"name": "JPA1 BOOK",
"price": 10000,
"stockQuantity": 99,
"categories": [],
"author": null,
"isbn": null
},
"orderPrice": 10000,
"count": 1,
"totalPrice": 10000
},
{
"id": 7,
"item": {
"id": 3,
"name": "JPA2 BOOK",
"price": 20000,
"stockQuantity": 98,
"categories": [],
"author": null,
"isbn": null
},
"orderPrice": 20000,
"count": 2,
"totalPrice": 40000
}
],
"delivery": {
"id": 5,
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
},
"status": null
},
"orderDate": "2021-05-18T19:05:29.470303",
"status": "ORDER",
"totalPrice": 50000
},
...
]
따로 반복문을 통해 강제 초기화를 하지 않더라도 모든 지연 로딩 관계인 엔티티들이 잘 찍히는 것을 볼 수 있다.
+) 2021.05.19 추가
스프링은 JSON을 만들 때 Jackson 라이브러리를 기본으로 사용한다. 이 라이브러리는 자바의 getXxx() 메서드를 호출해서 get을 떼고 소문자로 만든 후, 필드 값으로 사용한다. (자바 빈 프로퍼티 접근 방식)
-> 따라서 DTO에서는 Getter를 열어줘야 한다.
<정리>
지금까지 지연 로딩으로 인해 연관관계 상황에서 발생할 수 있는 문제들을 해결해 보았다.
하지만 계속 강조했듯이 엔티티를 API 응답으로 외부로 노출하는 것은 좋지 않다. API 스펙상 필요하지 않은 정보들까지 노출할 수 있고, 필요한 정보들의 형태도 API마다 다양하기 때문이다.
따라서 Hibernate5Module을 사용하기 보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법이다.
+) 주의
지연 로딩을 피하기 위해 즉시 로딩으로 설정하면 안된다.
즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다.
특히 JPQL의 경우 N+1 문제가 있기 때문에 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 진다.
-> 항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우 페치 조인을 사용하자 (V3에서 설명)
간단한 주문 조회 V2: 엔티티를 DTO로 변환
</api/OrderSimpleApiController.java - 추가>
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return result;
}
@Data
static class SimpleOrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
public SimpleOrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName(); //LAZY 초기화
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address=order.getDelivery().getAddress(); //LAZY 초기화
}
}
엔티티를 DTO로 변환하는 일반적인 방법이다.
<결과>
[
{
"orderId": 4,
"name": "userA",
"orderDate": "2021-05-18T19:34:05.669935",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipcode": "1111"
}
},
{
"orderId": 11,
"name": "userB",
"orderDate": "2021-05-18T19:34:05.743141",
"orderStatus": "ORDER",
"address": {
"city": "진주",
"street": "2",
"zipcode": "2222"
}
}
]
API에서 필요한 정보만을 전달할 수 있다는 장점이 있다.
단점이 있다면 쿼리 실행 수이다. (N+1문제)
1. order 조회 -> 쿼리 1번 (order 2건 조회)
2. member 조회 -> 지연 로딩 조회 N(2)번
3. delivery 조회 -> 지연 로딩 조회 N(2)번
총 1+2+2 = 5번 실행된다. (물론 동일 id 회원을 조회하는 등, 1차 캐시에서 조회하게 되면 쿼리 수가 감소할 수 있다)
-> 조회되는 order의 수가 증가할수록 발생하는 쿼리 수는 급격히 증가하게 된다.
+) @JsonIgnore는 엔티티를 외부에 API로 노출할 때만 효과가 있다. 즉, DTO를 사용하는 방식에서는 이제 필요 없다.
간단한 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화
</api/OrderSimpleApiController.java - 내용 추가>
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return result;
}
주문 조회 때, 다른 메서드를 사용했다.
</repository/OrderRepository.java - 내용 추가>
public List<Order> findAllWithMemberDelivery() {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class
).getResultList();
}
기존 Order 엔티티만을 조회하고 Member, Delivery 엔티티는 프록시 객체로 조회하는 경우와 다르게,
Order, Member, Delivery를 페치 조인을 사용해서 쿼리 1번에 조회할 수 있다.
페치 조인으로 Member, Delivery 역시 이미 조회된 상태이기 때문에, DTO에 데이터가 저장될 때, 추가 쿼리가 나가지 않는다.
+) 페치 조인 참고
https://gksdudrb922.tistory.com/58
객체지향 쿼리 언어2 - 중급 문법
자바 ORM 표준 JPA 프로그래밍 - 기본편 스프링 핵심 원리 - 기본편 - 인프런 스프링 입문자가 예제를 만들어가면서 스프링의 핵심 원리를 이해하고, 스프링 기본기를 확실히 다질 수 있습니다. 초
gksdudrb922.tistory.com
+) 페치 조인 vs 지연 로딩
연관된 엔티티들을 조회해야 하는 상황에서,
1. 페치 조인을 사용하면 쿼리를 한 번에 보낼 수 있기 때문에 성능 측면에서 좋다. 그러나 API 형식이 변화해서 추가로 페치 조인을 해야 하는 상황이면 메서드를 수정하는 등 유연성이 떨어질 수 있다는 단점이 있다.
2. 지연 로딩은 쿼리를 여러번 보내기 때문에 성능 측면에서 좋지 않지만, API 형식이 변화해 추가로 다른 엔티티를 조회해야 하는 상황에서도 쿼리를 한 번 더 보내면 되기 때문에 유연성이 높다.
이런 트레이드 오프를 잘 고려해서 상황에 맞게 조회 방식을 선택해야 한다.
+) 페치 조인 - 별칭
JPA는 기본적으로 페치 조인 대상에게 별칭을 줄 수 없다.
페치 조인 대상에게 별칭을 부여하지 않는 이유는 페치 조인의 대상은 on, where 등에서 필터링 조건으로 사용하면 안되기 때문이다. 예를 들어 컬렉션을 페치 조인하는 경우 컬렉션 전체를 가져올 수 없는 문제가 발생할 수 있다.
Team - Member가 일대다 관계일 때,
Select t from Team t join fetch t.members m where m.name=:memberName
-> 이런 경우 한 team에 속한 members 전체를 불러오지 못할 수 있다.
(페치 조인은 기본적으로 연관된 것을 모두 끌고 온다는 개념이다)
결론 : 페치 조인의 대상은 on, where 등에서 필터링 조건으로 사용하면 안된다.
그런데 리포지토리 코드를 보면 페치 조인 대상에게 별칭을 부여해도 정상 작동하는 것을 볼 수 있다.
사실 JPA의 구현체인 하이버네이트에서는 별칭을 지원한다.
그럼 언제 별칭을 사용해도 좋을까? -> 페치 조인의 결과와 DB에서의 데이터의 일관성이 문제가 없으면 사용해도 좋다.
다음 예시를 보자.
Select m from Member m join fetch m.team t where t.name=:teamName
이처럼 컬렉션이 아닌 team 하나만 페치 조인하는 경우, 이 쿼리는 페치 조인 결과 조회된 회원은 DB와 동일한 일관성을 유지한 팀의 결과를 가지고 있다.
그러니까 페치 조인의 결과와, member.getTeam()의 결과가 같기 때문에 일관성을 해치지 않는다는 것이다.
결론 : 일관성을 해치지 않는 범위에서 성능 최적화를 위해 페치 조인 대상에 별칭을 사용해도 좋다.
물론 페치 조인의 대상이 아니면 on, where 조건에 별칭을 사용해도 된다.
Select m from Member m join fetch m.team t where m.name=:memberName
+) 페치 조인과 일반 조인의 차이
페치 조인이 아닌 그냥 join을 써도 되지 않을까?
-> 실제 sql 쿼리를 비교하면 왜 페치 조인을 써야 하는지 쉽게 알 수 있다.
<페치 조인>
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.delivery_id as delivery1_2_2_,
order0_.delivery_id as delivery4_6_0_,
order0_.member_id as member_i5_6_0_,
order0_.order_date as order_da2_6_0_,
order0_.status as status3_6_0_,
member1_.city as city2_4_1_,
member1_.street as street3_4_1_,
member1_.zipcode as zipcode4_4_1_,
member1_.name as name5_4_1_,
delivery2_.city as city2_2_2_,
delivery2_.street as street3_2_2_,
delivery2_.zipcode as zipcode4_2_2_,
delivery2_.status as status5_2_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
조인한 테이블의 모든 필드를 전부 조회한다.
<조인>
select
order0_.order_id as order_id1_6_,
order0_.delivery_id as delivery4_6_,
order0_.member_id as member_i5_6_,
order0_.order_date as order_da2_6_,
order0_.status as status3_6_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
Order 엔티티의 필드만 조회한다 -> member.getName()과 같은 메서드 호출 시 추가 쿼리 나간다.(지연 로딩)
간단한 주문 조회 V4: JPA에서 DTO로 바로 조회
지금까지 엔티티를 조회해서 DTO로 변환하는 방식을 사용했다. 이번에는 바로 DTO로 조회하는 방법을 알아보자.
</api/OrderSimpleApiController.java - 내용 추가>
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderRepository.findOrderDtos();
}
- findOrderDtos() : DTO 조회용 메서드
- OrderSimpleQueryDto : v2에서 만든 DTO(SimpleOrderDto)가 현재 컨트롤러 계층의 이너 클래스로 있다.
이를 그대로 사용하면 리포지토리 메서드에서 컨트롤러 계층을 의존하게 되는 상황이 발생하기 때문에 이를 방지하고자 같은 내용의 새로운 DTO를 리포지토리 계층에 만든다.
</repository/OrderSimpleQueryDto.java>
@Data
public class OrderSimpleQueryDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
public OrderSimpleQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
}
}
생성자가 엔티티가 아닌 필드 하나하나를 입력 받도록 수정했는데, 그 이유는 리포지토리 메서드를 보면 알 수 있다.
리포지토리에 새로운 DTO 조회용 메서드를 추가한다.(findOrderDtos())
</repository/OrderRepository.java>
public List<OrderSimpleQueryDto> findOrderDtos() {
return em.createQuery(
"select new jpabook.jpashop.repository.OrderSimpleQueryDto(o.id,m.name,o.orderDate,o.status,d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderSimpleQueryDto.class)
.getResultList();
}
select 절에 엔티티가 아닌 DTO를 조회함으로써 JPA에서 DTO로 바로 조회가 가능하다.
+) OrderSimpleQueryDto(o)처럼 엔티티 전체를 생성자로 받지 않고 저렇게 필드 하나하나 받는 이유는 JPQL에서 엔티티는 식별자(PK)로 취급하기 때문에 OrderSimpleQueryDto(o)는 실제로 OrderSimpleQueryDto(o.id)가 되기 때문이다.
즉, 엔티티 전체를 넘기는 방법이 없기 때문에 저렇게 필드 하나하나를 넘기는 것이다.
+) 페치 조인이 아닌 일반 조인을 사용
페치 조인은 JPA에서 제공하는 조인 방식으로 반드시 엔티티를 조회해야 한다.
findOrderDtos는 DTO를 조회하기 때문에 페치 조인을 사용할 수 없다.
<한계>
이렇게 JPA에서 DTO로 바로 조회하는 방식을 사용하면 DTO에 필요한 데이터들만 조회할 수 있기 때문에 엔티티의 모든 필드들을 조회하는 페치 조인에 비해 좋은 성능을 기대할 수 있다.
그러나 이 방식은 여러 한계를 갖고 있다.
1. 보통 성능 이슈는 '조인'에서 나타나기 때문에 select 절에서 몇 줄 줄어든 것의 성능 향상은 생각보다 미비하다.
2. findOrderDtos()는 정말 특정 DTO 전용 메서드라는 성격을 띄고 있다. 즉, 재사용성이 없고 필드 하나하나를 파라미터로 주입하는 방식은 코드도 지저분하게 한다.
-> 중요한 것은 리포지토리의 성격이다. 리포지토리는 가급적 순수한 엔티티를 다루는 용도인데, findOrderDtos() 메서드로 인해 API에 의존하게 된다.
페치 조인 방식인 V3 정도의 방식까지는 용도가 맞으나 오로지 DTO를 위한 메서드가 리포지토리에 있는 것은 용도상 맞지 않다.
결론 : JPA에서 DTO로 바로 조회하는 메서드는 리포지토리에 있으면 안된다.
이를 해결하기 위해 리포지토리 하위에 DTO 전용 새로운 리포지토리 계층을 만든다.
</repository/order/simple/OrderSimpleQueryRepository.java> (경로에 집중)
@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
private final EntityManager em;
public List<OrderSimpleQueryDto> findOrderDtos() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id,m.name,o.orderDate,o.status,d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderSimpleQueryDto.class)
.getResultList();
}
}
이렇게 새로운 리포지토리에 V4 방식 메서드를 추가한다.
</repository/OrderSimpleQueryDto.java -> /repository/order/simple/OrderSimpleQueryDto.java로 경로 변경>
DTO 리포지토리와 한 패키지에 DTO를 관리하기 위해 경로를 같도록 변경한다.
</api/OrderSimpleApiController.java - 수정>
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderSimpleQueryRepository.findOrderDtos();
}
새로운 DTO용 리포지토리를 통해 메서드를 호출한다.
+) 패키지 경로
이렇게 V4 방식은 DTO용 패키지를 별도로 만들어 분리하는 것이 권장된다. 예제에서는 리포지토리 하위에 패키지를 만들었는데 이것은 패키지의 응집도를 고려하면서 상황에 맞게 패키지를 만들어야 한다.
만약 여러 패키지에서 공유해야 하는 상황이면 최상위 폴더에 별도의 DTO 패키지를 만드는 것이 좋다.
그러나 예제처럼 리포지토리 계층에서만 사용되는 DTO의 경우 리포지토리 하위에 패키지를 만드는 것이 좋다.
<정리>
엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다. 둘중 상황에 따라서 더 나은 방법을 선택하면 된다. 엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다. DTO로 조회하면 약간의 성능 향상을 기대할 수 있다.
권장하는 방법은 다음과 같다.
쿼리 방식 선택 권장 순서
- 우선 엔티티를 DTO로 변환하는 방법을 선택한다.(V2)
- 필요하면 페치 조인으로 성능을 최적화한다.(V3) -> 대부분의 성능 이슈가 해결된다.
- 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.(V4)
- 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template를 사용해서 SQL을 직접 사용한다.
'java > jpa' 카테고리의 다른 글
[JPA] API 개발 고급 - 실무 필수 최적화 (1) | 2021.05.21 |
---|---|
[JPA] API 개발 고급 - 컬렉션 조회 최적화 (0) | 2021.05.19 |
[JPA] API 개발 고급 - 준비 (0) | 2021.05.18 |
[JPA] API 개발 기본 (0) | 2021.05.17 |
[JPA] 웹 계층 개발 (0) | 2021.04.20 |