본문 바로가기

Dot Programming/JPA

[Spring] 스프링 JPA의 OSIV 전략 - 트랜잭션, 영속성 컨텍스트 생명주기

    OSIV(Open Session In View)

    OSIV는 영속성 컨텍스트를 뷰까지 열어둔다는 뜻이다. 영속성 컨텍스트가 살아있으면 엔티티는 영속 상태로 유지된다. 따라서 뷰에서도 지연 로딩을 사용할 수 있다. 참고로 OSIV는 Hibernate 용어다. JPA는 OEIV(Open EntityManager In View)라 하지만 관례상 모두 OSIV로 부른다.

     

    과거 OSIV: 요청 당 트랜잭션

    OSIV의 핵심은 뷰에서도 지연 로딩이 가능하도록 하는 것이다. 가장 단순한 구현 방법은 클라이언트의 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 트랜잭션을 시작하고 요청이 끝날 때 트랜잭션도 끝나는 것이다. 이것을 요청 당 트랜 잭션방식의 OSIV라 한다.

     

    과거 OSIV: 요청 당 트랜잭션

    1. 요청이 들어오면 서블릿 필터나 스프링 인터셉터에서 영속성 컨텍스트를 만들면서 트랜잭션을 시작한다.
    2. Controller, Service, Repository 층에서 모두 영속성 컨텍스트가 유지된다.
    3. 조회된 엔티티 또한 뷰까지 영속 상태를 유지하므로 지연 로딩이 가능하다. 미리 초기화할 필요가 없다.
      1. 뷰에서도 지연 로딩을 할 수 있으므로 FACACDE 계층 없이도 뷰에 독립적인 서비스 계층을 유지할 수 있다.
    4. 요청이 끝날 때 트랜잭션과 영속성 컨텍스트를 함께 종료한다.

     

    문제점

    요청 당 트랜잭션 방식 OSIV가 가지는 문제점은 컨트롤러나 뷰 같은 프레젠테이션 계층이 엔티티를 변경할 수 있다는 점이다. 예를 들어, 보안상의 이유로 고객 이메일을 Base64로 인코딩해서 출력해야 한다고 가정하자. 렌더링할 때 뷰에 넘겨주었는데 개발자 의도와는 다르게 DB의 고객 이메일까지 인코딩된 주소로 변경되었다.

     

    요청 당 트랜잭션 방식은 뷰를 렌더링한 후에 트랜잭션을 커밋한다. 트랜잭션을 커밋하면 당연히 영속성 컨텍스트를 flush하면서 dirty check 기능이 동작하여 변경된 엔티티의 값을 DB에 반영해버린다. 결국 고객 데이터가 변경되는 심각한 문제가 발생한다.

     

    해결법

    이에 대한 해결법으로는 모두 엔티티를 직접 노출하는 대신에 다른 방식으로 읽기 전용 메소드 혹은 객체를 사용하는 방법이 있다. 그러한 방식에는 여러가지가 있다.

    1. 엔티티를 읽기 전용 인터페이스로 제공: 읽기 전용 메서드(getter)만 제공하는 인터페이스를 만든다.
    2. 엔티티 레핑: 1번과 유사한 방식으로, 읽기 전용 메서드(getter)만 가지고 있는 엔티티를 감싼 객체(EntityWrapper)를 만든다.
    3. DTO만 반환: 가장 전통적인 방식으로 엔티티 대신 단순한 데이터 전달 객체(DTO; ata Transfer Object)를 생성해서 반환한다. 하지만 이 방법은 OSIV의 장점을 살릴 수 없고 엔티티를 거의 복사한 듯한 DTO 클래스도 하나 더 만들어야 한다.

     

    이러한 해결법들이 존재하지만 모두 코드량이 증가한다는 단점이 있다. 차라리 프레젠테이션 계층에서 엔티티를 수정하면 안 된다고 개발자끼리 합의하는 것이 더 실용적일 수 있다. 또는 적절한 도구를 사용하여 컴파일 단계에서 프레젠테이션 계층에서 사용되는 setter를 잡아내는 방식도 있지만 쉽지는 않다.

     

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

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

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

     

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

     

    스프링 OSIV 특징

    스프링이 제공하는 OSIV를 사용하면 프레젠테이션 계층에서는 트랜잭션이 없으므로 엔티티를 수정할 수 없다. 따라서 프레젠테이션 계층에서 엔티티를 수정할 수 있는 기존 OSIV의 단점을 보완했다. 그리고 트랜잭션 없이 읽기를 사용해서 프레젠테이션 계층에서 지연 로딩 기능을 사용할 수 있다.

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

     

    프레젠테이션 계층에서 강제로 플러시한다면?

    만약 프레젠테이션 계층에서 엔티티의 값을 변경하고 강제로 플러시를 해도 2가지의 이유로 동작하지 안흔다.

    1. 트랜잭션을 사용하는 서비스 계층이 끝날 때 트랜잭션이 커밋되면서 이미 플러시해버렸다. 그리고 스프링이 제공하는 OSIV 서블릿 필터나 OSIV 스프링 인터셉터는 요청이 끝나면 플러시를 호출하지 않고 em.close()로 영속성 컨텍스트만 종료해 버리므로 플러시가 일어나지 않는다.
    2. 프레젠테이션 계층에서 em.flush()로 강제로 플러시해도 트랜잭션 범위 밖이므로 데이터를 수정할 수 없다는 TransactionRequireException 예외가 발생한다.

     

    스프링 OSIV 주의사항

    스프링 OSIV는 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있으므로 한 가지 예외가 있다. 프레젠테이션 계층에서 엔티티를 수정한 후 트랜잭션을 시작하는 서비스 계층을 호출하면 문제가 발생한다. 

    • OSIV를 사용하지 않는 트랜잭션 범위의 영속성 컨텍스트 전략은 트랜잭션의 생명주기와 영속성 생명주기가 같으므로 이런 문제가 발생하지 않는다.

    스프링 OSIV 주의사항

    1. 컨트롤러에서 회원 이메일을 조회하고 해당 값을 수정했다.
    2. biz() 메서드를 실행해서 트랜잭션이 있는 비즈니스 로직을 실행했다.
    3. 트랜잭션 AOP가 동작하면서 영속성 컨텍스트에 트랜잭션을 시작한다. 그리고 biz() 메서드를 실행한다.
    4. biz() 메서드가 끝나면 트랜잭션 AOP는 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시한다. 이때 변경 감지가 동작하면서 회원 엔티티의 수정 사항을 데이터베이스에 반영한다.

     

    이러한 예외 사항을 피하기 위해서는 컨트롤러에서 엔티티 값을 변경하는 로직이 있을 때는 항상 주의해야 한다. 만약 변경하는 로직이 있을 때는 트랜잭션이 있는 비즈니스 로직을 모두 실행한 후 뷰에 렌더링하기 직전에 엔티티 값을 변경해주면 된다. 

     

    엄격한 계층 부수기: 직접 Repository 조회

    OSIV를 사용하기 전에는 프레젠테이션 계층에서 사용할 지연 로딩되니 엔티티를 미리 초기화해야 했다. 그리고 초기화는 아직 영속성이 살아있는 서비스 계층이나 FACADE 계층이 담당했다. 하지만 OSIV를 사용하면 영속성 컨텍스트가 프레젠테이션 계층까지 살아있으므로 Repository를 직접 호출해도 아무런 문제가 없다.

    엄격한 계층 부수기: 직접 Repository 조회

     

    OSIV는 항상 옳은가? 

    서비스 성능을 알아 볼 때 초당 몇 개의 클라이언트 요청을 처리(TPS)하는지 측정한다. 중요한 부분 중 하나가 서버와 DB에서 처리되는 시간이다. DB와 연결하여 얼마나 빨리 데이터를 읽고 쓰기 처리하냐는 점도 중요하다. 하지만 그전에 서버와 DB 커넥션이 정상적으로 동작해야 한다. 

     

    문제점

    동시 접속 유저가 많아질수록 커넥션 풀이 넉넉해야 TPS가 높아진다. 하지만 OSIV 전략을 사용하면 커넥션을 효율적으로 사용하지 못하게 된다. 왜냐하면 JPA 영속성 컨텍스트는 DB와 1:1로 연결되어 커넥션 하나를 종료될 때 까지 계속 사용하기 때문이다.

     

    기본적으로 트랜잭션을 시작할 때 영속성 컨텍스트가 DB 커넥션을 가져온다. 커넥션의 생명은 영속성 컨텍스트가 종료될 때까지 유지된다. 즉, 스프링 OSIV 전략을 사용하면 API 응답이 끝날 때까지 유지하게 된다. 왜냐하면 트랜잭션이 끝나더라도 지연 로딩으로 프록시 객체를 초기화할 상황이 생기기 때문이다. 따라서 영속성 컨텍스트가 DB 커넥션을 계속 물고 있어야 한다.

     

    API 응답은 결과가 뷰에 렌더링되어 유저에게 반환될 때까지이다. 이러한 점이 OSIV의 치명적인 단점이다. 너무 오랫동안 DB 커넥션을 물고 있기 때문에 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 모자라서 장애로 이어질 수 있다.

     

    해결법

    OSIV는 지연 로딩을 적극적으로 활용할 수 있다는 장점이 있지만 API 응답이 길어지면 길어질수록 부하가 생길 확률이 높아진다. 그래서 유저 트래픽이 많은 실시간 API 서버는 OSIV를 끄는 것이 좋을 수 있다.

     

    OSIV를 끈 상태로 복잡성을 관리하려면 CQRS 패턴(Command Query Responsibility Segregation)을 사용하여 커맨드와 쿼리를 분리하는 것이 좋다. (참고)

    • Command: MemberService에는 핵심 비즈니스 로직을 사용
    • Query: MemberQueryService에는 화면이나 API에 맞춘 서비스로 구현하여 읽기 전용 트랜잭션을 사용
    • 보통 서비스 계층에서 트랜잭션을 유지하기 때문에 두 서비스 모두 트랜잭션을 유지하면서 지연 로딩을 사용할 수 있다.

     

    정리

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

    • 스프링이나 J2EE 환경에서 JPA를 사용하면 트랜잭션과 영속성 컨텍스트의 생명주기가 같은 트랜잭션 범위의 영속성 컨텍스트 전략이 적용된다. 그리고 같은 트랜잭션 안에서는 항상 같은 영속성 컨텍스트에 접근한다. 이 전략의 유일한 단점은 프레젠테이션 계층에서 엔티티가 준영속 상태가 되므로 지연 로딩을 할 수 없다는 점이다.

     

    OSIV를 사용하면 좀 더 유연하고 실용적인 관점으로 접근할 수 있다. 

    • 기존 OSIV는 프레젠테이션 계층에서도 엔티티를 수정할 수 있다는 단점이 있었다.
    • 스프링 프레임워크가 제공하는 OSIV는 기존 OSIV의 단점을 해결해서 프레젠테이션 계층에서 엔티티를 수정하지 않는다.

     

    그렇다고 OSIV가 항상 옳은 것은 아니다. OSIV를 사용하면 지연 로딩을 쉽게 사용한다는 장점이 있지만 DB 커넥션과 영속성 컨텍스트의 생명주기는 같기 때문에 특정한 경우(동시 접속 트래픽이 많은 실시간 API 서버) 치명적인 단점이 존재한다.

    • 유저 트래픽이 많은 실시간 API 서버는 OSIV를 사용하는 것이 비효율적일 수 있다. 커넥션이 유지되는 시간이 길어져 성능 대비 커넥션 풀을 무리하게 늘려야 할 수 있기 때문이다.
    • 실무에서는 그러한 경우 OSIV를 끄고 CQRS 패턴으로 커맨드와 쿼리를 분리하여 복잡성을 해결한다고 한다.

     


    참고

    자바 ORM 표준 JPA 프로그래밍

    https://dodeon.gitbook.io/study/kimyounghan-spring-boot-and-jpa-optimization/04-osiv

    https://youtu.be/fSXh4hWJtKo