Backend/JPA

준영속 상태에서의 지연 로딩 문제와 그 해결법인 OSIV

olsohee 2024. 2. 4. 14:21

트랜잭션 범위의 영속성 컨텍스트 전략

스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다. 이름 그대로 트랜잭션의 범위와 영속성 컨텍스트의 생존 범위가 같다는 뜻이다. 즉, 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다. 그리고 같은 트랜잭션 안에서는 항상 같은 영속성 컨텍스트에 접근한다.

스프링 프레임워크를 사용하면 보통 비즈니스 로직을 시작하는 서비스 계층에서 @Transactional 어노테이션을 선언해서 트랜잭션을 시작한다. @Transactional 어노테이션이 있으면 해당 클래스의 메소드 호출시 메소드 실행 직전에 스프링의 트랜잭션 AOP가 먼저 동작한다. 트랜잭션 AOP는 대상 메소드를 호출하기 직전에 트랜잭션을 시작한다. 그리고 대상 메소드가 정상 종료되면 트랜잭션을 커밋하면서 종료하는데, 이때 JPA는 먼저 영속성 컨텍스트를 플러시해서 변경 내용을 데이터베이스에 반영한 후에 트랜잭션을 커밋한다. 만약 예외가 발생하면 트랜잭션을 롤백하고 종료하는데, 이때는 플러시를 호출하지 않는다.

예제를 통해 알아보자.

@Controller
class HelloController {

    @Autowired HelloService helloService;
    
    public void hello() {
    	Member member = helloService.logic(); // Member 엔티티는 준영속 상태
    }
}

@Service
class HelloService {

    @PersistenceContext // 스프링 컨테이너가 엔티티 매니저를 주입
    EntityManager em;
    
    @Autowired Repository1 repository1;
    @Autowired Repository2 repository2;

    @Transactional
    public void logic() {
    	repository1.hello();
        Member member = repository2.findMember(); // Member 엔티티는 영속 상태 
        return member;
    }
}

@Repository
class Repository1 {

    @PersistenceContext
    EntityManager em;

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

@Repository
class Repository2 {

    @PersistenceContext
    EntityManager em;

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

 

트랜잭션 실행 흐름은 다음과 같다.

  1. HelloService.logic() 메소드에 @Transactional을 선언해서 메소드를 호출할 때 트랜잭션을 먼저 시작한다.
  2. repository2.findMember()를 통해 조회한 Member 엔티티는 트랜잭션 범위 안에 있으므로 영속성 컨텍스트의 관리를 받는 영속 상태이다.
  3. @Trancational을 선언한 메소드가 끝나면서 트랜잭션과 영속성 컨텍스트가 종료되었다. 따라서 컨트롤러에 반환된 Member 엔티티는 준영속 상태이다.

주의할 점은 다음과 같다.

  • 트랜잭션이 같으면 같은 영속성 컨텍스트를 사용한다. 
    • 트랜잭션 범위의 영속성 컨텍스트 전략은 다양한 위치에서 엔티티 매니저를 주입받아 사용해도 트랜잭션이 같으면 항상 같은 영속성 컨텍스트를 사용한다.
    • 위 예제 코드에서 Repository1과 Repository2는 다른 엔티티 매니저를 사용해도 같은 영속성 컨텍스트에 접근한다.

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

트랜잭션 범위의 영속성 컨텍스트 전략에서 지연로딩 문제 발생

트랜잭션 범위의 영속성 컨텍스트 전략은 트랜잭션이 종료되면서 영속성 컨텍스트도 함께 종료된다. 따라서 트랜잭션 범위 밖에서는 영속성 컨텍스트가 없기 때문에 엔티티는 준영속 상태이며, 지연 로딩과 같은 영속성 컨텍스트를 이용한 기능을 사용할 수 없다. 

 

즉, 트랜잭션이 없는 프리젠테이션 계층에서 엔티티는 준영속 상태이기 때문에 변경 감지와 지연 로딩이 동작하지 않는다.

