8 minute read

13장_ 웹 애플리케이션과 영속성 관리

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

스프링이나 J2EE 컨테이너 환경에서 JPA를 사용하면 컨테이너가 제공하는 전략을 따라야 한다.

13.1.1 스프링 컨테이너의 기본 전략

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

트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다. 그리고 같은 트랜잭션 안에서는 항상 같은 영속성 컨텍스트에 접근한다.

스프링 트랜잭션 AOP

스프링 프레임워크를 사용하면 보통 비즈니스 로직을 시작하는 서비스 계층에 @Transactional 어노테이션을 선언해서 트랜잭션을 시작한다.

이 어노테이션이 있으면 호출한 메소드를 실행하기 직전에 스프링의 트랜잭션 AOP가 먼저 동작한다.

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

  • 트랜잭션이 같으면 같은 영속성 컨텍스트를 사용한다.
    • 다양한 위치에서 엔티티 매니저를 주입받아 사용해도 트랜잭션이 같으면 항상 같은 영속성 컨텍스트를 사용한다.
    • 엔티티 매니저가 달라도 트랜잭션이 같으면 같은 영속성 컨텍스트를 사용한다.
  • 트랜잭션이 다르면 다른 영속성 컨텍스트를 사용한다.
    • 같은 엔티티 매니저를 사용해도 트랜잭션에 따라 접근하는 영속성 컨텍스트가 다르다.

    왜 같은 영속성 컨텍스트를 공유하는데, 엔티티 매니저 객체는 다를 수도 있을까?

    **| `@PersistenceContext` 로 주입되는 `EntityManager`는 프록시 객체다**
    
    • 스프링에서 @PersistenceContext 로 주입하는 EntityManager는 실제 EntityManager가 아니라 프록시 객체
    • 이 프록시 객체가 내부적으로 같은 트랜잭션 내에서 동일한 실제 EntityManager 인스턴스를 반환하도록 관리

      //SharedEntityManagerCreator class
      public static EntityManager createSharedEntityManager(EntityManagerFactory emf, @Nullable Map<?, ?> properties, boolean synchronizedWithTransaction, Class<?>... entityManagerInterfaces) {
              ClassLoader cl = null;
              if (emf instanceof EntityManagerFactoryInfo emfInfo) {
                  cl = emfInfo.getBeanClassLoader();
              }
          
              Class<?>[] ifcs = new Class[entityManagerInterfaces.length + 1];
              System.arraycopy(entityManagerInterfaces, 0, ifcs, 0, entityManagerInterfaces.length);
              ifcs[entityManagerInterfaces.length] = EntityManagerProxy.class;
              return (EntityManager)Proxy.newProxyInstance(cl != null ? cl : SharedEntityManagerCreator.class.getClassLoader(), ifcs, new SharedEntityManagerInvocationHandler(emf, properties, synchronizedWithTransaction));
          }
      

    프록시 객체가 “현재 트랜잭션에 맞는 실제 EntityManager를 찾아서 반환”하는 방식

  • 스프링 컨테이너는 스레드마다 각각 다른 트랜잭션을 할당한다.
    • 같은 엔티티 매니저를 호출해도 접근하는 영속성 컨텍스트가 다르므로 멀티스레드 상황에 안전하다.

→ 스프링 컨테이너는 트랜잭션과 복잡한 멀티 스레드 상황을 처리해준다.

13.2 준영속 상태와 지연 로딩

트랜잭션은 보통 서비스 계층에서 시작하므로 서비스 계층이 끝나는 시점에 트랜잭션이 종료되면서 영속성 컨텍스트도 함께 종료된다. 따라서 조회한 엔티티가 서비스와 리포지토리 계층에서는 영속성 컨텍스트에 관리되면서 영속 상태를 유지하지만 컨트롤러나 뷰 같은 프리젠테이션 계층에서는 준영속 상태가 된다.

