JPA + Sping boot 활용

개발하는 도비·2023년 5월 4일

JPA

목록 보기
9/13
post-thumbnail

엔티티 클래스 개발

개발 기본 원칙

  • 실무에서는 가급적 Getter는 열어두고, Setter 꼭 필요한 경우에만 사용하는 것을 추천.

  • 이론적으로 Getter, Setter 모두 제공하지 않고, 꼭 필요한 별도의 메서드를 제공하는게 가장 이상적. -> 하지만 데이터 조회할 일이 많으므로 Getter는 열어두는 것 편리.

  • 엔티티의 식별자는 id, 컬럼명은 class_id를 관례로 사용. classId도 사용. -> 중요한 것은 일관되게 사용할 것.

  • 값 타입은 변경 불가능하게 설계

    protect clas(){}
    
    public class (...){...}

연관관계

  • 모든 연관관계는 지연로딩.
    • EAGER(즉시 로딩) 예측과 SQL 추적이 어려움. ->JPQL 1+N 문제가 자주 발생.
    • OneToOne, ManyToOne은 default가 즉시 로딩이므로 직접 LAZY로 설정

컬렉션

  • 필드에서 바로 초기화가 안전
    • null 문제에서 안전
    • 하이버네이트 entity를 영속화할 때 내장 컬렉션으로 변경. -> 임의의 메서드로 컬랙션을 잘못 생성하면 내부 메커님즘에 문제 발생할 수 있음.

변경 감지와 병합(merge)

  • 준영속 entity
    • 영속성 컨텍스트가 더는 관리하지 않는 entity

변경 감지

  • 트랜잭션 commit()시 영속화된 스냅샵과 바뀐 entity정보를 비교해서 업데이트 해주는 기능.
    • 스냅샵 : 최초의 캐시된 정보.
  • 병경 감지 작동 순서
    • 트랜잭션 commit()시 EntityManager에서 flush()를 호출
      • flush : 영속성 컨텍스트 변경 내용 DB에 반영.
    • flush()호출 시 스냅샵과 entity변경 정보 비교
    • 바뀐 부분을 기준으로 업데이트 쿼리 작성
  • JPA 변경 감지를 조건
    • 변경하려는 entity가 영속 상태여야함.
    • 트랜잭션 안에 묶여 있어야함. @Transactional
    • 정상적으로 commit() 되어야함. -> flush()를 위해.

병합

  • 준영속 entity -> 영속 entity로 바꿀 때 사용.
  • 작동 방식(위의 이미지 참조)
    • merge() 실행
    • 파라미터로 넘어온 준영속 entity의 식별자 값으로 1차 캐시 조회
      • 1차 캐시에 없을 경우 DB에서 조회 -> 1차 캐시 저장.
    • 조회한 영속 entity(mergeMember)에 member entity의 값을 채워 넣는다.
    • 영속 상태의 mergeMember 반환
  • 주의
    • 변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있지만, 병합을 사용하면 모든 속성이 변경된다. -> 값이 없으면 null로 업데이트 될 가능성 존재.
    • 병합은 모든 필드를 교체.

엔티티를 변경 실무팁

  • 엔티티를 변경할 때는 항상 변경 감지를 사용.
  • 컨트롤러에서 entity 생성 x
  • 트랜잭션이 있는 서비스 계층에 식별자(id)와 변경할 데이터를 명확하게 전달 -> 파라미터, dto
  • 트랜잭션이 있는 서비스 계층에서 영속 상태의 entity를 조회하고, entity의 데이터를 직접 변경.
  • 트랜잭션 커밋 시점에 변경 감지가 실행

API 개발 방법

엔티티 vs DTO

