X to One 방식과 마찬가지로 fetch join을 사용하여 LAZY 로딩 없이 하나의 쿼리로 전부 가져올 수 있다.

 

 

    @GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3() {
        List<Order> orders = orderRepository.findAllWithItem();

        // order를 dto로 변환하여 반환
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.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;
        // DTO 안에 엔티티가 있는 것도 잘못된 설계
        // 이조차도 이런식으로 전부 DTO로 바꿔줘야 함

        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(Collectors.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();
        }
    }
    
    .
    .
    .
    
    // OrderRepository 내부
    public List<Order> findAllWithItem() {
        return  em.createQuery(
                // JPQL distinct 기능 두 가지
                // 1. DB에 날리는 쿼리 수준에서 distinct 수행
                // 2. 그래도 걸러지지 않은 것들에 대해서는 o의 참조값을 확인하여 중복 제거
                "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();
    }
    
    
    

 

fetch join을 사용하지 않으면 order, orderItem, item 각각의 개수마다 쿼리가 1개씩 나가게 된다.

 

order를 조회하는 JPQL에서 연관된 모든 엔티티에 fetch join을 걸어주면 한 번의 쿼리만으로 모든 엔티티를 동시에 가져올 수 있다.

 

이때 distinct의 역할이 중요하다.

 

1:N 관계에서 join 쿼리를 수행할경우 row의 개수는 N쪽(여기선 orderItems)으로 맞춰진다. 즉, 1쪽(여기선 order)의 데이터가 중복해서 들어가게 된다.

다시말해서 똑같은 o가 여러 개가 나오는 것.

 

이러한 중복을 막기 위해 distinct를 사욯안다.

 

distinct는 두 가지 기능을 수행한다.

1. 일반적인 쿼리문의 distinct과 똑같은 기능

-> 하지만 위의 경우에서는 order만 같을 뿐 item 등의 정보는 모두 다르기 때문에 distinct에서 걸러지지 않는다 ( distinct는 row의 모든 column이 같을 때만 중복으로 판단 )

 

2. 똑같은 참조값을 가진 객체 중복 제거

-> 여기서 o의 중복이 제거된다.

 

 

 

 

이러한 방식은 성능을 줄여주는 데 큰 도움이 되지만 페이징 기능을 사용할 수 없다는 단점이 있다.

(원인에 대해서는 교재or강의를 참고하자 V3.1 강의의 16:30 ~ 23:30)

 

 

결론 : X To One 관계는 페치 조인을 해도 페이징에 아무런 영향이 없다. 따라서 X To One 관계는 페치 조인으로 한 번에 가져와서 쿼리 수를 줄이고, X To Many 관계는 hibernate: default_batch_fetch_size로 해결하자.

 

 

 

 

 

 

단순 쿼리를 직접 작성할 필요 없이 대신 작성해주는 것은 JPA의 큰 장점이다.

 

하지만 역설적이게도 이러한 장점이 성능 문제를 초래할 수 있다.

테이블을 이것저것 마구잡이로 조회해서 불필요하게 많은 쿼리가 나가는 것이다.

 

이러한 문제를 방지하고 최적의 성능을 이끌어내려면 여러가지 사항들을 신경쓰며 개발해야 한다.

아래는 이에 관하여 알아두면 좋은 내용들을 정리한 것이다.

 

 

 

 

 

(하이버네이트 모듈)

implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'

@Bean
Hibernate5Module hibernate5Module() {
	return new Hibernate5Module();
}

모든 연관관계에 대하여 LAZY 전략을 사용하는 것이 좋다.

LAZY 로딩을 사용한 객체는 직접적으로 사용하기 전까지는 프록시 객체를 담고 있게 된다.

 

이렇게 프록시 객체를 담고 있는 엔티티를 JSON으로 반환할 경우 제대로 된 값이 들어있지 않아 문제가 발생한다.

 

위 코드처럼 하이버네이트 모듈을 빈으로 등록해  놓는다면 이런 프록시 객체 엔티티에 대해서 모두 null값을 전달하도록 하는 기능을 기본 기능으로서 사용할 수 있다. (LAZY로딩된 엔티티들에 대한 로딩을 강제하는 옵션도 있음)

 

 

(@JsonIgnore)

양방향 연관관계를 가진 엔티티들에 대해 조회가 이뤄질 경우 무한루프를 돌면서 계속 서로를 조회하는 문제가 발생할 수 있다.

이를 방지하기 위해 양방향 연관관계 중 한 곳의 관계를 @JsonIgnore로 끊어서 JSON 전달을 막을 수 있다.

 

 

 

사실 위의 두 방법 (하이버네이트 모듈, @JsonIgnore)는 실무에서는 별로 사용될 일이 없다. 어차피 실무에서는 엔티티를 직접 전달하지 않고 항상 DTO를 통해서만 API 통신이 이뤄지기 때문이다.

 

 

 

 

(DTO를 통한 전달)

    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByCriteria(new OrderSearch());
        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();  // Member LAZY 초기화
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();  // Delivery LAZY 초기화
        }
    }

