Search

[강의 요약] 김영한 - 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화

Last update: @5/15/2023
주의
본 포스팅은 인프런 강의를 통해 학습한 내용을 임의로 요약한 것으로 일부 내용의 오류 및 누락, 링크 숨김 등이 존재합니다.
HTTP 요청에 대해 HTML이 아닌 JSON 형태로 응답한 후 클라이언트 사이드에서 HTML을 직접 렌더링 하는 것을 보통 REST API 방식이라고 부르고, 편의상 API라고 함.

API JSON 요청

HTTP 요청 시 HTTP 메시지 바디에 JSON 형태로 요청 데이터를 전달함
이 JSON 형태의 요청 데이터는 아래처럼 @RequestBody를 통해 받을 수 있음
엔티티를 직접 받기보다 DTO를 이용하는 것이 좋음(사실상 필수)
@PostMapping("/api/v2/members") public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request) { Member member = new Member(); member.setName(request.getName()); Long id = memberService.join(member); return new CreateMemberResponse(id); }
Java
복사
@ModelAttribute처럼 @Valid로 자바 빈 검증기 사용 가능

API JSON 응답

클래스 레벨에 @RestController 부착
@RestController@Controller + @ResponseBody
객체를 return하면 객체 → JSON 변환 라이브러리인 Jackson을 통해 JSON이 HTTP 응답으로 나감

조회

컬렉션을 직접 반환하는 것은 JSON Array를 직접 반환하게 되기 때문에 추후에 컬렉션 이외의 데이터를 추가할 때 유연성이 떨어짐
따라서 보통 Result 클래스로 한 번 감싸서 응답함
역시 DTO를 통해 응답하는 것이 좋음
@RestController @RequiredArgsConstructor public class MemberApiController { private final MemberService memberService; @GetMapping("/api/v2/members") public Result getMembersV2() { List<MemberDto> collect = memberService.findMembers() .stream() .map(m -> new MemberDto(m.getName())) .collect(Collectors.toList()); return new Result(collect.size(), collect); } @Data @AllArgsConstructor static class Result<T> { private int count; private T data; } @Data @AllArgsConstructor static class MemberDto { private String name; } }
Java
복사

지연로딩과 무한루프 및 프록시 (참고한 질문과 답변)

@Entity @Table(name = "orders") @Getter @Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Order { ... @OneToOne(fetch = LAZY, cascade = CascadeType.ALL) @JoinColumn(name = "delivery_id") private Delivery delivery; ... }
Java
복사
위처럼 지연로딩을 설정하게 되면 Order 데이터를 가져올 때 delivery 필드에 Delivery 클래스를 상속받은 프록시 객체가 들어옴
이 프록시 객체에는 아래와 같은 필드들이 존재하게 됨
1.
id (기존 Delivery 필드)
2.
order (기존 Delivery 필드)
3.
address (기존 Delivery 필드)
4.
hibernateLazyInitializer (프록시 관련 필드)
Jackson에서 Delivery 객체를 JSON으로 변환할 때 2번 Order 객체를 다시 객체로 변환하기 위해 order에 들어가면 다시 delivery 필드를 마주침
위와 같은 원리로 무한루프에 빠져서 Memory가 터지고 에러가 남
무한루프를 막기 위해 Delivery 객체의 Order 필드에 @JsonIgnore를 붙여줘 Delivery의 order 필드는 Jackson이 JSON으로 만들지 않도록 처리함
@Entity @Getter @Setter public class Delivery { ... @JsonIgnore @OneToOne(mappedBy = "delivery", fetch = LAZY) private Order order; ... }
Java
복사
이렇게 되면 무한 루프는 해결됨. 하지만 Order 객체를 JSON으로 만들 때 4. hibernateLazyInitializer 필드를 만날 때 JSON으로 변환할 수 없기 때문에 오류가 남
따라서 지연 로딩 설정된 것을 강제 로딩되도록 Hibernate5Module을 import 후 FORCE_LAZY_LOADING 설정을 해주고 무한 루프를 막기 위해 역참조 필드에 @JsonIgnore을 붙여주면 문제가 해결됨
사실상 쓸 일이 거의 없으니 코드는 생략

