Close
Close full mode
logo만렙 개발자 키우기

(2) 준영속 상태와 지연 로딩

Git RepositoryEdit on Github
Last update: 9 months ago by nowwaterReading time: 6 min

트랜잭션은 보통 서비스 계층에서 시작하므로 서비스 계층이 끝나는 시점에 트랜잭션이 종료되면서 영속성 컨텍스트도 함께 종료된다.

서비스와 리포지토리 계층에서 조회한 엔티티는 영속성 컨텍스트에 관리되면서 영속상태를 유지하지만,

컨트롤러나 뷰 같은 프레젠테이션 계층에서는 서비스 계층으로부터 엔티티를 넘겨받은 경우, 이 엔티티는 준영속 상태이다. => 이 때는 변경 감지와 지연 로딩이 동작하지 않는다.

ex) 컨트롤러에서 member.getName() // 예외 발생


준영속 상태와 변경 감지

보통 변경 감지 기능은 서비스 계층에서 비즈니스 로직을 수행하면서 발생한다. 프레젠테이션 계층은 데이터를 단순히 보여주기만 하고 수정할 일은 없다.

  • 비즈니스 로직은 서비스 계층에서 끝낸다.
  • 프레젠테이션 계층은 단순히 데이터를 보여주기만 한다.

변경 감지 기능이 프레젠테이션 계층에서도 동작했을 때의 단점

  1. 애플리케이션 계층이 가지는 책임이 모호해짐

  2. 유지보수성이 떨어진다.
    => 데이터가 어디서 어떻게 변했는지 프레젠테이션 계층까지 다 찾아야 함

준영속 상태와 지연 로딩

준영속 상태에서 가장 골치 아픈 문제는 지연 로딩 기능이 동작하지 않는다는 점이다

ex)

  1. 뷰를 렌더링할 때 연관된 엔티티도 함께 사용해야 하는데 연관된 엔티티를 지연 로딩으로 설정해서 프록시 객체로 조회했다.
  1. 초기화하지 않은 프록시 객체를 사용하면 실제 데이터를 볼러오기 위해 초기화를 시도한다.
  1. 하지만 준영속 상태는 영속성 컨텍스트가 없으므로 지연 로딩을 할 수 없어 예외를 발생시킨다.

준영속 상태의 지연 로딩 문제를 해결하는 방법

영속성 컨텍스트가 살아 있을 때 뷰에 필요한 엔티티들을 미리 다 로딩하거나 초기화해서 반환한다

어디서 미리 로딩하느냐에 따라 3가지 방법이 존재한다.

  • 글로벌 페치 전략 수정
  • JPQL 페치 조인
  • 강제로 초기화
  • OSIV를 사용해서 엔티티를 항상 영속 상태로 유지하는 방법

13.2.1 글로벌 페치 전략으로 수정

글로벌 페치 전략을 지연 로딩에서 즉시 로딩으로 변경한다.

ex) @ManyToOne(fetch = FetchType.EAGER) => 즉시 로딩 전략

애플리케이션 전체에서 해당 엔티티를 로딩할 때마다 해당 전략을 사용하므로 글로벌 페치 전략 이라 한다.

// 주문 엔티티를 조회하면 연관된 member 엔티티도 항상 함께 로딩한다.
@Entity
public class Order{
...
@ManyToOne(fetch = FetchType.EAGER)
private Member member; // 주문 회원
}

글로벌 페치 전략: 즉시 로딩 사용 시 단점

  • 사용하지 않는 엔티티를 로딩한다.

    order 엔티티만 필요한 경우에 order를 조회하면서 사용하지 않는 member도 함께 조회한다.

  • 🚒 N+1 문제가 발생한다.

    JPA 를 사용하면서 성능상 가장 조심해야 하는 문제

    처음 조회한 데이터 수만큼 다시 SQL을 사용해서 조회하는 문제.
    => SQL이 상당히 많이 호출되므로 조회 성능에 치명적이다.

    따라서 최우선 최적화 대상이다. JPQL 페치 조인 으로 해결 가능하다.

// OK
Order order = em.find(Order.class, 1L); // 즉시 로딩 시 JOIN 쿼리를 사용해 연관된 엔티티까지 조회
-------------------- 실행된 SQL --------------------
select o.*, m.*
from Order o
left outer join Member m on o.MEMBER_ID=m.MEMBER_ID
where o.id = 1
// BAD. 문제는 JPQL 사용 시 발생
// 처음 조회한 order 엔티티 수만큼 Member 엔티티를 다시 SQL을 사용해 조회한다.
List<Order> orders = em.createQuery("select o from Order o", Order.class)
.getResultList();
-------------------- 실행된 SQL --------------------
select * from Order // JPQL로 실행된 SQL
select * from Member where id = ? // EAGER로 실행된 SQL
select * from Member where id = ? // EAGER로 실행된 SQL
select * from Member where id = ? // EAGER로 실행된 SQL
select * from Member where id = ? // EAGER로 실행된 SQL
...

JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 페치 전략을 참고하지 않고 오직 JPQL 자체만 사용한다.

=> 즉시 로딩이든, 지연 로딩이든 구분하지 않고 JPQL 쿼리 자체에 충실하게 SQL을 만든다.

  1. select o from Order o -> select * from Order
  1. 데이터베이스에서 결과를 받아 order 엔티티 인스턴스 생성
  1. Order.member의 글로벌 페치 전략이 즉시 로딩. 따라서 order 로딩하면서 즉시 연관된 member도 로딩
  1. 연관된 member를 영속성 컨텍스트에서 찾고, 없으면 SELECT * FROM MEMBER WHERE ID = ? 조회를 order 엔티티 수 만큼 실행

