Session 4JPA Workshop

연관관계 고급 & N+1 문제

JPA 성능의 핵심인 N+1 문제를 이해하고 해결하는 다양한 방법을 학습합니다.

학습 목표

  • N+1 문제가 발생하는 원인을 이해한다
  • Fetch Join, @EntityGraph, @BatchSize의 차이를 구분한다
  • 상황에 맞는 N+1 해결 방법을 선택할 수 있다
  • Cascade와 orphanRemoval을 올바르게 사용할 수 있다
  • OSIV, 벌크 연산, 페이징 등 실무 최적화 패턴을 적용할 수 있다
  • JPA 관련 트러블슈팅을 효과적으로 수행할 수 있다

1. N+1 문제란?

N+1 문제: JPA 성능 문제의 가장 흔한 원인

1개의 쿼리로 N개의 데이터를 조회한 후, 연관된 데이터를 조회하기 위해 N개의 추가 쿼리가 발생하는 문제입니다.

1.1 N+1 문제 발생 예시

문제 상황
// Entity 구조
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;
}

// 주문 목록 조회
List<Order> orders = orderRepository.findAll();
// 쿼리 1: SELECT * FROM orders

// 각 주문의 고객 이름 출력
for (Order order : orders) {
    System.out.println(order.getCustomer().getName());
    // 쿼리 2: SELECT * FROM customers WHERE id = 1
    // 쿼리 3: SELECT * FROM customers WHERE id = 2
    // 쿼리 4: SELECT * FROM customers WHERE id = 3
    // ... N개의 추가 쿼리!
}

// 결과: 1(주문 조회) + N(고객 조회) = N+1 쿼리 발생!
실제 발생하는 쿼리
-- 1. 주문 목록 조회 (1개 쿼리)
SELECT o.id, o.customer_id, o.order_date, o.status 
FROM orders o;

-- 2. 각 주문의 고객 조회 (N개 쿼리)
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 1;
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 2;
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 3;
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 4;
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 5;
-- ... 주문 수만큼 반복!

-- 100개 주문 조회 시: 1 + 100 = 101개 쿼리 실행!

1.2 N+1 문제가 발생하는 경우

1. LAZY 로딩 + 반복문에서 연관 Entity 접근

지연 로딩된 연관 Entity를 반복문에서 접근할 때

2. EAGER 로딩 (즉시 로딩)

JPQL 사용 시 EAGER도 N+1 발생 (JOIN이 아닌 추가 쿼리로 로딩)

3. @OneToMany 컬렉션 접근

부모 Entity 조회 후 자식 컬렉션에 접근할 때

1.3 EAGER 로딩도 N+1 발생

// EAGER 로딩 설정
@Entity
public class Order {
    @ManyToOne(fetch = FetchType.EAGER)  // 즉시 로딩
    @JoinColumn(name = "customer_id")
    private Customer customer;
}

// JPQL로 조회 시 N+1 발생!
List<Order> orders = em.createQuery("SELECT o FROM Order o", Order.class)
    .getResultList();

// 실행되는 쿼리:
// 1. SELECT * FROM orders
// 2. SELECT * FROM customers WHERE id = 1  (EAGER라서 즉시 로딩)
// 3. SELECT * FROM customers WHERE id = 2
// ... N개 추가 쿼리!

// EAGER는 em.find()에서만 JOIN으로 동작
Order order = em.find(Order.class, 1L);
// SELECT o.*, c.* FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.id = 1
결론: EAGER 로딩은 N+1 문제를 해결하지 못합니다. 항상 LAZY 로딩을 기본으로 사용하세요.

2. Fetch Join으로 해결

Fetch Join은 JPQL에서 연관된 Entity를 한 번의 쿼리로 함께 조회하는 방법입니다. N+1 문제의 가장 기본적인 해결책입니다.

2.1 기본 Fetch Join

@ManyToOne Fetch Join
// Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    
    // Fetch Join으로 Customer 함께 조회
    @Query("SELECT o FROM Order o JOIN FETCH o.customer")
    List<Order> findAllWithCustomer();
    
    // 조건 추가
    @Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
    List<Order> findByStatusWithCustomer(@Param("status") OrderStatus status);
}

