Search
๐Ÿ”ƒ

[TroubleShooting] N+1 ์ฟผ๋ฆฌ

Date
2025/10/30
Category
WEB
Tag
TroubleShooting
SpringBoot
๋ชฉ์ฐจ

๋ฌธ์ œ ์ƒํ™ฉ

ํ”„๋กœ์ ํŠธ ์ „๋ฐ˜์—์„œ JPA์˜ ์ง€์—ฐ ๋กœ๋”ฉ(Lazy Loading) ์ด ๊ธฐ๋ณธ์œผ๋กœ ์„ค์ •๋˜์–ด ์žˆ์—ˆ๊ธฐ ๋•Œ๋ฌธ์—, ์ƒํ’ˆ, ์ฃผ๋ฌธ, ๊ฒฐ์ œ, ํ™˜๋ถˆ ๋“ฑ ์ฃผ์š” ๋„๋ฉ”์ธ ์กฐํšŒ ์‹œ N+1 ์ฟผ๋ฆฌ ๋ฌธ์ œ๊ฐ€ ๋นˆ๋ฒˆํ•˜๊ฒŒ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.
์˜ˆ๋ฅผ ๋“ค์–ด, ์ฃผ๋ฌธ(Order)์„ ์กฐํšŒํ•  ๋•Œ ์—ฐ๊ด€๋œ ์ฃผ๋ฌธ ์ƒ์„ธ(OrderDetail), ๋ฐฐ์†ก์ง€(DeliveryAddress), ๊ฒฐ์ œ(Payment), ํ™˜๋ถˆ(Refund) ์ •๋ณด๋ฅผ ํ•จ๊ป˜ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๊ณผ์ •์—์„œ ๊ฐ ์—ฐ๊ด€ ๊ด€๊ณ„๋งˆ๋‹ค Lazy ๋กœ๋”ฉ์ด ๋ฐœ์ƒํ•˜๋ฉฐ, ๊ฒฐ๊ณผ์ ์œผ๋กœ ํ•œ API ํ˜ธ์ถœ์— ์ˆ˜์‹ญ~์ˆ˜๋ฐฑ ๊ฐœ์˜ ์ฟผ๋ฆฌ๊ฐ€ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค.
-- ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ (1ํšŒ) SELECT * FROM p_order; -- ์ฃผ๋ฌธ๋ณ„ ์ƒ์„ธ ์กฐํšŒ (NํšŒ) SELECT * FROM p_order_detail WHERE order_id = ?; -- ์ฃผ๋ฌธ๋ณ„ ํ™˜๋ถˆ ์กฐํšŒ (NํšŒ) SELECT * FROM p_refund WHERE order_id = ?; -- ์ฃผ๋ฌธ๋ณ„ ๋ฐฐ์†ก์ง€ ์กฐํšŒ (NํšŒ) SELECT * FROM p_delivery_address WHERE delivery_address_id = ?;
SQL
๋ณต์‚ฌ
์ด๋กœ ์ธํ•ด ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์กฐํšŒ ํšŸ์ˆ˜๊ฐ€ ๊ธ‰๊ฒฉํžˆ ๋Š˜์–ด๋‚˜๋ฉฐ ์‘๋‹ต ์ง€์—ฐ๊ณผ ํŠธ๋žœ์žญ์…˜ ์ฒ˜๋ฆฌ ์‹œ๊ฐ„์ด ์ฆ๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค.

์›์ธ ๋ถ„์„

1. Lazy Loading์˜ ๊ธฐ๋ณธ ๋™์ž‘ ๋ฐฉ์‹

โ€ข
JPA์˜ @ManyToOne, @OneToMany ๊ด€๊ณ„๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์ง€์—ฐ ๋กœ๋”ฉ(LAZY) ์„ ์‚ฌ์šฉ
โ€ข
์ฆ‰, ์กฐํšŒ ์‹œ์ ์—๋Š” ์—ฐ๊ด€ ๊ฐ์ฒด๊ฐ€ ํ”„๋ก์‹œ๋กœ ๋‚จ์•„ ์žˆ๋‹ค๊ฐ€ ์‹ค์ œ ์ ‘๊ทผ ์‹œ์ (.getXXX())์— ์ถ”๊ฐ€ ์ฟผ๋ฆฌ๊ฐ€ ์‹คํ–‰๋จ
โ†’ ์—”ํ‹ฐํ‹ฐ ๊ทธ๋ž˜ํ”„๋ฅผ ๋‹จ์ˆœํžˆ ์ˆœํšŒํ•˜๋Š” ๊ฒƒ๋งŒ์œผ๋กœ๋„ ์—ฌ๋Ÿฌ ๊ฐœ์˜ SELECT๊ฐ€ ์‹คํ–‰๋˜๋Š” ๊ตฌ์กฐ

2. DTO ์„ค๊ณ„ ๋ฌธ์ œ

