쌩로그

자바 ORM 표준 JPA 프로그래밍 Ch.13. 웹 애플리케이션과 영속성 관리 (1) 본문

Spring/JPA

자바 ORM 표준 JPA 프로그래밍 Ch.13. 웹 애플리케이션과 영속성 관리 (1)

.쌩수. 2024. 1. 29. 00:01
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. 트랜잭션 범위의 영속성 컨텍스트
      2-2. 준영속 상태와 지연 로딩
  3. 요약

1. 포스팅 개요

자바 ORM 표준 JPA 프로그래밍13장 웹 애플리케이션과 영속성 관리를 학습하며 정리한 첫 번째 포스팅이다.

(내용이 길어져서 두 번에 걸쳐 포스팅할 예정이다.)

해당 장을 통해 컨테이너 환경에서 JPA가 동작하는 내부 동작 방식을 이해하고, 컨테이너 환경에서 웹 애플리케이션을 개발할 때 발생할 수 있는 다양한 문제점과 해결 방안을 알아본다.

2. 본론

참고로 해당 챕터에서는 yml설정을 다음과 같이 바꿔놔야 한다.

spring:
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        # show_sql: true
        format_sql: true
        default_batch_fetch_size: 50 # in query 물음표 개수 100개로 됨.
    open-in-view: false # OSIV

가장 밑에 있는 open-in-view: false # OSIV 이 설정이 가장 중요하다.

2-1. 트랜잭션 범위의 영속성 컨텍스트

스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다.

  • 이 전략은 이름 그대로 트랜잭션의 범위와 영속성 컨텍스트의 생존 법위가 같다는 뜻이다.
  • 이 전략은 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다.

스프링 프레임워클르 사용하면 보통 비즈니스 로직을 시작하는 서비스 계층에 @Transactional 어노테이션을 선언해서 트랜잭션을 시작한다.
외부에서는 단순히 서비스 계층의 메서드를 호출하는 것처럼 보이지만, 이 어노테이션이 있으면 호출한 메서드를 실행하기 직전에 스프링의 트랜잭션 AOP가 먼저 동작한다.

스프링 트랜잭션 AOP는 대상 메서드를 호출하기 직전에 트랜잭션을 시작하고, 대상 메서드가 종료되면 트랜잭션을 커밋하면서 종료한다.

이 때 트랜잭션을 커밋하면 JPA는 먼저 영속성 컨텍스트를 플러시해서 변경 내용을 데이터베이스에 반영한 후데이터베이스 트랜잭션을 커밋한다.

영속성 컨텍스트의 변경 내용이 데이터베이스에 정상 반영된다.
만약 예외가 발생하면 트랜잭션을 롤백하고 종료하는데 이 때는 플러시를 호출하지 않는다.

다음 코드를 살펴보자.

@Controller
public class HelloController {

    @Autowired
    HelloService helloService;

    public void hello() {
        // 반환된 member 엔티티는 준영속 상태다. --- 4
        Member member = helloService.logic();
    }

}


@Service
public class HelloService {

    @PersistenceContext // 엔티티 매니저 주입
    EntityManager em;

    @Autowired
    Repository1 repository1;
    @Autowired
    Repository2 repository2;

    @Transactional // 트갠잭션 시작 --- 1
    public Member logic() {
        repository1.hello();

        // member는 영속 상태다. --- 2
        Member member = repository2.findMember();
        return member;
    }

    // 트랜잭션 종료 --- 3
}


@Repository
public class Repository1 {

    @PersistenceContext // 엔티티 매니저 주입
    EntityManager em;

    public void hello() {
//        em.xxx(); // AA.  영속성 컨텍스트 접근
    }
}

@Repository
public class Repository2 {

    @PersistenceContext // 엔티티 매니저 주입
    EntityManager em;

    public Member findMember() {
        return em.find(Member.class, "id1"); // BB.  영속성 컨텍스트 접근
    }
}

현재 스프링부트 3.x 기준 jakarta.persistence.EntityManager 애너테이션을 사용하면 스프링 컨테이너가 엔티티 매니저를 주입해준다.

@PersistenceContext // 엔티티 매니저 주입
EntityManager em;

