스터디/JPA 스터디

[JPA] 웹 애플리케이션과 영속성 관리

제이온 (Jayon) 2022. 2. 9.

jpa-study에서 스터디를 진행하고 있습니다.

트랜잭션 범위의 영속성 컨텍스트

스프링 컨테이너의 기본 전략

  • 스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스틀 전략을 사용한다.
  • 이 전략은 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고, 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다.
  • 스프링 프레임워크를 사용하면 비즈니스 로직을 시작하는 서비스 계층에 @Transactional을 선언하여 트랜잭션을 시작한다. 그래서 서비스 위 계층은 준영속 상태가 된다.

 

Untitled

  • 이때 같은 트랜잭션 안에서는 같은 영속성 컨텍스트에 접근한다.

 

// repository1과 repository2는 같은 영속성 컨텍스트에 접근
@Transactional
public void logic() {
    repository1.hello();
    repository2.hello();
}

// repository3는 repository1과 repository2와 다른 영속성 컨텍스트에 접근
@Transactional
public void logic2() {
    repository3.hello();
}

 

준영속 상태와 지연 로딩

  • 앞서 이야기했듯이, 컨트롤러나 뷰 같은 프레젠테이션 계층은 준영속 상태가 된다. 이때 지연 로딩 전략을 가진 객체를 조회하면 예외가 발생한다.

 

@Entity
public class Order {

    @Id
    @GeneratedValue
    private Long Id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;

}

 

  • 가령 위 Order 객체를 컨트롤러에서 getMember() 를 통해 지연 로딩 객체를 초기화하려고 하면 예외가 발생하는 것이다.
  • 준영속 상태의 지연 로딩을 해결하는 방법은 크게 2가지가 있다.
    • 뷰가 필요한 엔티티를 미리 로딩해 두는 방법
      • 글로벌 페치 전략 수정
      • JPQL 페치 조인
      • 강제로 초기화
    • OSIV를 사용하여 엔티티를 항상 영속 상태로 유지하는 방법

 

글로벌 페치 전략 수정

@Entity
public class Order {

    @Id
    @GeneratedValue
    private Long Id;

    @ManyToOne(fetch = FetchType.EAGER)
    private Member member;

}

 

  • 글로벌 페치 전략을 지연 로딩에서 즉시 로딩으로 변경하면 된다. 그러면 항상 비즈니스 로직에서 연관 관계가 다 들어가 있는 객체를 반환해 줄 수 있다.

 

글로벌 페치 전략의 단점

  • 사용하지 않는 엔티티를 로딩한다.
    • 예를 들어 화면 A에서 order과 member 둘 다 필요해서 글로벌 로딩 전략을 즉시 로딩으로 설정했다. 반면 화면 B는 order 엔티티만 있으면 충분하다. 하지만 화면 B는 로딩 전략으로 인해, order를 조회하면서 사용하지 않는 member도 함께 조회하게 된다.
  • N + 1 문제가 발생한다.
    • 단일 조회는 괜찮지만, JPQL을 사용하여 List<Order> 을 반환하면 Order 엔티티 하나를 가져올 때마다 Order 개수 N개만큼 Member 단 건 조회 쿼리를 날리게 된다.

 

JPQL 페치 조인

  • 페치 조인을 사용하면 N + 1 문제를 해결하면서 연관된 엔티티를 한꺼번에 가져올 수 있다.

 

JPQL 페치 조인의 단점

  • 무분별하게 사용하면 View에 맞춘 Repository 메소드가 증가하여, 프레젠테이션 계층이 데이터 접근 계층을 침범하게 된다.
    • 가령, 화면 A는 order 엔티티만 필요하고 화면 B는 order 엔티티와 member 엔티티가 필요하다면, 이들을 위한 Repository 메소드가 증가하게 된다.

 

강제로 초기화

class OrderService {

    @Transactional
    public Order findOrder(Long Id) {
        Order order = orderRepository.findOrder(id);
        order.getMember().getName(); // 프록시 객체를 강제로 초기화
        return order;
}

 

  • 손 쉽게 View에서 필요한 연관 관계를 넣어서 반환해 줄 수 있지만, 프레젠테이션 계층이 서비스 계층을 침범하고 있다.
  • 따라서 비즈니스 로직을 담당하는 서비스 계층과 프레젠테이션 계층을 위한 프록시 초기화 역할을 분리해야 한다. 이때 FACADE 계층이 사용된다.

 

FACADE 계층 추가

Untitled

 

  • FACADE 계층은 프레젠테이션 계층과 서비스 계층 사이에서 프록시 객체를 초기화하는 역할을 한다.
  • 기존에는 트랜잭션의 시작을 서비스에서 진행하였지만, 이제는 Facade 계층에서 시작하면 된다.

 

class OrderFacade {

    @Autowired
    private OrderSerivce orderService;

    public Order findOrder(Long id) {
        Order order = orderService.findOrder(id);
        order.getMember().getName();
        return order;
    }
}

class OrderService {

    public Order findOrder(Long Id) {
        Order order = orderRepository.findOrder(id);
        return order;
}

 

  • 서비스 계층과 프레젠테이션 계층 사이의 의존 관계를 끊어냈지만, 계층 하나를 더 추가해야하니 복잡도가 올라간다는 단점이 있다.

 

준영속 상태와 지연 로딩의 문제점