  • 변경 감지 동작 X
    • 보통 변경 감지 기능은 서비스 계층에서 비즈니스 로직을 수행하면서 발생한다. 단순히 데이터를 보여주기만 하는 프리젠테이션 계층에서 데이터를 수정할 일은 거의 없다.오히려 변경 감지 기능이 프리젠테이션 계층에서도 동작하면 데이터가 어디서 어떻게 변경됐는지 프리젠테이션 계층까지 다 찾아야 하므로 유지보수하기 어렵다. 따라서 변경 감지 기능은 프리젠테이션 계층에서 동작하지 않는 것은 문제가 되지 않는다. 
  • 지연 로딩 동작 X
    • 예를 들어 뷰를 렌더링할 때 연관된 엔티티도 함께 사용해야 하는데 연관된 엔티티를 지연 로딩으로 설정해서 프록시 객체로 조회했다고 가정하자. 아직 초기화되지 않은 프록시 객체를 사용하면 실제 데이터를 불러오기 위해 초기화를 시도한다. 하지만 준영속 상태는 영속성 컨텍스트가 없으므로 지연 로딩을 할 수 없다. 이때 지연 로딩을 시도하면 org.hibernate.LazyInitializationException 예외가 발생한다.
    • 따라서 만약 Order 엔티티의 필드인 Member 엔티티를 지연 로딩으로 설정했다고 가정했을 때, 다음 예제에서 예외가 발생한다.
class OrderController {

    public String view(Long orderId) {
    
        Order order = orderService.findOne(orderId);
        Member member = order.getMember(); // Memer 엔티티는 프록시
        member.getName(); // 지연 로딩 시 예외 발생
    }
}

 

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

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

해결 1: 엔티티를 미리 로딩

영속성 컨텍스트가 살아 있을 때 뷰에 필요한 엔티티들을 미리 다 로딩하거나 초기화해서 반환하는 방법이다. 따라서 엔티티가 준영속 상태로 변해도 반환된 엔티티는 이미 다 로딩된 상태이기 때문에 지연 로딩이 발생하지 않는다. 이 방법은 어디서 미리 로딩하느냐에 따라 3가지 방법이 있다.

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

글로벌 페치 전략 수정

엔티티에 있는 fetch 타입을 지연 로딩에서 즉시 로딩으로 변경하면 된다. 

 

하지만 즉시 로딩으로 설정하는 것은 2가지 단점이 있다.

  • 사용하지 않는 엔티티를 로딩한다. 예를 들어 화면 A에서 order와 member 둘 다 필요해서 글로벌 전략을 즉시 로딩으로 설정했다. 반면 화면 B는 order 엔티티만 있으면 된다. 하지만 즉시 로딩으로 member 엔티티가 필요없는 경우에도 order를 조회하면서 member도 함께 조회하게 된다.
  • N+1 문제가 발생한다. order를 조회할 때 조회된 order 엔티티 수만큼 연관된 member 엔티티도 조회하게 된다. 따라서 1개의 쿼리에 추가로 N개의 쿼리가 실행된다. 이를 N+1 문제라고 한다. 실행 흐름은 다음과 같다. 
    1. select o from Order o라는 JPQL이 있다고 가정할 때, JPA는 JPQL을 분석해서 select * from Order라는 SQL을 생성한다.
    2. 데이터베이스에서 결과를 받아 order 엔티티 인스턴스들을 생성한다.
    3. Order.member의 글로벌 페치 전략이 즉시 로딩이므로 order를 로딩하는 즉시 연관된 member도 로딩해야 한다.
    4. 연관된 member를 영속성 컨텍스트에서 찾는다.
    5. 만약 영속성 컨텍스트에 없으면 select * from member where id=?라는 SQL을 생성하여 조회된 order 엔티티 수만큼 실행한다.

JPQL 페치 조인

즉시 로딩으로 인한 N+1 문제는 페치 조인으로 해결할 수 있다. 페치 조인을 사용하면 SQL JOIN을 사용해서 페치 조인 대상까지 한 번에 함께 조회한다. 따라서 N+1 문제가 발생하지 않는다.

 

페치 조인은 N+1 문제를 해결하면서 화면에 필요한 엔티티를 미리 로딩하는 현실적인 방법이다. 그러나 페치 조인을 무분별하게 사용하면 화면에 맞춘 리포지토리 메소드가 증가할 수 있다. 결국 프리젠테이션 계층이 알게 모르게 데이터 접근 계층을 침범하는 것이다. 예를 들어 화면 A는 order 엔티티만 필요하고, 화면 B는 order 엔티티와 연관된 member 엔티티까지 필요하다고 할 때, 두 화면을 모두 최적화하기 위해 둘을 지연 로딩으로 설정하고 리포지토리에 다른 2가지 메소드를 만들었다고 하자.

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

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

 

다른 대안으로 페치 조인을 적용한 메소드 하나만 만들고 화면 A와 화면 B 둘 다 하나의 메소드만 사용하도록 하는 방법이 있다. 이 경우 order 엔티티만 필요한 경우에도 페치 조인을 사용한다.

강제로 초기화

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

class OrderService {
    
