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

이가희·2025년 1월 19일
1

JPA

목록 보기
13/16
post-thumbnail

스프링이나 J2EE 컨테이너 환경에서 JPA를 사용하면 컨테이너가 트랜잭션과 영속성 컨텍스트를 관리해주어서 편리하다.

하지만 어떻게 동작하는지 알고는 있어야, 문제가 발생했을 때 해결도 할 수 있지 않겠는가.

이번 시간에는 스프링 환경에서 영속성 컨텍스트가 어떻게 관리되고 있는지 살펴보겠다.

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

스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다.
이 전략은 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다.

그리고 같은 트랜잭션 안에서는 항상 같은 영속성 컨텍스트에 접근한다.

만약 다양한 위치에서 엔티티 매니저를 주입받아 사용해도, 트랜잭션이 같으면 같은 영속성 컨텍스트를 사용하고,
여러 스레도에서 동시에 요청이 와서 같은 엔티티 매니저를 사용해도 트랜잭션이 다르면 접근하는 영속성 컨텍스트가 달라지게 된다.
그래서 멀티 스레드 상황에서 안전하게 된다. 🥰

이때, 트랜잭션이 커밋되면 JPA는 영속성 컨텍스트를 플러시해서 변경 내용을 DB에 반영한 후 데이터베이스 트랜잭션을 커밋한다.
그리고 예외가 발생하여 트랜잭션을 롤백할 때는 플러시를 호출하지 않는다.

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

앞선 설명으로 우리는 트랜잭션 생명 = 영속성 컨텍스트 생명이라는 것을 이해했다.
따라서 트랜잭션이 시작되기 전/후인 컨트롤러나 뷰 같은 프리젠테이션 계층에서는 엔티티가 준영속 상태가 된다.

따라서 프리젠테이션 계층에서는 다음의 기능이 작동되지 않는다.

  1. 변경 감지
    준영속 상태이기 때문에, 변경 감지가 동작하지 않는다. 하지만 단순히 데이터를 보여주기만 하는 프리젠테이션 계층에서는 데이터를 수정할 일이 없기 때문에 오히려 좋다.🥰 프리젠테이션 계층에서도 데이터를 수정한다면, 개발자들은 지금보다 더 싸우게 될 것이다.
  2. 지연 로딩
    이게 안 되는 건 좀 문제이다. 아직 초기화되지 않는 연관된 엔티티를 조회하려고 할 때 (ex member.getOrder.getName() 처럼, 여기서 order는 초기화되지 않았음) org.hibernate.LazyInitializationException이 발생하게 된다. 그런데 프리젠테이션에서 엔티티가 어디까지 초기화되었는지 파악하기는 어려움이 있으니, 참 골치다. ☹️
    참고로 예외는 구현체에 따라 다른 예외를 던질 수도 있다.

문제가 되는, 준영속 상태의 지연 로딩 문제를 해결하는 방법은 크게 2가지가 있다.

  1. 뷰가 필요한 엔티티를 미리 로딩해두는 법
    1.1 글로벌 페치 전략 수정
    1.2 JPQL 페치 조인
    1.3 강제로 초기화
  2. OSIV를 사용해서 엔티티를 항상 영속 상태로 유지하는 법

하나씩 살펴보자.

👊 1.1 글로벌 페치 전략 수정

아주 간단한데, 글로벌 페치 전략을 지연 로딩말고 즉시 로딩을 사용하면 된다.
지연 로딩을 써서 문제가 되니 안 쓰면 되는것이다! 간단 해결!

물론 이렇게 되면 지연 로딩을 사용하지 못 했을 때의 문제점들이 나타난다.

사용하지 않는 엔티티를 로딩하게 되고 N+1 문제가 발생하게 된다.
면접 단골 문제인 무시무시한 N+1 문제는 대체 무엇일까?

N+1 문제

N+1은 즉시 로딩 전략을 사용하고 JPQL을 사용할 때, 나타나는 문제이다.
예시로 설명 해 보겠다.

자, Order.member를 즉시 로딩으로 설정했다고 가정하고, 아래의 JPQL를 실행해 보자.

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
...

이런 SQL이 실행되게 된다.
왜일까?
JPA 가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 페치 전략을 참고하지 않고 오직 JPQL 자체만을 사용하기 때문이다.
따라서 내부에서 다음과 같은 순서로 동작하게 된다.

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

이처럼 처음 조회한 데이터 수만큼 (여기서는 order의 수) SQL을 사용해서 다시 조회하는 것을 N+1 문제라고 한다. 성능에 치명적이기 때문에 반드시 수정해야 하고, JPQL 페치 조인으로 해결 할 수 있다.

👊 1.2 JPQL 페치 조인

