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

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

순수하게 J2SE 환경에서 JPA를 사용하면 개발자가 직접 엔티티 매니저를 생성하고 트랜잭션도 관리해야 한다. 하지만 스프링니나 J2EE 컨테이너 환경에서 JPA를 사용하면 컨테이너가 제공하는 전략을 따라야 한다.

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

  • 스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 사용한다.

  • 해당 전략은 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고, 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다.

  • 스프링 프레임워크를 사용하면 비즈니스 로직을 시작하는 서비스 계층에 @Transactional을 선언하여 트랜잭션을 시작한다. 그래서 서비스 위 계층은 준영속 상태가 된다.

스크린샷 2023-10-30 오후 5.24.34.png

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

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

// repository3는 repository1과 repository2와 다른 영속성 컨텍스트에 접근
@Transactional
public void logic2() {
    repository3.hello();
}
  • 트랜잭션이 같으면 같은 영속성 컨텍스트를 사용한다.

스크린샷 2023-10-30 오후 5.33.44.png

엔티티 매니저를 사용하는 A, B 코드는 모두 같은 트랜잭션 범위에 있다.

따라서 엔티티 매니저는 달라도 같은 영속성 컨텍스트를 사용한다.

  • 트랜잭션이 다르면 다른 영속성 컨텍스트를 사용한다.

스크린샷 2023-10-30 오후 5.40.43.png

여러 스레드에서 동시에 요청이 와서 같은 엔티티 매니저를 사용해도 트랜잭션에 따라 접근하는 영속성 컨텍스트가 다르다. 스프링 컨테이너는 스레드마다 가각 다른 트랜잭션을 할당한다.

준영속 상태와 지연 로딩

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

@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에서 ordermember 둘 다 필요해서 글로벌 로딩 전략을 즉시 로딩으로 설정을 했다고 가정하자. 반면 화면 B는 order 엔티티만 있으면 충분하다. 하지만 B는 로딩 전략으로 인하여 order를 조회하면서 사용하지 않는 member도 함께 조회하게 된다.

  • N + 1문제가 발생한다.

    • 단일 조회른 괜찮지만, JPQL을 사용하여 List<Order>을 반환하면 Order 엔티티 하나를 가져올 때마다 Order 개수 N개만큼 Member 당 건 조회 쿼리를 날리게 된다.

    List<Order> orders = 
    		em.createQuery("select o from Order o", Order.class)
    		.getResultList(); // 연관된 모든 엔티티를 조회한다. 
    
    // 결과
    // 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
    ...

JPQL 페치 조인

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

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 페치 조인의 단점

무분별하게 사용하면 View에 맞춘 Repository 메서드가 증가하여, 프레젠테이션 계층이 데이터 접근 계층을 침범하게 된다.

  • 화면 A를 위해 order만 조회하는 repository.findOrder() 메서드

  • 화면 B를 위해 ordermember를 페치 조인으로 조회하는 repository.findOrderWithMember() 메서드

이처럼 메서드를 각각 만들면 최적화는 할 수 있지만 뷰와 레퍼지토리 간에 논리적인 의존관계가 발생한다.

즉, 무분별한 최적화로 프리젠테이션 계층과 데이터 접근 계층 간에 의존관계가 급격하게 증가하는 것 보다는 적절한 선에서 타협점을 찾는 것이 합리적이다.

강제로 초기화

class OrderService {

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

이 방법은 손쉽게 View에서 필요한 연관 관계를 넣어서 반환해 줄 수 있지만, 프레젠테이션 계층이 서비스 계층을 침범하고 있다.

따라서 비즈니스 로직을 담당하는 서비스 계층과 프레젠테이션 계층을 위한 프록시 초기화 역할을 분리해야 한다. 이때 FACADE 계층를 사용한다.

FACADE 계층 추가

스크린샷 2023-10-30 오후 6.00.47.png
  • 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 계층을 사용하더라도 각 화면마다 필요한 여러 종류의 조회 메서드를 추가해야 하므로 유지 보수하기 나쁘다.

결국 모든 문제는 엔티티가 프레젠테이션 계층에서 준영속 상태이기 때문에 발생한다.

OSIV

  • OSIVOpen Session In VIEW의 약자로, 영속성 컨텍스트를 View까지 열어 둔다는 뜻이다. 따라서 View에서도 지연 로딩을 사용할 수 있게 된다.

