영속성 관리

Java Spring이나, J2EE 환경에서 JPA을 이용한 개발을 하게 되면 컨테이너에서 트랜잭션과 영속성 컨텍스트를 관리해준다.

Spring Basic Strategy

트랜잭션 범위

Spring 환경에서는 기본적으로 트랜잭션 범위의 영속성 컨텍스틑 전략을 취한다. 트랜잭션이 생성될 때, 영속성 컨텍스트가 생성되고, 종료될 때, 영속성 컨텍스트도 종료된다.

transaction_scope_persitence_context

위 그림을 보면, @Transaction 처리를 통해 트랜잭션 내에서 처리되는 Service, Repository에서 영속성 컨텍스트가 관리되는 것을 확인할 수 있다. 또한, 영속성 컨텍스트가 유지되는 상황에서 엔티티는 영속상태로 유지된다.

transaction_aop

@Transaction 선언을 하게 되면 우선적으로 service의 메소드가 호출되기 직전에 트랜잭션 AOP가 실행된다. 이는 실제 메소드 호출하기 전에 트랜잭션을 시작하고, 메소드가 종료될 때에는 영속성 컨텍스트의 변경내용이 DB에 반영되도록 플러시를 수행하고 커밋을 진행하게 된다. 트랜잭션 커밋을 하기 전에는 플러시 과정이 필요하다.

아래의 예제를 통해 언제 트랜잭션이 생성되는 지 알아보자

@Contorller
class HelloController {
		@Autowired HelloService helloService;
		
		public void hello() {
				//반환된 member 엔티티는 준영속 상태 
				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. 영속성 컨텍스트 접근
		}
}

같은 트랜잭션 내 엔티티 접근

트랜잭션이 같으면 항상 영속성 컨텍스트를 접근하게 된다. 즉, 영속성 컨테스트는 아래와 같이 트랜잭션 한개에 대해서 하나의 영속성 컨텍스트를 생성하게 된다.

transaction_persistence_context

다른 엔티티 매니저를 주입받아도, 같은 트랜잭션에 있게 되면 항상 같은 영속성 컨텍스트에 접근하게 되는 것이다.

Spring 이나 J2EE 환경에서는 내부적으로 멀티 스레드와 트랜잭션 기능을 컨테이너에서 처리해준다.

준영속 상태와 지연 로딩

Domain

@Entity
public class Order {
		@Id @GeneratedValue
		private Long id;

		@ManyToOne(fetch = FetchType.LAZY) //지연 로딩 전략
		pirvate Member member; //주문 회원
}

Controller

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

위와 같이 Domain에서 Member 엔티티에 대해 지연로딩을 설정하게 되면, Controller에서 준영속 상태인 member에 접속하게 되면 에러가 발생한다. 이는 준영속 상태에서는 지연로딩을 수행하지 않기 때문이다.

이러한 문제점은 뷰 랜더링 과정에서 지연로딩으로 설정된 연관된 엔티티를 함께 출력해야되는 경우 문제가 발생한다.

뷰 랜더링을 수행하는 Controller에서는 영속성 컨텐스트가 없는 상황에서 준영속 상태인 엔티티에 대한 지연로딩을 수행할 수 없어서 LazyInitializationException 에러가 발생하게 된다.(프록시 객체가 초기화를 위해 영속성 컨텍스트에 요청을 해야하는데, 영속성 컨텍스트가 없어서 프록시 객체 초기화가 일어나지 않는다.)

이를 해결하기 위해 2가지 방법이 존재한다.

뷰에서 필요한 모든 엔티티를 미리 로딩

글로벌 페치 전략 수정

기존의 지연 로딩으로 되어있던 페치 전략을 즉시 로딩으로 수정하여 페치 조인시 연관된 엔티티도 함께 로딩될 수 있도록 한다.

@Entity
public class Order {
		@Id @GeneratedValue
		private Long id;

