본문 바로가기

IT/Spring JPA

JPA 영속성 관리

반응형

JPA를 사용하면 컨테이너가 트랜젝션과 영속성 컨텍스트를 관리해주므로 애플리케이션을 손쉽게 개발할 수 있음
BUT JPA의 내부 동작을 이해하지 못하면 문제가 발생했을 때 해결하기가 쉽지 않음

먼저, 스프링이나 J2EE 환경에서 JPA를 사용하면 컨테이너가 제공하는 전략을 따라야 한다.
제공하는 기본 전략은 바로 트랜젝션 범위의 영속성 컨텍스트 전략이다.
(트랜젝션 범위 = 영속성 컨텍스트 범위)

보통 서비스계층에서 @Transactional 어노테이션으로 트랜젝션을 시작하는데 어노테이션이 있는 메소드를 호출하면 메소드 실행 전에 트랜젝션이 먼저 시작된다.

즉, 서비스단 메소드 호출 -> 트랜젝션 시작 -> 메소드 실행 -> 메소드 종료 -> 트랜젝션 커밋 (영속성 컨텍스트 플러시 발생 -> DB 반영 -> DB 커밋) 순으로 진행된다.
* 예외 발생 시 트랜젝션 롤백되면서 종료 (플러시 호출 X)

@Controller 
public class HelloController
{ 

    @Autowired HelloService helloService; 
    
    public void hello(int id){ 
        Member member = helloService.getMember(id); 
        // 준영속성 상태 
    } 
} 

@Service 
public class HelloService { 

    @Autowired HelloRepository helloRepository; 
    
    @Transactional //트랜젝션 시작 
    public Member getMember(int id){ 
        return helloRepository.getMember(id); //영속성 접근 
    }// 트랜젝션 종료 
} 

@Repository 
public class HelloRepository { 

    @PersistenceContext EntityManager em; 
    
    public Member getMember(int id){ 
        return em.find(Member.class, id); //영속성 접근 
    } 
}


트랜젝션 범위의 영속성 컨텍스트 전략의 특징
특징 1) 트랜젝션이 같으면 같은 영속성 컨텍스트를 사용한다.


여러 EntityManager를 사용해도 트랜젝션이 동일하다면 같은 영속성 컨텍스트의 관리를 받는다.

특징 2) 트랜젝션이 다르면 다른 영속성 컨텍스트를 사용한다.


여러 스레드에서 동시에 요청이 와서 같은 엔티티 매니저를 사용해도 트랜젝션에 따라 접근하는 영속성 컨텍스트가 다르다.
(멀티스레드 환경에서 안전함)

트랜젝션 범위의 영속성 컨텍스트 전략 문제점
트랜젝션 범위에 있는 레포지토리와 서비스 계층은 영속성 컨텍스트의 관리는 받지만, 뷰와 컨트롤러는 영속성 컨텍스트 관리를 받지 못한다. 이는 준영속상태라고 부른다.

프리젠테이션 계층 (뷰, 컨트롤러)는 트랜젝션이 없기에 변경 감지 또는 지연 로딩이 동작할 수 없는데 프리젠테이션 계층에서 엔티티의 변경이 일어나는건 바람직한 코드가 아님으로 변경 감지가 동작하지 않는 건 괜찮다.

하지만, 지연로딩이 동작하지 않는건 큰 문제점이다.
(지연로딩을 적용해 놓고 프록시 객체로 조회를 시도하면 LazyInitializationExcaption이 발생함)

준영속 상태의 지연 로딩 문제를 해결하는 방안은 2가지가 있다.
1) 뷰가 필요한 엔티티를 미리 로딩해주는 방법 (초기화)
2) OSIV를 사용해서 엔티티를 항상 영속 상태로 유지하는 방법

먼저, 뷰가 필요한 엔티티를 미리 로딩해주는 방법은 3가지가 존재한다.
1) 글로벌 페치 전략 수정 (Lazy -> Eager)
2) JPQL 페치 조인
3) 강제로 초기화

@Entity 
@Table(name = "ORDERS") 
public class Order { 