→ 트랜잭션이 없는 프리젠테이션 계층에서는 변경 감지와 지연 로딩이 동작하지 않는다.

  • 준영속 상태와 변경 감지
    • 변경 감지 기능은 영속성 컨텍스트가 살아있는 서비스 계층(트랜잭션 범위)까지만 동작하고 영속성 컨텍스트가 종료된 프리젠테이션 계층에서는 동작하지 않는다.
    • 변경 감지 기능이 프리젠테이션 계층에서도 동작하면 애플리케이션 계층이 가지는 책임이 모호해지고 무엇보다 데이터를 어디서 어떻게 변경했는지 프리젠테이션 계층까지 다 찾아야하므로 애플리케이션을 유지보수하기 어렵다.
    • 비즈니스 로직은 서비스 계층에서 끝내고 프리젠테이션 계층은 데이터를 보여주는 데 집중해야 한다.
  • 준영속 상태와 지연 로딩
    • 준영속 상태의 가장 골치 아픈 문제는 지연 로딩 기능이 동작하지 않는다는 점이다.
    • 준영속 상태는 영속성 컨텍스트가 없으므로 지연 로딩을 할 수 없다. 이때 지연로딩을 시도하면 문제가 발생한다. (hibernate의 경우 LazyInitializationException)

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

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

13.2.1 글로벌 페치 전략 수정

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

단점

  • 사용하지 않는 엔티티를 로딩한다.
    • 사용하지 않는 경우에도 항상 조회하게 된다.
  • N+1 문제가 발생한다.
    • JPA를 사용하면서 성능상 가장 조심해야 하는 것이 바로 N+1 문제다.(JPQL사용시 발생)
    • 만약 조회한 order 엔티티가 10개이면 member를 조회하는 SQL도 10번 실행한다. 이처럼 처음 조회한 데이터 수만큼 다시 SQL을 사용해서 조회하는 것을 N+1 문제라 한다. N+1 이 발생하면 SQL이 상당히 많이 호출되므로 조회 성능에 치명적이다. 따라서 최우선 최적화 대상이다.
    • 이런 N+1 문제는 JPQL 페치 조인으로 해결할 수 있다.

13.2.2 JPQL 페치 조인

JPQL을 호출하는 시점에 함께 로딩할 엔티티를 선택할 수 있다.

select o
from Order o
join fetch o.member
select o.*, m.*
from Order o
join Member m on o.MEMBER_ID=m.MEMBER_ID

페치 조인을 사용하면 SQL JOIN을 사용해서 페치 조인 대상까지 함께 조회한다.

단점

페치 조인이 현실적인 대안이긴 하지만 무분별하게 사용하면 화면에 맞춘 리포지토리 메소드가 증가할 수 있다.

메소드를 각각 만들면 최적화는 할 수 있지만 뷰와 리포지토리 간에 논리적인 의존관계가 발생한다.

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

13.2.3 강제로 초기화

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

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

프록시 객체는 실제 사용하는 시점에 초기화된다. order.getMember() 까지만 호출하면 단순히 프록시 객체만 반환하고 아직 초기화하지 않는다. 프록시 객체는 member.getName() 처럼 실제 값을 사용하는 시점에 초기화된다.

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

org.hibernate.initialize(order.getMember()); //hibernate 프록시 초기화

예제처럼 프록시를 초기화하는 역할을 서비스 계층이 담당하면 뷰가 필요한 엔티티에 따라 서비스 계층의 로직을 변경해야 한다. 은근슬쩍 프리젠테이션 게층이 서비스 계층을 침범하는 상황이다.

서비스 계층에서 프리젠테이션 계층을 위한 프록시 초기화 역할을 분리해야 한다. FACADE 계층이 그 역할을 담당해줄 것이다.

13.2.4 FACADE 계층 추가

프리젠테이션 계층과 서비스 계층 사이에 FACADE 계층을 하나 더 두는 방법이다. 뷰를 위한 프록시 초기화는 이곳에서 담당한다.

결과적으로 FACADE 계층을 도입해서 서비스 계층과 프리젠테이션 계층 사이에 논리적인 의존성을 분리할 수 있다.

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

FACADE 계층의 역할과 특징

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

단점

