BackEnd/JPA

[JPA] Open Session In View 더 깊게

샤아이인 2022. 9. 11.

 

사실 예전에 이미 OSIV에 대한 글을 작성한 적이 있다.

https://blogshine.tistory.com/379

 

[JPA] Open Session In View (OSIV)

내가 공부한 것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼 겸 상세히 기록하고 얕은 부분들은 가볍게 포스팅하겠습니다. 1. OSIV와 성능 최적화 " data-ke-type="html"> <>HTML 삽입 미리보기할 수 없

blogshine.tistory.com

위의 지난 번 글은, 영한님의 강의를 주로 학습하면서 정리한 글이었다.

 

하지만 이번 글에서는 지난번 글에서는 학습하지 못했었던 부분을 추가해볼까 한다.

 

1. OSIV 이전의 문제점

Spring이나 J2EE 컨테이너 환경에서 JPA를 사용하면 트랜잭션 범위와 영속성 컨텍스트의 생존 범위가 같다.

출처 - 인프런 김영한 JPA 활용 2편

같은 트랜잭션 안에서는 항상 같은 영속성 컨택스트에 접근한다.

이 전략은 트랜잭션이라는 단위로 영속성 컨텍스트를 관리하므로 트랜잭션을 커밋하거나 롤백할 때 문제가 없다.

 

하지만 이 전략은 Presentation 계층에서 Entity가 준영속 상태가 되므로 지연 로딩을 할 수 없다는 문제가 있다.

따라서 View가 필요한 Entity를 미리 로딩하는, 즉 Service 계층에서 필요한 데이터를 전부 초기화한 후 Controller로 반환해야 한다.


OSIV는 Entity가 Controller 계층에서 준영속 상태이기 때문에 발생하는 문제를 해결하기 위해 영속성 컨텍스트를 뷰까지 살아있게 열어두는 방식이다. 그럼 뷰에서도 Lazy Loading을 사용할 수 있게 된다.

 

이러한 OSIV가 어떠한 상황에서 나오게 되었는지 그 기반을 먼저 생각해보자.

 

우선, 준영속 상태의 가장 큰 문제는 지연 로딩 기능이 동작하지 않는다는 점이다.

예를 들어 View를 랜더링 할 때 연관된 Entity도 함께 사용해야 하는데, 연관된 Entity들이 Lazy 로딩으로 설정돼서 Proxy객체로 조회됐다고 해보자.

 

아직 초기화가 되지 않았기 때문에 해당 프록시의 메서드를 호출하면 실제 데이터를 불러오려고 초기화를 시도한다.

하지만, 준영속 상태는 영속성 컨택스트가 없어 지연 로딩을 할 수 없다.

이때 지연 로딩을 시도하면 LazyInitializationException이 발생하게 된다.

 

이를 해결하는 방식으로는 다음과 같은 방식들이 있다.

 

  1. View가 필요한 Entity를 미리 로딩하는 방식
    • Gloabal Fetch 전략 수정
    • JPQL 페치 조인
    • 강제로 초기화
    • FACADE 계층 추가
  2. OSIV를 사용해서 Entity를 항상 영속 상태로 유지하는 방식

 

이번 글에서는 2번 방식(OSIV)에 초점을 집중하자!

 

2. 과거 OSIV: 요청 당 트랜잭션

과거에는 OSIV가 켜져 있으면 클라이언트 요청이 올 때 서블릿 필터나 스프링 인터셉터에서 트랜잭션이 시작되면서 영속성 컨텍스트도 시작되고, 요청이 끝날 때 트랜잭션과 영속성 컨텍스트가 함께 종료되었다.

 

이를 요청 당 트랜잭션 방식의 OSIV라고 한다.

 

하지만 이방식은 문제점이 있다.

Controller나 View 같은 Presentation 계층이 Entity를 변경할 수 있다는 점이다.

 

예를 들어 다음과 같이 보안상의 이유로 고객 이름을 xxx로 변경해서 출력해야 한다고 가정해보자.

class MemberControler {

    public String viewMember(Long id) {
        Member memeber = memberService.getMember(id);
        member.setName("xxx"); // 보안상의 이유로 고객이름 가리기
        model.addAttribute("member", member);
    }
}

컨트롤러에서 고객의 이름을 "xxx"로 변경해서 View로 넘겨주었다.

 

문제는 뷰를 렌더링 한 후 트랜잭션을 commit 한다는 점이다.

따라서 영속성 컨택스트는 Flush 되고 변경 감지 기능을 통해 변경사항을 반영하여 DB에 저장되어 버린다.

 

고객의 이름이 "xxx"로 변경되는 심각한 문제가 발생하게 된 것이다.

 

이를 해결하기 위한 방식은 다음과 같다.

  1. Entity를 읽기 전용 인터페이스로 제공
  2. Entity 래핑 하기
  3. DTO만 반환하기

 

하지만 최근에는 이러한 방식을 거의 사용하지 않는다.

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

Spring은 이러한 방식의 OSIV를 사용한다.

 

3. Spring의 OSIV: 비즈니스 계층 트랜잭션

3 - 1) Spring이 제공하는 OSIV

Spring 제공하는 비즈니스 계층 트랜잭션의 그림을 살펴보자.