		@ManyToOne(fetch = FetchType.EAGER) //지연 로딩 전략
		pirvate Member member; //주문 회원
}

이렇게 하면, 모든 엔티티가 함께 로딩되어서 모든 엔티티에 접근할 수 있게 된다.

단점

  1. 항상 즉시 로딩을 수행하기 때문에, 해당 객체를 쓰지 않아도 되는 상황에서도 객체는 로딩된다.

  2. N+1 문제

즉시 로딩으로 설정되어 있으면, em.find를 실행하게 되면 아래와 같이 left join을 통해 한번에 모든 엔티티가 조회된다.

Order order=em.find(Order.class,1L);

//sql
/*
SELECT o.*, m.* from Order o
left join Member m on o.member_id=m.member_id
where o.id=1L
*/

만약, jpql을 사용하게 되면 어떻게 될까?

List<Orders> orders=em.createQuery("select o from Order o",Order.class).getResultList();

//sql
/*
select * from Order o;
select * from Member where id=?
select * from Member where id=?
....
*/

jpql에서는 글로벌 페치 전략이 어떻게 설정되어 있는지 상관 없이 항상 jpql만을 참조해서 엔티티를 가져오게 된다. 우선 jpql을 있는 그대로 실행하게 된다.

위의 jpql 코드는 아래와 같은 순서로 동작하게 된다.

  1. select o from Order o JPQL을 분석해 select * from Order SQL을 생성
  2. 데이터베이스에서 결과를 받아 order 엔티티 인스턴스들을 생성
  3. Order.member의 글로벌 페치 전략이 즉시 로딩이므로 order를 로딩하는 즉시 연관된 member도 로딩
  4. 연관된 member를 영속성 컨텍스트에서 조회
  5. 만약 영속성 컨텍스트에 없으면 select * from Member where id = ? SQL을 조회한 order 엔티티 수만큼 실행

즉, jpql 자체만을 먼저 실행하고, 해당 엔티티가 즉시 로딩으로 설정되어 있으니까 ,그제서야 연관된 엔티티에 대해 각각 sql 을 실행하게 되는것이다.

이렇게 처음에 조회된 엔티티 만큼 다시 sql 문을 실행되는 것을 N+1 문제라고 한다. 이렇게 SQL문을 많이 호출하게 되면 성능상 문제가 발생하게 된다.

이 부분은 JPQL 페치 조인을 이용해서 해결할 수 있다.

JPQL Fetch Join

List<Orders> orders=em.createQuery("select o from Order o join fetch o.member",Order.class).getResultList();

//sql
/*
SELECT o.*, m.* from Order o join Member m on o.member_id=m.member_id
where o.id=1L
....
*/

위와 같이 jpql을 이용해서 페치 조인을 수행하면 연관된 엔티티도 미리 로딩하게 된다.

단점

화면에 맞춘 리포지토리 메소드가 많아 지게 된다.

가령 화면 A는 orders만 필요하고, 화면 B는 order에 연관된 멤버까지 필요하다고 하면 화면 A에는 repository.findOrder을 통해 order만 로딩하고, 화면 B에는 repository.findOrderWithMember을 이용해서 order와 member모두를 로딩하는 메소드를 따로 설계한다. 이렇게 하면 각각 최적화는 진행되지만, 뷰와 리포지토리 간에 논리적인 의존관계가 형성되며, 메소드 개수또한 많아진다.

이럴때는 그냥 findOrder을 이용해서 member, order 모두를 join해서 로딩하는 방향으로 적절하게 타협을 본다.

강제 초기화

영속성 컨텍스트가 있을때, 프록시 객체에 대한 강제 초기화를 진행하는 것이다.

class OrderService{
    @Transactional
    public Order findOrder(id){
        Order order=orderRepository.findOrder(id);
        order.getMember().getName(); //member에 실제 사용하는 것 처럼 수행해서 강제 초기화 수행
        return order;
    }
}