첫 번째 방법으로 하면 N+1 문제도 나고, 애플리케이션 전체에 영향을 주니까 아무튼 참 비효율적이다.
그래서 이번에는 JPQL을 호출하는 시점에 함께 로딩할 엔티티를 선택할 수 있는 페치 조인을 알아보겠다.

사용방법은 아주 간단하다.
조인 명령어 마지막에 fetch를 넣어주기만 하면 된다. 이렇게 하면 SQL JOIN을 사용해서 페치 조인 대상까지 함께 조회된다.
최종적으로 N+1문제를 해결하면서 화면에 필요한 엔티티를 미리 로딩해 둘 수 있게 된다.

다만 이 방법도 단점은 있다.

무분별하게 사용하면 화면에 맞춘 리포지토리 메소드가 증가되고, 결국 프리젠테이션 계층이 데이터 접근 계층을 알게 모르게 침범하게 된다.
예를 들어 화면 A에서는 order 엔티티만 필요하고, 화면 B에서는 order 엔티티와 연관된 member 엔티티 둘 다가 필요할 때 화면 A를 위한 method, 화면 B를 위한 method를 만드는 것이다.

이렇게 되면 뷰와 리포지토리 간에 논리적인 의존관계가 발생하게 된다.

따라서 이러한 상황에서는 repository.findOrder() 하나만 만들고 여기서 페치 조인으로 order, member을 함께 로딩하도록 하고 이를 화면 A, 화면 B에서 사용하게 하는 것이다.
order만 필요한 화면 B에서는 약간의 로딩 시간이 증가하겠지만, 보통의 상황에서는 페치 조인은 JOIN을 사용해 쿼리 한 번으로 필요한 데이터를 조회하니 성능에 미치는 영향은 미미하다.

👊 1.3 강제로 초기화

강제로 초기화는 영속성 컨텍스트가 살아있을 때 프리젠테이션 계층이 필요한 엔티티를 강제로 초기화해서 반환하는 방법이다.
(ex 영속성 컨텍스트가 살아있을 때 order.getMember().getName()와 같은 코드를 통해 프록시 객체를 강제로 초기화 함)

그런데 영속성 컨텍스트는 서비스 계층에서 살아있고,
뷰에서 필요한 엔티티에 따라 서비스 계층의 로직을 변경해야 한다.
은근슬쩍 프리젠테이션 계층이 서비스 계층을 침범하는 상황이다.
따라서 프록시 초기화 역할을 분리해야 하는데, FACADE 계층을 넣어 이 문제를 해결할 수도 있다.

👊 1.3 강제로 초기화 + FACADE 계층 추가

프리젠테이션 계층과 서비스 계층 사이에 FACADE 계층을 두어서, 뷰를 위한 프록시 초기화는 FACADE에서 담당하게 한다.
이를 도입함으로써 서비스 계층과 프리젠테이션 계층 사이에 논리적인 의존성을 분리할 수 있다.

프록시를 초기화하려면 영속성 컨텍스트가 필요하므로 FACADE에서 트랜잭션을 시작해야 한다.

class OrderFacade {
	@Autowired OrderService orderService;
    
    public Order findOrder(id){
    	Order order = orderService.findOrder(id);
        //프리젠테이션 계층이 필요한 프록시 객체를 강제로 초기화한다.
        order.getMember().getName();
        return order;
    }
 }

위의 예시와 같은 방법으로 Facade 에서 초기화를 담당시키게 할 수 있다.
다만, 중간에 계층을 만들었으니 코드를 더 많이 작성해야 하고,
Facade에서는 단순히 서비스 계층을 호출만 하는 의미 없는 위임 코드가 상당히 많아질 수도 있다.

그리고 화면 별로 최적화된 엔티티를 딱딱 맞아떨어지게 초기화해서 조회하려면 FACADE 계층에 수 많은 종류의 조회 메소드가 필요하게 될 수도 있다.

이 방법도 아주 효과적인 방법은 아닌 것 같다.
모든 문제는 엔티티가 프리젠테이션 계층에서 준영속 상태이기 때문에 발생 한다.
그럼 뷰까지 살아있게 열어두면 되지 않을까?
이것이 바로 OSIV이다.

3. OSIV

OSIV (Open Session In View)는 영속성 컨텍스트를 뷰까지 열어둔다는 뜻이다.
따라서 뷰에서도 지연 로딩을 사용할 수 있게 된다.

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

클라이언트의 요청이 들어오자마자 서블릿 필터나 스프링 인터셉터에서 트랜잭션을 시작하고, 요청이 끝날 때 트랜잭션도 끝내면 트랜잭션 범위가 프리젠테이션 계층까지 확장되니 뷰에서도 지연 로딩이 가능해 진다.
이것이 요청 당 트랜잭션 방식의 OSIV 이다.

이 방식은 치명적인 단점이 존재하는데,
바로 프리젠테이션 계층에서 엔티티를 변경할 수도 있다는 것이다. 😱

