[JPA] 실전 활용법 총정리

초보개발자·2024년 1월 14일

JPA

목록 보기
11/11
post-thumbnail

⭐엔티티 설계시 주의점

1. 엔티티에는 가급적 Setter를 사용하지 말자

  • Setter가 모두 열려있다
  • 변경 포인트가 너무 많아서, 유지보수가 어렵다
  • 나중에 리펙토링으로 Setter 제거

2. 모든 연관관계는 지연로딩으로 설정

  • 즉시로딩( EAGER )은 예측이 어렵고, 어떤 SQL이 실행될지 추적하기 어렵다. 특히 JPQL을 실행할 때 N+1 문제가 자주 발생한다
  • 실무에서 모든 연관관계는 지연로딩( LAZY )으로 설정해야 한다
  • 연관된 엔티티를 함께 DB에서 조회해야 하면, fetch join 또는 엔티티 그래프 기능을 사용한다
  • @XToOne(OneToOne, ManyToOne) 관계는 기본이 즉시로딩이므로 직접 지연로딩으로 설정해야 한다\

3. 컬렉션은 필드에서 초기화 하자

  • 컬렉션은 필드에서 바로 초기화 하는 것이 안전하다
  • null 문제에서 안전하다
  • 하이버네이트는 엔티티를 영속화 할 때, 컬랙션을 감싸서 하이버네이트가 제공하는 내장 컬렉션으로 변경한다
  • 만약 getOrders() 처럼 임의의 메서드에서 컬력션을 잘못 생성하면 하이버네이트 내부 메커니즘에 문제가 발생할 수 있다
  • 따라서 필드레벨에서 생성하는 것이 가장 안전하고, 코드도 간결하다
Member member = new Member();
System.out.println(member.getOrders().getClass());
em.persist(member);
System.out.println(member.getOrders().getClass());
//출력 결과
class java.util.ArrayList
class org.hibernate.collection.internal.PersistentBag

4. 테이블, 컬럼명 생성 전략

  • 스프링 부트에서 하이버네이트 기본 매핑 전략을 변경해서 실제 테이블 필드명은 다름

스프링 부트 신규 설정 (엔티티(필드) 테이블(컬럼))

  1. 카멜 케이스 언더스코어(memberPoint member_point)
  2. .(점) _(언더스코어)
  3. 대문자 소문자

적용 2 단계

  1. 논리명 생성: 명시적으로 컬럼, 테이블명을 직접 적지 않으면 ImplicitNamingStrategy 사용
    spring.jpa.hibernate.naming.implicit-strategy : 테이블이나, 컬럼명을 명시하지 않을 때 논리명 적용
  2. 물리명 적용:
    spring.jpa.hibernate.naming.physical-strategy : 모든 논리명에 적용됨, 실제 테이블에 적용
    (username usernm 등으로 회사 룰로 바꿀 수 있음)

📑계층 별 필요 기술

1. 엔티티 개발

  • 엔티티에만 종속된 비즈니스 로직이라면 엔티티에 작성하자
  • 양방향 연관관계일 경우 생성시 주의
@Entity
@Table(name = "orders")
@Getter
@Setter
public class Order {
    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member; //주문 회원
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();
    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery; //배송정보
    private LocalDateTime orderDate; //주문시간
    @Enumerated(EnumType.STRING)
    private OrderStatus status; //주문상태 [ORDER, CANCEL]

    //==연관관계 메서드==//
    public void setMember(Member member) {
        this.member = member;
        member.getOrders().add(this);
    }

    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
        orderItem.setOrder(this);
    }

    public void setDelivery(Delivery delivery) {
        this.delivery = delivery;
        delivery.setOrder(this);
    }

    //==생성 메서드==//
    public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) {
        Order order = new Order();
        order.setMember(member);
        order.setDelivery(delivery);
        for (OrderItem orderItem : orderItems) {
            order.addOrderItem(orderItem);
        }
        order.setStatus(OrderStatus.ORDER);
        order.setOrderDate(LocalDateTime.now());
        return order;
    }
    //==비즈니스 로직==//

    /**
     * 주문 취소
     */
    public void cancel() {
        if (delivery.getStatus() == DeliveryStatus.COMP) {
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }
        this.setStatus(OrderStatus.CANCEL);
        for (OrderItem orderItem : orderItems) {
            orderItem.cancel();
        }
    }
    //==조회 로직==//

    /**
     * 전체 주문 가격 조회
     */
    public int getTotalPrice() {
        int totalPrice = 0;
        for (OrderItem orderItem : orderItems) {
            totalPrice += orderItem.getTotalPrice();
        }
        return totalPrice;
    }
}