HelloController.hello()가 호출한 HelloService.logic()부터 순서대로 보자.

  1. HelloService.logic() 메서드에 @Transactional을 선언해서 메서드를 호출할 때 트랜잭션을 먼저 시작한다.
  2. repository2.findMember()를 통해 조회한 member 엔티티는 트랜잭션 범위 안에 있으므로 영속성 컨텍스트의 관리를 받는다. 지금은 영속상태다.
  3. @Transactional을 선언한 메서드가 정상 종료되면 트랜잭션을 커밋하는데, 이때 영속성 컨텍스트를 종료한다. 영속성 컨텍스트가 사라졌으므로 조회한 엔티티(member)는 이제부터 준영속 상태가 된다.
  4. 서비스 메서드가 끝나면서 트랜잭션과 영속성 컨텍스트가 종료되었다. 따라서 컨트롤러에 반환된 member 엔티티는 준영속 상태다.

트랜잭션 범위의 영속성 컨텍스트 전략을 조금 더 구체적으로 보자.

트랜잭션이 같으면 같은 영속성 컨텍스트를 사용한다.

트랜잭션 범위의 영속성 컨텍스트 전략은 다양한 위치에서 엔티티 매니저를 주입받아 사용해도 트랜잭션이 같으면 항상 같은 영속성 컨텍스트를 사용한다.

위의 예제 코드에서 엔티티 매니저를 사용하는 A,B 코드는 모두 같은 트랜잭션 범위에 있다.

트랜잭션이 다르면 다른 영속성 컨텍스트를 사용한다.

여러 스레드에서 동시에 요청이 와서 같은 엔티티 매니저를 사용해도 트랜잭션에 따라 접근하는 영속성 컨텍스트가 다르다.
즉, 스프링 컨테이너는 스레드마다 각각 다른 트랜잭션을 할당한다.
따라서 같은 엔티티 매니저를 호출해도 접근하는 영속성 컨텍스트가 다르므로 멀티 스레드 상황에 안전하다.

스프링과 같은 컨테이너의 가장 큰 장점은 트랜잭션과 복잡한 멀티 스레드 상황을 컨테이너가 처리해준다는 점이다.
따라서 개발자는 싱글 스레드 애플리케이션처럼 단순하게 개발할 수 잇고, 결과적으로 비즈니스 로직 개발에 집중할 수 있다.

2-2. 준영속 상태와 지연 로딩

방금 본 그림인데, 스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다.
그리고 트랜잭션 전략을보통 서비스 게층에서 시작하므로 서비스 계층이 끝나는 시점에 트랜잭션이 종료되면서 영속성 컨텍스트도 함께 종료된다.

조회한 엔티티 서비스와 리포지토리 계층에서는 영속성 컨텍스트에 관리되면서 영속 상태를 유지하지만, 컨트롤러나 뷰 같은 프레젠테이션 계층에서는 준영속 상태가 된다.

다음과 같은 Entity 코드가 있다고 하자.

@Entity
public class Order {

  @Id @GeneratedValue
  private Long id;

  @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 전략
  private Member member;

}

컨테이너 환경의 기본 전략인 트랜잭션 범위의 영속성 컨텍스트 전략을 사용하면 트랜잭션이 없는 프레젠테이션 계층에서 엔티티는 준영속 상태다.
따라서 변경감지와 지연 로딩이 동작하지 않는다.

다음 코드는 컨트롤러에 있는 로직인데 지연 로딩 시점에 예외가 발생한다.
(다시 한 번 언급하지만, yml에서 OSIV 설정이 false가 되어야 한다.)

@GetMapping("/no-dirtychecking-and-lazy-loading/{orderId}")
public String noDirtycheckingAndLazyLoading(@PathVariable ("orderId") Long orderId) {
    Order order = orderService.findOne(orderId);
    Member member = order.getMember();
    member.getName();   // 지연 로딩시 예외 발생
    return "redirect:/orders";
}

이런 코드가 있다고 가정하자.

서버를 실행하고, 다음과 같은 Path를 주었을 때,

다음과 같은 페이지가 뜬다.

에러 로그는 다음과 같다.

준영속 상태와 변경 감지

변경 감지 기능은 영속성 컨텍스트가 살아 있는 서비스 계층(트랜잭션 범위)까지만 동작하고 영속성 컨텍스트가 종료된 프레젠테이션 계층에서는 동작하지 않는다.
보통 변경 감지 기능은 서비스 계층에서 비즈니스 로직을 수행하면서 발생한다.
단순히 데이터를 보여주기만 하는 프레젠테이션 계층에서 데이터를 수정할 일은 거의 없다.