// 사용
List<Order> orders = orderRepository.findAllWithCustomer();
for (Order order : orders) {
    // 추가 쿼리 없음! 이미 로딩됨
    System.out.println(order.getCustomer().getName());
}

// 실행되는 쿼리 (단 1개!)
// SELECT o.*, c.* 
// FROM orders o 
// INNER JOIN customers c ON o.customer_id = c.id

2.2 컬렉션 Fetch Join

@OneToMany Fetch Join
// Order의 OrderItems 함께 조회
@Query("SELECT o FROM Order o JOIN FETCH o.orderItems")
List<Order> findAllWithOrderItems();

// 여러 연관관계 함께 조회
@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.customer " +
       "JOIN FETCH o.orderItems oi " +
       "JOIN FETCH oi.product")
List<Order> findAllWithCustomerAndItems();

// 실행되는 쿼리
// SELECT o.*, c.*, oi.*, p.*
// FROM orders o
// JOIN customers c ON o.customer_id = c.id
// JOIN order_items oi ON o.id = oi.order_id
// JOIN products p ON oi.product_id = p.id

2.3 컬렉션 Fetch Join 주의사항

데이터 중복 문제

컬렉션 Fetch Join 시 1:N 관계로 인해 결과가 뻥튀기됩니다.

// Order 1개에 OrderItem 3개인 경우
// JOIN 결과: Order가 3번 중복됨!

// 해결: DISTINCT 사용
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.orderItems")
List<Order> findAllWithOrderItems();

// Hibernate 6+에서는 자동으로 중복 제거됨

컬렉션 Fetch Join + 페이징 금지!

컬렉션 Fetch Join과 페이징을 함께 사용하면 메모리에서 페이징합니다.

// 위험! 모든 데이터를 메모리에 로드 후 페이징
@Query("SELECT o FROM Order o JOIN FETCH o.orderItems")
Page<Order> findAllWithOrderItems(Pageable pageable);  // 경고 발생!

// 해결책: @BatchSize 또는 별도 쿼리로 분리

2.4 Fetch Join 제한사항

1. 둘 이상의 컬렉션 Fetch Join 불가

카테시안 곱으로 데이터 폭발. MultipleBagFetchException 발생

2. 컬렉션 Fetch Join + 페이징 불가

메모리에서 페이징하여 OutOfMemoryError 위험

3. 별칭(alias) 사용 제한

Fetch Join 대상에 별칭을 주고 WHERE 조건 사용 비권장

Fetch Join 사용 가이드

  • • @ManyToOne, @OneToOne: Fetch Join 자유롭게 사용
  • • @OneToMany: 하나만 Fetch Join, DISTINCT 필수
  • • 페이징 필요 시: @BatchSize 또는 별도 쿼리 사용
  • • 복잡한 경우: DTO 직접 조회 고려

3. @EntityGraph

@EntityGraph는 어노테이션 기반으로 Fetch 전략을 지정하는 방법입니다. JPQL 없이도 연관 Entity를 함께 조회할 수 있습니다.

3.1 기본 사용법

Repository에서 @EntityGraph 사용
public interface OrderRepository extends JpaRepository<Order, Long> {
    
    // attributePaths로 함께 조회할 연관관계 지정
    @EntityGraph(attributePaths = {"customer"})
    List<Order> findAll();
    
    // 여러 연관관계 지정
    @EntityGraph(attributePaths = {"customer", "delivery"})
    List<Order> findByStatus(OrderStatus status);
    
    // 중첩 연관관계
    @EntityGraph(attributePaths = {"customer", "orderItems", "orderItems.product"})
    Optional<Order> findById(Long id);
    
    // 메서드 이름 쿼리와 함께 사용
    @EntityGraph(attributePaths = {"customer"})
    List<Order> findByCustomerId(Long customerId);
}

// 사용
List<Order> orders = orderRepository.findAll();
// LEFT OUTER JOIN으로 customer 함께 조회

3.2 Named EntityGraph