하지만, 이런식으로 뷰가 필요로 한 엔티티에 따라 서비스에서 초기화 작업까지 진행하게 View 와 서비스 계층 간에 의존 관계가 형성이 되어 좋지 않다.

이러한 초기화 작업을 분리해줘야하는 데, 이는 FACADE 계층이 해당 기능을 수행할 수 있다.

facade_structure

Controller와 Service 사이에 존재하여, FACADE 층이 프록시 객체 초기화 작업을 수행하게 되면 Controller와 Service 간의 논리적 의존성이 분리 된다.

FACADE 계층의 역할과 특징

  • 프리젠테이션 계층과 도메인 모델 계층 간의 논리적 의존성을 분리
  • 프리젠테이션 계층에서 필요한 프록시 객체를 초기화
  • 서비스 계층을 호출해서 비즈니스 로직을 실행
  • 레포지토리를 직접 호출해서 뷰가 요구하는 엔티티를 탐색
class OrderFacade{
    @Autowired OrderService orderService;

    public Order findOrder(id){
        Order order=orderService.findOrder(id);
        order.getMember().getName(); //member에 실제 사용하는 것 처럼 수행해서 강제 초기화 수행
        return order;
    }
}

단점

  • 계층이 한개 추가 되면서 코드량이 증가한다
  • 해당 계층에는 서비스 계층 호출, 프록시 객체 초기화를 수행하는 메소드들로만 가득 차게 된다.

준영속 상태와 지연로딩이 가지는 단점

View 계층에서 사용할 엔티티를 미리 로딩하는 것은 위에서 봤듯 다양한 문제점을 야기한다.

서비스 계층과 View 간에 논리적인 의존관계가 생성되게 된다. FACADE 계층을 이용해서 이러한 의존관계를 어느정도 해소 할 수 있지만, 그렇다 해도 FACADE 계층을 구성해야 한다는 문제점이 추가로 발생한다.

아래와 같이 최적화를 위해 View에 맞춘 다양한 메소드를 생성해야한다.

//화면 A는 order만 필요하다
getOrder();

//화면 B는 order, order.member가 필요하다
getOrderWithMember();

//order, order.orderItems가 필요하다
getOrderWithOrderItems()

//order, order.member, order.orderItems가 필요하다
getOrderWithMemberWithOrderItems()

이러한 모든 문제는 엔티티가 Controller, View 단에서는 준영속 상태로 되기 때문이다.

OSIV

OSIV는 Open Session In View으로, 영속성 컨텍스트를 View 계층까지 열어두는 것을 의미한다. 이를 통해 View에서도 지연 로딩을 수행할 수 있게 된다.

Transaction Per OSIV

transaction_per_osiv

위와 같이 클라이언트의 요청이 들어 왔을때, Filter Interceptor에서 트랜잭션을 시작하고, 해당 요청이 종료될 때 트랜잭션을 종료하는 방식을 의미한다.

트랜잭션이 생성되면서 영속성 컨텍스트가 생성되기 때문에, View 계층에서도 지연로딩을 수행할 수 있다.

단점

이렇게 되면, View 계층, Controller 계층에서 엔티티에 대한 수정작업을 수행할 수 있게 된다.

엔티티가 영속 상태로 존재하기 때문에 수정에 대한 변경감지가 이루어져서 해당 수정 사항이 DB에 바로 반영되는 문제가 발생할 수 있다.

이러한 View, Controller 계층에서의 수정작업을 방지하기 위한 방법들이 존재한다.

Read-Only Interface 제공

interface MemberView {
	public String getName();
}


@Entity
class Member implements MemberView {
	...
}


class MemberService {
	public MemberView getMember(id) {
			return memberRepository.findById(id);
	}
}

위와 같이 getName만 호출 할 수 있는 인터페이스 형태로 제공하면 수정을 할 수 없다.

Entity Wrapping

