이번 포스팅은 개인적으로 JPA/Querydsl을 공부하다 의문점이 들어 작성하게 되었다.
[서론] team_id만 있는데, 어떻게 Team 객체가 들어오는 걸까?
나만 그런건지 많은 사람들이 나와 같은 생각을 했는지는 잘 모르겠으나, JPA를 잘 모르는 나는 오늘 이상한 점이 눈에 띄었다.
그동안 그냥 그러려니 하고 생각했던 JPA 연관관계 객체의 주입에 대해서였다.
아래는 Member 클래스와 Team 클래스가 작성되어 있다. (N:1 관계)
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String username;
private int age;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
public Member(String username) {
this(username, 0);
}
public Member(String username, int age) {
this(username, age, null);
}
public Member(String username, int age , Team team) {
this.username = username;
this.age = age;
if (team != null) {
changeTeam(team);
}
}
}
@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "name"})
public class Team {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "team_id")
private Long id;
private String name;
@OneToMany(mappedBy = "team")
List<Member> members = new ArrayList<>();
public Team(String name) {
this.name = name;
}
}
우리는 보통 Member 엔티티의 필드로 private Team team 처럼 객체를 사용하지만 @JoinColumn(name="team_id") 으로 실제로 DB에는 team_id라는 외래키(Long)만 존재한다.
그리고 실제 테스트 코드에서 Member 객체를 리스트로 조회하는 코드를 실행해서 실제 실행되는 쿼리를 확인해 보면 아래와 같다.
@Test
public void test() {
QMember member = QMember.member;
QTeam team = QTeam.team;
List<Member> result = queryFactory
.selectFrom(member)
.where(member.team.name.eq("teamA"))
.fetch();
}
나는 이론상? 생각으로는? 당연히 select 절에 조회되는 데이터가 Member 뿐만 아니라 Team 도 같이 조회되어 바인딩이 되는 줄 알았다.
그래야지 Member 객체에 데이터가 담기고, Team 도 조회해서 Member 객체 안에 Team 객체안으로 값을 넣어줘야 내가 생각하는 그림이 나올 거 같았다.
하지만 쿼리를 보면 알겠지만 team 을 join 할 뿐 team 자체를 조회하지 않는다.
(물론, team_id 는 조회하지만 이건 Long 타입 숫자일 뿐 team의 데이터 전체는 아니다.)
그럼 도대체 member.getTeam.getName() 은 어떻게 가능한걸까?
Team 데이터를 조회하지 않았는데, 아래의 결과처럼 어떻게 Member 객체 안에 가지고 있을 수 있는 것일까?
이번 글은 이 내용에 대해 파헤쳐보려고 한다.
JPA 연관관계 매핑 구조
JPA에서는 객체와 객체 사이의 관계(연관관계)를 데이터베이스의 외래키(Foreign Key)로 자동 매핑해 준다.
우리는 team_id 같은 외래키를 직접 다루지 않고도, member.getTeam(). getName()처럼 객체지향적인 방식으로 연관된 데이터를 다룰 수 있다.
다시 Member 클래스와 Team 클래스의 필드만 가져와서 연관관계를 확인해 보자.
Member - Team 관계 (ManyToOne)
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String username;
private int age;
@ManyToOne(fetch = FetchType.LAZY) // 다대일 관계 (Member → Team)
@JoinColumn(name = "team_id") // member 테이블에 team_id 컬럼 생성
private Team team;
}
- @ManyToOne : 이 회원이 하나의 팀에 속해 있다는 뜻 (N:1, 외래키 보유자)
- fetch = FetchType.LAZY : member.getTeam()을 호출할 때까지 Team 정보를 DB에서 조회하지 않는다. (프락시로 대기)
- @JoinColumn(name = "team_id") : 실제 DB에는 member 테이블에 team_id라는 컬럼이 생긴다.
Team - Member (OneToMany)
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team") // Member.team 필드를 기준으로 매핑됨
private List<Member> members = new ArrayList<>();
}
- mappedBy = "team" : Member 엔티티의 team 필드가 외래키를 관리하고 있다는 뜻 (반대쪽에서 외래키를 관리함을 나타낸다, 읽기 전용)
- 양방향 연관관계에서는 실제 외래키는 Member 쪽이 관리한다.
간단한 실제 코드를 예로 들어보자면 아래와 같다.
Member member = new Member("member1", 10, teamA);
em.persist(member);
// 나중에 team.name 접근
System.out.println(member.getTeam().getName()); // teamA
이때, getTeam() 은 프록시로 먼저 들어와 있다가, getName() 호출 순간 DB에 가서 Team 데이터를 가져온다.
아하! 그렇다면 위에서 보았던 실제 실행 쿼리에서는 team을 SELECT 하지 않았던 게 맞았던 거구나 싶다.
왜냐면 getTeam(). getName()을 호출할 시점에 DB에 가서 데이터를 가져오니까 그 시점에 team 데이터를 가져오는 쿼리가 실행이 되고 Member 객체 내부의 Team 객체에 데이터를 집어넣는구나라는 생각을 하였다.
아래 테스트 코드를 보자.
조회 쿼리 테스트를 진행하기 전에 em.clear()를 통해 영속성 컨텍스트를 비워주었다.
그리고 우리가 예상했던 결과를 아래에서 확인해 보자.
member1의 정보를 출력할 때는 추가 쿼리실행 없이 출력되는 것을 볼 수 있다.
이후 member1.getTeam(). getName()을 호출할 때 추가로 쿼리가 한방 더 나가는 것을 확인할 수 있다.
현재 영속성 컨텍스트에는 team 이 존재하지 않고 있기 때문에, 처음 Member를 조회할 때는 Member 내부의 Team 객체에는 프록시 객체가 들어가게 된다. 이후 getName()을 호출하면 그 프록시 객체가 DB를 조회하여 데이터를 가져오게 되는 흐름인 것이다.
근데 만약에 영속성 컨텍스트에 team이 존재한다면???
Member를 최초 조회할 때 Team 객체에 프록시 객체를 넣으려고 하였지만, 영속성에 관리되는 Team 객체가 존재하기에 진짜 Team 객체를 그대로 넣어버리게 된다. 때문에 처음부터 Team 객체에 값이 존재하게 된 것.
그래서 이후 getName()을 호출하더라도 Team 객체를 DB에서 조회하려고 쿼리를 실행하지 않게 되는 것이다.
(이미 진짜 객체가 들어와 있으니까!!_+!!)
아래와 같이 테스트 전에 Team 객체와 Member 객체를 저장한다. (영속성 컨텍스트 관리)
이번에는 영속성 컨텍스트를 비우지 않고 테스트를 진행한다. (em.clear() 실행 X)
TeamA는 영속성 컨텍스트에 관리되고 있는 Team 객체였기 때문에 getName()을 진행하더라도 추가 조회 쿼리가 실행되지 않고 출력되는 것을 확인할 수 있다.!!!
Querydsl에서의 프록시 객체와 진짜 객체 (feat. N+1 문제)
현재 내 테스트 코드는 아래와 같다.
@Test
public void test() {
QMember member = QMember.member;
QTeam team = QTeam.team;
em.clear();
List<Member> result = queryFactory
.selectFrom(member)
.where(member.team.name.eq("teamA"))
.fetch();
System.out.println("========= TEST ===========");
for (Member member1 : result) {
System.out.println("member1: " + member1);
System.out.println("member1.team.name: " + member1.getTeam().getName());
}
}
위에서 설명했듯이 이 코드를 정리하면 아래와 같다.
1. 영속성 컨텍스트를 비웠다.
2. where(member.team.name.eq("teamA")) 를 통해 실제 실행 쿼리를 확인하면 join 이 진행된다.
3. member 객체를 조회할 때에 Team 객체는 프록시 객체로 주입이 된다.
4. 이후 getName()을 호출하는 시점에 DB에서 Team 데이터를 조회하는 쿼리를 실행한다.
그리고 실제 Team 객체는 언제 주입되는지 찾아보니
- Lazy Loading 일 때
- Member만 조회하면 team 은 프록시 객체
- member.getTeam(). getName() 호출 시 DB에서 조회 -> N+1 문제 발생 가능
- join 조건이 들어간 경우 (Querydsl 혹은 JPQL에서)
- member.team.name 조건 -> 무조건 join 발생
- 이미 name을 select 했기 때문에 프록시가 아닌 실제 Team 객체로 초기화
찾아보니 내가 작성한 코드는 join 이 실제로 이루어지는 코드였다.
명시적으로 join()을 해준 것은 아니였지만, where(member.team.name.eq("teamA") 구문이 암묵적(묵시적)으로 조인을 진행한다.
그래서 나는 join 조건이 들어갔기 때문에 프록시가 아닌 실제 Team 객체로 초기화를 할 줄 알았고, 그러면 이후에 getName()을 호출할 때에도 추가 쿼리가 안 가는 거 아닌가?라는 생각을 했지만 잘못된 생각이었다.
문제는 바로 암묵적 조인이었다.
join 이 되긴 했지만 실제 쿼리를 보면 결국에는 Member 만 조회하고 있다.
Member 만 조회한다는 것은 Team 은 Select 절에 포함되지 않았기에 Team을 초기화하지 않는다.
이런 코드는 N+1을 야기한다.
지금이야 teamA를 추가로 호출하니까 추가 쿼리가 1번만 실행이 되지만, 만약 100개의 team을 추가로 호출해야 한다면?
쿼리가 101번이나 실행되게 되는 문제가 발생한다.
그럼 이 문제는 어떻게 해결해야 할까?
정답은 바로 fetchJoin()이다.
명시적으로 join을 선언해 주고 fetchJoin()을 작성해 주었다.
아래 코드는 실제 실행되는 쿼리이다.
fetchJoin을 사용하지 않았을 때와는 다르게 SELECT 절에 team_id 뿐만 아니라 team의 name도 조회하고 있다.
이는 Team 객체를 초기화할 때 프록시 객체가 아닌 실제 객체로 초기화한다는 것을 의미한다.
영속성 컨텍스트에 객체가 관리되고 있지 않음에도 불구하고 말이다.
실제 객체로 관리하고 있기에 아래와 같이 getName()을 호출해도 추가 쿼리가 나가지 않게 되었다.
글을 마무리하며..
정말 아무것도 모르고 "아 그렇구나 그냥 잘 작동하니까 그러려니 해야지.."라는 식의 사고방식을 정말 싫어한다.
평일에는 회사에 어쩔 수 없이 기한을 맞추어 개발해야 했기에 작업했지만, 스스로 스트레스를 많이 받았다.
그렇기에 주말에 집에서 혼자 찾아서 공부하고 정리하면서 이해를 하려고 노력하였다.
이젠 개운하고 스스로 한 발짝 성장한 것 같아 뿌듯한 마음이다.