  • OSIV는 하이버네이트에서 부르는 용어이고, JPA에서는 OEIV라고 부른다. 하지만 둘 다 관례상 OSIV라고 부른다.

스크린샷 2023-10-30 오후 6.13.21.png

그림과 같이 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 영속성 컨텍스트를 만들면서 트랜잭션을 시작하고 요청이 끝날 때 트랜잭션과 영속성 컨텍스트를 함께 종료한다.

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

요청 당 트랜잭션 방식의 OSIV가 가지는 문제점은 컨트롤러나 뷰 같은 프리젠테이션 계층이 엔티티를 변경할 수 있다는 점이다. 프리젠테이션 계층에서 엔티티를 수정하지 못하게 막는 방법들은 아래와 같다.

  • 엔티티를 읽기 전용 인터페이스로 제공

엔티티를 직접 노출하는 대신에 아래 코드와 같이 읽기 전용 메서드만 제공하는 인터페이스를 프레젠테이션 계층에 제공하는 방법이다.

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: 비즈니스 계층 트랜잭션

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

스크린샷 2023-10-30 오후 6.30.53.png
  • 클라이언트의 요청이 들어오면 서블릿 필터나, 스프링 인터셉터에서 영속성 컨텍스트를 생성한다. 단, 이때 트랜잭션은 시작하지 않는다.

  • 서비스 계층에서 @Transactional로 트랜잭션을 시작할 때, 1번에서 미리 생성해 둔 영속성 컨텍스트를 찾아와서 트랜잭션을 시작한다.

  • 서비스 계층이 끝나면 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시한다. 이때 트랜잭션은 끝나지만 영속성 컨텍스트는 그대로 유지된다.

  • 컨트롤러와 View까지 영속성 컨텍스트가 유지되므로 조회한 엔티티는 영속 상태를 유지한다.

  • 서블릿 필터나, 스프링 인터셉터로 요청이 들어오면 영속성 컨텍스트를 종요한다. 이때 플러시를 호출하지 않고 바로 종료한다.

트랜잭션 없이 읽기

엔티티를 변경하지 않고 단순히 조회할 때는 트랜잭션이 없어도 되는데, 이를 트랜잭션 없이 읽기라고 한다.

프레젠테이션 계층에는 트랜잭션이 없지만, 트랜잭션이 없이 읽기를 사용해서 지연 로딩을 수행할 수 있다.

OSIV는 아래와 같은 특징이 있다.

  • 영속성 컨텍스트를 프레젠테이션 계층까지 유지한다.

  • 프레젠테이션 계층에는 트랜잭션이 없으므로 엔티티를 수정할 수 없다.

  • 프레젠테이션 계층에는 트랜잭션에 없지만, 트랜잭션 없이 읽기를 사용해서 지연로딩을 할 수 있다.

컨트롤러에서 플러시가 동작하지 않는 이유

class MemberController {

    public void viewMember(Long id) {
        Member member = memberService.getMember(id);
        member.setName("XXX");
        model.addAttribute("member", member);
    }
}
  • 트랜잭션을 사용하는 서비스 계층이 끝날 때 트랜잭션이 커밋되면서 이미 플러시를 해버린다. 스프링이 제공하는 OSIV 서블릿 필터OSIV 스프링 인터셉터는 요청이 끝나면 플러시를 호출하지 않고, em.close()로 영속성 컨텍스트만 종료해 버리므로 플러시가 일어나지 않는다.

  • 프레젠테이션 계층에서 em.flush()를 호출해서 강제로 플러시해도 트랜잭션 범위 밖에 있기 때문에 데이터를 수정할 수 없다는 예외가 발생한다.

스프링 OSIV 주의 사항

  • 프레젠테이션 계층에서 엔티티를 수정한 직후에 트랜잭션을 시작하는 서비스 계층을 호출하면 문제가 발생한다.

  • 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있기 때문에 롤백 같은 일이 발생할 때 문제가 생길 수 있다.

  • 복잡한 화면은 객체 그래프를 사용하기보다는 DTO로 반환하는 것이 좋다.

  • OSIV는 JVM을 벗어난 원격 상황에서는 사용할 수 없다.

  • JSON으로 생성한 API를 외부 API, 내부 API로 나눌 수 있는데, 외부 API는 변경이 잦으므로 DTO를 사용하고 내부 API는 변경이 적으므로 OSIV를 사용하는 것이 좋다.

너무 엄격한 계층

OSIV를 사용하기 전에는 프레젠테이션 계층에서 사용할 지연 로딩된 엔티티를 미리 초기화해야 했다. 그리고 초기화는 서비스 계층이나 FACADE 계층이 담당했다.

OSIV를 사용하게 되면 영속성 컨텍스트가 프레젠테이션 계층까지 살아있으므로 미리 초기화 할 필요가 없기 때문에 단순한 엔티티 조회는 컨트롤러에서 Repository를 호출해도 상관없다.

스크린샷 2023-10-30 오후 6.46.23.png

참고 자료

Last updated