2025-05-28

JPA와 Hibernate

JPAHibernateSpringN+1성능 튜닝

JPA와 Hibernate

JPA와 Hibernate를 한 단계 더 깊이 이해하기 위해 중요한 네 가지 주제를 다뤄보겠습니다. 실제 예제를 통해 영속성 컨텍스트의 동작 원리부터, 효과적인 로딩 전략, N+1 문제 해결 기법, 그리고 쿼리 성능 튜닝까지 살펴보겠습니다.


1. 영속성 컨텍스트, flush, dirty checking

JPA의 핵심은 '영속성 컨텍스트(Persistence Context)'입니다. 영속성 컨텍스트는 EntityManager가 관리하는 1차 캐시로, 애플리케이션에서 조회한 엔티티를 보관합니다. 이 컨텍스트 안에서는 엔티티의 변경을 자동으로 감지하고, 필요 시점에 DB로 동기화해 주죠.

@Transactional
void dirtyCheckingTest(EntityManager em) {
    Member member = em.find(Member.class, 1L);   // 1) 영속성 컨텍스트에 엔티티 등록
    member.setName("홍길동");                     // 2) 필드 변경 (dirty)
    em.flush();                                  // 3) 변경 사항을 즉시 DB 반영
    // → UPDATE MEMBER SET name = '홍길동' WHERE id = 1
}
  • dirty checking: 변경 내역을 스캔해 자동으로 UPDATE 쿼리를 생성
  • flush(): 영속성 컨텍스트와 DB를 동기화
  • 주의: flush만 호출해도 SQL은 즉시 실행되지만, 트랜잭션이 커밋되어야 실제로 반영됩니다.

2. Fetch 전략 (즉시 vs 지연 로딩)

연관관계를 맺은 엔티티를 조회할 때, 언제 SQL이 실행될지 결정하는 것이 Fetch 전략입니다.

@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;  // 기본값이 LAZY
}
  • 즉시(EAGER): 부모 조회 시점에 연관 엔티티도 함께 즉시 조회
  • 지연(LAZY): 실제 접근 시점에 프록시가 초기화되며 조회
  • : LAZY 로딩 후 트랜잭션을 벗어나면 LazyInitializationException이 발생하니, 꼭 필요한 곳에서만 사용하고 조회 전략을 조심스럽게 선택하세요.

3. N+1 문제와 해결

지연 로딩을 사용하다 보면 흔히 마주치는 N+1 문제. 예를 들어, 모든 주문을 조회하고 각 주문마다 회원을 가져오는 경우입니다.

List<Order> orders = em.createQuery("select o from Order o", Order.class)
                       .getResultList();   // 1번 쿼리
orders.forEach(o -> System.out.println(o.getMember().getName()));
// 주문 개수(N)만큼 추가 쿼리 발생 → N+1

해결 방안

Fetch Join

List<Order> orders = em.createQuery(
    "select o from Order o join fetch o.member", Order.class)
    .getResultList(); // 단 1개의 쿼리로 해결

EntityGraph

@NamedEntityGraph(name = "Order.withMember",
    attributeNodes = @NamedAttributeNode("member"))
public class Order { ... }
 
// 조회 시 힌트 지정
em.createQuery("select o from Order o", Order.class)
  .setHint("javax.persistence.fetchgraph", em.getEntityGraph("Order.withMember"))
  .getResultList();

이 두 기법으로 연관 엔티티를 한 번에 가져와 불필요한 추가 쿼리를 방지할 수 있습니다.


4. 성능 개선을 위한 쿼리 튜닝

1) 인덱스 적용

컬럼에 인덱스를 걸어 검색 비용을 크게 줄일 수 있습니다.

@Table(name = "MEMBER",
       indexes = @Index(name = "idx_member_email", columnList = "email"))
public class Member { ... }

2) Projection

불필요한 모든 컬럼을 조회하지 않고, 필요한 데이터만 DTO나 인터페이스로 가져오면 조회 속도가 빨라집니다.

DTO 생성자

public class MemberDto {
    private Long id;
    private String name;
 
    public MemberDto(Long id, String name) {
        this.id = id;
        this.name = name;
    }
    // getters
}
 
// JPQL
List<MemberDto> dtos = em.createQuery(
    "select new com.example.dto.MemberDto(m.id, m.name) " +
    "from Member m " +
    "where m.status = :status", MemberDto.class)
  .setParameter("status", "ACTIVE")
  .getResultList();

인터페이스 프로젝션 (Spring Data JPA)

// 프로젝션 인터페이스
public interface MemberSummary {
    Long getId();
    String getName();
}
 
// Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    List<MemberSummary> findByStatus(String status);
}
 
// 사용
List<MemberSummary> summaries = memberRepository.findByStatus("ACTIVE");

마무리

  • 인덱스는 검색 속도의 핵심 열쇠이지만, 너무 많이 걸면 쓰기 성능이 저하됩니다.
  • 프로젝션은 불필요한 데이터를 깔끔히 제거해 전송·처리 비용을 크게 절감합니다.