변경 감지 기능이 프레젠테이션 계층에서도 동작하면
애플리케이션 계층이 가지는 책임이 모호해지고 무엇보다 데이터를 어디서 어떻게 변경했는지 프레젠테이션 계층까지 다 찾아야 하므로 애플리케이션을 유지보수하기 어렵다.

비즈니스 로직은 서비스 계층에서 끝내고 프레젠테이션 계층은 데이터를 보여주는 데 집중해야 한다. 따라서 변경 감지 기능이 프레젠테이션 계층에서 동작하지 않는 것은 특별히 문제가 되지 않는다.

준영속 상태와 지연 로딩

준영속 상태의 가장 골치 아픈 문제는 지연 로딩 기능이 동작하지 않는다는 점이다.
뷰를 렌더링할 때 연관된 엔티티도 함께 사용해야 하는데 연관된 엔티티를 지연 로딩으로 설정해서 프록시 객체로 조회했다고 가정하자.
초기화하지 않은 프록시 객체를 사용하면 실제 데이터를 불러오려고 초기화를 시도한다.
하지만 준영속 상태는 영속성 컨텍스트가 없으므로 지연로딩을 할 수 없다.
이때 지연 로딩을 시도하면 문제가 발생한다.
만약 하이버네이트를 구현체로 사용하면, org.hibernate.LazyInitializationException 예외가 발생한다.

참고

참고로 JPA 표준에 어떤 문제가 발생하는지 정의하지 않아서 구현체마다 다르게 동작한다.

준영속 상태의 지연 로딩 문제를 해결한느 방법은 크게 2가지가 있다.

  • 뷰가 필요한 엔티티를 미리 로딩해두는 방법
  • OSIV를 사용해서 엔티티를 항상 영속 상태로 유지하는 방법

우선 뷰가 필요한 엔티티를 미리 로딩하는 다양한 방법을 알아보자.
이 방법은 이름 그대로 영속성 컨텍스트가 살아있을 때 뷰에 필요한 엔티티들을 미리 다 로딩하거나 초기화해서 반환하는 방법이다.
따라서 엔티티가 준영속 상태로 변해도 연관된 엔티티를 이미 다 로딩해두어서 지연 로딩이 발생하지 않는다.

뷰가 필요한 엔티티를 미리 로딩해두는 방법은 어디서 미리 로딩하느냐에 따라 3가지 방법이 있다.

  • 글로벌 페치 전략 수정
  • JPQL 페치 조인(fetch join)
  • 강제로 초기화

글로벌 페치 전략 수정

가장 간단한 방법은 글로벌 페치 전략을 지연 로딩에서 즉시 로딩으로 변경하면 된다.
즉 Lazy 로딩을 Eager로 변경한다.

@Entity
public class Order {
  @Id @GeneratedValud
  private Long id;

  @ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩 전략
  private Member member; // 주문 회원
  ...
}

위와 같이 엔티티에 있는 fetch 타입을 변경하면 애플리케이션 전체에서 이 엔티티를 로딩할 때마다 해당 전략을 사용하므로 글로벌 페치 전략이라고 한다.

FetchType.EAGER로 설정하고, 엔티티를 조회하면 연관된 엔티티도 항상 함께 로딩한다.
다음 코드를 보자.

Order order = em.find(Order.clas, orderId);
List<Order> orders = em.createQuery("select o from Order o");

order와 orders 모두 연관된 member 엔티티를 미리 로딩해서 가진다.
따라서 준영속 상태가 되어도 member를 사용할 수 있다.
하지만 이렇게 글로벌 페치 전략을 즉시 로딩으로 설정하는 것은 2가지 단점이 있다.

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

  • 사용하지 않은 엔티티를 로딩한다.
  • N+1 문제가 발생한다.

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

예를 들어서 화면 A에서 order와 member 둘 다 필요해서 글로벌 전략을 즉시 로딩으로 설정했다.
반면에 화면 B는 order 엔티티만 있으면 충분하다.
하지만 화면 B는 즉시 로딩 전략으로 인해, order를 조회하면서 사용하지 않는 member도 함께 조회하게 된다.

N + 1 문제가 발생한다.

JPA를 사용하면서 성능상 가장 조심해야 하는 것이 바로 N + 1 문제다.