class MemberWrapper {
		private Member member;

		public MemberWrapper(Member member) {
				this.member = member;
		}

		//읽기 전용 메소드만 제공
		public String getName() {
				member.getName();
		}
}

위와 같이 엔티티와 해당 엔티티에 대해 읽기 수행만 가능한 메소드를 wrapping 한 객체를 전달한다.

DTO 전달

class MemberDTO {
		private String name;

		//Getter, Setter
}

...
MemberDTO memberDTO = new MemberDTO();
memberDTO.setName(member.getName());
return memberDTO;

엔티티 대신에 엔티티 DTO를 만들어서 데이터만 전달한다. 이렇게 하면, OSIV의 장점을 이용하지 못하는 문제가 발생한다.

위 세 가지 방식 모두 공통적으로 코드의 양이 증가한다는 문제가 있다. 그래서 이와 같이 요청 당 osiv는 사용되지 않고 스프링에서 제공하는 osiv가 사용된다.

Spring OSIV

스프링에서 제공하는 osiv 라이브러리는 아래와 같다.

OSIV Descriptions
하이버네이트 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  

서블릿 필터에서 적용할지 스프링 인터셉터에서 적용할지에 따라 원하는 클래스를 선택해서 사용하면 된다.

spring_osiv

스프링 osiv를 보면, 영속성 컨텍스트는 요청당 osiv를 동일하게 View,Controller 영역까지 생존해 있음을 확인할 수 있다. 하지만 트랜잭션은 비즈니스 계층에만 존재하게 된다.

동작 원리

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

영속성 컨텍스트에 대한 모든 변경은 트랜잭션 내에서 이루어져야 한다. 그렇지 않은 경우, TransactionRequiredException 에러가 발생한다.

단, 엔티티에 대한 조회 기능은 트랜잭션 없이 수행가능한데, 이를 nontrasactional read라고 한다.(지연로딩을 통한 프록시 객체 초기화 작업은 조회 기능에 해당됨)

따라서, 스프링 osiv와 같은 구조에서는, View, Controller 계층에서 수정이 발생하면 트랜잭션 외에서 수정작업이 이루어진 것이므로 에러가 발생한다.

class MemberController {	
    public String viewMember(Long id) {
            Member member = memberService.getMember(id);
            member.setName("XXX"); //보안상 XXX로 변경
            model.addAtrribute("member", member);
    }
}

위와 같이 Controller에서 수정작업이 이루어진다고 해도, 영속성 컨텍스트가 종료될 때 플러시 없이 영속성 컨텍스트를 종료만 하고, 만일 강제로 플러시를 한다 하더라도 트랜잭션이 없어서 에러가 발생하게 된다.

주의 사항

트랜잭션이 없는 프레젠테이션 계층(Controller, View)에서 엔티티에 대한 수정을 해도 DB에 반영되지 않는다. 하지만, 프레젠테이션에서 엔티티를 수정한 직후에 서비스 계층을 호출하면 문제가 발생한다.

//Controller
class MemberController {	
    public String viewMember(Long id) {
            Member member = memberService.getMember(id);
            member.setName("XXX"); //보안상 XXX로 변경
            
            
            memberService.biz();//비즈니스 로직 수행
            return "view";
    }
}

//Service
class MemberService{
    @Transactional
    public void biz(){

    }
}

spring_osiv_cautions

위의 그림을 통해 과정을 살펴 보면,

  1. controller에서 엔티티에 대한 setName()을 통해 이름 수정
  2. memberSerive 의 biz 호출
  3. service의 biz가 실행되기 전에 트랜잭션이 생성됨
  4. biz()가 실행된 이후에 트랜잭션 커밋 수행과정에서 변경감지가 되어 엔티티의 수정사항이 DB에 반영된다.

이와 같은 문제는 컨트롤러에서 엔티티 수정 이후에 뷰를 호출 한 것이 아닌 비즈니스 계층을 호출해서 그런것이다.