    @Id @GeneratedValue @Column(name = "ORDER_ID") 
    private Long id; 

    @ManyToOne(fetch = FetchType.EAGER) 
    @JoinColumn(name = "MEMBER_ID") 
    private Member member; //주문 회원 

....

}
public void test(){ 

    OrderSearch orderSearch = new OrderSearch(); 
    orderSearch.setMemberName("test1"); 
    orderSearch.setOrderStatus(OrderStatus.ORDER); 
    
    List<Order> orderList = orderService.findOrders(orderSearch); 
    
    Member member = orderList.get(0).getMember(); 
    member.getName(); 
}

글로벌 페치 전략 수정은 페치 타입을 Lazy -> Eager으로 변경하는 것이다. 이 경우 주문엔티티를 조회하면 회원 엔티티도 항상 로딩되는 문제가 발생한다.

사용하지 않는 엔티티를 조회하는 것뿐만 아니라 N+1 문제도 발생한다. (성능적으로 크리티컬한 이슈임)

N+1 문제
성능상 가장 조심해야 되는 문제이다.
JPQL를 사용할 경우 발생하는데 JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 페치 전략을 참고하지 않고 오직 JPQL 자체만 사용한다. (즉시 로딩이건 지연 로딩이건 구분하지 않고 JPQL에만 충실함)

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

위 코드를 실행하면
select * from Order;
select * from Member where id = ? ;
select * from Member where id = ? ;
select * from Member where id = ? ;
select * from Member where id = ? ;
...
으로 쿼리가 발생한다.

N+1 쿼리가 발생하는 이유는 아래와 같이 동작하기 때문이다.
1) select o from Order o를 분석해서 select * from order 쿼리가 생성됨
2) DB에서 결과 값을 받아 order 엔티티를 생성함
3) order.member 의 즉시로딩 전략때문에 order를 조회하는 순간 member도 로딩됨
4) 영속성 컨텍스트에서 member를 찾음
5) 만약 없으면 select * from member where id = ? 쿼리로 조회한 order 엔티티 수만큼 발생하기 됨.

N+1 문제는 JPQL 페치 조인으로 해결할 수 있다.

JPQL 페치 조인은 즉시 로딩 대신에 JPQL 호출때 함께 로딩한 엔티티에 대한 페치 조인을 명시하는 방법이다.

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

이렇게 명시할 경우 order 조회시 바로 member 또한 영속성 컨텍스트에 적재됨으로 n+1 문제가 발생하지 않는다.

JPQL 페치 조인의 단점
무분별하게 사용할 경우 화면에 맞춘 리포지토리 메소드가 증가할 수 있다.
ex) 주문 정보만 조회하는 repository.findOrder(), 주문과 회원 정보를 조회하는 repository.findOrderWithMember() ...
이 경우 그냥 repository.findOrder 한개의 메소드에 member 페치 조인을 적용함으로써 공통 메소드를 사용하는게 최선의 방법이다
왜냐하면 역할 별로 메소드를 추가로 생성하는 것보다 페치 조인을 적용하는 것이 성능 + 유지보수적으로 더 낫기 때문이다.

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

더보기

[지연로딩 동작]

지연 로딩을 적용한 경우 엔티티를 조회하면 연관된 객체에 대해서는 프록시 객첵만 반환하고선 엔티티는 존재하지 않는 상태다.

그 후 진짜 연관된 엔티티를 호출할 때 해당 엔티티에 대한 초기화가 진행된다.

영속성 컨텍스트가 살아있는 서비스 계층에서 엔티티의 강제 초기화를 진행할 경우 서비스 계층에서 비즈니스 로직이 아닌 프리젠테이션 계층을 위한 일을 수행하게 된다. 이는 좋은 코드가 아니기에 FACADE 계층을 중간에 두어 강제 초기화 역할을 수행하도록 하는 것이 좋다.

FACADE 계층을 도입하여 서비스 계층과 프리젠테이션 계층 사이에 논리적인 의존성을 분리할 수 있다.
(프록시 초기화를 위해 FACADE 계층에서 트랜젝션 시작)