이렇게 inner class로 따로 만들어준 DTO 객체를 반환하면 내가 원하는 정보들만 담을 수 있으므로 위와 같은 상황을  고려하지 않아도 된다.

 

내가 필요한  엔티티에  Member, Delivery에 대해서는 getter를 사용할 때 쿼리가 날아가면서 프록시 객체가 아닌 진짜 객체가 들어간다.

 

하지만 이러한 방식은 order에 대한 쿼리 1번 + member, delivery에 대한 쿼리 각각 1번.  총 3번의 쿼리를 호출하게 되어 성능 저하를 초래할 수 있다.

 

 

 

 

(fetch join을 사용한 성능 최적화)

    // fetch 조인을 사용하여 발생하는 쿼리 수를 대폭 줄임
    @GetMapping("/api/v3/simple-orders")
    public List<SimpleOrderDto> orderV3() {
        List<Order> orders = orderRepository.findAllWithMemberDelivery();
        List<SimpleOrderDto> result = orders.stream()
                .map(o -> new SimpleOrderDto(o))
                .collect(Collectors.toList());
        return result;
    }
    
    .
    .
    .
    
    // OrderRepository 내부
    public List<Order> findAllWithMemberDelivery() {
        // order에 대한 한 번의 쿼리에서 member, delivery 를 한 번에 가져옴
        return em.createQuery(
                "select o from Order o" +
                        " join fetch o.member m" +
                        " join fetch o.delivery d", Order.class
        ).getResultList();
    }
    

레포지토리 내부에 페치 조인으로 order들을 조회하는 함수를 생성해 이를 사용한다.

 

fetch join을 사용해 JPQL을 작성하면 order에 대한 member, delivery를 별도의 쿼리로 조회하는 것이 아니라 order를 조회할 때 컬럼만 추가하여 함께 조회한다.

 

즉, order에 대한 쿼리 하나만 나가는 것이다.

 

 

 

 

 

(딱히 JPA에 관련된 내용은 아니지만 JPA 강의에서 배운 내용이라 여기에 적는다)

 

 

@RequestBody, @ResponseBody를 통해 편리하게 API 통신을 할 수 있다.

 

이때, 엔티티 객체를 직접적으로 받고, 반환해줘도 단순히 기능을 구현하는 데는 별 문제가 없다. 

하지만 이러한 방식은 좋지 않다. "반드시" 엔티티를 그대로 사용하지 말고 DTO를 통해 엔티티의 정보를 전달해야한다.

 

DTO를 반드시 사용해야 하는 이유는 다음과 같다.

 

 

1. DTO를 사용하지 않을 경우 엔티티의 변경에 의해 API 스펙이 변경될 수 있다.

 

엔티티와 API가 일대일 대응의 관계를 가진다면 엔티티에 수정이 일어날 때마다 API 스펙을 일일히 변경해줘야한다.

이는 매우 번거로운 작업이며 컴파일 에러로 이를 감지할 수 없기 때문에 에러 원인을 찾기가 어렵다.

(DTO를 사용하면 DTO -> 엔티티 과정에서 컴파일 에러가 발생되므로 엔티티의 변경사항을 반드시 파악할 수 있다)

 

 

2. 하나의 엔티티에 대해서 API는 여러 개가 존재할 수 있다.

 

각각의 API가 요구하는 엔티티에 대한 데이터는 모두 다를 확률이 높다.

이때, 그냥 엔티티의 모든 정보를 넘겨줘버린다면 필요없는 데이터까지 받긴 하지만 필요한 건 전부 받은 셈이니 기능 동작에는 문제가 없을 것이다.

 

하지만 PW처럼 보안상 감추어야 할 정보까지 모두 JSON으로 함께 넘어가기 때문에 보안 문제가 발생할 수 있다.

엔티티 측에서 컬럼에 @JsonIgnore를 사용해 JSON 전달을 막을 수는 있지만 이는 엔티티가 API 스펙에 의존성을 갖게 되므로 좋지 않다.

 

유지보수가 복잡해질 뿐 아니라 다른 API에 대해서는 그떄그떄 또 변경을 해줘야 하는 쓸 데 없는 번거로움이 발생한다.

 

 

