자바 ORM 표준 JPA 프로그래밍

[자바 ORM 표준 JPA 프로그래밍] 13장 - 웹 애플리케이션과 영속성 관리

jny0 2023. 7. 26. 23:45
 

자바 ORM 표준 JPA 프로그래밍 | 김영한 - 교보문고

자바 ORM 표준 JPA 프로그래밍 | 자바 ORM 표준 JPA는 SQL 작성 없이 객체를 데이터베이스에 직접 저장할 수 있게 도와주고, 객체와 관계형 데이터베이스의 차이도 중간에서 해결해준다. 이 책은 JPA

product.kyobobook.co.kr

 

스프링 환경에서 JPA를 사용하면 컨테이너가 트랜잭션과 영속성을 관리해준다.

컨테이너 환경에서 동작하는 JPA의 내부 동작 방식을 이해하지 못하면 문제가 발생했을 때 해결이 쉽지 않다.

 

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

 

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

  • 스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다.
  • 트랜잭션의 범위와 영속성 컨텍스트의 생존 범위가 같다
    • 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고, 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다.
    • 같은 트랜잭션 안에서는 항상 같은 영속성 컨텍스트에 접근한다

 

  • @Transactional 어노테이션이 있으면 호출한 메소드를 실행하기 직전에 스프링의 트랜잭션 AOP가 먼저 동작한다.
  • 스프링 트랜잭션 AOP는 대상 메소드를 호출하기 직전에 트랜잭션을 시작하고, 대상 메소드가 정상 종료되면 트랜잭션을 커밋하면서 종료한다.
    • 트랜잭션 커밋 → 영속성 컨텍스트 플러시 (변경내용 DB 반영) → DB트랜잭션 커밋
    • if 예외가 발생하면 트랜잭션을 롤백하고 종료 (플러시를 호출하지 않음)

 

코드 예제

더보기
@Contorller
class HelloController {
		@Autowired HelloService helloService;
		
		public void hello() {
				//반환된 member 엔티티는 준영속 상태 -- 4
				Member member = helloService.logic();
		}
}

@Service
class HelloService {
		@PersistenceContext //엔티티 매니저 주입
		EntityManager em;

		@Autowired Repository1 repository1;
		@Autowired Repository2 repository2;

		//트랜잭션 시작 -- 1
		@Transactional
		public void logic() {
				repository1.hello();
				
				//member는 영속 상태 -- 2
				Member member = repository2.findMember();
				return member;
		}
		//트랜잭션 종료 -- 3
}

@Repository
class Repository1 {
		@PersistenceContext
		EntityManager em;

		public void hello() {
				em.xxx(); //A. 영속성 컨텍스트 접근
		}
}

@Repository
class Repository2 {
		@PersistenceContext
		EntityManager em;

		public Member member() {
				return em.find(Member.class, "id1"); //B. 영속성 컨텍스트 접근
		}
}

 

트랜잭션이 같으면 같은 영속성 컨테스트를 사용한다

  • 엔티티 매니저는 달라도 같은 트랜잭션 범위안에 있다면 같은 영속성 컨텍스트를 사용한다.

 

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

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

 


 

2. 준영속 상태와 지연 로딩

  • 트랜잭션은 보통 서비스 계층에서 시작되므로 서비스 계층이 끝나는 시점에 트랜잭션이 종료되면서 영속성 컨텍스트도 함께 종료
    • 서비스와 리포지토리 계층에서는 영속 상태를 유지
    • 컨트롤러나 뷰 같은 프리젠테이션 계층에서는 준영속 상태
  • 프리젠테이션 계층에서 준영속 상태이기 때문에 변경감지와 지연로딩이 동작하지 않는다.
@Entity
public class Order {

    @Id
    @GeneratedValue
    private Long Id;

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

}

// 컨트롤러 로직
class OrderController {
        public String view(Long orderId) {
                Order order = orderService.findOne(orderId);
                Member member = order.getMember();
                member.getName(); //지연 로딩 시 예외 발생
        }
}

 