이 방식은 중간에 계층이 한개 더 생기는 것으로 코드 양이 증가하는 단점이 있다.

지금까지 언급한 것은 뷰가 필요한 엔티티를 미리 로딩해주는 방법인데 이는 코드양이 늘어나는 이슈와 프록시 엔티티에 대한 초기화 처리를 빼먹는 실수가 발생할 수 있어 좋은 방법이 아니다.

지연로딩이 동작하지 않는 건 프리젠테이션 계층이 준영속성 상태이기 때문임으로 프리젠테이션 계층까지 영속성 컨텍스트 범위를 확대하는 것이 최선의 방법이다.

OSIV(open session in view) 는 영속성 컨텍스트를 뷰까지 여는 것을 뜻한다.

먼저 과거 방식인 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 트랜젝션을 시작함으로써 영속성 컨텍스트 범위를 확대할 수 있다. 이는 요청 당 트랜젝션 방식의 OSIV라고 한다.

이 방식은 프레젠테이션 계층에서 엔티티 변경을 할 수 있다는 문제점이 있다.

@Controller 
public class MemberController { 

    @Autowired MemberService memberService; 
    
    public void test(Long id){ 
        Member member = memberService.findOne(id); 
        member.setName("XXX"); 
        model.addAttribute("member", member); 
        ... 
    } 
}

개발자의 의도는 화면에 조회한 회원 이름을 "XXX"로 변경하고 싶었던 것이지만 실제론 변경 감지가 동작해서 DB에 해당 회원 이름이 "XXX"로 UPDATE되는 심각한 문제가 발생한다.

이러한 문제를 막기위해선
엔티티를 읽기 전용 인터페이스로 제공, 엔티티 레핑, DTO만 반환하는 방법으로 막을 순 있다.
그치만 이 해결방법 모두 불필요한 코드양이 상당히 증가한다는 단점 때문에 요청 당 트랜젝션 방식의 OSIV 거의 사용하고 있지 않다.

가장 권장하는 방법인 스프링 OSIV (=비즈니스 계층 트랜젝션)은 spring-orm.jar에서 제공하는 기능으로
OSIV를 적용하고 싶은 부분에 따라 원하는 클래스는 선택해서 사용하면 된다.

하이버네이트 OSIV 서블릿 필터 : org.springframwork.orm.hibernate4.support.OpenSessionInViewFilter
하이버네이트 OSIV 스프링 인터셉터 : org.springframwork.orm.hibernate4.support.OpenSessionInViewInterceptor
JPA OEIV 서블릿 필터 : org.springframwork.orm.jpa.support.OpenEntityManagerInViewFilter
JPA OEIV 스프링 인터센터 : org.springframwork.orm.jpa.support.OpenEntityManagerInViewInterceptor


비즈니스 계층 트랜젝션 OSIV의 동작원리는
1) 요청이 들어오면 설정한 서블릿 필터 또는 인터셉터에서 영속성 컨텍스트를 생성한다 (트랜젝션 시작 X)
2) 서비스 계층에서 @Transactional 를 통해 트랜젝션이 시작된다.
3) 서비스 계층 끝난 후 트랜젝션은 커밋되며 플러시가 발생하지만 영속성 컨텍스트는 종료되지 않는다.
4) 프리젠테이션 계층까지 영속성 컥텍스트가 유지된다.
5) 서블릿 필터 또는 인터셉터로 요청이 돌아오면 플러시 호출 없이 영속성 컨텍스트를 종료한다.

엔티티 변경 없이 단순 조회할 경우 트랜젝션 없이 진행할 수 있는데 이것을 트랜젝션 없이 읽기라고 부른다.

즉, 스프링에서 제공하는 OSIV를 사용하면 트랜젝션 없이 읽기가 가능하기에 프리젠테이션 계층에서의 엔티티들이 영속성 상태로 유지되며 지연 로딩이 동작하는 것이다.

@Controller 
public class MemberController { 