Entity에 미리 정의
// Entity에 Named EntityGraph 정의
@Entity
@NamedEntityGraph(
    name = "Order.withCustomer",
    attributeNodes = @NamedAttributeNode("customer")
)
@NamedEntityGraph(
    name = "Order.withAll",
    attributeNodes = {
        @NamedAttributeNode("customer"),
        @NamedAttributeNode("delivery"),
        @NamedAttributeNode(value = "orderItems", subgraph = "orderItems")
    },
    subgraphs = @NamedSubgraph(
        name = "orderItems",
        attributeNodes = @NamedAttributeNode("product")
    )
)
public class Order {
    @Id @GeneratedValue
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY)
    private Customer customer;
    
    @OneToOne(fetch = FetchType.LAZY)
    private Delivery delivery;
    
    @OneToMany(mappedBy = "order")
    private List<OrderItem> orderItems = new ArrayList<>();
}

// Repository에서 사용
public interface OrderRepository extends JpaRepository<Order, Long> {
    
    @EntityGraph(value = "Order.withCustomer")
    List<Order> findByStatus(OrderStatus status);
    
    @EntityGraph(value = "Order.withAll")
    Optional<Order> findDetailById(Long id);
}

3.3 EntityGraph Type

FETCH (기본값)

지정한 속성은 EAGER, 나머지는 LAZY

@EntityGraph(type = EntityGraphType.FETCH)

LOAD

지정한 속성은 EAGER, 나머지는 Entity 설정 따름

@EntityGraph(type = EntityGraphType.LOAD)

3.4 Fetch Join vs @EntityGraph

구분Fetch Join@EntityGraph
방식JPQL에 직접 작성어노테이션
JOIN 타입INNER JOINLEFT OUTER JOIN
유연성WHERE 조건 자유롭게메서드 이름 쿼리와 조합
재사용쿼리마다 작성Named로 재사용

선택 가이드

  • Fetch Join: 복잡한 조건, INNER JOIN 필요 시
  • @EntityGraph: 단순 조회, 메서드 이름 쿼리와 조합 시
  • 둘 다: @Query + @EntityGraph 조합 가능

4. @BatchSize

@BatchSize는 지연 로딩 시 IN 쿼리로 한 번에 여러 Entity를 조회합니다. N+1을 1+1로 줄여주는 효과적인 방법입니다.

4.1 @BatchSize 사용법

Entity에 적용
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;
    
    // @ManyToOne에 적용
    @ManyToOne(fetch = FetchType.LAZY)
    @BatchSize(size = 100)  // 최대 100개씩 IN 쿼리
    @JoinColumn(name = "customer_id")
    private Customer customer;
    
    // @OneToMany에 적용
    @OneToMany(mappedBy = "order")
    @BatchSize(size = 100)
    private List<OrderItem> orderItems = new ArrayList<>();
}

// 또는 연관 Entity 클래스에 적용
@Entity
@BatchSize(size = 100)  // 이 Entity를 조회할 때 배치 적용
public class Customer {
    @Id @GeneratedValue
    private Long id;
    private String name;
}
동작 방식
// @BatchSize 없이 (N+1 발생)
List<Order> orders = orderRepository.findAll();  // 100개 주문
for (Order order : orders) {
    order.getCustomer().getName();  // 100번의 추가 쿼리!
}
// 총 101개 쿼리

// @BatchSize(size = 100) 적용 시
List<Order> orders = orderRepository.findAll();  // 100개 주문
for (Order order : orders) {
    order.getCustomer().getName();  // IN 쿼리 1번!
}
// 총 2개 쿼리

// 실행되는 쿼리
-- 1. 주문 조회
SELECT * FROM orders;

-- 2. 고객 배치 조회 (IN 쿼리)
SELECT * FROM customers 
WHERE id IN (1, 2, 3, 4, 5, ... 100);

4.2 글로벌 설정

application.yml에서 전역 설정
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100  # 전역 배치 사이즈

# 모든 지연 로딩에 자동 적용
# 개별 @BatchSize로 오버라이드 가능
권장: 전역 설정으로 100~1000 사이 값 설정. 대부분의 N+1 문제가 자동으로 완화됩니다.

4.3 @BatchSize + 페이징

컬렉션 Fetch Join은 페이징이 불가능하지만, @BatchSize는 페이징과 함께 사용 가능합니다.

