elevne's Study Note
JPA N+1 & OSIV 본문
스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다. 이는 트랜잭션 범위와 영속성 컨텍스트의 생존 범위가 같다는 뜻이다. (트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다. 같은 트랜잭션 안에서는 항상 같은 영속성 컨텍스트에 접근한다) 여러 스레드에서 동시에 요청이 와서 같은 엔티티 매니저를 사용해도 트랜잭션에 따라 접근하는 영속성 컨텍스트가 다르다. (스프링 컨테이너는 스레드마다 각각 다른 트랜잭션을 할당한다)
스프링이나 J2EE 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다. 트랜잭션은 보통 서비스 계층에서 시작하므로 서비스 계층이 끝나는 시점에 트랜잭션이 종료되면서 영속성 컨텍스트도 함께 종료된다. 따라서 조회한 엔티티가 서비스와 레포지토리 계층에서는 영속성 컨텍스트에 관리되면서 영속 상태를 유지하지만 컨트롤러나 뷰 같은 프레젠테이션 계층에서는 준영속 상태가 된다. 고로 만약 트랜잭션 범위의 영속성 컨텍스트 전략을 사용할 때 엔티티 내에서 지연 로딩을 사용한다면, 트랜잭션이 없는 프레젠테이션 계층에서 엔티티는 준영속 상태이므로 변경감지와 지연로딩이 동작하지 않게 되는 것이다(예외가 발생하게 됨). 변경 감지 기능은 영속성 컨텍스트가 살아있는 서비스 계층까지만 동작하고 영속성 컨텍스트가 종료된 프레젠테이션 계층에서는 동작하지 않는다.
컨트롤러에서 변경감지 기능을 사용할 수 없는 것은 큰 문제가 되지 않는다. 애플리케이션 계층이 가지는 책임을 확실하게 하여 데이터 변경 등의 비즈니스 로직은 서비스 계층에서 끝낸다면 문제가 되지 않는다. 다만, 지연 로딩이 되지 않는 것은 문제가 될 수 있다. 연관된 엔티티를 지연로딩을 설정해서 프록시 객체로 조회했다면, 트랜잭션 내에서는 아직 초기화하지 않은 프록시 객체를 사용하면 실제 데이터를 불러오려고 초기화를 시도한다. 하지만 준영속 상태에서는 영속성 컨텍스트가 없기 때문에 지연로딩을 할 수 없다. 이 문제는 아래 두 가지 방법 중 하나를 택하여 해결할 수 있다.
- 뷰가 필요한 엔티티를 미리 로드
- OSIV 를 사용하여 엔티티를 항상 영속 상태로 유지
1 번 방법을 진행할 수 있는 방법에도 여러가지 세부 방법들이 있다.
1. 글로벌 페치 전략 수정: 가장 간단한 방법은 FetchType 을 지연로딩에서 EAGER, 즉시 로딩으로 변경해주는 것이다. 하지만 이는 사용하지 않는 엔티티를 로드하는 경우가 생기고, N+1 문제를 발생시킬 수 있다. JPA 를 사용하며 성능상 가장 조심해야하는 것이 N+1 문제다. em.find() 메소드로 엔티티를 조회할 때 연관된 엔티티를 로딩하는 전략이 즉시 로딩이면 데이터베이스에 JOIN 쿼리를 사용해서 한 번에 연관된 엔티티까지 조회한다. 이 때, 여러 행을 한 번에 조회한다면 연관된 엔티티를 각 행 별로 따로따로 JPQL 을 생성하여 조회하는 현상이 발생하는 것이다. (한 번에 조회하는 행의 갯수가 N 이면 N+1 개 만큼 SQL 을 실행하게되어 N+1 문제라고 한다) 이러한 N+1 문제는 Fetch Join 을 통해 해결할 수 있다.
2. JPQL Fetch Join: 글로벌 페치 전략을 즉시 로딩으로 설정하면 애플리케이션 전체에 영향을 주므로 비효율적이다. JPQL 을 호출하는 시점에 함께 로드할 엔티티를 선택할 수 있는 Fetch Join 을 사용할 수 있다. Fetch Join 은 조인 명령어 마지막에 fetch 를 넣어주기만 하면 된다.
SELECT o FROM Order o JOIN FETCH o.member
Fetch Join 을 사용하면 페치 조인 대상까지 함께 조회한다. (따라서 N+1 문제가 발생하지 않는다) 이러한 Fetch Join 에도 단점이 있다. 바로 뷰와 레포지토리 간 의조관계가 발생한다는 것이다. (각각의 화면의 상황에 따라 다른 데이터 접근 메소드를 만들어아하기 때문이다) 다른 대안으로 repository 에는 메소드 하나만 만들고, 여기서 페치조인으로 데이터를 전부 읽어오게끔 할 수 있다. 그리고 모든 화면에 대해 그 하나의 메소드를 사용하게끔 하는 것이다. 물론 엔티티 하나만 필요한 화면에서는 약간의 로딩 시간이 증가하겠지만, 페치조인은 JOIN 을 사용해서 쿼리 한 번으로 필요한 데이터를 조회하므로 성능에 미치는 영향이 미비하다. 무분별한 최적화로 프레젠테이션 계층과 데이터 접근 계층 간 의존관계가 급격하게 증가하는 것보다는 적절한 선에서 타협점을 찾을 필요가 있다.
3. 강제로 초기화: 이는 영속성 컨텍스트가 살아있을 때 프레젠테이션 계층에 필요한 엔티티를 강제로 초기화해서 반환하는 방법이다. order.getMember().getName() 과 같이 엔티티의 필드에 접근해서 프록시 객체를 초기화해주거나, 하이버네이트에서는 initialize() 메소드를 사용해 프록시를 강제로 초기화한다.
그런데 프록시를 초기화하는 역할을 서비스 계층이 담당하면 뷰가 필요한 엔티티에 따라 서비스 계층의 로직을 변경해야 한다. 이렇게 되면 프레젠테이션 계층이 서비스 계층을 침범한다. 서비스 계층은 비즈니스 로직을 담당해야지, 이렇게 프레젠테이션 계층을 위한 일까지 하는 것은 좋지 않다. 따라서 비즈니스 로직을 담당하는 서비스 계층에서 프레젠테이션 계층을 위한 프록시 초기화 역할을 분리해야 한다. FACADE 계층이 그 역할을 담당한다.
FACADE 는 프레젠테이션, 서비스 계층 사이에 위치한다. 뷰를 위한 초기화는 이곳에서 담당한다. 서비스 계층은 프레젠테이션 계층을 위해 프록시를 초기화하지 않아도 된다. 프록시를 초기화하려면 영속성 컨텍스트가 필요하므로 FACADE 에서 트랜잭션을 시작해야 한다.
@Component
public class MemberFacade {
@Autowired MemberService memberService;
public Member findMember(Long id) {
Member member = memberService.findMember(id);
member.getPosts().getTitle();
return member;
}
}
FACADE 계층을 사용해 서비스 계층과 프레젠테이션 계층 간에 논리적 의존관계를 제거한다. 하지만 이 방식 또한 코드를 매번 작성하기 매우 번거롭다는 단점이 있다.
위 모든 문제들은 엔티티가 프레젠테이션 계층에서 준영속 상태이기 때문에 발생한다. 영속성 컨텍스트를 뷰까지 살아있게 해주면 해결된다. 그럼 뷰에서도 지연로딩을 사용할 수 있다. 이것이 OSIV 이다. OSIV (Open Session in View) 는 영속성 컨텍스트를 뷰에서까지 열어둔다는 뜻이다. 따라서 뷰에서도 지연로딩을 할 수 있다.
이를 사용하는 가장 단순한 구현방법은 클라이언트의 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 트랜잭션을 시작하고, 요청이 끝날 때 트랜잭션도 끝내는 것이다. 하지만 이렇게하면 뷰 같은 프레젠테이션 계층에서도 엔티티를 변경할 수 있다. 프레젠테이션 계층에서 데이터를 잠시 변경했다고 실제 데이터베이스까지 변경내용이 반영되면 애플리케이션을 유지보수하기 상당히 힘들어진다. 이러한 문제를 막기 위해서는 프레젠테이션 계층에서 엔티티를 수정하지 못하게 막으면 된다.
- 엔티티를 읽기전용 인터페이스로 제공
- 엔티티 래핑
- DTO 반환
1 번 방법은, Member 엔티티를 MemberView 와 같은 인터페이스를 구현하게끔 하고 해당 인터페이스 타입으로 프레젠테이션 게층에 넘기는 것이다. MemberView 내에는 읽기 전용 메소드만 넣어 수정할 수 없게끔 막는 것이다. 2 번 방법은 엔티티의 읽기 전용 메소드만 갖고 있는 엔티티를 감싼 객체를 만들고, 이를 프레젠테이션 계층에 반환하는 방법이다. (Member 객체를 멤버변수로 갖는 MemberWrapper 클래스를 넘겨주기) 3 번 DTO 는 전통적인 방법으로, 엔티티 대신에 단순히 데이터만 전달하는 객체인 DTO 를 생성해서 반환하는 것이다. 하지만 이 방법은 OSIV 를 사용하는 장점을 살릴 수 없고 엔티티를 거의 복사한 듯한 DTO 클래스도 하나 더 만들어야 한다는 단점이 있다. 위 세 방법 모두 코드량이 상당히 많다.
최근에는 이를 보완하여 비즈니스 계층에서만 트랜잭션을 유지하는 방식의 OSIV 를 사용한다. (스프링 프레임워크가 제공하는 OSIV 가 바로 이 방식을 사용) Spring 의 spring-orm.jar 는 다양한 OSIV 클래스를 제공한다. OSIV 를 서블릿 필터에서 적용할지 스프링 인터셉터에서 적용할지에 따라 원하는 클래스를 선택해서 사용한다. (OpenSessionInViewFilter, OpenSessionInViewInterceptor, OpenEntityManagerInViewFilter, OpenEntityManagerInViewInterceptor)
클라이언트의 요청이 들어오면 영속성 컨텍스트를 생성한다. 이 때 트랜잭션은 시작하지 않는다. 서비스 계층에서 트랜잭션을 시작하면 앞에서 생성해둔 영속성 컨텍스트에 트랜잭션을 시작한다. 비즈니스 로직을 실행하고 서비스 계층이 끝나면 트랜잭션을 커밋하면서 영속성 컨텍스트를 플러시한다. 이 때 트랜잭션만 종료하고 영속성 컨텍스트는 살려둔다. 이후 클라이언트의 요청이 끝날 때 영속성 컨텍스트를 종료하는 것이다.
영속성 컨텍스트를 통한 모든 변경은 트랜잭션 내에서 이루어져야 한다. 만약 트랜잭션 없이 엔티티를 변경하고 영속성 컨텍스트를 플러시하면 TransactionRequiredException 예외가 발생한다. 엔티티를 변경하지 않고 단순히 조회만 할 때에는 트랜잭션이 없어도 되는데, 이를 Nontransactional Reads 라고 한다.
스프링 OSIV 를 사용하면 프레젠테이션 계층에서 엔티티를 수정해도 수정 내용을 데이터베이스에 반영하지 않는다. 그런데, 한 가지 예외로 프레젠테이션 계층에서 엔티티를 수정한 직후에 트랜잭션을 시작하는 서비스 계층을 호출하면 문제가 발생한다. 그렇게되면, 해당 영속성 컨텍스트를 플거시하며 변경 감지가 동작하기 때문에 수정 사항이 데이터베이스에 반영된다.
Reference:
자바 ORM 표준 JPA 프로그래밍
'Backend > JPA 프로그래밍' 카테고리의 다른 글
JPA 고급주제와 성능 최적화 (0) | 2023.07.27 |
---|---|
JPA 컬렉션과 부가기능 (0) | 2023.07.26 |
Spring Data JPA (0) | 2023.07.19 |
Spring Maven 설정 (0) | 2023.07.17 |
JPA - NativeSQL (0) | 2023.07.16 |