  • View 개발할 때 엔티티 클래스를 보고 개발하지, FACADE나 서비스 클래스까지 열어 보는 것은 번거롭다. 그래서 영속성 컨텍스트가 없는 View에서 초기화하지 않은 프록시 엔티티를 조회하는 실수가 생기게 된다.
  • FACADE 계층을 사용하더라도 각 화면마다 필요한 여러 종류의 조회 메소드를 추가해야 하므로 유지 보수하기 나쁘다.
    • 화면 A는 order만 필요함.
    • 화면 B는 order, member가 필요함.
    • 화면 C는 order, orderItem이 필요함.

 

OSIV

  • OSIV는 Open Session In VIEW의 약자로, 영속성 컨텍스트를 View까지 열어 둔다는 뜻이다. 따라서 View에서도 지연 로딩을 사용할 수 있게 된다.
  • OSIV는 하이버네이트에서 부르는 용어고, JPA에서는 OEIV라고 부른다. 하지만 둘 다 관례상 OSIV라고 부른다.

 

과거 OSIV: 요청 당 트랜잭션

  • OSIV의 가장 단순한 구현은 클라이언트의 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 트랜잭션을 시작하고, 요청이 끝날 때 트랜잭션을 끝내는 것이다.
  • 영속성 컨텍스트가 처음부터 끝까지 살아있으므로 조회한 데이터도 영속 상태를 유지한다. 따라서 View에서도 지연 로딩이 가능하므로 FACADE 계층 없이 필요 없어진다.
  • 하지만 이 방식은 컨트롤러나 View 같은 프레젠테이션 계층이 엔티티를 변경할 수 있으므로, 프레젠테이션 계층에서 변경 사항이 데이터베이스에도 적용이 되는 심각한 문제가 발생할 수 있다.
  • 따라서 프레젠테이션 계층에서 엔티티를 수정하지 못하도록 막아야 한다.
    • 엔티티를 읽기 전용 인터페이스로 제공
    • 엔티티 레핑
      • 엔티티를 한 단계 감싼 객체를 만들고, 엔티티의 읽기 전용 메소드만 제공하는 방식
    • DTO만 반환
  • 위 방식 모두 코드량이 증가한다는 단점이 있어서, 비즈니스 계층에서만 트랜잭션을 유지하는 방식의 OSIV를 사용한다.

 

스프링 OSIV: 비즈니스 계층 트랜잭션

  • 스프링 프레임워크가 제공하는 OSIV는 비즈니스 계층에서 트랜잭션을 사용하는 OSIV이다.

 

Untitled

 

  1. 클라이언트의 요청이 들어오면 서블릿 필터나, 스프링 인터셉터에서 영속성 컨텍스트를 생성한다. 단, 이때 트랜잭션은 시작하지 않는다.
  2. 서비스 계층에서 @Transactional로 트랜잭션을 시작할 때, 1번에서 미리 생성해 둔 영속성 컨텍스트를 찾아와서 트랜잭션을 시작한다.
  3. 서비스 계층이 끝나면 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시한다. 이때 트랜잭션은 끝나지만 영속성 컨텍스트는 그대로 유지된다.
  4. 컨트롤러와 View까지 영속성 컨텍스트가 유지되므로 조회한 엔티티는 영속 상태를 유지한다.
  5. 서블릿 필터나, 스프링 인터셉터로 요청이 돌아오면 영속성 컨텍스트를 종료한다. 이때 플러시를 호출하지 않고 바로 종료한다.

 

트랜잭션 없이 읽기

  • 엔티티를 변경하지 않고 단순히 조회할 때는 트랜잭션이 없어도 되는데, 이를 트랜잭션 없이 읽기라고 한다.
  • 프레젠테이션 계층에는 트랜잭션이 없지만, 트랜잭션 없이 읽기를 사용해서 지연 로딩을 수행할 수 있다.

 

class MemberController {

    public void viewMember(Long id) {
        Member member = memberService.getMember(id);
        member.setName("XXX");
        model.addAttribute("member", member);
    }
}

 

  • OSIV 스프링 인터셉터는 요청이 끝나면 플러시를 호출하지 않고, 영속성 컨텍스트를 종료하므로 플러시가 발생하지 않는다. 따라서 위처럼 회원의 이름을 변경해도 문제가 없다.
  • 프레젠테이션 계층에서 강제로 플러시해도 에러를 띄워서 데이터베이스에 변경 사항이 일어나지 않도록 막는다.

 

스프링 OSIV 주의 사항

  • 프레젠테이션 계층에서 엔티티를 수정한 직후에, 트랜잭션을 시작하는 서비스 계층을 호출하면 문제가 발생한다.
  • 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있으므로 롤백 같은 일이 발생할 때 문제가 생길 수 있다.
  • 복잡한 화면은 객체 그래프를 사용하기보다는 DTO로 반환하는 것이 좋다.
  • OSIV는 JVM을 벗어난 원격 상황에서는 사용할 수 없다.
  • JSON으로 생성한 API를 외부 API, 내부 API로 나눌 수 있는데, 외부 API는 변경이 잦으므로 DTO를 사용하고 내부 API는 변경이 적으므로 OSIV를 사용하는 것이 좋다.

 

너무 엄격한 계층

  • OSIV를 사용하기 전에는 프레젠테이션 계층에서 사용할 지연 로딩된 엔티티를 미리 초기화해야 했다. 그리고 초기화는 서비스 계층이나 FACADE 계층이 담당했다.
  • OSIV를 사용하게 되면 영속성 컨텍스트가 프레젠테이션 계층까지 살아있으므로 미리 초기화 할 필요가 없으므로 단순한 엔티티 조회는 컨트롤러에서 Repository를 호출해도 상관 없다.

 

Untitled

 

출처

김영한 - 자바 ORM 표준 JPA 프로그래밍

댓글

추천 글