✅ 트랜잭션 범위의 영속성 컨텍스트
🔹스프링 컨테이너의 기본 동작
- 스프링은 트랜잭션 범위의 영속성 컨텍스트를 기본 전략으로 사용한다. 이 전략은 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다.
- 트랜잭션 범위와 영속성 컨텍스트의 생존 범위가 동일하며, 트랜잭션 종료 시, 영속성 컨텍스트도 동일하게 종료하게 된다.
스프링 어플리케이션에서는 @Transactional을 사용하면 호출한 메소드를 실행하기 직전에 스프링의 트랜잭션 AOP가 먼저 동작한다. 스프링 트랜잭션 AOP는 대상 메소드를 호출하기 직전에 트랜잭션을 시작하고, 대상 메소드가 정상 종료되면 트랜잭션을 커밋하면서 종료한다. 이때 영속성 컨텍스트로 동일한 라이프 사이클을 가지게 된다.
- 스프링 트랜잭션 AOP는 대상 메소드를 호출하기 직전에 트랜잭션을 시작하며, 호출이 정상적으로 종료되면 트랜잭션을 커밋하고 종료하게 된다.
- 트랜잭션 커밋 시, JPA는 영속성 컨텍스트를 플러시하여 변경 내용을 DB에 반영한 후 트랜잭션을 커밋하게 된다.
- 예외가 발생하게 되면, 트랜잭션을 롤백하고 종료하게 되는데, 이때는 영속성 컨텍스트를 플러시하지 않게 된다.
🔻트랜잭션이 같을 경우, 같은 영속성 컨텍스트를 사용한다
다양한 위치에서 엔티티 매니저(EntityManager)를 주입받아 사용해도 트랜잭션이 같으면 항상 같은 영속성 컨텍스트를 사용하게 된다. 동일한 트랜잭션 A를 사용하는 Repository 1과 2는 서로 다른 EntityManager을 주입 받았더라도 동일한 영속성 컨텍스트를 사용하게 된다.
🔻트랜잭션이 다를 경우, 다른 영속성 컨텍스트를 사용한다
여러 스레드에서 동시에 요청에 올 경우, 같은 엔티티 매니저를 사용한다고 하더라도 트랜잭션에 따라 접근하는 영속성 컨텍스트가 달라진다. 스프링 컨테이너에서는 스레드별로 각기 다른 트랜잭션을 할당하게 되는데, 따라서 같은 엔티티 매니저를 호출해도 접근하는 영속성 컨텍스트가 다르기 때문에 멀티스레드 환경에서도 안전하다.
✅ 준영속과 지연 로딩
class OrderController {
public String view(Long orderId) {
Order order = orderService.findOne(orderId); // order.member은 프록시 객체로 생성된다
Member member = order.getMember(); // member에 프록시 객체를 반환받는다
member.getName(); // 지연 로딩 시 예외 발생
...
}
}
조회한 엔티티가 트랜잭션 범위인 서비스, 레파지토리 레이어에서는 영속성 컨텍스트에 의해 관리가 되기 때문에 Order 엔티티가 영속 상태를 유지하지만, 이 밖의 레이어에서는 준영속 상태가 된다.
🔻영속성 컨텍스트의 관리 밖에서는 지연 로딩 및 변경 감지가 동작하지 않는다.
OrderController는 트랜잭션이 동작하지 않았다. 따라서 스프링의 기본적인 트랜잭션과 영속성 컨텍스트의 범위가 같은 전략에서는 반환된 Order 객체는 OrderService의 트랜잭션이 종료되면서 준영속 상태, 더 이상 영속성 컨텍스트에서 관리하지 않는 객체로 반환된다. 지연 로딩(Fetch.Lazy)에서는 연관관계에 있는 객체는 프록시 객체로 반환되고, 실제 호출 시에 초기화된다. 하지만 준영속 상태에서는 프록시 객체를 초기화하기 위해 DB에 조회할 수 없다.
지연 로딩 기능이 동작하지 않기 때문에 생기는 이슈는 생각보다 크다. 만약 컨트롤러와 같은 레이어에서 지연 로딩 상태의 연관 엔티티나 밸류를 조회하게 될 경우(member.getName()
), LazyIntializationException 예외가 발생하게 된다.
🔹글로벌 페치 전략 수정
🔻글로벌 페치 전략을 지연 로딩에서 즉시 로딩으로 변경한다.
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩 전략
private Member member; // 주문 회원
...
}
글로벌 페치 전략을 지연 로딩에서 즉시 로딩으로 변경하면 연관된 모든 엔티티를 조회해서 반환한다. Order 엔티티가 가지고 있는 모든 Member 엔티티까지 조회해서 가지고 오기 때문에 준영속 상태의 Order 엔티티라도 Member 엔티티에 대한 정보를 모두 가지고 있어 객체 그래프 탐색 시 오류가 발생하지 않는다.
➡️ 하지만 글로벌 페치 전략으로 즉시 로딩을 사용하면 다음과 같은 단점이 있다
- 사용하지 않는 엔티티를 로딩한다.
화면 B에서는 order 엔티티만 있으면 충분하지만 즉시 로딩 전략으로 인해 필요하지 않은 member도 함께 조회하게 된다. 즉, 불필요한 SQL이 발생하게 되고, 이는 시스템의 리소스를 갉아먹는다.
- N+1 문제가 발생한다.
JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 페치 전략을 참고하지 않고 오직
JPQL
자체만 사용한다. 따라서 Join을 사용하지 않고 Member을 조회한다. ORDER 테이블의 레코드 개수만큼 MEMBER 테이블에서 FK를 통해서 조회하는 쿼리를 발생시킨다.List<Order> orders = em.createQuery("select o from Order o", Order.class) .getResultList(); // 연관된 모든 엔티티를 조회한다. // 생성된 SQL // select * from Order // JPQL로 실행된 SQL // select * from Member where id=? // EAGER로 실행된 SQL // select * from Member where id=? // EAGER로 실행된 SQL // select * from Member where id=? // EAGER로 실행된 SQL // select * from Member where id=? // EAGER로 실행된 SQL // select * from Member where id=? // EAGER로 실행된 SQL ...
select o from Order o
JPQL을 분석해서select * from Order
SQL을 생성한다.
- 데이터베이스에서 결과를 받아
order
엔티티 인스턴스들을 생성한다.
Order.member
의 글로벌 페치 전략이 즉시 로딩이므로order
를 로딩하는 즉시 연관된member
도 로딩해야 한다.
- 연관된
member
를 영속성 컨텍스트에서 찾는다.
- 만약 영속성 컨텍스트에 없으면
SELECT * FROM MEMBER WHERE id=?
SQL을 조회한order
엔티티 수만큼 실행한다.
👉 Order 엔티티를 조회하기 위해 N개의 Member 엔티티도 조회해야하는 N+1의 문제가 발생했다.
🔹JPQL 페치 조인
JPQL:
select o
from Order o
join fetch o.member
SQL:
select o.*, m.*
from Order o
join Member m on o.MEMBER_ID = m.MEMBER_ID
위 JPQL의 페치 조인은 Inner 조인으로 실행되었다. Inner 조인은 연관관계가 있는 엔티티만 조인해서 반환한다. 그림으로 그려보면 아래와 같다.
페치 조인이 현실적인 대안이긴 하지만 무분별하게 사용하면 화면에 맞춘 리포지터리 메소드가 증가할 수 있다.
- 화면 A를 위해 order만 조회하는
repository.findOrder()
메소드
- 화면 B를 위해 order와 연관된 member를 페치 조인으로 조회하는
repository.findOrderWithMember()
메소드
이제 화면 A와 화면 B에 각각 필요한 메소드를 호출하면 된다. 이처럼 메소드를 각각 만들면 최적화는 할 수 있지만 뷰와 리포지터리 간에 논리적인 의존관계가 발생한다. 무분별한 최적화로 프리젠테이션 계층과 데이터 접근 계층 간에 의존과계가 급격하게 증가하는 것보다는 적절한 선에서 타협점을 찾는 것이 합리적이다.
❗엔티티를 강제로 초기화하는 방법과 FACADE 계층을 사용하는 방법은 여기에 정리하지 않았다. 현재 잘 쓰이지 않는 것은 물론, 프레젠테이션 계층이 서비스 계층을 침범하거나, 복잡성이 증가하는 문제가 있기 때문이다.
🔹OSIV
결국에 모든 문제는 엔티티가 프레젠테이션 계층에서 준영속 상태이기 때문에 발생한다. 이는 스프링의 기본 전략인 트랜잭션과 영속성 컨텍스트의 라이프 사이클이 동일하며, 서비스 계층에서만 사용하도록 한정하기 때문에 발생한다. 이를 해결하기 위한 방법으로 OSIV를 제시한다.
OSIV(Open Session In View)는 영속성 컨텍스트를 뷰까지 열어둔다는 뜻이다. 즉, 기존에 트랜잭션과 영속성 컨텍스트의 범위를 View까지 넓히는 것이다.
🔻과거 OSIV : 요청 당 트랜잭션
그림과 같이 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 영속성 컨텍스트를 만들면서 트랜잭션을 시작하고 요청이 끝날 때 트랜잭션과 영속성 컨텍스트를 함께 종료한다.
- 요청 당 트랜잭션 방식의 OSIV 문제점
요청 당 트랜잭션 방식의 OSIV가 가지는 문제점은 컨트롤러나 뷰 같은 프리젠테이션 계층이 엔티티를 변경할 수 있다는 점이다. 프레젠테이션 계층에서 존재하는 엔티티도 영속 상태이기 때문에 변경된 내용이 DB에 반영될 수 있다. 즉, Client의 요청이 종료될 때 DB에 Commit 된다. 프리젠테이션 계층에서 엔티티를 수정하지 못하게 막는 방법들은 다음과 같다.
- 엔티티를 읽기 전용 인터페이스로 제공
엔티티를 직접 노출하는 대신에 다음 예제와 같이 읽기 전용 메소드(getter)만 제공하는 인터페이스를 프리젠테이션 계층에 제공하는 방법이다.
interface MemberView { public String getName(); } @Entity class Member implements MemberView { ... } class MemberService { public MemberView getMember(id) { return memberRepository.findById(id); } }
- 엔티티 래핑
엔티티의 읽기 전용 메소드만 가지고 있는 엔티티를 감싼 객체를 만들고 이것을 프리젠테이션 계층에 반환하는 방법이다.
class MemberWarpper { private Member member; public MemberWrapper(member) { this.member = member; } //읽기 전용 메소드만 제공 public String getName() { return member.getName();
- DTO만 반환
가장 전통적인 방법으로 프리젠테이션 계층에 엔티티 대신에 단순히 데이터만 전달하는 객체인 DTO를 생성해서 반환하는 것이다. 하지만 이 방법은 OSIV를 사용하는 장점을 살릴 수 없고 엔티티를 거의 복사한 듯한 DTO 클래스도 하나 더 만들어야 한다.
- 엔티티를 읽기 전용 인터페이스로 제공
🔻스프링 OSIV : 비즈니스 계층 트랜잭션
OSIV를 서블릿 필터에서 적용할지 스프링 인터셉터에서 적용할 지에 따라 원하는 클래스를 선택해서 사용하면 된다. 예를 들어 JPA를 사용하면서 서블릿 필터에 OSIV를 적용하려면 OpenEntityManagerInViewFilter
를 서블릿 필터에 등록하면 되고 스프링 인터셉터에 OSIV를 적용하려면 OpenEntityManagerInViewInterceptor
를 스프링 인터셉터에 등록하면 된다.
클라이언트의 요청이 들어오면 영속성 컨텍스트를 생성한다. 이때 트랜잭션은 시작하지 않는다. 비즈니스 로직을 실행하고 서비스 계층이 끝나면 트랜잭션을 커밋하면서 영속성 컨텍스트를 플러시한다. 이때 트랜잭션만 종료하고 영속성 컨텍스트는 살려둔다. 이후 클라이언트의 요청이 끝날 때 영속성 컨텍스트를 종료한다.
➡️ 동작 순서
- 클라이언트의 요청이 들어오면 서블릿 필터나, 스프링 인터셉터에서 영속성 컨텍스트를 생성한다. 단 이때 트랜잭션은 시작하지는 않는다.
- 서비스 계층에서 @Transactional로 트랜잭션을 시작할 때 1번에서 미리 생성해둔 영속성 컨텍스트를 찾아와서 트랜잭션을 시작한다.
- 서비스 계층이 끝나면 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시한다. 이때 트랜잭션은 끝내지만 영속성 컨텍스트는 종료하지 않는다.
- 컨트롤러와 뷰까지 영속성 컨텍스트가 유지되므로 조회한 엔티티는 영속 상태를 유지한다.
- 서블릿 필터나, 스프링 인터셉터로 요청이 들어오면 영속성 컨텍스트를 종료한다. 이때 플러시를 호출하지 않고 바로 종료한다.
🔻트랜잭션 없이 읽기 가능 / 쓰기 불가능
엔티티를 변경하지 않고 단순히 조회만 할 때는 트랜잭션이 없어도 되는데 이것을 트랜잭션 없이 읽기라 한다. 프록시를 초기화하는 지연 로딩도 조회 기능이므로 트랜잭션 없이 읽기가 가능하다. 단 트랜잭션이 없기 때문에 쓰기는 할 수 없다.
➡️ OSIV는 다음과 같은 특징이 있다.
- 영속성 컨텍스트를 프리젠테이션 계층까지 유지한다.
- 프리젠테이션 계층에는 트랜잭션이 없으므로 엔티티를 수정할 수 없다.
- 프리젠테이션 계층에는 트랜잭션에 없지만 트랜잭션 없이 읽기를 사용해서 지연로딩을 할 수 있다.
🏷️이미지 출처 및 참고한 사이트
Uploaded by N2T