왜?
JPA 실전편 2편에서 계속 업그레이드하자..
JPA 지식이 부족하다
회원 등록 API
- Entity와 DTO를 나누자..당연 (의존성 없애야함. 확장성도 필요)
- 당연히 Entity를 파라미터로 받아도안된다.
회원 수정 API
- controller 단
1 | "/api/v2/members/{id}") ( |
- memberService 단
1 |
|
쇼핑몰 도메인 모델과 테이블 설계
![스크린샷 2023-11-28 오전 12.02.38](/Users/lostcatbox/Library/Application Support/typora-user-images/스크린샷 2023-11-28 오전 12.02.38.png)
- @JsonIgnore 옵션을 한곳에 주어야 한다.
- 주의: 지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안된다! 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 진다. -> 항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해라!(V3에서 설명)
사전 DB 초기화
- 참고로, 생성자로 생성후 각 빈에 등록된 서비스들의 메서드를 호출해준다.
1 |
|
ManyToOne 조회
V1
- entity 직접반환
- 장점: 간단.
- 단점: 직접 반환 매우위험, 유지보수 안좋음.
V2
- DTO로 반환
1 |
|
N+1 문제발생
엔티티를 DTO로 변환하는 일반적인 방법이다.
쿼리가 총 1 + N + N번 실행된다. (v1과 쿼리수 결과는 같다.)
order 조회 1번(order 조회 결과 수가 N이 된다.) order -> member 지연 로딩 조회 N 번
order -> delivery 지연 로딩 조회 N 번
예) order의 결과가 4개면 최악의 경우 1 + 4 + 4번 실행된다.(최악의 경우)
지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.
V3
- fetchJoin()으로 N+1문제 해결 한방쿼리
- 단점: join을 한번밖에못함.
V4
- JPA -> entity -> DTO 가 아닌
- JPA -> DTO 로 바로 가져오기
- 장점: SELECT 절에서 원하는 데이터를 직접 선택하므로 DB 애플리케이션 네트웍 용량 최적화(생각보다 미비)
- 단점: 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가
1 |
|
쿼리 방식 선택 권장 순서
- 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
- 필요하면 페치 조인으로 성능을 최적화 한다. 대부분의 성능 이슈가 해결된다.
- 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
- 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.
OneToMany, OneToOne 최적화
- 주문내역에서 추가로 주문한 상품 정보를 추가로 조회하자
- Order 기준으로 컬렉션인 OrderItem 와 Item 이 필요하다.
앞의 예제에서는 toOne(OneToOne, ManyToOne) 관계만 있었다. 이번에는 컬렉션인 일대다 관계(OneToMany)를 조회하고, 최적화하는 방법을 알아보자.
V1
- entity 직접 노출
V2
entity -> DTO 로 변경
- 당연하게도 DTO안에 속성도 모두 DTO로 바꿔야함
역시 페치 조인이 아니라서 1+N 이슈있음
V3
- fetchJoin 사용
- 1:N fetchJoin == 컬렉션 페치 조인
- 단점: 데이터 뻥튀기 1:N 기준이면 N개의 레코드 조회됨
- distinct 사용하면 중복데이터 제거해줌 -> jpa 영속성 기준으로도 중복 제거해줌
- 단점2: 페이징 불가능
- 컬렉션 페치 조인을 사용하면 페이징이 불가능하다. 하이버네이트는 경고 로그를 남기면서 모든 데이 터를 DB에서 읽어오고, 메모리에서 페이징 해버린다(매우 위험하다). 자세한 내용은 자바 ORM 표준 JPA 프로그래밍의 페치 조인 부분을 참고하자.(outOfMemony)
- 컬렉션 페치 조인은 1개만 사용할 수 있다. 컬렉션 둘 이상에 페치 조인을 사용하면 안된다. 데이터가 부정합하게 조회될 수 있다. 자세한 내용은 자바 ORM 표준 JPA 프로그래밍을 참고하자.
1 | public List<Order> findAllWithItem() { |
V3.1
한계 돌파 (페이징 문제 해결)
1 | public List<Order> findAllWithMemberDelivery(int offset, int limit) { |
1 | "/api/v3.1/orders") ( |
1:N 컬렉션 fetchJoin을 할때는 무조건 데이터가 뻥튀기 되기 떄문에 결국, ToOne 구조들만 fetchJoin을 쓰고 OneToMany는 lazy loading을 해야한다
lazy loading을 한다면 1+N 이슈가 존재하므로 다음 batchSize 설정으로 In쿼리를 통해 한방에 쿼리를 내보낼수있다.
지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다.이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
- hibernate.default_batch_fetch_size: 글로벌 설정
- @BatchSize: 개별 최적화
장점
- 쿼리호출수가1+N, 1+1로최적화된다.
- 조인보다 DB 데이터 전송량이 최적화 된다. (Order와 OrderItem을 조인하면 Order가 OrderItem 만큼 중복해서 조회된다. 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.)
- 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.
- 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.
결론
- ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다.
따라서 ToOne 관계는 페치조인으로 쿼리 수를 줄이고 해결하고,
나머지는 hibernate.default_batch_fetch_size 로 최적화 하자. - 항상 트레이드 오프를 생각하자.
- fetchJoin : 1+N+M+O -> 1개의 쿼리 생성
- lazyLoading + batchSize (In 쿼리 사용) : 1+N+M+O -> 1+1+1+1 쿼리 생성
- ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다.
참고: default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를 선택 하는 것을 권장한다. 이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으 로 제한하기도 한다. 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다. 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부 하를 어디까지 견딜 수 있는지로 결정하면 된다.
V4
- 아래와 같이 ToOne을 먼저조회, 그후 OneToMany를 나중에 조회하는것은 결국 1+N 문제를 다시 야기함.
(batchSize로 문제 해결 가능) - OrderItemQueryDto : 응용불가능, 쿼리전용 dto
- 한번에 List
<OrderItemQueryDto>
를 가져오자 결국은 1+N 문제를 다시 야기함.
- 한번에 List
1 |
|
1 |
|
1 | /** |
1 | /** |
1 | /** |
V5
- 1+N 쿼리를 -> 1+1 쿼리로 구조적변경
- 메모리를 사용하고, in쿼리를 사용해서 orderItem + item 정보를 모두가져온다.
1 | /** |
1 |
|
1 | private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) { |
V6
1+1 쿼리말고, 1번의 쿼리로 변경해보자
OrderFlatDto : 형태로 바로 쿼리로 가져올꺼임
- 해당방법은 결국 쿼리후에는 데이터 뻥튀기가 되어있으므로 쿼리후 dto를 다시 정재해줘야함
장점:
- Query 1번
단점:
- 쿼리는 한번이지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되 므로 상황에 따라 V5 보다 더 느릴 수 도 있다.
- 애플리케이션에서 추가 작업이 크다.
- 페이징 불가능
1 |
|
1 | public List<OrderFlatDto> findAllByDto_flat() { |
1 | "/api/v6/orders") ( |
정리
최적화 순서
- 엔티티조회 방식으로 우선접근
- 페치조인으로 쿼리 수를 최적화
- 컬렉션 최적화
- 페이징 필요 hibernate.default_batch_fetch_size , @BatchSize 로 최적화
- 페이징 필요X 페치 조인 사용
- 엔티티조회 방식으로 해결이 안되면 DTO조회 방식 사용
- DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate
Version별 각 장단점
- DTO로 조회하는 방법도 각각 장단이 있다. V4, V5, V6에서 단순하게 쿼리가 1번 실행된다고 V6이 항상 좋은 방법인 것은 아니다.
- V4는코드가단순하다. 특정주문한건만조회하면이방식을사용해도성능이잘나온다.예를들어서조회 한 Order 데이터가 1건이면 OrderItem을 찾기 위한 쿼리도 1번만 실행하면 된다.
- (실무 가장 선호) V5는 코드가 복잡하다. 여러 주문을 한꺼번에 조회하는 경우에는 V4 대신에 이것을 최적화한 V5 방식을 사 용해야 한다. 예를 들어서 조회한 Order 데이터가 1000건인데, V4 방식을 그대로 사용하면, 쿼리가 총 1 + 1000번 실행된다. 여기서 1은 Order 를 조회한 쿼리고, 1000은 조회된 Order의 row 수다. V5 방식 으로 최적화 하면 쿼리가 총 1 + 1번만 실행된다. 상황에 따라 다르겠지만 운영 환경에서 100배 이상의 성 능 차이가 날 수 있다.
- V6는 완전히 다른 접근방식이다. 쿼리 한번으로 최적화 되어서 상당히 좋아보이지만, Order를 기준으로 페 이징이 불가능하다. 실무에서는 이정도 데이터면 수백이나, 수천건 단위로 페이징 처리가 꼭 필요하므로, 이 경우 선택하기 어려운 방법이다. 그리고 데이터가 많으면 중복 전송이 증가해서 V5와 비교해서 성능 차이도 미비하다.
API 개발 고급 - 실무 필수 최적화
OSIV와 성능 최적화
- Open Session In View: 하이버네이트
- Open EntityManager In View: JPA
- 이를 OSIV라고함
OSIV ON(유지보수성 높음, 성능낮음, 실시간성X)
![image-20231218220257075](/Users/lostcatbox/Library/Application Support/typora-user-images/image-20231218220257075.png)
- DB connection을 언제 돌려주냐가 중요
- jpa는 spring.jpa.open-in-view: true 기본값
- jpa에서는 lazy loading등등을 제공하므로, 세션이 끝날때까지 (받은 요청에 대한 응답이 종료될때까지) DB connection을 물고있다. 당연히 Service계층의 @Transaction이 끝나도 물고있다.
- OSIV를 끄면 모든 지연로딩을 트랜잭션 안에서 처리해야 한다. 따라서 지금까지 작성한 많은 지연 로딩 코드를 트랜잭션 안으로 넣어야 하는 단점이 있다. 그리고 view template에서 지연로딩이 동작하지 않는다. 결론적으로 트랜잭션이 끝나기 전에 지연 로딩을 강제로 호출해 두어야 한다.
- 하지만 이 전략은 너무 오랜시간동안 DB connection 리소스를 사용하기때문에, connection이 모자랄수있고, 장애로 이어진다.
OSIV OFF(유지보수성 낮음, 성능높음, 실시간성O)
- DB connection을 @Transactional(트랜잭션) 종료까지만 가지고있음.
커넥션 리소스 낭비 없음. 종료 후 lazy loading은 지원안됨. - DB connection을 connection pool 에 반환
- 따라서 해당 설정으로는 lazy loading은 영속성 컨텍스트 생존 범위에서 써야하고, fetchJoin을 적극 활용해야한다.
- 단점: service계층안으로 lazy loading한것들을 처리해야함..
커멘드와 쿼리 분리
실무에서 OSIV를 끈 상태로 복잡성을 관리하는 좋은 방법이 있다. 바로 Command와 Query를 분리하는 것이다.
- 보통 비즈니스 로직은 특정 엔티티 몇 개를 등록하거나 수정하는 것이므로 성능이 크게 문제가 되지 않는다.
- 그런데 복잡한 화면을 출력하기 위한 쿼리(조회쿼리)는 화면에 맞추어 성능을 최적화 하는 것이 중요하다. 하지만 그 복잡성에 비해 핵심 비즈니스에 큰 영향을 주는 것은 아니다.
크고 복잡한 애플리케이션을 개발한다면, 이 둘의 관심사를 명확하게 분리하는 선택은 유지보수 관점에서 충분히 의미 있다.
단순하게 설명해서 다음처럼 분리하는 것이다.
1 | OrderService |
보통 서비스 계층에서 트랜잭션을 유지한다. 두 서비스 모두 트랜잭션을 유지하면서 지연 로딩을 사용할 수있다.
참고: 필자는 고객 서비스의 실시간 API는 OSIV를 끄고, ADMIN 처럼 커넥션을 많이 사용하지 않는 곳에서는 OSIV를 켠다.
참고: OSIV에 관해 더 깊이 알고 싶으면 자바 ORM 표준 JPA 프로그래밍 13장 웹 애플리케이션과 영속성관리를 참고하자.