    @Autowired MemberService memberService; 
    
    public void test(Long id){ 
        Member member = memberService.findOne(id); 
        member.setName("XXX"); 
        model.addAttribute("member", member); 
        ... 
    } 
}

스프링 OSIV를 사용할 경우 과거의 요청 당 트랜젝션 OSIV의 이슈인 프리젠테이션 계층에서 엔티티 변경을 할 수 있는 문제점이 해결된다.
해결 가능한 이유로는 2가지가 존재한다.

1) 스프링 OSIV는 프리젠테이션 계층에서 flush가 호출되지 않기 때문이다.
2) 만약 강제로 프리젠테이션 계층에서 flush를 호출하더라도 트랜젝션 범위에 속하지 않음으로 TransactionRequiredException이 발생한다.

스프링 OSIV 사용시 주의사항 (매우 중요!!!)
프리젠테이션 계층에서 엔티티를 수정한 직후에 트랜젝션을 시작하는 서비스 계층을 호출하면 엔티티가 변경되는 이슈가 발생한다.

@Controller 
public class MemberController 
{ 

    @Autowired MemberService memberService; 
    
    public void test(Long id){ 
        Member member = memberService.findOne(id); 
        member.setName("XXX"); 
        meberService.biz(); 
        // 비즈니스 로직 
    } 
} 

@Service 
public class MemberService(){ 
    ... 
    @Transactional 
    public void biz(){ 
        ... 
    } 
}


biz 메소드가 끝나면 트랜젝션 AOP는 트랜젝션을 커밋하고 영속성 컨텍스트를 플러시한다.
이때 Member 엔티티에 대한 변경이 감지되면서 엔티티가 수정되는 이슈가 발생한다.
(같은 영속성 컨텍스를 여러 트랜젝션이 공유할 수 있기 때문에 주의 필요!!!)

그러므로 항상 트랜잭션이 있는 비즈니스 로직을 모두 호출한 뒤에 엔티티를 변경해야 된다.

OSIV를 사용하는 것만의 만능은 아니다.
예를 들어 복잡한 통계화면의 경우 JPQL를 작성해서 DTO로 조회하는 것이 효과적이며 많은 테이블을 조인해야 될 경우 또한 JPQL로 필요한 데이터만 조회해서 DTO로 반환하는 것이 더 나은 방법일 것이다.

DTO를 사용한 방법

Class MemberDTO { 
    private String name; 
    ... 
    
    public MemberDTO MemberDTO(Member member)
    { 
        this.name = member.getname; 
        ... 
    } 
    
    ... 
    //getter, setter 
}

----

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

그리고 엔티티는 생각보다 자주 변경될 수 있다. 그러므로 외부 API로 제공해야 될 경우 엔티티를 직접 노출하기 보단 엔티티를 변경해도 완충 역할을 할 수 있는 DTO로 변환해서 노출하는것이 안전하다.

정리
스프링이나 J2EE 컨테이너 환경에서 JPA를 사용하면 트랜잭션 범위의 영속성 컨텍스트 전략(트랜잭션 범위 = 영속성 컨텍스트)이 적용된다. 유일한 단점은 프리젠테이션 계층에서 엔티티가 준영속성 상태임으로 지연 로딩을 할 수 없다는 점이다.
하지만 OSIV를 사용하면 이런 문제들을 해결할 수 있다. 그중 스프링 OSIV는 기존 OSIV의 단점을 해결해서 프리젠테이션 계층에서 엔티티가 수정되는걸 막아준다. 예외 케이스도 존재함으로 항상 트랜잭션이 있는 비즈니스 로직을 모두 호출한 뒤에 엔티티를 변경해야 된다.

반응형

'IT > Spring JPA' 카테고리의 다른 글

JPA 성능 최적화  (0) 2021.08.13
JPA 예외처리 및 프록시 심화 익히기  (0) 2021.08.01
JPA 웹 어플리케이션 개발  (0) 2021.07.01
QueryDSL 사용법  (3) 2020.02.17
생성자 제한하기  (0) 2020.02.16