본문 바로가기

Back-End/JPA

@Fetch(EAGER)와 fetch join 차이와 LAZY 로딩의 작동 방식

1. 즉시 로딩의 두 종료

우선 즉시 로딩이란?

A 엔티티를 로딩할 때, A와 연결된 연관 엔티티 B도 함께 가져오는 것을 의미한다.

 

즉시 로딩을 구현하는 방법은 JPA에서 2가지로 구분할 수 있다.

방법 설명 예시 제어 가능성
EAGER 어노테이션으로 고정된 즉시 로딩 @ManyToOne(fetch = EAGER) 매우 제한적
fetch join JPQL 쿼리로 지정된 즉시 로딩 JOIN FETCH 동적으로 선택 가능

 


2. EAGER는 고정, fetch join은 선택 가능

EAGER는 선언한 시점부터 항상 즉시 로딩된다.

즉 우리가 어떤 쿼리를 쓰든 상관없이 해당 연관 엔티티는 무조건 같이 가져오게 된다.

@ManyToOne(fetch = FetchType.EAGER)
private User user;

위처럼 설정해두면 commentRepository.findById(1L)이든 .findBySchedule(...)이든 항상 user가 join되어 함께 로딩 된다.

문제는 이걸 제어할 수 없다는 점이다.

join이 필요하지 않은 쿼리에도 무조건 붙기 때문에 불필요한 데이터 로딩과 성능 저하, 예측 불가능한 SQL로 이어질 수 있다.


하지만 반대로 fetch join은 JPQL에서 명시적으로 join할 때만 즉시 로딩을 수행한다.

@Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.schedule.id = :id")
List<Comment> findByScheduleWithUser(@Param("id") Long scheduleId);

이 경우에는 Comment와 User가 함께 로딩된다.

하지만 다른 쿼리에서는 명시하지 않는 한 로딩되지 않는다.

 

즉 fetch join은 "내가 원하는 시점에만 즉시 로딩을 명령할 수 있다."


3. 실제 예제로 살펴보기

엔티티 구성

@Entity
public class Comment {
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    private Schedule schedule;
}

이 경우 user와 schedule은 모두 지연 로딩이다.


쿼리 1: 댓글만 필요할 때

@Query("SELECT c FROM Comment c WHERE c.schedule.id = :id")
List<Comment> findCommentsOnly(@Param("id") Long scheduleId);

➡️ 이 쿼리는 Comment만 로딩하고 User는 전혀 로딩하지 않는다.

➡️ comment.getUser()를 호출하는 순간에야 지연 로딩을 위해 SELECT 쿼리가 발생한다.


쿼리 2: 댓글과 작성자 이름도 필요할 때

@Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.schedule.id = :id")
List<Comment> findCommentWithUSer(@Param("id") Long scheduleId);

➡️ JOIN FETCH가 명시돼 있으므로 Comment와 User가 같은 쿼리 안에서 즉시 로딩된다.


4. LAZY의 진짜 작동 방식

그렇다면 LAZY는 정확히 어떻게 동작할까?

@ManyToOne(fetch = FetchType.LAZY)
private USer user;
Comment comment = commentRepository.findById(1L).orElseThrow();
User user = comment.getUser(); // 아직 로딩 안 됨.
log.info(user.getUsername()); // 이 시점에 SELECT 발생
  • 처음 comment.getUser()를 호출할 때는 프록시 객체가 반환된다.
  • 진짜 DB 접근은 getUsername() 같은 구체적 필드에 접근할 때 발생한다.
  • 이게 바로 프록시 기반 지연 로딩이다.

5. 즉시 로딩 판단 기준 - 언제 fetch join을 쓰고 안 쓰는가?

상황 fetch join 사용 이유
댓글 목록 + 작성자 이름 O N+1 방지. 루프에서 연관 엔티티 접근
상세 페이지(게시글 + 작성자 + 댓글 + 댓글 작성자) 일부만 여러 조인을 동시에 하면 중복 row, 성능 저하 발생
REST API 응답 DTO X fetch join 대신 Projection 사용이 더 적절
페이징 처리(1:N) X 컬렉션 fetch join은 JPA 페이징과 충돌함
단건 조회 + 연관 필드 꼭 필요 O 그 시점에만 명확히 로딩

