연관된 Entity를 언제 가져올 것인가? — Fetch Type의 핵심을 이해해 봅니다.
JPA에서 연관관계가 설정된 Entity를 조회할 때, 연관 Entity의 데이터를 언제 가져올지 선택할 수 있습니다.
이 설정을 Fetch Type 이라고 하며, 두 가지 방식이 있습니다.
| Fetch Type | 설명 |
|---|---|
EAGER (즉시 로딩) | 주 Entity 조회 시, 연관된 Entity를 즉시 함께 가져옵니다 |
LAZY (지연 로딩) | 주 Entity만 먼저 가져오고, 연관 Entity는 실제로 필요할 때 가져옵니다 |
JPA의 연관관계 Annotation은 각각 기본 Fetch Type이 정해져 있습니다.
| 애너테이션 | 기본 Fetch Type | 이유 |
|---|---|---|
@OneToMany | LAZY | 컬렉션 단위 조회 → 데이터가 많을 수 있어 지연 로딩이 효율적 |
@ManyToOne | EAGER | 단일 Entity → 바로 가져와도 부담이 적음 |
@OneToOne | EAGER | 단일 Entity |
@ManyToMany | LAZY | 컬렉션 단위 조회 |
Many가 붙으면 컬렉션 → LAZYOne이 붙으면 단일 → EAGER가 기본값입니다.아래 예시를 보겠습니다. Food와 User가 @ManyToOne (기본값: EAGER) 관계일 때, 단순히 음식 하나를 조회해 봅니다.
@Test
@DisplayName("아보카도 피자 조회")
void test1() {
Food food = foodRepository.findById(2L).orElseThrow(NullPointerException::new);
System.out.println("food.getName() = " + food.getName());
// 아직 User 정보를 사용하지 않았는데도...
System.out.println("food.getUser().getName() = " + food.getUser().getName());
}
음식 가격만 조회하려 했을 뿐인데, JPA가 자동으로 JOIN 쿼리를 실행하여 고객(User) 테이블까지 함께 조회합니다.
Hibernate:
select
f1_0.id,
f1_0.name,
f1_0.price,
u1_0.id,
u1_0.name
from
food f1_0
left join //findBuId (2) 만 했는데 자체적으로 User를 Join한 상황
users u1_0
on u1_0.id=f1_0.user_id
where
f1_0.id=?
food.getName() = 아보카도 피자
food.getUser().getName() = Kaneda
-- 원하는 것: food 테이블만 조회
-- 실제로 실행되는 것: food + user 테이블 JOIN 조회
SELECT food.*, user.*
FROM food
LEFT OUTER JOIN users user ON food.user_id = user.id
WHERE food.id = 2;
연관관계가 복잡하게 얽혀 있을수록 이 문제가 심각해집니다. 필요하지 않은 데이터까지 매번 불러오게 되어 성능에 악영향을 줍니다.
이것이 바로 EAGER 로딩을 남용하면 안 되는 이유입니다.
User와 Food가 @OneToMany (기본값: LAZY) 관계인 경우를 살펴봅니다.
@Entity
@Table(name = "users")
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// 기본값이 LAZY — 필요한 시점까지 데이터를 가져오지 않습니다
@OneToMany(mappedBy = "user")
private List<Food> foodList = new ArrayList<>();
}
@Test
@Transactional
@DisplayName("Kaneda 고객 조회")
void test2() {
// 1단계: User만 조회 (SELECT * FROM users WHERE name = 'Kaneda')
User user = userRepository.findByName("Kaneda");
System.out.println("user.getName() = " + user.getName());
System.out.println("Kaneda가 주문한 음식 이름 조회");
// 2단계: 실제로 foodList에 접근하는 시점에 추가 쿼리 실행
// (SELECT * FROM food WHERE user_id = ?)
for (Food food : user.getFoodList()) {
System.out.println(food.getName());
}
}
user.getFoodList()를 호출하는 순간, 그제서야 음식 테이블에 대한 SELECT 쿼리가 실행됩니다.
필요한 데이터만, 필요한 시점에 가져오는 것이 지연 로딩의 핵심입니다.
지연 로딩은 영속성 컨텍스트의 기능 중 하나입니다.
따라서 지연 로딩된 Entity 정보에 접근할 때, 반드시 영속성 컨텍스트(= 트랜잭션)가 살아있어야 합니다.
@Test
// @Transactional 없음 — 영속성 컨텍스트가 존재하지 않는 상태
@DisplayName("Kaneda 고객 조회 실패")
void test3() {
User user = userRepository.findByName("Kaneda");
System.out.println("user.getName() = " + user.getName()); // 여기까지는 OK
// foodList에 접근하는 순간 오류 발생
// LazyInitializationException: could not initialize proxy - no Session
for (Food food : user.getFoodList()) {
System.out.println(food.getName());
}
}
findByName() 호출 후 트랜잭션이 종료되면서 영속성 컨텍스트도 함께 닫힙니다.
이 상태에서 지연 로딩된 foodList에 접근하면 LazyInitializationException이 발생합니다.
지연 로딩을 사용할 때는 항상 트랜잭션 범위 안에서 연관 데이터에 접근해야 합니다.
// 권장 패턴: 모든 연관관계는 LAZY로 명시적으로 설정
@ManyToOne(fetch = FetchType.LAZY) // 기본값이 EAGER이므로 명시 필요
@JoinColumn(name = "user_id")
private User user;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY) // 기본값과 동일하지만 명시 권장
private List<Food> foodList = new ArrayList<>();
필요한 경우에만 연관 데이터를 불러오는 것이 성능 면에서 유리합니다.
LAZY로 설정해도 여러 연관 Entity를 한 번에 가져와야 할 때가 있습니다.
이때는 fetch join을 사용하면 쿼리 한 번으로 해결할 수 있습니다.
// Repository에서 fetch join 사용 예시
@Query("SELECT u FROM User u JOIN FETCH u.foodList WHERE u.name = :name")
User findByNameWithFoods(@Param("name") String name);
지연 로딩을 사용하다 보면 N+1 문제를 마주치게 됩니다.
이는 JPA를 사용할 때 가장 흔하게 발생하는 성능 문제 중 하나입니다.
// 문제 상황
List<User> users = userRepository.findAll(); // 쿼리 1번 (N명의 유저 조회)
for (User user : users) {
// 각 유저마다 foodList 조회 쿼리가 1번씩 추가 실행됨
// 유저가 10명이면 → 총 쿼리 1 + 10 = 11번 실행
user.getFoodList().forEach(food -> System.out.println(food.getName()));
}
유저 1명을 조회하는 쿼리 1번 + 각 유저의 음식 목록을 조회하는 쿼리 N번 = N+1 문제
해결 방법:
@EntityGraph 애너테이션 사용fetch join 사용@BatchSize 설정으로 IN 쿼리로 일괄 처리// @EntityGraph를 활용한 N+1 해결
@EntityGraph(attributePaths = {"foodList"})
@Query("SELECT u FROM User u")
List<User> findAllWithFoods();
Spring Data JPA 3.x / Spring Boot 3.x 환경
javax.persistence.* → jakarta.persistence.*)@EntityGraph와 Querydsl, Spring Data JPA의 Projection을 조합하는 패턴이 널리 사용됩니다.레거시 환경 (Spring Boot 2.x / Hibernate 5.x)
레거시 환경에서는 javax.persistence.* 패키지를 사용하며, Hibernate 5.x의 동작 방식을 이해하는 것도 여전히 중요합니다. 라이브러리 버전에 따라 동작 방식의 미묘한 차이가 있을 수 있으므로, 프로젝트 환경 확인이 필요합니다.
| 항목 | 내용 |
|---|---|
| LAZY | 필요한 시점에 쿼리 실행 |
| EAGER | 주 Entity 조회 시 즉시 JOIN 실행 |
| 기본값 | ToMany → LAZY, ToOne → EAGER |
| 지연 로딩 조건 | 반드시 트랜잭션(영속성 컨텍스트) 필요 |
| 권장 전략 | 전체 LAZY + 필요 시 fetch join |
| 주의 사항 | N+1 문제 항상 주의 |