Post

[SPRING] 스프링 지연로딩과 즉시로딩, EAGER and LAZY

지연로딩과 즉시로딩

지연로딩과 즉시로딩은 각각의 장단점이 있지만 실무에서는 오직 지연 로딩 만 사용한다. 그 이유에 대해 자세히 작성하는 글이다.

LAZY

LAZY 는 지연로딩이다. 한글뜻으로 게으름 인데 말 그대로 게을러서 필요할 때까지 로딩을 하지 않는다. 참고로 @ManyToOne@OneToOne 처럼 마지막에 One 으로 끝나는 어노테이션은 기본값이 EAGER 이다. 만약 다른 로딩으로 사용하고 싶다면 @ManyToOne(fetch = FetchType.LAZY) 로 어노테이션 옆에 설정해줄 값을 적어주면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Entity
@Getter @Setter
public class Member {

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

    public void changeTeam(Team team) {
        this.team = team;
        this.team.getMembers().add(this);
    }
}

@Entity
@Getter @Setter
public class Team {

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

Member 와 Team 의 관계

  • N : 1

현재 여러명의 멤버는 하나의 팀에 속할 수 있도록 Member 엔티티와 Team 엔티티는 N : 1 관계를 가지고 있다. 여기서 Member 를 조회해 보겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Team team = new Team();
team.setName("teamA");
em.persist(team);

Member member = new Member();
member.setUsername("memberA");
em.persist(member);

member.changeTeam(team);

em.flush();
em.clear();

// 1번 시점
Member findMember = em.find(Member.class, member.getId());
System.out.println(findMember.getTeam().getClass());

// 2번 시점
System.out.println("TEAM NAME : " + findMember.getTeam().getName());

여기서 MemberTeam 을 영속성 컨텍스트로 저장하고 em.find() 를 사용해 Member 를 찾는다. 코드에서 주석으로 처리한 1번시점에서 먼저 Member에 대한 select 쿼리를 날린다. 하지만 MemberTeam 이라는 객체를 가지고있는데 1번 시점에서는 Team필요로 하지않기 때문에 DB 에도 Team 에 대한 쿼리를 날리지 않는다. 그리고 Team 에 대한 객체는 DB 에서 가져온 Team 으로 초기화되지 않는 대신에 하이버네이트 에서 프록시 기술(ByteBuddyInterceptor) 로 만든 객체를 가진다. 실제로 1번 시점에 클래스 타입을 조회해 보면 아래와 같은 클래스명이 나온다.

1
Team$HibernateProxy$z4JtUeLD

클래스명을 찍어보면 하이버네이트가 만든 프록시객체를 가진다. 아직 Team필요 가 없으니 DB 에 쿼리를 안날려서 프록시 객체를 가지는 것이다. 하지만 가지고 있을뿐 초기화 된 것은 아니다. 초기화 되지 않았다는 뜻은 해당 프록시 객체가 참조하는 실제 객체의 값이 비어있다는 뜻이다. 그저 영속성 컨텍스트가 Team 의 PK 를 기준으로 프록시 객체를 보관만 하고 있다.

프록시객체를 가지는 이유는 영속성 컨텍스트로 가지고 있어야 나중에 Member 에서 Team 객체의 필드를 사용할때 DB 에 쿼리를 보낼 수 있기 때문이다. 만약 DB 에서 Member 테이블이 Team 테이블의 PK 를 참조하고 있는게 아니라면 프록시객체가 아닌 null 을 가진다.

2번 시점에서는 findMember.getTeam().getName() 을 실행되는 순간 멤버의 팀이름을 알아되기 때문에 Team 객체가 필요 해진다. 아무리 게을러도 필요해졌으면 이제 DB 에서 가져와야 된다. 그래서 이 때 Team 에 대한 select 쿼리를 날린다. 그러면 DB 에서 값을 가져와 프록시가 참조하는 실제 객체에 값을 넣어준다. 실제 값을 채운다고해서 프록시객체가 실제 객체로 바뀌는것은 아니다. 실제로 2번시점에서 클래스타입을 조회해도 1번시점과 동일한 프록시객체타입이 나온다.

참고로 findMember.getTeam().getId() 는 쿼리를 날리지 않는다. id 는 PK 인데 프록시 객체는 이미 PK 를 기준으로 영속성 컨텍스트에 저장되는것이기에 PK 는 프록시객체가 이미 알고있다.

결과적으로 총 2번의 쿼리를 날린다.