1) 댓글 목록 + 작성자 이름

상황

  • 댓글 여러 개를 리스트로 불러와서 화면에 "댓글 내용 + 작성자 이름"을 보여줘야 함.
List<Comment> comments = commentRepository.findByScheduleId(id);
for (Comment c : comments) {
    log.info(c.getUser().getUsername());
}

무슨 일이 일어나나? (LAZY라면)

  • comments는 1번 쿼리로 다 가져온다.
  • 근데 각 댓글의 .getUser()를 호출할 때마다 추가 쿼리 발생
    -> 댓글 10개 -> 유저 쿼리 10번 -> 총 11번 쿼리 발생 = N + 1 문제

해결 : fetch join 사용

@Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.schedule.id = :id")

-> 댓글 + 유저를 한 번의 쿼리로 로딩

-> .getUser() 호출해도 추가 쿼리 없음

 

결론

리스트에서 연관 엔티티에 접근할 예정이면, fetch join을 쓰자!


2) 상세 페이지( 게시글 + 작성자 + 댓글 + 댓글 작성자) -> 부분만 fetch join

상황

  • 게시글 상세 페이지에서 게시글 내용, 게시글 작성자, 댓글 리스트, 각 댓글의 작성자 정보도 필요

문제

@Query("""
  SELECT p FROM Post p
  JOIN FETCH p.author
  JOIN FETCH p.comments c
  JOIN FETCH c.user
  WHERE p.id = :id
""")
  • 이렇게 여러 fetch join을 한 번에 쓰면, Post x Comment 수 만큼 row가 중복돼서 나온다.
  • 댓글이 3개면 같은 Post가 3줄로 복제되어 메모리에 올라감
  • 컬렉션에 중복 삽입되거나, 페이징이 이상하게 작동할 수도 있다.

해결

  • Post와 Author만 fetch join 하고, 댓글은 @OneToMany 그대로 LAZY로 두고 따로 조회하거나
  • 댓글만 별도 쿼리로 뽑아서 DTO로 합쳐주는 방식

결론

1:N 관계에서 동시에 여러 개를 fetch join하면 성능과 중복 문제 생김
-> 부분적으로만 사용하자!


3. REST API 응답 DTO -> Fetch join 사용 X

상황

  • 백엔드에서 필요한 필드만 담은 DTO를 응답으로 내려주려함.
{
  "commentId": 1,
  "content": "좋아요",
  "username": "유저1"
}

 

문제

  • 굳이 엔티티 전체를 로딩할 필요 없다.
  • fetch join으로 불러와도 결국 DTO로 변환하는데 낭비가 발생
  • 연관 엔티티가 필요 없을 수도 있고, 단일 필드만 필요한 경우 많음

더 좋은 방법

@Query("""
  SELECT new com.example.CommentResponse(c.id, c.content, u.username)
  FROM Comment c JOIN c.user u
""")
List<CommentResponse> findCommentDtos();

-> 이렇게 DTO로 바로 가져오면 필요한 필드만 SELECT 하므로 성능, 용량 모두 최적화 가능

 

결론

API 응답을 위해 DTO만 필요할 때는 fetch join 말고 Projection(JPQL new) 쓰는게 낫다.


6. 결론 정리

  • EAGER는 항상 즉시 로딩되며, 제어가 어렵다.
  • fetch join은 JPQL에서만 명시적으로 작동하는 즉시 로딩이다.
  • LAZY는 실제 코드에서 접근할 때까지 로딩하지 않으며, 성능 최적화에 유리하다.
  • 실무에서는 대부분의 연관관계를 LAZY로 설정하고, 필요한 시점에 fetch join이나 @EntityGraph로 로딩을 제어한다.