[Spring] 즉시 로딩과 지연 로딩

2025. 9. 21. 02:15·Spring

들어가며

JPA에서 연관된 데이터를 읽어올 때 방식이 2가지이다. 

  • 즉시 로딩 (EAGER) : 엔티티를 조회할 때 연관된 것까지 한 번에 가져온다. 
  • 지연 로딩 (LAZY) : 엔티티만 먼저 가져오고, 필요해지는 시점에 연관 데이터를 추가로 가져온다. 

핵심은 '화면/요청 마다 필요한 데이터가 다르다'는 것이다. 그래서 보통은 LAZY로 깔고, 진짜 필요한 화면에서만 묶어서 가져오도록 한다. 


개념 

`FetchType` 은 JPA가 하나의 엔티티를 조회할 때, 연관관계에 있는 객체들을 어떻게 가져올 것이냐를 나타내는 설정값이다. 

추가적으로, 

`cascade` 는 부모를 저장이나 삭제할 때 자식에도 동일하게 동작을 전파하는 것이다. 그래서 `cascade = CascadeType.ALL` 은 부모를 저장하거나 삭제하면 자식도 함께 저장되고 삭제되는 것을 말한다. 

`orphanRemoval = true`는 컬렉션에서 자식을 빼버리면 db에서도 삭제를 하는 것이다. 


즉시 로딩 (EAGER) 

데이터를 조회할 때 연관된 데이터까지 한 번에 불러오는 것을 말한다. 

 

예를 들어, Member 엔티티와 Team 엔티티가 다대일 연관관계를 갖는다고 가정해보자. 

@Entity
public class Member {

    @Id @
    GeneratedValue
    private Long id;
    
    private String username;

    // 여기서 즉시로딩을 사용해보겠다 ! 
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "team_id")
    Team team;
}

@Entity
public class Team {

    @Id 
    @GeneratedValue
    private Long id;
    
    private String teamname;
}

JPQL로 Member 1건을 조회한다고 가정하면, 

Member member = em.createQuery("select m from Member m', Member.class).getSingleResult();

실제 SQL 코드는 다음과 같다. 

//멤버를 조회하는 쿼리
select
    member0_.id as id1_0_,
    member0_.team_id as team_id3_0_,
    member0_.username as username2_0_ 
from
    Member member0_

//팀을 조회하는 쿼리
select
    team0_.id as id1_3_0_,
    team0_.name as name2_3_0_ 
from
    Team team0_ 
where
    team0_.id=?

즉, 즉시로딩 방식을 사용하면 Member를 조회하는 시점에 바로 Team까지 불러오는 쿼리를 날려 한꺼번에 데이터를 불러오는 것을 볼 수 있다. 


지연 로딩 (LAZY) 

필요한 시점에 연관된 데이터를 불러오는 것을 말한다. 

위에 즉시 로딩과 같은 예시를 지연 로딩으로만 바꾸어서 해보면, 실제 SQL 코드는 

select
    member0_.id as id1_0_,
    member0_.team_id as team_id3_0_,
    member0_.username as username2_0_ 
from
    Member member0_

다음과 같으며 Member를 조회하는 시점에 Team을 조회하는 쿼리가 나가지 않는다. 

 

만약 Member를 조회하는 JPQL을 날렸는데, 한 명의 Member에 연관된 Team이 100개가 있다면 Member를 조회하는 쿼리를 하나 날렸을 뿐인데 Team을 조회하는 SQL 쿼리 100개가 추가로 나가게 된다. 이게 바로 N+1 문제이고, 그렇기 때문에 기본적으로는 지연로딩을 사용하는 것이 적합하다. 

 

프록시와 함께 

LAZY는 내부적으로 '프록시'를 사용한다. LAZY로 설정된 연관 필드에는 진짜 엔티티 대신 프록시가 먼저 들어오고, 필드를 실제로 읽는 순간 DB에 쿼리를 날려서 진짜를 채운다. 

 

언제 생기나 ? 

1) 연관관계를 LAZY로 두면 자동으로 생긴다. 

@ManyToOne(fetch = FetchType.LAZY)
private Team team;  // ← 여기에 Team 프록시가 먼저 들어옴

2) `getMemberById(id)` : Spring Data 에서 `getMemberById` 로 아이디만 아는 엔티티를 가져올 때, 추가 SELECT 없이 프록시를 돌려준다. 

 

어떻게 동작하나 ? 

Member member = memberRepository.findById(id).orElseThrow();

Team team = member.getTeam();     // 아직 쿼리 안 나갈 수 있음(프록시 상태)
Long teamId = team.getId();  // 보통 추가 쿼리 없음(이미 FK로 앎)
String name = team.getName();// ← 이 순간 처음으로 SELECT 나가며 '초기화'

올바른 사용 패턴 

1) 기본 

