본문 바로가기
👩‍💻TIL/JPA

[JPA] 영속성 관리, 준영속 상태와 지연로딩, N+1 문제 발생 원인

by devuna 2022. 8. 31.
728x90

[JPA] 영속성 관리, 준영속 상태와 지연 로딩, N+1 문제 발생 원인

💡 들어가면서

스프링 환경에서 JPA를 사용할때, 트랜잭션 범위의 영속성 컨텍스트 전략이 기본으로 사용된다.

이름 그대로, 트랜잭션의 범위가 영속성 컨텍스트의 생존 범위가 같다는 의미이다.

트랜잭션을 시작할때, 영속성 컨텍스트가 생성되며, 끝날 때 종료한다.

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

더보기

추가

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

여러 스레드에서 동시에 요청이 와서 같은 엔티티 매니저를 사용한다고 해도, 트랜잭션에 따라 접근하는 영속성 컨텍스트가 다르기 때문에 멀티 스레드 상황에서 안전하다.

스프링에서는 트랜잭션과 멀티 스레드 상황을 컨테이너가 처리해주어, 개발자는 싱글 스레드 어플리케이션 개발처럼 단순하게 개발하며 비지니스 로직 개발에 좀 더 집중할 수 있다!

 

보통 스프링에서 트랜젝션을 쓸때는 비지니스 로직을 시작하는 서비스 계층에서

@Transactional 어노테이션을 사용하여 시작하게 된다. 

그 내부에서 정상처리되면 커밋되어 영속성 컨텍스트의 내용이 데이터베이스에 정상 반영되며,

예외가 발생하면 롤백하고 종료한다.

 

 

💡 준영속 상태와 지연 로딩

스프링에서는 보통 서비스 계층에서 시작하므로, 서비스 계층이 끝나는 시점에서 트랜잭션이 종료되며 영속성 컨텍스트도 함께 종료된다. 따라서 조회한 엔티티가 서비스와 리포지토리 계층에서는 영속 상태를 유지하지만, 컨트롤러나 뷰 같은 프리젠테이션 계층으로 가면 준영속 상태가 된다.

 

준영속 상태에서는 변경 감지와 지연 로딩이 동작하지 않는다.  

 

사실 단순히 데이터를 보여주기만 하는 프리젠테이션 계층에서 데이터를 수정할 일은 거의 없다.

오히려 변경 감지 기능이 프리젠테이션 계층에서도 동작을 한다면, 데이터를 어디서 변경했는지 찾는 과정이 더 어려워진다. 따라서 변경감지기능이 동작하지 않는 것은 큰 문제가 아니지만, 지연 로딩이 동작하지 않는다는 점이 여기서 문제가 된다. (준영속 상태에서 지연 로딩을 시도하면 Lazyinitializationexception 예외 발생)

 


준영속 상태의 지연 로딩 문제를 해결하는 방법 중, 뷰가 필요한 엔티티를 미리 로딩하는 방법 3가지는 아래와 같다.

- 글로벌 페치 전략 수정
- JPQL 페치 조인(fetch join)
- 강제 초기화

 

1. 글로벌 페치 전략 수정

가장 간단한 방법! 글로벌 페치 전략을 지연 로딩에서 즉시 로딩으로 변경하는 것이다.

@Entity
public class Classroom {
@Id @GeneratedValue
private Long Id;

@ManyToOne(fetch = FetchType.EAGER) // 즉시로딩
private Student student; //학생

...

}

엔티티에 있는 fecth 타입을 FetchType.LAZY (지연 로딩)에서  FetchType.EAGER(즉시로딩) 로 설정하고 

프리젠테이션 로직에서 조회하면 연관된 student 엔티티도 항상 함께 로딩된다.

이렇게 해결이 쉬우면... 문제가 안될 것이다.

사실 즉시 로딩은 많은 문제를 유발하기 때문에 사용을 조심해야 한다. (가급적 쓰지 않길 추천)

 

그 단점은

첫째, 불필요한 엔티티를 로딩한다.

둘째, N+1 문제를 발생시킨다.

 

JPA에서 성능상 가장 조심해야 하는 부분이 바로 이 N+1 문제다.

일반적으로 제공되는 find() 메서드 등을 사용할 때는jpa가 내부적으로 join문에 대한 쿼리를 만들어서 반환을 하기 때문에 즉시 로딩이 큰 문제가 없어보이기도 합니다.

그러나 JPQL을 사용할 때, 문제가 발생합니다. 

JPA는 JPQL을 사용할 때는 글로벌 페치 전략을 참고하지 않고 오직 JPQL 만 사용하기 때문에,

즉시로딩 / 지연 로딩 구분 없이 쿼리문만 따르게 됩니다. 

 

List<Classroom> classrooms = 
em.createQuery("select c from Classroom c", Classroom.class)
.getResultList(); //연관된 모든 엔티티를 조회한다.

위의 조회문을 실행하면, 조인이 일어나지 않고

1. Classroom을 조회하는 퀴리문 1개 실행

2. Classroom 안에 들어있는 연관된 Student를 조회하는 쿼리문도 N 번 실행된다.

따라서, 데이터의 개수+1 개만큼 SQL이 호출되기 때문에 조회 성능에 아주 치명적인 문제가 발생한다.

 

(N+1 문제가 발생하는 상황과 해결방법에 대한 포스팅 [JPA] N+1 문제 발생 원인과 해결방법 참고)

 

2.JPQL 페치 조인

1번에서 말한 글로벌 페치 전략을 즉시 로딩으로 설정하는 것은 지양해야 하므로,

사용할 수 있는 방법 중 하나는 fetch join이다. JPQL에서 join fetch 만 넣어 사용하면 N+1 문제가 발생하지 않는다.

 (N+1 문제없이 화면에 필요한 엔티티를 미리 로딩하는 현실적인 방법)

 

페치 조인 사용 전 

JPQL : select c from Classroom c;
SQL: select * from CLASSROOM;

 

페치조인 사용 후 

JPQL : select c from Classroom c join fetch c.student;

SQL: select * from CLASSROOM join STUDENT s on c.student_id = s.id

그러나 페치 조인을 사용하게 되면,

classroom만 필요한 화면 A, classroom과 연관된 student 모두 필요한 화면 B 가 있는 경우가 있어 

결국 최적화를 위해 A를 위한 메소드, B를 위한 페치 조인이 사용된 메소드 두 가지가 필요할 수 있다.

이런 경우에는 뷰와 리포지토리 간 의존관계가 발생한다.

 

3. 강제 초기화

글로벌 페치 전략을 지연 로딩으로 설정하면, 연관된 엔티티를 실제 엔티티가 아닌 프록시 객체로 조회한다.

그리고 이 프록시 객체는 실제 사용하는 시점에 초기화 된다. 

 

예를 들어, classroom.getStudent()까지만 호출하면 단순 프록시 객체만 반환하며, 아직 초기화 되지않은 상태이다.

프록시 객체는 student.getName()과 같이 해당 객체 안의 실제 값이 호출될 때 초기화된다.

 

따라서, 코드에 임의로 classroom.getStudent(). getName()과 같이 프록시 객체 내의 값을 호출하는 부분을 넣어 초기화 과정을 진행하도록 하는 것이 강제 초기화 방법이다.

 

 

 

 

 

 

참고)

Java Orm JPA 프로그래밍, 김영한, 에이콘 출판

728x90

댓글