데일리 CS 학습
더보기
JPA N+1 문제
- Q. IoC(Inversion of Control)와 DI(Dependency Injection)를 설명해 주세요.
- A. IoC는 객체 생성, 관리의 제어권을 개발자가 아닌 Spring 컨테이너가 가지는 개념입니다. DI는 IoC를 구현하는 방법으로, 객체가 필요한 의존성을 외부(컨테이너)로부터 주입받습니다.
- Q. Spring Bean이란 무엇이고, 어떻게 등록하나요?
- A. Spring IoC 컨테이너가 관리하는 자바 객체입니다. @Component, @Service, @ Repository, @Controller, @RestController 등 어노테이션을 붙이거나 @Configuration 클래스에서 @Bean으로 등록합니다.
- Q. Spring Bean의 스코프 종류를 설명해 주세요.
- A. Singleton(기본값, 컨테이너당 하나의 인스턴스), Prototype(요청마다 새 인스턴스), Request(HTTP 요청마다 하나), Session(HTTP 세션마다 하나) 등이 있습니다.
- Q. @Autowired, @Inject, @Resourse의 차이는 무엇인가요?
- A. @Autowired는 Spring 전용으로 타입 기준 주입, @Inject는 Java 표준으로 타입 기준, @Resource는 Java 표준으로 기준 주입입니다. 일반적으로 생성자 주입과 @Autowired를 권장합니다.
- Q. 생성자 주입이 권장되는 이유는 무엇인가요?
- A. 필드 주입과 달리 불변성 보장, 필수 의존성 누락 시 컴파일 에러로 조기 발견, 테스트 시 Mock 객체 주입이 용이합니다. 순환 참조도 애플리케이션 시작 시점에 감지됩니다.
- Q. AOP란 무엇이고 어디에 활용되나요?
- A. 관점 지향 프로그래밍으로, 핵심 비즈니스 로직과 공통 관심사(로깅, 트랜잭션, 보안)를 분리하는 기법입니다. @Transactional이 대표적인 AOP 활용 예입니다.
- Q. @Component, @Service, @Repository, @Controller의 차이는 무엇인가요?
- A. 모두 @Component를 상속하며 Bean 등록 기능은 동일합니다. 다만 @Repository는 데이터 접근 예외 변환, @Controller는 MVC 핸들러 인식, @Service는 비즈니스 레이어 의미를 부여합니다. 역할과 명확화와 AOP 적용을 위해 구분합니다.
- Q. Spring의 트랜잭션(@Transactional)은 어떻게 동작하나요?
- A. AOP 프록시 방식으로 동작합니다. 메서드 호출 전 트랜잭션을 시작하고, 정상 종료 시 커밋, 런타임 예외 발생 시 롤백합니다. 같은 클래스 내부 메서드 호출에는 적용되지 않는 Self-invocation 주의가 필요합니다.
Today I Learned · 2025 · Spring Backend
JPA N+1 문제
발생 원인과 3가지 해결 전략
LAZY/EAGER 로딩 차이, fetch join vs @BatchSize vs @EntityGraph — 실무 코드로 완전 정리
Java Spring Boot JPA Hibernate 성능최적화
01 N+1 문제란?
1번의 쿼리로 N개의 결과를 가져온 후, 각 결과에 연관된 데이터를 가져오기 위해 N번의 추가 쿼리가 발생하는 문제입니다. 쿼리 총 수가 1 + N이 되어 N+1 문제라 부릅니다.
⚠ 체감 예시
회원 100명을 조회하고, 각 회원의 주문 목록을 가져오는 경우 → SELECT member 1번 + SELECT orders WHERE member_id=? 를 100번 = 총 101번의 쿼리 발생. 회원이 10,000명이면 10,001번 쿼리.
회원 100명을 조회하고, 각 회원의 주문 목록을 가져오는 경우 → SELECT member 1번 + SELECT orders WHERE member_id=? 를 100번 = 총 101번의 쿼리 발생. 회원이 10,000명이면 10,001번 쿼리.
101
N+1 발생 시 쿼리 수 (회원 100명 조회)
회원 목록 SELECT 1번 + 회원별 주문 SELECT 100번. 연관 엔티티가 추가될수록 기하급수적으로 증가.
1~2
해결 후 쿼리 수
fetch join 또는 BatchSize 적용 시 1~2번의 쿼리로 해결 가능.
발생 시나리오 — 엔티티 설계
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY) // 기본값 LAZY
private List<Order> orders = new ArrayList<>();
}
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
private String itemName;
private LocalDateTime orderedAt;
}Java
N+1이 발생하는 코드
// 서비스 코드
@Transactional(readOnly = true)
public void printAllMemberOrders() {
List<Member> members = memberRepository.findAll(); // ① SELECT * FROM member (1번)
for (Member m : members) {
// ② members.size()만큼 SELECT * FROM orders WHERE member_id=? 반복 (N번)
m.getOrders().size();
}
}
// 실제 실행 SQL (회원 3명일 때)
// select * from member
// select * from orders where member_id = 1
// select * from orders where member_id = 2
// select * from orders where member_id = 3 → 총 4번 (1+3)Java
findAll()
SELECT member
SELECT member
→
member[0].getOrders()
SELECT orders WHERE id=1
SELECT orders WHERE id=1
→
member[1].getOrders()
SELECT orders WHERE id=2
SELECT orders WHERE id=2
→
member[N].getOrders()
... N번 반복
... N번 반복
02 LAZY vs EAGER 로딩
LAZY — 지연 로딩
연관 엔티티를 실제로 사용하는 시점에 쿼리를 날립니다. JPA 기본값은 @OneToMany, @ManyToMany → LAZY, @ManyToOne, @OneToOne → EAGER입니다.
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders;
Member member = memberRepository.findById(1L).orElseThrow();
// 아직 orders 쿼리 없음
member.getOrders(); // 프록시 반환 (쿼리 없음)
member.getOrders().size(); // ← 여기서 실제 SELECT 쿼리 발생 (프록시 초기화)Java
⚠ LazyInitializationException
트랜잭션이 끝난 후 LAZY 프록시에 접근하면
트랜잭션이 끝난 후 LAZY 프록시에 접근하면
LazyInitializationException이 발생합니다. 주로 Controller에서 엔티티를 직접 반환하거나, 트랜잭션 밖에서 연관 필드에 접근할 때 발생합니다. DTO로 변환하거나 트랜잭션 내에서 미리 초기화해야 합니다.EAGER — 즉시 로딩과 함정
연관 엔티티를 항상 함께 JOIN해서 가져옵니다. 단순 조회 시에는 편리하지만 치명적인 함정이 있습니다.
@ManyToOne(fetch = FetchType.EAGER) // @ManyToOne 기본값이 EAGER
private Member member;
// JPQL: "select o from Order o"
// → Hibernate는 JPQL을 그대로 변환: SELECT * FROM orders
// → 결과를 매핑할 때 EAGER 설정 확인 → member를 가져와야 함
// → SELECT * FROM member WHERE id=? 를 각 order마다 실행 → N+1 발생!Java
⚠ EAGER가 N+1의 주범이 되는 이유
JPQL/쿼리 메서드는 SQL을 먼저 생성한 뒤 결과에서 EAGER 연관을 채우는 방식으로 동작합니다. 결과 행마다 추가 SELECT를 날립니다. 모든 연관관계는 LAZY로 선언하고, 필요한 경우만 fetch join으로 명시적으로 로딩하는 것이 정석입니다.
JPQL/쿼리 메서드는 SQL을 먼저 생성한 뒤 결과에서 EAGER 연관을 채우는 방식으로 동작합니다. 결과 행마다 추가 SELECT를 날립니다. 모든 연관관계는 LAZY로 선언하고, 필요한 경우만 fetch join으로 명시적으로 로딩하는 것이 정석입니다.
| 구분 | LAZY | EAGER |
|---|---|---|
| 로딩 시점 | 실제 필드 접근 시 | 엔티티 조회 시 즉시 |
| 기본 적용 대상 | @OneToMany, @ManyToMany |
@ManyToOne, @OneToOne |
| JPQL 사용 시 | 명시적 fetch join 필요 시 추가 쿼리 | 자동으로 추가 SELECT → N+1 위험 |
| 권장 여부 | ✅ 항상 LAZY로 설정 권장 | ❌ 실무에서 지양, 예외적으로만 사용 |
03 3가지 해결 전략
① fetch join
JPQL / QueryDSL
- 단건 쿼리로 JOIN 조회
- 컬렉션 페이징 불가
- 직접 쿼리 작성 필요
② @BatchSize
IN절 배치 조회
- 설정 한 줄로 적용
- 페이징 가능
- 쿼리 수 N→1~2로 감소
③ @EntityGraph
Repository 어노테이션
- 메서드별 fetch 전략 지정
- 내부적으로 fetch join
- 복잡한 쿼리엔 부적합
① fetch join — JPQL / QueryDSL
JPQL에서 JOIN FETCH 키워드를 사용해 연관 엔티티를 한 번의 쿼리로 함께 로딩합니다. 가장 직접적이고 강력한 방법입니다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("select distinct m from Member m join fetch m.orders")
List<Member> findAllWithOrders();
// 다중 연관 fetch join (OneToMany 2개 이상이면 MultipleBagFetchException 주의)
@Query("select distinct m from Member m"
+ " join fetch m.orders o"
+ " join fetch o.items")
List<Member> findAllWithOrdersAndItems();
}Java
-- 실제 실행 SQL (fetch join 적용 후)
SELECT DISTINCT m.*, o.*
FROM member m
INNER JOIN orders o ON o.member_id = m.id
-- 단 1번의 쿼리로 모든 데이터 조회!SQL
⚠ fetch join + 페이징 = 위험!
@OneToMany fetch join에 페이징을 함께 사용하면 Hibernate는 전체 데이터를 메모리에 올린 뒤 애플리케이션에서 페이징합니다. 데이터가 많으면 OOM이 발생할 수 있습니다. 컬렉션 fetch join은 페이징과 함께 쓰지 마세요.⚠ MultipleBagFetchException
List 타입의 @OneToMany 연관이 2개 이상일 때 동시에 fetch join하면 예외가 발생합니다. 해결책: ① 컬렉션 타입을 Set으로 변경, ② 하나만 fetch join하고 나머지는 @BatchSize로 처리.② @BatchSize — IN절 배치 조회
N+1 쿼리를 WHERE id IN (?, ?, ...) 형태로 묶어서 한 번의 쿼리로 처리합니다. fetch join보다 코드 변경이 적고, 페이징과 함께 사용할 수 있습니다.
@Entity
public class Member {
@BatchSize(size = 100) // 한 번에 최대 100개의 member_id를 IN절로 조회
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders;
}
// SELECT * FROM member (1번)
// SELECT * FROM orders WHERE member_id IN (1번)
// (1, 2, 3, ..., 100) → 총 2번으로 해결!Java
전역 설정 (실무 권장)
# application.yml — 모든 LAZY 컬렉션에 일괄 적용
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100 # 전역 설정 (100~1000 권장)YAML
✅ default_batch_fetch_size 권장 값
보통 100~1000 사이가 적당합니다. 대부분의 실무에서
보통 100~1000 사이가 적당합니다. 대부분의 실무에서
default_batch_fetch_size: 100을 전역 설정으로 걸어두는 것을 권장합니다.③ @EntityGraph
Repository 메서드에 @EntityGraph를 붙여 특정 연관만 fetch join으로 로딩합니다. 내부적으로 fetch join SQL을 생성합니다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@EntityGraph(attributePaths = "orders")
List<Member> findAll();
@EntityGraph(attributePaths = {"orders", "orders.items"})
Optional<Member> findById(Long id);
@EntityGraph(attributePaths = "orders")
@Query("select m from Member m where m.name = :name")
Optional<Member> findByNameWithOrders(@Param("name") String name);
}Java
⚠ @EntityGraph는 내부적으로 LEFT OUTER JOIN
fetch join은
fetch join은
INNER JOIN인 반면 @EntityGraph는 LEFT OUTER JOIN을 사용합니다. 연관 데이터가 없어도 부모 엔티티가 조회됩니다. 또한 컬렉션 + 페이징을 함께 쓰면 메모리 페이징 문제가 발생합니다.04 전략별 비교 및 선택 기준
쿼리 수 비교 (회원 100명, 주문 포함 조회)
N+1 (미해결)
EAGER (JPQL)
@EntityGraph
fetch join
@BatchSize
| 전략 | 쿼리 수 | 페이징 | 다중 컬렉션 | 권장 상황 |
|---|---|---|---|---|
| fetch join | 1번 | ❌ 위험 | ❌ MultipleBag | 단건 상세 조회, 페이징 불필요한 목록 |
| @BatchSize | 1~2번 | ✅ 가능 | ✅ 가능 | 페이징 목록 조회, 다중 컬렉션 |
| @EntityGraph | 1번 | ❌ 위험 | △ 주의 | 단순한 Repository 메서드에 fetch 추가 |
| QueryDSL fetch join | 1번 | ❌ 위험 | ❌ MultipleBag | 복잡한 동적 쿼리 + 연관 함께 조회 |
✅ 실무 권장 조합
전역 default_batch_fetch_size: 100 설정을 기본으로 깔고, 성능이 중요한 특정 조회는 fetch join 또는 QueryDSL로 최적화합니다. 이 두 가지 조합이 대부분의 N+1 문제를 커버합니다.
전역 default_batch_fetch_size: 100 설정을 기본으로 깔고, 성능이 중요한 특정 조회는 fetch join 또는 QueryDSL로 최적화합니다. 이 두 가지 조합이 대부분의 N+1 문제를 커버합니다.
05 실무에서 자주 하는 실수
실수 1 — DTO 변환 위치가 잘못된 경우
// ❌ 트랜잭션 밖에서 DTO 변환 → LazyInitializationException
@GetMapping("/members")
public List<MemberDto> getMembers() {
List<Member> members = memberService.findAll(); // 트랜잭션 종료
return members.stream()
.map(m -> new MemberDto(m.getName(), m.getOrders().size())) // ← BOOM!
.toList();
}
// ✅ 서비스 내부(트랜잭션 안)에서 DTO 변환
@Transactional(readOnly = true)
public List<MemberDto> findAllDto() {
return memberRepository.findAll().stream()
.map(m -> new MemberDto(m.getName(), m.getOrders().size()))
.toList();
}Java
실수 2 — fetch join 후 distinct를 빠뜨리는 경우
// ❌ distinct 없이 fetch join — member가 주문 수만큼 중복 반환
@Query("select m from Member m join fetch m.orders")
List<Member> findAllWithOrders();
// ✅ JPQL에 distinct 추가 (DB DISTINCT + JPA 레벨 중복 제거 동시 적용)
@Query("select distinct m from Member m join fetch m.orders")
List<Member> findAllWithOrders();
// Spring Data JPA 3.x (Hibernate 6)부터는 자동 중복 제거되어 distinct 불필요Java
실수 3 — 카운트 쿼리에도 fetch join이 들어가는 경우
// ✅ countQuery를 별도로 분리 (카운트는 JOIN 없이 단순하게)
@Query(
value = "select m from Member m join fetch m.orders",
countQuery = "select count(m) from Member m"
)
Page<Member> findAll(Pageable pageable);Java
실수 4 — N+1이 발생하는 줄 모르고 운영 배포
# application.yml — 개발 환경에서 SQL 로그 설정 (반드시 켜두기)
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql: TRACEYAML
06 면접 예상 질문
Q1. N+1 문제가 무엇인지 설명하고 해결 방법을 말해보세요.
N+1 문제는 1번의 쿼리로 N개의 엔티티를 조회한 뒤, 각 엔티티의 연관 데이터를 가져오기 위해 N번의 추가 쿼리가 발생하는 성능 문제입니다. 해결 방법은 세 가지입니다. 첫째, fetch join — JPQL에서 JOIN FETCH로 연관을 한 번에 조회하지만 컬렉션 페이징과 함께 쓸 수 없습니다. 둘째, @BatchSize — LAZY 로딩 시 IN절로 묶어 한 번에 조회하며 페이징과 함께 사용 가능합니다. 셋째, @EntityGraph — Repository 메서드에 어노테이션으로 fetch join을 적용하지만 내부적으로 LEFT OUTER JOIN을 사용합니다.
Q2. EAGER 로딩을 사용하면 N+1이 해결되지 않나요?
오히려 EAGER가 N+1의 주범이 될 수 있습니다. JPQL로 조회 시 Hibernate는 JPQL을 그대로 SQL로 변환하고, 결과를 매핑할 때 EAGER 연관을 확인해 각 행마다 추가 SELECT를 날립니다. 따라서 모든 연관관계는 LAZY로 설정하고 필요한 경우에만 fetch join으로 명시적으로 로딩하는 것이 정석입니다.
Q3. fetch join과 @BatchSize를 어떤 기준으로 선택하나요?
페이징이 필요하면 @BatchSize, 페이징 없이 한번에 조회하면 fetch join을 선택합니다. 실무에서는
default_batch_fetch_size: 100을 전역 설정으로 걸고, 성능이 중요한 쿼리는 fetch join으로 최적화하는 혼합 전략을 많이 씁니다.Q4. fetch join 시 왜 distinct가 필요한가요?
@OneToMany fetch join 시 DB 레벨에서 JOIN 결과로 행이 중복됩니다. 예를 들어 회원 1명에 주문 3개가 있으면 JOIN 결과가 3행이 되어 같은 member 엔티티가 3개 반환됩니다. JPQL의 distinct는 DB SQL에 DISTINCT를 추가함과 동시에 JPA 레벨에서도 동일 엔티티의 중복을 제거합니다. 단 Hibernate 6 이상(Spring Boot 3.x)부터는 자동으로 중복을 제거해 불필요합니다.Q5. @EntityGraph와 fetch join의 차이점은?
가장 큰 차이는 JOIN 종류입니다. fetch join은
INNER JOIN을 사용해 연관 데이터가 없는 부모 엔티티는 결과에서 제외됩니다. 반면 @EntityGraph는 LEFT OUTER JOIN을 사용해 연관 데이터가 없어도 부모 엔티티를 포함합니다.핵심 요약
- N+1 원인 — LAZY 프록시를 루프에서 초기화하거나, EAGER + JPQL 사용 시 발생
- 모든 연관관계는 LAZY로 선언하고, 필요한 경우만 명시적으로 로딩
- 페이징 없는 조회 → fetch join / @EntityGraph 사용
- 페이징 있는 조회 → default_batch_fetch_size 전역 설정 사용
- 컬렉션 fetch join + 페이징은 메모리 페이징 → OOM 위험, 절대 혼용 금지
- 실무 권장 → default_batch_fetch_size: 100 전역 설정 + 핵심 쿼리 fetch join
오늘의 핵심 — 연관관계는 항상 LAZY. 페이징 없으면 fetch join, 페이징 있으면 BatchSize. 개발 중엔 SQL 로그를 항상 켜서 N+1을 눈으로 확인하자.
728x90
'스프링' 카테고리의 다른 글
| @Transactional 동작 원리 / AOP 프록시 · self-invocation · readOnly 최적화 (0) | 2026.04.16 |
|---|---|
| @Transactional 동작 원리AOP 프록시 · self-invocation · readOnly 최적화 (0) | 2026.04.15 |
| 트랜잭션 전파(Propagation) &격리 수준(Isolation Level) (0) | 2026.04.10 |
| 스프링 인증/인가..! JWt..? (0) | 2024.09.10 |
| Spring 인증 및 관리 시스템 (0) | 2024.09.06 |