출처 - 인프런 김영한 JPA 활용 2편

  1. Client의 요청이 들어오면 Servlet Filter나 Spring interceptor에서 영속성 컨텍스트를 생성한다. (트랜잭션은 시작 안 함)
  2. 서비스 계층에서 @Transational로 트랜잭션을 시작할 때 1번에서 미리 생성한 영속성 컨텍스트를 찾아와서 트랜잭션을 시작한다.
  3. Service 계층이 끝나면 트랜잭션을 commit 하고, 영속성 컨텍스트를 flush 한다. 이때 트랜잭션만 종료되고, 영속성 컨텍스트는 유지된다.
  4. 조회된 Entity는 영속성 컨텍스트가 유지되므로 영속 상태를 유지한다.
  5. Servlet Filter나 Spring interceptor로 요청이 반환되면 영속성 컨텍스트를 종료한다. 이때 Flush는 호출하지 않고 바로 종료한다.

 

즉, Presentation 계층에서 트랜잭션 없이 조회하게 되는 것이다.

이를 트랜잭션 없이 읽기(Nontransactional reads)라고 부른다. 단순 조회만 할 때는 트랜잭션이 없어도 되는 것이다.

프록시를 초기화하는 지연 로딩도 조회 기능이므로 트랜잭션 없이 읽기가 가능하다.

 

따라서 Presentation 계층에서 트랜잭션이 없으니 Entity를 수정할 수 없게 되었고, 트랜잭션 없이 읽기를 통해 지연 로딩의 기능은 사용할 수 있게 되었다.

 

위에서 살펴본 코드를 다시 한번 살펴보자.

class MemberControler {

    public String viewMember(Long id) {
        Member memeber = memberService.getMember(id);
        member.setName("xxx"); // 보안상의 이유로 고객이름 가리기
        model.addAttribute("member", member);
    }
}

회원의 이름을 변경한 후, 아직 영속성 컨택스트가 살아있다.

따라서 이름을 변경 한 후, flush()를 호출하면 변경 감지로 인하여 DB에 반영되는 것 아닐까?

 

아니다!

 

Service계층이 끝날 때 트랜잭션이 commit 되면서 이미 flush 해버렸다.

그리고 Spring이 제공하는 OSIV 서블릿 필터나, 인터셉터는 종료 시 em.close()만 호출한다. flush()는 호출되지 않는다.

 

설령 Presentaion계층에서 em.flush()를 직접 호출한다 해도 트랜잭션이 범위 밖이기 때문에 데이터를 수정할 수 없는 예외가 발생한다.

javax.persistence.TransactionRequiredException

 

3 - 2) Spring OSIV 주의사항 

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

트랜잭션 AOP가 동작하며 트랜잭션이 실행되고 해당 트랜잭션이 끝날 때 변경 감지가 동작하면서 엔티티에서 수정한 부분이 데이터베이스에 반영되어 버린다.

=> 비즈니스 로직을 먼저 수행한 뒤 엔티티를 변경하면 된다.

 

조금 더 근본적인 문제인데, OSIV가 켜져 있으면 뷰 렌더링이 이루어지거나 API가 유저에게 반환될 때까지 영속성 컨텍스트와 데이터베이스 커넥션이 유지된다.

따라서 실시간 트래픽이 중요한 애플리케이션에서는 connection이 부족할 수 있다. 이는 결국 장애로 이어진다.

 

극적인 예로 Controller에서 외부 API를 호출한다고 생각해보자.

해당 외부 API를 호출하고 응답받기까지 3초가 걸린다면 connection 또한 3초 이상 반환되지 못하고 대기 중이다.

 

4. 해결책 : 커맨드와 쿼리를 분리

실무에서 OSIV를 끈 상태로 복잡성을 관리하는 좋은 방법이 있다. 바로 Command와 Query를 분리하는 것이다.

즉, Command Query Separation (CQS)로 DB의 데이터를 업데이트하는 명령과 조회하는 쿼리를 분리하는 것이다!

대부분의 경우 조회하는 과정에서 문제가 발생한다.

 

일반적으로 비즈니스 로직에서 Entity 몇 개를 등록하거나 수정하는 명령은 성능이 크게 문제 되지 않는다.

그런데, 복잡한 화면을 출력하기 위한 쿼리는 화면에 맞추어 성능을 최적화하는 것이 중요하다.

하지만 그 복잡성에 비해 핵심 비즈니스에 큰 영향을 주는 것은 아니다.
그래서 크고 복잡한 애플리케이션을 개발한다면, 이 둘의 관심사를 명확하게 분리하는 선택은 유지보수 관점에서 충분히 의미 있다.

 

예를 들어 다음처럼 분리하는 것이다.

OrderService
- OrderCommandService: 핵심 비즈니스 로직 수행
- OrderQueryService: 화면이나 API에 맞춘 서비스 (주로 읽기 전용 트랜잭션 사용, @Transactional(readOnly = true)를 설정)

 

그럼 언제 OSIV를 켜고, 꺼야 할까?

고객 서비스처럼 실시간 API는 OSIV를 끄고, ADMIN처럼 커넥션을 많이 사용하지 않는 곳에서는 OSIV를 켠다.

 

댓글