2. 리포지토리 개발

  • @Repository : 스프링 빈으로 등록, JPA 예외를 스프링 기반 예외로 예외 변환
  • @PersistenceContext : 엔티티 메니저( EntityManager ) 주입
  • @PersistenceUnit : 엔티티 메니터 팩토리( EntityManagerFactory ) 주입

3. 서비스 개발

  • @Service
  • @Transactional : 트랜잭션, 영속성 컨텍스트
    - readOnly=true : 데이터의 변경이 없는 읽기 전용 메서드에 사용, 영속성 컨텍스트를 플러시 하지 않으므로 약간의 성능 향상(읽기 전용에는 다 적용)
    - 데이터베이스 드라이버가 지원하면 DB에서 성능 향상
  • @Autowired
    - 생성자 Injection 많이 사용, 생성자가 하나면 생략 가능

실무에서는 검증 로직이 있어도 멀티 쓰레드 상황을 고려해서 회원 테이블의 회원명 컬럼에 유니크 제약 조
건을 추가하는 것이 안전하다

4. 기능 테스트

  • @SpringBootTest : 스프링 부트 띄우고 테스트(이게 없으면 @Autowired 다 실패)
  • @Transactional : 반복 가능한 테스트 지원, 각각의 테스트를 실행할 때마다 트랜잭션을 시작하고 테스트가 끝나면 트랜잭션을 강제로 롤백 (이 어노테이션이 테스트 케이스에서 사용될 때만 롤백)

Given, When, Then
http://martinfowler.com/bliki/GivenWhenThen.html

예외를 터트리는 테스트

@Test(expected = NotEnoughStockException.class)
public void 상품주문_재고수량초과() throws Exception {
    //Given
    Member member = createMember();
    Item item = createBook("시골 JPA", 10000, 10); //이름, 가격, 재고
    int orderCount = 11; //재고보다 많은 수량
    //When
    orderService.order(member.getId(), item.getId(), orderCount);
    //Then
    fail("재고 수량 부족 예외가 발생해야 한다.");
}

5. 예외 만들기

public class NotEnoughStockException extends RuntimeException {
    public NotEnoughStockException() {
    }

    public NotEnoughStockException(String message) {
        super(message);
    }

    public NotEnoughStockException(String message, Throwable cause) {
        super(message, cause);
    }

    public NotEnoughStockException(Throwable cause) {
        super(cause);
    }
}

☀️변경 감지와 병합

1. 준영속 엔티티?

  • 영속성 컨텍스트가 더는 관리하지 않는 엔티티를 말한다
  • 예를 들면, requestBody를 통해 임의로 새로 생성한 Book 객체(단, 기존 ID 포함) -> 준영속 엔티티
  • Book 객체는 이미 DB에 한번 저장되어서 식별자가 존재한다
  • 이렇게 임의로 만들어낸 엔티티도 기존 식별자를 가지고 있으면 준영속 엔티티로 볼 수 있다

2. 변경 감지 기능

@Transactional
void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티
	Item findItem = em.find(Item.class, itemParam.getId()); //같은 엔티티를 조회한다.
	findItem.setPrice(itemParam.getPrice()); //데이터를 수정한다.
}
  • 트랜잭션 안에서 엔티티를 다시 조회, 변경할 값 선택 트랜잭션 커밋 시점에 변경 감지(Dirty Checking)이 동작해서 데이터베이스에 UPDATE SQL 실행

3. 병합 사용

  • 병합은 준영속 상태의 엔티티를 영속 상태로 변경할 때 사용하는 기능이다
@Transactional
void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티
	Item mergeItem = em.merge(itemParam);
}

