커머스 백엔드에서 가장 많이 깨지는 지점은 의외로 결제도, 동시성도 아닌 조회 쿼리다. 주문 한 건을 화면에 띄우려면 다음 57개 테이블이 엮인다. - 주문 헤더 - 주문 라인 - 메뉴 - 메뉴 옵션 - 쿠폰 - 매장 - 회원 JPA를 쓰는 팀이라면 이 시점에서 거의 반드시 N+1 쿼리 문제를 만난다. 더 나쁜 점은 N+1이 단위 테스트에서는 안 보인다는...
커머스 백엔드에서 가장 많이 깨지는 지점은 의외로 결제도, 동시성도 아닌 조회 쿼리다. 주문 한 건을 화면에 띄우려면 다음 5~7개 테이블이 엮인다.
CJ푸드빌 같은 외식 프랜차이즈 도메인은 N+1이 더 잘 터지는 구조다. 매장(브랜드/지점) × 메뉴(베이스 상품) × 옵션(사이즈, 토핑, 사이드) × 쿠폰(적용 가능 여부) 조합이 항상 묶여서 다닌다. "주문 상세 조회 한 번에 200쿼리"가 농담이 아니라 실측치로 잡힌다.
이 문서는 N+1을 단순히 "fetch join 쓰면 된다"로 끝내지 않고, 언제 fetch join을 쓰면 안 되는지, 언제 read model을 분리해야 하는지, 언제 JPA를 버리고 MyBatis로 가는지 까지 시니어 백엔드 관점으로 정리한다. 면접에서 "JPA N+1 어떻게 해결하셨어요?"라는 질문은 실은 "당신이 ORM의 한계를 알고 있느냐"를 묻는 질문이다.
관련 문서가 있다면 다음과 가볍게 연결해서 읽는다.
N+1 쿼리는 다음 세 조건이 동시에 만족될 때 발생한다.
LAZY 연관관계를 갖는다.이때 부모 1쿼리 + 자식 N쿼리가 발생해서 총 N+1쿼리가 된다. EAGER로 바꿔도 단지 같은 N+1을 INSERT 시점에 미리 던질 뿐이다. 즉 EAGER는 해결책이 아니다.
JPA를 쓰는 팀이 자주 빠지는 함정은 쓰기용 도메인 모델 = 조회용 응답 모델이라고 가정하는 것이다. 도메인 모델은 비즈니스 규칙(주문 상태 전이, 결제 가능 여부, 환불 가능 여부)을 표현하기 위해 풍부한 객체 그래프를 가진다. 그러나 화면용 응답은 평탄한 DTO 한 덩어리만 필요하다. 이 둘을 같은 엔티티로 처리하려고 하면 결국 LAZY를 강제로 깨거나, fetch join을 남발하거나, OSIV로 트랜잭션을 끌고 다니게 된다.
시니어 레벨 답변의 핵심은 이것이다.
"쓰기 모델은 JPA 엔티티로 두고, 조회 모델은 별도 DTO 또는 별도 쿼리(MyBatis/JdbcTemplate/JPQL DTO projection)로 분리한다."
요구사항: 한 화면에 다음을 모두 보여준다.
엔티티 구조 가정:
@Entity
public class Order {
@Id Long id;
@ManyToOne(fetch = LAZY) Store store;
@ManyToOne(fetch = LAZY) Member member;
@OneToMany(mappedBy = "order", fetch = LAZY) List<OrderLine> lines;
@OneToMany(mappedBy = "order", fetch = LAZY) List<AppliedCoupon> coupons;
}
@Entity
public class OrderLine {
@Id Long id;
@ManyToOne(fetch = LAZY) Order order;
@ManyToOne(fetch = LAZY) Menu menu;
@OneToMany(mappedBy = "line", fetch = LAZY) List<OrderLineOption> options;
}나쁜 코드:
public OrderDetailResponse getOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
return OrderDetailResponse.from(order); // 여기서 모든 LAZY 다 깨짐
}이 코드는 다음과 같이 쿼리가 폭발한다.
라인이 5개면 13쿼리, 20개면 43쿼리.
요구사항: 회원의 최근 주문 20건을 카드 리스트로 보여준다. 카드 한 장에는 첫 번째 라인의 메뉴 이미지, 라인 개수, 총 금액, 적용 쿠폰명이 들어간다.
public Page<OrderCardResponse> listOrders(Long memberId, Pageable pageable) {
Page<Order> orders = orderRepository.findByMemberId(memberId, pageable);
return orders.map(OrderCardResponse::from); // 카드마다 LAZY 깨짐
}20건을 띄우는데 쿼리가 20 × (lines + menu + coupons) = 60~80쿼리. 이게 운영에서 가장 흔한 N+1 패턴이다.
요구사항: 매장별 메뉴 카탈로그 화면. 메뉴 50개, 메뉴별 옵션 그룹 3~5개, 그룹별 옵션 3~10개.
이건 N+1+M 구조로 폭이 더 넓다. 부모 50건 조회 후 자식 컬렉션 두 단계가 LAZY로 풀린다.
요구사항: 메뉴 카드 100개에 대해 "이 쿠폰을 지금 적용 가능한가"를 함께 표시.
이건 단순 N+1을 넘어, 카르테시안 폭발까지 같이 검토해야 한다. 쿠폰 정책이 join 대상이 되면 fetch join은 오히려 위험하다.
JOIN FETCH)가장 직관적인 해결책. 한 번의 SQL로 부모와 자식을 한꺼번에 가져온다.
@Query("""
select o from Order o
join fetch o.store
join fetch o.lines l
join fetch l.menu
where o.id = :id
""")
Optional<Order> findDetailById(@Param("id") Long id);언제 쓰나: 단건 상세 조회, 컬렉션 1개까지.
언제 쓰면 안 되나:
MultipleBagFetchException이 터지거나, 카르테시안 곱이 발생한다.firstResult/maxResults specified with collection fetch; applying in memory 경고). 데이터가 많으면 OOM.@EntityGraphJPQL 안 건드리고 fetch 전략만 선언적으로 바꿀 때.
@EntityGraph(attributePaths = {"store", "lines", "lines.menu"})
Optional<Order> findById(Long id);내부 동작은 fetch join과 사실상 같다. 한계도 같다(컬렉션 둘+페이징 금지). 다만 Spring Data 메서드 시그니처를 그대로 두면서 fetch만 강화할 수 있어서 코드 가독성이 좋아진다.
default_batch_fetch_size (Hibernate batch fetching)application.yml:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100부모 N건을 조회한 뒤, LAZY 컬렉션을 처음 접근할 때 Hibernate가 자식 N개를 한 번의 where parent_id in (?, ?, …) 쿼리로 묶어서 가져온다. N+1이 N+1이 아니라 1+1 또는 1+(N/100) 으로 줄어든다.
언제 쓰나:
주의점:
new 연산자, JPQL/QueryDSL projection)엔티티 그래프가 아니라 응답 모델 자체를 SQL로 직조한다.
@Query("""
select new com.cj.order.dto.OrderCardDto(
o.id, o.totalPrice, o.createdAt, m.name, m.imageUrl, c.name
)
from Order o
join o.lines l
join l.menu m
left join o.coupons ac
left join ac.coupon c
where o.member.id = :memberId
""")
Page<OrderCardDto> findCards(@Param("memberId") Long memberId, Pageable pageable);언제 쓰나: 목록 화면, 검색 화면, 통계 화면. 즉 "엔티티의 행동이 필요 없는" 모든 조회.
장점:
단점:
규모가 더 커지면 단순 DTO projection을 넘어 조회 전용 SQL 레이어를 별도로 두는 게 옳다. JPA를 버리는 게 아니라, 쓰기용 Repository(OrderRepository extends JpaRepository)와 조회용 Reader(OrderQueryDao — JdbcTemplate, MyBatis, QueryDSL projection 등)를 분리한다.
public interface OrderRepository extends JpaRepository<Order, Long> { /* 쓰기 */ }
public interface OrderQueryDao {
OrderDetailView findDetail(long orderId);
Page<OrderCardView> findCards(long memberId, Pageable pageable);
}이 구조의 장점은 분명하다.
CQRS의 가벼운 버전이다. 굳이 명령/조회를 별도 DB로 분리하지 않아도, 레이어를 나누는 것만으로 면접에서 충분히 강한 답이 나온다.
@Query("""
select distinct o from Order o
join fetch o.lines l
join fetch l.menu
where o.member.id = :memberId
""")
Page<Order> findAllWithLines(@Param("memberId") Long id, Pageable pageable);문제:
distinct가 SQL distinct이지 의미가 다름. 카르테시안 곱 그대로 받아 Java에서 중복 제거public Page<OrderCardView> findCards(Long memberId, Pageable pageable) {
Page<OrderHeaderView> headers = orderQueryDao.findHeaders(memberId, pageable);
List<Long> orderIds = headers.stream().map(OrderHeaderView::orderId).toList();
Map<Long, List<OrderLineView>> lineMap =
orderQueryDao.findLinesByOrderIds(orderIds).stream()
.collect(groupingBy(OrderLineView::orderId));
return headers.map(h -> OrderCardView.of(h, lineMap.getOrDefault(h.orderId(), List.of())));
}쿼리 수: 헤더 1쿼리 + 라인 1쿼리 = 2쿼리. 페이징 정확히 동작. 카르테시안 곱 없음.
@EntityGraph(attributePaths = {"store", "member", "lines", "lines.menu", "lines.options"})
Optional<Order> findById(Long id);단건이면 컬렉션 fetch join이 안전하다(페이징 없음). 단, 컬렉션 둘 이상이면 한쪽은 batch fetching에 맡긴다.
hibernate:
default_batch_fetch_size: 200List<Menu> menus = menuRepository.findByStoreId(storeId);
menus.forEach(m -> m.getOptionGroups().forEach(g -> g.getOptions().size()));
// 쿼리: 메뉴 1 + 옵션 그룹 1 + 옵션 1 = 3쿼리Spring Boot의 기본값은 spring.jpa.open-in-view=true다. 이 값은 요청이 끝날 때까지 영속성 컨텍스트를 살려둔다. 컨트롤러나 뷰에서 LAZY 접근이 가능해서 편하지만, 다음 부작용이 있다.
운영 권장 설정:
spring:
jpa:
open-in-view: falsefalse로 두면 트랜잭션 안에서 필요한 데이터를 모두 끌어와야 한다. 이게 강제되면 자연스럽게 fetch 전략과 DTO projection이 들어오게 된다. 즉 OSIV를 끄는 것 자체가 N+1 예방 장치다.
면접 답변 포인트:
"OSIV는 개발 편의를 위한 기본값이지, 운영 권장 값이 아닙니다. 저는 OSIV를 false로 두고, 서비스 계층에서 필요한 LAZY를 명시적으로 초기화하거나 DTO projection으로 처리합니다."
JPA를 쓴다고 모든 쿼리를 JPA로 풀어야 한다는 법은 없다. CJ푸드빌 같은 외식 도메인 관점에서 정리하면 다음과 같다.
| 상황 | 권장 |
|---|---|
| 도메인 규칙이 풍부한 쓰기 (주문 생성, 결제 처리, 상태 전이) | JPA |
| 단건 상세 조회 | JPA + EntityGraph |
| 목록 / 검색 / 카드 리스트 | JPA DTO projection 또는 MyBatis |
| 보고서, 통계, 정산, 외부 추출 | MyBatis 또는 JdbcTemplate |
| 동적 검색 조건이 많고 SQL 가독성이 중요한 경우 | MyBatis |
| 다중 테이블 join + group by + 윈도우 함수 | MyBatis |
핵심은 "조회는 SQL 친화 도구, 쓰기는 객체지향 도구"라는 분업이다. JPA만 고집하다 fetch join을 누더기로 깁는 것보다, 조회 한두 개를 MyBatis로 빼는 게 운영 친화적이다.
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: shopdb
ports: ["3306:3306"]
command: --default-authentication-plugin=mysql_native_passwordcreate table store (
id bigint primary key auto_increment,
name varchar(100) not null
);
create table menu (
id bigint primary key auto_increment,
store_id bigint not null,
name varchar(100) not null,
price int not null,
index idx_menu_store (store_id)
);
create table orders (
id bigint primary key auto_increment,
member_id bigint not null,
store_id bigint not null,
total_price int not null,
created_at datetime not null,
index idx_orders_member_created (member_id, created_at desc)
);
create table order_line (
id bigint primary key auto_increment,
order_id bigint not null,
menu_id bigint not null,
qty int not null,
index idx_line_order (order_id)
);
create table order_line_option (
id bigint primary key auto_increment,
line_id bigint not null,
name varchar(100) not null,
extra_price int not null,
index idx_option_line (line_id)
);insert into store(name) values ('역삼점'),('잠실점');
insert into menu(store_id,name,price)
select 1, concat('메뉴',n), 8000 + n*100 from
(select 1 as n union all select 2 union all select 3 union all select 4 union all select 5) t;
insert into orders(member_id,store_id,total_price,created_at)
select 7, 1, 20000, now() - interval n day from
(select 1 as n union all select 2 union all select 3 union all select 4 union all select 5
union all select 6 union all select 7 union all select 8 union all select 9 union all select 10) t;spring:
jpa:
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: tracep6spy를 쓰면 바인딩 값까지 한 줄로 잡힌다. N+1 추적에는 p6spy + decorator.datasource.p6spy.enable-logging=true 조합이 가장 강력하다.
@SpringBootTest
@Transactional
class OrderNPlusOneTest {
@Autowired OrderRepository orderRepository;
@Test
void detect_n_plus_one() {
Statistics stats = sessionFactory.getStatistics();
stats.clear();
List<Order> orders = orderRepository.findByMemberId(7L);
orders.forEach(o -> o.getLines().forEach(l -> l.getMenu().getName()));
long count = stats.getPrepareStatementCount();
System.out.println("queries = " + count);
}
}목록 10건이면 30+쿼리가 찍힌다. 그다음 default_batch_fetch_size: 100을 켜고 재실행해 3쿼리로 떨어지는 것을 확인한다.
@Query("select o from Order o join fetch o.lines where o.member.id = :id")
Page<Order> badQuery(@Param("id") Long id, Pageable pageable);실행하면 HHH000104: firstResult/maxResults specified with collection fetch; applying in memory! 경고가 뜬다. 이 경고를 무시하지 않는 게 시니어다.
public Page<OrderCardView> findCards(Long memberId, Pageable pageable) {
Page<OrderHeaderView> headers = headerDao.find(memberId, pageable);
if (headers.isEmpty()) return headers.map(h -> OrderCardView.empty(h));
List<Long> ids = headers.getContent().stream().map(OrderHeaderView::orderId).toList();
Map<Long, List<OrderLineView>> lines = lineDao.findInOrderIds(ids).stream()
.collect(groupingBy(OrderLineView::orderId));
return headers.map(h -> OrderCardView.of(h, lines.getOrDefault(h.orderId(), List.of())));
}<select id="findCards" resultType="OrderCardView">
select
o.id as orderId,
o.total_price as totalPrice,
o.created_at as createdAt,
(select group_concat(m.name separator ',')
from order_line ol join menu m on m.id = ol.menu_id
where ol.order_id = o.id) as menuNames
from orders o
where o.member_id = #{memberId}
order by o.created_at desc
limit #{size} offset #{offset}
</select>JPA로 같은 결과를 만들려면 native query에 의존하게 되므로, 이 정도 화면은 MyBatis가 더 정직하다.
N+1은 단일 해결책이 아니라 케이스별 도구 선택 문제로 봅니다.
- 단건 상세는 fetch join이나 EntityGraph로 한 번에 끌어옵니다. 단 컬렉션 둘 이상이면 한쪽은 batch fetching에 맡깁니다.
- 목록 + 컬렉션은 fetch join을 쓰면 안 됩니다. 페이징이 메모리에서 일어나기 때문입니다. 이 경우
default_batch_fetch_size를 설정해 IN 쿼리로 묶거나, DTO projection으로 응답 모델을 직접 만들고 부족한 컬렉션은 별도 IN 쿼리로 한 번 더 가져오는 2단계 전략을 씁니다.- 화면이 복잡한 카드 리스트나 통계는 아예 조회 전용 Reader를 두고 MyBatis로 처리합니다. 쓰기 모델과 조회 모델을 분리하는 것이 핵심입니다.
동작 원리는 같습니다. 둘 다 한 SQL로 join 해서 가져옵니다. 차이는 선언 위치입니다. fetch join은 JPQL에 명시적으로 들어가고, EntityGraph는 메서드 시그니처 위 어노테이션으로 선언합니다. 같은 Repository 메서드에 다양한 fetch 전략을 두고 싶을 때는 EntityGraph가 깔끔하고, 동적 join이 필요하면 QueryDSL fetch join을 씁니다.
운영 환경에서는 false가 기본이라고 봅니다. true는 LAZY 접근을 어디서든 허용해서 편하지만, 트랜잭션 밖에서 커넥션을 잡고, 외부 API 호출 중에도 커넥션이 묶여서 풀이 빠르게 고갈됩니다. false로 두면 서비스 계층에서 필요한 데이터를 명시적으로 가져와야 하기 때문에, 자연스럽게 N+1 예방이 됩니다.
도메인 규칙이 들어가는 쓰기는 JPA가 강합니다. 상태 전이, 무결성 제약, 도메인 이벤트가 깔끔합니다. 반면 보고서, 통계, 동적 검색, 카드 리스트처럼 SQL 가독성과 join 자유도가 중요한 조회는 MyBatis가 단순합니다. 한 도구로 모든 걸 풀려고 fetch join을 누더기로 깁기보다, 조회 일부를 MyBatis로 분리해 쓰기 모델의 일관성을 지키는 쪽이 운영에 더 좋다고 판단합니다.
컬렉션 fetch join + 페이징은 메모리 페이징이 일어나서 위험합니다. 저라면 두 가지 중 하나를 씁니다. 첫째,
default_batch_fetch_size를 설정해 페이지 안에서 라인을 IN 쿼리로 묶어서 가져오게 합니다. 둘째, 헤더 DTO를 먼저 페이징으로 가져오고 헤더 ID 목록을 모아서 라인을 별도 IN 쿼리로 한 번 더 가져온 뒤, 메모리에서 group by로 합칩니다. 응답 모델이 복잡하면 후자를 선호합니다. 페이징 정확성과 인덱스 활용 모두 명시적으로 보장됩니다.
@OneToMany에 fetch = EAGER를 박아 두고 N+1을 피했다고 착각하는 경우. 실제로는 모든 조회 시점에 N+1이 강제로 일어난다.distinct를 SQL distinct로 오해해 카르테시안 곱을 메모리에 그대로 받는 경우.findAll() 후 stream().map(toDto) 안에서 LAZY를 깨는 경우. 트랜잭션 밖이면 LazyInitializationException, OSIV 켜져 있으면 N+1.spring.jpa.open-in-view를 false로 두고 서비스 계층에서 LAZY를 명시적으로 처리했는가order_id, (member_id, created_at desc) 등)