// 페이징 + @BatchSize 조합 (권장)
@Entity
public class Order {
    @OneToMany(mappedBy = "order")
    @BatchSize(size = 100)  // 페이징과 함께 사용 가능!
    private List<OrderItem> orderItems = new ArrayList<>();
}

// Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    @EntityGraph(attributePaths = {"customer"})  // ToOne은 Fetch Join
    Page<Order> findByStatus(OrderStatus status, Pageable pageable);
}

// 사용
Page<Order> orders = orderRepository.findByStatus(
    OrderStatus.PAID, 
    PageRequest.of(0, 20)
);

// 실행되는 쿼리
-- 1. 주문 페이징 조회 (customer JOIN)
SELECT o.*, c.* FROM orders o 
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PAID'
LIMIT 20;

-- 2. OrderItems 배치 조회 (IN 쿼리)
SELECT * FROM order_items 
WHERE order_id IN (1, 2, 3, ... 20);

N+1 해결 전략 정리

@ManyToOne, @OneToOne: Fetch Join 또는 @EntityGraph
@OneToMany (페이징 없음): Fetch Join + DISTINCT
@OneToMany (페이징 있음): @BatchSize (전역 설정 권장)
복잡한 조회: DTO 직접 조회

5. Cascade와 orphanRemoval

5.1 Cascade (영속성 전이)

부모 Entity의 영속 상태 변화를 자식 Entity에 전파합니다. 부모를 저장할 때 자식도 함께 저장하는 등의 동작이 가능합니다.

CascadeType 종류
타입설명사용 시점
PERSIST저장 시 함께 저장부모 저장 시 자식도 저장
REMOVE삭제 시 함께 삭제부모 삭제 시 자식도 삭제
MERGE병합 시 함께 병합준영속 → 영속 전환 시
REFRESH새로고침 시 함께 새로고침DB에서 다시 조회
DETACH분리 시 함께 분리영속성 컨텍스트에서 분리
ALL모든 상태 전이생명주기가 완전히 같을 때
Cascade 사용 예시
@Entity
@Table(name = "orders")
public class Order {
    @Id @GeneratedValue
    private Long id;
    
    // Order 저장/삭제 시 OrderItem도 함께
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();
    
    // Order 저장/삭제 시 Delivery도 함께
    @OneToOne(cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;
    
    public void addOrderItem(OrderItem item) {
        orderItems.add(item);
        item.setOrder(this);
    }
}

// 사용: Order만 저장하면 OrderItem, Delivery도 함께 저장
Order order = new Order();
order.setDelivery(new Delivery(address));
order.addOrderItem(OrderItem.create(product1, 2));
order.addOrderItem(OrderItem.create(product2, 1));

orderRepository.save(order);  // 3개 Entity 모두 INSERT

5.2 orphanRemoval (고아 객체 제거)

부모 Entity와의 관계가 끊어진 자식 Entity를 자동으로 삭제합니다.

@Entity
public class Order {
    @OneToMany(mappedBy = "order", 
               cascade = CascadeType.ALL, 
               orphanRemoval = true)  // 고아 객체 자동 삭제
    private List<OrderItem> orderItems = new ArrayList<>();
    
    public void removeOrderItem(OrderItem item) {
        orderItems.remove(item);
        item.setOrder(null);
        // orphanRemoval = true이면 자동 DELETE
    }
}

// 사용
Order order = orderRepository.findById(orderId).orElseThrow();
order.removeOrderItem(order.getOrderItems().get(0));
// 컬렉션에서 제거만 해도 DELETE 쿼리 실행!

// 컬렉션 전체 비우기
order.getOrderItems().clear();
// 모든 OrderItem DELETE

5.3 Cascade 사용 주의사항

Cascade 사용 조건