병합 동작 방식

  1. merge() 를 실행한다
  2. 파라미터로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 엔티티를 조회한다
    2-1. 만약 1차 캐시에 엔티티가 없으면 데이터베이스에서 엔티티를 조회하고, 1차 캐시에 저장한다
  3. 조회한 영속 엔티티( mergeMember )에 member 엔티티의 값을 채워 넣는다. (member 엔티티의 모든 값을
    mergeMember에 밀어 넣는다
    .
    이때 mergeMember의 “회원1”이라는 이름이 “회원명변경”으로 바뀐다.)
  4. 영속 상태인 mergeMember를 반환한다

주의

변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있지만, 병합을 사용하면 모든 속성이 변경된
다. 병합시 값이 없으면 null 로 업데이트 할 위험도 있다. (병합은 모든 필드를 교체한다) 실무에서는 보통 업데이트 기능이 매우 제한적이다. 그런데 병합은 모든 필드를 변경해버리고, 데이터가 없으면 null 로 업데이트 해버린다. 병합을 사용하면서 이 문제를 해결하려면, 변경 폼 화면에서 모든 데이터를 항상 유지해야 한다. 실무에서는 보통 변경가능한 데이터만 노출하기 때문에, 병합을 사용하는 것이 오히려 번거롭다


💻다양한 쿼리 방식

1. 엔티티를 DTO로 변환

@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
    List<Order> orders = orderRepository.findAllByString(new OrderSearch());
    List<OrderDto> result = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(toList());
    return result;
}

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

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

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

  • 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다
  • 일다대에서 일(1)을 기준으로 페이징을 하는 것이 목적이다
  • 그런데 데이터는 다(N)를 기준으로 row가 생성된다

한계 돌파

  • 먼저 ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인 한다
  • ToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다
  • 컬렉션은 지연 로딩으로 조회한다
  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000
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;
}

스프링 부트 3.1 - 하이버네이트 6.2 변경사항

하이버네이트 6.2 부터는 where in 대신에 array_contains 를 사용한다

where item.item_id in(?,?,?,?)
where array_contains(?,item.item_id)

where in 쿼리는 동적으로 데이터가 변하는 것을 넘어서 SQL 구문 자체가 변해버리는 문제가 발생한다. SQL 입장에서는 ? 로 바인딩 되는 숫자 자체가 다르기 때문에 완전히 다른 SQL이다. 이렇게 되면 성능 관점에서 좋지않다. array_contains 은 왼쪽에 배열을 넣는데, 배열에 들어있는 숫자가 오른쪽(item_id)에 있다면 참이된다. 이 문법은 ?에 바인딩 되는 것이 딱1개 이다.. 배열1개가 들어가는 것이다. 따라서 배열에 들어가는 데이터가 늘어도 SQL 구문 자체가 변하지 않는다.

4. JPA에서 DTO로 바로 조회

5. 쿼리 방식 선택 방법

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

🔒OSIV와 성능 최적화

  • Open Session In View: 하이버네이트
  • Open EntityManager In View: JPA

1. OSIV ON

  • spring.jpa.open-in-view : true (기본값)
  • OSIV 전략은 트랜잭션 시작처럼 최초 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 때 까지 영속성 컨텍스트와 데이터베이스 커넥션을 유지한다
  • 그래서 View Template이나 API 컨트롤러에서 지연 로딩이 가능했던 것
  • 지연 로딩은 영속성 컨텍스트가 살아있어야 가능하고, 영속성 컨텍스트는 기본적으로 데이터베이스 커넥션을 유지한다(매우 큰 장점)
  • 그런데 이 전략은 너무 오랜시간동안 데이터베이스 커넥션 리소스를 사용하기 때문에, 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 모자랄 수 있다. 그리고 이것은 장애로 이어진다.

2. OSIV OFF

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

3. 커멘드와 쿼리 분리

  • OrderService
    - OrderService: 핵심 비즈니스 로직
    - OrderQueryService: 화면이나 API에 맞춘 서비스 (주로 읽기 전용 트랜잭션 사용)
  • 고객 서비스의 실시간 API는 OSIV를 끄고, ADMIN 처럼 커넥션을 많이 사용하지 않는 곳에서는 OSIV를 켠다

Reference

[인프런] 실전! 스프링 부트와 JPA 활용 1&2 - 김영한

profile
꾸준히 빠르게

0개의 댓글