    @Transactional
    public Order findOrder(id) {
        Order order = orderRepository.findOrder(id);
        order.getMember().getName(); // 프록시 강제 초기화
        return order;
    }
}

 

위와 같이 영속성 컨텍스트가 살아 있을 때 강제로 초기화해서 반환하면 이미 초기화했으므로 준영속 상태에서도 연관된 엔티티를 사용할 수 있다. 초기화하는 방법으로는 위 예제와 같이 프록시 객체의 실제 값을 사용하여(order.getMember().getName()) 초기화하는 방법이 있고, 하이버네이트를 사용하면 initialize() 메소드를 사용해서 프록시를 강제로 초기화할 수도 있다. 

 

그러나 예제처럼 서비스 계층에서 뷰에서 사용될 엔티티를 고려해서 프록시를 초기화하면 뷰가 필요한 엔티티에 따라 서비스 계층의 코드가 변경되야 한다. 즉, 프리젠테이션 계층이 서비스 계층을 침범한다. 따라서 비즈니스 로직을 담당하는 서비스 계층에서 프리젠테이션 계층을 위한 프록시 초기화 역할을 분리해야 한다. 이때 FACADE 계층을 사용하면 된다.

FACADE 계층 도입

프리젠테이션 계층과 서비스 계층 사이에 FACADE 계층을 하나 추가하여, 뷰를 위한 프록시 초기화는 이곳에서 담당한다. 프록시를 초기화하려면 영속성 컨텍스트가 필요하므로 FACADE 계층에서 트랜잭션을 시작해야 한다.

class OrderFacade {

    @Autowired OrderService orderService;
    
    public Order findOrder(Long id) {
        Order order = orderService.findOrder(id); // 서비스 계층 호출
        order.getMember().getName(); // 프록시 강제 초기화
        return order;
    }
}

 

FACADE 계층의 역할과 특징은 다음과 같다.

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

그러나 실용적인 관점에서 FACADE의 단점은 중간에 계층이 하나 더 추가되어 결국 더 많은 코드를 작성해야 한다는 단점이 있다. 그리고 FACADE에는 단순히 서비스 계층을 호출하는 코드가 상당히 많을 것이다.

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

지금까지 준영속 상태일 때 지연 로딩의 문제를 극복하기 위해 글로벌 페치 전략을 즉시 로딩으로 수정하고, JPQL의 페치 조인도 사용하고, 강제로 초기화하고 FACADE 계층까지 사용해봤다. 그러나 뷰를 개발할 때 필요한 엔티티를 미리 초기화하는 방법은 오류가 발생할 가능성이 높다. 왜냐하면 보통 뷰를 개발할 때는 엔티티 클래스를 보고 개발하지 이것이 초기화되어 있는지 아닌지 확인하기 위해 FACADE나 서비스 계층의 클래스까지 열어보는 것은 상당히 번거롭기 때문이다. 결국 영속성 컨텍스트가 없는 뷰에서 초기화하지 않은 프록시 엔티티를 조회하는 실수를 하게 되고 LazyInitializationException을 만나게 될 것이다.

 

그리고 애플리케이션 로직과 뷰가 물리적으로는 나눠져 있지만 논리적으로는 서로 의존한다는 문제가 있다. 물론 FACADE를 사용해서 이런 문제를 어느 정도 해결할 수 있지만 매우 번거롭다. 예를 들어 주문 엔티티와 연관된 회원 엔티티를 조회할 때 화면별로 최적화된 엔티티를 딱딱 맞아떨어지게 초기화해서 조회하려면 FACADE 계층에 여러 종류의 조회 메소드를 정의해야 한다.

 

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

해결 2: OSIV 

OSIV(Open Source In View)는 영속성 컨텍스트를 뷰까지 열어둔다는 뜻이다. 따라서 뷰에서도 엔티티는 영속 상태로 유지되고 뷰에서도 지연 로딩을 사용할 수 있다.

과거의 OSIV: 요청 당 트랜잭션

요청 당 트랜잭션(Transaction per request)은 클라이언트의 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 트랜잭션을 시작하고, 요청이 끝날 때 트랜잭션도 끝내는 것이다.

 