  • 1. 단일 소유자: 자식이 하나의 부모에만 속할 때
    예: OrderItem은 Order에만 속함 (O)
  • 2. 생명주기 동일: 부모와 자식의 생명주기가 같을 때
    예: Order 삭제 시 OrderItem도 삭제되어야 함 (O)
  • 금지: 자식이 여러 부모에 속하는 경우
    예: Product는 여러 OrderItem에서 참조 → Cascade 금지!

이커머스에서의 Cascade 적용

적용 O: Order → OrderItem, Order → Delivery
적용 X: OrderItem → Product, Order → Customer

6. 실습: 성능 최적화 적용

최적화된 Entity 설정
// application.yml - 전역 설정
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100
        
// Order Entity
@Entity
@Table(name = "orders")
public class Order extends BaseEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY)  // 항상 LAZY
    @JoinColumn(name = "customer_id")
    private Customer customer;
    
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> orderItems = new ArrayList<>();
    
    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;
    
    @Enumerated(EnumType.STRING)
    private OrderStatus status;
}

// OrderRepository - 상황별 조회 메서드
public interface OrderRepository extends JpaRepository<Order, Long> {
    
    // 목록 조회: Customer만 Fetch Join
    @EntityGraph(attributePaths = {"customer"})
    Page<Order> findByStatus(OrderStatus status, Pageable pageable);
    
    // 상세 조회: 모든 연관관계 Fetch Join
    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.customer " +
           "JOIN FETCH o.delivery " +
           "WHERE o.id = :id")
    Optional<Order> findDetailById(@Param("id") Long id);
    
    // OrderItems는 @BatchSize로 자동 처리
}
Service 계층 최적화
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {
    
    private final OrderRepository orderRepository;
    
    // 주문 목록 조회 (페이징)
    public Page<OrderListDto> getOrders(OrderStatus status, Pageable pageable) {
        Page<Order> orders = orderRepository.findByStatus(status, pageable);
        
        // DTO 변환 (OrderItems는 @BatchSize로 IN 쿼리)
        return orders.map(order -> new OrderListDto(
            order.getId(),
            order.getCustomer().getName(),
            order.getStatus(),
            order.getOrderItems().size(),  // @BatchSize 적용
            order.getTotalPrice()
        ));
    }
    
    // 주문 상세 조회
    public OrderDetailDto getOrderDetail(Long orderId) {
        Order order = orderRepository.findDetailById(orderId)
            .orElseThrow(() -> new EntityNotFoundException("주문을 찾을 수 없습니다"));
        
        return new OrderDetailDto(order);
    }
    
    // 주문 생성
    @Transactional
    public Long createOrder(Long customerId, List<OrderItemRequest> items, Address address) {
        Customer customer = customerRepository.findById(customerId).orElseThrow();
        
        Order order = Order.create(customer, new Delivery(address));
        
        for (OrderItemRequest item : items) {
            Product product = productRepository.findById(item.getProductId()).orElseThrow();
            order.addOrderItem(OrderItem.create(product, item.getQuantity()));
        }
        
        orderRepository.save(order);  // Cascade로 OrderItem, Delivery 함께 저장
        return order.getId();
    }
}

6.1 정리

모든 연관관계는 LAZY 로딩

EAGER는 N+1 문제 해결 안됨

@ManyToOne, @OneToOne: Fetch Join 또는 @EntityGraph

ToOne 관계는 자유롭게 Fetch Join

@OneToMany + 페이징: @BatchSize

전역 설정으로 default_batch_fetch_size 권장

Cascade는 단일 소유자 + 동일 생명주기

Order → OrderItem (O), OrderItem → Product (X)

6.2 핵심 키워드

N+1 문제Fetch JoinJOIN FETCH@EntityGraph@BatchSizedefault_batch_fetch_sizeFetchType.LAZYCascadeType.ALLorphanRemovalDISTINCT

6.3 다음 세션 예고

Session 5: Spring Data JPA

다음 세션에서는 Spring Data JPA의 편의 기능을 학습합니다.

JpaRepository 인터페이스

기본 CRUD 메서드

Query Methods

메서드 이름으로 쿼리 생성

@Query와 JPQL

직접 쿼리 작성

Pageable과 Sort

페이징과 정렬

7. 실무 최적화 패턴

7.1 OSIV (Open Session In View)

OSIV란?

영속성 컨텍스트를 뷰 렌더링까지 유지하는 전략입니다. Spring Boot에서는 기본값이 true이지만, 실시간 트래픽이 많은 서비스에서는 OFF를 권장합니다.

OSIV ON vs OFF
# application.yml
spring:
  jpa:
    open-in-view: true   # 기본값 (개발 편의)
    # open-in-view: false  # 실무 권장
