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

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

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

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

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

트랜잭션 커밋 -> 영속성 컨텍스트 플러시(변경 내용 데이터베이스에 반영) -> 데이터베이스 트랜잭션 커밋

예외가 발생하면 트랜잭션을 롤백하고 종료하는데 이때는 플러시를 호출하지 않는다.

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

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

  • 엔티티 매니저는 달라도 같은 영속성 컨텍스트를 사용한다.

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

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

준영속 상태와 지연 로딩

트랜잭션이 끝난 후인 컨트롤러나 뷰 같은 프리젠테이션 계층에서는 준영속 상태가 된다.

준영속 상태와 변경 감지

  • 영속성 컨텍스트가 살아 있는 서비스 계층까지만 동작한다.
  • 비즈니스 로직은 서비스 계층에서 끝내고 프리젠테이션 계층은 데이터를 보여주는 데 집중해야 한다.

준영속 상태와 지연 로딩

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

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

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

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

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

글로벌 페치 전략 수정

단점

  • 사용하지 않는 엔티티를 로딩한다.
  • N+1 문제가 발생한다.
    • 처음 조회한 데이터 수만큼 다시 SQL을 사용해서 조회하는 것을 N+1 문제라 한다.
    • 만약 조회한 order 엔티티가 10개이면 member를 조회하는 SQL도 10번 실행한다.
    • JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 페치 전략을 참고하지 않고 오직 JPQL 자체만 사용한다. 따라서 즉시 로딩이든 지연 로딩이든 구분하지 않고 JPQL 쿼리 자체에 충실하게 SQL을 만든다.
    • 이런 N+1 문제는 JQPL 페치 조인으로 해결할 수 있다.

JQPL 페치 조인

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

페치 조인을 사용하면 SQL JOIN을 사용해서 페치 조인 대상까지 함께 조회한다. 따라서 N+1 문제가 발생하지 않는다. (연관된 엔티티를 이미 로딩했으므로 글로벌 페치 전략은 무의미하다)

페치 조인은 N+1 문제를 해결하면서 화면에 필요한 엔티티를 미리 로딩하는 현실적인 방법이다.

JPQL 페치 조인의 단점

  • 무분별하게 사용하면 화면에 맞춘 리포지토리 메소드가 증가할 수 있다. 결국 프리젠테이션 계층이 알게 모르게 데이터 접근 계층을 침범하는 것이다.
    • 화면마다 각각 필요한 메소드를 만들면 뷰와 리포지토리 간에 논리적인 의존관계가 발생한다.
  • 차라리 화면 하나의 로딩 시간이 약간 증가하더라도 페치 조인을 사용한 같은 메소드를 사용하는 편이 낫다.

강제로 초기화

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

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

프록시를 초기화하는 역할을 서비스 계층이 담당하면 뷰가 필요한 엔티티에 따라 서비스 계층의 로직을 변경해야 한다. 따라서 비즈니스 로직을 담당하는 서비스 계층에서 프리젠테이션 계층을 위한 프록시 초기화 역할을 분리해야 한다. FACADE 계층이 그 역할을 담당해줄 것이다.

FACADE 계층 추가

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

FACADE 계층의 역할과 특징

  • 프리젠테이션과 도메인 모델 계층 간의 논리적 의존성을 분리해준다.

  • 프리젠테이션 계층에서 필요한 프록시 객체를 초기화한다.

  • 서비스 계층을 호출해서 비즈니스 로직을 실행한다.

  • 리포지토리를 직접 호출해서 뷰가 요구하는 엔티티를 찾는다.

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

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

뷰를 개발할 때 필요한 엔티티를 미리 초기화하는 방법은 생각보다 오류가 발생할 가능성이 높다.

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

OSIV

OSIV는 영속성 컨텍스트를 뷰까지 열어둔다는 뜻이다.

과거 OSIV: 요청 당 트랜잭션

요청 당 트랜잭션

  • 가장 단순한 구현 방법인 클라이언트의 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 트랜잭션을 시작하고 요청이 끝날 때 트랜잭션도 끝내는 방법이다.
  • 문제점
    • 프리젠테이션 계층이 엔티티를 변경할 수 있다.
    • 프리젠테이션 계층에서 데이터를 잠심 변경했다고 실제 데이터베이스까지 변경 내용이 반영되면 애플리케이션을 유지보수하기 상당히 힘들어진다.
    • 이런 문제를 해결하려면 프리젠테이션 계층에서 엔티티를 수정하지 못하게 막으면 된다.

