While implementing gift API queries, I handled related data such as products, options, and wishlist items. The response looked correct, but SQL logs showed that one API call could produce more queries than expected.
This was the typical JPA N+1 problem.
Problem
If a list of entities is loaded first and each entity lazily loads a related object later, JPA can execute one additional query per item. The data appears normal, but query count grows with the result size.
For example, a wishlist list API can load wishlist items first.
select * from wishlist_item where member_id = ?
Then each item can trigger another product query when the code accesses the lazy relationship.
select * from product where id = ?
select * from product where id = ?
select * from product where id = ?
The response shape still looks correct, so the issue is easy to miss without SQL logs.
Solution Options
Fetch Join loads required relationships in one query. It makes the intent clear but should be used carefully, especially with collections and pagination.
@Query("""
select w
from WishlistItem w
join fetch w.product
where w.member.id = :memberId
""")
List<WishlistItem> findAllWithProductByMemberId(Long memberId);
EntityGraph allows specific associations to be loaded for a query while keeping query code cleaner in some cases.
@EntityGraph(attributePaths = "product")
List<WishlistItem> findByMemberId(Long memberId);
Batch Size reduces repeated lazy-loading queries by grouping them. It is useful when lazy loading still makes sense but repeated individual queries are too costly.
@BatchSize(size = 100)
@ManyToOne(fetch = FetchType.LAZY)
private Product product;
Each option solves a slightly different problem.
| Option | Useful when | Caution |
|---|---|---|
| Fetch Join | The association is always needed for this query. | Collection fetch joins and pagination need care. |
| EntityGraph | The repository method should declare the needed graph. | It can still over-fetch if applied too broadly. |
| Batch Size | Lazy loading still makes sense, but repeated one-by-one queries are too costly. | It reduces query count but does not express query intent as clearly. |
After seeing the SQL logs, I started treating query count as part of the API result. A response can look correct while still being expensive to produce.
Takeaway
JPA does not automatically protect against inefficient SQL. When building read APIs, I check not only the response shape but also how many queries are needed to produce it.