(x-to-one) 엔티티 조회 vs DTO 직접 조회(프로젝션)

JPA에서 지연 로딩으로 설정하면 조회 한 번에 쿼리가 N + N + … 개씩 더 나가는 N+1 문제가 터짐
이를 최적화 하기 위해 엔티티를 fetch join 후 DTO로 매핑하거나 DTO를 직접 프로젝션하여 사용하는 방법 두 가지가 있음
Entity를 통해 fetch join 후 DTO로 매핑하는 방법
@GetMapping("/api/v3/simple-orders") public List<SimpleOrderDto> ordersV3() { List<Order> orders = orderRepository.findAllWithMemberDelivery(); return orders.stream() .map(SimpleOrderDto::new) .collect(Collectors.toList()); } @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(); orderDate = order.getOrderDate(); orderStatus = order.getStatus(); address = order.getDelivery().getAddress(); } }
Java
복사
controller
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(); }
Java
복사
repository
재사용성이 높고 fetch join으로 간단하게 성능 최적화 가능
DTO를 프로젝션해서 직접 조회하는 방법
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(); }
Java
복사
repository
필요한 데이터만 가져올 수 있고 x-to-one 연관관계를 가져올 때 최적화 가능
하지만 코드가 매우 복잡해지고 재사용성이 낮음
두 방법 모두 여의치 않으면 Native SQL이나 JDBC Template 사용

(x-to-many) 컬렉션 엔티티 조회 vs DTO 직접 조회(프로젝션)

x대다(x-to-many) 관계에서 컬렉션을 가져오는 경우도 N+1 문제가 발생함
이를 해결하기 위해 fetch join 후 JPQL에 distinct 사용하게 된다면 모든 조회 데이터를 메모리에 올리기 때문에 매우 위험하고, 따라서 페이징이 불가능함
public List<Order> findAllWithItem() { return em.createQuery( "select distinct o from Order o" + " join fetch o.member m" + " join fetch o.delivery d" + " join fetch o.orderItems oi" + " join fetch oi.item i", Order.class) .getResultList(); }
Java
복사
위 문제를 해결하기 위해 hibernate.default_batch_fetch_size 또는 @BatchSize를 통해 배치 사이즈를 설정
spring: jpa: properties: hibernate: default_batch_fetch_size: 100
YAML
복사
application.yml
개별로 설정하려면 @BatchSize 를 적용하면 됨 (컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스) - 하지만 보통 글로벌 설정으로 많이 씀
보통 100~500정도 주고, 보통 DB에서 WHERE IN문의 한계가 1000이기 때문에 1000을 max로 보면 됨
위 설정 후 x-to-one은 fetch join을 통해 가져오고, x-to-many 컬렉션은 로딩 지연 후 for문을 통해 지연로딩함
@GetMapping("/api/v3/orders") public List<OrderDto> ordersV3() { List<Order> orders = orderRepository.findAllWithItem(); List<OrderDto> result = orders.stream() .map(o -> new OrderDto(o)) .collect(toList()); return result; }
Java
복사
controller
public List<Order> findAllWithItem() { return em.createQuery( "select distinct o from Order o" + " join fetch o.member m" + " join fetch o.delivery d" + " join fetch o.orderItems oi" + " join fetch oi.item i", Order.class) .getResultList(); }
Java
복사
repository
이렇게 하면 for문을 돌 때 매 loop마다 쿼리가 나가는 게 아니라 WHERE IN 쿼리에 넣을 ID를 BatchSize만큼 모을 때마다 쿼리를 날림(또는 for문이 종료될 때)
DTO로 직접 조회를 할 수 있는데, DTO는 엔티티가 아니기 때문에 BatchSize가 적용이 안 됨
따라서 아래처럼 개발자가 직접 WHERE IN에 들어갈 엔티티 id값을 모아서 쿼리로 날리거나
public List<OrderQueryDto> findAllByDto_optimization() { //루트 조회(toOne 코드를 모두 한번에 조회) List<OrderQueryDto> result = findOrders(); List<Long> orderIds = result.stream() .map(o -> o.getOrderId()) .collect(Collectors.toList()); //orderItem 컬렉션을 MAP 한방에 조회 List<OrderItemQueryDto> orderItems = em.createQuery( "select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(" + " oi.order.id," + " i.name," + " oi.orderPrice," + " oi.count" + ")" + " from OrderItem oi" + " join oi.item i" + " where oi.order.id in :orderIds" , OrderItemQueryDto.class).setParameter("orderIds", orderIds).getResultList(); Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream() .collect(Collectors.groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId())); //루프를 돌면서 컬렉션 추가(추가 쿼리 실행X) result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId()))); return result; }
Java
복사
직접 필요한 테이블을 모조리 join한 무식한 쿼리를 메모리로 퍼올려 수동 페이징을 해주는 방법이 있음
@GetMapping("/api/v6/orders") public List<OrderQueryDto> ordersV6() { List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat(); return flats.stream() .collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()), mapping(o -> new OrderItemQueryDto(o.getOrderId(), o.getItemName(), o.getOrderPrice(), o.getCount()), toList()) )).entrySet().stream() .map(e -> new OrderQueryDto(e.getKey().getOrderId(), e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(), e.getKey().getAddress(), e.getValue())) .collect(toList()); }
Java
복사
물론 메모리 문제로 페이징이 불가능하고, 쿼리는 한번만 가지만 중복 데이터를 퍼올리기 때문에 오히려 이전 방법보다 조회 성능이 느릴 수 있음
따라서 조회는 엔티티를 통해 하고, DTO로 매핑해주는 쪽이 일반적으로 더 좋다고 볼 수 있음