프리제넽이션 계층에서 엔티티를 수정하지 못하게 막는 방법

  • 엔티티를 읽기 전용 인터페이스로 제공
  • 엔티티 레핑
  • DTO만 반환

엔티티를 읽기 전용 인터페이스로 제공

  • 읽기 전용 메소드만 제공하는 인터페이스를 프리젠테이션 계층에 제공하는 방법

    interface MemberView {
    public String getName();
    } 
    
      @Entity  
      class Member implements MemberView {  
      ...  
      }
    
      class MemberService {
    
      public MemberView getMember(id) {  
      return memberRepository.findById(id);  
      }  
      }

엔티티 레핑

  • 엔티티의 읽기 전용 메소드만 가지고 있는 엔티티를 감싼 객체를 만들고 이것을 프리젠테이션 계층에 반환하는 방법

    class MemberWrapper {
    
    private Member member;
    
    public MemberWrapper(member) {
      this.member = member;
    
      //읽기 전용 메소드만 제공
      public String getName() {
        member.getName();
      }
    }
    }

DTO만 반환

  • 프리젠테이션 계층에 엔티티 대신에 단순히 데이터만 전달하는 객체인 DTO를 생성해서 반환하는 것

  • 이 방법은 OSIV를 사용하는 장점을 살릴 수 없고 엔티티를 거의 복사한 듯한 DTO 클래스도 하나 더 만들어야 한다.

    class MemberDTO {
    
    private String name;
    
    //Getter, Setter
    }

차라리 프리젠테이션 계층에서 엔티티를 수정하면 안 된다고 개발자들끼리 합의하는 것이 더 실용적일 수 있다.

지금까지 설명한 OSIV는 요청 당 트랜잭션 방식의 OSIV다. 이것은 지금까지 설명했던 문제점들로 인해 최근에는 거의 사용하지 않는다.

최근에는 이런 문제점을 어느정도 보완해서 비즈니스 계층에서만 트랜잭션을 유지하는 방식의 OSIV를 사용한다.

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

스프링 프레임워크가 제공하는 OSIV 라이브러리

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

  • 하이버네이트 OSIV 서블릿 필터
  • 하이버네이트 OSIV 스플이 인터셉터
  • JPA OEIV 서블릿 필터
  • JPA OEIV 스프링 인터셉터

스프링 OSIV 분석

  • 비즈니스 계층에서 트랙잭션을 사용하는 OSIV다.
  • 서비스 계층이 끝나면 트랜잭션을 커밋하면서 영속성 컨텍스트를 플러시한다. 이때 트랜잭션만 종료하고 영속성 컨텍스트는 살려둔다. 이후 클라이언트의 요청이 끝날 때 영속성 컨텍스트를 종료한다.

트랜잭션 없이 읽기

  • 영속성 컨텍스트를 통한 모든 변경은 트랜잭션 안에서 이루어져야 한다.
  • 엔티티를 변경하지 않고 단순히 조회만 할 때는 트랜잭션이 없어도 된다.
  • 프록시를 초기화하는 지연 로딩도 조회 기능이므로 트랜직션 없이 읽기가 가능하다.
  • 스프링이 제공하는 OSIV를 사용하면 프리젠테이션 계층에서는 트랜잭션이 없으므로 엔티티를 수정할 수 없다.

OSIV 특징 정리

  • 영속성 컨텍스트를 프리젠테이션 계층까지 유지한다.
  • 프리젠테이션 계층에는 트랜잭션이 없으므로 엔티티를 수정할 수 없다.
  • 프리젠테이션 계층에는 트랜잭션이 없지만 트랜잭션 없이 읽기를 사용해서 지연 로딩을 할 수 있다.