모든 연관은 LAZY로 명시한다. 

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;

서비스 (트랜잭션) 안에서만 엔티티를 다루고, 컨트롤러 응답은 엔티티를 직접 노출하지 않고 DTO로 변환해서 내보낸다. 

// DTO
public record MemberDto(Long id, String name, String teamName) {}

// 서비스
@Service
@RequiredArgsConstructor
public class MemberService {
  private final MemberRepository memberRepository;

  @Transactional(readOnly = true)
  public MemberDto getOne(Long id) {
    Member m = memberRepository.findById(id).orElseThrow();
    return new MemberDto(m.getId(), m.getUsername(), m.getTeam().getTeamname());
  }
}

LAZY로 깔아두면 화면이나 요건별로 필요한 것들만 가져오도록 설계할 수 있다. 

2) 조회 최적화 

A. `@EntityGraph` 

리파지토리 메서드에 '연관도 같이 로딩해.' 라고 선언하는 방식이다. 간단한 코드로 N+1을 해결할 수 있다. 

메서드 쿼리를 사용하면서 간단히 연관 하나를 가져오고 싶을 때 주로 사용한다. 

public interface MemberRepository extends JpaRepository<Member, Long> {

  @EntityGraph(attributePaths = "team") // 이 메서드로 조회할 때 team 연관도 같이 로딩해라는 선언
  List<Member> findByUsernameStartingWith(String prefix);

  @EntityGraph(attributePaths = {"team", "profile"}) // 여러 연관이 필요하면 배열로
  List<Member> findByAgeGreaterThan(int age);
}

B. JPQL `fetch join` 

JPQL에  `join fetch`를 붙여 한 번의 SQL로 연관을 가져오는 방법이다. N+1을 피하기에 가장 직관적이다. 특정 화면에서 반드시 함께 필요한 연관이 있을 경우에 주로 사용한다. 쿼리 하나로 N+1을 해결할 수 있어 간단하다. 

 

아래 메서드는 이름이 kw로 시작하는 회원들을 찾되, 각 회원의 Team까지 한 번에 가져와라 라는 뜻이다. 

public interface MemberRepository extends JpaRepository<Member, Long> {

  @Query("""
    select m
    from Member m
    join fetch m.team
    where m.username like concat(:kw, '%')
  """)
  List<Member> findWithTeam(@Param("kw") String keyword);
}

C. 페이징

나이가 age 이상인 회원들을 페이지로 가져오되, 각 회원의 팀까지 한 번에 로딩하는 쿼리이다. 그리고 페이징 정보도 같이 가져오도록 한다. 

@Query(
  value = """
    select m
    from Member m
    join fetch m.team
    where m.age >= :age
    order by m.id desc
  """,
  countQuery = """
    select count(m)
    from Member m
    join m.team t
    where m.age >= :age
  """
)
Page<Member> findPageWithTeam(@Param("age") int age, Pageable pageable);

 

 

'Spring' 카테고리의 다른 글

[Spring] N+1 문제  (0) 2025.10.03
[Spring] 객체지향 쿼리 언어  (0) 2025.10.02
[Spring] Transactional 사용 시 자기 호출(Self-Invocation) 이슈  (0) 2025.09.11
[Spring] Spring Events 사용해 이벤트 발행하기  (0) 2025.09.07
[Spring] Setter 사용을 지양해야하는 이유  (0) 2025.08.29
'Spring' 카테고리의 다른 글
  • [Spring] N+1 문제
  • [Spring] 객체지향 쿼리 언어
  • [Spring] Transactional 사용 시 자기 호출(Self-Invocation) 이슈
  • [Spring] Spring Events 사용해 이벤트 발행하기
erika0915
erika0915
백엔드 개발자가 되고 싶어요 .
  • erika0915
    erikoding
    erika0915
  • 전체
    오늘
    어제
    • 분류 전체보기 (78)
      • 프로젝트 (13)
        • 끼니콩 (3)
        • 덕메랑 (3)
        • handDoc (7)
        • Haeil (0)
      • Java (9)
        • 클린코더스 (0)
      • Spring (30)
      • Redis (3)
      • CS (7)
        • 운영체제 (3)
        • 컴퓨터구조 (0)
        • 네트워크 (4)
      • DevOps (2)
      • 코딩테스트 (0)
      • Tech (14)
        • TDD (1)
        • 정리 (5)
        • 우테코 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    레디스
    MongoDB
    운영체제
    깃허브
    java
    스프링
    파인튜닝
    coderabbit
    redis
    Network
    docker
    AI
    TDD
    OS
    Spring
    git
    CoolSMS
    springboot
    jira
    깃
    스프링부트
    STT
    promtail
    도커
    자바
    지라
    코드레빗
    github
    네트워크
    몽고디비
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
erika0915
[Spring] 즉시 로딩과 지연 로딩

티스토리툴바