  • 맨 처음 Member 를 조회하기 위해 1번
  • 팀이름이 필요해져 Team 을 조회하기 위해 1번

EAGER

EAGER 는 즉시로딩이라고 하는데, 한글뜻 으로도 ‘열렬한’ 이라는 뜻으로 일을 받으면 빨리빨리 처리할려는 성격을 가졌다. 아까 LAZY 는 필요해지는 그 순간까지 초기화를 하지 않지만, EAGER 는 반대로 즉시 로딩을 해버린다. Member 의 페치타입을 @ManyToOne(fetch = FetchType.EAGER) 로 변경하고 실행해보자. 사실 위에서도 말했지만 @XXXToOne 은 기본값이 EAGER 여서 안적어도 상관없지만 확실히 보여주기 위해 명시했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Entity
@Getter @Setter
public class Member {

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

    public void changeTeam(Team team) {
        this.team = team;
        this.team.getMembers().add(this);
    }
}

EAGER 의 경우 1번 시점에서 Member 를 select 할려다보니 Team 도 필요하네? 가 되서 MemberTeam 을 join 하는 쿼리를 날린다. 1번 시점에서 이미 MemberTeam 을 초기화 하니 당연히 프록시객체 또한 만들지 않는다. 그러니 1번시점이나 2번시점이나 Team 클래스타입은 동일하게 Team 클래스이다. 그리고 Team 을 알아내기위해 DB 에 쿼리를 추가로 날릴 필요도 없다.

결과적으로 총 1번의 쿼리를 날린다.