  • 위 그림처럼 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 영속성 컨텍스트를 만들면서 트랜잭션을 시작하고, 요청이 끝날 때 트랜잭션과 영속성 컨텍스트를 함께 종료한다.
  • 이렇게 하면 영속성 컨텍스트가 처음부터 끝까지 살아있으므로 조회한 엔티티도 영속 상태를 유지한다.
  • 따라서 뷰에서도 지연 로딩을 할 수 있으므로 엔티티를 미리 초기화할 필요가 없으며, 뷰에서도 지연 로딩을 할 수 있으므로 FACADE 계층 없이 뷰에 독립적인 서비스 계층을 유지할 수 있다.

요청 당 트랜잭션 방식의 OSIV 문제점

그러나 요청 당 트랜잭션 방식의 OSIV의 문제점은 컨트롤러나 뷰 같은 프리젠테이션 계층이 엔티티를 변경할 수 있다는 점이다. 예를 들어 고객 정보를 출력해야 하는데 보안상의 이유로 고객 이름을 XXX로 변경해서 출력한다고 가정하자.

class MemberController {

    public String viewMember(Long id) {
        Member member = memberService.getMember(id);
        member.setName("XXX"); // 고객 이름을 XXX로 변경
        model.addAttribute("member", member);
        ...
    }
}

 

위와 같이 컨트롤러에서 고객 이름을 XXX로 변경해서 렌더링할 뷰에 넘겨줄 때, 요청 당 트랜잭션 방식의 OSIV로 인해 트랜잭션이 살아있는 상태이기 때문에 뷰를 렌더링한 후에 트랜잭션을 커밋한다. 그리고 이때 영속성 컨텍스트가 살아있는 상태이기 때문에 트랜잭션을 커밋할 때 플러시가 일어나고 변경 감지 기능이 작동해서 변경되니 엔티티가 데이터베이스에 반영된다.

 

이렇게 프리젠테이션 변경한 데이터가 데이터베이스에 반영되면 애플리케이션을 유지보수하기 힘들어진다. 따라서 프리젠테이션 계층에서 엔티티를 수정하지 못하게 막아야 한다. 프리젠테이션 계층에서 엔티티를 수정하지 못하게 막는 방법은 다음과 같다.

  • 엔티티를 읽기 전용 인터페이스로 제공
    • 프리젠테이션 계층에 엔티티를 직접 노출하지 않고 읽기 전용 메소드만 제공하는 인터페이스를 제공하는 방법이다. 
    • 프리젠테이션 계층은 읽기 전용 메소드만 있는 인터페이스를 반환받기 때문에 엔티티를 수정할 수 없다. 
interface MemberView {
    public String getName();
}

@Entity
class Member implements MemberView {
    ...
}

class MemberService {

    // 프리젠테이션 계층에 엔티티(Member)가 아닌 읽기 전용 인터페이스(MemberView)를 반환 
    public MemberView getMember(Long id) {
        return memberRepository.findById(id); 
    }
}
  • 엔티티 레핑
    • 프리젠테이션 계층에 엔티티를 직접 노출하지 않고 읽기 전용 메소드만 제공하는 인터페이스를 제공하는 방법이다. 
    • 프리젠테이션 계층은 읽기 전용 메소드만 있는 인터페이스를 반환받기 때문에 엔티티를 수정할 수 없다. 
class MemberWrapper {

    private Member member; // 엔티티
    
    public MemberWrapper(Member member) {
        this.member = membet;
    }
    
    // 읽기 전용 메소드 제공
    public String getName() {
        member.getName();
    }
}
  • DTO 반환
    • 가장 전통적인 방법으로, 프리젠테이션 계층에 엔티티 대신 단순히 데이터만 전달하는 객체인 DTO(Data Tansfer Object)를 반환하는 방법이다.
    • 하지만 이 방법은 OSIV를 사용하는 방법을 살릴 수 없고 엔티티를 거의 복사한 듯한 DTO 클래스를 만들어야 한다는 단점이 있다.

하지만 위 방법들은 모두 코드량이 상당히 증가한다는 단점이 있다. 따라서 지금까지 설명한 문제들로 인해 요청 당 트랜잭션 방식의 OSIV는 거의 사용되지 않는다. 최근에는 스프링 프레임워크가 제공하는 이런 문제를 보완한 비즈니스 계층에서만 트랜잭션을 유지하는 방식의 OSIV를 사용한다. 

스프링의 OSIV: 비즈니스 계층 트랜잭션

스프링 프레임워크는 다양한 OSIV 클래스를 제공한다. OSIV를 서블릿 필터에서 적용할지 스프링 인터셉터에서 적용할지에 따라 원하는 클래스를 사용하면 된다.

