Spring/JPA

JPA 활용 2 - API 개발과 성능 최적화

ummchicken 2022. 12. 19. 23:23

출처 : 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화

섹션 1. API 개발 기본

 

1 - 1. 회원 등록 API

  • @Controller + @ResponseBody == @ RestController (스프링 MVC 강의에 나옴)
  • @RequestBody : JSON으로 온 Body를 Member에 그대로 매핑해서 쫙 넣어준다.
    즉, JSON data를 Member로 쫙 바로 바꿔준다. (MVC에 관련된 내용)

 

🚨 API를 만들 때는 엔티티를 파라미터로 받지 말아라 & 엔티티를 외부에 노출해서도 안 됨.
즉, API는 항상 요청이 들어오거나 나가는 건 전부 다 엔티티를 사용하지 않고,
DTO(객체)를 사용해서 등록이랑 응답을 받는 걸 권장함.

// 예시 코드 : Member 엔티티 대신 별도의 DTO(CreateMemberRequest)를 받음 (API의 정석) 

@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);
}

@Data
static class CreateMemberRequest {
    private String name;
}

 

  • Postman으로 보내기
    POST : http://localhost:8080/api/v2/members
    {
      "name": "hello"
    }


1 - 2. 회원 수정 API

  • 등록이랑 수정은 API가 다르다.
  • 별도의 Reponse를 가져간다.
  • DTO는 그냥 대충 데이터만 왔다갔다하는 것이기 때문에, 크게 로직이 있는 게 아니다.
  • 변경감지 써라

 

@PutMapping("/api/v2/members/{id}")
public UpdateMemberResponses updateMemberV2(@PathVariable("id") Long id, @RequestBody @Valid UpdateMemberRequest request) {

    memberService.update(id,request.getName());
    Member findMember = memberService.findOne(id);
    return new UpdateMemberResponses(findMember.getId(), findMember.getName());
}

@Data
static class UpdateMemberRequest {
    private String name;
}

@Data
@AllArgsConstructor
static class UpdateMemberResponses {
    private Long id;
    private String name;
}

 

  • Postman으로 보내기
    PUT : http://localhost:8080/api/v2/members/1
    {
      "name": "new-hello"
    }


1 - 3. 회원 조회 API

  • ddl-auto: none
@GetMapping("/api/v2/members")
public Result memberV2() {
    List<Member> findMembers = memberService.findMembers();
    List<MemberDto> collect = findMembers.stream()
            .map(m -> new MemberDto(m.getName()))
            .collect(Collectors.toList());

    return new Result(collect);
}

@Data
@AllArgsConstructor
static class Result<T> {
    private T data;
}

@Data
@AllArgsConstructor
static class MemberDto {
    private String name;
}

 

  • Postman으로 보내기
GET : http://localhost:8080/api/v2/members

 

  • 결과
{ "data": [ { "name": "member1" }, { "name": "member2" } ] }

 

 

1 - 4. 결론

API를 만들 땐,
엔티티를 외부에 직접 반환하지 말고,
중간에 API 스펙에 맞는 DTO를 만들고 활용해라!





 

섹션 2. API 개발 고급 - 준비

주제 : 조회에 대한 API를 어떻게 성능 최적화를 할까?
Ex) N + 1 문제 같은...
  • ddl-auto: create

 

 


 

⭐섹션 3. API 개발 고급-지연 로딩과 조회 성능 최적화⭐

주문 + 배송정보 + 회원을 조회하는 API를 만들자.
지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결해보자.



3 - 1. 간단한 주문 조회 V1: 엔티티를 직접 노출

엔티티 그대로 반환하지 말아라 (외부 노출 X)
  • 지연로딩 : DB에서 안 끌고 옴
  • 프록시 기술 : JPA 기본편에 자세히 설명되어 있음

 

🚨 주의
엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은
꼭! 한곳을 @JsonIgnore 처리 해야 한다.
안그러면 양쪽을 서로 호출하면서 무한 루프가 걸린다.
※ 참고
앞에서 계속 강조했듯이
정말 간단한 애플리케이션이 아니면 엔티티를 API 응답으로 외부로 노출하는 것은 좋지 않다.
따라서 Hibernate5Module를 사용하기 보다는
DTO로 변환해서 반환하는 것이 더 좋은 방법이다.
🚨 주의
지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안된다!
즉시 로딩 때문에 연관관계가 필요 없는 경우에도
데이터를 항상 조회해서 성능 문제가 발생할 수 있다.


즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 진다.
항상 지연 로딩을 기본으로 하고,
성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해라! (V3에서 설명)



3 - 2. 간단한 주문 조회 V2: 엔티티를 DTO로 변환

@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
    List<Order> orders = orderRepository.findAll(new OrderSearch());

    // ORDER(N) 2개라면, 2번 루프가 돈다.
    // N + 1 -> 1(첫 번째 쿼리 : ORDERS 가져옴) + 회원 N + 배송 N
    // 이 예제의 경우라면, 1 + 2 + 2 == 5, 총 5개의 쿼리가 실행됨.
    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 초기화
    }
}