엔티티를 직접 노출

  • 문제점
    • entity에 프레젠테이션 계층을 위한 로직이 추가.
    • entity에 API 검증을 위한 로직이 추가됨. (@NotEmpty 등등)
    • 실무에서는 회원 entity를 위한 API가 다양하게 만들어지는데, 한 entity에 각각의 API를 위한 모든 요청 요구사항을 담기는 어려움.
    • entity가 변경되면 API 스펙이 변함.
  • 활용 방안
    • API 요청 스펙에 맞추어 별도의 DTO를 파라미터로 받음.
    @PostMapping("/api/v1/members")
      public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member)
      {
          Long id = memberService.join(member);
          return new CreateMemberResponse(id);
      }
      
      @Data
      static class CreateMemberRequest {
          private String name;
      }
      
      @Data
      static class CreateMemberResponse {
      
    			private Long id;
          public CreateMemberResponse(Long id) {
          this.id = id;
      	}
      }

엔티티를 DTO로 변환

  • DTO를 entity 대신에 RequestBody와 매핑.
    • 코드 기준 member -> CreateMemberRequest
  • entity와 프레젠테이션 계층을 위한 로직을 분리할 수 있다.
  • entity와 API 스펙을 명확하게 분리할 수 있다.
  • entity가 변해도 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);
      }

지연 로딩과 조회 성능 최적화

  • ManyToOne, OneToOne 관계 최적화

엔티티를 직접 노출

  • 양방향 관계 문제 발생 -> @JsonIgnore를 사용
    • @JsonIgnore : json의 해당 어노테이션을 데이터는 null로 들어감.
 @GetMapping("/api/v1/simple-orders")
 public List<Order> ordersV1() {
 	List<Order> all = orderRepository.findAllByString(new OrderSearch());
    for (Order order : all) {
    	order.getMember().getName(); //Lazy 강제 초기화
        order.getDelivery().getAddress(); //Lazy 강제 초기환 
	}
	return all; 
}

엔티티를 DTO로 변환

  • 지연로딩으로 쿼리 N번 호출
    • 쿼리가 총 1 + N + N번 실행된다. (엔티티를 직접 노출과 쿼리수 결과는 같다.)
    • order 조회 1번(order 조회 결과 수가 N이 된다.)
    • order -> member 지연 로딩 조회 N 번
    • order -> delivery 지연 로딩 조회 N 번
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
	List<Order> orders = orderRepository.findAll();
    List<SimpleOrderDto> result = orders.stream()
    	.map(o -> new SimpleOrderDto(o))
        .collect(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();
    orderDate = order.getOrderDate();
    orderStatus = order.getStatus();
    address = order.getDelivery().getAddress();
    } 
  }    
}

엔티티를 DTO로 변환 - 페치 조인 최적화

  • fetch join으로 쿼리 1번 호출
  • 페치 조인으로 order -> member , order -> delivery 는 이미 조회 된 상태 이므로 지연로딩X
@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(toList());
  return result;
}

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

JPA에서 DTO로 바로 조회

  • 쿼리1번 호출
  • select 절에서 원하는 데이터만 선택해서 조회
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
      return orderSimpleQueryRepository.findOrderDtos();
}

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

쿼리 방식 선택 권장 순서

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

컬렉션 조회 최적화

  • OneToMany를 조회하고, 최적화하는 방법

엔티티 직접 노출

  • 문제
    • entity가 변하면 API 스펙이 변한다.
    • 트랜잭션 안에서 지연 로딩 필요
    • 양방향 연관관계 문제 - > 양방향 연관관계면 무한 루프에 걸리지 않게 한곳에 @JsonIgnore 를 추가.
@RestController
@RequiredArgsConstructor
public class OrderApiController {
    private final OrderRepository orderRepository;
	@GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order : all) {
			order.getMember().getName(); //Lazy 강제 초기화 	
            order.getDelivery().getAddress(); //Lazy 강제 초기환
		List<OrderItem> orderItems = order.getOrderItems(); orderItems.stream().forEach(o -> o.getItem().getName()); //Lazy 강제 초기화
}
	return all;
    }
}

엔티티를 DTO로 변환

  • 문제점
    • 지연 로딩으로 너무 많은 SQL 실행
    • SQL 실행 수
    • order 1번
    • member , address N번(order 조회 수 만큼)
    • orderItem N번(order 조회 수 만큼)
    • item N번(orderItem 조회 수 만큼)
