웹 애플리케이션과 영속성 관리
**트랜잭션 범위의 영속성 컨텍스트**
**스프링 컨테이너의 기본 전략**
스프링 프레임워크를 사용하면 보통 비즈니스 로직을 시작하는 서비스 계층에 **@Transactional** 어노테이션을 선언하여 트랜잭션을 시작한다.
**@Transactional** 메소드가 있으면 메소드를 실행하기 직전에 스프링 트랜잭션 AOP가 먼저 동작
대상 메소드가 종료되면 트랜잭션을 커밋하면서 종료한다.
트랜잭션을 커밋하면 JPA는 먼저 영속성 컨텍스트를 플러시해서 변경 내용을 DB에 반영 한 후 DB트랜잭션을 커밋한다.
예외가 발생하면 트랜잭션을 롤백하고 종료 하는데 플러시를 호출하지 않는다.
- 트랜잭션이 같으면 같은 영속성 컨텍스트를 사용한다
- 엔티티 매니저는 달라도 같은 영속성 컨텍스트를 사용한다.
- 트랜잭션이 다르면 다른 영속성 컨텍스트를 사용한다.**스프링 컨테이너는 스레드마다 각각 다른 트랜잭션을 할당하기 때문에 멀티스레드 상황에 안전하다****스프링이나 J2EE 컨테이너의 가장 큰 장점은 트랜잭션과 복잡한 멀티 스레드 상황을 컨테이너가 처리해준다. 그래서 개발자는 싱글 스레드 애플리케이션 처럼 단순하게 개발할 수 있고 비즈니스 로직 개발에 집중할 수 있다.
- 같은 엔티티 매니저를 사용해도 트랜잭션에 따라 접근하는 영속성 컨텍스트가 다르다.
준영속 상태와 지연 로딩
- **준영속 상태와 변경 감지**
- 변경 감지 기능은 영속성 컨텍스트가 살아 있는 서비스 계층(트랜잭션 범위)까지만 동작하고 영속성 컨텍스트가 종료된 프리젠테이션 계층에서는 동작하지 않는다.
변경 감지 기능이 프리젠테이션 계층에서도 동작하면 애플리케이션 계층이 가지는 책임이 모호 해지고 데이터가 변경된 장소를 프리젠테이션 계층까지 다 찾아야 하므로 유지보수가 힘들다
- **준영속 상태와 지연 로딩**프록시 객체를 사용해도 초기화를 시도하지만 지연 로딩은 할수 없다.
- 준영속 상태의 가장 골치 아픈 문제는 지연 로딩 기능이 동작하지 않는다는 점이다.
지연 로딩 문제를 해결하는 2가지의 방법
- 뷰가 필요한 엔티티를 로딩하는 방법
- OSIV를 사용해서 엔티티를 항상 영속 상태로 유지하는 방법
**뷰가 필요한 엔티티를 로딩하는 방법**
엔티티가 준영속 상태로 변해도 연관된 엔티티를 이미 다 로딩해두어서 지연 로딩이 발생하지 않는다.
뷰가 엔티티를 미리 어디에 로딩하냐에 따라 3가지 방법으로 나뉜다
- 글로벌 페치 전략 수정
- JPQL 페치 조인
- 강제로 초기화
**글로벌 페치 전략 수정**
지연 로딩에서 즉시 로딩으로 변경한다.
@Entity
public class Order{
@ManyToOne(fetch = FetchType.EAGER)
private Member member;
}
**단점**
사용하지 않는 엔티티를 로딩한다
N+1 문제가 발생한다.
**N+1 에서 문제는 JPQL에서 발생한다.**
List<Order> orders = em.createQuery("select o from Order o", Order.class).getResultList(); //연관된 모든 엔티티를 조회한다.
**JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 페치 전략을 참고하지 않고 오직 JPQL 자체만 사용한다.** 따라서 즉시 로딩이든 지연 로딩이든 구분하지 않고 JPQL 쿼리 자체에 충실하게 SQL을 만든다.
select o from Order o
에서 Order를 로딩하는 즉시 연관된 member로 로딩한다.
문제는 조회된 Order 엔티티가 10개이면 member를 조회하는 sql도 10번 실핸한다.
N+1이 발생하면 SQL이 상당히 많이 호출되므로 조회 성능에 치명적이다.
**N+1문제는 JPQL 페치조인으로 해결할 수 있다.**
**JPQL 페치 조인**
글로벌 페치 전략을 즉시 로딩으로 설정하면 애플리케이션 전체에 영향을 주므로 매우 비효율 적이다.
페치 조인을 사용하면 SQL JOIN을 사용해서 페치 조인 대상까지 함께 조회된다. 따라서 N+1 문제가 발생하지 않는다.
**페치 조인 단점**
현실적인 대안이긴 하지만 무분별하게 사용하면 화면에 맞춘 리포지토리 메소드가 증가할 수 있다. 결국 프리젠테이션 계층이 알게 모르게 데이터 접근 계층을 침범하는 것이다.
- 화면 A 를 위해 order 만 조회하는 repository.findOrder() 메소드
- 화면 B를 위해 order와 연관 member를 페치 조인으로 조회하는 repository.findOrderWithMember() 메소드
이런식으로 최적화 할수 있지만 뷰와 레포지토리 간의 논리적인 의존관계가 발생한다.
**또 다른 대안은 repository.findOrder() 하나만 만들고 여기서 페치 조인으로 order 와 member를 함께 로딩하는 것이다.**
**order 엔티티만 필요한 것은 member까지 같이 조회하므로 약간의 로딩 시간이 증가하겠지만 페치 조인은 JOIN을 사용해서 쿼리 한번으로 필요한 데이터를 조회하므로 성능에 미치는 영향이 미비하다.**
무분별한 최적화로 프리젠테이션과 데이터 접근 계층간의 의존도가 급격히 증가하는 것보단 적절한 선에서 타협접을 찾는 것이 합리적
강제로 초기화
영속성 컨텍스트가 살아있을 때 프리젠테이션 계층이 필요한 엔티티를 강제로 초기화해서 반환하는 방법
class OrderService{
@Transactional
public Order findOrder(id){
Order order = orderRepository.findOrder(id);
order.getMember().getName(); //프록시 객체를 강제로 초기화한다.
return order;
}
}
글로벌 페치 전략을 지연 로딩으로 설정하면 연관된 엔티티를 실제 엔티티가 아닌 프록시 객체로 조회된다.
order.getMember() 까지만 호출하면 프록시 객체만 반환하고 아직 초기화 하지 않는다.
프록시 객체는 member.getName() 처럼 실제 값을 사용하는 시점에 초기화 된다.
프리젠테이션 계층에서 필요한 프록시 객체를 영속성 컨텍스트가 살아 있을 때 강제로 초기화 해서 반환하면 이미 초기화 해했으므로 준영속 상태에서도 사용할 수 있다.
예제처럼 프록시 초기화 역할을 서비스 계층이 담당하면 뷰가 필요한 엔티티에 따라 서비스 계층의 로직을 변경해야 한다.
**프리젠테이션 계층이 서비스 계층을 침범하는 상황이다**
비즈니스 로직을 담당하는 서비스 계층에서 프리젠테이션 계층을 위한 프록시 초기화 역할을 분리하는 것은 FACADE 계층이 그 역할을 담당해 준다.
**FACADE 계층 추가**
프리젠테이션 계층과 서비스 계층 사이에 FACADE 계층을 하나 더 두는 방법이다.
프록시를 초기화 하려면 영속성 컨텍스트가 필요하므로 FACADE 에서 트랜잭션을 시작해야 한다.
**FACADE 계층의 역할과 특징**
- 프리젠테이션/도메인 모델 계층 간의 논리적 의존성을 분리
- 프리젠테이션 계층에서 필요한 프록시 객체를 초기화
- 서비스 계층을 호출해서 비즈니스 로직 실행
- 레포지토리 직접 호출하여 뷰가 요구하는 엔티티 찾는다.
FACADE 계층 추가
class OrderFacade {
@Autowired
OrderService orderService;
public Order findOrder(id){
Order order = orderService.findOrder(id);
//프리젠테이션 계층이 필요한 프록시 객체를 강제로 초기화한다.
order.getMember().getName();
return order;
}
}
class OrderService{
public Order findOrder(id){
return orderRepository.findOrder(id);
}
}
FACADE 계층을 사용해서 서비스 계층과 프리젠테이션 계층 간의 논리적 의존관계를 제거하였다.
하지만 실용적인 관점에서 최대 단점은 중간에 계층이 하나 더 끼어든다는 점이다.
그리고 FACADE 에 단순히 서비스 계층을 호출만 하는 위임 코드가 상당히 많을 것이다.
**준영속 상태와 지연 로딩의 문제점**
뷰를 개발할 때 필요한 엔티티를 미리 초기화하는 방법은 생각보다 오류가 발생할 가능성이 높다.
보통 뷰 개발시 엔티티 클래스를 초점에 두고 개발을 하지만 이것이 초기화가 된것인지 아닌지 확인하기 위해 FACADE 나 서비스 클래스를 열어보는것은 상당히 번거롭고 놓치기 쉽다.
그리고 물리적으로는 애플리케이션 로직과 뷰가 나눠져있으나, 논리적으로는 서로 의존관계에 있다.
FACADE를 사용하면 어느정도 의존문제를 해결할 수 있으나 상당히 번거롭다.
화면별로 필요로 하는 엔티티에 맞게 최적화 하려면 FACADE 계층에 여러 종류의 조회 메소드가 필요해지게 된다.
Order만 필요한 메소드
Order, Member 둘다 필요한 메소드
Member만 필요한 메소드
**이런 문제는 엔티티가 프리젠테이션 계층에서 준영속 상태이기 때문에 발생한다**
영속성 컨텍스트를 뷰까지 살아있게 열어두는 OSIV 방법이 있다.
**OSIV**
Open Seesion In View는 영속성 컨텍스트를 뷰까지 열어둔다는 뜻이다.
따라서 뷰에서도 지연로딩을 사용할 수 있게된다.
- 과거 OSIV:요청당 트랜잭션
- 최근 OSIV: 스프링 OSIV 비즈니스 계층 트랜잭션
**과거 OSIV: 요청당 트랜잭션**
클라이언트의 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 트랜잭션을 시작하고 요청이 끝날 때 트랜잭션도 끝내는 것이다.
이것이 요청 당 트랜잭션 방식이다.
이렇게 되면 뷰에서도 지연로딩이 가능하며 엔티티를 미리 초기화할 필요가 없다. FACADE 계층 없이도 뷰에 독립적인 서비스 계층을 유지할 수 없다.
**요청당 트랜잭션 방식의 OSIV 문제점**
컨트롤러나 뷰 같은 프리젠테이션 계층에서 엔티티를 변경할 수 있는 것 이 문제이다
public MemberController{
public String viewMember(Long id){
Member member = memberService.getMember(id);
member.setName("xxx");
model.addAttribute("member", member);
...
}
}
의도는 단순히 뷰에 노출할 때만 고객이름을 xxx로 변경하고 싶은것인데
요청당 트랜잭션 OSIV 는 뷰를 렌더링한 후에 트랜잭션을 커밋하기 때문에 당연히 영속성 컨텍스트도 플러시 되고 영속성 컨텍스트의 변경 감지 기능이 작동해서 변경된 엔티티를 실제DB에 반영이 되어버린다.
**때문에 대응방안은 프리젠테이션 계층에서 엔티티를 수정하지 못하게 막으면 된다.**
대응방법 3가지
- 엔티티를 읽기 전용 인터페이스로 제공
- 엔티티 래핑
- 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();
}
}
member 엔티티를 감싸고 있는 MemberWrapper 객체를 만들었다.
**DTO만 반환**
가장 전통적인 방법, 단순히 데이터만 전달하는 객체인 DTO를 생성하서 반환하는 것이다.
이 방법은 OSIV 장점을 살릴수 없고 엔티티를 거의 복사한 듯한 DTO 클래스를 하나 더 만들어야 한다.
**지금까지의 위의 방법들은 모두 코드량이 상당히 증가한다는 단점이 있다.**
이런 문제점을 어느정도 보완해서 비즈니스 계층에서만 트랜잭션을 유지하는 스프링 프레임워크가 제공하는 OSIV 방식을 사용한다.
스프링 OSIV: 비즈니스 계층 트랜잭션
스프링 프레임워크가 제공하는 OSIV는 비즈니스 계층에서 트랜잭션을 사용하는 OSIV 다.
**과정**
1.클라이언트 요청이 들어오면 서블릿 필터 또는 스프링 인터셉터에서 영속성 컨텍스트를 생성한다. 이때 트랜잭션은 시작하지 않는다.
2.서비스 계층에서 @Transactional로 트랜잭션을 시작할 때 1번에서 미리 생성해둔 영속성 컨텍스트를 찾아와 트랜잭션을 시작한다.
3.서비스 계층이 끝나면 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시한다.
**이때 트랜잭션은 끝났지만 영속성 컨텍스트는 종료하지 않는다**
4.컨트롤러와 뷰까지 영속성 컨텍스트가 유지되므로 조회한 엔티티는 영속상태를 유지한다
5.서블릿 필터나 스프링 인터셉터로 요청이 돌아오면 영속성 컨텍스트를 종료한다. 이때 플러시를 호출하지 않고 바로 종료한다.
트랜잭션 없이 읽기
영속성 컨텍스트를 통한 모든 변경은 트랜잭션 안에서 이루어져야 한다.
만약 트랜잭션 없이 변경하고 영속성 컨텍스트를 플러시하면 관련예외 가 발생한다.
**엔티티를 변경하지 않고 단순히 조회만 할 땐 트랜잭션이 없이도 조회가 가능한데 이것을 트랜잭션 없이 읽기라 한다.**
- 영속성 컨텍스트는 트랜잭션 범위 안에서 엔티티를 조회하고 수정 할 수 있다.
- 영속성 컨텍스트는 트랜잭션 범위 밖에서 엔티티를 조회만 할 수 있다. 이것을 트랜잭션 없이 읽기라 한다.
**스프링이 제공하는 OSIV**를 사용함으로써 프리젠테이션 계층에서 엔티티를 수정할 수 없으므로 기존 OSIV의 단점을 보완했으며
**트랜잭션 없이 읽기**를 사용하여 프리젠테이션 계층에서 지연 로딩 기능을 사용할 수 있다
**스프링이 제공하는 비즈니스 계층 트랜잭션 OSIV 특징**
- 영속성 컨텍스트를 프리젠테이션 계층까지 유지한다
- 프리젠테이션 계층에는 트랜잭션이 없으므로 엔티티를 수정할 수 없다
- 프리젠테이션 계층에는 트랜잭션이 없지만 트랜잭션 없이 읽기를 사용해서 지연로딩을 할 수 있다.
이전 요청당 트랜잭션 방식의 OSIV 문제점 코드를 스프링이 제공하는 OSIV를 사용했다면?
public MemberController{
public String viewMember(Long id){
Member member = memberService.getMember(id);
member.setName("xxx");
model.addAttribute("member", member);
...
}
}
여기서 2가지의 이유르 플러시가 동작하지 않는다
- 트랜잭션을 사용하는 서비스 계층이 끝날때 트랜잭션을 커밋하면서 이미 플러시가 되어버렸다. 스프링이 제공하는 OSIV 서블릿 필터나 OSIV 스프링 인터셉터는 요청이 끝나면 플러시를 호출하지 않고 em.close() 로 영속성 컨텍스트만 종료해버려 플러시가 일어나지 않는다
- 프리젠테이션 계층에서 em.flush()를 호출해서 강제로 플러시해도 트랜잭션 범위 밖이므로 데이터를 수정할수 없다는 예외를 만난다
스프링 OSIV 주의사항
프리젠테이션 계층에서 엔티티를 수정해도 수정 내용을 DB에 반영하지 않는다.
**그러나 한 가지 예외가 있다**
프리젠테이션 계층에서 엔티티를 수정한 직후 트랜잭션을 시작하는 서비스 계층을 호출하면 문제가 생긴다.
class MemberController{
public String viewMember(Long id){
Member member = memberService.getMember(id);
member.setName("xxx"); //고객 이름을 xxx로 변경하였음
memberService.biz(); //비즈니스 로직
return "view";
}
}
class MemberService{
@Transactional
public void biz(){
//....비즈니스 로직 실행
}
}
**변경된 유저이름이 그대로 DB반영되는 문제가 나타난다**
컨트롤러에서 엔티티를 수정하고 즉시 뷰를 호출한 것이 아니라 트랜잭션이 동작하는 비즈니스 로직을 호출했으므로 이런 문제가 발생한다
**해결방안은** 트랜잭션이 있는 비즈니스 로직을 모두 호출하고 나서 엔티티를 변경하면 된다.
memberService.biz(); // 비즈니스 로직 먼저 실행
Member member = memberService.getMember(id);
member.setName("xxx"); // 마지막에 엔티티를 수정한다.
스프링 OSIV는 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있으므로 이런 문제가 발생한다.
**스프링 OSIV의 단점**
- 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있음, 트랜잭션 롤백 시 주의
- 엔티티를 수정하고 나서 비즈니스 로직을 실행하면 엔티티가 수정될수 있는 예외가 존재
- 프리젠테이션 계층에서 지연 로딩에 의한 SQL 실행, 따라서 성능 튜닝시에 확인해야 할 부분이 넓음
**OSIV vs FACADE vs DTO**
어떤 방법을 사용하던 준영속 상태가 되기 전에 프록시를 초기화 해야함
엔티티와 비슷한 DTO를 만들어서 엔티티를 노출시키지 않고 반환하는 방법이 있음
그러나 어떤 방법을 사용하던 OSIV를 사용하는 것과 비교해서 지루한 코드를 많이 작성해야함
**OSIV가 만능은 아니다**
OSIV 사용시에 화면을 출력할때 엔티티를 유지하면서 객체 그래프를 맘껏 탐색할 수 있음
그러나 복잡한 화면을 구성할땐 효과적이지 않음 ex)통계화면
수많은 테이블을 조인해서 보여줘야하는 복잡한 경우가 있으면 객체그래프로 표현하기 어려움
**직접 엔티티를 조회하기보다 JPQL로 필요한 데이터들만 조회해서 DTO로 반환하는 것이 더 나은 해결책일수 있음**
OSIV는 같은 JVM을 벗어난 원격 상황에서는 사용할 수 없음
원격지인 클라이언트에서 연관된 엔티티를 지연 로딩하는 것은 불가능하다.
때문에 클라이언트가 필요한 데이터를 모두 JSON으로 반환해야한다.
JSON으로 생성한 API는 한번 정의하면 수정하기 어려운 외부API와 언제든지 수정이 가능한 내부API로 나뉨
- 외부API: 외부에 노출, 한번 정의하면 변경이 어려움, 서버와 클라이언트를 동시에 수정하기 어려움
- 내부API: 외부에 노출하지 않음, 언제든지 변경가능, 서버/클라이언트 동시수정 가능 ex)ajax
엔티티는 생각보다 자주 변경됨
**외부 api는 엔티티를 직접 노출하는것 보단 엔티티를 변경해도 완충 역할을 할 수 있는 DTO로 변환해서 노출하는것이 안전함**
너무 엄격한 계층
컨트롤러에서 레포지토리를 직접 접근한다
OSIV 사용 전에는 프리젠테이션 계층에서 사용할 지연 로딩된 엔티티를 미리 초기화해야 했다.
그리고 초기화는 아직 영속성 컨텍스트가 살아있는 서비스 계층 또는 FACADE 계층이 담당했다.
아지만 OSIV를 사용하면 영속성 컨텍스트가 프리젠테이션 계층까지 살아있으므로 미리 초기화할 필요가 없다.
따라서 단순한 엔티티 조회는 아무런 문제가 없다.
정리
스프링이나 J2EE 컨테이너 환경에서 JPA를 사용하면 트랜잭션 범위의 영속성 컨텍스트 전략이 적용된다.
이 전략은 트랜잭션의 범위와 영속성 컨텍스트의 생존 범위가 같다.
이 전략의 유일한 단점은 프리젠테이션 계층에서 엔티티가 준영속 상태가 되므로 지연 로딩을 할 수 없다는 점이다.
스프링 프레임워크가 제공하는 OSIV를 사용하여 프리젠테이션 계층에서 엔티티를 수정하지 않고 위의 문제를 해결할 수 있다.
'SPRING > JPA' 카테고리의 다른 글
[JPA] @Transactional(readOnly = true) 은 왜 사용해요? (0) | 2024.03.14 |
---|---|
JPA 엔티티 이름대문자로 설정하기 (0) | 2022.05.31 |
객체지향 쿼리언어 (0) | 2022.03.26 |
jpa metamodel must not be empty! 테스트 에러 (0) | 2022.03.24 |
JPA 상에서의 코드성데이터 처리방법중 하나 (0) | 2022.03.21 |