โ€ข
๊ธฐ์กด DTO ๊ตฌ์กฐ๋Š” ์—”ํ‹ฐํ‹ฐ ์ž์ฒด๋ฅผ ํ•„๋“œ๋กœ ๋ณด์œ ํ•˜๊ณ  ์žˆ์–ด, DTO ์ƒ์„ฑ ์‹œ ์—ฐ๊ด€ ๊ฐ์ฒด ์ ‘๊ทผ โ†’ Lazy ์ฟผ๋ฆฌ ์—ฐ์‡„ ์‹คํ–‰
โ€ข
๊ฒฐ๊ณผ์ ์œผ๋กœ DTO ๋ณ€ํ™˜ ๊ณผ์ •์—์„œ๋„ N+1 ์ฟผ๋ฆฌ๊ฐ€ ์ถ”๊ฐ€๋กœ ๋ฐœ์ƒ
public record RefundDetailResponse( UUID orderId, String orderNum, List<ProductInfoDto> productList, RecipientAddressDto recipientAddress, UUID refundId, LocalDateTime cancelDate, RefundStatus refundStatus, int refundPrice, RefundReason refundReason ) { public static RefundDetailResponse of(Order order, List<OrderDetail> orderDetails, Refund refund) { List<ProductInfoDto> productList = orderDetails.stream().map(ProductInfoDto::fromEntity).toList(); RecipientAddressDto recipientAddress = RecipientAddressDto.fromEntity(order.getDeliveryAddress()); // Lazy ์ ‘๊ทผ return new RefundDetailResponse( order.getOrderId(), order.getOrderNum(), productList, recipientAddress, refund.getRefundId(), refund.getCreatedAt(), refund.getRefundStatus(), refund.getPrice(), refund.getReason()); } }
Java
๋ณต์‚ฌ

ํ•ด๊ฒฐ ๊ณผ์ •

1. Fetch Join ์ ์šฉ

โ€ข
JPQL + Fetch Join์„ ํ†ตํ•ด ํ•„์š”ํ•œ ์—ฐ๊ด€ ์—”ํ‹ฐํ‹ฐ๋ฅผ ํ•œ ๋ฒˆ์˜ ์ฟผ๋ฆฌ๋กœ ํ•จ๊ป˜ ์กฐํšŒ
@Query(""" SELECT o FROM Order o JOIN FETCH o.deliveryAddress JOIN FETCH o.orderDetails LEFT JOIN FETCH o.refund WHERE o.orderId = :orderId """) Optional<Order> findByIdWithRelations(@Param("orderId") UUID orderId);
Java
๋ณต์‚ฌ
๊ฒฐ๊ณผ:
Order, OrderDetail, DeliveryAddress, Refund๋ฅผ ๋‹จ์ผ SQL๋กœ ์กฐํšŒ
โ†’ Lazy ๋กœ๋”ฉ ์ œ๊ฑฐ, DB ์™•๋ณต ํšŸ์ˆ˜ ๋Œ€ํญ ๊ฐ์†Œ

2. Batch Fetch Size ์„ค์ •

โ€ข
์ผ๊ด„ ๋กœ๋”ฉ(Batch Fetch)์„ ํ†ตํ•ด N+1 ๋ฌธ์ œ๋ฅผ ์™„ํ™”
โ€ข
Hibernate ์„ค์ •์œผ๋กœ ํ•œ ๋ฒˆ์˜ SELECT์— ์—ฌ๋Ÿฌ ์—”ํ‹ฐํ‹ฐ๋ฅผ ๋ฌถ์–ด์„œ ๊ฐ€์ ธ์˜ค๋„๋ก ์ง€์ •
spring: jpa: properties: hibernate.default_batch_fetch_size: 100
YAML
๋ณต์‚ฌ
ํšจ๊ณผ: ์—ฌ๋Ÿฌ ์—ฐ๊ด€ ์—”ํ‹ฐํ‹ฐ๋ฅผ ๋ฌถ์–ด์„œ ํ•œ ๋ฒˆ์— SELECT โ†’ ๋Œ€๋Ÿ‰ ๋ชฉ๋ก ์กฐํšŒ์—์„œ๋„ 100ํšŒ ์ดํ•˜์˜ ์ตœ์†Œ ์ฟผ๋ฆฌ๋กœ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ ๊ฐ€๋Šฅ

3. DTO ๊ตฌ์กฐ ๊ฐœ์„ 

โ€ข
DTO์—์„œ ์—”ํ‹ฐํ‹ฐ๋ฅผ ์ง์ ‘ ์ฐธ์กฐํ•˜์ง€ ์•Š๊ณ , ๋‚ด๋ถ€ static record ๊ตฌ์กฐ๋ฅผ ํ™œ์šฉํ•ด ํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ๋งŒ ๋…ธ์ถœํ•˜๋„๋ก ๊ฐœ์„ 
public record RefundDetailResponse( UUID orderId, String orderNum, List<ProductInfoDto> productList, RecipientAddressDto recipientAddress, UUID refundId, LocalDateTime cancelDate, RefundStatus refundStatus, int refundPrice, RefundReason refundReason) { public record ProductInfoDto( UUID orderDetailId, int productPrice, String productName, String optionTitle, int quantity) { public static ProductInfoDto fromEntity(OrderDetail orderDetail) { return new ProductInfoDto( orderDetail.getOrderDetailId(), orderDetail.getPrice(), orderDetail.getProductName(), orderDetail.getOptionName(), orderDetail.getQuantity()); } } public record RecipientAddressDto( String postalCode, String city, String sigungu, String roadName, String addressDetail, String recipientName, String recipientContact) { public static RecipientAddressDto fromEntity(DeliveryAddress deliveryAddress) { return new RecipientAddressDto( deliveryAddress.getAddress().getPostalCode(), deliveryAddress.getAddress().getCity(), deliveryAddress.getAddress().getSigungu(), deliveryAddress.getAddress().getRoadName(), deliveryAddress.getAddress().getAddressDetail(), deliveryAddress.getRecipientName(), deliveryAddress.getRecipientContact()); } } }
Java
๋ณต์‚ฌ
๋ชฉ์ :
โ€ข
Lazy ๊ฐ์ฒด ์ ‘๊ทผ ๋ฐฉ์ง€
โ€ข
DTO๋Š” โ€œ์ฝ๊ธฐ ์ „์šฉ ๋ทฐ ๋ชจ๋ธโ€๋กœ ์‚ฌ์šฉ โ†’ ๋ถˆ๋ณ€์„ฑ ๋ณด์žฅ
โ€ข
์กฐํšŒ ์‹œ์ ์— ํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ๋งŒ Selectํ•˜์—ฌ ์ „์†ก๋Ÿ‰ ๋ฐ ์ฒ˜๋ฆฌ ์†๋„ ๊ฐœ์„ 

๊ฐœ์„  ํšจ๊ณผ

๊ตฌ๋ถ„
๊ฐœ์„  ์ „
๊ฐœ์„  ํ›„
์ฟผ๋ฆฌ ์ˆ˜
์ตœ๋Œ€ 3N+1
1 ~ 2ํšŒ
DB ์™•๋ณต ํšŸ์ˆ˜
์—”ํ‹ฐํ‹ฐ ์ˆ˜๋งŒํผ ๋ฐ˜๋ณต
์ผ๊ด„ ์กฐํšŒ๋กœ ์ตœ์†Œํ™”
์กฐํšŒ ์„ฑ๋Šฅ
๋А๋ฆผ (Lazy ๋กœ๋”ฉ ์ค‘์ฒฉ)
๋น ๋ฆ„ (Fetch Join + Batch Fetch)
DTO ๊ตฌ์กฐ
์—”ํ‹ฐํ‹ฐ ์ง์ ‘ ํฌํ•จ
ํ•„์š”ํ•œ ํ•„๋“œ๋งŒ ๋‚ด๋ถ€ DTO๋กœ ๋งคํ•‘

ย ์ธ์‚ฌ์ดํŠธ

1.
Lazy Loading์€ ํ•„์—ฐ์ ์œผ๋กœ N+1 ๋ฌธ์ œ๋ฅผ ๋™๋ฐ˜ํ•˜๋ฏ€๋กœ, ์กฐํšŒ ์ „์šฉ API์—๋Š” ๋ฐ˜๋“œ์‹œ ์ œ์–ด๊ฐ€ ํ•„์š”ํ•˜๋‹ค.
2.
์ „์—ญ BatchSize ์„ค์ •์€ ๊ฐ„ํŽธํ•˜์ง€๋งŒ, ๋„๋ฉ”์ธ๋ณ„ ์ตœ์  ํฌ๊ธฐ๋ฅผ ์กฐ์ •ํ•ด์•ผ ํ•œ๋‹ค.
3.
DTO๋Š” ์—”ํ‹ฐํ‹ฐ๋ฅผ ์ง์ ‘ ๋‹ด์ง€ ๋ง๊ณ , ๋ฐ์ดํ„ฐ ์ „์†ก ๋ชฉ์ ์— ๋งž๋Š” ํ˜•ํƒœ๋กœ ๋ณ€ํ™˜ํ•ด์•ผ ์กฐํšŒ ์„ฑ๋Šฅ๊ณผ ๋ถˆ๋ณ€์„ฑ์„ ํ™•๋ณดํ•  ์ˆ˜ ์žˆ๋‹ค.
4.
์ตœ์ ์˜ ์„ค๊ณ„๋Š” โ€œ์กฐํšŒ์šฉ ์ฟผ๋ฆฌ(์ฝ๊ธฐ)โ€์™€ โ€œ๋ช…๋ น์šฉ ๋กœ์ง(์“ฐ๊ธฐ)โ€์„ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒƒ์ด๋‹ค. (CQRS ์ ‘๊ทผ)

ย ์ฐธ๊ณ ์ž๋ฃŒ