  • 맨 처음 Member 를 조회할 때 Team 까지 join 해서 같이 조회하는 한방 쿼리를 날린다.

EAGER VS LAZY

LAZY는 2번의 쿼리에 복잡한 프록시 기술까지 사용하며 클래스를 초기화하고, EAGER는 1번의 쿼리로 끝났는데 EAGER 가 더 좋은게 아닌가?? 라는 의문을 품을 수 있다. 하지만 잘 생각해보면 EAGER 의 단점이 있다. 나는 Member 만 조회하고 싶은데 EAGER 로 타입이 등록되어있으면 무조건 Team 도 같이 조회해 버린다는 것이다. 불필요한 정보까지 가져와버리니 성능이 낮아진다. 물론 반대로 MemberTeam 은 항상 같이 조회 되는거라면 LAZY 는 항상 2번의 쿼리를 날리니 LAZY 의 성능이 더 안좋을 수 도 있다.

결과적으로 상황에 따라 LAZY 와 EAGER 의 성능차이가 날 수 있다. 하지만 이것만 보면 글 시작에 말했던 실무에서는 무조건 LAZY 만 쓴다 가 이해가 안된다.

EAGER 의 두 번째 단점이 존재하기 때문인데, 바로 N + 1 문제이다.

N + 1

N + 1 문제란것은 쿼리 하나를 날렸더니, N 개의 쿼리가 추가로 날라가는것을 뜻한다. 새로운 예제를 보여주겠다. 이번에는 멤버가 2명, 팀도 2개 이고 각각 다른 팀이다. 그리고 모든 멤버를 조회하는 JPQL 을 날렸다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Team team1 = new Team();
team1.setName("teamA");
em.persist(team1);

Team team2 = new Team();
team2.setName("teamB");
em.persist(team2);

Member member1 = new Member();
member1.setUsername("memberA");
em.persist(member1);
member1.changeTeam(team1);

Member member2 = new Member();
member2.setUsername("memberB");
em.persist(member2);
member2.changeTeam(team2);

em.flush();
em.clear();

List<Member> members = em
              .createQuery("select m from Member m", Member.class)
.getResultList();

실행을 해보면 맨 처음에 Member 를 select 하는 쿼리가 1개 나간다. 그런데 Member 를 조회하고 나니까 Team 도 필요하네? 라고 생각하고 MemberAMemberB 에서 무슨 Team 인지 찾는 쿼리를 각각 하나씩 날린다.

결과적으로 Member 조회쿼리 한개 + 무슨 Team 인지 찾는 쿼리 두개로 총 3개의 쿼리를 날렸다. 만약 멤버가 10000 명이라면 10001 개의 쿼리를 날리게 된다. 쿼리를 하나날렸는데 n 개의 쿼리가 추가로 날라간다고 해서 N + 1 문제라고 한다.

그런데 여기서 문득 드는 의문점이 있다. 아까는 EAGER 여서 MemberTeam 을 join 해서 한방쿼리를 날리지 않았는가? 근데 왜 지금은 join 해서 한방쿼리를 안날리고 Member 쿼리 날리고, Team 쿼리도 날리는가?

바로 조회방식의 차이가 있다. 맨처음 EAGER 를 사용할때는 em.find() 함수를 이용해 찾았다. em.find() 함수에서 PK 를 이용해 DB 에서 가져오는 경우 JPA 내부에서 최적화가 가능하기때문에 MemberTeam 을 join 해서 한방쿼리를 날리는것이다.

하지만 JPQL 를 사용하면 JPQL 자체가 SQL 로 변환해서 쿼리를 그대로 날리기때문에 select m from Member m 의 경우 쿼리 그대로 Member 만 select 한다. 그러면 당연히 DB 에서는 Member 만 가져온다. 하지만 EAGER 특성상 Member 내부의 Team 필드도 초기화가 되어있어야 하므로 추가로 DB 에 Team 을 select 하는 쿼리를 날린다.

결과적으로 EAGER 에서 JPQL 쿼리를 날리면 N + 1 문제가 발생할 수 있다.

N + 1 해결

위에서 서술했듯이 EAGER 의 경우 JPQL 에서 무조건 N + 1 문제가 발생할 수 있다. 그런데 LAZY 라고 발생이 안하는가? 라고 물으면 아니다. MemberTeam 필드를 @ManyToOne(fetch = FetchType.LAZY) LAZY 로 설정하고 실행하면 어떻게 될까?

쿼리는 Member 를 조회하는 JPQL 을 날렸기때문에 Member 는 전부다 조회한다. 그리고 LAZY 이기 때문에 각 MemberAMemberBTeam 객체는 프록시객체를 가진다. 만약 여기서 각 MemberTeam 이름을 조회하는 기능을 추가해서 실행하면 각 Member 에서 팀이름 찾는 쿼리가 하나씩 DB에 날라간다. 이렇게되면 EAGER 든 LAZY 든 똑같이 1 + N 개의 쿼리를 날리게 된다.

차이

EARGR 와 LAZY 에는 과정의 차이가 있다.

  • EAGER 는 Member 를 조회한순간 Team 을 조회해 무조건 1 + N 개의 쿼리를 날린다.
  • LAZY 는 Member 를 조회만 한다. 그 다음에 Team 에 대한 정보가 필요해지면 쿼리를 날린다.

Team 에 있는 필드를 조회할때 EAGER 는 하나의 과정으로 처리가되지만 LAZY 는 Member 조회 -> Team 조회 라는 두 가지 과정으로 처리가 된다. 그래서 LAZY 로 설정해두고, 저 과정을 조금 조작해주면 1 + N 개의 쿼리를 1~3개의 쿼리로 줄일 수 있다. 하지만 EAGER 는 과정자체를 조작할 순간이 없기에 무조건 1 + N 개의 쿼리를 날리는 것이다.

해결 방법

LAZY 로 설정을 해뒀다면 엔티티그래프, 어노테이션, 배치사이즈, fetch join 을 이용해 해결이 가능하다. 대부분은 fetch join 을 사용해 해결한다. 자세한 내용은 다음시간에 포스팅 하겠다!

Reference

https://ict-nroo.tistory.com/130
https://developer-youngjun.tistory.com/21
https://ict-nroo.tistory.com/132
https://yeon-kr.tistory.com/190
https://velog.io/@imcool2551/JPA-%ED%94%84%EB%A1%9D%EC%8B%9C%EC%99%80-%EC%A7%80%EC%97%B0-%EB%A1%9C%EB%94%A9

This post is licensed under CC BY 4.0 by the author.