예를 들어 보안 상 고객의 이름을 XXX로 출력해야 해서
컨트롤러에서 고객 이름을 XXX로 변경해서 렌더링할 뷰에 넘겨주었는데,
요청 당 트랜잭션 OSIV 방식을 적용하면 DB에도 반영이 되어 고객 이름이 XXX로 변경되어 버리는 것이다.

이 끔찍한 문제를 해결하기 위해서는 프리젠테이션 계층에서 엔티티를 수정하지 못하게 막으면 된다.
다음의 3가지 방법이 있다.

  1. 엔티티를 읽기 전용 인터페이스로 제공
  2. 엔티티 레핑
  3. DTO 만 반환

하나씩 천천히 알아보자.

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

이 방법은 일기 전용 메소드만 제공하는 인터페이스를 프리젠테이션 계층에 제공하는 방법이다.

interface MemberView {
	public String getName();
}

@Entity
class Member implements MemberView {
 ...
}

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

이렇게 하면 프리젠테이션 계층은 읽기 전용 메소드만 있는 인터페이스를 사용하므로 엔티티를 수정할 수 없다.

👊 2. 엔티티 레핑

이 방법은 읽기 전용 메소드만 가지고 있으면서, 엔티티를 감싼 객체를 만들고 이것을 프리젠테이션 계층에 반환하는 방법이다.

class MemberWrapper {
	private Member member;
    
    public MemberWrapper (member) {
    	this.member = member;
    }
    
    //읽기 전용 메소드만 제공
    public String getName() {
    	member.getName();
    }
}

이렇게 하면 마찬가지로 프리젠테이션 계층에서 엔티티를 수정할 수가 없다.

👊 3. DTO만 반환

가장 전통적인 방법이다.
프리젠테이션 계층에 엔티티 대신에 단순히 데이터만 전달하는 DTO 객체를 생성해서 반환하는 것이다. 하지만 이렇게 되면 OSIV를 사용하는 장점을 살릴 수 없고, 엔티티를 거의 복사한 듯인 DTO 클래스를 하나 더 만들어야 한다.

지금까지 살펴 본 방법들은 모두 코드량이 상당히 증가한다는 단점이 있다.
그래서 요청 당 트랜잭션 방식의 OSIV는 지금까지 설명했던 문제들로 인해 최근에는 거의 사용하지 않는다.
요즘에는 이런 문제들을 어느정도 보완해서 비즈니스 계층에서만 트랜잭션을 유지하는 방식의 OSVI를 사용한다.
스프링 프레임워크가 제공하는 OSIV가 바로 이 방식이다.

👊 요즘 OSIV : 비즈니스 계층 트랜잭션

스프링 OSIV는 비즈니스 계층 트랜잭션을 제공한다.

위의 그림처럼, 프리젠테이션 계층에서도 영속성 컨텍스트가 살아있어서 지연 로딩을 사용할 수 있다.
그런데 프리젠테이션 계층에서 트랜잭션은 살아있지 않기 때문에 엔티리를 변경할 수 없다. 만약 트랜잭션 없이 엔티티를 변경하고 영속성 컨텍스트를 플러시하면 javax.persistence.TransactionRequireException이 발생하게 된다.

주의사항
프리젠테이션 계층에서 엔티티를 수정한 직후에 트랜잭션을 시작하는 서비스 계층을 호출하면 데이터가 변경되게 된다.

class MemberController {
	public String viewMember(Long id) {
    	Member member = memberService.getMember(id);
        member.setName("XXX");
        
        memberService.biz(); //비즈니스 로직 호출
       	//트랜잭션이 시작되어 버림.
        //biz() 메소드가 끝날 때 영속성 컨텍스트가 플러시 되어
        //회원 엔티티의 수정 사항 ("XXX")을 데이터 베이스에 반영하게 됨.
        return "view";
    }
}

비즈니스 로직을 먼저 호철하고, 마지막에 엔티티를 수정하도록 하면 문제는 해결된다.

이렇게 OSIV에 대해 알아보았는데, OSIV를 사용하는 것이 만능은 아니다.
같은 JVM을 벗어난 원격 상황에서는 사용할 수 없고, 복잡한 화면을 구성할 때는 엔티티로 조회하기 보다는 처음부터 통계 데이터를 구상하기 위한 JPQL을 작성해서 DTO로 조회하는 것이 효과적이다.

따라서 자신의 프로젝트에서 어떤 방법이 좋을지 잘 생각해보고 도입하는 것이 필요하다.


이렇게 트랜잭션 범위의 영속성 컨텍스트 전략을 살펴보았다.

다음 시간에는 JPA가 지원하는 컬렉션과 JPA의 다양한 부가 기능을 살펴보겠다.

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

profile
안녕하세요 개발하는 사람입니다.

0개의 댓글

관련 채용 정보