→ 🚨 Lazy 로딩으로 인한 데이터베이스 쿼리가 너무 많이 호출됨!

  • ORDER -> SQL 1번 -> 결과 주문수 2개
  • ORDER가 2번이니, 총 5개의 쿼리가 호출됨.
    1. ORDER
    2. MEMBER
    3. DELIVERY
    4. MEMBER
    5. DELIVERY
● 엔티티를 DTO로 변환하는 일반적인 방법이다.
● 쿼리가 총 1 + N + N번 실행된다. (v1과 쿼리수 결과는 같다.)
        ● order 조회 1번(order 조회 결과 수가 N이 된다.)
        ● order -> member 지연 로딩 조회 N 번
        ● order -> delivery 지연 로딩 조회 N 번
        ● 예) order의 결과가 4개면 최악의 경우 1 + 4 + 4번 실행된다.(최악의 경우)
                ● 지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.

 

 

3 - 3. 간단한 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화

// OrderSimpleController.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;
}

 

// 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();
}

 

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

→ 엔티티를 페치 조인(fetch join)을 사용해서 쿼리 1번에 조회
→ 페치 조인으로 order -> member , order -> delivery 는

이미 조회 된 상태 이므로 지연로딩 X

 

→ 연관된 걸 다 끌고오기 때문에 다시 한 번 더 최적화 해야함


3 - 4. 간단한 주문 조회 V4: JPA에서 DTO로 바로 조회

※ 3 - 3번까지는 엔티티를 조회한 다음에 DTO로 중간에 변환

 

select
    order0_.order_id as col_0_0_,
    member1_.name as col_1_0_,
    order0_.order_date as col_2_0_,
    order0_.status as col_3_0_,
    delivery2_.city as col_4_0_,
    delivery2_.street as col_4_1_,
    delivery2_.zipcode as col_4_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 해줌
→ V3보다는 성능 최적화에서 좀 더 낫다. (하지만 성능 차이가 많이 나진 않음)

  • 일반적인 SQL을 사용할 때 처럼 원하는 값을 선택해서 조회
  • new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환
  • SELECT 절에서 원하는 데이터를 직접 선택하므로 DB -> 애플리케이션 네트웍 용량 최적화 (생각보다 미비)
  • 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점

3 - 5. 정리

엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다.
둘중 상황에 따라서 더 나은 방법을 선택하면 된다.
엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다.
따라서 권장하는 방법은 다음과 같다.

 

쿼리 방식 선택 권장 순서

  1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
  2. 필요하면 페치 조인으로 성능을 최적화 한다. -> 대부분의 성능 이슈가 해결된다.
  3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
  4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.

 

섹션 4. API 개발 고급 - 컬렉션 조회 최적화

● 주문내역에서 추가로 주문한 상품 정보를 추가로 조회하자.
Order 기준으로 컬렉션인 OrderItem 와 Item 이 필요하다.
● 앞의 예제에서는 toOne(OneToOne, ManyToOne) 관계만 있었다.
이번에는 컬렉션인 일대다 관계(OneToMany)를 조회하고, 최적화하는 방법을 알아보자.

 

4 - 1. 주문 조회 V1: 엔티티 직접 노출

엔티티를 직접 노출하므로 좋은 방법은 아니다.

 

4 - 2. 주문 조회 V2: 엔티티를 DTO로 변환

  • OrderItem 조차도 DTO로 변환해서 해야 함.
    껍데기만 DTO로 하라는 게 아님.
orderItems = order.getOrderItems().stream()
        .map(orderItem -> new OrderItemDto(orderItem))
        .collect(toList());
@Data
static class OrderItemDto {
  private String itemName;//상품 명
  private int orderPrice; //주문 가격
  private int count; //주문 수량

  public OrderItemDto(OrderItem orderItem) {
      itemName = orderItem.getItem().getName();
      orderPrice = orderItem.getOrderPrice();
      count = orderItem.getCount();
  }
}

 

 

 

  • 지연 로딩으로 너무 많은 SQL 실행
  • SQL 실행 수
    • order 1번
    • member , address N번(order 조회 수 만큼)
    • orderItem N번(order 조회 수 만큼)
    • item N번(orderItem 조회 수 만큼)

4 - 3. 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화

  • 페치 조인으로 SQL이 1번만 실행됨
  • distinct 를 사용한 이유는 1대다 조인이 있으므로 데이터베이스 row가 증가한다.
    그 결과 같은 order엔티티의 조회 수도 증가하게 된다.
    JPA의 distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면,
    애플리케이션에서 중복을 걸러준다.
    이 예에서 order가 컬렉션 페치 조인 때문에 중복 조회 되는 것을 막아준다.
  • 단점
    • 페이징 불가능

 

🚨 메모리에서 페이징 처리는 매우 위험
🚨 컬렉션 둘 이상에 페치 조인을 사용하면 안된다.



4 - 4. 주문 조회 V3.1: 엔티티를 DTO로 변환 - 페이징과 한계 돌파