준영속 상태와 변경 감지

  • 변경 감지 기능은 영속성 컨텍스트가 살아있는 서비스 계층까지만 동작한다.
  • 변경 감지 기능이 프리젠테이션 계층에서도 동작하면 어플리케이션 계층이 가지는 책임이 모호해지고, 유지보수가 어렵다.

 

준영속상태와 지연로딩

  • 프리젠테이션 계층에서 연관된 엔티티를 사용하려고 할 때 준영속 상태라 지연 로딩을 할 수 없는 문제가 발생
  • 해결 방법 2가지
    • 뷰가 필요한 엔티티를 미리 로딩해두는 방법
      • 글로벌 페치 전략 수정
      • JPQL 페치 조인
      • 강제로 초기화
    • OSIV를 사용해서 엔티티를 항상 영속상태로 유지하는 방법

 

2.1 글로벌 페치 전략 수정

  • 가장 간단한 방법 - 글로벌 페치 전략을 지연로딩에서 즉시로딩으로 변경
  • 즉시 로딩 전략으로 설정하면 연관된 엔티티도 항상 함께 로딩한다.
@Entity
public class Order {

    @Id
    @GeneratedValue
    private Long Id;

    @ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩 전략
    private Member member;

}

 

글로벌 페치 전략에 즉시로딩 사용시 단점

  • 사용하지 않는 엔티티를 로딩한다
  • N+1 문제가 발생한다 - JPQL 페치 조인으로 해결 가능

 

2.2 JPQL 페치 조인

  • 페치 조인을 사용하면 N+1 문제를 해결하면서 연관된 엔티티를 함께 로딩한다.
  • 페치 조인은 조인 명령어 마지막에 fetch를 넣어주면 된다
  • select o from Order o join fetch o.member

 

JPQL 페치 조인의 단점

  • 페치 조인을 무분별하게 사용하면 화면에 맞춘 리포지토리 메소드가 증가할 수 있다. 결국 프리젠테이션 계층이 알게 모르게 데이터 접근 계층을 침범하는 것이다.
    • 화면 A를 위해 order만 조회하는 repository.findOrder() 메소드
    • 화면 B를 위해 order와 연관된 member를 페치 조인으로 조회하는 repository.findOrderWithMember() 메소드
  • 무분별한 최적화로 프리젠테이션 계층과 데이터 접근 계층 간에 의존관계가 급격하게 증가하는 것 보다는 적절한 선에서 타협점을 찾는 것이 합리적이다.

 

2.3 강제로 초기화

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

    @Transactional
    public Order findOrder(Long Id) {
        Order order = orderRepository.findOrder(id);
        order.getMember().getName(); // 프록시 객체를 강제로 초기화
        return order;
}
  • 프록시 객체는 실제 사용하는 시점에 초기화
    • 하이버네이트를 사용하면 initialize()메소드 사용해서 강제로 초기화 가능
  • 손 쉽게 뷰에서 필요한 연관 관계를 넣어서 반환해 줄 수 있지만, 프레젠테이션 계층이 서비스 계층을 침범하고 있다.
  • 따라서 비즈니스 로직을 담당하는 서비스 계층과 프레젠테이션 계층을 위한 프록시 초기화 역할을 분리해야 한다. 이때 FACADE 계층이 사용된다.

 

2.4 FACADE 계층 추가

  • 프리젠테이션 계층과 서비스 계층 사이에 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;
}
  • 실용적인 관점에서 중간에 계층이 하나 더 끼어든다는 단점
  • 단순히 서비스 계층을 호출만 하는 위임코드가 상당히 많은 문제

 

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

  • 뷰를 개발할 때 필요한 엔티티를 미리 초기화 하는 방법은 생각보다 오류가 발생할 가능성이 높다. 초기화여부를 확인하기 위해 FACADE나 서비스 클래스를 열어보는 일은 상당히 번거롭다.
  • 어플리케이션 로직과 뷰가 물리적으로는 나누어져 있지만 논리적으로는 서로 의존한다는 문제가 있다.
  • 모든 문제는 엔티티가 프리젠테이션 계층에서 준영속 상태이기 때문에 발생한다. 그러므로 영속성 컨텍스트를 뷰까지 살아있게 열어둘 수 있는데, 이것이 OSIV이다.

 


 