OSIV ON
  • • 뷰에서 지연 로딩 가능
  • • DB 커넥션 오래 점유
  • • 개발 편의성 높음
OSIV OFF (권장)
  • • 트랜잭션 안에서만 지연 로딩
  • • 커넥션 빠르게 반환
  • • 서비스 계층에서 DTO 변환 필수
OSIV OFF 권장 패턴
// Command Service (쓰기)
@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {
    public Long order(Long memberId, Long productId, int count) {
        // 비즈니스 로직
    }
}

// Query Service (읽기) - OSIV OFF에서 필수
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderQueryService {
    
    public OrderDto findOrder(Long orderId) {
        // 트랜잭션 안에서 DTO 변환 완료
        Order order = orderRepository.findByIdWithMemberDelivery(orderId)
            .orElseThrow();
        return OrderDto.from(order);
    }
}

// Controller
@RestController
@RequiredArgsConstructor
public class OrderController {
    
    private final OrderQueryService orderQueryService;
    
    @GetMapping("/api/orders/{id}")
    public OrderDto getOrder(@PathVariable Long id) {
        return orderQueryService.findOrder(id); // 이미 DTO
    }
}

7.2 읽기 전용 쿼리 최적화

@Transactional(readOnly = true)
@Service
@Transactional(readOnly = true) // 클래스 레벨 기본값
public class MemberQueryService {
    
    public List<MemberDto> findMembers() {
        // 읽기 전용 - 스냅샷 비교 생략, 성능 향상
    }
    
    @Transactional // 쓰기 작업은 오버라이드
    public void updateMember(Long id, String name) {
        // 쓰기 작업
    }
}

readOnly = true 효과

  • • 플러시 모드를 MANUAL로 설정 → 스냅샷 비교 생략
  • • 더티 체킹 비용 절감
  • • DB에 따라 읽기 전용 트랜잭션 최적화

7.3 벌크 연산

@Modifying 사용법
public interface MemberRepository extends JpaRepository<Member, Long> {
    
    // 벌크 UPDATE
    @Modifying(clearAutomatically = true) // 영속성 컨텍스트 자동 초기화
    @Query("update Member m set m.age = m.age + 1 where m.age >= :age")
    int bulkAgePlus(@Param("age") int age);
    
    // 벌크 DELETE
    @Modifying(clearAutomatically = true)
    @Query("delete from Member m where m.age < :age")
    int bulkDelete(@Param("age") int age);
}

주의사항

벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리합니다. 반드시 clearAutomatically = true를 사용하거나 em.clear()를 호출하세요.

7.4 페이징 최적화

Page vs Slice vs List
Page<T>
  • • COUNT 쿼리 추가 실행
  • • 전체 페이지 수 계산
  • • 전통적 페이지네이션
Slice<T>
  • • COUNT 쿼리 없음
  • • limit + 1로 다음 페이지 확인
  • • 무한 스크롤, 더보기
List<T>
  • • COUNT 쿼리 없음
  • • 페이징 메타 정보 없음
  • • 단순 데이터 조회
// COUNT 쿼리 분리 (복잡한 조인이 있는 경우)
@Query(value = "select m from Member m left join m.team t",
       countQuery = "select count(m) from Member m") // 조인 없이 카운트
Page<Member> findByAge(int age, Pageable pageable);

// No Offset 페이징 (커서 기반) - 대용량 데이터에 효과적
@Query("select m from Member m where m.id > :lastId order by m.id")
List<Member> findByIdGreaterThan(@Param("lastId") Long lastId, Pageable pageable);

7.5 실무 권장 설정

application.yml 권장 설정
spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      minimum-idle: 5
      connection-timeout: 30000
      
  jpa:
    hibernate:
      ddl-auto: validate  # 운영: validate, 개발: update
    properties:
      hibernate:
        format_sql: true
        default_batch_fetch_size: 100  # N+1 방어
        jdbc:
          batch_size: 50
        order_inserts: true
        order_updates: true
    open-in-view: false  # 실시간 서비스 권장
    
logging:
  level:
    org.hibernate.SQL: DEBUG  # 개발 환경만