3. 엔티티를 그대로 넘겨줄 경우, 엔티티가 가진 정보 외의 것들은 넘겨주지 못한다.

DTO를 사용하면 엔티티의 정보 외에 추가적으로 필요한 정보도 함께 넘겨줄 수 있다.

 

 

 

 

 

 

 

 

 

JPA에서는 update, delete 등의 변경 사항에 대해 따로 쿼리를 작성하지 않아도 JPA가 알아서 DB에 쿼리를 날려주어 해당 내용을 반영시켜준다.

 

하지만 이같은 기능에만 기댔다가는 성능이 매우 떨어지는 상황이 발생할 수도 있다.

만약 특정 조건을 만족하는 모든 행에 대하여 update연산을 처리하고 싶다면?

 

where조건을 넣은 JPQL로 List<Entity>를 가져올 것이고.. 반복문을 돌려가며 이들 하나하나에 전부 변경 사항을 적용하는 코드를 짜야 한다.

 

이럴 경우 JPA가 update에 대한 쿼리를 대신 DB로 보내주는 것은 맞지만 각 row마다 쿼리를 한 번 씩 개별적으로 수행하기 떄문에 대상 List의 크기에 따라 엄청나게 많은 쿼리가 발생할 수도 있게 된다.

 

이러한 불상사를 막기 위해 JPQL로 update, delete 쿼리를 직접 작성하여 DB에 바로 보내버리는 벌크 연산이 사용된다.

(벌크 연산은 update, delete만 지원한다)

 

int resultCount = em.createQuery("update Member m set m.age = 20").executeUpdate();

(createQuery는 쿼리가 적용된 row의 수를 반환한다)

 

 

주의할 점은, 벌크 연산은 영속성 컨텍스트를 거치지 않고 DB에 쿼리를 바로 적용시킨다는 것이다.

이러한 특성 때문에 데이터의 일관성이 깨지는 일이 발생할 수 있다.

(ex : 영속성 컨텍스트에는 age=10으로 되어있음 -> 벌크연산을 통해 age=20으로 수정 -> 이미 영속성 컨텍스트에 존재하는 엔티티 객체가 있기 때문에 age=10인 객체를 그대로 사용하는 문제 발생)

 

이러한 문제를 방지하기 위해 아래와 같은 두 가지 해결책이 존재한다.

 

1. 애플리케이션을 수행하기 전, 아직 영속성 컨텍스트가 비어있을 때, 벌크 연산을 가장 먼저 실행

-> 영속성 컨텍스트엔 아무것도 없기 때문에 이후에 엔티티 객체 필요로 할 시 벌크 연산이 적용된 DB에서 해당 객체 조회하여 사용할 것.

 

2. 벌크 연산 수행 후 영속성 컨텍스트 초기화

-> 근본적으로는 1과 똑같은 방식. 벌크 연산 수행 후 em.clear()로 영속성 컨텍스트 초기화. 이 후에 DB에서 새롭게 객체 조회하여 사용.

(단, em.clear()로 인해 준영속 상태가 된 이전에 사용하던 객체들은 더이상 사용하면 안 됨)

 

 

 

 

수정 예정.

 

지연 로딩 옵션보다 fetch join이 우선시 됨

 

JPQL 에서는 두 가지 방식으로 테이블 간 조인을 실행할 수 있다.

 

1. 명시적 조인

일반적인 SQL문 처럼 join 키워드를 직접적으로 사용해 명시적으로 조인 여부를 나타내는 것

ex : select t.members from Team t join t.members m

 

2. 묵시적 조인

from절에서 조인 관계를 따로 명시하진 않는다. select절에서 의존성을 가지는 다른 엔티티 객체를 조회하려 할 경우 JPA가 알아서 PK, FK를 가지고 해당 테이블과 inner join을 수행하는 쿼리를 날려준다.

당연한 이야기지만 묵시적 조인에서는 inner join만 가능하다. outter join을 하려면 만드시 명시적 조인을 사용해야 한다.

ex : select t.members from Team  ->  따로 조인 없이 Team에 있는 members에 접근

 

 

주의할 점, 


묵시적 조인으로 해당 엔티티가 가지고있는 컬렉션을 조회할 순 있으나 이 컬렉션은 경로 탐색의 끝이 된다.

select t.members.username from Team 이런식으로 컬렉션 members를 넘어선 경로 탐색이 불가능하다.

 

이런 식의 조회가 하고 싶다면 명시적 조인을 통해 해당 컬렉션에 대한 alias를 얻어와 그걸 통해 접근해야 한다.

select m.username from Team join t.members m  이렇게 짜면 컬렉션의 범위에서 더 깊은 경로 탐색을 실시할 수 있다.

 

 