em.find() 메서드로 엔티티를 조회할 때 연관된 엔티티를 로딩하는 전략이 즉시 로딩이면 데이터베이스에 JOIN 쿼리를 사용해서 한 번에 연관된 엔티티까지 조회한다.

다음 코드를 즉시 로딩으로 설정했다고 가정하자.

em.find()로 조회해보면, 다음과 같을 것이다.

Order order = em.find(Order.class, 1L);

쿼리는 다음과 같이 실행된다.

실행된 SQL을 보면 즉시 로딩으로 설정한 member 엔티티를 JOIN 쿼리로 함께 조회한다.

여기까지 보면 글로벌 즉시 로딩 전략이 상당히 좋아보인다만, 문제는 JPQL을 사용할 때 발생한다. 위처럼 즉시 로딩으로 설정했다고 가정하고 JPQL로 조회해보자.

Member member1 = createMember("userA", "서울", "1", "1111");
Member member2 = createMember("userB", "경기", "2", "2222");
Member member3 = createMember("userC", "부산", "3", "3333");
Member member4 = createMember("userD", "제주", "4", "4444");
em.persist(member1);
em.persist(member2);
em.persist(member3);
em.persist(member4);

Book book1 = createBook("JPA1 BOOK", 10000, 100);
em.persist(book1);

Book book2 = createBook("JPA2 BOOK", 20000, 100);
em.persist(book2);

OrderItem orderItem1 = OrderItem.createOrderItem(book1, 10000, 1);
OrderItem orderItem2 = OrderItem.createOrderItem(book2, 20000, 1);

Delivery delivery1 = createDeliverty(member1);
Order order1 = Order.createOrder(member1, delivery1, orderItem1, orderItem2);
Delivery delivery2 = createDeliverty(member2);
Order order2 = Order.createOrder(member2, delivery2, orderItem1);
Delivery delivery3 = createDeliverty(member3);
Order order3 = Order.createOrder(member3, delivery3, orderItem2);
Delivery delivery4 = createDeliverty(member4);
Order order4 = Order.createOrder(member4, delivery4, orderItem1, orderItem2);

em.persist(order1);
em.persist(order2);
em.persist(order3);
em.persist(order4);

em.flush();
em.clear();

System.out.println("================================");

List<Order> orders = em.createQuery("select o from Order o", Order.class)
        .getResultList(); // 연관된 모든 엔티티를 조회한다.

이를 실행하면 다음과 같다.

2024-01-28T16:23:23.628+09:00 DEBUG 528 --- [    Test worker] org.hibernate.SQL                        :
    select
        o1_0.order_id,
        o1_0.delivery_id,
        o1_0.member_id,
        o1_0.order_date,
        o1_0.status
    from
        orders o1_0
Hibernate:
    select
        o1_0.order_id,
        o1_0.delivery_id,
        o1_0.member_id,
        o1_0.order_date,
        o1_0.status
    from
        orders o1_0
2024-01-28T16:23:23.630+09:00  INFO 528 --- [    Test worker] p6spy                                    : #1706426603630 | took 0ms | statement | connection 4| url jdbc:h2:mem:e9791fbe-06e2-41ac-9506-48ac36289dde
select o1_0.order_id,o1_0.delivery_id,o1_0.member_id,o1_0.order_date,o1_0.status from orders o1_0
select o1_0.order_id,o1_0.delivery_id,o1_0.member_id,o1_0.order_date,o1_0.status from orders o1_0;
2024-01-28T16:23:23.657+09:00 DEBUG 528 --- [    Test worker] org.hibernate.SQL                        :
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
Hibernate:
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
2024-01-28T16:23:23.658+09:00  INFO 528 --- [    Test worker] p6spy                                    : #1706426603658 | took 0ms | statement | connection 4| url jdbc:h2:mem:e9791fbe-06e2-41ac-9506-48ac36289dde
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=?
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=1;
2024-01-28T16:23:23.668+09:00 DEBUG 528 --- [    Test worker] org.hibernate.SQL                        :
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
Hibernate:
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
2024-01-28T16:23:23.669+09:00  INFO 528 --- [    Test worker] p6spy                                    : #1706426603669 | took 0ms | statement | connection 4| url jdbc:h2:mem:e9791fbe-06e2-41ac-9506-48ac36289dde
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=?
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=2;
2024-01-28T16:23:23.672+09:00 DEBUG 528 --- [    Test worker] org.hibernate.SQL                        :
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
Hibernate:
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
2024-01-28T16:23:23.672+09:00  INFO 528 --- [    Test worker] p6spy                                    : #1706426603672 | took 0ms | statement | connection 4| url jdbc:h2:mem:e9791fbe-06e2-41ac-9506-48ac36289dde
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=?
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=3;
2024-01-28T16:23:23.675+09:00 DEBUG 528 --- [    Test worker] org.hibernate.SQL                        :
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
Hibernate:
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?

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