3. OSIV

  • OSIV (Open Session In View)는 영속성 컨텍스트를 뷰까지 열어 둔다는 뜻이다. 뷰에서도 엔티티가 영속상태이므로 지연로딩을 사용할 수 있다.
  • OSIV는 하이버네이트에서 부르는 용어고, JPA에서는 OEIV라고 부른다. 하지만 둘 다 관례상 OSIV라고 부른다.

 

3.1 과거 OSIV : 요청 당 트랜잭션

  • 요청 당 트랜잭션 : 클라이언트 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 트랜잭션을 시작하고 요청이 끝날 때 트랜잭션을 끝내는 것
  • 영속성 컨텍스트가 처음부터 끝까지 살아있으므로 프리젠테이션 계층에서도 지연로딩 사용이 가능

 

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

  • 프리젠테이션 계층이 엔티티를 변경할 수 있으므로, 프레젠테이션 계층에서 변경한 사항이 데이터베이스에도 적용이 되는 심각한 문제가 발생한다.
  • 해결 방법은 아래 3가지

 

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

  • 엔티티를 직접 노출하는 대신에 읽기 전용 메소드만 제공하는 인터페이스를 프리젠테이션 계층에 제공하는 방법
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를 사용한다.

 

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

  • 스프링 프레임워크는 다양한 OSIV 클래스를 제공한다.
    • 하이버네이트 OSIV 서블릿 필터:
      • org.springframework.orm.hibernate4.support.OpenSessionInViewFilter
    • 하이버네이트 OSIV 스프링 인터셉터:
      • org.springframework.orm.hibernate4.support.OpenSessionInViewInterceptor
    • JPA OEIV 서블릿 필터:
      • org.springframework.orm.jpa.support.OpenEntityManagerInViewFilter
    • JPA OEIV 스프링 인터셉터
      • org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor

 

스프링 OSIV 분석

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

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

 

트랜잭션 없이 읽기

  • 엔티티를 변경하지 않고 단순히 조회만 할 때는 트랜잭션이 없어도 되는데 이것을 트랜잭션 없이 읽기라 한다.
  • 프록시를 초기화하는 지연로딩도 조회기능이므로 트랜잭션 없이 읽기가 가능하다.
  • 정리하면, 영속성 컨텍스트는 트랜잭션 범위 안에서 엔티티 조회, 수정 가능 / 트랜잭션 범위 밖에서 엔티티 조회 가능

 

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

  • 서비스 게층이 끝날 때 트랜잭션이 커밋되면서 이미 플러시해버렸다.
  • 스프링이 제공하는 OSIV서블릿 필터나 OSIV 스프링 인터셉터는 요청이 끝나면 플러시를 호출하지 않고 em.close()로 영속성 컨텍스트만 종료해 버리므로 플러시가 일어나지 않는다 .
  • 프리젠테이션 계층에서 em.flush()를 호출해서 강제로 플러시해도 트랜잭션 범위 밖이이므로 데이터를 수정할 수 없다는 예외를 만난다.
  • 따라서 프리젠테이션 계층에서 엔티티를 수정해도 수정 내용을 DB에 반영하지 않는다.

 

스프링 OSIV 주의사항

  • 프리젠테이션 계층에서 엔티티를 수정한 직후에 트랜잭션을 시작하는 서비스 계층을 호출하면 문제가 발생
class MemberController {
    public String viewMember(Long id) {
        Member member = memberService.getMember(id);
        member.setName("XXX"); // 보안상의 이유로 고객 이름을 XXX로 변경했다. 

        memberService.biz(); // 비즈니스 로직
        return "view";
    }
}
  • biz() 메소드가 끝나면 트랜잭션 AOP는 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시한다. 이때 변경 감지가 동작하면서 회원 엔티티의 수정 사항을 DB에 반영한다
  • 해결 방법은 트랜잭션이 있는 비즈니스 로직을 모두 호출하고 나서 엔티티를 변경하면 된다.

 


 

4. 너무 엄격한 계층

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