JPA에서 N+1 문제란?
요청이 1개의 쿼리로 처리되기를 기대했는데 N개의 추가 쿼리가 발생하는 현상
즉, 1번의 쿼리로 N개의 데이터를 조회한 후 각각에 대해 추가로 1번씩 쿼리를 실행하는 문제. 결과적으로는 1 + N번의 쿼리가 실행된다.
현상 분석 전 JPA의 핵심개념
- 영속성 컨텍스트(Persistence Context): 엔티티를 관리하는 메모리 공간
- 프록시 객체(Proxy): 실제 객체 대신 사용하는 가짜 객체
- 지연 로딩(Lazy Loading): 필요할 때까지 데이터를 조회하지 않는 전략
왜 N+1 쿼리가 발생하는가?
JPA가 문제를 일으키는 근본적인 이유는 '관계를 맺고 있는 엔티티에 대한 조회 시점을 최적화' 하려는 시도 때문
사용하는 Fetch 전략은 3가지가 있다. EAGER Loading, LAZY Loading, Fetch Join.
여기서 EAGER, LAZY에서 쉽게 문제가 발생한다.
1. EAGER Loading (즉시 로딩)
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "category_id")
private Category category;
개발자의 기대: "EAGER로 설정했으니까 JOIN으로 한 번에 가져오겠지?"
실제 상황:
List<Product> products = productRepository.findAll();
실행되는 쿼리:
-- 1번째: 상품 목록 조회
SELECT * FROM product;
- - 2~N번째: 각 상품의 카테고리 개별 조회 (EAGER라서 자동 실행)
SELECT * FROM category WHERE category_id = 1;
SELECT * FROM category WHERE category_id = 2;
SELECT * FROM category WHERE category_id = 3;
-- ... N번 반복
2. LAZY Loading (지연 로딩)
@ManyToOne(fetch = FetchType.LAZY) // 기본값
@JoinColumn(name = "category_id")
private Category category;
동작 과정:
// 1단계: 상품만 조회 (Category는 프록시 객체)
Product product = productRepository.findById(1L);
System.out.println(product.getCategory().getClass());
// 출력: class Category$HibernateProxy$... (가짜 객체!)
// 2단계: 실제 사용할 때 쿼리 실행
String categoryName = product.getCategory().getName(); // 👈 이 순간 쿼리 실행!
JPA가 내부적으로 생성하는 프록시 객체는 이런 식으로 동작합니다:
// JPA가 만드는 프록시 객체 (개념적 설명)
public class Category$Proxy extends Category {
private boolean initialized = false;
private Object realObject;
@Override
public String getName() {
if (!initialized) {
// 👈 이 순간 데이터베이스 쿼리 실행!
this.realObject = entityManager.find(Category.class, this.id);
this.initialized = true;
}
return ((Category) realObject).getName();
}
}
우리 프로젝트에서 구체적인 문제
LAZY + 반복문
근본 원인: • 반복문 안에서 Repository 메서드 호출 • 각 호출마다 독립적인 쿼리 실행
1. ProductService
파일: ProductService.java - getMainPageProductsForGuest()
public MainProductGuestResponse getMainPageProductsForGuest() {
List<CategoryProductGroup> categoryGroups = new ArrayList<>();
// 🚨 1번 쿼리
List<Category> categories = categoryRepository.findAll();
for (Category category : categories) {
// 🚨 카테고리 수만큼 반복 쿼리 (N번)
List<Product> products = productRepository
.findTop8ByCategoryAndDeletedFalseOrderByWishlistCountDescCreatedAtDesc(category);
// ... 나머지 로직
}
}
문제 규모: 1 + 100 = 101번 쿼리
LAZY + Stream
근본 원인: • Stream 내부에서 지연 로딩 발생 • 각 요소마다 개별 쿼리 실행
2. ProductService 개인화 추천
파일: ProductService.java - getPersonalizedRecommendations()
return finalCandidates.stream()
.map(product -> {
double contentScore = product.getTags().stream() // 🚨 N번 쿼리 (태그 조회)
.mapToDouble(tag -> tagScoreMap.getOrDefault(tag.getId(), 0.0)).sum();
// ...
})
문제 규모: 상품 수 × 태그 수 = 수백~수천 번 쿼리
3. ProductService 상위 랭킹
파일: ProductService.java - getTopRankedProducts()
return productRepository.findAllByDeletedFalseOrderByWishlistCountDesc()
.stream()
.map(product -> new ProductResponseDto(product, // 🚨 각 상품마다 추가 쿼리 발생 가능
likedProductIds.contains(product.getId())))
4. ProductService 상품 상세
파일: ProductService.java - getProductDetail()
List<ProductVariantDto> variantDto = product.getVariants().stream() // 🚨 N번 쿼리
.map(ProductVariantDto::new)
.toList();
5. CartService 장바구니 조회
파일: CartService.java - getCartItems()
return cart.getCartItems().stream() // 🚨 각 CartItem마다 Product, Variant 조회
.map(CartItemDto::new)
.collect(Collectors.toList());
6. OrderService 주문 생성
파일: OrderService.java - createOrder()
List<OrderItem> orderItems = requestDto.getOrderItems().stream()
.map(itemDto -> {
// 🚨 N번 쿼리
ProductVariant variant = productVariantRepository.findById(itemDto.getVariantId())
.orElseThrow(...);
return OrderItem.builder()
.product(variant.getProduct()) // 🚨 추가 쿼리
.variant(variant)
.build();
}).collect(Collectors.toList());
7. WishlistService 찜 목록
파일: WishlistService.java - getWishlistProducts()
return sortedItems.stream()
.map(item -> new ProductResponseDto(item.getProduct(), true))
// 🚨 각 상품마다 추가 쿼리
.toList();
해결책
방법 1: Fetch Join 사용
명시적으로 JOIN을 지시해서 한 번의 쿼리로 모든 연관 데이터를 가져온다
@Query("SELECT DISTINCT p FROM Product p " +
"JOIN FETCH p.category " +
"LEFT JOIN FETCH p.variants")
List<Product> findAllWithAssociations();
// Service
List<Product> products = productRepository.findAllWithAssociations();
// 1번의 쿼리로 모든 연관 데이터 조회 완료!
위와 같이 작성되었을 때, 실행되는 SQL문은 아래와 같다.
SELECT DISTINCT
p.product_id, p.product_name, p.price,
c.category_id, c.category_name,
v.variant_id, v.size, v.color, v.stock
FROM product p
INNER JOIN category c ON p.category_id = c.category_id
LEFT JOIN product_variant v ON p.product_id = v.product_id;
그 결과 한번에 모든 내용을 호출함으로써, N+1 → 1번 으로 횟수가 줄어들게되고,
이후에도 product.getCategory().getName() 호출 시 추가 쿼리 없음
이는 프록시가 아닌 실제 객체를 메모리에 로드한다.
단점도 있는데 OneToMany 관계에서 데이터 중복이 발생할 수 있고, 복잡한 연관관계를 가진 호출시 복잡도가 증가하게된다.
방법 2: @EntityGraph 사용
어노테이션으로 로딩할 연관관계를 선언하면, JPA가 자동으로 Fetch Join으로 변환한다.
@EntityGraph(attributePaths = {"category", "variants"})
@Query("SELECT p FROM Product p")
List<Product> findAllWithGraph();
JPA가 위 코드를 다음과 같이 자동 변환한다
SELECT DISTINCT p.*, c.*, v.*
FROM product p
LEFT JOIN category c ON p.category_id = c.category_id
LEFT JOIN product_variant v ON p.product_id = v.product_id;
재사용 가능한 패턴:
@NamedEntityGraph(
name = "Product.withCategoryAndVariants",
attributeNodes = {
@NamedAttributeNode("category"),
@NamedAttributeNode("variants")
}
)
@Entity
public class Product { ... }
// 여러 곳에서 재사용
@EntityGraph("Product.withCategoryAndVariants")
List<Product> findByPriceGreaterThan(int price);
@EntityGraph("Product.withCategoryAndVariants")
List<Product> findByBrand(String brand);
Fetch Join 방식보다 더 간결하고, 재사용성도 좋고 동작방식도 비슷하지만, 복잡한 조건부 Join이 불가능하고, 실제 SQL문을 예상하기 어렵다.
방법 3: Batch Size 설정
N+1 쿼리를 완전히 없애는 대신, N개의 개별 쿼리를 더 적은 수의 배치 쿼리로 변환한다
@BatchSize(size = 100) // 100개씩 묶어서 조회
@OneToMany(mappedBy = "product")
private List<ProductVariant> variants;
기존 N+1 쿼리:
-- 상품 20개 조회 후 각각 개별 쿼리
SELECT * FROM product_variant WHERE product_id = 1;
SELECT * FROM product_variant WHERE product_id = 2;
SELECT * FROM product_variant WHERE product_id = 3;
-- ... 20번 개별 쿼리
Batch Size 적용 후:
-- 100개씩 묶어서 한 번에 조회
SELECT * FROM product_variant
WHERE product_id IN (1, 2, 3, 4, 5, ..., 20); -- 1번 쿼리로 해결!
동작 과정:
- 첫 번째 product.getVariants() 호출
- JPA가 현재 영속성 컨텍스트에서 같은 타입의 프록시들을 찾음
- 최대 100개까지 묶어서 IN 절로 한 번에 조회
- 조회된 결과를 각 엔티티에 분배
그래서 조금 복잡하더라도
Fetch Join으로 결정. 이왕 잡는거 확실하게 잡아놓고 가는게 좋을 것 같다는 생각이었다.
1. ProductService 도메인
📍 메인페이지 상품 조회 (getMainPageProductsForGuest)
Before (N+1 문제):
public MainProductGuestResponse getMainPageProductsForGuest() {
// 🚨 1번 쿼리: 모든 카테고리 조회
List<Category> categories = categoryRepository.findAll();
for (Category category : categories) {
// 🚨 N번 쿼리: 각 카테고리별로 개별 조회
List<Product> products = productRepository
.findTop8ByCategoryAndDeletedFalseOrderByWishlistCountDescCreatedAtDesc(category);
}
// 총 쿼리 수: 1 + N번 (카테고리 10개면 11번)
}
After (Fetch Join 해결):
// Repository에 최적화 메서드 추가
@Query("""
SELECT p FROM Product p
JOIN FETCH p.category c
WHERE p.deleted = false
AND [p.id](http://p.id/) IN (
SELECT [p2.id](http://p2.id/) FROM Product p2
WHERE p2.category = p.category
AND p2.deleted = false
ORDER BY p2.wishlistCount DESC, p2.createdAt DESC
LIMIT 8
)
ORDER BY [c.id](http://c.id/), p.wishlistCount DESC, p.createdAt DESC
""")
List<Product> findTop8ProductsPerCategoryWithCategory();
// Service 최적화
public MainProductGuestResponse getMainPageProductsForGuest() {
// 1번 쿼리로 모든 카테고리별 상위 8개 상품 조회
List<Product> allProducts = productRepository
.findTop8ProductsPerCategoryWithCategory();
// 카테고리별로 그룹핑
Map<Category, List<Product>> productsByCategory = allProducts.stream()
.collect(Collectors.groupingBy(Product::getCategory));
// 총 쿼리 수: 1번
}
📍 개인화 추천 (getPersonalizedRecommendations)
Before (N+1 문제):
return finalCandidates.stream()
.map(product -> {
// 🚨 각 상품마다 태그 조회 쿼리 발생
double contentScore = product.getTags().stream()
.mapToDouble(tag -> tagScoreMap.getOrDefault(tag.getId(), 0.0)).sum();
})
// 총 쿼리 수: 상품 수 × 태그 수 = 수백~수천번
After (Fetch Join 해결):
// Repository에 태그 포함 조회 메서드 추가
@Query("SELECT DISTINCT p FROM Product p LEFT JOIN FETCH p.tags WHERE p.id IN :ids AND p.deleted = false")
List<Product> findByIdsWithTags(@Param("ids") List<Long> ids);
// Service 최적화
List<Product> finalCandidates = productRepository.findByIdsWithTags(new ArrayList<>(candidateIds));
return finalCandidates.stream()
.map(product -> {
// 🚀 이미 fetch join으로 태그가 로딩되어 추가 쿼리 없음
double contentScore = product.getTags().stream()
.mapToDouble(tag -> tagScoreMap.getOrDefault(tag.getId(), 0.0)).sum();
})
// 총 쿼리 수: 1번
📍 상품 상세 조회 (getProductDetail)
Before (N+1 문제):
public ProductDetailResponseDto getProductDetail(Long userId, Long productId) {
Product product = productRepository.findByIdWithCategory(productId);
// 🚨 variants 조회 시 N+1 쿼리 발생
List<ProductVariantDto> variantDto = product.getVariants().stream()
.map(ProductVariantDto::new)
.toList();
}
After (Fetch Join 해결):
// Repository에 variants 포함 조회 메서드 추가
@Query("SELECT p FROM Product p JOIN FETCH p.category LEFT JOIN FETCH p.variants WHERE p.id = :id AND p.deleted = false")
Optional<Product> findByIdWithCategoryAndVariants(@Param("id") Long id);
// Service 최적화
public ProductDetailResponseDto getProductDetail(Long userId, Long productId) {
// 🚀 상품, 카테고리, variants를 한 번에 조회
Product product = productRepository.findByIdWithCategoryAndVariants(productId);
// 이미 fetch join으로 variants가 로딩되어 추가 쿼리 없음
List<ProductVariantDto> variantDto = product.getVariants().stream()
.map(ProductVariantDto::new)
.toList();
}
2. CartService 도메인
📍 장바구니 조회 (getCartItems)
Before (N+1 문제):
@Transactional(readOnly = true)
public List<CartItemDto> getCartItems(Long userId) {
Cart cart = findCartByUserId(userId);
// 🚨 각 CartItem마다 Product, Variant 개별 조회
return cart.getCartItems().stream()
.map(CartItemDto::new) // 여기서 N+1 쿼리 발생
.collect(Collectors.toList());
}
After (Fetch Join 해결):
// Repository에 연관 엔티티 포함 조회 메서드 추가
@Query("SELECT c FROM Cart c " +
"LEFT JOIN FETCH c.cartItems ci " +
"LEFT JOIN FETCH ci.variant v " +
"LEFT JOIN FETCH v.product p " +
"WHERE c.member.id = :memberId")
Optional<Cart> findByMemberIdWithItems(@Param("memberId") Long memberId);
// Service 최적화
@Transactional(readOnly = true)
public List<CartItemDto> getCartItems(Long userId) {
// 🚀 Cart, CartItem, Variant, Product를 한 번에 조회
Cart cart = cartRepository.findByMemberIdWithItems(userId);
// 이미 fetch join으로 모든 연관 데이터가 로딩되어 추가 쿼리 없음
return cart.getCartItems().stream()
.map(CartItemDto::new)
.collect(Collectors.toList());
}
3. OrderService 도메인
📍 주문 생성 (createOrder)
Before (N+1 문제):
List<OrderItem> orderItems = requestDto.getOrderItems().stream()
.map(itemDto -> {
// 🚨 각 주문 아이템마다 개별 쿼리
ProductVariant variant = productVariantRepository.findById(itemDto.getVariantId())
.orElseThrow(...);
return OrderItem.builder()
.product(variant.getProduct()) // 🚨 추가 쿼리
.variant(variant)
.build();
}).collect(Collectors.toList());
// 총 쿼리 수: 주문 아이템 수 × 2번
After (Batch Fetch 해결):
// Repository에 배치 조회 메서드 추가
@Query("SELECT pv FROM ProductVariant pv JOIN FETCH pv.product WHERE pv.variantId IN :variantIds")
List<ProductVariant> findAllByIdInWithProduct(@Param("variantIds") List<Long> variantIds);
// Service 최적화
// 🚀 모든 variant를 한 번에 조회
List<Long> variantIds = requestDto.getOrderItems().stream()
.map(OrderItemRequestDto::getVariantId)
.toList();
List<ProductVariant> variants = productVariantRepository.findAllByIdInWithProduct(variantIds);
// variant ID를 키로 하는 Map 생성
Map<Long, ProductVariant> variantMap = variants.stream()
.collect(Collectors.toMap(ProductVariant::getVariantId, v -> v));
List<OrderItem> orderItems = requestDto.getOrderItems().stream()
.map(itemDto -> {
ProductVariant variant = variantMap.get(itemDto.getVariantId());
return OrderItem.builder()
.product(variant.getProduct()) // 🚀 이미 fetch join으로 로딩됨
.variant(variant)
.build();
}).collect(Collectors.toList());
// 총 쿼리 수: 1번
4. WishlistService 도메인
📍 찜 목록 조회 (getWishlistProducts)
Before (N+1 문제):
@Transactional(readOnly = true)
public List<ProductResponseDto> getWishlistProducts(Member user) {
List<WishlistItem> sortedItems = wishlistItemRepository
.findAllByWishlist_WishlistIdOrderByCreatedAtDesc(wishlist.getWishlistId());
// 🚨 각 WishlistItem마다 Product 개별 조회
return sortedItems.stream()
.map(item -> new ProductResponseDto(item.getProduct(), true))
.toList();
}
After (Fetch Join 해결):
// Repository에 Product 포함 조회 메서드 추가
@Query("SELECT wi FROM WishlistItem wi JOIN FETCH wi.product WHERE wi.wishlist.wishlistId = :wishlistId ORDER BY wi.createdAt DESC")
List<WishlistItem> findAllByWishlistIdWithProductOrderByCreatedAtDesc(@Param("wishlistId") Long wishlistId);
// Service 최적화
@Transactional(readOnly = true)
public List<ProductResponseDto> getWishlistProducts(Member user) {
// 🚀 WishlistItem과 Product를 함께 조회
List<WishlistItem> sortedItems = wishlistItemRepository
.findAllByWishlistIdWithProductOrderByCreatedAtDesc(wishlist.getWishlistId());
// 이미 fetch join으로 Product가 로딩되어 추가 쿼리 없음
return sortedItems.stream()
.map(item -> new ProductResponseDto(item.getProduct(), true))
.toList();
}
📊 전체 성능 개선 결과
| 도메인 | 메서드 | Before | After | 개선율 |
|---|---|---|---|---|
| Product | 메인페이지 | 1 + N번 | 1번 | 90%+ |
| Product | 개인화추천 | N × M번 | 1번 | 95%+ |
| Product | 상품상세 | 1 + N번 | 1번 | 90%+ |
| Cart | 장바구니조회 | 1 + N번 | 1번 | 90%+ |
| Order | 주문생성 | N × 2번 | 1번 | 95%+ |
| Wishlist | 찜목록조회 | N번 | 1번 | 95%+ |
🎯 핵심 해결 패턴
1. Fetch Join 패턴
// 연관 엔티티를 명시적으로 함께 조회
@Query("SELECT e FROM Entity e JOIN FETCH e.association WHERE ...")
2. Batch Fetch 패턴
// 여러 ID를 한 번에 조회
@Query("SELECT e FROM Entity e JOIN FETCH e.association WHERE [e.id](http://e.id/) IN :ids")
3. Map 변환 패턴
// 조회한 데이터를 Map으로 변환하여 효율적 접근
Map<Long, Entity> entityMap = entities.stream()
.collect(Collectors.toMap(Entity::getId, e -> e));
핵심 교훈
1. JPA의 함정들
- EAGER ≠ N+1 해결: 오히려 더 많은 문제 야기 가능
- LAZY의 지연 실행: 예상치 못한 순간에 쿼리 실행
- 자동 최적화 기대 금지: JPA는 명시적 지시 없이 JOIN 안 함
2. 올바른 접근법
- 필요한 데이터만 명시적으로 Fetch Join
- 쿼리 로그로 실행 횟수 확인
- 성능 테스트로 검증
'Spring' 카테고리의 다른 글
| Spring Boot 게시판 개발, 핵심 어노테이션 정리 (0) | 2025.06.19 |
|---|---|
| View 환경설정: Thymeleaf 알아보기 (0) | 2025.06.18 |
| 프로젝트 환경설정 (0) | 2025.06.18 |