출처: https://blog.iroot.kr/341 [RootKR] 출처: https://blog.iroot.kr/341 [RootKR]

  OSIV(Open Session In View)는 영속성 컨텍스트를 뷰까지 열어둔다는 뜻이다. 이렇게 되면 뷰에서도 지연 로딩을 사용할 수 있게 된다. 

 

과거 OSIV: 요청 당 트랜잭션

 요청 당 트랜잭션 방식의 OSIV는 클라이언트의 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 트랜잭션을 시작하고 요청이 끝날 때 트랜잭션도 끝내는 방식이다. 

 

요청 당 트랜잭션 방식의 OSIV 문제점

 문제는 컨트롤러나 뷰 같은 프리젠테이션 계층이 엔티티를 변경할 수 있다는 점이다. 

@RestController
@RequiredArgsConstructor
public class MemberController {
   private final MemberService memberService;
  
   public String viewMemberName(Long id) {
       Member member = memberService.findOne(id);
       member.setName("XXX");
      
       return member.getName();
   }
}

 위 코드의 의도는 고객 이름을 그냥 "XXX"로 뷰에만 노출하려는 의도였는데, 변경 감지로 인해 데이터베이스도 함께 변경돼버린 참사가 일어났다. (실제로 실무에서 이런 식으로 하는 작업했던 동료 덕에 많은 것을 배웠다...^^). 서비스 계층이 아닌 프리젠테이션 계층에서 데이터 변경은 유지보수하기 힘들어진다. 프리젠테이션에서 엔티티를 수정하지 못하게 막는 방법은 다음과 같다. 

  • 엔티티를 읽기 전용 인터페이스로 제공
  • 엔티티 레핑
  • DTO만 반환

 요즘은 위 문제들로 인해 트랜잭션 범위를 프리젠테이션 계층까지 열어두지 않는다. 스프링 프레임워크가 제공하는 OSIV는 위 문제를 보완해 비즈니스 계층에서만 트랜잭션을 유지하는 방식의 OSIV를 사용한다. 

(현업에선 Spring Boot를 사용하고 있는데, 스프링 부트는 OISV를 OpenEntityManagerInViewInterceptor로 기본으로 하고 있기 때문에 프리젠테이션까지 트랜잭션이 살아 있는 듯 하다.)

스프링이나 J2EE 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다. 그리고 트랜잭션은 보통 서비스 계층에서 시작하므로 서비스 계층이 끝나는 시점에 트랜잭션이 종료되면서 영속성 컨텍스트도 함께 종료된다. 

 

 따라서 조회한 엔티티가 서비스와 레포지토리가 아닌 컨트롤러나 프리젠테이션 계층에서는 준영속 상태가 된다. 즉 트랜잭션이 없는 프리젠테이션 계층에서는 병경 감지와 지연 로딩이 동작하지 않는다. 

@RestController
@RequiredArgsConstructor
public class OrderController {
   private final OrderRepository orderRepository;
  
   public String view(Long orderId) {
       Order order = orderRepository.findOne(orderId);

       // 준 영속상태의 member. 아직 lazy 발동안됨
       Member member = order.getMember();
       member.getName(); // 사용시 Lazy 발동. 예외 발생
      
       return member.toString();
   }
}

 

준영속 상태와 변경 감지

 변경 감지 기능은 영속성 컨텍스트가 살아 있는 서비스 계층까지만 동작하고 영속성 컨텍스트가 종료된 프리젠테이션 계층에서는 동작하지 않는다. 보통 변경 감지 기능은 서비스 계층에서 비즈니스 로직을 수행하면서 발생한다. (근데 Spring Boot OSIV에선 컨트롤러까지 영속성 트랜잭션이 살아있다...)

 

 비즈니스 로직은 서비스 계층에서 끝내고 프리젠테이션 계층은 데이터를 보여주는 데 집중해야 한다. 변경 감지 기능이 프리젠테이션 계층에서 동작하면 유지보수가 어려워질 수 있다. 

 