스프링 OSIV 주의사항

  • 스프링 OSIV를 사용하면 프리젠테이션 계층에서 엔티티를 수정해도 수정 내용을 데이터베이스에 반영하지 않지만 여기에 한 가지 예외가 있다. 프리젠테이션 계층에서 엔티티를 수정한 직후에 트랜잭션을 시작하는 서비스 계층을 호출하면 문제가 발생한다.
    • 문제를 해결하는 단순한 방법은 트랜잭션이 있는 비즈니스 로직을 모두 호출하고 나서 엔티티를 변경하면 된다.
  • 스프링 OSIV는 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있으므로 이런 문제가 발생한다.

OSIV 정리

스프링 OSIV의 특징

  • 클라이언트의 요청이 들어올 때 영속성 컨텍스트를 생성해서 요청이 끝날 때까지 같은 영속성 컨텍스트를 유지한다. 따라서 한 번 조회한 엔티티는 요청이 끝날 때까지 영속 상태를 유지한다.
  • 엔티티 수정은 트랜잭션이 있는 계층에서만 동작한다. 트랜잭션이 없는 프리젠테이션 계층은 지연 로딩을 포함해서 조회만 할 수 있다.

스프링 OSIV의 단점

  • 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있다는 점을 주의해야 한다. 특히 트랜잭션 롤백 시 주의해야 한다.
  • 프리젠테이션 계층에서 지연 로딩에 의한 SQL이 실행된다. 따라서 성능 튜닝시에 확인해야 할 부분이 넓다.

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

  • 복잡한 화면을 구성할 때는 효과적이지 않은 경우가 많다.
  • 예를 들어 복잡한 통계 화면은 엔티티로 조회하기보다는 처음부터 통계 데이터를 구상하기 위한 JPQL을 작성해서 DTO로 조회하는 것이 효과적이다.
  • 그리고 수많은 테이블을 조인해서 보여주어야 하는 복잡한 관리자 화면도 객체 그래프로 표현하기 어려운 경우가 많다. 이때도 JPQL로 필요한 데이터들만 조회해서 DTO로 반환하는 것이 낫다.

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

  • 예를 들어 JSON이나 XML을 생성할 때는 지연 로딩을 사용할 수 있지만 원격지인 클라이언트에서 연관된 엔티티를 지연 로딩하는 것은 불가능하다.
  • 결국 클라이언트가 필요한 데이터를 모두 JSON으로 생성해서 반환해야 한다.
  • 이렇게 JSON으로 생성한 API는 한 번 정의하면 수정하기 어려운 외부 API와 언제든지 수정할 수 있는 내부 API로 나눌 수 있다.
    • 외부 API: 외부에 노출한다. 한 번 정의하면 변경이 어렵다. 서버와 클라이언트를 동시에 수정하기 어렵다. (타팀과 협업하기 위한 API, 타 기업과 협업하는 API)
    • 내부 API: 외부에 노출하지 않는다. 언제든지 변경할 수 있다. 서버와 클라이언트를 동시에 수정할 수 있다. (같은 프로젝트에 있는 화면을 구성하기 위한 AJAX 호출)

엔티티는 생각보다 자주 변경된다. 엔티티를 JSON 변환 대상 객체로 사용하면 엔티티를 변경할 때 노출하는 JSON API도 함께 변경된다.

따라서 외부 API는 엔티티를 직접 노출하기보다는 엔티티를 변경해도 완충 역할을 할 수 있는 DTO로 변환해서 노출하는 것이 안전하다.

내부 API는 엔티티를 변경해도 클라이언트와 서버를 동시에 수정할 수 있어서 실용적인 관점에서 엔티티를 직접 노출하는 방법도 괜찮다.

너무 엄격한 계층

OSIV를 사용하면 영속성 컨텍스트가 프리젠테이션 계층까지 살아있으므로 미리 초기화할 필요가 없다. 따라서 단순한 엔티티 조회는 컨트롤러에서 리포지토리를 직접 호출해도 아무런 문제가 없다.

EJB 시절에는 프리젠테이션 계층에 엔티티를 직접 반환하면 여러 가지 문제가 발생했다. 따라서 대부분 DTO를 만들어서 반환했고 엔티티가 계층을 뛰어넘는 것은 어려운 일이었다.

OSIV를 사용하면 설명한 것처럼 좀 더 유연하고 실용적인 관점으로 접근하는 것도 좋은 방법이라 생각한다.

+ 따끈한 최근 게시물