따라서 즉시 로딩이든 지연 로딩이든 구분하지 않고 JPQL 쿼리 자체에 SQL을 만든다.

코드를 분석하면 내부에서 다음과 같은 순서로 동작한다.

  1. select o from Order o JPQL을 분석해서 select * from Order SQL을 생성한다.
  2. 데이터베이스에서 결과를 받아 order 엔티티 인스턴스들을 생성한다.
  3. Order.member의 글로벌 페치 전략이 즉시 로딩이므로 order를 로딩하는 즉시 연관된 member도 로딩해야 한다.
  4. 연관된 member를 영속성 컨텍스트에서 찾는다.
  5. 만약 영속성 컨텍스트에 없으면 SELECT * FROM MEMBER WHERE id=? SQL을 조회한 order 엔티티 수만큼 실행한다.

지금 코드로도 보면 member를 8번 호출한다.

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

처음 조회한 데이터 수만큼 다시 SQL을 사용해서 조회하는 것을 N + 1 문제라고 한다.
N + 1이 발생하면 SQL이 상당히 많이 호출되므로 조회 성능에 치명적이다.
따라서 최우선 최적화 대상이다. 이런 N + 1문제는 JPQL 페치조인으로 해결할 수 있다.

참고로 N + 1 문제를 해결하는 다양한 방법은 다다음 포스팅에서 다룬다.

JPQL 페치 조인

글로벌 페치 전략을 즉시 로딩으로 설정하면 애플리케이션 전체에 영향을 주므로 너무 비효율적이다.
JPQL을 호출하는 시점에 함께 로딩할 엔티티를 선택할 수 있는 페치 조인을 알아보자.
방금 설명한 N + 1 문제가 발생햇던 예제에서 JPQL만 페치 조인을 사용하도록 변경한다.

페치 조인 사용 전은 이전에 보던 바와 같다.

jpql : select o from Order o
sql  : select * from Order

페치 조인 사용 후는 다음과 같다.

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

코드를 보자.

List<Order> orders = em.createQuery("select o from Order o join fetch o.member")
        .getResultList(); // join fetch를 사용

실행된 Query는 다음과 같다.

 select
        o1_0.order_id,
        o1_0.delivery_id,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name,
        o1_0.order_date,
        o1_0.status
    from
        orders o1_0
    join
        member m1_0
            on m1_0.member_id=o1_0.member_id

페치 조인을 사용하기 전과 포체 조인을 사용한 후인 코드를 비교해보면,
페치 조인은 조인 명령어 마지막에 fetch를 넣어주면 된다.
페치 조인이 적용된 SQL을 보면 알겠지만, 페치 조인을 사용하면 SQL JOIN을 사용해서 페치 조인 대상까지 함께 조회한다.

따라서 N + 1 문제가 발생하지 않는다(연관된 엔티티를 이미 로딩했으므로 글로벌 페치 전략은 무의미하다.)
페치 조인은 N + 1 문제를 해결하면서 화면에 필요한 엔티티를 미리 로딩하는 현식적인 방법이다.

JPQL 페치 조인의 단점

페치 조인이 현실적인 대안이긴 하지만 무분별하게 사용하면 화면에 맞춘 리포지토리 메서드가 증가할 수 있다.
즉, 프리젠테이션 계층이 알게 모르게 데이터 접근 계층을 침범하는 것이다.

예를 들어

  • 화면 A는 order 엔티티만 필요하다.

  • 화면 B는 order 엔티티와 연관된 member 엔티티 둘 다 필요하다.

    결국 두 화면을 모두 최적화하기 위해 둘을 지연 로딩으로 설정하고 리포지토리에 2가지 메서드를 만들어야 한다.

  • 화면 A를 위해 order만 조회하는 repository.findOrder() 메서드

  • 화면 B를 위해 order와 연관된 member를 페치 조인으로 조회하는 repository.findOrderWithMember() 메서드