준영속 상태와 지연로딩

 준영속 상태에서 지연 로딩이 동작하지 않아 지연로딩 객체 조회 시 프록시 객체가 사용된다. 프록시는 실제 데이터가 아니가 때문에 프록시 객체를 사용하면 실제 데이터를 불러오려고 초기화를 시도한다. 준영속 상태에선 지연로딩에 문제가 발생하므로, LazyInitialaztionException 예외가 발생한다. 

 

 준영속 상태의 지연로딩 문제를 해결하는 방법은 크게 2가지다. 

  • 뷰가 필요한 엔티티를 미리 로딩해 두는 방법
  • OSIV를 사용해 엔티티를 항상 영속 상태로 유지하는 방법

 뷰가 필요한 엔티티를 미리 로딩하는 방법을 살펴보자. 이 방법은 영속성 컨텍스트가 살아 있을 때 뷰에 필요한 엔티티들을 미리 다 로딩하거나 초기화해 반환하는 방법이다. 따라서 엔티티가 준영속 상태로 변해도 연관된 엔티티를 이미 다 로딩해 두어 지연 로딩이 발생하지 않는다. 

 

 뷰가 필요한 엔티티를 미리 로딩해 두는 방법은 어디서 미리 로딩하느냐에 따라 3가지 방법이 있다. 

 

1. 글로벌 페치 전략 수정

 가장 간단한 방법은 지연 로딩에서 즉시 로딩으로 변경하는 것이다. 

@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "member_id")
private Member member;

 fetch 전략을 EAGER로 하면 다음과 같이 프리젠테이션 계층에서 사용할 수 있다. 

public String view(Long orderId) {
   Order order = orderRepository.findOne(orderId);
  
   Member member = order.getMember();
   member.getName(); // 이미 다 로딩되서 오류 안남

   return member.toString();
}

 위의 방법은 편리하기는 하나 다음 단점이 있다. 

  • 사용하지 않는 엔티티를 로딩한다. 
    • 위 코드에서 만약 나는 Order만 조회하고 싶었는데, 쓸데없는 Member도 조회하는 꼴이 된다. 
  • N+1 문제가 발생한다. 
    • em.find()를 실행(엔티티 매니저)하면 EAGER로 설정 시 SQL은 Order와 Member를 left outer join 해서 한꺼번에 가져온다. 
    • 문제는 jpql에서 발생하는데, JPA가 jpql을 분석해 SQL을 생성할 때는 글로벌 페치 전략을 참고하지 않고 오직 jpql 자체만 사용한다. 
    • 만약 조회한 Order 엔티티가 10개라면 select * from Order 쿼리가 한번 날아가고, Order마다 해당하는 Member를 조회하려 10번의 select * from Member where id = ? 쿼리가 날아가는 문제가 생긴다.
    • 위 문제는 jpql 패치 조인으로 해결할 수 있다. 

2. JPQL 페치 조인

 페치 조인을 사용하면 SQL Join을 사용해 페치 조인 대상까지 함께 조회한다. 따라서 N+1 문제가 해결된다. 하지만 이 역시 단점이 존재한다. 

  • 무분별하게 사용하면 화면에 맞춘 리포지토리에 메서드가 증가할 수 있다. 즉 Order만 필요한 화면이 있고 Order와 Member 둘 다 필요한 화면이 있다고 할 때 api를 Order 전용과 Order를 Member와 페치 조인한 api가 2개나 생겨 버린다. 
  • 실무에서 나도 이 페치 조인 때문에 api 함수 개수가 늘어나 관리가 힘들었다...ㅠㅠ

 

3. 강제로 초기화

 강제로 초기화는 영속서 켄텍스트가 살아있을 때 프리젠테이션 계층이 필요한 엔티티를 강제로 초기화해 반환하는 방법이다. 

@Transactional(readOnly = true)
public Order findOrder(Long id) {
   Order order = orderRepository.findOne(id);
   order.getMember().getName();  // getMember()후 그 속성을 읽으면 초기화가 된다. 
  
   return order;
}

 

 글로벌 페치 전략으로 즉시 로딩하면 연관된 엔티티가 실제 엔티티가 아닌 프록시 객체로 조회된다. 프록시 객체는 사용하는 시점(getName())에 초기화된다. 위 예제처럼 프리젠 테이션 계층에 필요한 프록시 객체를 영속성 컨텍스트가 살아있을 때 강제로 초기화해 반환하면 이미 초기화했으므로 준영속 상태에서도 사용할 수 있게 된다. 

 위의 문제는 서비스 계층이 프리젠테이션 계층을 위한 프록시 초기화 역할을 하게 되어 계층을 침범하는 문제가 생기는데, 이는 FACADE 계층이 담당하면 된다. 

 