핵심 원칙 5가지

  1. 모든 연관관계는 LAZY - 즉시 로딩 사용 금지
  2. 글로벌 Batch Size 100 - N+1 기본 방어
  3. OSIV OFF - 실시간 트래픽 서비스
  4. API 응답은 DTO - 엔티티 직접 반환 금지
  5. 쿼리 로그 모니터링 - N+1 조기 발견

8. 트러블슈팅 가이드

8.1 LazyInitializationException

LazyInitializationException: could not initialize proxy - no Session

영속성 컨텍스트가 종료된 후 지연 로딩을 시도할 때 발생합니다.

문제 코드
// ❌ 문제 발생
@Service
public class MemberService {
    @Transactional
    public Member findMember(Long id) {
        return memberRepository.findById(id).get();
    }
}

@RestController
public class MemberController {
    @GetMapping("/members/{id}")
    public MemberDto getMember(@PathVariable Long id) {
        Member member = memberService.findMember(id);
        // 트랜잭션 종료 후 접근 → LazyInitializationException!
        return new MemberDto(member.getName(), member.getTeam().getName());
    }
}
해결 방법
// ✅ 해결책 1: 트랜잭션 안에서 DTO 변환
@Service
@Transactional(readOnly = true)
public class MemberService {
    public MemberDto findMemberDto(Long id) {
        Member member = memberRepository.findById(id).get();
        return new MemberDto(member.getName(), member.getTeam().getName());
    }
}

// ✅ 해결책 2: Fetch Join 사용
@Query("select m from Member m join fetch m.team where m.id = :id")
Optional<Member> findByIdWithTeam(@Param("id") Long id);

// ✅ 해결책 3: EntityGraph 사용
@EntityGraph(attributePaths = {"team"})
Optional<Member> findById(Long id);

8.2 MultipleBagFetchException

MultipleBagFetchException: cannot simultaneously fetch multiple bags

2개 이상의 컬렉션(List)을 동시에 Fetch Join할 때 발생합니다.

해결 방법
// ❌ 문제 발생
@Query("select t from Team t " +
       "join fetch t.members " +
       "join fetch t.projects") // MultipleBagFetchException!
List<Team> findAllWithMembersAndProjects();

// ✅ 해결책 1: Set으로 변경
@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    private Set<Member> members = new HashSet<>();
    
    @OneToMany(mappedBy = "team")
    private Set<Project> projects = new HashSet<>();
}

// ✅ 해결책 2: Batch Size 활용 (권장)
@Entity
public class Team {
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
    
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team")
    private List<Project> projects = new ArrayList<>();
}

8.3 영속성 컨텍스트와 DB 불일치

벌크 연산 후 불일치 문제
// ❌ 잘못된 사용 - 영속성 컨텍스트와 DB 불일치
@Transactional
public void wrongBulkUpdate() {
    Member member = memberRepository.findById(1L).get();
    System.out.println("변경 전: " + member.getAge()); // 20
    
    memberRepository.bulkAgePlus(10); // DB에서 age + 1
    
    // 영속성 컨텍스트에는 여전히 이전 값!
    System.out.println("변경 후: " + member.getAge()); // 여전히 20
}

// ✅ 올바른 사용
@Transactional
public void correctBulkUpdate() {
    memberRepository.bulkAgePlus(10);
    
    em.flush();
    em.clear(); // 영속성 컨텍스트 초기화
    
    Member member = memberRepository.findById(1L).get();
    System.out.println("변경 후: " + member.getAge()); // 21
}

8.4 동시성 문제 해결

낙관적 락 vs 비관적 락
낙관적 락 (Optimistic)
  • • @Version 사용
  • • 충돌이 거의 없을 때
  • • 읽기가 많고 쓰기가 적을 때
  • • 재시도 로직 필요
비관적 락 (Pessimistic)
  • • SELECT ... FOR UPDATE
  • • 충돌이 자주 발생할 때
  • • 재고 관리, 좌석 예약
  • • 데드락 주의
// 낙관적 락
@Entity
public class Product {
    @Version
    private Long version;
}

// 비관적 락
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);

8.5 성능 체크리스트

JPA 성능 최적화 체크리스트