이제 화면 A와 화면 B에 각각 필요한 메서드를 호출하면 된다.
메서드를 각각 만들면 최적화는 할 수 있지만 뷰와 리포지토리 간에 논리적인 의존관계가 발생한다.

다른 대안repository.findOrder()와 같이 하나만 만들고 여기서 페치 조인으로 order와 member를 함께 로딩하는 것이다.
그리고 화면 A, 화면 B 둘 다 repository.findOrder() 메서드를 사용하도록 한다.
(물론 order 엔티티만 필요한 화면 B는 약간의 로딩 시간이 증가하겠지만..) 페치조인은 JOIN을 사용해서 쿼리 한 번으로 필요한 데이터를 조회하므로 성능에 미치는 영향은 미비하다(물론 상황에 따라 다르다.)
무분별한 최적화로 프레젠테이션 계층과 데이터 접근 계층 간에 의존관계가 급격하게 증가하는 것보다는 적절한 선에서 타협점을 찾는 것이 합리적이다.

강제로 초기화

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

다음 코드를 보자.
(참고로 글로벌 페치 전략은 모두 지연로딩이라 가정한다.)

class OrderService {

    @Transactional
    public Order findOrder(id) {

        Order order = orderRepository.findOrder(id);
        order.getMember().getName(); //프록시를 강제로 초기화한다.
        return order;

    }
}

글로벌 페치 전략을 지연 로딩으로 설정하면 연관된 엔티티를 실제 엔티티가 아닌 프록시 객체로 조회한다.
프록시 객체는 실제 사용하는 시점에 초기화된다.
예를 들어 order.getMember()까지만 호출하면 단순히 프록시 객체만 반환하고 아직 초기화하지 않는다.
프록시 객체는 member.getName()처럼 실제 값을 사용하는 시점에 초기화한다.

위의 코드처럼 프리젠테이션 계층에서 필요한 프록시 객체를 영속성 컨텍스트가 살아 있을 때 강제로 초기화해서 반환하면 이미 초기화했으므로 준영속 상태에서도 사용할 수 있다.

하이버네이트를 사용하면 initialize() 메서드를 사용해서 프록시를 강제로 초기화할 수 있다.

List<Order> orders = em.createQuery("select o from Order o", Order.class)
        .getResultList();


System.out.println("================프록시 강제 초기화================");

for(Order order : orders) {
    Hibernate.initialize(order.getMember());   // 프록시 초기화
}

결과는 다음과 같다.

================프록시 강제 초기화================
2024-01-28T23:26:49.845+09:00 DEBUG 17396 --- [    Test worker] org.hibernate.SQL                        :
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
Hibernate:
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
2024-01-28T23:26:49.847+09:00  INFO 17396 --- [    Test worker] p6spy                                    : #1706452009847 | took 0ms | statement | connection 4| url jdbc:h2:mem:7457743c-93cd-40ed-952c-88ff5c29355f
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=?
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=1;
2024-01-28T23:26:49.851+09:00 DEBUG 17396 --- [    Test worker] org.hibernate.SQL                        :
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
Hibernate:
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
2024-01-28T23:26:49.852+09:00  INFO 17396 --- [    Test worker] p6spy                                    : #1706452009852 | took 0ms | statement | connection 4| url jdbc:h2:mem:7457743c-93cd-40ed-952c-88ff5c29355f
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=?
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=2;
2024-01-28T23:26:49.854+09:00 DEBUG 17396 --- [    Test worker] org.hibernate.SQL                        :
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
Hibernate:
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
2024-01-28T23:26:49.855+09:00  INFO 17396 --- [    Test worker] p6spy                                    : #1706452009855 | took 0ms | statement | connection 4| url jdbc:h2:mem:7457743c-93cd-40ed-952c-88ff5c29355f
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=?
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=3;
2024-01-28T23:26:49.857+09:00 DEBUG 17396 --- [    Test worker] org.hibernate.SQL                        :
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
Hibernate:
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
2024-01-28T23:26:49.857+09:00  INFO 17396 --- [    Test worker] p6spy                                    : #1706452009857 | took 0ms | statement | connection 4| url jdbc:h2:mem:7457743c-93cd-40ed-952c-88ff5c29355f
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=?
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=4;
2024-01-28T23:26:49.865+09:00  INFO 17396 --- [    Test worker] p6spy                                    : #1706452009865 | took 1ms | rollback | connection 4| url jdbc:h2:mem:7457743c-93cd-40ed-952c-88ff5c29355f