  • 하이버네이트 OSIV 서블릿 필터: org.springframework.orm.hibernate4.support.OpenSessionInViewFilter
  • 하이버네이트 OSIV 스프링 인터셉터: org.springframework.orm.hibernate4.support.OpenSessionInViewInterceptor
  • JPA OEIV 서블릿 필터: org.springframework.orm.jpa.support.OpenEntityManagerInViewFilter
  • JPA OEIV 스프링 인터셉터: org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor

이전에 설명했던 요청 당 트랜잭션 방식의 OSIV는 프리젠테이션 계층에서도 트랜잭션이 적용되기 때문에 프리젠테이션 계층에서 데이터베이스의 데이터를 변경할 수 있다는 문제가 있었다. 하지만 스프링이 제공하는 OSIV는 비즈니스 계층에서만 트랜잭션을 사용하여 이러한 문제를 해결한다.

  1. 클라이언트의 요청이 들어오면 서블릿 필터나 스프링 인터셉터에서 영속성 컨텍스트를 생성한다. 단 이때 트랜잭션은 시작하지 않는다.
  2. 서비스 계층에서 @Transactional로 트랜잭션을 시작할 때 1번에서 미리 생성해둔 영속성 컨텍스트를 찾아와서 트랜잭션을 시작한다.
  3. 서비스 계층이 끝나면 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시한다. 이때 트랜잭션은 끝내지만 영속성 컨텍스트는 종료하지 않는다.
  4. 컨트롤러와 뷰까지 영속성 컨텍스트가 유지되므로 조회한 엔티티는 영속 상태를 유지한다.
  5. 서블릿 필터나 스프링 인터셉터로 요청이 돌아오면 영속성 컨텍스트를 종료한다. 이때 플러시를 호출하지 않고 바로 종료한다. 따라서 프리젠테이션 계층에서 변경한 데이터는 데이터베이스에 반영되지 않는다.

트랜잭션 없이 읽기

  • 영속성 컨텍스트를 통한 모든 변경은 트랜잭션 안에서 이뤄져야 한다. 만약 트랜잭션 없이 엔티티를 변경하고 영속성 컨텍스트에 플러시하면 javax.persistence.TransactionRequireException 예외가 발생한다.
  • 그러나 엔티티를 변경하지 않고 조회만 할 때는 트랜잭션이 없어도 된다. 이를 트랜잭션 없이 읽기(Nontransactional read)라고 한다. 프록시를 초기화하는 지연 로딩도 단순 조회이므로 트랜잭션 없이 읽기가 가능하다.
  • 스프링이 제공하는 OSIV를 사용하면 프리젠테이션 계층에서는 트랜잭션이 없으므로 엔티티를 수정할 수 없다. 그러나 트랜잭션 없이 읽기를 통해 지연 로딩은 가능하다.

그렇다면 앞서 살펴본 예제에 스프링이 제공하는 OSIV를 적용하면 어떻게 될까?

class MemberController {

    public String viewMember(Long id) {
        Member member = memberService.getMember(id);
        member.setName("XXX"); // 고객 이름을 XXX로 변경
        model.addAttribute("member", member);
        ...
    }
}
  • 변경된 데이터를 데이터베이스에 반영하려면 플러시를 호출해야 한다. 그러나 스프링이 제공하는 OSIV를 사용하면 프리젠테이션 계층은 트랜잭션 범위 밖이므로 커밋과 플러시가 일어나지 않는다.
  • 그리고 서블릿 필터나 스프링 인터셉터는 요청이 끝나면 플러시를 호출하지 않고 em.close()를 통해 영속성 컨텍스트만 종료해 버리므로 플러시가 일어나지 않는다.
  • 만약 em.flush()를 호출해서 강제로 플러시를 한다고 해도 트랜잭션 범위 밖이므로 TransactionRequireException 예외가 발생한다.
  • 따라서 프리젠테이션 계층에서 데이터를 변경해도 데이터베이스에 반영되지 않는다.

스프링 OSIV 주의사항

스프링 OSIV를 사용하면 프리젠테이션 계층에서 엔티티를 수정해도 트랜잭션 범위 밖이므로 수정 내용이 데이터베이스에 반영되지 않는다. 그런데 프리젠테이션 계층에서 엔티티를 수정한 직후에 트랜잭션을 시작하는 서비스 계층을 호출하면 문제가 발생한다.

class MemberController {

