JPA의 영속성 컨텍스트와 N+1 문제 및 해결방법, 로딩 전략 (Fetch join)

양성준·2025년 4월 10일

스프링

목록 보기
27/49
post-thumbnail

영속성 컨텍스트

: JPA에서 영속성 컨텍스트(Persistence Context) 는 엔티티를 관리하는 메모리 상의 저장소입니다. 이곳에 저장된 객체는 Entity Manger가 관리하는 "영속 상태(Persistent State)"가 되며, JPA는 해당 객체들의 상태를 추적하게 된다.

영속성 컨텍스트의 특징

  • 메모리 기반 저장소: DB와 직접 연결되어 있지 않음
    • 영속성 컨텍스트에 있는 내용이 실제 DB에 반영되는 때는 트랜잭션이 commit될 때
  • 트랜잭션 단위로 동작: @Transactional이 붙은 메서드가 실행되면 트랜잭션 시작과 함께 EntityManager가 생성되어 쓰레드에 바인딩됨
    • EntityManager의 생명주기는 transaction과 같아서 보통 transaction이 끝나면 영속성 컨텍스트가 삭제됨
    • 즉, 1차 캐시의 내용은 transaction 범위 내에서만 유효하다.
  • 1차 캐시 제공: 이미 조회한 엔티티는 다시 DB에서 가져오지 않고 1차 캐시에서 바로 반환
    • 1차 캐시에는 객체가 저장될 때의 초기 상태인 스냅샷이 존재하여, 추후 변경감지가 가능해짐
  • 변경 감지(Dirty Checking): flush() 시점에 엔티티 객체의 상태 변경을 스냅샷 비교를 통해 자동으로 감지하여 적절한 SQL을 생성해 쓰기 지연 저장소에 저장 후, 쿼리를 DB에 날림
    • flush() 이후에는 동일 트랜잭션 내에서는 DB에 반영되어 이용이 가능하지만, 아직 외부 Transaction에서는 조회가 불가능
    • commit()을 해야 DB에 완전히 반영되어 외부 Transaction에서도 사용 가능
    • 변경감지 덕분에 엔티티를 수정하고 DB에 직접 반영하지 않아도 됨
    • 하지만, Spring Data JPA는 안전을 위해 save()를 호출하는 것을 권장함
  • 동일성 보장: 동일 트랜잭션 내에서는 같은 엔티티 객체가 항상 동일한 인스턴스로 유지됨

동작 흐름

  • em.find() 호출 → 먼저 1차 캐시 확인 → 없으면 DB 조회 후 1차 캐시에 저장 (영속화)
  • em.persist() 호출 → SQL 실행은 하지 않고 객체를 1차 캐시에 저장 (영속화)
    • insert SQL이 생성되어 쓰기 지연 저장소에 저장됨 -> flush 시점에 DB에 쿼리를 날림
  • 트랜잭션 커밋 시점 → flush() 자동 호출 → 변경 사항 반영(SQL 전송)
Product product1 = em.find(Product.class, 1L); // DB 조회 후 1차 캐시에 저장
Product product2 = em.find(Product.class, 1L); // 1차 캐시에서 바로 반환
System.out.println(product1 == product2); // true (동일성 보장)

Flush vs Commit

flush

  • 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영하는 작업 (동기화)
  • 트랜잭션 커밋 시점에 자동으로 호출됨
  • SQL 쿼리는 DB로 전송되지만 해당 트랜잭션이 commit 되지 않으면 최종 반영은 되지 않는다
    • 즉 변경된 사항은 DB 트랜잭션에 머물러있는 상태이지만 영구적인 저장 (영속화)은 되지 않는 상태
    • flush는 같은 트랜잭션 내부의 DB에는 반영되지만, 외부에서는 아직 동기화되지 않은 상태
Member m = new Member("userA");
em.persist(m); // 아직 DB에 insert 쿼리 안 나감