최적화 요약

아래 우선순위로 최적화 시도
1.
엔티티 조회 방식 사용
a.
feth join으로 쿼리 수 최적화
b.
컬렉션 최적화
i.
페이징이 필요할 경우 - BatchSize 설정으로 최적화
ii.
페이징이 필요 없을 경우 - fetch join 사용
2.
DTO 조회 방식 사용
3.
NativeSQL 또는 JdbcTemplate 사용

OSIV (Open Session In View)

영속성 컨텍스트는 @Transactional이 붙은 메서드, 즉 트랜잭션이 시작될 때 라이프사이클이 시작됨
일반적으로 Service 계층에서 트랜잭션을 시작함
영속성 컨텍스트가 종료되는 시점은 spring.jpa.open-in-view 설정이 true(기본값)일 때, 컨트롤러가 return될 때까지임
즉, in-view까지 DB session이 살아있다고 해서 Open Session In View
따라서 트랜잭션이 종료되더라도 컨트롤러단에서 Lazy Loading이 가능함
만약 spring.jpa.open-in-view 설정을 false로 바꾸면 트랜잭션이 끝나면서 영속성 컨텍스트도 사라짐
즉, 컨트롤러에서 호출한 서비스 계층 메서드가 종료되면 영속성 컨텍스트도 끝나버림
이럴 경우 controller단에서는 엔티티의 Lazy Loading이 불가능해지는 문제가 발생함
만약 OSIV 옵션을 켜둔 채로 외부 API 호출처럼 응답시간이 긴 작업을 처리하면 얼마 지나지 않아 커넥션풀이 고갈되는 치명적인 문제가 생김
따라서 프로젝트가 모듈화 되었다는 가정 하에 고객 서비스와 같은 실시간 API는 OSIV를 끄고, ADMIN 처럼 커넥션을 많이 사용하지 않는 곳에서는 OSIV를 켜는 등의 적절한 운용이 필요함