    public String viewMember(Long id) {
    
        Member member = memberService.getMember(id);
        member.setName("XXX"); // 엔티티 수정
        
        memberService.biz(); // 비즈니스 로직
        return "view";
    }
}
  • 위 예제의 경우 회원 엔티티의 이름을 XXX로 변경하고 트랜잭션이 적용되는 서비스 계층의 biz() 메소드를 호출했다. 
  • 따라서 서비스 계층에서 biz() 메소드가 끝나면서 트랜잭션 AOP는 트랜잭션을 커밋하고 영속성 컨텍스트에 플러시한다. 
  • 그리고 이때 변경 감지가 동작하면서 데이터베이스에 변경 내용이 반영된다.

따라서 뷰를 위해 엔티티를 변경할 때는 트랜잭션이 있는 비즈니스 로직을 모두 호출한 후에 엔티티를 변경해야 한다.

class MemberController {

    public String viewMember(Long id) {
        
        memberService.biz(); // 비즈니스 로직 먼저 실행
        Member member = memberService.getMember(id);
        member.setName("XXX"); // 마지막에 엔티티 수정
        return "view";
    }
}

OSIV 정리

스프링 OSIV의 특징

  • OSIV는 클라이언트의 요청이 들어올 때 영속성 컨텍스트를 생성해서 요청이 끝날 때까지 같은 영속성 컨텍스트를 유지한다. 따라서 한 번 조회한 엔티티는 끝날 때까지 영속 상태를 유지한다.
  • 엔티티 수정은 트랜잭션 범위 내에서만 가능하다. 트랜잭션 범위 밖인 프리젠테이션 계층에서 엔티티를 수정하면 TransactionRequireException 예외가 발생하고, 지연 로딩을 포함한 조회만 가능하다(Nontransactional read).

스프링 OSIV의 단점

  • OSIV를 적용하면 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있기 때문에 주의해야 한다. 
  • 프리젠테이션 계층에서 엔티티를 수정하고나서 트랜잭션 범위 내인 비즈니스 로직을 수행하면 데이터베이스에 변경 데이터가 반영될 수 있다.
  • 프리젠테이션 계층에서도 지연 로딩이 가능하기 때문에 SQL이 실행될 수 있다. 따라서 성능 튜닝 시에 확인해야 할 부분이 늘어난다.
  • 영속성 컨텍스트는 살아있는 동안 데이터베이스 커넥션 리소스를 사용하는데, OSIV 설정으로 인해 영속성 컨텍스트가 계속 살아있기 때문에 데이터베이스 커넥션을 오래 사용하게 된다. 따라서 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 부족해서 대기해야 할 수 있다. OSIV를 끄면 트랜잭션이 종료될 때 영속성 컨텍스트도 종료되고 커넥션도 반환된다. 따라서 커넥션 리소스가 낭비되지 않는다.
  • OSIV는 같은 JVM을 벗어난 원격 상황에서는 사용할 수 없다. 예를 들어 JSON이나 XML을 생성할 때는 지연 로딩을 사용할 수 있지만 원격지인 클라이언트에서 연관된 엔티티를 지연 로딩하는 것은 불가능하다. 결국 클라이언트가 필요한 데이터를 모두 JSON으로 생성해서 반환해야 한다.

Reference

  • 인프런, 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화
  • 자바 ORM 표준 JPA 프로그래밍, 김영한

'Backend > JPA' 카테고리의 다른 글

JPA의 낙관적 락과 비관적 락  (0) 2024.02.07
엔티티 조회 성능 최적화  (1) 2024.02.02
값 타입 컬렉션  (1) 2024.01.13
JPQL의 소개와 기본 문법  (1) 2024.01.10
영속성 전이(cascade)와 고아 객체  (0) 2024.01.09