List<Member> members = em.createQuery("SELECT m FROM Member m", Member.class).getResultList();
  • JPA에서는 JPQL 실행 시점에 flush()가 자동으로 나가므로, member의 insert 쿼리가 DB로 날아감.
    현재 트랜잭션 내에서는 insert된 상태인 것
    -> select문으로 가져오면, insert된 것이 반영되어 조회된다! (동일한 트랜잭션이기 때문)
  • flush가 필요한 이유는 영속성 컨텍스트의 쓰기 지연 메커니즘 때문에 필요
    • JPA는 변경사항에 대해 실시간으로 즉시 DB에 반영하지 않고 최적화된 SQL을 한번에 전송하기 위해서 쓰기 지연을 사용한다
    • 트랜잭션 안에서 발생되는 SQL 쿼리를 쓰기 지연 SQL 저장소에 저장하고 한번에 전송한다. (성능!)
      • 발생할 때마다 전송하는 것은 DB I/O 비용이 많이 들기 때문
  • 변경 감지(Dirty Checking)를 수행
  • flush가 실행될 때 변경 감지를 해서 쓰기 지연 저장소에 SQL을 추가하고, 쿼리를 데이터베이스로 전송한다.
    • 전송된 SQL은 데이터베이스 트랜잭션 버퍼라는 곳에 기록은 되지만 commit 전까지는 확정이 아니다
    • 그러므로 롤백이 가능하고, commit이 된 이후에는 롤백이 불가능함

=> flush()는 "트랜잭션 안에서만 볼 수 있는 DB 임시 저장"

commit

  • 해당 트랜잭션을 종료하고 변경 사항을 DB에 영구 반영한다
    • 외부 트랜잭션에서도 현재 트랜잭션에서 반영된 데이터에 접근 가능
  • 데이터베이스 트랜잭션을 확정한다
    • commit이 되면 롤백이 불가능

즉, flush는 SQL을 데이터베이스로 보내는 작업 + 현재 트랜잭션 내에서의 DB에 반영(외부에선 볼 수 없음)이고, commit은 데이터베이스에 변경 내용을 최종 반영 -> 다른 트랜잭션에서도 볼 수 있음!

N+1 문제

"하나의 쿼리를 실행했는데, 그로 인해 추가적인 N개의 쿼리가 실행되는 문제"
결과적으로 총 N+1개의 쿼리가 발생하면서 성능 저하가 생긴다.
보통 findAll()과 같은 여러 엔티티를 조회하는 메서드를 실행했을 때, 연관된 데이터들을 select where로 하나씩 쿼리를 날려 조회해오기 때문에 발생

List<Order> orders = orderRepository.findAll(); // 주문 전체 조회
for (Order order : orders) {
    System.out.println(order.getMember().getName()); // 주문한 회원의 이름 출력
}
  • Order와 Member는 @ManyToOne 관계 (Order → Member)
  • Order는 EAGER 로딩이거나, order.getMember()가 호출되었을 때 LAZY 로딩이 동작함

실제 실행되는 SQL

-- 1. 전체 주문 조회
SELECT * FROM orders;
-- 2. 각 주문에 대해 Member 조회 (주문이 N개라면 N번 실행됨)
SELECT * FROM member WHERE id = ?; -- N번 반복
  • 주문 1번 조회 + 회원 N번 조회 → N+1 쿼리
  • DB에 불필요한 쿼리를 과도하게 날림
  • 네트워크 비용 + DB 성능 저하 + 페이지 응답 속도 저하
  • 특히 목록 조회 시 성능 폭발 (100개 조회 → 101개 쿼리 발생)

해결 방법

1. @EntityGraph 사용 (Spring Data JPA)