참고로 JPA 표준에는 프록시 초기화 메서드가 없다. JPA 표준은 단지 **초기화 여부만 확인할 수 있따. 초기화 여부를 확인하는 코드는 다음과 같다.

List<Order> orders = em.createQuery("select o from Order o", Order.class)
        .getResultList();


System.out.println("================프록시 강제 초기화================");

for(int i = 0; i < orders.size(); i++) {
    if( i%2 != 0) {
        Hibernate.initialize(orders.get(i).getMember());   // 프록시 초기화
    }
    // 여기
    PersistenceUnitUtil persistenceUnitUtil = em.getEntityManagerFactory().getPersistenceUnitUtil();
    boolean loaded = persistenceUnitUtil.isLoaded(orders.get(i).getMember());
    // 여기

    System.out.println("i = " + i);
    System.out.println(i + "번째 order.getMember 프록시 초기화 여부 : " + loaded);
}

결과는 다음과 같다.

i = 0
0번째 order.getMember 프록시 초기화 여부 : false
2024-01-28T23:32:50.010+09:00 DEBUG 20760 --- [    Test worker] org.hibernate.SQL                        :
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
Hibernate:
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
2024-01-28T23:32:50.011+09:00  INFO 20760 --- [    Test worker] p6spy                                    : #1706452370011 | took 0ms | statement | connection 4| url jdbc:h2:mem:a87cd585-8a2a-4e7c-b095-7dbd0d9052b8
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=?
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=2;
i = 1
1번째 order.getMember 프록시 초기화 여부 : true
i = 2
2번째 order.getMember 프록시 초기화 여부 : false
2024-01-28T23:32:50.016+09:00 DEBUG 20760 --- [    Test worker] org.hibernate.SQL                        :
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
Hibernate:
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name
    from
        member m1_0
    where
        m1_0.member_id=?
2024-01-28T23:32:50.017+09:00  INFO 20760 --- [    Test worker] p6spy                                    : #1706452370017 | took 0ms | statement | connection 4| url jdbc:h2:mem:a87cd585-8a2a-4e7c-b095-7dbd0d9052b8
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=?
select m1_0.member_id,m1_0.city,m1_0.street,m1_0.zipcode,m1_0.name from member m1_0 where m1_0.member_id=4;
i = 3
3번째 order.getMember 프록시 초기화 여부 : true

index가 홀수라면 초기화 되도록 했다.

PersistenceUnitUtil persistenceUnitUtil = em.getEntityManagerFactory().getPersistenceUnitUtil();
boolean loaded = persistenceUnitUtil.isLoaded(orders.get(i).getMember());

초기화 여부를 이렇게 알 수 있다.

이처럼 프록시를 초기화하는 역할을 서비스 계층이 담당하면 뷰가 필요한 엔티티에 따라 서비스 계층의 로직을 변경해야한다.
프리젠테이션 계층이 서비스 계층을 침법하는 상황이다.
서비스 계층은 비즈니스 로직을 담당해야지 이렇게 프레젠테이션 계층을 위한 일까지 하는 것은 좋지 않다.
비즈니스 로직을 담당하는 서비스 계층에서 프레젠테이션 계층을 위한 프록시 초기화 역할을 분리해야 한다.
FACADE 계층이 이 역할을 담당해줄 것이다.

FACADE 계층 추가

프레젠테이션 계층과 서비스 계층 사이에 FACADE 계층을 하나 더 두는 방법이다.
뷰를 위한 프록시 초기화는 이곳에서 담당한다.
덕분에 서비스 계층은 프레젠테이션 계층을 위해 프록시를 초기화하지 않아도 된다.
결과적으로 FACADE 계층을 도입해서 서비스 계층과 프레젠테이션 계층 상이에 논리적인 의존성을 분리할 수 잇다.

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

FACADE 계층의 역할과 특징

  • 프레젠테이션 계층과 도메인 모델 계층 간의 논리적 의존성을 분리해준다.
  • 프레젠테이션 계층에서 필요한 프록시 객체를 초기화한다.
  • 서비스 계층을 호출해서 비즈니스 로직을 실행한다.
  • 리포지토리를 직접 호출해서 뷰가 요구하는 엔티티를 찾는다.