FACADE의 최대 단점은 중간에 계층이 하나 더 끼어든다는 점이다. 결국 더 많은 코드를 작성해야 한다. 그리고 FACADE에는 단순히 서비스 계층을 호출만 하는 위임 코드가 상당히 많을 것이다.

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

  1. 뷰를 개발할 때 필요한 엔티티를 미리 초기화하는 방법은 생각보다 오류가 발생할 가능성이 높다.
  2. 애플리케이션 로직과 뷰가 물리적으로는 나누어져 있지만 논리적으로는 서로 의존한다는 문제가 있다.
  3. FACADE를 사용한다고 해도 상당히 번거롭다.

→ 모든 문제는 엔티티가 프리젠테이션 계층에서 준영속 상태이기 때문에 발생한다.

13.3 OSIV

OSIV(Open Session In View)는 영속성 컨텍스트를 뷰까지 열어둔다는 뜻이다.

뷰에서도 지연로딩을 사용할 수 있다.

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

클라이언트의 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 트랜잭션을 시작하고 요청이 끝날 때 트랜잭션도 끝내는 것이다. 이것을 요청 당 트랜잭션 방식의 OSIV라 한다.

image.png

뷰에서도 지연로딩을 할 수 있으므로 엔티티를 미리 초기화할 필요가 없다. 그리고 뷰에서도 지연로딩을 할 수 있게 되면서 FACADE 계층 없이도 뷰에 독립적인 서비스 계층을 유지할 수 있다.

문제점

요청 당 트랜잭션 방식의 OSIV가 가지는 문제점은 컨트롤러나 뷰 같은 프리젠테이션 계층이 엔티티를 변경할 수 있다는 점이다.

class MemberController {
		public String viewMember(Long id){
				Member member = memberService.getMember(id);
				member.setName("XXX");//보안상의 이유로 고객 이름을 XXX로 변경했다.
				model.addAttribute("member", member);
		}
}

요청 당 트랜잭션 방식의 OSIV는 뷰를 렌더링한 후에 트랜잭션을 커밋한다. 트랜잭션을 커밋하면서 영속성 컨텍스트를 플러시한다. 이때 영속성 컨텍스트의 변경 감지 기능이 작동해서 변경된 엔티티를 데이터베이스에 반영해버린다. 결국 데이터베이스의 고객 이름이 XXX로 변경되는 심각한 문제가 발생한다.

→ 프리젠테이션 계층에서 엔티티를 수정하지 못하게 막으면 된다.

  1. 엔티티를 읽기 전용 인터페이스로 제공
  2. 회원 엔티티 대신 회원 엔티티의 읽기 전용 메소드만 있는 MemberView 인터페이스를 제공

     interface MemberView {
       public String getName();
     }
          
     class MemberService(){
         public MemberView getMember(Long id){
           return memberRepository.findById(id);
         }
     }
    
  3. 엔티티 레핑
  4. 회원 엔티티를 감싸고 있는 MemberWrapper 객체를 만들고 읽기 메소드만 제공한다.
  5. DTO만 반환
  6. 프리젠테이션 계층에 엔티티 대신에 단순히 데이터만 전달하는 객체인 DTO를 생성해서 반환하는 것이다. 하지만 이 방법은 OSIV를 사용하는 장점을 살릴 수 없고 엔티티를 거의 복사한 듯한 DTO 클래스도 하나 더 만들어야 한다.

→ 최근에는 거의 사용하지 않고, 이런 문제점을 어느정도 보완해서 비즈니스 계층에서만 트랜잭션을 유지하는 방식의 OSIV를 사용한다. 스프링 프레임워크가 제공하는 OSIV가 바로 이 방식을 사용하는 OSIV다.

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

OSIV를 사용하기는 하지만 트랜잭션은 비즈니스 계층에서만 사용하는 방식.

image.png

동작 원리

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

트랜잭션 없이 읽기

영속성 컨텍스트를 통한 모든 변경은 트랜잭션 안에서 이루어져야 한다. 만약 트랜잭션 없이 엔티티를 변경하고 영속성 컨텍스트를 플러시하면 javax.persistence.TransactionRequiredException 예외가 발생한다.