@GetMapping("/api/v2/orders")
  public List<OrderDto> ordersV2() {
      List<Order> orders = orderRepository.findAll();
      List<OrderDto> result = orders.stream()
              .map(o -> new OrderDto(o))
              .collect(toList());
      return result;
}

@Data
static class OrderDto {
	private Long orderId;
	private String name;
	private LocalDateTime orderDate; //주문시간 
    private OrderStatus orderStatus;
	private Address address;
	private List<OrderItemDto> orderItems;
    public OrderDto(Order order) {
        orderId = order.getId();
        name = order.getMember().getName();
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
		address = order.getDelivery().getAddress();
		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();
  } 
}

엔티티를 DTO로 변환 - 페치 조인 최적화

  • 페치 조인으로 SQL이 1번
  • 문제점
    • 페이징 불가능
@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;
}

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

엔티티를 DTO로 변환 - 페이징과 한계 돌파

  • 컬렉션을 페치 조인하면 페이징이 불가능하다.
    • 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
    • 일다대에서 일(1)을 기준으로 페이징을 하는 것이 목적. 데이터는 다(N)를 기준으로 row가 생성된다.
    • Order를 기준으로 페이징 하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버린다.
  • 한계 돌파
    • 먼저 ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인 한다. ToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
    • 컬렉션은 지연 로딩으로 조회한다.
    • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다.
      • hibernate.default_batch_fetch_size: 글로벌 설정
      • @BatchSize: 개별 최적화
      • 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
	return em.createQuery(
    	 "select o from Order o" +
         " join fetch o.member m" +
         " join fetch o.delivery d", Order.class)
     .setFirstResult(offset)
     .setMaxResults(limit)
	 .getResultList();    
     
@GetMapping("/api/v3.1/orders")
  public List<OrderDto> ordersV3_page(@RequestParam(value = "offset",
  defaultValue = "0") int offset, @RequestParam(value = "limit", defaultValue
  = "100") int limit) {
      List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
      List<OrderDto> result = orders.stream()
              .map(o -> new OrderDto(o))
              .collect(toList());
      return result;
}
     

JPA에서 DTO 직접 조회

  • Query: 루트 1번, 컬렉션 N 번 실행
  • ToOne(N:1, 1:1) 관계들을 먼저 조회하고,
    • ToMany(1:N) 관계는 각각 별도로 처리한다.
    • ToOne 관계는 조인해도 데이터 row 수가 증가하지 않는다.
    • ToMany(1:N) 관계는 조인하면 row 수가 증가한다.
  • row 수가 증가하지 않는 ToOne 관계는 조인으로 최적화 하기 쉬우므로 한번에 조회하고, ToMany 관계는 최적화 하기 어려우므로 findOrderItems() 같은 별도의 메서드로 조회한다.
  • 기억이 안날 경우 강의 노트 참조

JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화

  • Query: 루트 1번, 컬렉션 1번
  • ToOne 관계들을 먼저 조회, 여기서 얻은 식별자orderId로 ToMany 관계인 OrderItem 을 한꺼번에 조회
  • MAP을 사용해서 매칭 성능 향상(O(1))
  • 기억이 안날 경우 강의 노트 참조

JPA에서 DTO로 직접 조회, 플랫 데이터 최적화

Query: 1번

  • 단점
  • 쿼리는 한번이지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 JPA에서 DTO 직접 조회 보다 더 느릴 수도 있다.
  • 애플리케이션에서 추가 작업이 크다.
  • 페이징 불가능

권장 순서

  • entity 조회 방식으로 우선 접근
    • 페치조인으로 쿼리 수를 최적화
    • 컬렉션 최적화
      • 페이징 필요 hibernate.default_batch_fetch_size , @BatchSize 로 최적화
      • 페이징 필요X 페치 조인 사용
  • entity 조회 방식으로 해결이 안되면 DTO 조회방식 사용
  • DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate

실무 필수 최적화

OSIV와 성능 최적화

  • Open Session In View: 하이버네이트
  • Open EntityManager In View: JPA
  • spring.jpa.open-in-view : true 기본값

참조

  • 인프런 : 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발

  • 링크

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

  • 링크

profile
도비의 양말을 찾아서

0개의 댓글