글로벌 페치 전략을 즉시 로딩으로 설정하면 애플리케이션 전체에 영향을 주므로 매우 비효율적이다.

요약 : 조회한 order 엔티티가 10개이면 member를 조회하는 SQL도 10번 실행

조회한 데이터 수 만큼 SQL을 사용해서 추가로 조회하는 것이 N+1문제


13.2.2 JPQL 페치 조인

  • 페치 조인을 사용하면 JPQL을 호출하는 시점에 함께 로딩할 엔티티를 선택할 수 있다. => N+1 문제 가 발생하지 않는다.
  • 조인 명령어 마지막에 fetch를 넣어주면 된다.
JPQL:
select o
from Order o
join fetch o.member
SQL:
select o.*, m.*
from Order o
join Member m on o.MEMBER_ID = m.MEMBER_ID

JPQL 페치 조인의 단점

  • 무분별하게 사용하면 화면에 맞춘 리포지토리 메소드가 증가한다.
    => 프레젠테이션 계층이 알게 모르게 데이터 접근 계층을 침범하게 된다.

    화면 A를 위해 order만 조회하는 repo.findOrder()

    화면 B를 위해 ordermember를 페치 조인으로 조회하는 repo.findOrderWithMember()

    최적화는 가능하지만 뷰와 리포지토리 간에 논리적인 의존관계가 발생한다.



    무분별한 최적화로 프레젠테이션 계층과 데이터 접근 계층 간에 의존 관계가 급격하게 증가하는 것 보다는 다음과 같이 적절한 선에서 타협점을 찾는 것이 합리적이다.

    repo.findOrder() 하나만 만들고 여기서 페치 조인으로 ordermember를 함께 로딩


13.2.3 강제로 초기화

영속성 컨텍스트가 살아있을 때 프레젠테이션 계층이 필요한 엔티티를 강제로 초기화해서 반환하는 방법이다.

class OrderService{
@Transactional
public Order findOrder(id){
Order order = orderRepository.findOrder(id);
order.getMember().getName(); //프록시 객체를 강제로 초기화
return order;
}
}
  • 글로벌 페치 전략을 지연 로딩으로 설정하면 연관된 엔티티를 실제 엔티티가 아닌 프록시 객체로 조회한다.
  • 프록시 객체는 실제로 사용하는 시점에 초기화된다.

    order.getMember 까지만 호출하면 단순히 프록시 객체만 반환하고 초기화 x

    order.getMember.getName() 처럼 실제 값을 사용하는 시점에 초기화 o

  • 초기화된 프록시 객체를 반환하기 때문에 준영속 상태에서도 사용할 수 있다.

하지만 이렇게 서비스 계층은 비즈니스 로직을 담당해야지, 프레젠테이션 계층을 위한 일까지 하는 것은 좋지 않다.

이렇게 서비스 계층이 프레젠테이션을 위한 일까지 하면 뷰에서 필요한 엔티티에 따라 서비스 계층의 로직이 바뀌어야한다.

따라서 서비스 계층에서 프레젠테이션 계층을 위한 프록시 초기화 역할을 분리하고, 그 역할을 FACADE 계층이 담당해준다.


13.2.4 FACADE 계층 추가

image

프레젠테이션 계층과 서비스 계층 사이에 뷰를 위한 프록시 초기화를 담당하는 계층 => 논리적인 의존성을 분리할 수 있다.

프록시를 초기화하려면 영속성 컨텍스트가 필요하므로 FACADE 에서 트랜잭션을 시작해야 한다.

FACADE 계층의 역할과 특징

  • 프레젠테이션 계층과 도메인 모델 계층 간의 논리적 의존성을 분리
  • 프레젠테이션 계층에서 필요한 프록시 객체를 초기화
  • 서비스 계층을 호출해서 비즈니스 로직을 실행
  • 리포지토리를 직접 호출해서 뷰가 요구하는 엔티티를 찾는다.
class OrderFacade {
@Autowired OrderService orderService;
public Order = orderService.findOrder(id);
// 프레젠테이션 계층이 필요한 프록시 객체를 강제로 초기화
order.getMember().getName();
return order;
}
class OrderService{
public Order findOrder(id){
return ordeRepository.findOrder(id);
}
}

단점 😥

  • 중간에 계층이 하나 더 생겨버린다. 즉 더 많은 코드를 작성해야 한다. -> 번거로움!
  • 단순히 서비스 계층을 호출만 하는 위임 코드가 상당히 많을 것이다.
  • 화면별로 최적화된 엔티티를 딱딱 맞아떨어지게 초기화해서 조회하려면 여러 종류의 조회 메소드가 필요하다. => JPQL 페치 조인의 단점과 비슷

    // order만 필요한 경우
    getOrder()
    // order, order.member 가 필요한 경우
    getOrderWithMember()
    ...

결국 모든 문제는 엔티티가 프레젠테이션 계층에서 준영속 상태이기 때문에 발생 !!!

이를 해결하기 위해 뷰에서도 지연 로딩을 사용할 수 있도록 하는 것이 13.3 에서 배울 OSIV 이다.


내 정리

즉시 로딩 vs JPQL 페치 조인

즉시 로딩 설정 시, 원하는 엔티티를 조회하고 자동으로 연관된 엔티티까지 다시 조회하는 것

JPQL 페치 조인은 엔티티를 조회할 때 같이 조회할 연관된 엔티티까지 다 같이 한 번에 조회하는 것.

=> N+1 문제를 해결하면서 화면에 필요한 엔티티를 미리 로딩하는 현실적인 방법 , 화면에 맞춘 메소드가 늘어난다는 단점은 감안.

🚀 JPA — Previous
(1) 트랜잭션 범위와 영속성 컨텍스트
Next — 🚀 JPA
(3) OSIV