@EntityGraph(attributePaths = {\"member\"})
List<Order> findAll(); // join fetch처럼 작동
  • left outer join만 지원 (Fetch Join보다는 속도면에서 살짝 느릴 수 있다.)
  • 동적 페치 전략 적용
    • 기본적으로 FetchType.LAZY로 설정된 연관 엔티티를 특정 쿼리에서만 FetchType.EAGER로 가져오도록 할 수 있다. 즉, @EntityGraph를 사용하면 기본 매핑 설정과 달리 특정 쿼리에서 필요한 연관 엔티티만 즉시 로딩할 수 있게 된다.
    • 반대로, 필요에 따라 기본적으로 즉시 로딩이 설정된 엔티티를 지연 로딩으로 변경할 수도 있다.
  • N+1 문제 해결
    • @EntityGraph를 활용해 JPA가 여러 엔티티와 연관된 데이터를 함께 조회하도록 지정할 수 있어, 연관 데이터 접근 시 추가적인 쿼리를 줄여준다.
    • 예를 들어, @OneToMany 관계로 매핑된 엔티티를 지연 로딩 설정으로 조회할 때 N+1 문제가 발생할 수 있지만, @EntityGraph를 사용하면 batchSize 또는 subselect 전략을 사용해 IN쿼리로 연관된 엔티티를 한 번에 가져오게 되어 성능이 개선된다.
  • 복잡한 JPQL 대신 직관적인 데이터 조회:
    • 코드 가독성을 높이며, 복잡한 fetch join JPQL 대신 훨씬 직관적으로 연관 엔티티를 조회할 수 있게 한다.
  • @ToMany 상황에서 N+1문제 해결 + 페이징이 가능하다.
    • 1차적으로는 부모 엔티티만 페이징하여, row 수 불일치 문제를 해결 (페이징)
      • 연관된 자식 컬렉션은 이 시점에 로딩하지 않음
    • 2차적으로는 Hibernate가 내부적으로 @BatchSize 전략을 활용해 자식 컬렉션을 IN 쿼리로 한번에 가져옴 (N+1 문제 해결)
  • @ToMany가 여러개 연관되어 있는 경우, Fetch Join의 경우 카티션 곱으로 row가 나오기 때문에 @EntityGraph를 사용할수도 있지만, 쿼리 수 폭증과 성능 저하가 있을 수 있으므로 DTO projection + 서브쿼리를 사용하는 것이 더 좋다.

https://geonit.tistory.com/72

2. JOIN FETCH 사용

@Query(\"SELECT o FROM Order o JOIN FETCH o.member\")\nList<Order> findAllWithMember();
  • 내부적으로 Inner join을 기본적으로 사용하나, left join fetch가 가능
    • 연관관계 엔티티가 nullable인 경우, 부모 엔티티가 조회되지 않는 경우가 있으므로 left join fetch 사용해야함
  • @ToMany 관계가 있을 경우, 카티션 곱으로 row가 나오기 때문에 페이징이 불가능
    • 이 때는, ToMany가 1개인 경우에는 배치 사이즈나 EntityGraph를 사용하고, 여러개라 복잡한 경우에는 DTO Projection + 서브쿼리를 사용하는 것이 좋다.
  • 쿼리 한 번에 Order + Member를 JOIN해서 가져오고, 영속성 컨텍스트에 즉시 로딩
    • 초기화 시점에만 쿼리가 한 번 나가고, 그 이후 조회에서는 1차 캐시에서 조회하므로 쿼리 X
    • Inner Join + Lazy Loading의 경우, 조인 쿼리로 결과를 가져오긴 하지만, 연관 객체는 영속성 컨텍스트에 넣지 않고 필요할 때 쿼리를 날려 영속성 컨텍스트에 가져옴
    • EAGER 로딩 이더라도, 연관 엔티티는 select where로 전부 조회해서 영속성 컨텍스트에 등록함
      -> 여전히 N+1 문제가 발생한다.

=> 오직 JOIN FETCH만이, 연관 엔티티를 한번에 전부 영속성 컨텍스트에 등록해줘서 추가 SELECT 쿼리가 안나감

SELECT o.*, m.* FROM orders o JOIN member m ON o.member_id = m.id;
  • @ToMany 관계에서 페이징이 불가능하여, Batch Size를 이용하거나 EntityGraph로 해결

3. 실무에서는 FetchType.LAZY + 필요 시 fetch join 조절

  • 연관 관계는 기본 LAZY로 유지
  • 실제 필요한 상황에만 JOIN FETCH로 최적화

Fetch Join vs @EntityGraph

EntityGraph는 기본적으로 fetchType을 Eager로 설정하여 연관된 엔티티를 함께 로드하며, 이 과정에서 LEFT OUTER JOIN을 수행한다. 이는 연관된 엔티티가 존재하지 않더라도 부모 엔티티를 반환하기 위해 사용된다. 따라서 EntityGraph를 사용하면 연관된 엔티티가 없어도 부모 엔티티가 결과에 포함된다.

반면 Fetch Join은 연관된 엔티티를 로드할 때 기본적으로 INNER JOIN을 수행한다. 따라서 연관된 엔티티가 존재하지 않는 경우 부모 엔티티도 결과에서 제외될 수 있다. 따라서 INNER JOIN은 연관된 모든 엔티티가 존재해야만 부모 엔티티가 결과에 포함된다.
(이 경우, left join fetch로 해결 가능)

Fetch Join은 N개의 연관된 엔티티를 한 번에 조인할 수 없기 때문에, 전체 결과가 중복되거나 성능 저하를 초래할 수 있다. 이 문제는 DISTINCT를 사용하여 해결하지만, 이는 쿼리 성능에 영향을 미칠 수 있다.

하지만 EntityGraph를 사용할 경우, LEFT OUTER JOIN을 사용하여 연관된 모든 데이터를 가져올 수 있기 때문에 Fetch Join의 단점 중 하나인 1:N 컬렉션 조인 시 최대 한 개의 컬렉션만 조인할 수 있는 제약이 없다. -> 페이징 가능
또한 EntityGraph는 DISTINCT를 필요로 하지 않아 중복된 결과나 성능 저하를 방지할 수 있다.

https://chaewsscode.tistory.com/258

즉시 로딩 vs 지연 로딩

즉시 로딩 (EAGER)

  • 연관된 엔티티를 즉시 영속성 컨텍스트에 로딩함 (예: Order -> Member).
    • 하지만 기본적으로 JOIN이 아닌 별도 SELECT 쿼리로 동작함.
    • SELECT 쿼리가 두 차례에 걸쳐 나가기 때문에 N+1 문제를 유발할 수 있음.
  • @XToOne의 경우 즉시 로딩이 기본
- 최초 1번 실행: 모든 멤버 조회
SELECT * FROM member;

-- 즉시로딩이기 때문에, 멤버를 조회할 때 각 멤버별로 연관된 팀을 전부 조회 (N번 실행)
SELECT * FROM team WHERE team_id = ?;
SELECT * FROM team WHERE team_id = ?;
SELECT * FROM team WHERE team_id = ?;

지연 로딩 (LAZY)

  • 영속성 컨텍스트에 프록시 객체만 저장해두고, 실제 사용할 때 SELECT 쿼리를 실행해 데이터를 프록시 내부에 채워넣음
  • 초기화 성능 최적화에 유리하지만 트랜잭션 범위 안에서만 (영속성 컨텍스트에 등록이 되어있을 때만) 사용 가능.
  • @XToMany의 경우 지연 로딩이 기본
Order order = em.find(Order.class, 1L); // SELECT orders
Member member = order.getMember();     // SELECT members (지연 시점)
  • 지연해서 데이터를 가져오긴 하지만, 여전히 JOIN이 아닌 SELECT 쿼리로 동작하기 때문에 N+1 문제 발생 가능성

JOIN FETCH 필요성

  • JPA에서 연관된 엔티티까지 "한 번에 로딩"하는 명령
    • 즉, 다른 JOIN과는 다르게 연관된 엔티티까지 영속성 컨텍스트에 함께 등록해준다. (영속화)
      • 영속화가 되어있기 때문에, 조회할 때 쿼리를 날리지 않고 1차 캐시에서 바로 조회!
    • 다른 JOIN의 경우, 테이블의 데이터를 전부 가져오지만 영속화는 안하므로, 여전히 연관 엔티티가 Lazy 프록시로 남아있음
      -> 접근할 때 다시 SELECT 쿼리 날려서 영속화 (N+1 문제 발생)
  • JOIN FETCH는 "즉시 로딩"을 명시하면서, 그걸 SQL의 JOIN으로 한 번에 가져오는 방식 (즉시 로딩은 select로 일일이..)
  • EAGER든 LAZY든 명시적으로 JOIN FETCH를 써야 JOIN 기반 쿼리로 가져올 수 있음.
  • JOIN을 통해 한번에 데이터를 땡겨와서 영속성 컨텍스트에 저장하기 때문에 처음에만 쿼리가 한 번만 나간다!
    • 그 이후에는 동일 트랜잭션 내에서는 조회를 해도 1차 캐시에서 조회하기 때문에 쿼리가 나가지 않음
  • 지연 로딩 설정이어도, JPQL에서는 fetch join을 사용하면 관련된 엔티티가 즉시 로딩된다.

=> 실무에서는 LazyLoading + Fetch Join으로 성능상의 문제를 해결한다. (@XToOne의 경우에도)

profile
백엔드 개발자

0개의 댓글