JPA에서 연관된 엔티티를 언제 가져올지 결정하는 것이 Fetch 전략이다. 이걸 제대로 이해하지 못하면 쿼리가 폭발하거나, 반대로 필요한 데이터를 가져오지 못해 예외가 터진다. 초보 단계에서 반드시 짚고 가야 할 개념이다.
- N+1 문제와 해결 전략 — Fetch 전략 설정 이후 발생하는 쿼리 폭발 문제와 해결법
Fetch 전략이란
엔티티를 조회할 때 연관된 데이터를 즉시 가져올 것인가, 아니면 나중에 필요할 때 가져올 것인가를 정하는 설정이다.
예를 들어 Feed를 조회할 때, Feed가 가진 author(User) 정보를 같이 가져올 것인가 말 것인가를 결정한다.
JPA는 두 가지 전략을 제공한다.
- EAGER : 즉시 로딩. 엔티티를 조회하는 시점에 연관 데이터도 함께 가져온다.
- LAZY : 지연 로딩. 엔티티만 먼저 가져오고, 연관 데이터는 실제로 접근하는 시점에 가져온다.
EAGER — 즉시 로딩
@ManyToOne(fetch = FetchType.EAGER)
private User author;
Feed를 조회하면 JPA가 즉시 JOIN을 걸어 User도 함께 불러온다.
SELECT f.*, u.*
FROM feed f
JOIN user u ON f.author_id = u.id
WHERE f.id = 1
데이터를 한 방에 가져오니까 좋아 보인다. 하지만 문제가 있다.
목록을 조회하는 상황을 생각해보자.
List<Feed> feeds = feedRepository.findAll();
Feed가 100개면 JPA는 각 Feed마다 User를 가져오기 위해 추가 쿼리를 100번 날린다. 이것이 N+1 문제다. 단건 조회에서는 괜찮지만, 목록 조회에서는 성능 재앙으로 이어진다.
@ManyToOne의 기본값이 EAGER다. 아무것도 설정하지 않으면 즉시 로딩으로 동작한다. 자신도 모르는 사이에 성능 문제를 만들어내고 있을 수 있다.
LAZY — 지연 로딩
@ManyToOne(fetch = FetchType.LAZY)
private User author;
Feed를 조회할 때는 Feed만 가져온다.
SELECT * FROM feed WHERE id = 1
author 필드는 실제 User 객체가 아니라 프록시 객체로 채워진다. 겉보기엔 User처럼 생겼지만 내부는 비어있는 껍데기다. 실제로 접근하는 순간 쿼리를 날린다.
Feed feed = feedRepository.findById(1L); // 쿼리 1번 (Feed만)
String name = feed.getAuthor().getName(); // 이 줄에서 User 쿼리 1번
getAuthor()를 호출하는 시점에 비로소 DB에서 User를 가져온다.
JPA가 만들어주는 가짜 객체다. User를 상속한 서브클래스로 만들어지며, 내부적으로 실제 User를 가리키는 참조를 가진다. 처음엔 비어있다가 필드에 접근하는 순간 DB 쿼리를 실행해 실제 데이터를 채운다. 이 메커니즘 덕분에 지연 로딩이 가능하다.
기본값 정리
| 어노테이션 | 기본 Fetch | 권장 설정 |
|---|---|---|
@ManyToOne | EAGER | LAZY |
@OneToOne | EAGER | LAZY |
@OneToMany | LAZY | LAZY 유지 |
@ManyToMany | LAZY | LAZY 유지 |
@ManyToOne과 @OneToOne의 기본값이 EAGER라는 점이 핵심이다. 나머지는 기본값이 LAZY라 별도 설정이 필요 없다.
왜 이 두 어노테이션만 EAGER가 기본값인지는 ManyToOne과 OneToOne의 기본값이 EAGER인 이유 참고.
실무 원칙
모든 연관관계는 LAZY로 설정하는 것이 표준이다.
LAZY로 설정하면 각 상황에 맞게 필요한 데이터만 가져올 수 있다. 필요할 때 fetch join이나 @EntityGraph로 한 번에 조회하면 된다.
// 필요한 경우에만 fetch join으로 한 번에 가져온다
@Query("SELECT f FROM Feed f JOIN FETCH f.author WHERE f.id = :id")
Feed findWithAuthor(@Param("id") Long id);
JOIN FETCH를 쓰면 LAZY로 설정했더라도 해당 쿼리에서는 즉시 JOIN해서 가져온다. 이렇게 하면 N+1 문제 없이 필요한 데이터를 정확히 가져올 수 있다.
자주 하는 실수
JPA 엔티티에 @ManyToOne만 쓰면 기본값이 EAGER다. 의도치 않게 즉시 로딩이 적용된다. 목록 조회 API에서 쿼리가 폭발하는 원인이 된다.
```java
// 위험: 기본값 EAGER
@ManyToOne
private User author;
// 안전: 명시적으로 LAZY 설정
@ManyToOne(fetch = FetchType.LAZY)
private User author;
```
[!BUG] 트랜잭션 밖에서 LAZY 접근하는 실수
LAZY로 설정한 필드는 트랜잭션 안에서만 접근할 수 있다. 트랜잭션이 종료된 뒤 프록시에 접근하면 LazyInitializationException이 발생한다.
```java
// 트랜잭션이 끝난 후 author에 접근하면 예외 발생
Feed feed = feedService.getFeed(1L); // 서비스 메서드 종료 = 트랜잭션 종료
feed.getAuthor().getName(); // LazyInitializationException!
```
해결책은 두 가지다. 트랜잭션 안에서 필요한 데이터를 미리 DTO로 변환하거나, fetch join으로 한 번에 가져오면 된다.
[!BUG] EAGER가 더 효율적이라고 착각하는 실수
"어차피 author를 항상 쓰니까 EAGER가 낫지 않나?"라는 생각은 틀렸다. 단건 조회에서는 큰 차이가 없어 보여도, 목록 조회에서 N+1이 발생한다. 항상 필요하다면 LAZY + fetch join이 훨씬 명시적이고 안전하다.
면접 Q&A
@ManyToOne의 기본 Fetch 전략은?EAGER다. 반드시 fetch = FetchType.LAZY를 명시해줘야 한다. @OneToMany는 기본값이 LAZY다.
[!QUESTION] LAZY 로딩이 동작하는 원리는?
JPA가 프록시 객체를 만들어 필드에 주입한다. 프록시는 실제 엔티티를 상속한 서브클래스로, 필드에 처음 접근하는 순간 DB 쿼리를 실행해 데이터를 채운다.
[!QUESTION] LAZY로 설정하면 N+1 문제가 해결되나?
아니다. LAZY로 설정해도 반복문 안에서 연관 필드에 접근하면 N+1은 그대로 발생한다. N+1 해결은 fetch join이나 @EntityGraph로 별도로 처리해야 한다. 자세한 내용은 N+1 문제와 해결 전략 참고.
[!QUESTION] LAZY 필드에 접근할 때 LazyInitializationException이 발생했다. 이유는?
트랜잭션이 종료된 뒤 프록시에 접근했기 때문이다. 영속성 컨텍스트가 닫혀 DB 조회를 할 수 없는 상태에서 LAZY 필드를 건드리면 발생한다. 트랜잭션 범위를 늘리거나, 서비스 계층에서 DTO로 변환해서 반환해야 한다.