페이징과 한계 돌파

  • 컬렉션을 페치 조인하면 페이징이 불가능하다.
  • 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다.
    최악의 경우 장애로 이어질 수 있다.

한계 돌파

💡 그러면 페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?

  • 먼저 ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인 한다.
    ToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
  • 컬렉션은 지연 로딩으로 조회한다.
  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다.
    • hibernate.default_batch_fetch_size: 글로벌 설정
    • @BatchSize: 개별 최적화
    • 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.

 


 

⭐ 섹션 5. API 개발 고급 - 실무 필수 최적화

⭐ 잘 알고 넘어가야 함!

 

OSIV와 성능 최적화

  • Open Session In View: 하이버네이트
  • Open EntityManager In View: JPA
    (관례상 모두 OSIV라 한다.)
OSVI는 영속성 컨텍스트를 뷰까지 열어둔다는 뜻이다.
영속성 컨텍스트가 살아있으면 엔티티는 영속 상태로 유지된다.
따라서 뷰에서도 지연로딩을 사용할 수 있다.



OSIV ON

OSIV의 핵심 : 뷰에서도 지연 로딩이 가능하도록 하는 것

  • spring.jpa.open-in-view : true 기본값
    WARN 4496 --- [  restartedMain] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning



영속성 컨텍스트랑 데이터베이스 커넥션은 굉장히 밀접하게 매칭이 되어있다.

  • 그럼 언제 JPA가 데이터베이스 커넥션을 획득하는가?
    → 기본적으론, 데이터베이스 트랜잭션을 시작할 때 JPA 영속성 컨텍스트가 데이터베이스 커넥션을 가져온다.
    → Service 계층에서 트랜잭션(@Transactional)을 시작하는 시점에 DB를 가져온다.

 

  • 그럼 언제 DB에 돌려줘야할까?
    → OSIV가 켜져있으면(ON), @Transactional 끝나도, 커넥션을 반환하지 않는다.

 

  • OSIV는 트랜잭션이 끝나도 영속성 컨텍스트를 끝까지 살려둠
    → API 경우, API가 유저에 반환이 될 때까지
    → 화면인 경우엔, View 템플릿이 렌더링 등을 할때까지

 

  • 즉, 요청이 들어와서 응답이 나갈 때까지 끝까지 살아있음.
    → 그래서 지금까지 View Template이나 API 컨트롤러에서 지연 로딩이 가능했던 것



🚨 장점

  • 엔티티를 적극 활용해서 LAZY 로딩 같은 기술을 컨트롤러와 뷰에서 적응 활용할 수 있음
    → 중복도 줄이고, 투명하게 레이지 로딩을 끝까지 해나갈 수 있다.
    → 코드의 유지보수성을 높일 수 있음.



🚨 단점

  • 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 모자랄 수 있음
    → 너무 오랜시간동안 데이터베이스 커넥션 리소스를 사용하기 때문
    → 결국 장애로 이어진다.

 

일반적인 애플리케이션에서는 데이터베이스 트랜잭션이 끝나면,
커넥션 반환해버리면 됨.
근데 이것은 데이터베이스 커넥션을 끝까지 물고있다가
고객에서 response 주는 타이밍에서 커넥션을 반환함.



OSIV OFF

장점 : 데이터베이스의 커넥션을 굉장이 짧게 유지함

  • spring.jpa.open-in-view: false OSIV 종료
    → OSIV를 끄면 트랜잭션을 종료할 때 영속성 컨텍스트를 닫고, 데이터베이스 커넥션도 반환함
    → 따라서 커넥션 리소스를 낭비하지 않음
  • OSIV를 끄면 모든 지연로딩을 트랜잭션 안에서 처리해야 한다.
    → 따라서 지금까지 작성한 많은 지연 로딩 코드를 트랜잭션 안으로 넣어야 하는 단점
    → 그리고 view template에서 지연로딩이 동작하지 않음
    → 결론적으로 트랜잭션이 끝나기 전에 지연 로딩을 강제로 호출해 두어야 한다.



💡 이런 문제를 해결하려면?
→ 프리젠테이션 계층에서 엔티티를 수정하지 못하게 막으면 됨!

  • 엔티티를 읽기 전용 인터페이스로 제공
    → 엔티티를 직접 노출하는 대신에,
    읽기 전용 메소드만 제공하는 인터페이스를 프리젠테이션 계층에 제공함.
  • 엔티티 레핑
  • DTO만 반환

 

커멘드와 쿼리 분리

실무에서 OSIV를 끈 상태로 복잡성을 관리하는 좋은 방법이 있다.
→ 바로 Command와 Query를 분리하는 것이다.

 

예시

  • OrderService
    • OrderService: 핵심 비즈니스 로직
    • OrderQueryService: 화면이나 API에 맞춘 서비스 (주로 읽기 전용 트랜잭션 사용)

 

→ 보통 서비스 계층에서 트랜잭션을 유지한다.
두 서비스 모두 트랜잭션을 유지하면서 지연 로딩을 사용할 수 있다.