보통의 경우에서는 비즈니스 계층을 모두 실행한 이후에 엔티티에 대한 수정이 발생하므로 이런 문제는 발생하지 않지만 일어날 수 있는 가능성이 있기 때문에 주의해야한다.

정리

Spring OSIV 특징

  • 클라이언트의 요청이 들어 올때, 영속성 컨텍스트를 생성해서 요청이 끝날때 까지 유지한다.
  • 엔티티 수정은 트랜잭션이 있는 비즈니스 계층에서만 이루어진다.

Spring OSIV 단점

  • OSIV를 적용하면 여러 트랜잭션이 같은 영속성 컨텍스트를 공유하기 때문에 문제가 발생할 수 있다.
  • 컨트롤러에서 엔티티 수정 이후에 서비스 계층을 호출하면 수정사항이 DB에 반영될 수 있다.
  • 프레젠테이션 계층에서 지연로딩에 의한 SQL이 실행된다. 성능 상 문제가 될 수 있음

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

JSON, XML 을 생성할 때는 지연로딩을 사용할 수 있지만, 원격지인 클라이언트에서 연관된 엔티티를 지연 로딩하는 것은 불가능하다. 클라이언트가 필요한 데이터는 모두 JSON으로 반환해야한다. 이때, 엔티티 혹은 DTO를 이용해서 노출한다.

이런식으루 JSON 형태로 반환하는 것을 API라고 한다. 이러한 API에는 외부 API, 내부 API가 있는데,

  • 외부 API
    • 외부에 노출하며 한번 정의하면 변경 어려움, 서버-클라이언트
    • 동시 수정 어려움
    • 예: 타팀과 협업하기 위한 API, 타 기업과 협엽하는 API
  • 내부 API
    • 외부에 노출없이 언제든 변경 가능
    • 서버-클라이언트 동시 수정 가능
    • 예: 같은 프로젝트에 있는 화면을 구성하기 위한 AJAX 호출

엔티티는 자주 바뀌기 때문에 JSON으로 반환하는 JSON API의 경우도 자주 변경되기 때문에, 외부 API는 엔티티를 직접 노출 하기 보다는 DTO로 변환해서 제공하는 것이 좋음, 내부 API는 클라이언트와 서버가 동시에 수정할 수 있어 엔티티를 직접 노출하는 것도 적절하다.

Controller에서 Repository 직접 호출

class OrderController {
		@Autowired OrderService orderService;
		@Autowired OrderRepository orderRepository;

		public String orderRequest(Order order, Model model) {
				long Id = orderService.order(order); //상품 구매
				
				//레포지토리 직접 접근
				Order orderRequest = orderRepository.findOne(id);
				model.addAttribute("order", orderResult); 
		}
}

@Transactional
class OrderService {
		
		@Autowired OrderRepository orderRepository;
		
		public Long order(Order order) {
				//... 비즈니스 로직
				return orderRepository.save(order);
		}
}

class OrderRepository {
		@PersistenceContext EntityManager em;
		
		public Order findOne(Long id) {
				return em.find(Order.class, id);
		}
}

기존에 osiv를 이용하기 전에는 프레젠테이션 계층에서 영속성 컨텍스트가 유지 되지 않아서 지연로딩되는 엔티티를 미리 초기화 해줘야 되는 문제가 있었다. 하지만 영속성 컨텍스트가 유지되는 osiv에서는 그럴 필요가 없다. 따라서, 위와 같이 단순한 조회의 경우 controller에서 리포지토리를 바로 호출해도 큰 문제는 없다.

이렇듯, 항상 controller -> service -> repository 가 아닌 controller -> repository도 가능하다.

osiv을 이용하면 위와 같이 유연한 계층 구조를 활용할 수 있다.

References

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

book_link

태그: ,

카테고리:

업데이트:

댓글남기기