FACADE 계층 추가

 서비스 계층과 프리젠테이션 계층 사이에 FACADE 계층을 하나 더 두는 방법이다. 뷰를 위한 프록시 초기화는 이곳에서 담당하면 된다. 프록시를 초기화하려면 영속성 컨텍스트가 필요하므로 FACADE에서 트랜잭션을 시작해야 한다. 

 

FACADE 계층의 역할과 특징

  • 프리젠테이션 계층과 도메인 모델 계층 간의 논리적 의존성을 분리
  • 프리젠테이션 계층에서 필요한 프록시 객체를 초기화함
  • 서비스 계층을 호출해 비즈니스 로직을 실행
  • 리포지토리를 직접 호출해 뷰가 요구하는 엔티티를 찾음
@RequiredArgsConstructor
public class OrderFacade {
   private final OrderService orderService;
  
   public Order findOrder(Long id) {
       Order order = orderService.findOrder(id);
       order.getMember().getName(); // 프록시 초기화
      
       return order;
   }
}

 

 서비스 계층과 프리젠테이션 계층 간 논리적 의존관계를 제거했지만, 실용적 관점에서 볼 때 FACADE의 단점은 중간에 계층이 하나 더 끼어들었다는 점이다. 

 

'개발 이야기 > JPA' 카테고리의 다른 글

[개발이야기 - JPA] OSIV를 알아보자  (0) 2020.05.12
[개발이야기 - JPA] 병합 - merge()  (0) 2020.05.02

 병합은 준영속 상태의 엔티티를 다시 영속 상태로 변경할 때 사용한다. merge() 메서드는 준영속 상태의 엔티티를 받아 그 정보로 새로운 영속 상태의 엔티티를 반환한다. 

 

@RequiredArgsConstructor
public class ExMain {
   private final EntityManager em;


   @Transactional
   public void update() {
       // 준영속 상태의 객체
       Member member = new Member("오세진", 32);
       member.setAge(30);
   }
}

 위 코드에서 member는 준영속 상태의 객체다. 아직 JPA에 관리된 상태가 아닌 순수한 객체이기 때문이다. 여기서 아무리 setXXX 메서드로 값을 변경해 보았자, 변경감지에 걸리지 않아 디비에 반영이 되지 않는다. 

 

 이를 변경하고 싶다면 준영속 상태의 엔티티를 영속 상태로 변경해야 하는데, 이때 merge()를 사용하면 된다. 

@RequiredArgsConstructor
public class ExMain {
   private final EntityManager em;

   @Transactional
   public  void update() {
       // 준영속 상태의 객체
       Member member = new Member("오세진", 32);
       member.setAge(30);

       // 영속 상태의 객체
       Member mergedMember = em.merge(member);
       mergedMember.setAge(32); // 변경감지
   }
}

 위 코드에서 영속상태의 객체를 변경감지 동작을 통해 변경된 데이터가 Transaction이 끝나면서 데이터베이스에 반영된다. 여기서 주의할 점은 파라미터로 넘어온 member 객체는 여전히 준영속 상태라는 점이다. 

 

merge()의 동작 방식

  1. merge()를 실행
  2. 파라미터로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 엔티티를 조회
    • 만약 1차 캐시에 엔티티가 없으면 데이터베이스에 엔티티를 조회하고 1차 캐시에 저장.
    • 무조건 1번은 db 조회를 하므로 성능에 좋지 않을 수 있다. 
  3. 조회한 영속 엔티티에 member 엔티티의 값을 채워 넣음
    • 이때 member의 모든 값을 영속 엔티티에 채워 넣기 때문에 null 값이 들어갈 수 도 있는 문제가 생긴다.
    • 이래서 업데이트 시 merge()보단 변경 감지를 사용하자.
  4. 영속 상태의 객체를 반환

 

 병합은 파라미터로 넘어온 엔티티의 식별자 값으로 영속성 컨텍스트를 조회하고, 찾는 엔티티가 없으면 데이터베이스에서 조회한다. 만약 데이터베이스에도 없다면 새로운 엔티티를 생성해 병합한다. 따라서 병합은 save or update 기능을 수행한다고 볼 수 있다.

 

현업에서 jpa의 동작을 재대로 이해하지 못한 체 데이터를 update 치려다 수많은 삽질을 했던 경험이 있다. 이 기능들을 잘 이해하고 나니 update에 대한 이해와 흐름을 파악하며 개발할 수 있을 것 같다.

+ Recent posts