이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 영속성 컨텍스트는 엔티티 동일성, 변경 감지, 지연 로딩을 어떻게 가능하게 하는가?
  • N+1 문제는 왜 생기고, 왜 단순히 EAGER로 바꾸는 것이 좋은 해결이 아닌가?
  • fetch join, EntityGraph, batch size, DTO projection은 각각 어떤 상황에서 검토하는가?

개요

JPA를 제대로 쓰려면 Entity를 테이블 행 DTO로만 보면 안 된다. Entity는 영속성 컨텍스트 안에서 생명주기를 가진 객체다. 영속성 컨텍스트는 1차 캐시, 동일성 보장, 변경 감지, 쓰기 지연, 지연 로딩 같은 기능을 제공한다.

이 기능들은 생산성을 크게 높이지만 SQL 실행 시점을 숨기기도 한다. 특히 연관관계 로딩과 N+1 문제는 JPA 학습에서 가장 자주 만나는 함정이다.

영속성 컨텍스트

트랜잭션 안에서 엔티티를 조회하면 영속성 컨텍스트가 그 엔티티를 관리한다. 같은 식별자의 엔티티를 다시 조회하면 같은 객체 인스턴스를 돌려줄 수 있다. 엔티티 필드를 변경하면 JPA는 flush 시점에 변경 감지를 통해 update SQL을 만들 수 있다.

@Transactional
public void changeName(Long memberId, String name) {
    Member member = memberRepository.findById(memberId)
            .orElseThrow(() -> new MemberNotFoundException(memberId));
    member.changeName(name);
}

이 코드에는 명시적인 save 호출이 없어도 변경 감지가 update를 만들 수 있다. 단, 이 동작은 영속 상태 엔티티와 트랜잭션 경계 안에서 이해해야 한다. detached Entity를 변경하거나 트랜잭션 밖에서 변경하면 같은 기대를 할 수 없다.

지연 로딩과 트랜잭션

지연 로딩은 연관 데이터를 실제 접근 시점에 가져온다. 단건 상세 화면에서는 편리할 수 있지만, 트랜잭션 밖에서 접근하면 초기화 예외가 나거나 Open Session in View 설정에 의존하게 된다. OSIV는 View나 Controller 근처에서 lazy loading을 가능하게 만들 수 있지만, SQL 실행 위치가 웹 계층으로 밀려 성능 추적이 어려워질 수 있다.

API 응답 DTO 변환을 어디서 할지도 중요하다. Controller에서 Entity를 그대로 반환하거나 트랜잭션 밖에서 연관관계를 접근하면 SQL 실행 시점이 예측하기 어려워진다. 조회 Service 또는 전용 query layer에서 필요한 데이터를 DTO로 변환하는 편이 안전하다.

N+1 문제

N+1은 목록 하나를 조회한 뒤 각 행의 연관관계를 접근하면서 추가 쿼리가 N번 발생하는 문제다.

@Transactional(readOnly = true)
public List<OrderSummary> findOrders() {
    List<Order> orders = orderRepository.findAll();
    return orders.stream()
            .map(order -> new OrderSummary(order.getId(), order.getMember().getName()))
            .toList();
}

findAll()이 주문 목록을 한 번 조회하고, order.getMember().getName()에서 각 주문의 회원을 추가 조회하면 N+1이 된다. 데이터가 적을 때는 눈에 띄지 않다가 운영 데이터에서 급격히 느려지는 전형적인 패턴이다.

해결 선택지

fetch join은 JPQL에서 필요한 연관관계를 한 번에 가져오는 강력한 방법이다. 단, 컬렉션 fetch join과 pagination은 중복 row, 메모리 pagination, count query 문제를 만들 수 있으므로 반드시 실제 SQL과 실행 계획을 확인해야 한다.

EntityGraph는 Repository 메서드 단위로 fetch plan을 지정하는 방법이다. 기본은 lazy로 두고 특정 조회에서만 필요한 연관을 가져올 수 있다.

batch size는 지연 로딩이 발생하더라도 여러 연관 조회를 묶어 쿼리 수를 줄이는 방법이다. N+1을 완전히 한 쿼리로 바꾸지는 않지만 실전에서 효과적일 수 있다.

DTO projection은 애초에 화면이나 API에 필요한 데이터만 조회한다. 복잡한 목록 화면이나 읽기 전용 조회에 적합하다.

실전 팁

  • 연관관계 기본 전략은 보수적으로 lazy를 두고, 조회 유스케이스에서 fetch plan을 명시한다.
  • N+1은 SQL 로그와 쿼리 카운트 테스트로 확인한다.
  • Entity를 API 응답으로 직접 노출하지 않는다.
  • 컬렉션 fetch join과 pagination 조합은 반드시 실제 SQL과 메모리 사용을 확인한다.
  • OSIV를 켜 두고 Controller나 JSON 직렬화 단계에서 SQL이 나가는 구조를 방치하지 않는다.
  • 쓰기 모델과 읽기 모델이 크게 다르면 전용 query repository나 projection을 둔다.

위험 신호!

  • N+1을 해결하려고 모든 연관관계를 EAGER로 바꾼다.
  • Controller에서 Entity를 JSON으로 바로 반환한다.
  • SQL 로그 없이 JPA 성능을 감으로 판단한다.
  • 목록 조회 후 stream 안에서 연관관계를 반복 접근한다.
  • 트랜잭션 밖에서 lazy association을 초기화하려고 OSIV에 의존한다.
  • detached Entity를 수정해도 변경 감지가 자동으로 반영될 것이라고 기대한다.

확인 질문

확인 질문

  • 영속성 컨텍스트가 변경 감지를 할 수 있는 이유는 무엇인가?
    • 트랜잭션 안에서 관리 중인 엔티티의 상태를 추적하고 flush 시점에 변경분을 SQL로 반영하기 때문이다.
  • N+1 문제는 어떤 접근 패턴에서 생기는가?
    • 목록 조회 후 각 엔티티의 연관관계를 반복 접근해 추가 쿼리가 N번 실행될 때 생긴다.
  • N+1 해결을 위해 전역 EAGER보다 조회별 fetch plan이 나은 이유는 무엇인가?
    • 전역 EAGER는 모든 유스케이스에 불필요한 로딩을 강제하고 다른 성능 문제를 만들 수 있기 때문이다.

참고 문서