강제로 초기화하기에서 설명했던 코드에 다음과 같이 FACADE 계층을 도입해보자.


class OrderFacade {

    @Autowired OrderService orderSerice;

    public Order findOrder(id) {
        Order order = orderService.findOrder(id);
        // 프레젠테이션 계층이 필요한 프록시 객체를 강제로 초기화한다.
        order.getMember().getName();
        return order;
    }
}

class OrderService {

    public Order findOrder(id) {
        return orderRepository.findOrder(id);
    }
}

(이전에 코드는 참고로 필자가 변형한 부분이 조금 있다...)

이 예시 코드는 주문내역을 조회하는 단순한 코으이다.
OrderService에 있던 프록시 초기화 코드를 OrderFacade로 이동했다.

FACADE 계층을 사용해서 서비스 계층과 프레젠테이션 계층 간에 논리적 의존관계를 제거했다.

이제 서비스 계층은 비즈니스 로직에 집중하고 프레젠테이션을 계층을 위한 초기화 코드는 모두 FACADE가 담당하면 된다.
하지만 실용적인 관점에서 볼 때 FACADE의 최대 단점은 중간에 계층이 하나 더 끼어든다는 점이다.
결국 더 많은 코드를 작성해야 한다. 그리고 FACADE에는 단순히 서비스 계층을 호출만 하는 위임 코드가 상당히 많을 것이다.

준영속 상태와 지연 로딩의 문제점

지금까지 준영속 상태일 때 지연 로딩 문제를 극복하기 위한 방법들을 ㅇ찰아보았다.
뷰를 개발할 때 필요한 엔티티를 미리 초기화하는 방법은 생각보다 오류가 발생할 가능성이 높다.

왜냐하면 보통 뷰를 개발할 때는 엔티티 클래스를 보고, 개발하지 이것이 초기화되어 있는지 아닌지 확인하기 위해 FACADE나 서비스 클래스까지 열어보는 것은 상당히 번거롭고 놓치기 쉽기 때문이다. 결국 영속성 컨텍스트가 없는 뷰에서 초기화하지 않은 프록시 엔티티를 조회하는 실수를 하게 되고 LazyInitializationException을 만나게 될 것이다.

그리고 애플리케이션 로직과 뷰가 물리적으로는 나누어져 있지만 논리적으로는 서로 의존한다는 문제가 있다.
물론 FACADE를 사용해서 이런 문제를 어느 정도 해소할 수는 있지만 상당히 번거롭다.

결국 모든 문제는 엔티티가 프레젠테이션 계층에서 준영속 상태이기 때문에 발생한다.
영속성 컨텍스트를 뷰까지 살아있게 열어두면 된다.
그럼 뷰에서도 지연로딩을 사용할 수 있는데 이것이 OSIV

OSIV에 대해서는 다음 포스팅에서 알아보자.

3. 요약

이번 내용을 통해서
서비스 계층에서 Transaction이 커밋되는 시점에 영속성 컨텍스트가 사라짐에 따라 조회된 엔티티가 준영속 상태가 되고,

엔티티의 로딩 전략이 지연 로딩일 경우, 발생하는 문제에 대해서 살펴보고, 그 문제를 어떻게 해결할 수 있는지에 대해 알아보았다.

영속성 컨텍스트가 살아있을 때 연관된 엔티티를 가져와야 하는데 다음과 같은 방법들이 있었다.

페치전략즉시 로딩 전략으로 하거나,
혹은 서비스 계층에서 조회된 엔티티와 연관된 프록시 객체를 모두 초기화한 상태에서 프레젠테이션으로 반환하는 방법이 있었다.

그런데 이러한 과정에서 발생하는 N + 1 문제에 대해 알아보았고,
이를 어떻게 극복할 수 있는지 살펴보았다.

FACADE 계층을 만드는 부분도 알아보았다.

다음 포스팅은 프레젠테이션 계층까지 영속성 컨텍스트를 유지시켜, 지연 로딩을 사용할 수 있는 OSIV에 대해서 알아볼 것이다.

참고로 이 포스팅애서 OSIV에 대해서 간단히 소개되어있다.

728x90
Comments