권장사항은 항상 명시적 조인을 사용하는 것이다. 당장 쓰기에 묵시적 조인이 편할 수는 있지만 성능이나 유지보수 측면을 고려하였을 때 명시적 조인을 사용하는 것이 바람직하다.

 

 

 

 

 

(프로젝션) - JPQL의 select 절에서 조회할 대상을 지정하는 방식을 말한다.

 

1. 엔티티 프로젝션 : 조회된 데이터가 하나의 객체인 경우

2. 임베디드 타입 프로젝션 : 엔티티 객체 안에 존재하는 임베디드 타입 객체를 조회하는 경우

3. 스칼라 타입 프로젝션 : 자바에서 기본 제공되는 자료형을 조회하는 경우 (엔티티 객체의 속성)

 

엔티티 프로젝션의 경우 조회한 결과들에 대해서 모두 영속성 컨텍스트의 관리가 보장된다.

 

 

 

(페이징) - order by로 정렬했을때 일부 결과만 가져오도록 설정

기존 쿼리에서 둘만 추가하면 된다.

List<Member> resultList = em.createQuery(JPQL, Member.class)
		.setFirstResult(0)  // 0번째 결과부터
     		.setMaxResults(10)  // 10개를 가져온다
        	.getResultList();

MySQL의 limit이나 Oracle의 rownum과 동일한 기능이다.

 

 

 

 

 

JPA를 사용해 엔티티 객체를 중심으로 개발을 진행하듯이 JPQL을 사용하여 엔티티 객체를 대상으로 쿼리를 작성할 수 있다.

 

JPQL로 작성한 쿼리는 SQL로 변환되어 디비에 적용된다.

 

JPQL은 테이블과 컬럼명을 대상으로 짜는 쿼리가 아니다. 엔티티 객체와 그 속성에 대해 작성하는 쿼리이다. 그렇기 때문에 엔티티 객체, 속성에 대해서는 반드시 대소문자를 구분해야 한다.

 

 

 

(쿼리 타입) - JPQL 쿼리는 두 가지 타입으로 받을 수 있다.

1. TypedQuery<T> - 조회하는 데이터의 타입이 명확할 때 사용

TypedQuery<Member> query = em.createQuery("select m from Member m", Member.class);

Member 객체를 통째로 조회한다. 조회되는 자료의 타입을 명시할 수 있다.

 

TypedQuery<String> query = em.createQuery("select m.username from Member m", String.class);

Member의 username 하나만 조회한다. String으로 타입이 명확하다.

 

 

2. Query - 조회하는 데이터의 타입이 명확하지 않을 때

Query query = em.createQuery("select m.username, m.age from Member m");

서로 타입이 다른(String, int) username, age를 조회한다. 결과 데이터에 대한 타입을 명확하게 나타낼 수 없으므로 TypedQuery<T>가 아닌 Query를 사용한다.

 

 

엔티티 객체의 여러 속성을 한 번에 조회하여 쿼리 타입을 특정할 수 없을 때는 아래와 같이 Object[]로 받아서 다루는 방법이 있다.

List<Object[]> resultList = em.createQuery("select m.username, m.age from Member m").getResultList();
Object[] result = resultList.get(0);
String name = result[0];
int age = result[1];

 

 

 

 

 

주의 - query.getResultList(), query.getSingleResult()

메서드 이름에서도 알 수 있듯이 getResultList()는 쿼리의 결과가 여러 개일 때 그것들을 List로 묶어서 반환하고, getSingleResult()는 쿼리 결과가 하나 뿐일 때 사용한다.

 

getResultSet()은 결과를 꺼냈을 때 조회된 행이 없다면 null을 반환하지만 getSingleResult()은 예외를 발생시킨다. 결과가 여럿이어도 예외를 발생시킨다.

 

즉, 결과가 단 하나만 나오는 것이 보장될 때만 사용해야 한다, (스프링 데이터 JPA에서는 예외 발생 시 null을 반환하도록 설정되어 있다고 한다)

 

 

 

 

 

 

(파라미터 바인딩) - JPQL에서 사용할 파라미터를 직접 입력하지 않고 따로 세팅해줄 수 있다.

TypedQuery<Member> query = 
		em.createQuery("select m from Member m where m.username = :username", Member.class);
query,setParameter("username", "member1");

검색하려는 속성에 대해서 파라미터를 바인딩 해줄 수 있다.

 

파라미터의 위치를 사용한 바인딩도 가능하지만 이는 사용하지 않는 것이 좋다. 중간에 다른 검색 조건이 들어올 경우 순서가 망가져버리기 때문.

 

 

 

 

+ Recent posts