티스토리 뷰
본문에 앞서 예시를 하나 들겠다. 다음과 같이 멤버 엔티티와 팀 엔티티가 N:1 연관관계를 맺고 있다고 해 보자.
이를 위한 엔티티 클래스는 아래와 같이 간단하게 작성했다.
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
}
@Entity
@Getter @Setter
public class Team {
@Id @GeneratedValue
@Column(name = "team_id")
private Long id;
private String name;
}
만약, 아래와 같은 2가지 요구사항이 있다고 가정해 보겠다.
- 조회한 멤버가 어떤 팀에 속해있는지 알고 싶은 경우
- 멤버의 데이터만 필요한 경우
1번의 경우 멤버를 조회할 때 팀 엔티티를 같이 조회하는 것이 비즈니스 로직 상으로도 맞고,데이터베이스 접근 횟수를 줄일 수 있기 때문에 성능상 이점도 있다.
반면, 2번의 경우에는 멤버 데이터만 조회하면 되기 때문에 굳이 팀 데이터까지 끌고 오면 오버헤드가 발생한다.
이렇게 하나의 엔티티를 조회한다고 했을 때, 요구사항마다 연관된 엔티티들을 다 같이 조회할 것인지, 해당 엔티티만 조회할 것인지를 고민해 봐야 한다.
JPA는 개발자가 연관된 엔티티의 조회 시점을 선택할 수 있도록 다음 두 가직 작업을 제공한다.
즉시(Eager) 로딩 vs 지연(Lazy) 로딩
연관관계를 위한 애노테이션에는 fetch
속성이 있다.
fetch = FetchType.EAGER
: 즉시 로딩, 엔티티를 조회할 때 연관된 엔티티도 함께 조회한다.fetch = FetchType.LAZY
: 지연 로딩, 연관된 엔티티를 실제 사용할 때 조회한다.
즉시 로딩(Eager Loading)
즉시 로딩은 엔티티 조회 시, 즉시 로딩으로 설정된 연관관계 엔티티를 한 번에 같이 조회하는 방식이다.
설정 방법은 연관관계를 지정해 주는 애노테이션의 fetch
속성 값을 FetchType.EAGER
로 지정하면 된다.
@ManyToOne(fetch = FetchType.EAGER)
💡참고로 @ManyToOne 은 fetch 의 디폴트 값이 FetchType.EAGER 다. 이 외에도 @OneToOne 역시 즉시 로딩이 디폴트 값이고, 나머지 @OneToMany, @ManyToMany 는 디폴트 값이 FetchType.LAZY 다.
이렇게 연관관계가 있는 엔티티를 즉시 로딩으로 설정하면, 엔티티 조회 시 조인 쿼리를 사용해 한 번에 다 같이 조회한다.
Member findMember = em.find(Member.class, 1L);
// 실행 결과
Hibernate:
select
m1_0.member_id,
m1_0.name,
t1_0.team_id,
t1_0.name
from
member m1_0
left join
team t1_0
on t1_0.team_id=m1_0.team_id
where
m1_0.member_id=?
위와 같이 멤버 엔티티를 조회했지만, 연관관계를 맺고 있는 팀 엔티티가 즉시 로딩으로 설정되어 있기 때문에 조인을 사용해 팀 엔티티까지 한 번에 조회한다.
💡위 SQL을 자세히 보면 left join을 사용했다. 내부 조인이 아닌 외부 조인을 사용한 이유는, 외래키인 team_id 값이 NULL 을 허용하고 있기 때문이다. 따라서 팀에 소속되지 않은 멤버가 있을 가능성이 있기 때문에 JPA가 외부 조인을 사용한 것이다.만약 성능 최적화를 위해 내부 조인을 사용하고 싶으면, 외래 키에 NOT NULL 제약 조건을 추가하면 된다. 이를 추가하는 방법은 외래 키 지정 애노테이션인 @JoinColumn 의 속성 중, nullable 값을 false 로 설정해 주면 된다.
@JoinColumn(name = "team_id", nullable = false)
즉시 로딩은 처음에 가정했던 요구사항 중 1번과 같이 멤버를 조회하는데 팀의 데이터까지 필요할 때 유용하게 사용될 수 있다. 다만 다음과 같은 상황에서는 주의해야 한다.
컬렉션을 하나 이상 즉시 로딩해야 할 때
컬렉션을 조인한다는 것은 데이터베이스 테이블로 보면 1:N 조인이다. 이는 1쪽에서 데이터를 1개만 조회해도 다 쪽에 있는 수 만큼 결과 데이터가 조회된다는 의미다. 예를 들어, 팀 테이블에서 멤버 테이블을 조인해서 조회한다고 했을 때, 팀을 1개만 조회해도 그 팀과 연관된 멤버 데이터들을 전부 조회해야 된다. 이런 상황에서 만약 연관관계를 가지는 테이블이 2개 이상이 된다면, N*M개의 데이터를 조회해야 할 수도 있다.
즉시 로딩에서의 N + 1 문제
N + 1 문제란 1번의 쿼리를 날렸는데 의도치 않은 N번의 쿼리가 추가로 발생하는 문제다. 보통 연관관계가 매핑된 엔티티를 조회할 때 발생하기 쉽다. 예를 들어 다음과 같이 테이블에 데이터가 세팅되어 있다고 해 보자.
이때, 멤버 테이블의 모든 데이터를 조회하려면 어떻게 해야 할까? 가장 많이 사용되는 방법은 일반적인 SQL이 아닌 JPQL(Java Persistence Query Language)을 사용하는 것이다. 그럼 결과가 조인 쿼리를 하나 날려서 조회하면 될 것 같지만 실상은 그렇게 간단하지 않다. 직접 확인해 보자.
List<Member> findMembers = em.createQuery("select m from Member m", Member.class).getResultList();
//실행 결과
Hibernate:
/* select
m
from
Member m */ select
m1_0.member_id,
m1_0.name,
m1_0.team_id
from
member m1_0
Hibernate:
select
t1_0.team_id,
t1_0.name
from
team t1_0
where
t1_0.team_id=?
Hibernate:
select
t1_0.team_id,
t1_0.name
from
team t1_0
where
t1_0.team_id=?
조인을 사용하지 않고 위와 같이 총 3개의 쿼리가 발생한다. 이는 JPQ가 JPQL을 네이티브 SQL로 거의 그대로 번역하기 때문이다. 우리가 작성한 JPQL은 조인 쿼리가 없는, 단순히 멤버 엔티티만 조회하는 쿼리다. 따라서 번역된 SQL을 보면 멤버 테이블만 조회하고 있다. 이 다음에 JPA가 멤버 엔티티에 있는 팀 필드를 채우기 위해서 팀 테이블을 다시 조회하는 것이다. 문제는 여기서 발생한다.
- 멤버 전체 조회 (1번)
id=1
멤버의 팀 필드를 채우기 위해 팀 테이블에서team_id=1
인 데이터 조회 (2번)id=2
멤버의 팀 필드는 영속성 컨텍스트의 1차 캐시에서 조회id=3
멤버의 팀 필드를 채우기 위해 팀 테이블에서team_id=2
인 데이터 조회 (3번)
위와 같이 1번의 쿼리를 날리고자 했는데, 2개의 예상치 못한 쿼리가 발생하게 된 것이다. 이는 추후 실 서비스 상황에서 치명적인 성능 저하를 불러온다.
지연 로딩(Lazy Loading)
반면에, 지연 로딩은 연관관계가 있는 엔티티를 프록시 객체로 채워 놓고, 해당 엔티티의 값이 사용될 때 그제서야 쿼리를 다시 날려 실제 엔티티를 채워 넣는 방식이다.
프록시 객체 : 실제 엔티티 클래스를 상속받아 만들어 진 껍데기 객체. 초기화 전에는 아무 데이터가 없는 빈 껍데기다. 하지만 값이 필요해지면 DB로 쿼리를 날려 엔티티 조회한 후, 조회한 엔티티를 target 필드가 가리키도록 초기화 한다. 이후 target 필드로 엔티티를 접근해 값을 조회한다.
지연 로딩 설정 방법은 fetch
속성 값을 FetchType.LAZY
로 지정하면 된다.
@ManyToOne(fetch = FetchType.LAZY)
지연 로딩으로 설정하면 즉시 로딩에서 발생했던 N + 1 문제를 해결할 수 있다. 이제 다시 멤버 테이블을 전체 조회해 보자.
List<Member> findMembers = em.createQuery("select m from Member m", Member.class).getResultList();
//실행 결과
Hibernate:
/* select
m
from
Member m */ select
m1_0.member_id,
m1_0.name,
m1_0.team_id
from
member m1_0
단순히 멤버 테이블을 전체 조회하는 쿼리가 하나만 날라간다. 다만, JPA 입장에서는 팀 필드를 null
로 넣어둘 수 없기 때문에 프록시 객체를 생성해서 넣어둔다.
이렇게 지연 로딩을 사용하면 즉시 로딩에서 발생하는 N+1 문제를 효과적으로 해결할 수 있다. 즉시 로딩에서의 주의사항과 지연 로딩의 장점을 종합해 보면, 일반적으로 모든 연관관계에서 지연 로딩을 사용하는 것이 유리하면서도 유지보수에서 훨씬 편리하다. 그 다음, 필요한 곳에서만 즉시 로딩을 사용하도록 최적화하면 된다.
지연 로딩에서의 N + 1 문제
지연 로딩에서도 N + 1 문제는 발생한다. 조회된 멤버 리스트를 순회하면서 팀 엔티티에 실제 어떤 클래스가 들어가 있는지, 그리고 팀의 name
필드 값을 확인해 보자.
for (Member member : findMembers) {
System.out.println("member.name = " + member.getName());
System.out.println("member.getTeam().getClass() = " + member.getTeam().getClass());
System.out.println("member.team.name = " + member.getTeam().getName());
}
//실행 결과
member.name = 회원1
member.getTeam().getClass() = class hello.jpa.Team$HibernateProxy$d2y37C52
Hibernate:
select
t1_0.team_id,
t1_0.name
from
team t1_0
where
t1_0.team_id=?
member.team.name = teamA
member.name = 회원2
member.getTeam().getClass() = class hello.jpa.Team$HibernateProxy$d2y37C52
member.team.name = teamA
member.name = 회원3
member.getTeam().getClass() = class hello.jpa.Team$HibernateProxy$d2y37C52
Hibernate:
select
t1_0.team_id,
t1_0.name
from
team t1_0
where
t1_0.team_id=?
member.team.name = teamB
실행 결과를 보면 team
필드에는 하이버네이트가 생성한 프록시 객체가 들어가 있는 것을 알 수 있다. member.getTeam().getName()
로 값을 조회하려고 하면, 그제서야 DB로 쿼리를 날려 프록시 객체를 초기화한 뒤, 실제 팀 엔티티를 조회한다. 이 때마다 DB로 쿼리를 날려야하기 때문에 N + 1 문제가 발생하게 된다.
이쯤 되면 그냥 SQL로 조인 쿼리를 날리고 싶다는 생각이 들 수 있다. JPQL을 사용하면서도 조인 쿼리를 날릴 수 있는 효율적인 방법은 페치 조인(Fetch join)을 사용하는 것이다.
💡프록시 객체 클래스 명을 자세히 보면, $d2y37C52 로 프록시가 toString()으로 생성하는 이름은 같아 보이지만 사실은 다른 객체다. 실제로 == 비교를 해 보면 false 가 나온다.
페치 조인(Fetch join)
페치 조인은 연관된 엔티티나 컬렉션을 SQL 조인문으로 쿼리 한 번에 함께 조회하는 기능이다. 실무에서 성능 튜닝으로 유용하게 쓰이기 때문에 매우 중요하다. 또한 대부분의 N + 1 문제를 손쉽게 해결할 수 있다.
위 예제에서 페치 조인을 사용해 보자.
List<Member> findMembers = em.createQuery("select m from Member m join fetch m.team", Member.class)
.getResultList();
for (Member member : findMembers) {
System.out.println("member.name = " + member.getName());
System.out.println("member.team.name = " + member.getTeam().getName());
}
//실행 결과
Hibernate:
/* select
m
from
Member m
join
fetch
m.team */ select
m1_0.member_id,
m1_0.name,
m1_0.team_id,
t1_0.team_id,
t1_0.name
from
member m1_0
join
team t1_0
on t1_0.team_id=m1_0.team_id
member.name = 회원1
member.team.name = teamA
member.name = 회원2
member.team.name = teamA
member.name = 회원3
member.team.name = teamB
실행 결과를 보면, 쿼리가 총 1번만 발생했다. 생성된 SQL을 확인해 보면, 멤버 테이블과 팀 테이블을 내부 조인(inner join)하는 쿼리가 생성됐다.
페치 조인을 사용하면 연관된 엔티티를 한 번에 조회하는, 마치 즉시 로딩처럼 동작하되, 조인을 사용해서 한 번의 쿼리로 조회할 수 있다. 때문에 지연 로딩에서의 N + 1 문제를 효율적으로 해결할 수 있다.
💡페치 조인의 결과가 외부 조인이 아닌 내부 조인인 이유는, 먼저 연관된 엔티티가 없는 불필요한 데이터 조회를 방지할 수 있다는 이점이 있다. 하지만 이보다 더 중요한 이유는, 페치 조인을 사용하는 의도에 있다.앞서 즉시 로딩은 디폴트가 외부 조인이었다. 이는 멤버 객체를 조회할 때, 혹시라도 팀에 소속되지 않은 멤버를 놓치지 않고 조회하기 위함이다.
반면에 페치 조인을 한다는 의미는, 해당 시점에 멤버와 팀의 데이터가 모두 필요하기 때문이다. 즉, 팀에 소속된 멤버가 필요하다는 것이다. 혹시라도 팀에 소속되지 않은 멤버 객체에서 팀을 조회해 NullPointException 이 발생하는 것도 방지할 수 있다. 결과적으로 일관된 데이터를 반환하게 된다.
페치 조인과 일반 조인의 차이
그럼 일반 조인과는 어떤 점이 다를까? 일반 조인은 연관된 엔티티를 “함께” 조회하지 않기 때문에 N+1 문제가 해결되지 않는다. 일반 조인을 사용한 결과를 확인해 보자.
List<Member> findMembers = em.createQuery("select m from Member m join m.team", Member.class).getResultList();
// 실행 결과
Hibernate:
/* select
m
from
Member m
inner join
m.team t */ select
m1_0.member_id,
m1_0.name,
m1_0.team_id
from
member m1_0
join
team t1_0
on t1_0.team_id=m1_0.team_id
당연하게도 조회 대상이 멤버뿐이기 때문에 팀 테이블을 조인했더라도 멤버 데이터만 가져오게 된다. 사실 위 쿼리는 조인이 없어도 똑같은 결과를 도출하게 되는 것이다.
참고로 위와 같은 쿼리를 날렸을 때도, 페치 전략에 따라 결과가 달라진다.
- 즉시 로딩 : 총 3번의 쿼리를 날려 팀 필드에 실제 팀 엔티티로 초기화
- 지연 로딩 : 팀 필드를 프록시 객체로 초기화
그럼 팀 객체도 조회 대상에 추가하면 되지 않을까??
List results = em.createQuery("select m, t from Member m join m.team t").getResultList();
// 실행 결과
Hibernate:
/* select
m,
t
from
Member m
join
m.team t */ select
m1_0.member_id,
m1_0.name,
m1_0.team_id,
t1_0.team_id,
t1_0.name
from
member m1_0
join
team t1_0
on t1_0.team_id=m1_0.team_id
위와 같은 방식으로 조회하면, 멤버 타입으로 반환받는 것이 불가능하다. 생성된 SQL을 보면 알 수 있지만, 멤버 테이블에 있는 필드와 팀 테이블에 있는 필드를 모두 합쳐서 조회하게 되는데, 이를 JPA가 멤버 객체에 이쁘게 담아줄 수는 없다. 때문에 객체 그래프 탐색도 불가능해진다. 그나마 활용할 수 있게 하려면 사용하고 싶은 필드를 따로 모아 만든 DTO 클래스 타입으로 받아야 한다. 하지만 원하는 결과는 결코 아니다.
이처럼 페치 조인은 객체 그래프 탐색이 가능하도록, 객체지향 적으로 결과를 반환해 준다.
페치 조인은 객체 그래프를 SQL 한 번에 조회하는 개념
결론은, 앞서 언급했듯이 글로벌 설정은 지연 로딩으로 하고, 선택적으로 페치 조인을 사용함으로써 즉시 로딩의 이점을 가져가도록 하는 것을 추천한다.
References
- 자바 ORM 표준 JPA 프로그래밍 - 김영한
- https://www.inflearn.com/course/ORM-JPA-Basic/dashboard
'JPA' 카테고리의 다른 글
[JPA] 영속성 컨텍스트(persistence context) 개요 및 특징(feat. Dirty Checking, flush()) (1) | 2025.02.07 |
---|---|
[JPA] QueryDSL 간단 (with Gradle, VSCode) (0) | 2023.05.08 |
[JPA] EntityManagerFactory & EntityManager (0) | 2023.05.07 |
- Total
- Today
- Yesterday
- git merge
- 지옥에서 온 git
- 운영체제 반효경
- JPA
- 파이썬 for Beginner 연습문제
- 프로그래머스
- git
- 쉽게 배우는 운영체제
- 생활코딩 javascript
- Do it! 정직하게 코딩하며 배우는 딥러닝 입문
- 스프링 테스트
- 스프링 컨테이너
- jsp
- 패킷 스위칭
- 쉘 코드
- Python Cookbook
- spring mvc
- Thymeleaf
- Computer_Networking_A_Top-Down_Approach
- 선형 회귀
- 방명록 프로젝트
- 파이썬 for Beginner 솔루션
- 김영환
- 스프링 mvc
- 스프링
- Spring Boot
- Spring Data JPA
- Gradle
- Spring
- git branch
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 |