엔티티를 변경하지 않고 단순히 조회만 할 때는 트랜잭션이 없어도 되는데 이것을 트랜잭션 없이 읽기라 한다. 프록시를 초기화하는 지연 로딩도 조회 기능이므로 트랜잭션 없이 읽기가 가능하다.

  • 영속성 컨텍스트는 트랜잭션 범위 안에서 엔티티를 조회하고 수정할 수 있다.
  • 영속성 컨텍스트는 트랜잭션 범위 밖에서 엔티티를 조회만 할 수 있다. 이것을 트랜잭션 없이 읽기라 한다.
  • 스프링이 제공하는 OSIV 서블릿 필터나 OSIV 스프링 인터셉터는 요청이 끝나면 플러시를 호출하지 않고 em.close()로 영속성 컨텍스트만 종료해 버리므로 플러시가 일어나지 않는다.

스프링 OSIV 주의사항

프리젠테이션 계층에서 엔티티를 수정한 직후에 트랜잭션을 시작하는 서비스 계층을 호출하면 문제가 발생한다.

Member member = memberService.getMember(id);
member.setName("XXX");//보안상의 이유로 고객 이름을 XXX로 변경했다.-> 굳이 여기서 해야할지..

memberService.biz();
return "view";

스프링 OSIV는 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있으므로 이런 문제가 발생한다.

13.3.3 OSIV 정리

스프링 OSIV의 특징

  • 엔티티 수정은 트랜잭션이 있는 계층에서만 동작한다. 트랜잭션이 없는 프리젠테이션 계층은 지연 로딩을 포함해서 조회만 할 수 있다.

스프링 OSIV의 단점

  • OSIV를 적용하면 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있다는 점을 주의해야 한다. 특히 트랜잭션 롤백 시 주의해야 한다.(영속성 컨텍스트 초기화 문제)
  • 프리젠테이션 계층에서 엔티티를 수정하고 나서 비즈니스 로직을 수행하면 엔티티가 수정될 수 있다.
  • 프리젠테이션 계층에서 지연로딩에 의한 SQL이 실행된다. 따라서 성능 튜닝 시에 확인해야 할 부분이 넓다.
  • DB connection 부족 문제 → 성능 문제가 생길 수 있다. → 대규모 트래픽 서비스에서는 추천하지 않는다.

OSIV vs FACADE vs DTO

어떤 방법을 사용하든 OSIV를 사용하는 것과 비교해서 지루한 코드를 많이 작성해야 한다.

OSIV를 사용하는 방법은 만능이 아니다.

OSIV를 사용하면 화면을 출력할 때 엔티티를 유지하면서 객체 그래프를 마음껏 탐색할 수 있다.

하지만 복잡한 화면을 구성할 때는 이 방법이 효과적이지 않은 경우가 많다.

이런 경우는 JPQL로 필요한 데이터들만 조회해서 DTO로 반환하는 것이 더 나은 해결책일 수 있다.

OSIV는 같은 JVM을 벗어난 원격 상황에서는 사용할 수 없다.

예를 들어 JSON이나 XML을 생성할 때는 지연 로딩을 사용할 수 있지만 원격지인 클라이언트에서 연관된 엔티티를 지연 로딩하는 것은 불가능하다. 결국 클라이언트가 필요한 데이터를 모두 JSON으로 생성해서 반환해야 한다. 보통 Jackson이나 Gson같은 라이브러리를 사용해서 객체를 JSON으로 변환하는데, 변환 대상 객체로 엔티티를 직접 노출하거나 또는 DTO를 사용해서 노출한다.

13.5 정리

스프링이나 J2EE 컨테이너 환경에서 JPA를 사용하면 트랜잭션 범위의 영속성 컨텍스트 전략이 적용된다.

이 전략은 트랜잭션의 범위와 영속성 컨텍스트의 생존 범위가 같다. 그리고 같은 트랜잭션 안에서는 항상 같은 영속성 컨텍스트에 접근한다.

이 전략의 유일한 단점은 프리젠테이션 계층에서 엔티티가 준영속 상태가 되므로 지연 로딩을 할 수 없다는 점이다.

Updated: